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>
92 lines
3.1 KiB
Python
92 lines
3.1 KiB
Python
"""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"] == []
|