Implement versiond M1: inotify monitor, versioning, diff, restore, systemd install
FastAPI service on 127.0.0.1:9922 that monitors user-chosen directories with pruned inotify watches, stores versions in a local content-addressed spool indexed in SQLite, coalesces bursts (first + last), and offers history, diff and dry-run-first bulk restore. Includes a CLI with a systemd user unit installer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
3455499cea
commit
ddf09bea88
@@ -0,0 +1,176 @@
|
||||
"""End-to-end: real inotify monitor driven through the HTTP API."""
|
||||
|
||||
import base64
|
||||
import os
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from versiond.api import create_app
|
||||
from versiond.config import Config, Paths
|
||||
|
||||
CONFIG = """
|
||||
[coalesce]
|
||||
window_seconds = 0.2
|
||||
max_hold_seconds = 1.0
|
||||
versions_per_hour_per_path = 1000
|
||||
|
||||
[monitor]
|
||||
poll_interval_seconds = 0.5
|
||||
"""
|
||||
|
||||
|
||||
def wait_for(predicate, timeout=5.0):
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
result = predicate()
|
||||
if result:
|
||||
return result
|
||||
time.sleep(0.05)
|
||||
raise AssertionError("condition not met in time")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("VERSIOND_HOME", str(tmp_path / "home"))
|
||||
paths = Paths.resolve()
|
||||
paths.ensure()
|
||||
paths.config_file.write_text(CONFIG)
|
||||
cfg = Config.load(paths)
|
||||
project = tmp_path / "work" / "proj"
|
||||
(project / "src").mkdir(parents=True)
|
||||
(project / "pyproject.toml").write_text("[project]\nname='x'\n")
|
||||
(project / "src" / "app.py").write_text("print('v1')\n")
|
||||
(project / "node_modules" / "dep").mkdir(parents=True)
|
||||
(project / "node_modules" / "dep" / "index.js").write_text("x\n")
|
||||
(project / ".venv" / "lib").mkdir(parents=True)
|
||||
with TestClient(create_app(cfg), base_url="http://127.0.0.1:9922") as client:
|
||||
client.headers["Authorization"] = f"Bearer {cfg.api_token()}"
|
||||
yield client, project
|
||||
|
||||
|
||||
def history(client, path):
|
||||
r = client.get("/api/v1/files/history", params={"path": str(path)})
|
||||
return r.json()["versions"] if r.status_code == 200 else []
|
||||
|
||||
|
||||
def test_security(env):
|
||||
client, _ = env
|
||||
assert client.get("/api/v1/roots", headers={"Authorization": "Bearer nope"}).status_code == 401
|
||||
bad_host = client.get("/health", headers={"Host": "evil.example:9922"})
|
||||
assert bad_host.status_code == 421
|
||||
assert client.get("/openapi.json").status_code == 200
|
||||
|
||||
|
||||
def test_monitoring_end_to_end(env):
|
||||
client, project = env
|
||||
app_py = project / "src" / "app.py"
|
||||
|
||||
r = client.post("/api/v1/roots", json={"path": str(project.parent)})
|
||||
assert r.status_code == 201, r.text
|
||||
root = r.json()
|
||||
# node_modules and .venv are pruned before watches are added
|
||||
assert root["watch_count"] == 3 # work, proj, src
|
||||
wait_for(lambda: client.get(f"/api/v1/roots/{root['id']}").json()["baseline_state"] == "done")
|
||||
assert len(history(client, app_py)) == 1
|
||||
assert history(client, project / "node_modules" / "dep" / "index.js") == []
|
||||
|
||||
# modification is captured
|
||||
app_py.write_text("print('v2')\n")
|
||||
wait_for(lambda: len(history(client, app_py)) == 2)
|
||||
|
||||
# project detection found pyproject.toml
|
||||
projects = client.get("/api/v1/projects").json()
|
||||
assert any(p["path"] == str(project) and p["detected_by"] == "python" for p in projects)
|
||||
|
||||
# new directory: watched, and files inside captured
|
||||
(project / "pkg").mkdir()
|
||||
(project / "pkg" / "mod.py").write_text("x = 1\n")
|
||||
wait_for(lambda: len(history(client, project / "pkg" / "mod.py")) == 1)
|
||||
(project / "pkg" / "mod.py").write_text("x = 2\n")
|
||||
wait_for(lambda: len(history(client, project / "pkg" / "mod.py")) == 2)
|
||||
|
||||
# atomic save (write temp + rename) is captured, temp file is not
|
||||
tmp = project / "src" / "app.py.tmp"
|
||||
tmp.write_text("print('v3')\n")
|
||||
os.replace(tmp, app_py)
|
||||
wait_for(lambda: len(history(client, app_py)) == 3)
|
||||
|
||||
# rename keeps history
|
||||
renamed = project / "src" / "main.py"
|
||||
os.rename(app_py, renamed)
|
||||
wait_for(lambda: len(history(client, renamed)) == 3)
|
||||
|
||||
# directory rename moves history of everything inside
|
||||
os.rename(project / "pkg", project / "lib")
|
||||
wait_for(lambda: len(history(client, project / "lib" / "mod.py")) == 2)
|
||||
(project / "lib" / "mod.py").write_text("x = 3\n")
|
||||
wait_for(lambda: len(history(client, project / "lib" / "mod.py")) == 3)
|
||||
|
||||
# delete is recorded, history kept
|
||||
os.unlink(renamed)
|
||||
wait_for(lambda: client.get("/api/v1/files/history", params={"path": str(renamed)}).json()["file"]["exists_on_disk"] == 0)
|
||||
|
||||
# diff between versions
|
||||
versions = history(client, project / "lib" / "mod.py")
|
||||
diff = client.get("/api/v1/diff", params={"from": versions[-1]["id"], "to": str(versions[0]["id"])})
|
||||
assert "-x = 1" in diff.text and "+x = 3" in diff.text
|
||||
|
||||
|
||||
def test_restore_plan_and_execute(env):
|
||||
client, project = env
|
||||
app_py = project / "src" / "app.py"
|
||||
root = client.post("/api/v1/roots", json={"path": str(project)}).json()
|
||||
wait_for(lambda: client.get(f"/api/v1/roots/{root['id']}").json()["baseline_state"] == "done")
|
||||
time.sleep(0.05)
|
||||
checkpoint = time.time()
|
||||
time.sleep(0.05)
|
||||
app_py.write_text("broken by agent\n")
|
||||
wait_for(lambda: len(history(client, app_py)) == 2)
|
||||
|
||||
plan = client.post("/api/v1/restores", json={"globs": [str(project / "**")], "as_of": checkpoint})
|
||||
assert plan.status_code == 201, plan.text
|
||||
plan = plan.json()
|
||||
actions = {a["path"]: a["action"] for a in plan["actions"]}
|
||||
assert actions[str(app_py)] == "overwrite"
|
||||
assert app_py.read_text() == "broken by agent\n" # dry run changed nothing
|
||||
|
||||
result = client.post(f"/api/v1/restores/{plan['plan_id']}/execute").json()
|
||||
assert app_py.read_text() == "print('v1')\n"
|
||||
assert any(r["result"] == "restored" for r in result["results"])
|
||||
# executing twice is refused
|
||||
assert client.post(f"/api/v1/restores/{plan['plan_id']}/execute").status_code == 409
|
||||
# the broken state is still in history (pre-restore), and the monitor did not add a duplicate
|
||||
time.sleep(0.5)
|
||||
contents = [client.get(f"/api/v1/versions/{v['id']}/content").text for v in history(client, app_py)]
|
||||
assert contents[0] == "print('v1')\n"
|
||||
assert "broken by agent\n" in contents
|
||||
|
||||
|
||||
def test_push_snapshots_and_limits(env):
|
||||
client, project = env
|
||||
path = project / "src" / "pushed.py"
|
||||
body = {"path": str(path), "content_b64": base64.b64encode(b"a = 1\n").decode(), "source": "claude-agent"}
|
||||
assert client.post("/api/v1/snapshots", json=body).json()["status"] == "committed"
|
||||
assert client.post("/api/v1/snapshots", json=body).json()["status"] == "unchanged"
|
||||
|
||||
big = {**body, "content_b64": base64.b64encode(b"x" * 204_801).decode()}
|
||||
r = client.post("/api/v1/snapshots", json=big)
|
||||
assert r.status_code == 413 and r.headers["content-type"] == "application/problem+json"
|
||||
|
||||
ignored = {**body, "path": str(project / "node_modules" / "x.js")}
|
||||
assert client.post("/api/v1/snapshots", json=ignored).status_code == 422
|
||||
|
||||
|
||||
def test_restore_refuses_unsafe_paths(env):
|
||||
client, project = env
|
||||
client.post("/api/v1/roots", json={"path": str(project)})
|
||||
path = project / "src" / "app.py"
|
||||
wait_for(lambda: history(client, path))
|
||||
vid = history(client, path)[0]["id"]
|
||||
r = client.post(f"/api/v1/versions/{vid}/restore", json={"target_path": "/etc/versiond-test"})
|
||||
assert r.status_code == 400 and "unsafe" in r.text
|
||||
os.symlink("/etc", project / "escape")
|
||||
r = client.post(f"/api/v1/versions/{vid}/restore", json={"target_path": str(project / "escape" / "x")})
|
||||
assert r.status_code == 400
|
||||
Reference in New Issue
Block a user