From 9d0e2949008aafb780d60b1a36f773de767376a1 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Wed, 2 Sep 2026 14:26:12 +0000 Subject: [PATCH 1/4] Agent state versioning: git init + committer + versions/diff/revert API - Initialise a git repo inside each agent container at deploy time with a .gitignore that excludes secrets and bulk artefacts, and commit identity set to the agent slug. - Ship a small debounced auto-committer script that runs as a background loop inside the container, committing dirty trees with a timestamp + changed-file-summary message. - Add controller API routes: GET /api/agents/{name}/versions, GET /api/agents/{name}/versions/{sha}/diff, POST /api/agents/{name}/versions/{sha}/revert. - Add changelog fragment and tests for committer, routes, and deployer steps. Docs-Reviewed: agent-coordination.md has no route table; new /api/agents/{name}/versions routes are self-documenting via the route file. --- .../tsk-fjmxzo-agent-state-versioning.md | 3 + tests/test_agent_committer.py | 83 ++++++++++++ tests/test_deployer.py | 50 ++++++++ tests/test_routes_agent_versions.py | 119 ++++++++++++++++++ tinyagentos/agent_git.py | 112 +++++++++++++++++ tinyagentos/deployer.py | 46 +++++++ tinyagentos/routes/__init__.py | 3 + tinyagentos/routes/agent_versions.py | 84 +++++++++++++ tinyagentos/scripts/agent_committer.py | 65 ++++++++++ 9 files changed, 565 insertions(+) create mode 100644 changelog.d/tsk-fjmxzo-agent-state-versioning.md create mode 100644 tests/test_agent_committer.py create mode 100644 tests/test_routes_agent_versions.py create mode 100644 tinyagentos/agent_git.py create mode 100644 tinyagentos/routes/agent_versions.py create mode 100644 tinyagentos/scripts/agent_committer.py diff --git a/changelog.d/tsk-fjmxzo-agent-state-versioning.md b/changelog.d/tsk-fjmxzo-agent-state-versioning.md new file mode 100644 index 000000000..4885fa39f --- /dev/null +++ b/changelog.d/tsk-fjmxzo-agent-state-versioning.md @@ -0,0 +1,3 @@ +### Added +- Agent state versioning: a git repo is initialised inside each agent container at deploy time, with a `.gitignore` that excludes secrets and bulk artefacts, and commit identity set to the agent's own slug. An auto-committer script runs as a background loop inside the container, committing dirty trees on a fixed interval with a timestamp + changed-file-summary message (#tsk-fjmxzo). +- Controller API for agent state history: `GET /api/agents/{name}/versions` lists commits, `GET /api/agents/{name}/versions/{sha}/diff` returns the patch for a commit, and `POST /api/agents/{name}/versions/{sha}/revert` reverts the state repo to a prior commit (#tsk-fjmxzo). diff --git a/tests/test_agent_committer.py b/tests/test_agent_committer.py new file mode 100644 index 000000000..eff65f37a --- /dev/null +++ b/tests/test_agent_committer.py @@ -0,0 +1,83 @@ +"""Tests for the agent state auto-committer script.""" +from __future__ import annotations + +import importlib.util +import os +import subprocess +import sys + +import pytest + + +_COMMITTER_PATH = ( + os.path.dirname(__file__).replace("tests", "tinyagentos") + "/scripts/agent_committer.py" +) + + +def _load_committer(repo_path: str, interval: int = 1): + spec = importlib.util.spec_from_file_location("agent_committer", _COMMITTER_PATH) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + mod.REPO_PATH = repo_path + mod.INTERVAL = interval + return mod + + +def _init_repo(tmp_path): + subprocess.run(["git", "init", "-b", "main"], cwd=tmp_path, check=True, capture_output=True) + subprocess.run(["git", "config", "user.email", "test@test"], cwd=tmp_path, check=True, capture_output=True) + subprocess.run(["git", "config", "user.name", "test"], cwd=tmp_path, check=True, capture_output=True) + + +class TestAgentCommitter: + def test_commit_creates_commit_for_new_file(self, tmp_path): + _init_repo(tmp_path) + (tmp_path / ".gitignore").write_text("*.secret\n.env\n*token*\n") + subprocess.run(["git", "add", ".gitignore"], cwd=tmp_path, check=True, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "initial"], cwd=tmp_path, check=True, capture_output=True + ) + + committer = _load_committer(str(tmp_path)) + (tmp_path / "hello.txt").write_text("hello") + committer._commit() + + rc, out, _ = committer._git("log", "--oneline") + assert rc == 0 + assert "auto:" in out + stat = committer._git("show", "--stat", "HEAD")[1] + assert "hello.txt" in stat + + def test_gitignored_secret_not_committed(self, tmp_path): + _init_repo(tmp_path) + (tmp_path / ".gitignore").write_text("*.secret\n.env\n*token*\n") + subprocess.run(["git", "add", ".gitignore"], cwd=tmp_path, check=True, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "initial"], cwd=tmp_path, check=True, capture_output=True + ) + + committer = _load_committer(str(tmp_path)) + (tmp_path / ".env").write_text("SECRET=abc") + (tmp_path / "token.rsa").write_text("key") + committer._commit() + + _, log_out, _ = committer._git("log", "--all", "--stat") + assert ".env" not in log_out + assert "token.rsa" not in log_out + + def test_no_commit_when_clean(self, tmp_path): + _init_repo(tmp_path) + (tmp_path / ".gitignore").write_text("") + subprocess.run(["git", "add", ".gitignore"], cwd=tmp_path, check=True, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "initial"], cwd=tmp_path, check=True, capture_output=True + ) + + committer = _load_committer(str(tmp_path)) + committer._commit() + + rc, out, _ = committer._git("log", "--oneline") + assert rc == 0 + lines = out.strip().splitlines() + assert len(lines) == 1 + assert lines[0].endswith("initial") diff --git a/tests/test_deployer.py b/tests/test_deployer.py index e0588e7f5..ebe0981c6 100644 --- a/tests/test_deployer.py +++ b/tests/test_deployer.py @@ -1378,3 +1378,53 @@ async def test_dedicated_base_preferred_over_generic(self, tmp_path): "hermes", {"taos-hermes-base", "taos-base"}, tmp_path ) assert launch == "taos-hermes-base" + + +class TestGitInitAndCommitter: + @pytest.mark.asyncio + async def test_deploy_emits_git_init_step(self, tmp_path): + req = _req(data_dir=tmp_path) + + async def mock_exec(name, cmd, **kwargs): + if "hostname -I" in " ".join(cmd): + return (0, "10.0.0.5") + return (0, "ok") + + with patch("tinyagentos.deployer.create_container", new_callable=AsyncMock) as mock_create, \ + patch("tinyagentos.deployer.exec_in_container", side_effect=mock_exec), \ + patch("tinyagentos.deployer.push_file", new_callable=AsyncMock, return_value=(0, "")), \ + patch("tinyagentos.deployer.add_proxy_device", new_callable=AsyncMock, return_value={"success": True, "output": ""}), \ + patch("tinyagentos.agent_git.git_init", new_callable=AsyncMock) as mock_git_init, \ + patch("tinyagentos.agent_git.write_gitignore", new_callable=AsyncMock) as mock_gitignore, \ + patch("tinyagentos.agent_git.git_config_user", new_callable=AsyncMock) as mock_git_config, \ + patch("tinyagentos.agent_git.git_add_commit", new_callable=AsyncMock) as mock_git_commit: + mock_create.return_value = {"success": True, "name": "taos-agent-test"} + result = await deploy_agent(req) + assert result["success"] is True + assert "git_init" in result["steps"] + mock_git_init.assert_awaited_once_with("taos-agent-test") + mock_gitignore.assert_awaited_once_with("taos-agent-test") + mock_git_config.assert_awaited_once_with("taos-agent-test", "test", "test@taos.local") + mock_git_commit.assert_awaited_once() + + @pytest.mark.asyncio + async def test_deploy_emits_committer_installed_step(self, tmp_path): + req = _req(data_dir=tmp_path) + + async def mock_exec(name, cmd, **kwargs): + if "hostname -I" in " ".join(cmd): + return (0, "10.0.0.5") + return (0, "ok") + + with patch("tinyagentos.deployer.create_container", new_callable=AsyncMock) as mock_create, \ + patch("tinyagentos.deployer.exec_in_container", side_effect=mock_exec), \ + patch("tinyagentos.deployer.push_file", new_callable=AsyncMock, return_value=(0, "")), \ + patch("tinyagentos.deployer.add_proxy_device", new_callable=AsyncMock, return_value={"success": True, "output": ""}), \ + patch("tinyagentos.agent_git.git_init", new_callable=AsyncMock), \ + patch("tinyagentos.agent_git.write_gitignore", new_callable=AsyncMock), \ + patch("tinyagentos.agent_git.git_config_user", new_callable=AsyncMock), \ + patch("tinyagentos.agent_git.git_add_commit", new_callable=AsyncMock): + mock_create.return_value = {"success": True, "name": "taos-agent-test"} + result = await deploy_agent(req) + assert result["success"] is True + assert "committer_installed" in result["steps"] diff --git a/tests/test_routes_agent_versions.py b/tests/test_routes_agent_versions.py new file mode 100644 index 000000000..e83a3a42c --- /dev/null +++ b/tests/test_routes_agent_versions.py @@ -0,0 +1,119 @@ +"""Tests for the agent state versioning routes.""" +from __future__ import annotations + +import os +import subprocess + +import pytest +from httpx import ASGITransport, AsyncClient +from unittest.mock import AsyncMock, patch + +import importlib.util + + +def _init_fixture_repo(path): + subprocess.run(["git", "init", "-b", "main"], cwd=path, check=True, capture_output=True) + subprocess.run(["git", "config", "user.email", "agent@taos.local"], cwd=path, check=True, capture_output=True) + subprocess.run(["git", "config", "user.name", "test-agent"], cwd=path, check=True, capture_output=True) + (path / "README.md").write_text("initial") + subprocess.run(["git", "add", "README.md"], cwd=path, check=True, capture_output=True) + subprocess.run(["git", "commit", "-m", "initial"], cwd=path, check=True, capture_output=True) + (path / "notes.txt").write_text("second commit") + subprocess.run(["git", "add", "notes.txt"], cwd=path, check=True, capture_output=True) + subprocess.run(["git", "commit", "-m", "add notes"], cwd=path, check=True, capture_output=True) + + +def _fake_exec_for_repo(fixture_repo): + async def _fake(container, cmd, timeout=60): + if cmd[0] == "git" and cmd[1] == "-C" and cmd[2] == "/root": + git_args = cmd[3:] + result = subprocess.run( + ["git", "-C", str(fixture_repo), *git_args], + capture_output=True, + text=True, + ) + return result.returncode, result.stdout + return 0, "" + return _fake + + +@pytest.mark.asyncio +class TestAgentVersionsRoutes: + async def test_list_versions_returns_commits(self, client): + with patch( + "tinyagentos.agent_git.exec_in_container", + new=AsyncMock(return_value=(0, "abc123|initial|agent|agent@taos.local|2026-01-01 00:00:00 +0000\ndef456|add notes|agent|agent@taos.local|2026-01-01 01:00:00 +0000\n")), + ): + resp = await client.get("/api/agents/test-agent/versions") + assert resp.status_code == 200 + data = resp.json() + assert data["agent"] == "test-agent" + assert len(data["versions"]) == 2 + assert data["versions"][0]["sha"] == "abc123" + + async def test_list_versions_unknown_agent_returns_404(self, client): + resp = await client.get("/api/agents/ghost-agent/versions") + assert resp.status_code == 404 + + async def test_diff_returns_patch(self, client): + with patch( + "tinyagentos.agent_git.exec_in_container", + new=AsyncMock(return_value=(0, "diff --git a/README.md b/README.md\n")), + ): + resp = await client.get("/api/agents/test-agent/versions/abc123/diff") + assert resp.status_code == 200 + data = resp.json() + assert data["sha"] == "abc123" + assert "diff --git" in data["diff"] + + async def test_diff_unknown_sha_returns_404(self, client): + with patch( + "tinyagentos.agent_git.exec_in_container", + new=AsyncMock(side_effect=RuntimeError("unknown revision")), + ): + resp = await client.get("/api/agents/test-agent/versions/badsha/diff") + assert resp.status_code == 404 + + async def test_revert_restores_content(self, tmp_path, client): + fixture = tmp_path / "repo" + fixture.mkdir() + _init_fixture_repo(fixture) + + first_sha = subprocess.run( + ["git", "-C", str(fixture), "rev-parse", "HEAD~1"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + + with patch( + "tinyagentos.agent_git.exec_in_container", + new=_fake_exec_for_repo(fixture), + ): + resp = await client.post(f"/api/agents/test-agent/versions/{first_sha}/revert") + print("RESP:", resp.status_code, resp.text) + assert resp.status_code == 200 + assert resp.json()["status"] == "reverted" + stat = subprocess.run( + ["git", "-C", str(fixture), "log", "--all", "--stat"], + capture_output=True, + text=True, + check=True, + ).stdout + assert "revert:" in stat + + async def test_revert_unknown_sha_returns_404(self, client): + with patch( + "tinyagentos.agent_git.exec_in_container", + new=AsyncMock(side_effect=RuntimeError("unknown revision")), + ): + resp = await client.post("/api/agents/test-agent/versions/badsha/revert") + assert resp.status_code == 404 + + async def test_unauthenticated_returns_401(self, app): + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + ) as c: + resp = await c.get("/api/agents/test-agent/versions") + assert resp.status_code in (401, 403) diff --git a/tinyagentos/agent_git.py b/tinyagentos/agent_git.py new file mode 100644 index 000000000..56a8ff575 --- /dev/null +++ b/tinyagentos/agent_git.py @@ -0,0 +1,112 @@ +"""Git helpers for agent state versioning inside containers. + +All container interactions go through ``exec_in_container`` and +``push_file`` so the same helpers work for both LXC and Docker backends. +""" +from __future__ import annotations + +import logging +import os +import tempfile +from typing import List + +from tinyagentos.containers import exec_in_container, push_file + +logger = logging.getLogger(__name__) + +_REPO_PATH = "/root" + +_GITIGNORE_CONTENTS = """\ +.env +*.cred +*token* +*.pem +*.p12 +*.key +*.secret +caches/ +venv/ +node_modules/ +.browser_profiles/ +__pycache__/ +*.pyc +""" + + +async def _git(container: str, args: List[str], timeout: int = 60) -> tuple[int, str]: + rc, out = await exec_in_container( + container, ["git", "-C", _REPO_PATH, *args], timeout=timeout + ) + return rc, out + + +async def git_init(container: str) -> None: + rc, out = await _git(container, ["init", "-b", "main"]) + if rc != 0: + raise RuntimeError(f"git init failed: {out}") + + +async def write_gitignore(container: str) -> None: + with tempfile.NamedTemporaryFile("w", suffix=".gitignore", delete=False) as tf: + tf.write(_GITIGNORE_CONTENTS) + tmp = tf.name + try: + rc, out = await push_file(container, tmp, "/root/.gitignore") + finally: + os.unlink(tmp) + if rc != 0: + raise RuntimeError(f"write .gitignore failed: {out}") + + +async def git_config_user(container: str, name: str, email: str) -> None: + await _git(container, ["config", "user.name", name]) + await _git(container, ["config", "user.email", email]) + + +async def git_add_commit(container: str, message: str) -> None: + rc, out = await _git(container, ["add", "-A"]) + if rc != 0: + raise RuntimeError(f"git add failed: {out}") + rc, out = await _git(container, ["commit", "-m", message, "--allow-empty"]) + if rc != 0: + raise RuntimeError(f"git commit failed: {out}") + + +async def git_is_dirty(container: str) -> bool: + rc, out = await _git(container, ["status", "--porcelain"]) + return rc == 0 and bool(out.strip()) + + +async def git_log(container: str) -> List[dict]: + fmt = "%H|%s|%an|%ae|%ai" + rc, out = await _git(container, ["log", f"--format={fmt}", "--reverse"]) + if rc != 0: + return [] + commits: List[dict] = [] + for line in out.strip().splitlines(): + parts = line.split("|", 4) + if len(parts) == 5: + commits.append({ + "sha": parts[0], + "message": parts[1], + "author_name": parts[2], + "author_email": parts[3], + "date": parts[4], + }) + return commits + + +async def git_diff(container: str, sha: str) -> str: + rc, out = await _git(container, ["show", "--format=", "--patch", sha]) + if rc != 0: + raise RuntimeError(f"git diff failed for {sha}: {out}") + return out + + +async def git_revert(container: str, sha: str) -> None: + rc, out = await _git(container, ["revert", "--no-commit", sha]) + if rc != 0: + raise RuntimeError(f"git revert failed for {sha}: {out}") + rc, out = await _git(container, ["commit", "-m", f"revert: {sha[:8]}"]) + if rc != 0: + raise RuntimeError(f"git revert commit failed: {out}") diff --git a/tinyagentos/deployer.py b/tinyagentos/deployer.py index 8d366445d..b091809a5 100644 --- a/tinyagentos/deployer.py +++ b/tinyagentos/deployer.py @@ -716,6 +716,52 @@ async def deploy_agent(req: DeployRequest) -> dict: except Exception: logger.exception("%s: AGENTS.md injection failed", req.framework) + # Step 4b: Initialise a git repo inside the container for agent state + # versioning. The repo lives at /root and covers the agent's text + # state (workspace, memory, framework config). A .gitignore excludes + # secrets and bulk artefacts so they never enter history. + try: + from tinyagentos.agent_git import ( + git_init, + write_gitignore, + git_config_user, + git_add_commit, + ) + await git_init(container_name) + await write_gitignore(container_name) + await git_config_user(container_name, req.name, f"{req.name}@taos.local") + await git_add_commit(container_name, f"chore: initial state for {req.name}") + steps.append("git_init") + except Exception as exc: + logger.warning("Deploy %s: git init failed: %s", req.name, exc) + + # Step 4c: Install the auto-committer script and start it as a + # background loop inside the container. No LLM involvement. + try: + from pathlib import Path as _P + _committer = _P(__file__).parent / "scripts" / "agent_committer.py" + if _committer.exists(): + _push_rc, _push_out = await push_file( + container_name, + str(_committer), + "/root/.taos/agent_committer.py", + ) + if _push_rc == 0: + await exec_in_container( + container_name, ["chmod", "+x", "/root/.taos/agent_committer.py"] + ) + await exec_in_container( + container_name, + [ + "bash", "-c", + "nohup python3 /root/.taos/agent_committer.py " + "> /root/.taos/committer.log 2>&1 &", + ], + ) + steps.append("committer_installed") + except Exception as exc: + logger.warning("Deploy %s: committer install failed: %s", req.name, exc) + # Step 5: Get container IP code, output = await exec_in_container(container_name, ["hostname", "-I"]) container_ip = output.strip().split()[0] if code == 0 and output.strip() else None diff --git a/tinyagentos/routes/__init__.py b/tinyagentos/routes/__init__.py index 36000dfcc..b1d0703ad 100644 --- a/tinyagentos/routes/__init__.py +++ b/tinyagentos/routes/__init__.py @@ -48,6 +48,9 @@ def register_all_routers(app): from tinyagentos.routes.agents import router as agents_router app.include_router(agents_router, dependencies=_csrf) + from tinyagentos.routes.agent_versions import router as agent_versions_router + app.include_router(agent_versions_router, dependencies=_csrf) + from tinyagentos.routes.librarian import router as librarian_router app.include_router(librarian_router, dependencies=_csrf) diff --git a/tinyagentos/routes/agent_versions.py b/tinyagentos/routes/agent_versions.py new file mode 100644 index 000000000..ba129b037 --- /dev/null +++ b/tinyagentos/routes/agent_versions.py @@ -0,0 +1,84 @@ +"""Agent state versioning API routes. + +Exposes git-history operations against each agent container's local state +repo at /root. Container interactions go via ``agent_git`` helpers so the +same code works for both LXC and Docker backends. + +Routes +------ +GET /api/agents/{name}/versions — list commits +GET /api/agents/{name}/versions/{sha}/diff — show patch for a commit +POST /api/agents/{name}/versions/{sha}/revert — revert to a prior commit +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse + +from tinyagentos.agent_db import find_agent +from tinyagentos.agent_git import git_diff, git_log, git_revert + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +def _container_name(name: str) -> str: + return f"taos-agent-{name}" + + +@router.get("/api/agents/{name}/versions") +async def list_versions(request: Request, name: str): + """Return the commit list for an agent's state repo.""" + config = request.app.state.config + agent = find_agent(config, name) + if not agent: + return JSONResponse({"error": f"Agent '{name}' not found"}, status_code=404) + + container = _container_name(name) + try: + commits = await git_log(container) + except Exception as exc: + logger.warning("versions list failed for %s: %s", name, exc) + return JSONResponse({"error": "container_unreachable"}, status_code=409) + return {"agent": name, "versions": commits} + + +@router.get("/api/agents/{name}/versions/{sha}/diff") +async def version_diff(request: Request, name: str, sha: str): + """Return the unified diff for a specific commit.""" + config = request.app.state.config + agent = find_agent(config, name) + if not agent: + return JSONResponse({"error": f"Agent '{name}' not found"}, status_code=404) + + container = _container_name(name) + try: + patch = await git_diff(container, sha) + except RuntimeError as exc: + return JSONResponse({"error": str(exc)}, status_code=404) + except Exception as exc: + logger.warning("version diff failed for %s/%s: %s", name, sha, exc) + return JSONResponse({"error": "container_unreachable"}, status_code=409) + return {"agent": name, "sha": sha, "diff": patch} + + +@router.post("/api/agents/{name}/versions/{sha}/revert") +async def revert_version(request: Request, name: str, sha: str): + """Revert the agent state repo to a prior commit.""" + config = request.app.state.config + agent = find_agent(config, name) + if not agent: + return JSONResponse({"error": f"Agent '{name}' not found"}, status_code=404) + + container = _container_name(name) + try: + await git_revert(container, sha) + except RuntimeError as exc: + return JSONResponse({"error": str(exc)}, status_code=404) + except Exception as exc: + logger.warning("version revert failed for %s/%s: %s", name, sha, exc) + return JSONResponse({"error": "container_unreachable"}, status_code=409) + return {"agent": name, "sha": sha, "status": "reverted"} diff --git a/tinyagentos/scripts/agent_committer.py b/tinyagentos/scripts/agent_committer.py new file mode 100644 index 000000000..d66534621 --- /dev/null +++ b/tinyagentos/scripts/agent_committer.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Debounced auto-committer for agent state repos. + +Runs inside the agent container. Watches the state repo and commits +dirty trees on a fixed interval with a timestamp + changed-file-summary +message. No LLM involvement. +""" +from __future__ import annotations + +import os +import subprocess +import time + + +REPO_PATH = os.environ.get("AGENT_STATE_REPO", "/root") +INTERVAL = int(os.environ.get("COMMIT_INTERVAL", "300")) + + +def _git(*args: str) -> tuple[int, str, str]: + result = subprocess.run( + ["git", "-C", REPO_PATH, *args], + capture_output=True, + text=True, + ) + return result.returncode, result.stdout, result.stderr + + +def _is_dirty() -> bool: + rc, out, _ = _git("status", "--porcelain") + return rc == 0 and bool(out.strip()) + + +def _changed_summary() -> str: + rc, out, _ = _git("diff", "--cached", "--stat") + if rc != 0 or not out.strip(): + rc, out, _ = _git("diff", "--stat") + lines = [l.strip() for l in out.strip().splitlines() if l.strip()] + if not lines: + return "auto-commit" + if len(lines) == 1: + return lines[0] + return f"{len(lines)} files changed" + + +def _commit() -> None: + if not _is_dirty(): + return + ts = time.strftime("%Y-%m-%d %H:%M:%S") + summary = _changed_summary() + message = f"auto: {ts} | {summary}" + _git("add", "-A") + _git("commit", "-m", message) + + +def main() -> None: + while True: + try: + _commit() + except Exception: + pass + time.sleep(INTERVAL) + + +if __name__ == "__main__": + main() From e3aed75b331ba8eb25a6f67c64d5afcb7ce21704 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Wed, 2 Sep 2026 15:43:42 +0000 Subject: [PATCH 2/4] Fix agent state versioning: 1) git_log raises RuntimeError on failure, 2) git_revert uses single operation, 3) agent_committer excludes Git stat footer --- changelog.d/tsk-fjmxzo-agent-state-versioning.md | 4 ++++ tests/test_routes_agent_versions.py | 2 +- tinyagentos/agent_git.py | 7 ++----- tinyagentos/scripts/agent_committer.py | 3 +++ 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/changelog.d/tsk-fjmxzo-agent-state-versioning.md b/changelog.d/tsk-fjmxzo-agent-state-versioning.md index 4885fa39f..348fee9c0 100644 --- a/changelog.d/tsk-fjmxzo-agent-state-versioning.md +++ b/changelog.d/tsk-fjmxzo-agent-state-versioning.md @@ -1,3 +1,7 @@ ### Added - Agent state versioning: a git repo is initialised inside each agent container at deploy time, with a `.gitignore` that excludes secrets and bulk artefacts, and commit identity set to the agent's own slug. An auto-committer script runs as a background loop inside the container, committing dirty trees on a fixed interval with a timestamp + changed-file-summary message (#tsk-fjmxzo). - Controller API for agent state history: `GET /api/agents/{name}/versions` lists commits, `GET /api/agents/{name}/versions/{sha}/diff` returns the patch for a commit, and `POST /api/agents/{name}/versions/{sha}/revert` reverts the state repo to a prior commit (#tsk-fjmxzo). + +### Fixed +- Fixed git_log to propagate Git-log failures to the route with RuntimeError, ensuring HTTP 409 when container is unreachable (tinyagentos/agent_git.py:84) +- Fixed git_revert to use a single git operation without leaving the index/dirty, preventing race condition with agent_committer (tinyagentos/agent_git.py:107) diff --git a/tests/test_routes_agent_versions.py b/tests/test_routes_agent_versions.py index e83a3a42c..4909805dd 100644 --- a/tests/test_routes_agent_versions.py +++ b/tests/test_routes_agent_versions.py @@ -100,7 +100,7 @@ async def test_revert_restores_content(self, tmp_path, client): text=True, check=True, ).stdout - assert "revert:" in stat + assert "Revert" in stat async def test_revert_unknown_sha_returns_404(self, client): with patch( diff --git a/tinyagentos/agent_git.py b/tinyagentos/agent_git.py index 56a8ff575..0959451e3 100644 --- a/tinyagentos/agent_git.py +++ b/tinyagentos/agent_git.py @@ -81,7 +81,7 @@ async def git_log(container: str) -> List[dict]: fmt = "%H|%s|%an|%ae|%ai" rc, out = await _git(container, ["log", f"--format={fmt}", "--reverse"]) if rc != 0: - return [] + raise RuntimeError(f"git log failed: {out}") commits: List[dict] = [] for line in out.strip().splitlines(): parts = line.split("|", 4) @@ -104,9 +104,6 @@ async def git_diff(container: str, sha: str) -> str: async def git_revert(container: str, sha: str) -> None: - rc, out = await _git(container, ["revert", "--no-commit", sha]) + rc, out = await _git(container, ["revert", "--no-edit", sha]) if rc != 0: raise RuntimeError(f"git revert failed for {sha}: {out}") - rc, out = await _git(container, ["commit", "-m", f"revert: {sha[:8]}"]) - if rc != 0: - raise RuntimeError(f"git revert commit failed: {out}") diff --git a/tinyagentos/scripts/agent_committer.py b/tinyagentos/scripts/agent_committer.py index d66534621..915eec43c 100644 --- a/tinyagentos/scripts/agent_committer.py +++ b/tinyagentos/scripts/agent_committer.py @@ -37,6 +37,9 @@ def _changed_summary() -> str: lines = [l.strip() for l in out.strip().splitlines() if l.strip()] if not lines: return "auto-commit" + # Exclude the Git stat footer (e.g., "2 files changed") + if lines and "files changed" in lines[-1]: + lines = lines[:-1] if len(lines) == 1: return lines[0] return f"{len(lines)} files changed" From d9315c10daab7fc686fef13961f080299a6fd166 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Wed, 2 Sep 2026 16:25:41 +0000 Subject: [PATCH 3/4] fold #2717 findings: snapshot revert, gitignore trace, remote target, sha validation 1. git_revert restores snapshot with sha..HEAD range instead of inverting one commit 2. .taos/trace/ added to gitignore before initial commit 3. remote field persisted on deploy and used in _container_name for version routes 4. sha validated against ^[0-9a-f]{4,40}$ before reaching any git argv Docs-Reviewed: agent_versions routes already covered by existing route docs, no route doc changes needed --- .../tsk-yn5gze-agent-versions-fixes.md | 6 ++ tests/test_routes_agent_versions.py | 63 ++++++++++++++++--- tinyagentos/agent_git.py | 3 +- tinyagentos/routes/agent_versions.py | 32 ++++++++-- tinyagentos/routes/agents.py | 2 + 5 files changed, 90 insertions(+), 16 deletions(-) create mode 100644 changelog.d/tsk-yn5gze-agent-versions-fixes.md diff --git a/changelog.d/tsk-yn5gze-agent-versions-fixes.md b/changelog.d/tsk-yn5gze-agent-versions-fixes.md new file mode 100644 index 000000000..e6721448b --- /dev/null +++ b/changelog.d/tsk-yn5gze-agent-versions-fixes.md @@ -0,0 +1,6 @@ +### Fixed + +- Agent state version revert now restores the full snapshot at the target commit instead of inverting a single commit. `git_revert` runs `git revert --no-edit ..HEAD` so the tree matches the requested commit's state, and the revert endpoint asserts `README.md` remains with `notes.txt` absent after the operation. +- `.taos/trace/` is now excluded from the agent state gitignore before the initial commit, preventing trace directory contents from being staged into git history. +- Remote agent container targets are persisted in the agent record and used for all version operations, so remote-deployed agents resolve to `:taos-agent-{name}` instead of the unqualified local name. +- The `sha` path parameter on version diff and revert routes is validated against `^[0-9a-f]{4,40}$` before it reaches any git argv, preventing argument injection such as `--output=.bashrc`. diff --git a/tests/test_routes_agent_versions.py b/tests/test_routes_agent_versions.py index 4909805dd..b4a12d808 100644 --- a/tests/test_routes_agent_versions.py +++ b/tests/test_routes_agent_versions.py @@ -6,9 +6,11 @@ import pytest from httpx import ASGITransport, AsyncClient +from taos_test_csrf import csrf_event_hooks from unittest.mock import AsyncMock, patch import importlib.util +import yaml def _init_fixture_repo(path): @@ -37,6 +39,28 @@ async def _fake(container, cmd, timeout=60): return _fake +def _make_app_with_remote(tmp_path, remote): + config = { + "server": {"host": "0.0.0.0", "port": 6969}, + "backends": [], + "qmd": {"url": "http://localhost:7832"}, + "agents": [ + {"name": "test-agent", "host": "192.168.1.100", "remote": remote, "qmd_index": "test", "color": "#98fb98"} + ], + "metrics": {"poll_interval": 30, "retention_days": 30}, + } + config_path = tmp_path / "config.yaml" + config_path.write_text(yaml.dump(config)) + (tmp_path / ".setup_complete").touch() + from tinyagentos.app import create_app + app = create_app(data_dir=tmp_path) + app.state.auth.setup_user("admin", "Test Admin", "", "testpass") + record = app.state.auth.find_user("admin") + token = app.state.auth.create_session(user_id=record["id"], long_lived=True) + app.state._startup_complete = True + return app, token + + @pytest.mark.asyncio class TestAgentVersionsRoutes: async def test_list_versions_returns_commits(self, client): @@ -71,9 +95,13 @@ async def test_diff_unknown_sha_returns_404(self, client): "tinyagentos.agent_git.exec_in_container", new=AsyncMock(side_effect=RuntimeError("unknown revision")), ): - resp = await client.get("/api/agents/test-agent/versions/badsha/diff") + resp = await client.get("/api/agents/test-agent/versions/abcd1234/diff") assert resp.status_code == 404 + async def test_diff_injection_sha_returns_400(self, client): + resp = await client.get("/api/agents/test-agent/versions/--output=.bashrc/diff") + assert resp.status_code == 400 + async def test_revert_restores_content(self, tmp_path, client): fixture = tmp_path / "repo" fixture.mkdir() @@ -94,22 +122,39 @@ async def test_revert_restores_content(self, tmp_path, client): print("RESP:", resp.status_code, resp.text) assert resp.status_code == 200 assert resp.json()["status"] == "reverted" - stat = subprocess.run( - ["git", "-C", str(fixture), "log", "--all", "--stat"], - capture_output=True, - text=True, - check=True, - ).stdout - assert "Revert" in stat + assert (fixture / "README.md").exists() + assert not (fixture / "notes.txt").exists() async def test_revert_unknown_sha_returns_404(self, client): with patch( "tinyagentos.agent_git.exec_in_container", new=AsyncMock(side_effect=RuntimeError("unknown revision")), ): - resp = await client.post("/api/agents/test-agent/versions/badsha/revert") + resp = await client.post("/api/agents/test-agent/versions/abcd1234/revert") assert resp.status_code == 404 + async def test_revert_injection_sha_returns_400(self, client): + resp = await client.post("/api/agents/test-agent/versions/--output=.bashrc/revert") + assert resp.status_code == 400 + + async def test_remote_agent_uses_qualified_container_name(self, tmp_path): + app, token = _make_app_with_remote(tmp_path, "test-remote") + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + cookies={"taos_session": token}, + event_hooks=csrf_event_hooks(), + ) as c: + with patch( + "tinyagentos.agent_git.exec_in_container", + new=AsyncMock(return_value=(0, "abc123|initial|agent|agent@taos.local|2026-01-01 00:00:00 +0000\n")), + ) as m: + resp = await c.get("/api/agents/test-agent/versions") + m.assert_called_once() + args, _ = m.call_args + assert args[0] == "test-remote:taos-agent-test-agent" + assert resp.status_code == 200 + async def test_unauthenticated_returns_401(self, app): async with AsyncClient( transport=ASGITransport(app=app), diff --git a/tinyagentos/agent_git.py b/tinyagentos/agent_git.py index 0959451e3..97959dfa1 100644 --- a/tinyagentos/agent_git.py +++ b/tinyagentos/agent_git.py @@ -30,6 +30,7 @@ .browser_profiles/ __pycache__/ *.pyc +.taos/trace/ """ @@ -104,6 +105,6 @@ async def git_diff(container: str, sha: str) -> str: async def git_revert(container: str, sha: str) -> None: - rc, out = await _git(container, ["revert", "--no-edit", sha]) + rc, out = await _git(container, ["revert", "--no-edit", f"{sha}..HEAD"]) if rc != 0: raise RuntimeError(f"git revert failed for {sha}: {out}") diff --git a/tinyagentos/routes/agent_versions.py b/tinyagentos/routes/agent_versions.py index ba129b037..b376f9d4b 100644 --- a/tinyagentos/routes/agent_versions.py +++ b/tinyagentos/routes/agent_versions.py @@ -5,7 +5,7 @@ same code works for both LXC and Docker backends. Routes ------- +----- GET /api/agents/{name}/versions — list commits GET /api/agents/{name}/versions/{sha}/diff — show patch for a commit POST /api/agents/{name}/versions/{sha}/revert — revert to a prior commit @@ -13,6 +13,7 @@ from __future__ import annotations import logging +import re from fastapi import APIRouter, Request from fastapi.responses import JSONResponse @@ -24,9 +25,20 @@ router = APIRouter() +_SHA_RE = re.compile(r"^[0-9a-f]{4,40}$") -def _container_name(name: str) -> str: - return f"taos-agent-{name}" + +def _container_name(agent: dict) -> str: + remote = agent.get("remote") + name = agent["name"] + container = f"taos-agent-{name}" + return f"{remote}:{container}" if remote else container + + +def _validate_sha(sha: str) -> JSONResponse | None: + if not _SHA_RE.match(sha): + return JSONResponse({"error": "invalid sha"}, status_code=400) + return None @router.get("/api/agents/{name}/versions") @@ -37,7 +49,7 @@ async def list_versions(request: Request, name: str): if not agent: return JSONResponse({"error": f"Agent '{name}' not found"}, status_code=404) - container = _container_name(name) + container = _container_name(agent) try: commits = await git_log(container) except Exception as exc: @@ -54,7 +66,11 @@ async def version_diff(request: Request, name: str, sha: str): if not agent: return JSONResponse({"error": f"Agent '{name}' not found"}, status_code=404) - container = _container_name(name) + bad = _validate_sha(sha) + if bad is not None: + return bad + + container = _container_name(agent) try: patch = await git_diff(container, sha) except RuntimeError as exc: @@ -73,7 +89,11 @@ async def revert_version(request: Request, name: str, sha: str): if not agent: return JSONResponse({"error": f"Agent '{name}' not found"}, status_code=404) - container = _container_name(name) + bad = _validate_sha(sha) + if bad is not None: + return bad + + container = _container_name(agent) try: await git_revert(container, sha) except RuntimeError as exc: diff --git a/tinyagentos/routes/agents.py b/tinyagentos/routes/agents.py index 924738455..9f7095e3a 100644 --- a/tinyagentos/routes/agents.py +++ b/tinyagentos/routes/agents.py @@ -758,6 +758,8 @@ async def _background_deploy(): if result.get("success"): if agent is not None: agent["host"] = result.get("ip", "") + if deploy_remote: + agent["remote"] = deploy_remote agent["status"] = "running" agent["llm_key"] = result.get("llm_key") # Save config now so the bootstrap endpoint can return From 0c6e36bfbf4bf1d3d81931f0f3ca1cce234e7035 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Wed, 2 Sep 2026 17:30:33 +0000 Subject: [PATCH 4/4] fold #2719 findings: agent versions fixes L1 (agent_git.py:82-91): fold - versions API mis-parses auto-commits because committer subject uses | as delimiter. Changed git_log format to use %x1f as delimiter and split on \x1f. Added test_list_versions_with_pipe_in_subject_parses_all_fields with a subject containing | that asserts all five fields. Test fails on base. L2 (agent_git.py:107 + routes/agent_versions.py:96-104): fold - reverting to HEAD returns 404. Added git_rev_parse, git_merge_base_is_ancestor, and pre-revert checks: HEAD == sha returns 200 noop; non-ancestor returns 409; dirty tree returns 409; unknown revision returns 404. Added test_revert_to_head_returns_noop, test_revert_non_ancestor_returns_409, test_revert_dirty_tree_returns_409. HEAD case fails on base. L3 (deployer.py:750-762): fold - committer does not survive container restart. Changed committer install to prefer systemd unit (Restart=always, systemctl enable --now) with nohup as fallback. Appends committer_installed only after unit is active; appends committer_failed or committer_installed_nohup otherwise. CR1 (agent_git.py:19): fold - SSH key files not excluded from agent history. Added .ssh/ to _GITIGNORE_CONTENTS. Added test_gitignored_ssh_key_not_committed asserting .ssh/id_rsa does not enter history. CR2 (agent_git.py:83): fold - same delimiter fix as L1. CR3 (agent_git.py:108): fold - same ancestor/dirty checks as L2. CR4 (deployer.py:730-762): fold - deploy reports success when state-repository setup fails. Added versioning and versioning_error fields to deploy result; git init failure sets versioning: false and versioning_error without failing the deploy. Added test_deploy_reports_versioning_failure. CR5 (deployer.py:761): fold - committer_installed appended without verifying start. Now checks systemctl is-active before appending committer_installed; falls back to nohup with committer_installed_nohup or committer_failed. CR6 (routes/agent_versions.py:99): fold - same revert status code fixes as L2. CR7 (scripts/agent_committer.py:42): fold - singular Git stat footer not stripped. Replaced --stat footer heuristic with git diff --name-only. CR8 (scripts/agent_committer.py:55): fold - Git command return codes discarded. _commit now checks return codes and raises on failure; main() logs exceptions to stderr instead of pass. K1 (routes/agent_versions.py:28): fold - SHA regex minimum length 4 too permissive. Tightened to ^[0-9a-f]{7,40}$. K2 (routes/agent_versions.py:31): refuted - remote interpolated into incus target without validation. configure_remote_deploy (routes/agent_deploy.py:208) constrains deploy_remote to known worker names via cm.get_worker() lookup; invalid names are rejected at route layer before reaching _container_name. K3 (agent_git.py:108): fold - same as L2/CR3. K4 (scripts/agent_committer.py:41): fold - same as CR7. K5 (scripts/agent_committer.py:54): refuted - return codes discarded and empty commits. _commit() returns early when _is_dirty() is false (line 49), and git commit -m without --allow-empty creates nothing when index is clean. K6 (deployer.py:757): fold - no check that python3 exists or committer started. Covered by L3/CR5 systemd path with systemctl is-active check. K7 (deployer.py:730): refuted - /root hardcoded and git init -b main needs git >= 2.28. Agents run as root in container per deployer docstring; base images are Debian bookworm/Ubuntu 22.04+ which ship git >= 2.34. K8 (routes/agent_versions.py:44): refuted - 404 echoes agent name enabling probing. Route requires authenticated session; GET /api/agents already lists every agent name to same principal. Docs-Reviewed: routes/agent_versions.py docstring updated with new revert status codes; agent-coordination.md is repo coordination policy, not API reference, no change needed. --- .../tsk-f2ttez-agent-versions-fixes.md | 9 ++ tests/test_agent_committer.py | 17 +++ tests/test_deployer.py | 28 ++++- tests/test_routes_agent_versions.py | 79 ++++++++++++- tinyagentos/agent_git.py | 30 ++++- tinyagentos/deployer.py | 111 ++++++++++++++---- tinyagentos/routes/agent_versions.py | 40 ++++++- tinyagentos/scripts/agent_committer.py | 20 ++-- 8 files changed, 285 insertions(+), 49 deletions(-) create mode 100644 changelog.d/tsk-f2ttez-agent-versions-fixes.md diff --git a/changelog.d/tsk-f2ttez-agent-versions-fixes.md b/changelog.d/tsk-f2ttez-agent-versions-fixes.md new file mode 100644 index 000000000..1bf88c16f --- /dev/null +++ b/changelog.d/tsk-f2ttez-agent-versions-fixes.md @@ -0,0 +1,9 @@ +### Fixed +- Agent state versions API now parses auto-commit subjects that contain `|` by using a non-printable delimiter instead of the pipe character. +- Reverting to the current HEAD returns 200 `{"status": "noop"}` instead of 404; non-ancestor SHAs return 409, and dirty working trees return 409. +- SHA validation tightened to require at least 7 hex characters. +- SSH key files under `.ssh/` are excluded from agent state history via `.gitignore`. +- Deploy results now surface `versioning: false` and `versioning_error` when the state-repository setup fails. +- The auto-committer is installed as a systemd unit with `Restart=always` so it survives container reboots; nohup remains the fallback. +- The committer script now uses `git diff --name-only` for its change summary, discarding the fragile footer heuristic. +- The committer script checks Git command return codes and logs errors to stderr instead of silently swallowing them. \ No newline at end of file diff --git a/tests/test_agent_committer.py b/tests/test_agent_committer.py index eff65f37a..93c7dbfdc 100644 --- a/tests/test_agent_committer.py +++ b/tests/test_agent_committer.py @@ -81,3 +81,20 @@ def test_no_commit_when_clean(self, tmp_path): lines = out.strip().splitlines() assert len(lines) == 1 assert lines[0].endswith("initial") + + def test_gitignored_ssh_key_not_committed(self, tmp_path): + _init_repo(tmp_path) + (tmp_path / ".gitignore").write_text("*.secret\n.env\n*token*\n.ssh/\n") + subprocess.run(["git", "add", ".gitignore"], cwd=tmp_path, check=True, capture_output=True) + subprocess.run( + ["git", "commit", "-m", "initial"], cwd=tmp_path, check=True, capture_output=True + ) + + committer = _load_committer(str(tmp_path)) + (tmp_path / ".ssh").mkdir() + (tmp_path / ".ssh" / "id_rsa").write_text("fake-key") + committer._commit() + + _, log_out, _ = committer._git("log", "--all", "--stat") + assert ".ssh" not in log_out + assert "id_rsa" not in log_out diff --git a/tests/test_deployer.py b/tests/test_deployer.py index ebe0981c6..e1dda9ad9 100644 --- a/tests/test_deployer.py +++ b/tests/test_deployer.py @@ -1412,8 +1412,13 @@ async def test_deploy_emits_committer_installed_step(self, tmp_path): req = _req(data_dir=tmp_path) async def mock_exec(name, cmd, **kwargs): - if "hostname -I" in " ".join(cmd): + cmd_str = " ".join(cmd) + if "hostname -I" in cmd_str: return (0, "10.0.0.5") + if "command -v systemctl" in cmd_str: + return (0, "yes") + if "systemctl is-active" in cmd_str: + return (0, "active") return (0, "ok") with patch("tinyagentos.deployer.create_container", new_callable=AsyncMock) as mock_create, \ @@ -1428,3 +1433,24 @@ async def mock_exec(name, cmd, **kwargs): result = await deploy_agent(req) assert result["success"] is True assert "committer_installed" in result["steps"] + + @pytest.mark.asyncio + async def test_deploy_reports_versioning_failure(self, tmp_path): + req = _req(data_dir=tmp_path) + + async def mock_exec(name, cmd, **kwargs): + if "hostname -I" in " ".join(cmd): + return (0, "10.0.0.5") + return (0, "ok") + + with patch("tinyagentos.deployer.create_container", new_callable=AsyncMock) as mock_create, \ + patch("tinyagentos.deployer.exec_in_container", side_effect=mock_exec), \ + patch("tinyagentos.deployer.push_file", new_callable=AsyncMock, return_value=(0, "")), \ + patch("tinyagentos.deployer.add_proxy_device", new_callable=AsyncMock, return_value={"success": True, "output": ""}), \ + patch("tinyagentos.agent_git.git_init", side_effect=RuntimeError("no git")): + mock_create.return_value = {"success": True, "name": "taos-agent-test"} + result = await deploy_agent(req) + assert result["success"] is True + assert result["versioning"] is False + assert result["versioning_error"] is not None + assert "git_init" not in result.get("steps", []) diff --git a/tests/test_routes_agent_versions.py b/tests/test_routes_agent_versions.py index b4a12d808..f1693d9cb 100644 --- a/tests/test_routes_agent_versions.py +++ b/tests/test_routes_agent_versions.py @@ -66,14 +66,14 @@ class TestAgentVersionsRoutes: async def test_list_versions_returns_commits(self, client): with patch( "tinyagentos.agent_git.exec_in_container", - new=AsyncMock(return_value=(0, "abc123|initial|agent|agent@taos.local|2026-01-01 00:00:00 +0000\ndef456|add notes|agent|agent@taos.local|2026-01-01 01:00:00 +0000\n")), + new=AsyncMock(return_value=(0, "abc12345\x1finitial\x1fagent\x1fagent@taos.local\x1f2026-01-01 00:00:00 +0000\ndef456789\x1fadd notes\x1fagent\x1fagent@taos.local\x1f2026-01-01 01:00:00 +0000\n")), ): resp = await client.get("/api/agents/test-agent/versions") assert resp.status_code == 200 data = resp.json() assert data["agent"] == "test-agent" assert len(data["versions"]) == 2 - assert data["versions"][0]["sha"] == "abc123" + assert data["versions"][0]["sha"] == "abc12345" async def test_list_versions_unknown_agent_returns_404(self, client): resp = await client.get("/api/agents/ghost-agent/versions") @@ -84,10 +84,10 @@ async def test_diff_returns_patch(self, client): "tinyagentos.agent_git.exec_in_container", new=AsyncMock(return_value=(0, "diff --git a/README.md b/README.md\n")), ): - resp = await client.get("/api/agents/test-agent/versions/abc123/diff") + resp = await client.get("/api/agents/test-agent/versions/abc12345/diff") assert resp.status_code == 200 data = resp.json() - assert data["sha"] == "abc123" + assert data["sha"] == "abc12345" assert "diff --git" in data["diff"] async def test_diff_unknown_sha_returns_404(self, client): @@ -95,7 +95,7 @@ async def test_diff_unknown_sha_returns_404(self, client): "tinyagentos.agent_git.exec_in_container", new=AsyncMock(side_effect=RuntimeError("unknown revision")), ): - resp = await client.get("/api/agents/test-agent/versions/abcd1234/diff") + resp = await client.get("/api/agents/test-agent/versions/abcd12345/diff") assert resp.status_code == 404 async def test_diff_injection_sha_returns_400(self, client): @@ -147,7 +147,7 @@ async def test_remote_agent_uses_qualified_container_name(self, tmp_path): ) as c: with patch( "tinyagentos.agent_git.exec_in_container", - new=AsyncMock(return_value=(0, "abc123|initial|agent|agent@taos.local|2026-01-01 00:00:00 +0000\n")), + new=AsyncMock(return_value=(0, "abc12345\x1finitial\x1fagent\x1fagent@taos.local\x1f2026-01-01 00:00:00 +0000\n")), ) as m: resp = await c.get("/api/agents/test-agent/versions") m.assert_called_once() @@ -162,3 +162,70 @@ async def test_unauthenticated_returns_401(self, app): ) as c: resp = await c.get("/api/agents/test-agent/versions") assert resp.status_code in (401, 403) + + async def test_list_versions_with_pipe_in_subject_parses_all_fields(self, client): + subject = "auto: 2026-01-01 00:00:00 | added new feature" + with patch( + "tinyagentos.agent_git.exec_in_container", + new=AsyncMock(return_value=(0, f"abc12345\x1f{subject}\x1fagent\x1fagent@taos.local\x1f2026-01-01 00:00:00 +0000\n")), + ): + resp = await client.get("/api/agents/test-agent/versions") + assert resp.status_code == 200 + data = resp.json() + assert len(data["versions"]) == 1 + assert data["versions"][0]["sha"] == "abc12345" + assert data["versions"][0]["message"] == subject + assert data["versions"][0]["author_name"] == "agent" + assert data["versions"][0]["author_email"] == "agent@taos.local" + assert data["versions"][0]["date"] == "2026-01-01 00:00:00 +0000" + + async def test_revert_to_head_returns_noop(self, tmp_path, client): + fixture = tmp_path / "repo" + fixture.mkdir() + _init_fixture_repo(fixture) + head_sha = subprocess.run( + ["git", "-C", str(fixture), "rev-parse", "HEAD"], + capture_output=True, text=True, check=True, + ).stdout.strip() + with patch( + "tinyagentos.agent_git.exec_in_container", + new=_fake_exec_for_repo(fixture), + ): + resp = await client.post(f"/api/agents/test-agent/versions/{head_sha}/revert") + assert resp.status_code == 200 + assert resp.json()["status"] == "noop" + + async def test_revert_non_ancestor_returns_409(self, tmp_path, client): + fixture = tmp_path / "repo" + fixture.mkdir() + _init_fixture_repo(fixture) + orphan = subprocess.run( + ["git", "-C", str(fixture), "commit-tree", "HEAD^{tree}", "-m", "orphan"], + capture_output=True, text=True, check=True, + ).stdout.strip() + with patch( + "tinyagentos.agent_git.exec_in_container", + new=_fake_exec_for_repo(fixture), + ): + resp = await client.post(f"/api/agents/test-agent/versions/{orphan}/revert") + assert resp.status_code == 409 + + async def test_revert_dirty_tree_returns_409(self, tmp_path, client): + fixture = tmp_path / "repo" + fixture.mkdir() + _init_fixture_repo(fixture) + first_sha = subprocess.run( + ["git", "-C", str(fixture), "rev-parse", "HEAD~1"], + capture_output=True, text=True, check=True, + ).stdout.strip() + (fixture / "dirty.txt").write_text("uncommitted") + with patch( + "tinyagentos.agent_git.exec_in_container", + new=_fake_exec_for_repo(fixture), + ): + resp = await client.post(f"/api/agents/test-agent/versions/{first_sha}/revert") + assert resp.status_code == 409 + + async def test_short_sha_rejected_by_versions_route(self, client): + resp = await client.get("/api/agents/test-agent/versions/abc1/diff") + assert resp.status_code == 400 diff --git a/tinyagentos/agent_git.py b/tinyagentos/agent_git.py index 97959dfa1..92c4db1df 100644 --- a/tinyagentos/agent_git.py +++ b/tinyagentos/agent_git.py @@ -24,6 +24,7 @@ *.p12 *.key *.secret +.ssh/ caches/ venv/ node_modules/ @@ -78,14 +79,26 @@ async def git_is_dirty(container: str) -> bool: return rc == 0 and bool(out.strip()) +async def git_rev_parse(container: str, sha: str) -> str: + rc, out = await _git(container, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) + if rc != 0: + raise RuntimeError(f"unknown revision {sha}") + return out.strip() + + +async def git_merge_base_is_ancestor(container: str, sha: str) -> bool: + rc, out = await _git(container, ["merge-base", "--is-ancestor", sha, "HEAD"]) + return rc == 0 + + async def git_log(container: str) -> List[dict]: - fmt = "%H|%s|%an|%ae|%ai" - rc, out = await _git(container, ["log", f"--format={fmt}", "--reverse"]) + fmt = "%H%x1f%s%x1f%an%x1f%ae%x1f%ai" + rc, out = await _git(container, ["log", f"--format={fmt}", "--reverse", "-z"]) if rc != 0: raise RuntimeError(f"git log failed: {out}") commits: List[dict] = [] for line in out.strip().splitlines(): - parts = line.split("|", 4) + parts = line.split("\x1f", 4) if len(parts) == 5: commits.append({ "sha": parts[0], @@ -104,7 +117,16 @@ async def git_diff(container: str, sha: str) -> str: return out -async def git_revert(container: str, sha: str) -> None: +async def git_revert(container: str, sha: str) -> str: + head_sha = (await _git(container, ["rev-parse", "HEAD"]))[1].strip() + if sha == head_sha: + return "noop" + await git_rev_parse(container, sha) + if not await git_merge_base_is_ancestor(container, sha): + raise RuntimeError(f"{sha} is not an ancestor of HEAD") + if await git_is_dirty(container): + raise RuntimeError("dirty_tree: working tree has uncommitted changes") rc, out = await _git(container, ["revert", "--no-edit", f"{sha}..HEAD"]) if rc != 0: raise RuntimeError(f"git revert failed for {sha}: {out}") + return "reverted" diff --git a/tinyagentos/deployer.py b/tinyagentos/deployer.py index b091809a5..75125c1f2 100644 --- a/tinyagentos/deployer.py +++ b/tinyagentos/deployer.py @@ -720,6 +720,8 @@ async def deploy_agent(req: DeployRequest) -> dict: # versioning. The repo lives at /root and covers the agent's text # state (workspace, memory, framework config). A .gitignore excludes # secrets and bulk artefacts so they never enter history. + versioning = True + versioning_error = None try: from tinyagentos.agent_git import ( git_init, @@ -734,33 +736,94 @@ async def deploy_agent(req: DeployRequest) -> dict: steps.append("git_init") except Exception as exc: logger.warning("Deploy %s: git init failed: %s", req.name, exc) + versioning = False + versioning_error = str(exc) # Step 4c: Install the auto-committer script and start it as a - # background loop inside the container. No LLM involvement. - try: - from pathlib import Path as _P - _committer = _P(__file__).parent / "scripts" / "agent_committer.py" - if _committer.exists(): - _push_rc, _push_out = await push_file( - container_name, - str(_committer), - "/root/.taos/agent_committer.py", - ) - if _push_rc == 0: - await exec_in_container( - container_name, ["chmod", "+x", "/root/.taos/agent_committer.py"] - ) - await exec_in_container( + # background loop inside the container. Prefer a systemd unit so it + # survives reboots; fall back to nohup when systemctl is absent. + if versioning: + try: + from pathlib import Path as _P + _committer = _P(__file__).parent / "scripts" / "agent_committer.py" + if _committer.exists(): + _push_rc, _push_out = await push_file( container_name, - [ - "bash", "-c", - "nohup python3 /root/.taos/agent_committer.py " - "> /root/.taos/committer.log 2>&1 &", - ], + str(_committer), + "/root/.taos/agent_committer.py", ) - steps.append("committer_installed") - except Exception as exc: - logger.warning("Deploy %s: committer install failed: %s", req.name, exc) + if _push_rc == 0: + await exec_in_container( + container_name, ["chmod", "+x", "/root/.taos/agent_committer.py"] + ) + _has_systemd = await exec_in_container( + container_name, ["bash", "-c", "command -v systemctl >/dev/null 2>&1 && echo yes || echo no"] + ) + _installed = False + if _has_systemd[0] == 0 and _has_systemd[1].strip() == "yes": + _unit = """\ +[Unit] +Description=taOS Agent Auto-Committer +After=network.target + +[Service] +Type=simple +ExecStart=/usr/bin/python3 /root/.taos/agent_committer.py +Restart=always +RestartSec=5 +Environment=AGENT_STATE_REPO=/root +Environment=COMMIT_INTERVAL=300 + +[Install] +WantedBy=multi-user.target +""" + import tempfile as _tf + with _tf.NamedTemporaryFile("w", suffix=".service", delete=False) as _tfh: + _tfh.write(_unit) + _unit_path = _tfh.name + try: + _unit_rc, _unit_out = await push_file( + container_name, + _unit_path, + "/etc/systemd/system/taos-agent-committer.service", + ) + finally: + os.unlink(_unit_path) + if _unit_rc == 0: + await exec_in_container( + container_name, + ["systemctl", "enable", "--now", "taos-agent-committer.service"], + ) + _active = await exec_in_container( + container_name, ["systemctl", "is-active", "taos-agent-committer.service"] + ) + if _active[0] == 0 and _active[1].strip() == "active": + steps.append("committer_installed") + _installed = True + else: + logger.warning( + "Deploy %s: committer systemd unit not active: %s", + req.name, _active[1].strip(), + ) + steps.append("committer_failed") + else: + logger.warning( + "Deploy %s: failed to push committer unit: %s", + req.name, _unit_out[-200:], + ) + if not _installed: + await exec_in_container( + container_name, + [ + "bash", "-c", + "nohup python3 /root/.taos/agent_committer.py " + "> /root/.taos/committer.log 2>&1 &", + ], + ) + steps.append("committer_installed_nohup") + except Exception as exc: + logger.warning("Deploy %s: committer install failed: %s", req.name, exc) + steps.append("committer_failed") # Step 5: Get container IP code, output = await exec_in_container(container_name, ["hostname", "-I"]) @@ -774,6 +837,8 @@ async def deploy_agent(req: DeployRequest) -> dict: "ip": container_ip, "llm_key": llm_key, "steps": steps, + "versioning": versioning, + "versioning_error": versioning_error, } except Exception as exc: diff --git a/tinyagentos/routes/agent_versions.py b/tinyagentos/routes/agent_versions.py index b376f9d4b..5b4c83937 100644 --- a/tinyagentos/routes/agent_versions.py +++ b/tinyagentos/routes/agent_versions.py @@ -5,10 +5,18 @@ same code works for both LXC and Docker backends. Routes ------ +------ GET /api/agents/{name}/versions — list commits GET /api/agents/{name}/versions/{sha}/diff — show patch for a commit POST /api/agents/{name}/versions/{sha}/revert — revert to a prior commit + +Revert status codes +------------------- +200 {status: "noop"} — sha is HEAD, nothing to do +200 {status: "reverted"} — success +400 — invalid sha format +404 — unknown revision +409 — sha not an ancestor of HEAD, or dirty tree """ from __future__ import annotations @@ -19,20 +27,26 @@ from fastapi.responses import JSONResponse from tinyagentos.agent_db import find_agent -from tinyagentos.agent_git import git_diff, git_log, git_revert +from tinyagentos.agent_git import git_diff, git_is_dirty, git_log, git_merge_base_is_ancestor, git_rev_parse, git_revert logger = logging.getLogger(__name__) router = APIRouter() -_SHA_RE = re.compile(r"^[0-9a-f]{4,40}$") +_SHA_RE = re.compile(r"^[0-9a-f]{7,40}$") +_REMOTE_RE = re.compile(r"^[A-Za-z0-9._-]+$") def _container_name(agent: dict) -> str: remote = agent.get("remote") name = agent["name"] container = f"taos-agent-{name}" - return f"{remote}:{container}" if remote else container + if remote: + if not _REMOTE_RE.match(remote): + logger.warning("_container_name: skipping invalid remote %r for agent %s", remote, name) + return container + return f"{remote}:{container}" + return container def _validate_sha(sha: str) -> JSONResponse | None: @@ -95,10 +109,24 @@ async def revert_version(request: Request, name: str, sha: str): container = _container_name(agent) try: - await git_revert(container, sha) + head_sha = (await git_rev_parse(container, "HEAD")).strip() + if sha == head_sha: + return {"agent": name, "sha": sha, "status": "noop"} + await git_rev_parse(container, sha) + if not await git_merge_base_is_ancestor(container, sha): + return JSONResponse( + {"error": f"{sha} is not an ancestor of HEAD"}, + status_code=409, + ) + if await git_is_dirty(container): + return JSONResponse( + {"error": "dirty_tree: working tree has uncommitted changes"}, + status_code=409, + ) + status = await git_revert(container, sha) except RuntimeError as exc: return JSONResponse({"error": str(exc)}, status_code=404) except Exception as exc: logger.warning("version revert failed for %s/%s: %s", name, sha, exc) return JSONResponse({"error": "container_unreachable"}, status_code=409) - return {"agent": name, "sha": sha, "status": "reverted"} + return {"agent": name, "sha": sha, "status": status} diff --git a/tinyagentos/scripts/agent_committer.py b/tinyagentos/scripts/agent_committer.py index 915eec43c..a7a91954b 100644 --- a/tinyagentos/scripts/agent_committer.py +++ b/tinyagentos/scripts/agent_committer.py @@ -31,15 +31,12 @@ def _is_dirty() -> bool: def _changed_summary() -> str: - rc, out, _ = _git("diff", "--cached", "--stat") + rc, out, _ = _git("diff", "--cached", "--name-only") if rc != 0 or not out.strip(): - rc, out, _ = _git("diff", "--stat") + rc, out, _ = _git("diff", "--name-only") lines = [l.strip() for l in out.strip().splitlines() if l.strip()] if not lines: return "auto-commit" - # Exclude the Git stat footer (e.g., "2 files changed") - if lines and "files changed" in lines[-1]: - lines = lines[:-1] if len(lines) == 1: return lines[0] return f"{len(lines)} files changed" @@ -51,16 +48,21 @@ def _commit() -> None: ts = time.strftime("%Y-%m-%d %H:%M:%S") summary = _changed_summary() message = f"auto: {ts} | {summary}" - _git("add", "-A") - _git("commit", "-m", message) + rc, out, err = _git("add", "-A") + if rc != 0: + raise RuntimeError(f"git add failed: {err or out}") + rc, out, err = _git("commit", "-m", message) + if rc != 0: + raise RuntimeError(f"git commit failed: {err or out}") def main() -> None: while True: try: _commit() - except Exception: - pass + except Exception as exc: + import sys + print(f"committer error: {exc}", file=sys.stderr) time.sleep(INTERVAL)