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

324
my-deepagent/static/app.js Normal file
View File

@@ -0,0 +1,324 @@
/* my-deepagent Web GUI — vanilla JS for v0.2 PR #3.
*
* SECURITY (XSS policy):
* All user-controlled strings MUST be inserted via element.textContent.
* element.innerHTML / insertAdjacentHTML / outerHTML are FORBIDDEN.
* See plan.md D3 "GUI 정책 명시" — markdown / HTML rendering is v0.3+.
*/
const API = window.location.origin + "/api";
function $(sel) { return document.querySelector(sel); }
function $$(sel) { return Array.from(document.querySelectorAll(sel)); }
async function jsonFetch(path, opts = {}) {
const r = await fetch(API + path, opts);
if (!r.ok) {
let detail = r.statusText;
try {
const body = await r.json();
detail = body.detail || JSON.stringify(body);
} catch (_) { /* ignore */ }
throw new Error(`${r.status} ${detail}`);
}
return r.json();
}
function setError(msg) {
const el = $("#error");
if (!el) return;
if (msg) {
el.textContent = msg;
el.style.display = "block";
} else {
el.style.display = "none";
}
}
// =============== index.html ===============
async function renderRunsList() {
setError("");
let runs;
try {
runs = await jsonFetch("/runs?limit=50");
} catch (e) {
setError(`runs 목록을 불러오지 못했습니다: ${e.message}`);
return;
}
const tbody = $("#runs tbody");
tbody.replaceChildren();
if (runs.length === 0) {
const tr = document.createElement("tr");
const td = document.createElement("td");
td.colSpan = 6;
td.className = "empty";
td.textContent = "아직 실행된 run이 없습니다. 신규 run을 시작해 보세요.";
tr.appendChild(td);
tbody.appendChild(tr);
return;
}
for (const r of runs) {
const tr = document.createElement("tr");
const cells = [
["id", r.id.slice(0, 8) + "…", { href: `/run.html?id=${r.id}` }],
["state", r.state, { state: r.state }],
["repo", r.repo_path],
["branch", r.base_branch],
["created", (r.created_at || "").slice(0, 19)],
["ended", (r.ended_at || "—").slice(0, 19)],
];
for (const [_, v, opts] of cells) {
const td = document.createElement("td");
if (opts && opts.state) td.className = `state-${opts.state}`;
if (opts && opts.href) {
const a = document.createElement("a");
a.href = opts.href;
a.textContent = v;
td.appendChild(a);
} else {
td.textContent = v;
}
tr.appendChild(td);
}
tbody.appendChild(tr);
}
}
async function renderBudgetSummary() {
const container = $("#budget-summary");
if (!container) return;
container.replaceChildren();
try {
const summary = await jsonFetch("/budget");
function line(scope, spent, cap, warn) {
const div = document.createElement("div");
div.className = "budget-line";
const left = document.createElement("span");
left.className = "scope";
left.textContent = scope;
const right = document.createElement("span");
right.className = "amount";
const capStr = cap != null ? ` / $${cap.toFixed(2)}` : "";
right.textContent = `$${spent.toFixed(4)}${capStr}`;
if (cap != null && spent >= cap) right.classList.add("over");
else if (warn != null && spent >= warn) right.classList.add("warn");
div.appendChild(left);
div.appendChild(right);
container.appendChild(div);
}
if (summary.day) line(summary.day.scope, summary.day.spent_usd, summary.day.cap_usd, summary.day.warn_usd);
for (const r of summary.runs) line(r.scope, r.spent_usd, r.cap_usd, r.warn_usd);
for (const p of summary.personas) line(p.scope, p.spent_usd, p.cap_usd, p.warn_usd);
if (!summary.day && summary.runs.length === 0 && summary.personas.length === 0) {
const empty = document.createElement("div");
empty.className = "empty";
empty.textContent = "지출 기록이 없습니다.";
container.appendChild(empty);
}
} catch (e) {
const err = document.createElement("div");
err.className = "empty";
err.textContent = `budget 정보를 불러오지 못했습니다: ${e.message}`;
container.appendChild(err);
}
}
// =============== new.html ===============
async function renderNewRunForm() {
setError("");
const tplSelect = $("#template");
const overrideContainer = $("#override-fields");
let workflows = [];
let personas = [];
try {
[workflows, personas] = await Promise.all([
jsonFetch("/workflows"),
jsonFetch("/personas"),
]);
} catch (e) {
setError(`workflow / persona 목록을 불러오지 못했습니다: ${e.message}`);
return;
}
for (const w of workflows) {
const opt = document.createElement("option");
opt.value = w.path.replace(/^.*workflows\//, "");
opt.textContent = `${w.name}@${w.version}${w.description || ""}`;
opt.dataset.roles = JSON.stringify(w.roles.map((r) => r.id));
tplSelect.appendChild(opt);
}
tplSelect.addEventListener("change", () => {
overrideContainer.replaceChildren();
const roles = JSON.parse(tplSelect.selectedOptions[0].dataset.roles || "[]");
for (const role of roles) {
const label = document.createElement("label");
label.textContent = `${role} (선택 사항: persona 이름)`;
const input = document.createElement("input");
input.dataset.role = role;
input.className = "override-input";
input.placeholder = "openrouter-deepseek-spec-writer@1";
overrideContainer.appendChild(label);
overrideContainer.appendChild(input);
}
});
if (tplSelect.options.length > 0) {
tplSelect.value = tplSelect.options[0].value;
tplSelect.dispatchEvent(new Event("change"));
}
$("#start-form").addEventListener("submit", async (ev) => {
ev.preventDefault();
setError("");
const override = {};
for (const input of $$(".override-input")) {
if (input.value.trim()) override[input.dataset.role] = input.value.trim();
}
const body = {
template_path: tplSelect.value,
repo_path: $("#repo-path").value.trim(),
base_branch: $("#base-branch").value.trim() || "main",
requirements_md: $("#requirements").value,
};
if (Object.keys(override).length > 0) body.override = override;
try {
const r = await jsonFetch("/runs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
window.location.href = `/run.html?id=${r.run_id}`;
} catch (e) {
setError(`run 시작 실패: ${e.message}`);
}
});
}
// =============== run.html ===============
function getRunIdFromUrl() {
const params = new URLSearchParams(window.location.search);
return params.get("id");
}
async function renderRunDetail() {
setError("");
const runId = getRunIdFromUrl();
if (!runId) {
setError("URL에 ?id=<run_id> 가 필요합니다.");
return;
}
$("#run-id").textContent = runId;
try {
const detail = await jsonFetch(`/runs/${runId}`);
$("#run-state").textContent = detail.run.state;
$("#run-state").className = `state-${detail.run.state}`;
$("#run-repo").textContent = `${detail.run.repo_path} @ ${detail.run.base_branch}`;
$("#run-worktree").textContent = detail.run.worktree_root;
$("#run-report").textContent = detail.run.final_report_path || "—";
const phaseTbody = $("#phases tbody");
phaseTbody.replaceChildren();
for (const p of detail.phases) {
const tr = document.createElement("tr");
const cells = [
p.phase_key,
p.state,
String(p.attempts),
(p.started_at || "—").slice(0, 19),
(p.ended_at || "—").slice(0, 19),
];
for (let i = 0; i < cells.length; i++) {
const td = document.createElement("td");
td.textContent = cells[i];
if (i === 1) td.className = `state-${p.state}`;
tr.appendChild(td);
}
phaseTbody.appendChild(tr);
}
const isTerminal = ["completed", "failed", "aborted"].includes(detail.run.state);
$("#resume-btn").disabled = isTerminal;
$("#abort-btn").disabled = isTerminal;
} catch (e) {
setError(`run 정보를 불러오지 못했습니다: ${e.message}`);
return;
}
startEventStream(runId);
}
function startEventStream(runId) {
const eventsContainer = $("#events");
eventsContainer.replaceChildren();
const src = new EventSource(`${API}/runs/${runId}/events`);
src.addEventListener("event", (ev) => {
try {
const data = JSON.parse(ev.data);
const line = document.createElement("div");
line.className = "event-line";
const ts = document.createElement("span");
ts.className = "ts";
ts.textContent = (data.ts || "").slice(0, 19);
const type = document.createElement("span");
type.className = "type";
type.textContent = ` ${data.type}`;
line.appendChild(ts);
line.appendChild(type);
if (data.payload && Object.keys(data.payload).length > 0) {
const payload = document.createElement("span");
payload.textContent = " " + JSON.stringify(data.payload);
line.appendChild(payload);
}
eventsContainer.appendChild(line);
eventsContainer.scrollTop = eventsContainer.scrollHeight;
} catch (_) { /* ignore */ }
});
src.addEventListener("done", () => {
src.close();
// Refresh the detail panel one last time to pick up final state.
setTimeout(() => renderRunDetail(), 300);
});
src.onerror = () => {
src.close();
};
}
async function abortRun() {
const runId = getRunIdFromUrl();
if (!runId) return;
if (!confirm("정말 이 run을 abort 할까요?")) return;
try {
await jsonFetch(`/runs/${runId}/abort`, { method: "POST" });
renderRunDetail();
} catch (e) {
setError(`abort 실패: ${e.message}`);
}
}
async function resumeRun() {
const runId = getRunIdFromUrl();
if (!runId) return;
try {
await jsonFetch(`/runs/${runId}/resume`, { method: "POST" });
renderRunDetail();
} catch (e) {
setError(`resume 실패: ${e.message}`);
}
}
// =============== bootstrap ===============
document.addEventListener("DOMContentLoaded", () => {
const page = document.body.dataset.page;
if (page === "index") {
renderRunsList();
renderBudgetSummary();
} else if (page === "new") {
renderNewRunForm();
} else if (page === "run") {
renderRunDetail();
$("#abort-btn").addEventListener("click", abortRun);
$("#resume-btn").addEventListener("click", resumeRun);
}
});