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:
retoor
2026-10-08 18:46:12 +02:00
co-authored by Claude Opus 5.5
parent 3455499cea
commit ddf09bea88
17 changed files with 2932 additions and 5 deletions
+176
View File
@@ -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
+100
View File
@@ -0,0 +1,100 @@
import asyncio
from pathlib import PurePath
from versiond.db import Database
from versiond.diff import hunks, unified
from versiond.filters import Filters
from versiond.ingest import Coalescer, Repository
from versiond.store import BlobStore
def test_filters_paths():
f = Filters()
ok = ["app.py", "src/main.go", ".env", ".env.production", "Makefile", "pkg/package.json", ".gitignore"]
for p in ok:
assert f.path_reason(PurePath(p)) is None, p
rejected = {
"node_modules/x/index.js": "ignored-directory",
".git/config": "ignored-directory",
"src/__pycache__/a.cpython-312.pyc": "ignored-directory",
"a.pyc": "ignored-extension",
".bashrc_local": "hidden-file",
"notes.txt~": "temporary-file",
"main.py.swp": "ignored-extension",
"venv/lib/x.py": "ignored-directory",
"target/debug/build.rs": "ignored-directory",
"4913": "temporary-file",
"src/pkg.egg-info/PKG-INFO": "ignored-directory",
}
for p, reason in rejected.items():
assert f.path_reason(PurePath(p)) == reason, p
assert f.size_reason(204_800) is None
assert f.size_reason(204_801) == "file-too-large"
assert f.content_reason(b"abc\x00def") == "binary-content"
def test_filter_patterns():
f = Filters(patterns=("*.log", "secret/*"))
assert f.path_reason(PurePath("x/debug.log")) == "ignored-pattern"
assert f.path_reason(PurePath("secret/key.txt")) == "ignored-pattern"
assert f.path_reason(PurePath("src/key.txt")) is None
def test_diff_unified():
a = b"one\ntwo\nthree\n"
b = b"one\n2\nthree\nfour\n"
out = unified(hunks(a, b), "a", "b")
assert "-two\n+2\n" in out and "+four\n" in out
assert hunks(b"x y\n", b"x y\n", ignore_whitespace=True) == []
def _repo(tmp_path):
db = Database(tmp_path / "index.sqlite")
return Repository(db, BlobStore(tmp_path / "spool"), Filters())
def test_coalescer_keeps_first_and_last(tmp_path):
repo = _repo(tmp_path)
path = str(tmp_path / "f.py")
async def run():
c = Coalescer(repo, window=0.2, max_hold=1.0, per_hour=100)
statuses = [c.submit(path, f"v{i}\n".encode(), "test", "edit")["status"] for i in range(6)]
await asyncio.sleep(0.5)
return statuses
statuses = asyncio.run(run())
assert statuses[0] == "committed"
assert set(statuses[1:]) == {"coalesced"}
file = repo.db.file_by_path(path)
contents = [repo.blobs.get(v["blob_sha256"]) for v in reversed(repo.db.history(file["id"]))]
assert contents == [b"v0\n", b"v5\n"]
def test_coalescer_rate_limit_keeps_newest(tmp_path):
repo = _repo(tmp_path)
path = str(tmp_path / "f.py")
async def run():
c = Coalescer(repo, window=0.05, max_hold=0.1, per_hour=2)
c.submit(path, b"a", "t", "e")
await asyncio.sleep(0.1)
c.submit(path, b"b", "t", "e")
await asyncio.sleep(0.2)
status = c.submit(path, b"c", "t", "e")["status"]
await asyncio.sleep(0.2)
flushed = c.flush()
return status, flushed
status, flushed = asyncio.run(run())
assert status == "coalesced"
assert flushed == 1 # over the limit it waits, and shutdown still stores it
file = repo.db.file_by_path(path)
assert repo.blobs.get(repo.db.latest_version(file["id"])["blob_sha256"]) == b"c"
def test_dedupe_unchanged(tmp_path):
repo = _repo(tmp_path)
path = str(tmp_path / "f.py")
assert repo.commit(path, b"x", "t", "e")["status"] == "committed"
assert repo.commit(path, b"x", "t", "e")["status"] == "unchanged"