Merge pull request #3 from 0xWheatyz/phase-3-ui

feat(phase-3): web UI served same-origin by the API
This commit is contained in:
Wyatt
2026-07-09 20:45:36 -04:00
committed by GitHub
10 changed files with 881 additions and 5 deletions
+9
View File
@@ -34,6 +34,15 @@ PROJECTS_ROOT=/var/lib/handler/projects
# Closes the "merge locally, push to main" path around the forge-merge approval gate. # Closes the "merge locally, push to main" path around the forge-merge approval gate.
# PROTECTED_BRANCHES=main,master # PROTECTED_BRANCHES=main,master
# Phase 3 (web UI). Serve the bundled UI from "/" and "/static". Set false for a
# headless, API-only deployment. Applied at process start (restart to change).
# UI_ENABLED=true
# Extra origins allowed to call the API cross-origin (comma-separated). Only needed if
# you host the UI on a DIFFERENT origin than the API; the shipped UI is same-origin and
# needs none. Empty => no CORS middleware.
# CORS_ORIGINS=https://handler.example.ts.net
# Per-project credentials are NOT set here — they live on each project's `credential_ref` # Per-project credentials are NOT set here — they live on each project's `credential_ref`
# as a POINTER (env:VAR / file:/path / cmd:...), resolved and injected only at spawn. # as a POINTER (env:VAR / file:/path / cmd:...), resolved and injected only at spawn.
# The database never stores the raw token. Example, when registering a project: # The database never stores the raw token. Example, when registering a project:
+9 -3
View File
@@ -233,10 +233,16 @@ Reviewed by a separate `code-reviewer` pass; findings on gate bypass (local-merg
**Definition of done:** an operator registers a project with a `credential_ref`, runs `handler forge-init`, and spawns junior/senior/deploy agents; the junior opens a PR, the senior approves via `handler approve`, and only then can the deploy agent merge — enforced by the gate, not convention — with the push→CI verdict recorded back automatically, all against any forge `forge` supports and with no raw credential ever stored. **Definition of done:** an operator registers a project with a `credential_ref`, runs `handler forge-init`, and spawns junior/senior/deploy agents; the junior opens a PR, the senior approves via `handler approve`, and only then can the deploy agent merge — enforced by the gate, not convention — with the push→CI verdict recorded back automatically, all against any forge `forge` supports and with no raw credential ever stored.
### Phase 3 — Production UI ### Phase 3 — Production UI
- [ ] Web frontend, API-backed only (same contract as `curl`) - [x] Web frontend, API-backed only (same contract as `curl`) — a no-build, same-origin static UI (vanilla `fetch` + plain CSS + one vendored `alpine.min.js`, no npm/bundler) served by FastAPI itself from `/` and `/static`. Zero new Python runtime deps (`StaticFiles`/`CORSMiddleware` ship with Starlette). Serving is additive and gated on `UI_ENABLED` (default on); an optional `CORS_ORIGINS` (default empty → no middleware) supports hosting the UI on a separate origin.
- [ ] Project switcher, agent list per project, live checkmark view, log history, "answer this question" form, plus a view for the shared/global feed - [x] Project switcher, agent list per project (live status badges), live checkmark view, paginated log history, "Answer" / "Answer & Resume" form for paused agents, and a shared/global feed + shared-context view. Polling is scoped to the selected agent (~3 requests/tick) to avoid an N+1 over the fleet.
**Definition of done:** open a URL, see every agent's state, answer a paused question, no terminal required. **Definition of done:** open a URL, see every agent's state, answer a paused question, no terminal required. **Met.**
The static shell is served **unauthenticated** (it holds no data); the browser prompts for the `AUTH_TOKEN` once, stores it in `localStorage`, and attaches it to every API call. All API values render via Alpine `x-text` (never `x-html`) so agent-authored strings can't inject markup. New tests in `tests/test_api_ui.py` lock the serving, the unauthenticated shell, a **non-shadowing** regression (`/projects` still 401s without auth with the UI mounted), the CORS toggle, and `UI_ENABLED=false`. 114 tests, ruff clean.
**Acceptance script (manual e2e):** seed a project + an agent driven to `paused_for_input` with an `open_question`; run `uvicorn handler.api.app:app --port 8000` with `AUTH_TOKEN` set. (1) Open `http://localhost:8000/` → shell loads with no token, shows the modal. (2) Paste the token → the project switcher lists the project. (3) Select the project → the agent shows an amber `paused_for_input` badge. (4) Select the agent → checkmark panel (where_it_stopped, next_steps, open_question, tests/build gate badges, timestamps) and newest-first log with Prev/Next. (5) Type a reply → **Answer & Resume** → the log gains the answer and the badge flips to blue `working` within one poll tick. (6) Enter a bad token → the next call 401s → the app clears the token and re-prompts. (7) Open the **Shared** tab → the global feed and shared-context render. The backend half of this flow (every endpoint the UI calls, including the unauthenticated shell, the 401 gate, and the `/answer` backfill) is verified end-to-end against a live uvicorn.
**Out of scope (additive follow-ups):** an aggregate `GET /projects/{project}/overview` (agents + latest checkmark in one call) to show every agent's checkmark at once; spawning agents / registering projects from the UI (still CLI-driven); shared-context **writes** from the UI (would need the shared-write token — MVP is read-only).
### Phase 4 — Observability (moved back, now optional) ### Phase 4 — Observability (moved back, now optional)
- [ ] Prometheus metrics endpoint on the API (agent counts, pending questions, checkpoint rate) - [ ] Prometheus metrics endpoint on the API (agent counts, pending questions, checkpoint rate)
+2
View File
@@ -31,6 +31,8 @@ dev = [
handler = "handler.control.cli:main" handler = "handler.control.cli:main"
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
# The bundled web UI (src/handler/api/static/*) ships automatically: it lives inside the
# packaged `src/handler` tree, and hatchling includes non-.py files there by default.
packages = ["src/handler"] packages = ["src/handler"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
+37 -2
View File
@@ -1,17 +1,28 @@
"""FastAPI application factory. """FastAPI application factory.
Run with: ``uvicorn handler.api.app:create_app --factory``. The UI and any future Run with: ``uvicorn handler.api.app:create_app --factory``. The UI and any future
integration are just clients of this — same contract as ``curl``. integration are just clients of this — same contract as ``curl``. When ``ui_enabled``
(the default) the bundled web UI (Phase 3) is served same-origin from ``/`` and
``/static``; the shell is a client of the very same API, so no contract changes.
""" """
from __future__ import annotations from __future__ import annotations
from fastapi import FastAPI from pathlib import Path
from fastapi import FastAPI
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from ..config import get_settings
from .routes import agents, interaction, projects, shared from .routes import agents, interaction, projects, shared
_STATIC_DIR = Path(__file__).parent / "static"
def create_app() -> FastAPI: def create_app() -> FastAPI:
settings = get_settings()
app = FastAPI( app = FastAPI(
title="Handler API", title="Handler API",
version="0.1.0", version="0.1.0",
@@ -26,6 +37,30 @@ def create_app() -> FastAPI:
app.include_router(agents.router) app.include_router(agents.router)
app.include_router(interaction.router) app.include_router(interaction.router)
app.include_router(shared.router) app.include_router(shared.router)
# Optional CORS, only for operators who host the UI on a different origin than the
# API. Empty CORS_ORIGINS => middleware never added => behaviour identical to headless.
if settings.cors_origin_list:
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origin_list,
allow_methods=["GET", "POST", "PUT"],
allow_headers=["Authorization", "Content-Type"],
)
# Serve the bundled UI same-origin. A dedicated "/static" prefix + an explicit "/"
# route (never a "/"-mounted catch-all) so the API routes above can't be shadowed.
# The shell holds no data and is served unauthenticated; all data comes from the
# authed API calls the browser makes after the operator supplies the bearer token.
if settings.ui_enabled and _STATIC_DIR.is_dir():
app.mount("/static", StaticFiles(directory=_STATIC_DIR), name="static")
@app.get("/", include_in_schema=False)
def index() -> FileResponse:
return FileResponse(_STATIC_DIR / "index.html")
return app return app
File diff suppressed because one or more lines are too long
+320
View File
@@ -0,0 +1,320 @@
/* Handler web UI — vanilla + Alpine.js, no build step.
*
* Classic (non-module) script: `function app()` below becomes a global that the
* shell references via x-data="app()". Loaded with `defer` BEFORE alpine.min.js so
* the global exists before Alpine evaluates the DOM.
*
* Security: every value from the API is rendered with Alpine `x-text` (textContent)
* in index.html — never x-html — so agent-authored strings can't inject markup.
*/
const TOKEN_KEY = "handler_token";
const PROJECT_KEY = "handler_project";
const POLL_MS = 5000;
const LOG_LIMIT = 100;
/* Raised when the API rejects our token; callers re-prompt for it. */
class AuthError extends Error {}
function app() {
return {
// --- auth / shell ---
token: null,
tokenInput: "",
showTokenModal: true,
tokenError: "",
// --- data ---
projects: [],
selectedProjectId: "",
agents: [],
selectedAgentName: null,
checkmark: null,
checkmarkMissing: false,
log: [],
logLimit: LOG_LIMIT,
logOffset: 0,
shared: { log: [], context: [] },
// --- answer form ---
answerText: "",
answerBusy: false,
answerMsg: "",
answerError: false,
// --- ui ---
tab: "agents",
lastError: "",
_poll: null,
get selectedAgent() {
return this.agents.find((a) => a.name === this.selectedAgentName) || null;
},
init() {
this.token = localStorage.getItem(TOKEN_KEY);
if (this.token) {
this.showTokenModal = false;
this.start();
} else {
this.showTokenModal = true;
this.$nextTick(() => this.$refs.tokenField?.focus());
}
// Pause polling when the tab is hidden; resume (with an immediate tick) on return.
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
this._stopPolling();
} else if (this.token) {
this.tick();
this._startPolling();
}
});
},
// --- token lifecycle ---
saveToken() {
const t = this.tokenInput.trim();
if (!t) return;
this.token = t;
localStorage.setItem(TOKEN_KEY, t);
this.tokenInput = "";
this.tokenError = "";
this.showTokenModal = false;
this.start();
},
signOut() {
this._stopPolling();
this.token = null;
localStorage.removeItem(TOKEN_KEY);
this.projects = [];
this.agents = [];
this.selectedAgentName = null;
this.checkmark = null;
this.showTokenModal = true;
this.$nextTick(() => this.$refs.tokenField?.focus());
},
_handle401() {
this._stopPolling();
this.token = null;
localStorage.removeItem(TOKEN_KEY);
this.tokenError = "Invalid token — please try again.";
this.showTokenModal = true;
this.$nextTick(() => this.$refs.tokenField?.focus());
},
// --- fetch wrapper ---
async api(path, opts = {}) {
const headers = { Authorization: `Bearer ${this.token}` };
if (opts.body !== undefined) headers["Content-Type"] = "application/json";
const res = await fetch(path, {
method: opts.method || "GET",
headers,
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
});
if (res.status === 401) {
this._handle401();
throw new AuthError("unauthorized");
}
if (!res.ok) {
let detail = `${res.status}`;
try {
detail = (await res.json()).detail || detail;
} catch (_) {}
const err = new Error(detail);
err.status = res.status;
throw err;
}
if (res.status === 204) return null;
return res.json();
},
// --- lifecycle ---
async start() {
await this.loadProjects();
const saved = localStorage.getItem(PROJECT_KEY);
if (saved && this.projects.some((p) => p.id === saved)) {
await this.selectProject(saved);
}
this._startPolling();
},
_startPolling() {
this._stopPolling();
this._poll = setInterval(() => this.tick(), POLL_MS);
},
_stopPolling() {
if (this._poll) {
clearInterval(this._poll);
this._poll = null;
}
},
/* One poll cycle for whatever view is active. Swallows AuthError (already handled). */
async tick() {
try {
if (this.tab === "shared") {
await this.loadShared();
return;
}
if (this.selectedProjectId) await this.loadAgents();
if (this.selectedAgentName) {
await this.loadCheckmark();
await this.loadLog();
}
} catch (e) {
if (!(e instanceof AuthError)) this.lastError = e.message;
}
},
refresh() {
this.tick();
},
// --- projects ---
async loadProjects() {
try {
this.projects = await this.api("/projects");
this.lastError = "";
} catch (e) {
if (!(e instanceof AuthError)) this.lastError = e.message;
}
},
async selectProject(id) {
this.selectedProjectId = id;
localStorage.setItem(PROJECT_KEY, id);
this.selectedAgentName = null;
this.checkmark = null;
this.log = [];
this.logOffset = 0;
await this.loadAgents();
},
// --- agents ---
async loadAgents() {
const p = this.selectedProjectId;
if (!p) return;
const agents = await this.api(`/projects/${encodeURIComponent(p)}/agents`);
this.agents = agents;
this.lastError = "";
},
async selectAgent(name) {
this.selectedAgentName = name;
this.answerText = "";
this.answerMsg = "";
this.answerError = false;
this.logOffset = 0;
await this.loadCheckmark();
await this.loadLog();
},
_agentPath() {
return `/projects/${encodeURIComponent(this.selectedProjectId)}/agents/${encodeURIComponent(this.selectedAgentName)}`;
},
async loadCheckmark() {
try {
this.checkmark = await this.api(`${this._agentPath()}/checkmark`);
this.checkmarkMissing = false;
} catch (e) {
if (e instanceof AuthError) return;
if (e.status === 404) {
this.checkmark = null;
this.checkmarkMissing = true;
} else {
this.lastError = e.message;
}
}
},
async loadLog() {
try {
this.log = await this.api(`${this._agentPath()}/log?limit=${this.logLimit}&offset=${this.logOffset}`);
} catch (e) {
if (!(e instanceof AuthError)) this.lastError = e.message;
}
},
pagePrev() {
if (this.logOffset === 0) return;
this.logOffset = Math.max(0, this.logOffset - this.logLimit);
this.loadLog();
},
pageNext() {
if (this.log.length < this.logLimit) return;
this.logOffset += this.logLimit;
this.loadLog();
},
// --- answer / resume ---
async submitAnswer(resume) {
const text = this.answerText.trim();
if (!text) return;
this.answerBusy = true;
this.answerMsg = "";
this.answerError = false;
try {
await this.api(`${this._agentPath()}/answer`, { method: "POST", body: { answer: text } });
if (resume) {
const r = await this.api(`${this._agentPath()}/resume`, { method: "POST", body: { answer: text } });
if (r.resumed) {
this.answerMsg = "Answered and resumed.";
this.answerText = "";
await this.tick(); // flip the badge to working without waiting a full interval
} else {
this.answerError = true;
this.answerMsg = `Answer saved, but resume failed: ${r.detail || "unknown error"}`;
await this.loadCheckmark();
await this.loadLog();
}
} else {
this.answerMsg = "Answer saved (agent still paused).";
this.answerText = "";
await this.loadCheckmark();
await this.loadLog();
}
} catch (e) {
if (e instanceof AuthError) return;
this.answerError = true;
this.answerMsg = e.message;
} finally {
this.answerBusy = false;
}
},
// --- shared tab ---
switchToShared() {
this.tab = "shared";
this.loadShared();
},
async loadShared() {
try {
const [log, context] = await Promise.all([
this.api("/shared/log"),
this.api("/shared/context"),
]);
this.shared = { log, context };
this.lastError = "";
} catch (e) {
if (!(e instanceof AuthError)) this.lastError = e.message;
}
},
// --- rendering helpers ---
badgeClass(kind, value) {
const v = value == null ? "unknown" : String(value);
return `badge-${kind}-${v.replace(/[^a-z0-9_]/gi, "")}`;
},
fmt(ts) {
if (!ts) return "";
const d = new Date(ts);
if (isNaN(d)) return String(ts);
return d.toLocaleString();
},
};
}
+205
View File
@@ -0,0 +1,205 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Handler</title>
<link rel="stylesheet" href="/static/styles.css" />
<!-- app.js defines window.app() and MUST run before Alpine starts; both are classic
`defer` scripts, which execute in document order, so app.js is guaranteed first. -->
<script defer src="/static/app.js"></script>
<script defer src="/static/alpine.min.js"></script>
</head>
<body x-data="app()" x-cloak>
<!-- Token modal: shown until a bearer token is supplied. Holds no data. -->
<div class="modal-backdrop" x-show="showTokenModal">
<form class="modal" @submit.prevent="saveToken()">
<h2>Handler</h2>
<p class="muted">Paste your API bearer token to continue.</p>
<input type="password" x-model="tokenInput" placeholder="AUTH_TOKEN"
autocomplete="current-password" x-ref="tokenField" />
<p class="error" x-show="tokenError" x-text="tokenError"></p>
<button type="submit">Save</button>
</form>
</div>
<template x-if="!showTokenModal">
<div>
<header class="topbar">
<div class="brand">Handler</div>
<div class="topbar-controls">
<label class="muted" for="project-select">Project</label>
<select id="project-select" x-model="selectedProjectId" @change="selectProject($event.target.value)">
<option value="" disabled>— select —</option>
<template x-for="p in projects" :key="p.id">
<option :value="p.id" x-text="p.id"></option>
</template>
</select>
<nav class="tabs">
<button :class="{ active: tab === 'agents' }" @click="tab = 'agents'">Agents</button>
<button :class="{ active: tab === 'shared' }" @click="switchToShared()">Shared</button>
</nav>
<button class="ghost" @click="refresh()" title="Refresh now"></button>
<button class="ghost" @click="signOut()" title="Sign out / change token">Sign out</button>
</div>
</header>
<p class="banner error" x-show="lastError" x-text="lastError"></p>
<!-- AGENTS TAB -->
<main class="layout" x-show="tab === 'agents'">
<!-- Agent list -->
<section class="panel agents">
<h3>Agents <span class="muted" x-show="selectedProjectId" x-text="'· ' + selectedProjectId"></span></h3>
<p class="muted" x-show="selectedProjectId && agents.length === 0">No agents in this project.</p>
<p class="muted" x-show="!selectedProjectId">Select a project.</p>
<ul class="agent-list">
<template x-for="a in agents" :key="a.id">
<li :class="{ selected: a.name === selectedAgentName }" @click="selectAgent(a.name)">
<span class="agent-name" x-text="a.name"></span>
<span class="badge" :class="badgeClass('status', a.status)" x-text="a.status"></span>
<span class="needs-answer" x-show="a.status === 'paused_for_input'">needs answer</span>
</li>
</template>
</ul>
</section>
<!-- Agent detail -->
<section class="panel detail" x-show="selectedAgentName">
<h3>
<span x-text="selectedAgentName"></span>
<span class="badge" x-show="selectedAgent" :class="badgeClass('status', selectedAgent?.status)"
x-text="selectedAgent?.status"></span>
</h3>
<!-- Checkmark -->
<div class="card">
<h4>Checkmark</h4>
<p class="muted" x-show="checkmarkMissing">No checkpoint recorded yet.</p>
<dl class="kv" x-show="checkmark && !checkmarkMissing">
<dt>Status</dt>
<dd><span class="badge" :class="badgeClass('status', checkmark?.status)" x-text="checkmark?.status"></span></dd>
<dt>Where it stopped</dt>
<dd x-text="checkmark?.where_it_stopped || '—'"></dd>
<dt>Open question</dt>
<dd x-text="checkmark?.open_question || '—'"></dd>
<dt>Next steps</dt>
<dd>
<ul class="next-steps" x-show="checkmark?.next_steps?.length">
<template x-for="(step, i) in (checkmark?.next_steps || [])" :key="i">
<li x-text="step"></li>
</template>
</ul>
<span x-show="!checkmark?.next_steps?.length"></span>
</dd>
<dt>Tests</dt>
<dd>
<span class="badge" :class="badgeClass('gate', checkmark?.tests_status)" x-text="checkmark?.tests_status"></span>
<span class="muted" x-text="fmt(checkmark?.tested_at)"></span>
</dd>
<dt>Build</dt>
<dd>
<span class="badge" :class="badgeClass('gate', checkmark?.build_status)" x-text="checkmark?.build_status"></span>
<span class="muted" x-text="fmt(checkmark?.built_at)"></span>
</dd>
<dt>Checkpoint at</dt>
<dd class="muted" x-text="fmt(checkmark?.checkpoint_at)"></dd>
</dl>
</div>
<!-- Answer form -->
<div class="card answer" x-show="selectedAgent?.status === 'paused_for_input'">
<h4>Answer this question</h4>
<p class="question" x-text="checkmark?.open_question || '(no question text on the checkmark)'"></p>
<textarea x-model="answerText" rows="3" placeholder="Your answer…"></textarea>
<div class="answer-actions">
<button @click="submitAnswer(false)" :disabled="answerBusy || !answerText.trim()">Answer</button>
<button class="primary" @click="submitAnswer(true)" :disabled="answerBusy || !answerText.trim()">Answer &amp; Resume</button>
</div>
<p class="answer-msg" :class="{ error: answerError }" x-show="answerMsg" x-text="answerMsg"></p>
</div>
<!-- Log -->
<div class="card">
<h4>Log <span class="muted">(newest first)</span></h4>
<p class="muted" x-show="log.length === 0">No log entries.</p>
<div class="table-wrap" x-show="log.length">
<table class="log">
<thead>
<tr><th>When</th><th>Status</th><th>Summary</th><th>Q / A</th><th>Vis</th><th>Push</th><th>CI</th></tr>
</thead>
<tbody>
<template x-for="e in log" :key="e.id">
<tr>
<td class="muted nowrap" x-text="fmt(e.created_at)"></td>
<td><span class="badge" :class="badgeClass('status', e.status)" x-text="e.status"></span></td>
<td x-text="e.summary || '—'"></td>
<td>
<div x-show="e.question"><strong>Q:</strong> <span x-text="e.question"></span></div>
<div x-show="e.answer"><strong>A:</strong> <span x-text="e.answer"></span></div>
<span x-show="!e.question && !e.answer"></span>
</td>
<td><span class="badge" :class="badgeClass('visibility', e.visibility)" x-text="e.visibility"></span></td>
<td class="mono" x-text="e.push_sha ? e.push_sha.slice(0,7) : '—'"></td>
<td><span class="badge" :class="badgeClass('ci', e.ci_status)" x-text="e.ci_status"></span></td>
</tr>
</template>
</tbody>
</table>
</div>
<div class="pager">
<button @click="pagePrev()" :disabled="logOffset === 0"> Newer</button>
<span class="muted" x-text="'offset ' + logOffset"></span>
<button @click="pageNext()" :disabled="log.length < logLimit">Older </button>
</div>
</div>
</section>
</main>
<!-- SHARED TAB -->
<main class="layout" x-show="tab === 'shared'">
<section class="panel">
<h3>Global feed</h3>
<p class="muted" x-show="shared.log.length === 0">No global log entries.</p>
<div class="table-wrap" x-show="shared.log.length">
<table class="log">
<thead><tr><th>When</th><th>Agent</th><th>Status</th><th>Summary</th><th>CI</th></tr></thead>
<tbody>
<template x-for="e in shared.log" :key="e.id">
<tr>
<td class="muted nowrap" x-text="fmt(e.created_at)"></td>
<td class="mono" x-text="e.agent_id"></td>
<td><span class="badge" :class="badgeClass('status', e.status)" x-text="e.status"></span></td>
<td x-text="e.summary || '—'"></td>
<td><span class="badge" :class="badgeClass('ci', e.ci_status)" x-text="e.ci_status"></span></td>
</tr>
</template>
</tbody>
</table>
</div>
</section>
<section class="panel">
<h3>Shared context <span class="muted">(read-only)</span></h3>
<p class="muted" x-show="shared.context.length === 0">No shared context keys.</p>
<div class="table-wrap" x-show="shared.context.length">
<table class="log">
<thead><tr><th>Key</th><th>Value</th><th>Updated</th></tr></thead>
<tbody>
<template x-for="c in shared.context" :key="c.key">
<tr>
<td class="mono" x-text="c.key"></td>
<td x-text="c.value"></td>
<td class="muted nowrap" x-text="fmt(c.updated_at)"></td>
</tr>
</template>
</tbody>
</table>
</div>
</section>
</main>
</div>
</template>
</body>
</html>
+179
View File
@@ -0,0 +1,179 @@
/* Handler web UI — plain CSS, no preprocessor. */
:root {
--bg: #0f1419;
--panel: #1a2029;
--card: #212936;
--border: #2d3644;
--text: #e6e9ee;
--muted: #8b96a5;
--accent: #4c8dff;
--blue: #4c8dff;
--green: #35c26a;
--amber: #e8a33d;
--red: #e5484d;
--grey: #5a6572;
}
[x-cloak] { display: none !important; }
* { box-sizing: border-box; }
body {
margin: 0;
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--text);
}
h2, h3, h4 { margin: 0 0 0.5rem; font-weight: 600; }
h3 { font-size: 1rem; }
h4 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.03em; color: var(--muted); }
.muted { color: var(--muted); }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.85em; }
.nowrap { white-space: nowrap; }
.error { color: var(--red); }
button {
background: var(--card);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.4rem 0.8rem;
cursor: pointer;
font: inherit;
}
button:hover:not(:disabled) { border-color: var(--accent); }
button:disabled { opacity: 0.4; cursor: not-allowed; }
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
button.ghost { background: transparent; }
input, textarea, select {
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.4rem 0.6rem;
font: inherit;
width: 100%;
}
textarea { resize: vertical; }
/* --- token modal --- */
.modal-backdrop {
position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.7);
display: flex; align-items: center; justify-content: center;
z-index: 50;
}
.modal {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1.5rem;
width: min(360px, 90vw);
display: flex; flex-direction: column; gap: 0.75rem;
}
/* --- topbar --- */
.topbar {
display: flex; align-items: center; justify-content: space-between;
gap: 1rem; flex-wrap: wrap;
padding: 0.75rem 1rem;
background: var(--panel);
border-bottom: 1px solid var(--border);
}
.brand { font-weight: 700; font-size: 1.05rem; }
.topbar-controls { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
.tabs { display: flex; gap: 0.25rem; }
.tabs button.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.banner { margin: 0; padding: 0.5rem 1rem; background: rgba(229,72,77,0.12); }
/* --- layout --- */
.layout {
display: grid;
grid-template-columns: 300px 1fr;
gap: 1rem;
padding: 1rem;
align-items: start;
}
@media (max-width: 780px) { .layout { grid-template-columns: 1fr; } }
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1rem;
}
/* --- agent list --- */
.agent-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.25rem; }
.agent-list li {
display: flex; align-items: center; gap: 0.5rem;
padding: 0.5rem 0.6rem;
border: 1px solid transparent;
border-radius: 6px;
cursor: pointer;
}
.agent-list li:hover { background: var(--card); }
.agent-list li.selected { background: var(--card); border-color: var(--accent); }
.agent-name { flex: 1; font-weight: 500; }
.needs-answer { font-size: 0.7rem; color: var(--amber); text-transform: uppercase; letter-spacing: 0.03em; }
/* --- detail cards --- */
.detail { display: flex; flex-direction: column; gap: 1rem; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 1rem; }
.kv { display: grid; grid-template-columns: 130px 1fr; gap: 0.4rem 1rem; margin: 0; }
.kv dt { color: var(--muted); }
.kv dd { margin: 0; }
.next-steps { margin: 0; padding-left: 1.1rem; }
.answer .question { background: var(--bg); border-left: 3px solid var(--amber); padding: 0.5rem 0.75rem; border-radius: 4px; }
.answer-actions { display: flex; gap: 0.5rem; margin-top: 0.6rem; }
.answer-msg { margin: 0.5rem 0 0; color: var(--green); }
.answer-msg.error { color: var(--red); }
/* --- tables --- */
.table-wrap { overflow-x: auto; }
table.log { width: 100%; border-collapse: collapse; }
table.log th, table.log td {
text-align: left; padding: 0.4rem 0.6rem;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
table.log th { color: var(--muted); font-weight: 500; font-size: 0.8rem; }
.pager { display: flex; align-items: center; gap: 0.75rem; margin-top: 0.6rem; }
/* --- badges --- */
.badge {
display: inline-block;
padding: 0.1rem 0.5rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
border: 1px solid transparent;
white-space: nowrap;
}
/* fallback for unknown/new vocabulary values */
.badge { background: rgba(90,101,114,0.2); color: var(--grey); }
.badge-status-working { background: rgba(76,141,255,0.18); color: var(--blue); }
.badge-status-paused_for_input { background: rgba(232,163,61,0.18); color: var(--amber); }
.badge-status-blocked { background: rgba(229,72,77,0.18); color: var(--red); }
.badge-status-done { background: rgba(53,194,106,0.18); color: var(--green); }
.badge-gate-pass { background: rgba(53,194,106,0.18); color: var(--green); }
.badge-gate-fail { background: rgba(229,72,77,0.18); color: var(--red); }
.badge-gate-unknown { background: rgba(90,101,114,0.2); color: var(--grey); }
.badge-ci-not_applicable { background: rgba(90,101,114,0.2); color: var(--grey); }
.badge-ci-pending { background: rgba(232,163,61,0.18); color: var(--amber); }
.badge-ci-pass { background: rgba(53,194,106,0.18); color: var(--green); }
.badge-ci-fail { background: rgba(229,72,77,0.18); color: var(--red); }
.badge-visibility-project { background: rgba(90,101,114,0.2); color: var(--grey); }
.badge-visibility-global { background: transparent; color: var(--blue); border-color: var(--blue); }
+13
View File
@@ -48,10 +48,23 @@ class Settings(BaseSettings):
# the "merge locally, push to main" path around the forge-merge approval gate. # the "merge locally, push to main" path around the forge-merge approval gate.
protected_branches: str = "main,master" protected_branches: str = "main,master"
# Serve the bundled web UI (Phase 3) from "/" and "/static". Off => headless, API-only
# deployment (the API contract is identical either way).
ui_enabled: bool = True
# Optional extra origins allowed to call the API cross-origin, for operators who host the
# UI on a different origin than the API. Empty => no CORS middleware, same-origin only
# (the shipped UI is same-origin and needs none). Comma-separated.
cors_origins: str = ""
@property @property
def protected_branch_set(self) -> set[str]: def protected_branch_set(self) -> set[str]:
return {b.strip() for b in self.protected_branches.split(",") if b.strip()} return {b.strip() for b in self.protected_branches.split(",") if b.strip()}
@property
def cors_origin_list(self) -> list[str]:
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
@property @property
def effective_shared_write_token(self) -> str: def effective_shared_write_token(self) -> str:
"""Token required to write shared_context; defaults to the global token.""" """Token required to write shared_context; defaults to the global token."""
+99
View File
@@ -0,0 +1,99 @@
"""Phase 3 UI serving: the bundled web UI is served same-origin and, critically, is
*additive* it must not shadow any existing API route, and both the toggle (UI_ENABLED)
and the optional CORS behave as documented. The frontend JS itself has no test runner
(by design) and is verified via the manual e2e walkthrough in docs/PLAN.md.
"""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
def _reset_caches() -> None:
from handler import config
from handler.db import engine
config.get_settings.cache_clear()
engine.get_engine.cache_clear()
def _fresh_client(monkeypatch, **overrides) -> TestClient:
"""Build an app after applying env overrides — the shared `client` fixture bakes in
defaults, so toggle tests need their own app constructed post-setenv."""
for key, value in overrides.items():
monkeypatch.setenv(key, value)
_reset_caches()
from handler.api.app import create_app
return TestClient(create_app())
# --- shell + assets are served, unauthenticated -------------------------------------
def test_index_served_unauthenticated(client):
res = client.get("/") # no Authorization header
assert res.status_code == 200
assert res.headers["content-type"].startswith("text/html")
assert "<title>Handler" in res.text
# the shell must never inline data or a token
assert "Bearer" not in res.text
@pytest.mark.parametrize(
"path, marker",
[
("/static/app.js", "function app("),
("/static/styles.css", ".badge"),
("/static/alpine.min.js", "Alpine.js"),
],
)
def test_static_assets_served_unauthenticated(client, path, marker):
res = client.get(path) # no auth
assert res.status_code == 200
assert marker in res.text
# --- the static surface must NOT shadow the API ------------------------------------
def test_api_routes_not_shadowed(client, auth):
# /health still open
assert client.get("/health").json() == {"status": "ok"}
# /projects still requires auth (the static mount didn't swallow it)
assert client.get("/projects").status_code == 401
res = client.get("/projects", headers=auth)
assert res.status_code == 200
assert res.json() == []
# "/" is an explicit route, not a catch-all: unknown paths still 404
assert client.get("/does-not-exist").status_code == 404
# --- CORS: off by default, on when configured --------------------------------------
def test_cors_absent_by_default(client):
res = client.get("/health", headers={"Origin": "https://example.com"})
assert res.status_code == 200
assert "access-control-allow-origin" not in {k.lower() for k in res.headers}
def test_cors_present_when_configured(env, monkeypatch):
origin = "https://handler.example.ts.net"
client = _fresh_client(monkeypatch, CORS_ORIGINS=origin)
res = client.get("/health", headers={"Origin": origin})
assert res.status_code == 200
assert res.headers.get("access-control-allow-origin") == origin
# --- UI_ENABLED=false => headless, API intact --------------------------------------
def test_ui_disabled_serves_no_shell_but_api_works(env, monkeypatch, auth):
client = _fresh_client(monkeypatch, UI_ENABLED="false")
assert client.get("/").status_code == 404
assert client.get("/static/app.js").status_code == 404
# API is untouched
assert client.get("/health").status_code == 200
assert client.get("/projects", headers=auth).status_code == 200