Files
2026-07-13 13:12:00 +08:00

433 lines
17 KiB
Python

"""
Integration tests for git worktree creation on ``POST /v1/sessions``.
Drives the JSON create path with a `git` block through the full app and
a fake host that auto-replies to the worktree control frames. Verifies
that the request's branch_name + base_branch reach the host's
``host.create_worktree`` frame, and that the created worktree path and
branch are persisted on the session. See designs/SESSION_GIT_WORKTREE.md.
"""
from __future__ import annotations
import asyncio
import contextlib
from collections.abc import AsyncIterator, Callable
from dataclasses import dataclass, field
from typing import Any
import httpx
import pytest
import pytest_asyncio
from fastapi import FastAPI
from omnigent.host.frames import (
HostCreateWorktreeFrame,
HostHelloFrame,
HostRemoveWorktreeFrame,
HostStatFrame,
decode_host_frame,
)
from omnigent.server.auth import RESERVED_USER_LOCAL
from omnigent.server.host_registry import HostConnection
from omnigent.stores.host_store import HostStore
from tests.server.helpers import create_test_agent
pytestmark = pytest.mark.asyncio
_HOST_ID = "host_wt_create"
_SOURCE_REPO = "/Users/alice/myrepo"
class _FakeWebSocket:
"""Minimal WebSocket stand-in (the registry only enqueues)."""
async def send_text(self, data: str) -> None:
"""No-op send — frames flow through the outbound queue.
:param data: JSON-encoded frame text (ignored).
"""
@dataclass
class _HostCapture:
"""
Frames a fake host received during one ``POST /v1/sessions`` create.
:param create: ``host.create_worktree`` frames received.
:param remove: ``host.remove_worktree`` frames received (a non-empty
list proves the create-rollback path fired).
"""
create: list[HostCreateWorktreeFrame] = field(default_factory=list)
remove: list[HostRemoveWorktreeFrame] = field(default_factory=list)
# Factory yielded by the ``register_worktree_host`` fixture:
# register(*, create_status=, create_error=) -> _HostCapture.
RegisterHost = Callable[..., _HostCapture]
@pytest_asyncio.fixture()
async def register_worktree_host(
app: FastAPI,
db_uri: str,
) -> AsyncIterator[RegisterHost]:
"""Yield a factory that registers a fake host with a replying drain.
The drain answers ``host.stat`` (so workspace validation passes) and
``host.create_worktree`` (capturing each frame). Every drain started
during the test is poisoned and awaited at teardown, so no background
task leaks into the next test's event loop (mirrors the cleanup in
``test_host_worktree.py``).
:param app: App whose ``host_registry`` to register into.
:param db_uri: DB URI so the ``host_id`` FK target row exists.
:returns: Async iterator yielding a ``register`` factory. Its
kwargs: ``create_status`` (``"ok"`` returns a worktree path,
``"failed"`` simulates a host git failure such as a bad base
ref) and ``create_error`` (the failure message). Returns a
``_HostCapture`` whose ``.create`` / ``.remove`` lists accumulate
the create- and remove-worktree frames the host received.
"""
conns: list[HostConnection] = []
def _register(*, create_status: str = "ok", create_error: str | None = None) -> _HostCapture:
HostStore(db_uri).upsert_on_connect(_HOST_ID, "wt-host", RESERVED_USER_LOCAL)
conn = app.state.host_registry.register(
host_id=_HOST_ID,
ws=_FakeWebSocket(), # type: ignore[arg-type] — duck-typed
hello=HostHelloFrame(version="0.1.0-test", frame_protocol_version=1, name="wt-host"),
owner=RESERVED_USER_LOCAL,
)
cap = _HostCapture()
async def _drain() -> None:
"""Answer stat + create/remove-worktree frames; capture them."""
while True:
frame_text = await conn.outbound_queue.get()
if frame_text is None:
return
frame = decode_host_frame(frame_text)
if isinstance(frame, HostStatFrame):
fut = conn.pending_stats.pop(frame.request_id, None)
if fut is not None and not fut.done():
fut.set_result(
{
"status": "ok",
"exists": True,
"type": "directory",
"canonical_path": frame.path,
"error": None,
}
)
elif isinstance(frame, HostCreateWorktreeFrame):
cap.create.append(frame)
fut = conn.pending_create_worktrees.pop(frame.request_id, None)
if fut is not None and not fut.done():
if create_status == "ok":
dirname = frame.branch_name.replace("/", "-")
fut.set_result(
{
"status": "ok",
"worktree_path": f"{frame.repo_path}-worktrees/{dirname}",
"branch": frame.branch_name,
"error": None,
}
)
else:
fut.set_result(
{
"status": "failed",
"worktree_path": None,
"branch": None,
"error": create_error,
}
)
elif isinstance(frame, HostRemoveWorktreeFrame):
cap.remove.append(frame)
fut = conn.pending_remove_worktrees.pop(frame.request_id, None)
if fut is not None and not fut.done():
fut.set_result({"status": "ok", "error": None})
conn._drain_task_for_test = asyncio.create_task(_drain()) # type: ignore[attr-defined]
conns.append(conn)
return cap
yield _register
# Poison each queue so the drain returns, then await/cancel it.
for conn in conns:
conn.outbound_queue.put_nowait(None)
task = conn._drain_task_for_test # type: ignore[attr-defined]
with contextlib.suppress(asyncio.CancelledError, asyncio.TimeoutError, Exception):
await asyncio.wait_for(asyncio.shield(task), timeout=1.0)
if not task.done():
task.cancel()
async def _create_git_session(
client: httpx.AsyncClient,
agent_id: str,
git: dict[str, Any],
) -> httpx.Response:
"""POST a JSON session-create with a ``git`` block.
:param client: The test HTTP client.
:param agent_id: Agent to bind.
:param git: The ``git`` block, e.g.
``{"branch_name": "feature/x", "base_branch": "main"}``.
:returns: The raw create response.
"""
return await client.post(
"/v1/sessions",
json={
"agent_id": agent_id,
"host_id": _HOST_ID,
"workspace": _SOURCE_REPO,
"git": git,
},
)
async def test_create_passes_branch_and_base_branch_to_host(
register_worktree_host: RegisterHost,
client: httpx.AsyncClient,
) -> None:
"""The request's branch_name + base_branch reach host.create_worktree,
and the resulting worktree path + branch are persisted on the session.
Proves the server route threads ``git.base_branch`` through
``_create_session_worktree`` → ``create_worktree_on_host`` → the
frame. If base_branch were dropped on the route, the captured
frame's base_branch would be ``None`` and this fails.
"""
cap = register_worktree_host()
agent = await create_test_agent(client, name="wt-create-agent")
resp = await _create_git_session(
client, agent["id"], {"branch_name": "feature/login", "base_branch": "main"}
)
assert resp.status_code == 201, resp.text
# The host received exactly one create-worktree frame carrying both
# the new branch and the requested base ref.
assert len(cap.create) == 1, f"expected one create_worktree frame, got {len(cap.create)}"
frame = cap.create[0]
assert frame.repo_path == _SOURCE_REPO
assert frame.branch_name == "feature/login"
assert frame.base_branch == "main"
# The returned worktree path becomes the session workspace, and the
# branch is persisted (drives sidebar display + delete cleanup).
body = resp.json()
assert body["git_branch"] == "feature/login"
assert body["workspace"] == f"{_SOURCE_REPO}-worktrees/feature-login"
async def test_create_without_base_branch_sends_none(
register_worktree_host: RegisterHost,
client: httpx.AsyncClient,
) -> None:
"""Omitting base_branch sends ``None`` to the host (branch from HEAD).
Pairs with the test above to pin both directions: a provided base
threads through, an omitted one stays ``None`` so the host branches
from the source repo's current HEAD.
"""
cap = register_worktree_host()
agent = await create_test_agent(client, name="wt-create-agent-2")
resp = await _create_git_session(client, agent["id"], {"branch_name": "wip"})
assert resp.status_code == 201, resp.text
assert len(cap.create) == 1
assert cap.create[0].branch_name == "wip"
assert cap.create[0].base_branch is None
async def test_create_with_invalid_base_branch_fails_400(
register_worktree_host: RegisterHost,
client: httpx.AsyncClient,
) -> None:
"""An invalid base branch fails the create with 400 INVALID_INPUT.
The host rejects the bad base ref (``host.create_worktree`` →
``status: failed``); the server maps that to INVALID_INPUT (400),
NOT 500 — it's user-correctable input — and surfaces the host's
reason. Worktree creation fails before ``create_conversation``, so
no session row is created (the response carries no session id).
"""
register_worktree_host(
create_status="failed",
create_error="base branch does not exist: nope-not-a-branch",
)
agent = await create_test_agent(client, name="wt-bad-base-agent")
resp = await _create_git_session(
client,
agent["id"],
{"branch_name": "feature/x", "base_branch": "nope-not-a-branch"},
)
# 400 (not 500): a bad base ref is user input, not a server fault.
assert resp.status_code == 400, resp.text
body = resp.json()
assert body["error"]["code"] == "invalid_input"
# The host's reason is surfaced verbatim so the UI can show it.
assert "base branch does not exist" in body["error"]["message"]
async def test_create_with_existing_worktree_persists_without_creating(
register_worktree_host: RegisterHost,
client: httpx.AsyncClient,
) -> None:
"""Starting in an existing worktree persists its branch, creates nothing.
``git.existing_worktree`` binds the session straight to a
pre-existing worktree directory: no create-worktree frame is sent
to the host, and ``branch_name`` is persisted as ``git_branch`` so
the sidebar shows it and the opt-in delete flow can offer to remove it.
"""
cap = register_worktree_host()
agent = await create_test_agent(client, name="wt-existing-agent")
resp = await client.post(
"/v1/sessions",
json={
"agent_id": agent["id"],
"host_id": _HOST_ID,
"workspace": _SOURCE_REPO,
"git": {"branch_name": "feature/existing", "existing_worktree": True},
},
)
assert resp.status_code == 201, resp.text
# No worktree was created — the host received no create frame.
assert len(cap.create) == 0, f"expected no create_worktree frame, got {len(cap.create)}"
# The existing worktree's branch is persisted; the workspace is the
# supplied directory verbatim (no worktree-path rewrite).
body = resp.json()
assert body["git_branch"] == "feature/existing"
assert body["workspace"] == _SOURCE_REPO
async def test_create_with_invalid_existing_worktree_branch_fails_400(
register_worktree_host: RegisterHost,
client: httpx.AsyncClient,
) -> None:
"""An invalid bind-mode ``branch_name`` fails the create with 400.
The host never runs git for this path, so the server is the only
gate on the name; a malformed branch is user-correctable input and
maps to INVALID_INPUT (400), not 500.
"""
register_worktree_host()
agent = await create_test_agent(client, name="wt-existing-bad-agent")
resp = await client.post(
"/v1/sessions",
json={
"agent_id": agent["id"],
"host_id": _HOST_ID,
"workspace": _SOURCE_REPO,
"git": {"branch_name": "bad..branch", "existing_worktree": True},
},
)
assert resp.status_code == 400, resp.text
body = resp.json()
assert body["error"]["code"] == "invalid_input"
# The failed create returned an error, not a session.
assert "id" not in body
async def test_create_failure_never_removes_existing_worktree(
register_worktree_host: RegisterHost,
client: httpx.AsyncClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A create_conversation failure must NOT destroy the user's worktree.
Regression: the ``existing_worktree`` bind path sets ``git_branch``
for a *pre-existing* worktree without Omnigent creating one. The
create-rollback (``git worktree remove --force`` + ``git branch -D``)
is gated on Omnigent having created a worktree, NOT on ``git_branch``
being set — otherwise a persistence failure would force-remove the
user's own worktree and delete their branch. Assert no remove frame
is sent when ``create_conversation`` raises on this path.
"""
from omnigent.stores.conversation_store.sqlalchemy_store import (
SqlAlchemyConversationStore,
)
cap = register_worktree_host()
agent = await create_test_agent(client, name="wt-no-destroy-agent")
# Force the persistence step to fail after the bind path has already
# set git_branch — the exact window the rollback guards. Patch the class
# method (the store is a thin, stateless db_uri wrapper, and the route
# uses its own instance) so the failure hits regardless of which
# instance the router closed over.
def _boom(*args: Any, **kwargs: Any) -> Any:
raise RuntimeError("simulated create_conversation failure")
monkeypatch.setattr(SqlAlchemyConversationStore, "create_conversation", _boom)
# The in-process ASGI transport re-raises unhandled server errors, so
# the simulated failure surfaces here rather than as a 500 response.
# Either way the create failed; what matters is the side effect below.
with pytest.raises(RuntimeError, match="simulated create_conversation failure"):
await client.post(
"/v1/sessions",
json={
"agent_id": agent["id"],
"host_id": _HOST_ID,
"workspace": _SOURCE_REPO,
"git": {"branch_name": "feature/existing", "existing_worktree": True},
},
)
# Critically, the user's worktree is left untouched: the create-rollback
# did NOT fire, so no remove_worktree frame reached the host.
assert cap.remove == [], (
f"create-rollback force-removed the user's existing worktree: {cap.remove}"
)
async def test_create_failure_rolls_back_omnigent_created_worktree(
register_worktree_host: RegisterHost,
client: httpx.AsyncClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A create_conversation failure DOES clean up an Omnigent-made worktree.
The counterpart to the data-loss guard: when Omnigent creates the
worktree (the ``git`` path) and persistence then fails, the orphan
worktree it just made must be force-removed. Proves the narrowed
rollback guard (gated on Omnigent having created a worktree) still
fires for the case it is meant to clean up.
"""
from omnigent.stores.conversation_store.sqlalchemy_store import (
SqlAlchemyConversationStore,
)
cap = register_worktree_host()
agent = await create_test_agent(client, name="wt-rollback-agent")
def _boom(*args: Any, **kwargs: Any) -> Any:
raise RuntimeError("simulated create_conversation failure")
monkeypatch.setattr(SqlAlchemyConversationStore, "create_conversation", _boom)
with pytest.raises(RuntimeError, match="simulated create_conversation failure"):
await _create_git_session(client, agent["id"], {"branch_name": "feature/orphan"})
# Omnigent created the worktree, so the rollback force-removed it: one
# remove frame for the worktree it just made, deleting the branch too.
assert len(cap.create) == 1, cap.create
assert len(cap.remove) == 1, f"expected a create-rollback remove frame, got {cap.remove}"
assert cap.remove[0].branch == "feature/orphan"
assert cap.remove[0].delete_branch is True