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..348fee9c0 --- /dev/null +++ b/changelog.d/tsk-fjmxzo-agent-state-versioning.md @@ -0,0 +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_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..4909805dd --- /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..0959451e3 --- /dev/null +++ b/tinyagentos/agent_git.py @@ -0,0 +1,109 @@ +"""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: + raise RuntimeError(f"git log failed: {out}") + 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-edit", sha]) + if rc != 0: + raise RuntimeError(f"git revert failed for {sha}: {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..915eec43c --- /dev/null +++ b/tinyagentos/scripts/agent_committer.py @@ -0,0 +1,68 @@ +#!/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" + # 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" + + +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()