mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 10:46:25 +00:00
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:
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Vendored
+8
File diff suppressed because one or more lines are too long
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 & 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>
|
||||||
@@ -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); }
|
||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user