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

@@ -3,6 +3,73 @@
## [Unreleased]
### Added
- **v0.2 PR #3 — FastAPI + SSE + minimal Web GUI (`mydeepagent serve`)**.
Localhost Web UI for run start / list / detail / resume / abort + live
event stream. Closes the v0.1.0 gap "GUI 미존재" from the user's first
session requirements. No auth, no multi-tenant; single uvicorn worker
(per DR-3).
- `pyproject.toml`: runtime deps `fastapi>=0.115`,
`uvicorn[standard]>=0.30`, `sse-starlette>=2.1` (8 transitive deps).
- `src/my_deepagent/api/` (new tree):
- `app.py``create_app(config=None) -> FastAPI` factory. lifespan
stores `db`/`config`/`personas`/`workflows` on `app.state`.
`CORSMiddleware(allow_origin_regex=r"^http://localhost(:\d+)?$")`.
Static frontend mounted under `/static`, plus `/`, `/{page}.html`.
- `models.py` — pydantic v2 DTOs (`RunSummary`, `RunDetail`,
`PhaseInfo`, `ArtifactInfo`, `EventInfo`, `StartRunRequest`,
`StartRunResponse`, `PersonaSummary`, `WorkflowSummary`,
`BudgetSummary`, `BudgetScopeEntry`). All `extra="forbid"` so typos
surface at 422 deserialization time.
- `deps.py``get_db`, `get_config`, `get_personas`, `get_workflows`,
`seed_root`. Annotated[...] wrappers in each route module.
- `runner.py``start_new_run` / `start_resume` /
`is_running`. Pre-allocates a UUID and passes it to
`WorkflowEngine.run(pre_allocated_run_id=...)` so the route can
return the run_id before the phase loop starts. In-memory
`_tasks: dict[UUID, asyncio.Task]` prevents GC of in-flight tasks.
- `sse.py``run_events_stream(db, run_id, last_event_id)`.
0.5 s polling against `run_events.seq > last_event_id`; emits
`ServerSentEvent` per row; sends `event: done` and HTTP-200-closes
when run reaches terminal state.
- `routes/runs.py` — GET `/api/runs?limit=&state=`, GET `/api/runs/{id}`,
POST `/api/runs` (start), POST `/api/runs/{id}/resume`,
POST `/api/runs/{id}/abort`, GET `/api/runs/{id}/events` (SSE).
`Last-Event-ID` HTTP header honored alongside `?last_event_id=`.
- `routes/personas.py` — GET `/api/personas`.
- `routes/workflows.py` — GET `/api/workflows`.
- `routes/budget.py` — GET `/api/budget` (day / runs / personas
buckets with cap + warn thresholds from `Config`).
- `src/my_deepagent/cli/serve.py` (new) — `mydeepagent serve [--host
127.0.0.1] [--port 8000]`. Loud stderr warning when host is not
loopback (the API is unauthenticated). Uses uvicorn factory form +
forces `workers=1`.
- `src/my_deepagent/cli/main.py` — `serve` command registered.
- `src/my_deepagent/engine.py` — `WorkflowEngine.run` gained
`pre_allocated_run_id: UUID | None = None` so the FastAPI runner can
return the run_id immediately. Default behavior unchanged.
- `static/` (new) — vanilla HTML/JS/CSS, no build system:
- `index.html` — 런 목록 + 예산 (data-page="index")
- `new.html` — 신규 run 폼 (workflow select, repo path, requirements,
per-role persona override) (data-page="new")
- `run.html` — run 상세 + SSE 이벤트 라이브 + resume/abort 버튼
(data-page="run")
- `app.js` — fetch + EventSource. **XSS policy hardcoded at the top
of the file**: `element.textContent` only, `innerHTML` /
`insertAdjacentHTML` / `outerHTML` forbidden.
- `style.css` — dark theme, single file.
- Tests (new):
- `tests/integration/test_api_read.py` — 5 cases (list empty, get 404,
personas seed count, workflows seed, budget empty).
- `tests/integration/test_api_write.py` — 5 cases (missing template
400, extra field 422, resume 404, abort 404, mock-runner happy path).
- `tests/integration/test_api_sse.py` — 1 case: seed terminal run +
events, drain stream, assert types present and stream closes.
- `tests/integration/test_api_static.py` — 5 cases: index/new/run
HTML 200, app.js content-type + XSS-policy substring, style.css
content-type.
All tests use `httpx.ASGITransport` + `app.router.lifespan_context`
(httpx does not auto-trigger FastAPI lifespan) and sqlite tmp_path.
- **v0.2 PR #2b — `mydeepagent runs resume <id>` real implementation**.
Closes the v0.1.0 KNOWN LIMIT where resume was an exit-2 stub. Reuses
v0.2 PR #2a's LangGraph wiring + sweep_orphan_runs's DB state machine,