diff --git a/apps/api/core/agent_versions.py b/apps/api/core/agent_versions.py new file mode 100644 index 0000000..0c3f2a6 --- /dev/null +++ b/apps/api/core/agent_versions.py @@ -0,0 +1,94 @@ +"""core/agent_versions.py — code-editing v1, Step 3: agent version data layer. + +Read/write helpers over the agent_versions table + hosted_agents.active_version_id. +PURE DATA ACCESS — no build, no dispatch. Dispatch still reads hosted_agents.code and +is unaffected; Step 4 wires the save→redeploy flow to create + build + activate versions. +""" +from __future__ import annotations + +import json + +PYTHON_ENTRYPOINT = "agent.py" +NODE_ENTRYPOINT = "agent.ts" + + +def entrypoint_for_runtime(runtime: str) -> str: + """The entrypoint filename for a runtime — the file dispatch runs + PARAMS extracts.""" + return NODE_ENTRYPOINT if (runtime or "").startswith("node") else PYTHON_ENTRYPOINT + + +def _parse(v): + return json.loads(v) if isinstance(v, (str, bytes)) else v + + +def _row(r) -> dict | None: + if r is None: + return None + d = dict(r) + for k in ("files", "requirements", "params_schema"): + if k in d: + d[k] = _parse(d[k]) + for k in ("id", "agent_id"): + if d.get(k) is not None: + d[k] = str(d[k]) + if d.get("created_at") is not None and hasattr(d["created_at"], "isoformat"): + d["created_at"] = d["created_at"].isoformat() + return d + + +async def next_version_no(db, agent_id) -> int: + n = await db.fetchval( + "SELECT COALESCE(MAX(version_no), 0) + 1 FROM agent_versions WHERE agent_id = $1::uuid", + str(agent_id)) + return int(n or 1) + + +async def create_version(db, agent_id, files: dict, *, requirements=None, params_schema=None, + image_ref: str | None = None, status: str = "building") -> dict: + """Insert a new immutable version (next version_no). Returns the parsed row.""" + version_no = await next_version_no(db, agent_id) + row = await db.fetchrow( + """INSERT INTO agent_versions + (agent_id, version_no, files, requirements, params_schema, image_ref, status) + VALUES ($1::uuid, $2, $3, $4, $5, $6, $7) + RETURNING *""", + str(agent_id), version_no, json.dumps(files), + json.dumps(requirements) if requirements is not None else None, + json.dumps(params_schema) if params_schema is not None else None, + image_ref, status) + return _row(row) + + +async def get_version(db, version_id) -> dict | None: + return _row(await db.fetchrow( + "SELECT * FROM agent_versions WHERE id = $1::uuid", str(version_id))) + + +async def get_active_version(db, agent_id) -> dict | None: + """The agent's currently-active version (via hosted_agents.active_version_id).""" + return _row(await db.fetchrow( + """SELECT av.* FROM agent_versions av + JOIN hosted_agents h ON h.active_version_id = av.id + WHERE h.id = $1::uuid""", str(agent_id))) + + +async def list_versions(db, agent_id) -> list: + """Version history (newest first), without the file bodies.""" + rows = await db.fetch( + """SELECT id, version_no, status, image_ref, created_at + FROM agent_versions WHERE agent_id = $1::uuid + ORDER BY version_no DESC""", str(agent_id)) + return [_row(r) for r in rows] + + +async def activate_version(db, agent_id, version_id) -> None: + """Repoint active_version_id (post-build activation or rollback). Verifies the + version belongs to the agent, so one agent can't be pointed at another's version.""" + updated = await db.fetchval( + """UPDATE hosted_agents h SET active_version_id = $2::uuid, updated_at = NOW() + WHERE h.id = $1::uuid + AND EXISTS (SELECT 1 FROM agent_versions av + WHERE av.id = $2::uuid AND av.agent_id = $1::uuid) + RETURNING h.id""", str(agent_id), str(version_id)) + if not updated: + raise ValueError(f"version {version_id} does not belong to agent {agent_id}") diff --git a/apps/api/main.py b/apps/api/main.py index 7374c34..33a5d88 100644 --- a/apps/api/main.py +++ b/apps/api/main.py @@ -1148,6 +1148,48 @@ async def lifespan(app: FastAPI): ALTER TABLE agent_runs ADD COLUMN IF NOT EXISTS params JSONB """) + # Migration 069: multi-file model + versioning (code-editing Step 3). + # Mirrored in infra/migrations/069_agent_versions.sql. Idempotent backfill + # (NOT EXISTS + active_version_id IS NULL) so re-running is a no-op. Dispatch + # is unchanged — nothing reads active_version_id/agent_versions yet (Step 4). + await _mconn.execute(""" + CREATE TABLE IF NOT EXISTS agent_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + agent_id UUID NOT NULL REFERENCES hosted_agents(id) ON DELETE CASCADE, + version_no INTEGER NOT NULL, + files JSONB NOT NULL, + requirements JSONB, + params_schema JSONB, + image_ref TEXT, + status TEXT NOT NULL DEFAULT 'active', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (agent_id, version_no) + ) + """) + await _mconn.execute(""" + CREATE INDEX IF NOT EXISTS idx_agent_versions_agent + ON agent_versions(agent_id, version_no DESC) + """) + await _mconn.execute(""" + ALTER TABLE hosted_agents + ADD COLUMN IF NOT EXISTS active_version_id UUID REFERENCES agent_versions(id) + """) + await _mconn.execute(""" + INSERT INTO agent_versions (agent_id, version_no, files, params_schema, status, created_at) + SELECT h.id, 1, + jsonb_build_object( + CASE WHEN h.runtime LIKE 'node%' THEN 'agent.ts' ELSE 'agent.py' END, + COALESCE(h.code, '')), + h.params_schema, 'active', COALESCE(h.created_at, NOW()) + FROM hosted_agents h + WHERE NOT EXISTS (SELECT 1 FROM agent_versions av WHERE av.agent_id = h.id) + """) + await _mconn.execute(""" + UPDATE hosted_agents h + SET active_version_id = av.id + FROM agent_versions av + WHERE av.agent_id = h.id AND av.version_no = 1 AND h.active_version_id IS NULL + """) await _mconn.execute(""" CREATE INDEX IF NOT EXISTS idx_hosted_agents_next_run ON hosted_agents(next_run_at) diff --git a/apps/api/pytest.ini b/apps/api/pytest.ini index ad2520a..aff24d7 100644 --- a/apps/api/pytest.ini +++ b/apps/api/pytest.ini @@ -31,5 +31,6 @@ python_files = test_suite_v060.py test_suite_v062.py test_suite_v0610.py test_se test_templates_params.py test_agent_deps.py test_sandbox_egress.py + test_agent_versions.py markers = no_api_key: test does not require WAYFORTH_TEST_API_KEY (e.g. probes unauthenticated paths) diff --git a/apps/api/tests/test_agent_versions.py b/apps/api/tests/test_agent_versions.py new file mode 100644 index 0000000..eca8363 --- /dev/null +++ b/apps/api/tests/test_agent_versions.py @@ -0,0 +1,133 @@ +"""test_agent_versions.py — code-editing v1, Step 3 (version data layer). + +Pure helpers over agent_versions + hosted_agents.active_version_id, tested with a fake +asyncpg-style db (JSONB comes back as text, like the real driver). +""" +from __future__ import annotations + +import json + +import pytest + +from core import agent_versions as av + + +class FakeDB: + """Returns canned results per method; records calls (q, args).""" + def __init__(self, fetchval=None, fetchrow=None, fetch=None): + self._fv, self._fr, self._f = fetchval, fetchrow, fetch + self.calls = [] + + async def fetchval(self, q, *a): + self.calls.append(("fetchval", q, a)) + return self._fv(q, a) if callable(self._fv) else self._fv + + async def fetchrow(self, q, *a): + self.calls.append(("fetchrow", q, a)) + return self._fr(q, a) if callable(self._fr) else self._fr + + async def fetch(self, q, *a): + self.calls.append(("fetch", q, a)) + return self._f(q, a) if callable(self._f) else self._f + + +AGENT = "11111111-1111-4111-8111-111111111111" +VID = "22222222-2222-4222-8222-222222222222" + + +# ── entrypoint by runtime ─────────────────────────────────────────────────────── + +@pytest.mark.parametrize("runtime,expected", [ + ("python3.12", "agent.py"), ("python", "agent.py"), + ("node20", "agent.ts"), ("node", "agent.ts"), (None, "agent.py")]) +def test_entrypoint_for_runtime(runtime, expected): + assert av.entrypoint_for_runtime(runtime) == expected + + +# ── JSONB parsing (driver returns text) ───────────────────────────────────────── + +def test_row_parses_jsonb_text_and_casts_ids(): + raw = {"id": "x", "agent_id": "y", "version_no": 2, + "files": json.dumps({"agent.py": "print(1)"}), + "requirements": json.dumps([{"name": "httpx"}]), + "params_schema": None, "status": "active"} + d = av._row(raw) + assert d["files"] == {"agent.py": "print(1)"} + assert d["requirements"] == [{"name": "httpx"}] + assert d["params_schema"] is None + assert d["id"] == "x" and d["agent_id"] == "y" + + +def test_row_none(): + assert av._row(None) is None + + +# ── next_version_no ───────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_next_version_no(): + db = FakeDB(fetchval=3) + assert await av.next_version_no(db, AGENT) == 3 + + +# ── create_version ────────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_create_version_inserts_with_next_no_and_serializes(): + inserted = {"id": VID, "agent_id": AGENT, "version_no": 2, + "files": json.dumps({"agent.py": "x"}), "requirements": None, + "params_schema": None, "image_ref": None, "status": "building"} + db = FakeDB(fetchval=2, fetchrow=inserted) + out = await av.create_version(db, AGENT, {"agent.py": "x"}) + assert out["version_no"] == 2 and out["files"] == {"agent.py": "x"} + # files passed to the INSERT as a JSON string (not a dict) + insert_call = [c for c in db.calls if c[0] == "fetchrow"][0] + args = insert_call[2] + assert json.loads(args[2]) == {"agent.py": "x"} # $3 = files json + assert args[1] == 2 # $2 = version_no (from next_version_no) + + +@pytest.mark.asyncio +async def test_create_version_serializes_requirements_and_schema(): + db = FakeDB(fetchval=1, fetchrow={"id": VID, "agent_id": AGENT, "version_no": 1, + "files": "{}", "requirements": "[]", "params_schema": "null", "status": "building"}) + await av.create_version(db, AGENT, {}, requirements=[{"name": "httpx", "version": "0.28.1"}], + params_schema={"fields": []}, image_ref="snap-1") + args = [c for c in db.calls if c[0] == "fetchrow"][0][2] + assert json.loads(args[3]) == [{"name": "httpx", "version": "0.28.1"}] # requirements + assert json.loads(args[4]) == {"fields": []} # params_schema + assert args[5] == "snap-1" # image_ref + + +# ── get_active_version / list_versions ────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_get_active_version_parses(): + db = FakeDB(fetchrow={"id": VID, "agent_id": AGENT, "version_no": 1, + "files": json.dumps({"agent.py": "y"}), "status": "active"}) + out = await av.get_active_version(db, AGENT) + assert out["files"] == {"agent.py": "y"} and out["version_no"] == 1 + + +@pytest.mark.asyncio +async def test_list_versions_newest_first(): + rows = [{"id": "b", "version_no": 2, "status": "active", "image_ref": None, "created_at": None}, + {"id": "a", "version_no": 1, "status": "active", "image_ref": None, "created_at": None}] + db = FakeDB(fetch=rows) + out = await av.list_versions(db, AGENT) + assert [v["version_no"] for v in out] == [2, 1] + + +# ── activate_version: ownership-guarded ───────────────────────────────────────── + +@pytest.mark.asyncio +async def test_activate_version_ok(): + db = FakeDB(fetchval=AGENT) # UPDATE … RETURNING id → a row → success + await av.activate_version(db, AGENT, VID) # no raise + + +@pytest.mark.asyncio +async def test_activate_version_rejects_foreign_version(): + db = FakeDB(fetchval=None) # RETURNING nothing → version not owned by agent + with pytest.raises(ValueError, match="does not belong"): + await av.activate_version(db, AGENT, VID) diff --git a/infra/migrations/069_agent_versions.sql b/infra/migrations/069_agent_versions.sql new file mode 100644 index 0000000..67b17a1 --- /dev/null +++ b/infra/migrations/069_agent_versions.sql @@ -0,0 +1,48 @@ +-- 069_agent_versions.sql — code-editing v1, Step 3: multi-file model + versioning. +-- +-- Pure data layer. Adds an immutable per-version record (files + requirements + +-- params_schema + the built image_ref) and an active-version pointer on hosted_agents. +-- Dispatch still reads hosted_agents.code and is UNCHANGED — nothing reads +-- active_version_id/agent_versions yet (Step 4 wires the redeploy to use them). +-- +-- Reversible: DROP the column + table; hosted_agents.code is never touched, so the +-- backfill is purely additive and removable. + +CREATE TABLE IF NOT EXISTS agent_versions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + agent_id UUID NOT NULL REFERENCES hosted_agents(id) ON DELETE CASCADE, + version_no INTEGER NOT NULL, + files JSONB NOT NULL, -- {path: content}; entrypoint agent.py (py) / agent.ts (node) + requirements JSONB, -- pinned deps for the build; NULL = none + params_schema JSONB, -- schema extracted from the entrypoint for this version + image_ref TEXT, -- per-version snapshot ref (Step 1 build); NULL until built + status TEXT NOT NULL DEFAULT 'active', -- building | active | failed + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (agent_id, version_no) +); + +CREATE INDEX IF NOT EXISTS idx_agent_versions_agent + ON agent_versions(agent_id, version_no DESC); + +ALTER TABLE hosted_agents + ADD COLUMN IF NOT EXISTS active_version_id UUID REFERENCES agent_versions(id); + +-- Backfill: each existing agent's single-file code -> one v1 version row. Entrypoint +-- filename is chosen by runtime so node agents keep agent.ts. params_schema carried +-- over; no requirements. hosted_agents.code is left untouched -> dispatch is identical. +-- Idempotent (NOT EXISTS guard + active_version_id IS NULL), so check_db can re-run it. +INSERT INTO agent_versions (agent_id, version_no, files, params_schema, status, created_at) +SELECT h.id, 1, + jsonb_build_object( + CASE WHEN h.runtime LIKE 'node%' THEN 'agent.ts' ELSE 'agent.py' END, + COALESCE(h.code, '')), + h.params_schema, + 'active', + COALESCE(h.created_at, NOW()) +FROM hosted_agents h +WHERE NOT EXISTS (SELECT 1 FROM agent_versions av WHERE av.agent_id = h.id); + +UPDATE hosted_agents h +SET active_version_id = av.id +FROM agent_versions av +WHERE av.agent_id = h.id AND av.version_no = 1 AND h.active_version_id IS NULL;