Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions apps/api/core/agent_versions.py
Original file line number Diff line number Diff line change
@@ -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}")
42 changes: 42 additions & 0 deletions apps/api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions apps/api/pytest.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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)
133 changes: 133 additions & 0 deletions apps/api/tests/test_agent_versions.py
Original file line number Diff line number Diff line change
@@ -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)
48 changes: 48 additions & 0 deletions infra/migrations/069_agent_versions.sql
Original file line number Diff line number Diff line change
@@ -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;
Loading