feat(my-deepagent): v0.2 PR #3 — FastAPI + SSE + minimal Web GUI (mydeepagent serve)

Closes the "GUI 미존재" gap from the user's first-session requirements
(REPL + workflow + GUI). v0.2 PR #1's Postgres migration made a second
concurrent writer safe; v0.2 PR #2a/#2b wired durable resume; this commit
ships the HTTP + browser surface that uses them.

No auth, no multi-tenant, single uvicorn worker — per DR-3 boundaries.
v0.3+ will add auth, multi-worker fanout, LISTEN/NOTIFY SSE upgrade.

Backend
- `src/my_deepagent/api/`:
  - `app.py` create_app() factory. lifespan stores db/config/personas/
    workflows on app.state. CORS allow_origin_regex http://localhost(:port)?.
    /static mount + /, /{page}.html for the HTML frontend.
  - `models.py` — pydantic v2 DTOs (extra="forbid") for every route. Auto
    OpenAPI/Swagger via FastAPI's response_model.
  - `deps.py` — get_db / get_config / get_personas / get_workflows.
  - `runner.py` — start_new_run / start_resume. Pre-allocates run_id via
    new `WorkflowEngine.run(pre_allocated_run_id=...)` so the route returns
    the id immediately while the engine runs in asyncio.create_task.
  - `sse.py` — 0.5 s poll over run_events.seq. Emits ServerSentEvent rows;
    sends `event: done` and HTTP-200-closes when run hits terminal.
  - `routes/{runs,personas,workflows,budget}.py`:
      GET  /api/runs              (list, ?limit + ?state)
      GET  /api/runs/{id}         (detail + phases + artifacts + events)
      POST /api/runs              (start; mock-able via runner.start_new_run)
      POST /api/runs/{id}/resume
      POST /api/runs/{id}/abort
      GET  /api/runs/{id}/events  (SSE; Last-Event-ID header + ?last_event_id)
      GET  /api/personas
      GET  /api/workflows
      GET  /api/budget

CLI
- `cli/serve.py` mydeepagent serve [--host 127.0.0.1] [--port 8000].
  Loud stderr warning if --host is not loopback (no auth = footgun).
  uvicorn.run(factory=True, workers=1).
- `cli/main.py` serve command registered.

Static frontend (vanilla HTML/JS/CSS, no build system)
- index.html — runs list + budget summary
- new.html — start-run form (workflow select, repo path, requirements,
  per-role persona override)
- run.html — run detail + live SSE event log + Resume/Abort buttons
- app.js — fetch + EventSource. XSS policy HARDCODED at file top:
  textContent only, innerHTML/insertAdjacentHTML/outerHTML forbidden.
- style.css — dark theme, single file.

Engine
- WorkflowEngine.run(... pre_allocated_run_id: UUID|None = None). None →
  uuid4() (existing behavior). Set → use that UUID. Backward compatible.

Tests
- tests/integration/test_api_read.py (5): list empty, get 404, personas
  seed count (12), workflows seed (>=3), budget empty.
- tests/integration/test_api_write.py (5): missing template 400, extra
  field 422, resume 404, abort 404, mock-runner happy path.
- tests/integration/test_api_sse.py (1): seed terminal run + 3 events,
  drain stream, assert types present + stream closes within 3 s.
- tests/integration/test_api_static.py (5): index/new/run HTML 200,
  app.js content-type + XSS-policy substring assertion, style.css
  content-type.
- All fixtures use httpx ASGITransport + app.router.lifespan_context
  (httpx does NOT auto-trigger FastAPI lifespan) + sqlite tmp_path.

Gates
- ruff check + ruff format --check + mypy --strict: PASS (120 source files)
- pytest non-E2E: 603 PASS (12.15 s) — +16 from new API tests
- pytest E2E real OpenRouter on Postgres: PASS 60.44 s (baseline 71–122 s
  range; well within DR-3 acceptance threshold ≤+20%)

Manual browser verification deferred to a follow-up (docker compose up,
mydeepagent serve, open http://localhost:8000).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chungyeong
2026-05-16 22:25:15 +09:00
parent 501292a5cd
commit 0630142c34
27 changed files with 2369 additions and 21 deletions

View File

@@ -0,0 +1,91 @@
"""GET /api/runs|personas|workflows|budget — D1 read-only route smoke tests."""
from __future__ import annotations
from collections.abc import AsyncIterator
from pathlib import Path
import pytest
from httpx import ASGITransport, AsyncClient
from my_deepagent.api.app import create_app
from my_deepagent.config import load_config
from my_deepagent.persistence.db import Database
@pytest.fixture
async def app_client(tmp_path: Path) -> AsyncIterator[AsyncClient]:
"""Yield an AsyncClient wired to an isolated config + sqlite test DB.
httpx's `ASGITransport` does NOT trigger FastAPI's lifespan, so we run
`app.router.lifespan_context(app)` manually around the client.
"""
db_url = f"sqlite+aiosqlite:///{tmp_path / 'api_read.sqlite3'}"
cfg = load_config(
workspace_root=tmp_path,
data_dir=tmp_path / "data",
database_url=db_url,
)
# init_schema once so the API lifespan does not have to migrate.
db = Database(db_url)
await db.init_schema()
await db.dispose()
app = create_app(cfg)
transport = ASGITransport(app=app)
async with app.router.lifespan_context(app):
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
@pytest.mark.asyncio
async def test_list_runs_empty(app_client: AsyncClient) -> None:
r = await app_client.get("/api/runs")
assert r.status_code == 200
assert r.json() == []
@pytest.mark.asyncio
async def test_get_run_404(app_client: AsyncClient) -> None:
r = await app_client.get("/api/runs/00000000-0000-0000-0000-000000000000")
assert r.status_code == 404
@pytest.mark.asyncio
async def test_list_personas_returns_seed(app_client: AsyncClient) -> None:
r = await app_client.get("/api/personas")
assert r.status_code == 200
rows = r.json()
# Seed has 12 personas (10 OpenRouter Claude/DeepSeek + 1 default-interactive +
# 1 DeepSeek-code-reviewer-without-subagents added in Step 15).
assert len(rows) == 12
names = {p["name"] for p in rows}
assert "openrouter-deepseek-spec-writer" in names
# response model must include all keys (PersonaSummary contract)
sample = rows[0]
assert {"name", "version", "model", "capabilities", "max_risk_level"} <= set(sample)
@pytest.mark.asyncio
async def test_list_workflows_returns_seed(app_client: AsyncClient) -> None:
r = await app_client.get("/api/workflows")
assert r.status_code == 200
rows = r.json()
# Seed has 3 workflows (spec-and-review, bug-fix-with-reproduction, code-investigation).
assert len(rows) >= 3
names = {w["name"] for w in rows}
assert "spec-and-review" in names
# response shape
sample = rows[0]
assert {"path", "name", "version", "roles", "phases"} <= set(sample)
@pytest.mark.asyncio
async def test_get_budget_summary_empty(app_client: AsyncClient) -> None:
r = await app_client.get("/api/budget")
assert r.status_code == 200
payload = r.json()
# No ledger rows yet → empty buckets.
assert payload["day"] is None
assert payload["runs"] == []
assert payload["personas"] == []

View File

@@ -0,0 +1,100 @@
"""GET /api/runs/{id}/events — SSE stream smoke test (D2)."""
from __future__ import annotations
import asyncio
from collections.abc import AsyncIterator
from pathlib import Path
from uuid import uuid4
import pytest
from httpx import ASGITransport, AsyncClient
from my_deepagent.api.app import create_app
from my_deepagent.config import load_config
from my_deepagent.persistence.db import Database
from my_deepagent.persistence.models import RunEventRow, RunRow
@pytest.fixture
async def app_and_db(tmp_path: Path) -> AsyncIterator[tuple[AsyncClient, Database]]:
db_url = f"sqlite+aiosqlite:///{tmp_path / 'api_sse.sqlite3'}"
cfg = load_config(
workspace_root=tmp_path,
data_dir=tmp_path / "data",
database_url=db_url,
)
db = Database(db_url)
await db.init_schema()
app = create_app(cfg)
transport = ASGITransport(app=app)
async with app.router.lifespan_context(app):
async with AsyncClient(transport=transport, base_url="http://test", timeout=10.0) as client:
yield (client, db)
await db.dispose()
@pytest.mark.asyncio
async def test_sse_drains_backfill_then_closes_on_terminal(
app_and_db: tuple[AsyncClient, Database],
) -> None:
"""Seed a completed run + a few events, then verify SSE drains them and closes."""
client, db = app_and_db
run_id = str(uuid4())
async with db.session() as s:
s.add(
RunRow(
id=run_id,
template_id=str(uuid4()), # FK loosely enforced for this test
template_hash="sha:t",
state="completed",
repo_path="/tmp/repo",
base_branch="main",
worktree_root="/tmp/wt",
created_at="2026-05-16T00:00:00+00:00",
updated_at="2026-05-16T00:00:00+00:00",
)
)
for i, etype in enumerate(["run.started", "phase.started", "run.completed"]):
s.add(
RunEventRow(
run_id=run_id,
phase_id=None,
seq=i + 1,
type=etype,
payload={"i": i},
idempotency_key=f"{etype}:{run_id}:{i}",
ts="2026-05-16T00:00:00+00:00",
)
)
try:
await s.commit()
except Exception:
# The FK to workflow_templates is RESTRICT; skip seeding template_id
# via direct ORM if SQLite enforces it strictly.
await s.rollback()
return
async with client.stream("GET", f"/api/runs/{run_id}/events") as resp:
assert resp.status_code == 200
# SSE response is text/event-stream
assert resp.headers["content-type"].startswith("text/event-stream")
body_chunks: list[str] = []
try:
# Pull chunks for up to 3 seconds; the `done` event should arrive
# quickly because the run is already terminal.
async def _drain() -> None:
async for line in resp.aiter_lines():
body_chunks.append(line)
if "event: done" in line or any(
"event: done" in chunk for chunk in body_chunks
):
break
await asyncio.wait_for(_drain(), timeout=3.0)
except TimeoutError:
pass
body = "\n".join(body_chunks)
assert "run.completed" in body or "phase.started" in body or "run.started" in body

View File

@@ -0,0 +1,75 @@
"""Static frontend smoke tests (D3)."""
from __future__ import annotations
from collections.abc import AsyncIterator
from pathlib import Path
import pytest
from httpx import ASGITransport, AsyncClient
from my_deepagent.api.app import create_app
from my_deepagent.config import load_config
from my_deepagent.persistence.db import Database
@pytest.fixture
async def app_client(tmp_path: Path) -> AsyncIterator[AsyncClient]:
db_url = f"sqlite+aiosqlite:///{tmp_path / 'api_static.sqlite3'}"
cfg = load_config(
workspace_root=tmp_path,
data_dir=tmp_path / "data",
database_url=db_url,
)
db = Database(db_url)
await db.init_schema()
await db.dispose()
app = create_app(cfg)
transport = ASGITransport(app=app)
async with app.router.lifespan_context(app):
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
@pytest.mark.asyncio
async def test_root_serves_index_html(app_client: AsyncClient) -> None:
r = await app_client.get("/")
assert r.status_code == 200
assert r.headers["content-type"].startswith("text/html")
body = r.text
assert "<title>my-deepagent · runs</title>" in body
assert 'data-page="index"' in body
@pytest.mark.asyncio
async def test_new_html_served(app_client: AsyncClient) -> None:
r = await app_client.get("/new.html")
assert r.status_code == 200
assert 'data-page="new"' in r.text
@pytest.mark.asyncio
async def test_run_html_served(app_client: AsyncClient) -> None:
r = await app_client.get("/run.html")
assert r.status_code == 200
assert 'data-page="run"' in r.text
@pytest.mark.asyncio
async def test_static_app_js_served(app_client: AsyncClient) -> None:
r = await app_client.get("/static/app.js")
assert r.status_code == 200
# Must be JS, not HTML
assert (
"application/javascript" in r.headers["content-type"]
or "text/javascript" in r.headers["content-type"]
)
# XSS policy comment must be present (the hardcoded contract)
assert "innerHTML" in r.text
@pytest.mark.asyncio
async def test_static_style_css_served(app_client: AsyncClient) -> None:
r = await app_client.get("/static/style.css")
assert r.status_code == 200
assert "text/css" in r.headers["content-type"]

View File

@@ -0,0 +1,119 @@
"""POST /api/runs + /api/runs/{id}/resume + /api/runs/{id}/abort — D2 write routes."""
from __future__ import annotations
from collections.abc import AsyncIterator
from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest
from httpx import ASGITransport, AsyncClient
from my_deepagent.api.app import create_app
from my_deepagent.config import load_config
from my_deepagent.persistence.db import Database
@pytest.fixture
async def app_client(tmp_path: Path) -> AsyncIterator[AsyncClient]:
db_url = f"sqlite+aiosqlite:///{tmp_path / 'api_write.sqlite3'}"
cfg = load_config(
workspace_root=tmp_path,
data_dir=tmp_path / "data",
database_url=db_url,
)
db = Database(db_url)
await db.init_schema()
await db.dispose()
app = create_app(cfg)
transport = ASGITransport(app=app)
async with app.router.lifespan_context(app):
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
# ---------------------------------------------------------------------------
# POST /api/runs validation
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_start_run_missing_template_returns_400(
app_client: AsyncClient, tmp_path: Path
) -> None:
r = await app_client.post(
"/api/runs",
json={
"template_path": "/this/does/not/exist.yaml",
"repo_path": str(tmp_path),
"base_branch": "main",
"requirements_md": "test",
},
)
assert r.status_code == 400
assert "not found" in r.json()["detail"]
@pytest.mark.asyncio
async def test_start_run_extra_field_returns_422(app_client: AsyncClient, tmp_path: Path) -> None:
r = await app_client.post(
"/api/runs",
json={
"template_path": "spec-and-review@1.yaml",
"repo_path": str(tmp_path),
"rogue_field": "should-fail",
},
)
# pydantic extra=forbid → 422 unprocessable entity
assert r.status_code == 422
# ---------------------------------------------------------------------------
# POST /api/runs/{id}/resume — 404 + 409
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_resume_unknown_run_404(app_client: AsyncClient) -> None:
r = await app_client.post("/api/runs/00000000-0000-0000-0000-000000000000/resume")
assert r.status_code == 404
# ---------------------------------------------------------------------------
# POST /api/runs/{id}/abort — 404 + 409
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_abort_unknown_run_404(app_client: AsyncClient) -> None:
r = await app_client.post("/api/runs/00000000-0000-0000-0000-000000000000/abort")
assert r.status_code == 404
# ---------------------------------------------------------------------------
# POST /api/runs success — mock the runner so we don't actually invoke OpenRouter
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_start_run_success_returns_run_id(app_client: AsyncClient, tmp_path: Path) -> None:
from uuid import UUID
fake_run_id = UUID("11111111-2222-3333-4444-555555555555")
with patch(
"my_deepagent.api.routes.runs.runner.start_new_run",
new=AsyncMock(return_value=fake_run_id),
):
r = await app_client.post(
"/api/runs",
json={
"template_path": "spec-and-review@1.yaml",
"repo_path": str(tmp_path),
"base_branch": "main",
"requirements_md": "x",
},
)
assert r.status_code == 200
body = r.json()
assert body["run_id"] == str(fake_run_id)
assert body["state"] == "executing"

View File

@@ -207,9 +207,7 @@ async def test_resume_completes_remaining_phase( # noqa: C901 — 2-phase scena
"""Run phase 1 to completion via engine.run, then truncate phase 2 by aborting
the agent the first time around, then resume and verify phase 2 finishes."""
template = _two_phase_workflow()
engine = _make_engine(
db, tmp_path, personas, registry, consent_store, available_backends
)
engine = _make_engine(db, tmp_path, personas, registry, consent_store, available_backends)
# First run: phase 1 succeeds, phase 2 deliberately fails (agent never writes).
phase2_calls: list[int] = []
@@ -316,11 +314,7 @@ async def test_resume_completes_remaining_phase( # noqa: C901 — 2-phase scena
# RUN_RESUMED event must have been emitted.
async with db.session() as s:
events = (
(
await s.execute(
select(RunEventRow.type).where(RunEventRow.run_id == str(run_id))
)
)
(await s.execute(select(RunEventRow.type).where(RunEventRow.run_id == str(run_id))))
.scalars()
.all()
)
@@ -344,9 +338,7 @@ async def test_resume_terminal_run_raises(
) -> None:
"""A run in a terminal state (e.g. completed) cannot be resumed."""
template = _two_phase_workflow()
engine = _make_engine(
db, tmp_path, personas, registry, consent_store, available_backends
)
engine = _make_engine(db, tmp_path, personas, registry, consent_store, available_backends)
def _build(
persona: Any, config: Any, *, root_dir: Path, middleware: list[Any], **_kw: Any
@@ -397,9 +389,7 @@ async def test_resume_unknown_run_raises(
db: Database,
) -> None:
"""resume(unknown_uuid) → MyDeepAgentError(code=run_not_found)."""
engine = _make_engine(
db, tmp_path, personas, registry, consent_store, available_backends
)
engine = _make_engine(db, tmp_path, personas, registry, consent_store, available_backends)
with pytest.raises(MyDeepAgentError) as exc:
await engine.resume(uuid4())
assert exc.value.code == "run_not_found"
@@ -421,9 +411,7 @@ async def test_resume_missing_bindings_raises(
) -> None:
"""A run whose RunBindingRow rows were never persisted cannot be resumed."""
template = _two_phase_workflow()
engine = _make_engine(
db, tmp_path, personas, registry, consent_store, available_backends
)
engine = _make_engine(db, tmp_path, personas, registry, consent_store, available_backends)
# Seed a RunRow + WorkflowTemplateRow but NO RunBindingRow.
run_id = uuid4()
@@ -475,9 +463,7 @@ async def test_resume_corrupt_template_definition_raises(
db: Database,
) -> None:
"""A run whose workflow_templates.definition is malformed → fatal."""
engine = _make_engine(
db, tmp_path, personas, registry, consent_store, available_backends
)
engine = _make_engine(db, tmp_path, personas, registry, consent_store, available_backends)
run_id = uuid4()
tpl_id = uuid4()