diff --git a/.gitignore b/.gitignore index de59ee70f..07899e405 100644 --- a/.gitignore +++ b/.gitignore @@ -70,6 +70,8 @@ python/robomp/data/ python/robomp/.cache/ python/robomp/src/robomp/static/ python/robomp/web/dist/ +python/robomp/web/verify-out/ +/verify-out/ python/robomp/.env # Local, machine-specific boot perf baseline (see packages/coding-agent/scripts/bench-guard.ts) diff --git a/python/robomp/tests/test_server.py b/python/robomp/tests/test_server.py index 399d7a377..52ee1e17f 100644 --- a/python/robomp/tests/test_server.py +++ b/python/robomp/tests/test_server.py @@ -484,6 +484,17 @@ def test_trigger_triage_replaces_inactive_manual_delivery(env, monkeypatch: pyte app = create_app(cfg) with TestClient(app) as client: db = get_database(cfg.sqlite_path) + db.record_event( + delivery_id="running-octo__widget-7", + event_type="issue_comment", + repo="octo/widget", + issue_key=issue_key("octo/widget", 7), + payload={"action": "created"}, + ) + claimed = db.claim_next_event() + assert claimed is not None + assert claimed.delivery_id == "running-octo__widget-7" + db.record_event( delivery_id=delivery, event_type="issues", @@ -632,6 +643,17 @@ def test_trigger_retry_by_delivery_id_requeues(env, monkeypatch: pytest.MonkeyPa app = create_app(cfg) with TestClient(app) as client: db = get_database(cfg.sqlite_path) + db.record_event( + delivery_id="running-widget-4", + event_type="issue_comment", + repo="octo/widget", + issue_key=issue_key("octo/widget", 4), + payload={"action": "created"}, + ) + claimed = db.claim_next_event() + assert claimed is not None + assert claimed.delivery_id == "running-widget-4" + db.record_event( delivery_id="d-old", event_type="issues", @@ -658,6 +680,17 @@ def test_trigger_retry_by_issue_finds_latest_non_skipped_event(env, monkeypatch: with TestClient(app) as client: db = get_database(cfg.sqlite_path) key = issue_key("octo/widget", 9) + db.record_event( + delivery_id="running-widget-9", + event_type="issue_comment", + repo="octo/widget", + issue_key=key, + payload={"action": "created"}, + ) + claimed = db.claim_next_event() + assert claimed is not None + assert claimed.delivery_id == "running-widget-9" + db.record_event( delivery_id="d-old-1", event_type="issues", diff --git a/python/robomp/tests/test_status_contract.py b/python/robomp/tests/test_status_contract.py new file mode 100644 index 000000000..8075bc42b --- /dev/null +++ b/python/robomp/tests/test_status_contract.py @@ -0,0 +1,340 @@ +"""Contract test for the robomp server API response, producing status-contract.json.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest +from fastapi.testclient import TestClient + +from robomp.config import Settings, reset_settings_cache +from robomp.db import get_database +from robomp.server import create_app + + +# Runtime/timestamp fields vary every run; normalize them so an ordinary pytest +# run regenerates a byte-identical committed fixture instead of dirtying it. +_VOLATILE_TS_KEYS = {"received_at", "started_at", "last_tool_ts", "updated_at"} +_FIXED_TS = "2024-01-01T00:00:00+00:00" + + +def _normalize_for_fixture(value: Any) -> Any: + if isinstance(value, dict): + normalized: dict[str, Any] = {} + for key, item in value.items(): + if key == "uptime_seconds": + normalized[key] = 0.0 + elif key in _VOLATILE_TS_KEYS and item is not None: + normalized[key] = _FIXED_TS + else: + normalized[key] = _normalize_for_fixture(item) + return normalized + if isinstance(value, list): + return [_normalize_for_fixture(item) for item in value] + return value + + +def test_status_contract(settings: Settings) -> None: + app = create_app(settings) + with TestClient(app) as client: + # Seed AFTER startup: + db = get_database(settings.sqlite_path) + + # 1. A running issue with live detail: + db.upsert_issue( + key="octo/widget#1", + state="reproducing", + repo="octo/widget", + number=1, + branch="farm/abc12345/fix", + pr_number=77, + ) + db.record_event( + delivery_id="run-x", + event_type="issue_comment", + repo="octo/widget", + issue_key="octo/widget#1", + payload={"action": "created"}, + ) + # claim_next_event will return run-x since it is queued + claimed = db.claim_next_event() + assert claimed is not None + assert claimed.delivery_id == "run-x" + db.set_event_model("run-x", "anthropic/claude-3-5-sonnet") + db.log_tool_call(issue_key="octo/widget#1", tool="edit", args={}) + + # 2. A failed issue: + db.upsert_issue( + key="octo/widget#2", + state="fixing", + repo="octo/widget", + number=2, + ) + db.record_event( + delivery_id="failed-x", + event_type="issues", + repo="octo/widget", + issue_key="octo/widget#2", + state="failed", + last_error="repro diverged", + payload={"action": "opened"}, + ) + + # 3. A superseded failed issue (older failed event + newer done event): + db.upsert_issue( + key="octo/widget#3", + state="fixing", + repo="octo/widget", + number=3, + ) + db.record_event( + delivery_id="superseded-failed", + event_type="issues", + repo="octo/widget", + issue_key="octo/widget#3", + state="failed", + last_error="old error", + payload={"action": "opened"}, + ) + db.record_event( + delivery_id="new-done", + event_type="issues", + repo="octo/widget", + issue_key="octo/widget#3", + state="done", + payload={"action": "opened"}, + ) + + # 4. An issue-less failed (orphan failed): + db.record_event( + delivery_id="orphan-failed-x", + event_type="issues", + repo="octo/widget", + issue_key=None, + state="failed", + last_error="orphan failed error", + payload={"action": "opened"}, + ) + + # 5. A terminal issue: + db.upsert_issue( + key="octo/widget#4", + state="merged", + repo="octo/widget", + number=4, + ) + db.record_event( + delivery_id="terminal-done", + event_type="issues", + repo="octo/widget", + issue_key="octo/widget#4", + state="done", + payload={"action": "opened"}, + ) + + # 6. A queued issue: + db.upsert_issue( + key="octo/widget#5", + state="new", + repo="octo/widget", + number=5, + ) + db.record_event( + delivery_id="queued-x", + event_type="issues", + repo="octo/widget", + issue_key="octo/widget#5", + state="queued", + payload={"action": "opened"}, + ) + + # 7. An active issue (no latest event): + db.upsert_issue( + key="octo/widget#6", + state="new", + repo="octo/widget", + number=6, + ) + + resp = client.get("/api/status") + assert resp.status_code == 200 + data = resp.json() + + # Assert Python-side: top-level keys exactly: + expected_keys = {"runtime", "event_counts", "issue_event_counts", "running_events", "inflight", "issues", "recent_events"} + assert set(data.keys()) == expected_keys + + # check running_events[0] keys: + running_ev = data["running_events"] + assert len(running_ev) == 1 + assert set(running_ev[0].keys()) == { + "delivery_id", "event_type", "repo", "issue_key", "received_at", + "started_at", "attempts", "model", "last_tool", "last_tool_ts" + } + assert running_ev[0]["model"] == "anthropic/claude-3-5-sonnet" + assert running_ev[0]["last_tool"] == "edit" + + # check issues keys / latest_event keys: + for issue_row in data["issues"]: + assert set(issue_row.keys()) == { + "key", "repo", "number", "branch", "pr_number", + "state", "classification", "updated_at", "latest_event", + } + latest = issue_row["latest_event"] + if latest is not None: + assert set(latest.keys()) == { + "delivery_id", "event_type", "state", "attempts", "received_at", "last_error" + } + + # check runtime: + assert data["runtime"]["repo_allowlist"] == ["octo/widget"] + + # Write fixture: + fixture_path = Path(__file__).parent.parent / "web/test/fixtures/status-contract.json" + fixture_path.parent.mkdir(parents=True, exist_ok=True) + fixture_path.write_text( + json.dumps(_normalize_for_fixture(data), indent=2), encoding="utf-8" + ) + + +def _enable_replay(monkeypatch: pytest.MonkeyPatch) -> str: + token = "trigger-secret" + monkeypatch.setenv("ROBOMP_REPLAY_TOKEN", token) + reset_settings_cache() + return token + + +def test_cancel_happy_path(env, monkeypatch: pytest.MonkeyPatch) -> None: + token = _enable_replay(monkeypatch) + cfg = Settings() + cfg.ensure_paths() + app = create_app(cfg) + with TestClient(app) as client: + db = get_database(cfg.sqlite_path) + db.record_event( + delivery_id="run-cancel-1", + event_type="issues", + repo="octo/widget", + issue_key="octo/widget#1", + payload={"action": "opened"}, + ) + # claim it to make it running + claimed = db.claim_next_event() + assert claimed is not None + assert claimed.delivery_id == "run-cancel-1" + + # Happy path + resp = client.post( + "/api/cancel", + json={"delivery_id": "run-cancel-1"}, + headers={"X-Robomp-Replay-Token": token}, + ) + assert resp.status_code == 202 + assert resp.json() == { + "delivery": "run-cancel-1", + "fired": False, + "previous_state": "running", + } + + +def test_cancel_errors_and_gating(env, monkeypatch: pytest.MonkeyPatch) -> None: + token = _enable_replay(monkeypatch) + cfg = Settings() + cfg.ensure_paths() + app = create_app(cfg) + with TestClient(app) as client: + db = get_database(cfg.sqlite_path) + db.record_event( + delivery_id="run-cancel-2", + event_type="issues", + repo="octo/widget", + issue_key="octo/widget#1", + payload={"action": "opened"}, + ) + # Not claimed, so it is queued + + # 404 unknown + resp = client.post( + "/api/cancel", + json={"delivery_id": "nope"}, + headers={"X-Robomp-Replay-Token": token}, + ) + assert resp.status_code == 404 + + # 401 with a bad token (header present but wrong value) + resp = client.post( + "/api/cancel", + json={"delivery_id": "run-cancel-2"}, + headers={"X-Robomp-Replay-Token": "bad-token"}, + ) + assert resp.status_code == 401 + + # 401 with the header missing entirely (distinct from a bad token) + resp = client.post( + "/api/cancel", + json={"delivery_id": "run-cancel-2"}, + ) + assert resp.status_code == 401 + + # with replay disabled (token not set) -> 404 + monkeypatch.setenv("ROBOMP_REPLAY_TOKEN", "") + reset_settings_cache() + cfg_disabled = Settings() + cfg_disabled.ensure_paths() + app_disabled = create_app(cfg_disabled) + with TestClient(app_disabled) as client: + resp = client.post( + "/api/cancel", + json={"delivery_id": "run-cancel-2"}, + headers={"X-Robomp-Replay-Token": token}, + ) + assert resp.status_code == 404 + + +def test_retry_state_transition(env, monkeypatch: pytest.MonkeyPatch) -> None: + token = _enable_replay(monkeypatch) + cfg = Settings() + cfg.ensure_paths() + app = create_app(cfg) + with TestClient(app) as client: + db = get_database(cfg.sqlite_path) + db.record_event( + delivery_id="running-same-issue", + event_type="issue_comment", + repo="octo/widget", + issue_key="octo/widget#1", + payload={"action": "created"}, + ) + claimed = db.claim_next_event() + assert claimed is not None + assert claimed.delivery_id == "running-same-issue" + + db.record_event( + delivery_id="failed-retry-1", + event_type="issues", + repo="octo/widget", + issue_key="octo/widget#1", + state="failed", + last_error="error", + payload={"action": "opened"}, + ) + + resp = client.post( + "/api/trigger", + json={"mode": "retry", "delivery_id": "failed-retry-1"}, + headers={"X-Robomp-Replay-Token": token}, + ) + assert resp.status_code == 202 + assert resp.json() == { + "delivery": "failed-retry-1", + "state": "queued", + "mode": "retry", + } + + # Verify DB side state transition + evt = db.get_event("failed-retry-1") + assert evt is not None + assert evt.state == "queued" + diff --git a/python/robomp/web/index.html b/python/robomp/web/index.html index d9711357f..d504a11a9 100644 --- a/python/robomp/web/index.html +++ b/python/robomp/web/index.html @@ -3,17 +3,18 @@ - + robomp
+ diff --git a/python/robomp/web/package.json b/python/robomp/web/package.json index 681d6471c..642f463f9 100644 --- a/python/robomp/web/package.json +++ b/python/robomp/web/package.json @@ -3,13 +3,14 @@ "private": true, "version": "0.1.0", "type": "module", - "description": "Glassmorphic SolidJS dashboard bundled by Vite and served by robomp's FastAPI app.", + "description": "Flat SolidJS operations console bundled by Vite and served by robomp's FastAPI app.", "homepage": "https://omp.sh", "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "bun test" }, "dependencies": { "solid-js": "catalog:" diff --git a/python/robomp/web/scripts/verify-cards.ts b/python/robomp/web/scripts/verify-cards.ts new file mode 100755 index 000000000..d7b53bcc1 --- /dev/null +++ b/python/robomp/web/scripts/verify-cards.ts @@ -0,0 +1,1083 @@ +#!/usr/bin/env bun +/** + * Robomp Lifecycle Cards Verification Harness. + * Drives headless Chrome to assert layout, contrast, interactions, and edge cases. + */ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import type { HTTPRequest } from "puppeteer-core"; +import puppeteer from "puppeteer-core"; +import type { StatusResponse } from "../src/types"; + +const VIEWPORT = { width: 1280, height: 720, deviceScaleFactor: 1 } as const; + +interface CheckResult { + name: string; + ok: boolean; + detail: string; +} + +const results: CheckResult[] = []; + +// Resolve a Chrome/Chromium binary. Walks the explicit path, PATH lookups via +// Bun.which, and well-known absolute locations; each candidate is confirmed +// with fs.stat so a directory or stale entry never wins. +async function resolveChrome(explicit: string | undefined): Promise { + const candidates: string[] = []; + const fromEnv = explicit ?? process.env.PUPPETEER_EXECUTABLE_PATH ?? process.env.CHROME_PATH; + if (fromEnv) candidates.push(fromEnv); + for (const name of ["google-chrome-stable", "google-chrome", "chromium", "chromium-browser", "chrome"]) { + const found = Bun.which(name); + if (found) candidates.push(found); + } + candidates.push( + "/usr/bin/google-chrome-stable", + "/usr/bin/google-chrome", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + "/snap/bin/chromium", + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + ); + for (const candidate of candidates) { + try { + const stat = await fs.stat(candidate); + if (stat.isFile()) return candidate; + } catch { + // not present — try the next candidate + } + } + throw new Error("No Chrome/Chromium found. Pass --chrome or set PUPPETEER_EXECUTABLE_PATH."); +} + +const baseStatus: StatusResponse = { + runtime: { + bot_login: "robomp-bot", + repo_allowlist: ["octo/widget"], + max_concurrency: 1, + model: "test-model", + thinking_level: "low", + uptime_seconds: 120, + }, + event_counts: { queued: 0, running: 0, done: 0, failed: 0, skipped: 0 }, + issue_event_counts: { queued: 0, running: 0, done: 0, failed: 0, skipped: 0 }, + running_events: [], + inflight: [], + issues: [], + recent_events: [], +}; + +const populatedStatus: StatusResponse = { + runtime: { + bot_login: "robomp-bot", + repo_allowlist: ["octo/widget"], + max_concurrency: 1, + model: "test-model", + thinking_level: "low", + uptime_seconds: 120, + }, + event_counts: { queued: 1, running: 1, done: 0, failed: 2, skipped: 0 }, + issue_event_counts: { queued: 1, running: 1, done: 0, failed: 2, skipped: 0 }, + running_events: [ + { + delivery_id: "run-del-2", + event_type: "issue_comment", + repo: "octo/widget", + issue_key: "octo/widget#2", + received_at: "2026-06-18T00:00:00Z", + started_at: "2026-06-18T00:01:00Z", + attempts: 1, + model: "claude-3-5", + last_tool: "edit", + last_tool_ts: "2026-06-18T00:01:10Z" + }, + { + delivery_id: "run-del-9", + event_type: "issue_comment", + repo: "octo/widget", + issue_key: "octo/widget#9", + received_at: "2026-06-18T00:00:00Z", + started_at: null, + attempts: 1, + model: null, + last_tool: null, + last_tool_ts: null + }, + { + delivery_id: "run-del-5", + event_type: "issue_comment", + repo: "octo/widget", + issue_key: null, + received_at: "2026-06-18T00:00:00Z", + started_at: "2026-06-18T00:01:00Z", + attempts: 1, + model: "claude-3-5", + last_tool: "edit", + last_tool_ts: null + } + ], + inflight: ["octo/widget#2", "octo/widget#7"], + issues: [ + { + key: "octo/widget#1", + repo: "octo/widget", + number: 1, + branch: null, + pr_number: null, + state: "fixing", + classification: "bug", + updated_at: "2026-06-18T00:00:00Z", + latest_event: { + delivery_id: "fail-del-1", + event_type: "issues", + state: "failed", + attempts: 1, + received_at: "2026-06-18T00:00:00Z", + last_error: "triage failed error" + } + }, + { + key: "octo/widget#2", + repo: "octo/widget", + number: 2, + branch: "fix-branch", + pr_number: 42, + state: "reproducing", + classification: "bug", + updated_at: "2026-06-18T00:00:00Z", + latest_event: { + delivery_id: "run-del-2", + event_type: "issue_comment", + state: "running", + attempts: 1, + received_at: "2026-06-18T00:00:00Z", + last_error: null + } + }, + { + key: "octo/widget#3", + repo: "octo/widget", + number: 3, + branch: null, + pr_number: null, + state: "new", + classification: "bug", + updated_at: "2026-06-18T00:00:00Z", + latest_event: { + delivery_id: "queued-del-3", + event_type: "issues", + state: "queued", + attempts: 1, + received_at: "2026-06-18T00:00:00Z", + last_error: null + } + }, + { + key: "octo/widget#4", + repo: "octo/widget", + number: 4, + branch: null, + pr_number: null, + state: "new", + classification: "bug", + updated_at: "2026-06-18T00:00:00Z", + latest_event: null + }, + { + key: "octo/widget#8", + repo: "octo/widget", + number: 8, + branch: null, + pr_number: null, + state: "fixing", + classification: "bug", + updated_at: "2026-06-18T00:00:00Z", + latest_event: { + delivery_id: "fail-del-8", + event_type: "issues", + state: "failed", + attempts: 1, + received_at: "2026-06-18T00:00:00Z", + last_error: null + } + }, + { + key: "octo/widget#9", + repo: "octo/widget", + number: 9, + branch: null, + pr_number: null, + state: "reproducing", + classification: "bug", + updated_at: "2026-06-18T00:00:00Z", + latest_event: { + delivery_id: "run-del-9", + event_type: "issue_comment", + state: "running", + attempts: 1, + received_at: "2026-06-18T00:00:00Z", + last_error: null + } + }, + { + key: "octo/widget#10", + repo: "octo/widget", + number: 10, + branch: null, + pr_number: null, + state: "done", + classification: "bug", + updated_at: "2026-06-18T00:02:00Z", + latest_event: { + delivery_id: "done-del", + event_type: "issues", + state: "done", + attempts: 1, + received_at: "2026-06-18T00:02:00Z", + last_error: null + } + } + ], + recent_events: [ + { + delivery_id: "superseded-del", + event_type: "issues", + repo: "octo/widget", + issue_key: "octo/widget#10", + state: "failed", + attempts: 1, + received_at: "2026-06-18T00:00:00Z", + last_error: "old error" + }, + { + delivery_id: "fail-del-6", + event_type: "issues", + repo: "octo/widget", + issue_key: null, + state: "failed", + attempts: 1, + received_at: "2026-06-18T00:00:00Z", + last_error: "orphan failed error" + } + ] +}; + +async function main(): Promise { + const outDir = path.resolve(import.meta.dir, "../verify-out/robomp-lifecycle-cards"); + await fs.mkdir(path.join(outDir, "shots"), { recursive: true }); + + const chrome = await resolveChrome(undefined); + console.log(`Chrome resolved: ${chrome}`); + + const browser = await puppeteer.launch({ + headless: true, + executablePath: chrome, + args: [ + "--no-sandbox", + "--disable-setuid-sandbox", + `--window-size=${VIEWPORT.width},${VIEWPORT.height}`, + ], + defaultViewport: { ...VIEWPORT }, + }); + + const page = await browser.newPage(); + await page.setRequestInterception(true); + + let statusMock: StatusResponse = baseStatus; + let statusDelayMs = 0; + let customIndexHtml: string | null = null; + + // Intercept trigger / cancel requests for asserting later. Bodies are + // narrowed to a record (or null) so property checks below stay type-safe + // instead of reaching into `unknown`. + const triggeredRequests: { url: string; method: string; headers: Record; body: Record | null }[] = []; + + page.on("request", async (req: HTTPRequest) => { + const url = new URL(req.url()); + + if (req.resourceType() === "document" && customIndexHtml !== null) { + req.respond({ + status: 200, + contentType: "text/html", + body: customIndexHtml, + }); + return; + } + + if (url.pathname === "/api/status") { + if (statusDelayMs > 0) { + await Bun.sleep(statusDelayMs); + } + req.respond({ + status: 200, + contentType: "application/json", + body: JSON.stringify(statusMock), + }); + } else if (url.pathname === "/api/logs") { + req.respond({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ entries: [], count: 0, limit: 400 }), + }); + } else if (url.pathname === "/api/github/issues") { + req.respond({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ issues: [], errors: [] }), + }); + } else if (url.pathname === "/api/trigger" && req.method() === "POST") { + const parsedBody: Record | null = req.postData() ? (JSON.parse(req.postData()!) as Record) : null; + const deliveryId = parsedBody?.delivery_id; + const mode = parsedBody?.mode; + triggeredRequests.push({ + url: req.url(), + method: req.method(), + headers: req.headers() as Record, + body: parsedBody, + }); + req.respond({ + status: 202, + contentType: "application/json", + body: JSON.stringify({ + delivery: (typeof deliveryId === "string" && deliveryId) ? deliveryId : "manual-trigger", + state: "queued", + mode: (typeof mode === "string" && mode) ? mode : "triage", + }), + }); + } else if (url.pathname === "/api/cancel" && req.method() === "POST") { + const parsedBody: Record | null = req.postData() ? (JSON.parse(req.postData()!) as Record) : null; + const deliveryId = parsedBody?.delivery_id; + triggeredRequests.push({ + url: req.url(), + method: req.method(), + headers: req.headers() as Record, + body: parsedBody, + }); + req.respond({ + status: 202, + contentType: "application/json", + body: JSON.stringify({ + delivery: (typeof deliveryId === "string" && deliveryId) ? deliveryId : "cancel-trigger", + fired: false, + previous_state: "running", + }), + }); + } else { + req.continue(); + } + }); + + const BASE_URL = "http://127.0.0.1:8099/"; + + // 1. Skeleton state + statusMock = baseStatus; + statusDelayMs = 600; + await page.goto(BASE_URL, { waitUntil: "domcontentloaded" }); + + // Wait a small frame but not full delay + await Bun.sleep(100); + const skeletonsCount = await page.evaluate(() => document.querySelectorAll(".rmp-card-skeleton").length); + const cardsCount = await page.evaluate(() => document.querySelectorAll(".rmp-card").length); + results.push({ + name: "skeleton-state", + ok: skeletonsCount >= 2 && cardsCount === 0, + detail: `Found ${skeletonsCount} skeletons and ${cardsCount} cards.`, + }); + await page.screenshot({ path: path.join(outDir, "shots/skeleton.png") }); + + statusDelayMs = 0; // reset delay + + // 2. Empty state + allowlist + statusMock = { + ...baseStatus, + runtime: { + ...baseStatus.runtime, + repo_allowlist: ["octo/widget", "octo/robomp"], + }, + }; + await page.goto(BASE_URL, { waitUntil: "networkidle0" }); + const emptyText = await page.evaluate(() => document.querySelector(".rmp-pipeline-empty")?.textContent ?? ""); + results.push({ + name: "empty-state-allowlist", + ok: emptyText.includes("No active work") && emptyText.includes("watching octo/widget, octo/robomp"), + detail: `Empty text: "${emptyText}"`, + }); + await page.screenshot({ path: path.join(outDir, "shots/empty-dark.png") }); + + // Helper for applying theme + const applyTheme = async (themeName: "dark" | "light") => { + await page.evaluate((theme) => { + localStorage.setItem("omp-robomp-theme", theme); + document.documentElement.dataset.theme = theme; + document.documentElement.style.colorScheme = theme; + }, themeName); + await page.reload({ waitUntil: "networkidle0" }); + }; + + // 3. Populated state - Dark Theme + statusMock = populatedStatus; + await page.goto(BASE_URL, { waitUntil: "networkidle0" }); + await applyTheme("dark"); + + const cards = await page.evaluate(() => { + const elements = document.querySelectorAll(".rmp-card"); + return Array.from(elements).map((el) => { + const key = el.querySelector(".rmp-card-id")?.textContent?.trim() ?? ""; + const bucket = el.getAttribute("data-bucket") ?? ""; + const errorText = el.querySelector(".rmp-card-error")?.textContent?.trim() ?? ""; + const actions = Array.from(el.querySelectorAll(".rmp-card-actions button")).map(b => b.textContent?.trim()); + const metaText = el.querySelector(".rmp-card-meta")?.textContent?.trim() ?? ""; + const stepper = el.querySelector(".rmp-step") !== null; + const currentStep = el.querySelector(".rmp-step-node[data-state='current']") !== null; + const liveStep = el.querySelector(".rmp-step-node[data-live='true']") !== null; + const failedStep = el.querySelector(".rmp-step-node[data-state='failed']") !== null; + return { key, bucket, errorText, actions, metaText, stepper, currentStep, liveStep, failedStep }; + }); + }); + + console.log("All resolved cards:", JSON.stringify(cards)); + const fullFailedCard = cards.find(c => c.key === "bugocto/widget#1"); + const runningCard = cards.find(c => c.key === "bugocto/widget#2"); + const inflightOnlyCard = cards.find(c => c.key === "octo/wid"); + const compactOrphanRunning = cards.find(c => c.key === "run-del-"); + const compactOrphanFailed = cards.find(c => c.key === "fail-del"); + const failedWithoutErr = cards.find(c => c.key === "bugocto/widget#8"); + const runningWithInvalidTs = cards.find(c => c.key === "bugocto/widget#9"); + const queuedCard = cards.find(c => c.key === "bugocto/widget#3"); + const activeCard = cards.find(c => c.key === "bugocto/widget#4"); + + // Check failed full card + results.push({ + name: "failed-full-card", + ok: !!fullFailedCard && fullFailedCard.bucket === "failed" && fullFailedCard.errorText === "triage failed error" && fullFailedCard.actions.includes("retry"), + detail: `Failed full card check: ${JSON.stringify(fullFailedCard)}`, + }); + + // Check running full card + results.push({ + name: "running-full-card", + ok: !!runningCard && runningCard.bucket === "running" && runningCard.metaText.includes("claude-3-5") && runningCard.metaText.includes("last action edit") && runningCard.actions.includes("cancel"), + detail: `Running full card check: ${JSON.stringify(runningCard)}`, + }); + + // Check inflight-only card + results.push({ + name: "inflight-only-card", + ok: !!inflightOnlyCard && inflightOnlyCard.bucket === "running" && inflightOnlyCard.metaText.includes("held by pool") && inflightOnlyCard.actions.length === 0, + detail: `Inflight-only card check: ${JSON.stringify(inflightOnlyCard)}`, + }); + + // Check compact orphan running + results.push({ + name: "compact-orphan-running", + ok: !!compactOrphanRunning && compactOrphanRunning.bucket === "running" && !compactOrphanRunning.stepper && compactOrphanRunning.actions.includes("cancel"), + detail: `Compact orphan running check: ${JSON.stringify(compactOrphanRunning)}`, + }); + + // Check compact orphan failed + results.push({ + name: "compact-orphan-failed", + ok: !!compactOrphanFailed && compactOrphanFailed.bucket === "failed" && !compactOrphanFailed.stepper && compactOrphanFailed.actions.includes("retry"), + detail: `Compact orphan failed check: ${JSON.stringify(compactOrphanFailed)}`, + }); + + // Check failed without error text + results.push({ + name: "failed-without-error-text", + ok: !!failedWithoutErr && failedWithoutErr.bucket === "failed" && failedWithoutErr.errorText === "" && failedWithoutErr.actions.includes("retry"), + detail: `Failed without error text check: ${JSON.stringify(failedWithoutErr)}`, + }); + + // Check invalid/missing started_at on running item elapsed renders "—" + results.push({ + name: "invalid-missing-started-at-elapsed", + ok: !!runningWithInvalidTs && runningWithInvalidTs.metaText.includes("elapsed —"), + detail: `Running with invalid ts check: ${JSON.stringify(runningWithInvalidTs)}`, + }); + + // Check queued full card — non-terminal issue whose latest event is queued. + results.push({ + name: "queued-full-card", + ok: !!queuedCard && queuedCard.bucket === "queued" && queuedCard.stepper && queuedCard.currentStep && queuedCard.actions.length === 0, + detail: `Queued full card check: ${JSON.stringify(queuedCard)}`, + }); + + // Check active full card — non-terminal issue with no latest event yet. + results.push({ + name: "active-full-card", + ok: !!activeCard && activeCard.bucket === "active" && activeCard.stepper && activeCard.currentStep && activeCard.actions.length === 0, + detail: `Active full card check: ${JSON.stringify(activeCard)}`, + }); + + // Failed cards must sort ahead of every other bucket: the first rendered + // card is a failed card (DOM order mirrors buildWorkItems sort order). + results.push({ + name: "failed-sorts-first", + ok: cards.length > 0 && cards[0].bucket === "failed", + detail: `First rendered card: ${JSON.stringify(cards[0] ?? null)}`, + }); + + // Stepper state indicators (existing data attributes, not invented): + // the running card's current node is marked live; the failed card's + // current node carries the failed state. + results.push({ + name: "stepper-running-live-node", + ok: !!runningCard && runningCard.currentStep && runningCard.liveStep, + detail: `Running card stepper currentStep=${runningCard?.currentStep} liveStep=${runningCard?.liveStep}`, + }); + results.push({ + name: "stepper-failed-node", + ok: !!fullFailedCard && fullFailedCard.failedStep, + detail: `Failed card stepper failedStep=${fullFailedCard?.failedStep}`, + }); + + // Check superseded exclusion + const hasSuperseded = await page.evaluate(() => document.body.textContent?.includes("superseded-del") ?? false); + results.push({ + name: "superseded-exclusion-visual", + ok: !hasSuperseded, + detail: `Superseded text found: ${hasSuperseded}`, + }); + const debugTextContrast = await page.evaluate(() => { + const getLuminance = (str: string): number => { + const s = str.trim().toLowerCase(); + if (s.startsWith("lab")) { + const m = s.match(/lab\(([\d.]+)\s+([\d.-]+)\s+([\d.-]+)/); + if (m) { + const L = parseFloat(m[1]); + return L > 8 ? Math.pow((L + 16) / 116, 3) : L / 903.3; + } + } + const mRgb = s.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); + if (mRgb) { + const r = parseInt(mRgb[1]), g = parseInt(mRgb[2]), b = parseInt(mRgb[3]); + const a = [r, g, b].map(v => { + v /= 255; + return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); + }); + return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722; + } + return 0; + }; + + const getContrast = (c1: string, c2: string): number => { + const lum1 = getLuminance(c1); + const lum2 = getLuminance(c2); + return (Math.max(lum1, lum2) + 0.05) / (Math.min(lum1, lum2) + 0.05); + }; + + const getBgColor = (el: Element, fallback = "lab(15 0 0)"): string => { + let cur: Element | null = el; + while (cur) { + const bg = window.getComputedStyle(cur).backgroundColor; + if (bg && bg !== "transparent" && !bg.includes("rgba(0, 0, 0, 0)")) { + return bg; + } + cur = cur.parentElement; + } + return fallback; + }; + + const meta = document.querySelector(".rmp-card-meta"); + const label = document.querySelector(".rmp-step-label"); + const error = document.querySelector(".rmp-card-error"); + const pill = document.querySelector(".pill"); + + const getElDetails = (el: Element | null) => { + if (!el) return null; + const fg = window.getComputedStyle(el).color; + const bg = getBgColor(el); + const ratio = getContrast(fg, bg); + return { + tag: el.tagName, + class: el.className, + text: el.textContent?.trim()?.slice(0, 30), + fgStr: fg, + bgStr: bg, + ratio + }; + }; + + return { + meta: getElDetails(meta), + label: getElDetails(label), + error: getElDetails(error), + pill: getElDetails(pill), + }; + }); + console.log("Contrast diagnostics (dark theme):", JSON.stringify(debugTextContrast, null, 2)); + + // Contrast & structural checks (dark theme) + const structuralCheck = await page.evaluate(() => { + const cards = Array.from(document.querySelectorAll(".rmp-card")); + + // borders and shadows + const borderShadowOk = cards.every(el => { + const style = window.getComputedStyle(el); + const radius = style.borderRadius; + const shadow = style.boxShadow; + const bl = parseFloat(style.borderLeftWidth); + const br = parseFloat(style.borderRightWidth); + return radius === "8px" && (shadow === "none" || shadow.includes("0px 0px 0px") || shadow === "") && bl <= 1.05 && br <= 1.05; + }); + + const getLuminance = (str: string): number => { + const s = str.trim().toLowerCase(); + if (s.startsWith("lab")) { + const m = s.match(/lab\(([\d.]+)\s+([\d.-]+)\s+([\d.-]+)/); + if (m) { + const L = parseFloat(m[1]); + return L > 8 ? Math.pow((L + 16) / 116, 3) : L / 903.3; + } + } + const mRgb = s.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); + if (mRgb) { + const r = parseInt(mRgb[1]), g = parseInt(mRgb[2]), b = parseInt(mRgb[3]); + const a = [r, g, b].map(v => { + v /= 255; + return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); + }); + return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722; + } + return 0; + }; + + const getContrast = (c1: string, c2: string): number => { + const lum1 = getLuminance(c1); + const lum2 = getLuminance(c2); + return (Math.max(lum1, lum2) + 0.05) / (Math.min(lum1, lum2) + 0.05); + }; + + const getBgColor = (el: Element): string => { + let cur: Element | null = el; + while (cur) { + const bg = window.getComputedStyle(cur).backgroundColor; + if (bg && bg !== "transparent" && !bg.includes("rgba(0, 0, 0, 0)")) { + return bg; + } + cur = cur.parentElement; + } + return "lab(15 0 0)"; + }; + + const metaElements = Array.from(document.querySelectorAll(".rmp-card-meta")); + const labelElements = Array.from(document.querySelectorAll(".rmp-step-label")); + const errorElements = Array.from(document.querySelectorAll(".rmp-card-error")); + const pillElements = Array.from(document.querySelectorAll(".pill")); + + const textElements = [...metaElements, ...labelElements, ...errorElements]; + const textContrastOk = textElements.every(el => { + const fg = window.getComputedStyle(el).color; + const bg = getBgColor(el); + const ratio = getContrast(fg, bg); + return ratio >= 4.5; + }); + + const pillContrastOk = pillElements.every(el => { + const fg = window.getComputedStyle(el).color; + const bg = getBgColor(el); + const ratio = getContrast(fg, bg); + return ratio >= 3.0; + }); + + return { borderShadowOk, textContrastOk, pillContrastOk }; + }); + + results.push({ + name: "dark-theme-structural", + ok: structuralCheck.borderShadowOk, + detail: "Computed borderRadius, boxShadow, and border widths match constraints.", + }); + + results.push({ + name: "dark-theme-text-contrast", + ok: structuralCheck.textContrastOk, + detail: "Text elements meta/label/error contrast ratio >= 4.5 against card backgrounds.", + }); + + results.push({ + name: "dark-theme-pill-contrast", + ok: structuralCheck.pillContrastOk, + detail: "Pill elements contrast ratio >= 3.0 against card backgrounds.", + }); + + await page.screenshot({ path: path.join(outDir, "shots/populated-dark-replay.png") }); + + // 4. Populated state - Light Theme + await applyTheme("light"); + + const lightThemeChecks = await page.evaluate(() => { + const getLuminance = (str: string): number => { + const s = str.trim().toLowerCase(); + if (s.startsWith("lab")) { + const m = s.match(/lab\(([\d.]+)\s+([\d.-]+)\s+([\d.-]+)/); + if (m) { + const L = parseFloat(m[1]); + return L > 8 ? Math.pow((L + 16) / 116, 3) : L / 903.3; + } + } + const mRgb = s.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); + if (mRgb) { + const r = parseInt(mRgb[1]), g = parseInt(mRgb[2]), b = parseInt(mRgb[3]); + const a = [r, g, b].map(v => { + v /= 255; + return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); + }); + return a[0] * 0.2126 + a[1] * 0.7152 + a[2] * 0.0722; + } + return 1.0; + }; + + const getContrast = (c1: string, c2: string): number => { + const lum1 = getLuminance(c1); + const lum2 = getLuminance(c2); + return (Math.max(lum1, lum2) + 0.05) / (Math.min(lum1, lum2) + 0.05); + }; + + const getBgColor = (el: Element): string => { + let cur: Element | null = el; + while (cur) { + const bg = window.getComputedStyle(cur).backgroundColor; + if (bg && bg !== "transparent" && !bg.includes("rgba(0, 0, 0, 0)")) { + return bg; + } + cur = cur.parentElement; + } + return "lab(100 0 0)"; + }; + + const metaElements = Array.from(document.querySelectorAll(".rmp-card-meta")); + const labelElements = Array.from(document.querySelectorAll(".rmp-step-label")); + const errorElements = Array.from(document.querySelectorAll(".rmp-card-error")); + const pillElements = Array.from(document.querySelectorAll(".pill")); + + const textElements = [...metaElements, ...labelElements, ...errorElements]; + const textContrastOk = textElements.every(el => { + const fg = window.getComputedStyle(el).color; + const bg = getBgColor(el); + const ratio = getContrast(fg, bg); + return ratio >= 4.5; + }); + + const pillContrastOk = pillElements.every(el => { + const fg = window.getComputedStyle(el).color; + const bg = getBgColor(el); + const ratio = getContrast(fg, bg); + return ratio >= 3.0; + }); + + return { textContrastOk, pillContrastOk }; + }); + + results.push({ + name: "light-theme-text-contrast", + ok: lightThemeChecks.textContrastOk, + detail: "Light theme: text elements meta/label/error contrast ratio >= 4.5.", + }); + + results.push({ + name: "light-theme-pill-contrast", + ok: lightThemeChecks.pillContrastOk, + detail: "Light theme: pill elements contrast ratio >= 3.0.", + }); + + await page.screenshot({ path: path.join(outDir, "shots/populated-light-replay.png") }); + + // 5. Read-only pass + // Fetch the real index.html from server, and replace the robomp-config script tag + const resp = await fetch(BASE_URL); + let html = await resp.text(); + html = html.replace( + /' + ); + customIndexHtml = html; + + // Reload page to parse with read-only config + await page.goto(BASE_URL, { waitUntil: "networkidle0" }); + await applyTheme("dark"); + + const readonly = await page.evaluate(() => { + const bar = document.querySelector(".rmp-trigger-bar"); + return { + chipText: bar?.querySelector(".rmp-readonly-chip")?.textContent?.trim() ?? "", + barInputs: document.querySelectorAll(".rmp-trigger-bar input").length, + barButtons: document.querySelectorAll(".rmp-trigger-bar button").length, + cardActionButtons: document.querySelectorAll(".rmp-card-actions button").length, + cardActionWrappers: document.querySelectorAll(".rmp-card-actions").length, + }; + }); + + results.push({ + name: "readonly-actions-disabled", + ok: + readonly.chipText.includes("trigger disabled · read-only") && + readonly.barInputs === 0 && + readonly.barButtons === 0 && + readonly.cardActionButtons === 0 && + readonly.cardActionWrappers === 0, + detail: `Trigger-bar chip: "${readonly.chipText}". Trigger-bar inputs: ${readonly.barInputs}, buttons: ${readonly.barButtons}. Card action buttons: ${readonly.cardActionButtons}, wrappers: ${readonly.cardActionWrappers}.`, + }); + + await page.screenshot({ path: path.join(outDir, "shots/populated-dark-readonly.png") }); + + customIndexHtml = null; // restore index intercept + + // 6. Responsive check + await page.setViewport({ width: 380, height: 720 }); + await page.goto(BASE_URL, { waitUntil: "networkidle0" }); + await applyTheme("dark"); + + const responsiveOk = await page.evaluate(() => { + const grid = document.querySelector(".rmp-card-grid"); + if (!grid) return false; + const cards = Array.from(document.querySelectorAll(".rmp-card")); + + // Single column: every card fits within the grid's content width. A card + // wider than the grid (allowing 1px sub-pixel rounding) means it broke out + // of the column and must fail. + const gridWidth = grid.getBoundingClientRect().width; + const allCardsWithinGrid = cards.every(c => { + const cardWidth = c.getBoundingClientRect().width; + return cardWidth <= gridWidth + 1; + }); + + // Content doesn't overflow card container. + const noCardOverflow = cards.every(c => { + return c.scrollWidth <= c.clientWidth + 1; + }); + + // The page itself must not scroll horizontally: document and body content + // width stay within their client width (1px tolerance for rounding). + const docEl = document.documentElement; + const noPageOverflow = + docEl.scrollWidth <= docEl.clientWidth + 1 && + document.body.scrollWidth <= document.body.clientWidth + 1; + + return allCardsWithinGrid && noCardOverflow && noPageOverflow; + }); + const debugResponsive = await page.evaluate(() => { + const grid = document.querySelector(".rmp-card-grid"); + const cards = Array.from(document.querySelectorAll(".rmp-card")); + const docEl = document.documentElement; + return { + gridWidth: grid ? grid.getBoundingClientRect().width : 0, + maxCardWidth: cards.reduce((m, c) => Math.max(m, c.getBoundingClientRect().width), 0), + docScrollWidth: docEl.scrollWidth, + docClientWidth: docEl.clientWidth, + bodyScrollWidth: document.body.scrollWidth, + bodyClientWidth: document.body.clientWidth, + }; + }); + console.log("Debug responsive widths:", JSON.stringify(debugResponsive)); + results.push({ + name: "responsive-single-column-no-overflow", + ok: responsiveOk, + detail: `At width 380: every card fits within the grid and neither cards nor the page scroll horizontally. Grid: ${debugResponsive.gridWidth}, maxCard: ${debugResponsive.maxCardWidth}, doc: ${debugResponsive.docScrollWidth}/${debugResponsive.docClientWidth}, body: ${debugResponsive.bodyScrollWidth}/${debugResponsive.bodyClientWidth}`, + }); + + await page.screenshot({ path: path.join(outDir, "shots/responsive-dark.png") }); + + // Restore viewport + await page.setViewport({ ...VIEWPORT }); + + // 7. Reduced motion check. + await page.emulateMediaFeatures([{ name: "prefers-reduced-motion", value: "reduce" }]); + + // 7a. Skeleton phase — delay the status response so the loading skeleton is + // actually on screen, then assert its pulse animation is suppressed + // WHILE skeleton elements exist (the old check ran after data loaded, so + // no skeleton was present and the assertion was vacuous). + statusMock = baseStatus; + statusDelayMs = 600; + await page.goto(BASE_URL, { waitUntil: "domcontentloaded" }); + await Bun.sleep(150); // inside the delay window: skeleton still rendered + const reducedSkeleton = await page.evaluate(() => { + const skeletons = Array.from(document.querySelectorAll(".rmp-card-skeleton")); + return { + count: skeletons.length, + allDisabled: skeletons.every((el) => window.getComputedStyle(el).animationName === "none"), + }; + }); + statusDelayMs = 0; + results.push({ + name: "reduced-motion-skeleton-animation-disabled", + ok: reducedSkeleton.count >= 2 && reducedSkeleton.allDisabled, + detail: `Skeletons present: ${reducedSkeleton.count}, all animations disabled: ${reducedSkeleton.allDisabled}`, + }); + + // 7b. Populated phase — once cards render, the live stepper node and card + // element exist; assert their animation/transition are suppressed too. + statusMock = populatedStatus; + await page.goto(BASE_URL, { waitUntil: "networkidle0" }); + const reducedPopulated = await page.evaluate(() => { + const liveStepNode = document.querySelector(".rmp-step-node[data-live='true']"); + const nodeStyle = liveStepNode ? window.getComputedStyle(liveStepNode).animationName : "none"; + const card = document.querySelector(".rmp-card"); + const cardTransition = card ? window.getComputedStyle(card).transition : "none"; + return { + liveNodePresent: liveStepNode !== null, + nodeAnimationOff: nodeStyle === "none" || nodeStyle === "", + cardTransitionOff: cardTransition.includes("none") || cardTransition === "all 0s" || cardTransition === "", + }; + }); + results.push({ + name: "reduced-motion-node-and-card-animations-disabled", + ok: reducedPopulated.liveNodePresent && reducedPopulated.nodeAnimationOff && reducedPopulated.cardTransitionOff, + detail: `Live node present: ${reducedPopulated.liveNodePresent}, node animation off: ${reducedPopulated.nodeAnimationOff}, card transition off: ${reducedPopulated.cardTransitionOff}`, + }); + + // Restore media features + await page.emulateMediaFeatures([]); + + // 8. Interactions (clicks). + // Serve a replay-enabled index with a KNOWN token so every privileged + // request carries an auditable `X-Robomp-Replay-Token: trigger-secret` + // header, independent of whatever token the live server was started with. + const REPLAY_TOKEN = "trigger-secret"; + const replayResp = await fetch(BASE_URL); + const replayHtml = (await replayResp.text()).replace( + /`, + ); + customIndexHtml = replayHtml; + statusMock = populatedStatus; + await page.goto(BASE_URL, { waitUntil: "networkidle0" }); + await applyTheme("dark"); + + // A. Retry click — exactly one POST /api/trigger {mode:"retry", delivery_id} + // carrying the replay token. + triggeredRequests.length = 0; + await page.evaluate(() => { + const btn = document.querySelector(".rmp-card[data-bucket='failed'] button") as HTMLElement | null; + btn?.click(); + }); + await Bun.sleep(100); // wait for request and state update + const retryReqs = triggeredRequests.filter( + (r) => r.url.endsWith("/api/trigger") && r.body?.mode === "retry" && r.body?.delivery_id === "fail-del-1", + ); + const statusTextA = await page.evaluate(() => document.querySelector(".rmp-trigger-bar-status")?.textContent ?? ""); + results.push({ + name: "interaction-retry-click", + ok: retryReqs.length === 1 && retryReqs[0].headers["x-robomp-replay-token"] === REPLAY_TOKEN && statusTextA.includes("queued retry: fail-del-1"), + detail: `Matching retry requests: ${retryReqs.length}, token: "${retryReqs[0]?.headers["x-robomp-replay-token"] ?? ""}". Status text: "${statusTextA}"`, + }); + + // B. Cancel click (confirm accepted) — exactly one POST /api/cancel + // {delivery_id} carrying the replay token. + triggeredRequests.length = 0; + await page.evaluate(() => { + window.confirm = () => true; + const card = Array.from(document.querySelectorAll(".rmp-card")).find(c => c.querySelector(".rmp-card-id")?.textContent?.includes("octo/widget#2")); + const btn = card?.querySelector("button.danger") as HTMLElement | null; + btn?.click(); + }); + await Bun.sleep(100); + const cancelReqs = triggeredRequests.filter( + (r) => r.url.endsWith("/api/cancel") && r.body?.delivery_id === "run-del-2", + ); + const statusTextB = await page.evaluate(() => document.querySelector(".rmp-trigger-bar-status")?.textContent ?? ""); + results.push({ + name: "interaction-cancel-click-accepted", + ok: cancelReqs.length === 1 && cancelReqs[0].headers["x-robomp-replay-token"] === REPLAY_TOKEN && statusTextB.includes("cancel signaled: run-del- (fired=false)"), + detail: `Matching cancel requests: ${cancelReqs.length}, token: "${cancelReqs[0]?.headers["x-robomp-replay-token"] ?? ""}". Status text: "${statusTextB}"`, + }); + + // C. Cancel click (confirm dismissed) — verbatim confirm gate suppresses the + // request entirely; assert zero /api/cancel requests fire. + triggeredRequests.length = 0; + await page.evaluate(() => { + window.confirm = () => false; + const card = Array.from(document.querySelectorAll(".rmp-card")).find(c => c.querySelector(".rmp-card-id")?.textContent?.includes("octo/widget#2")); + const btn = card?.querySelector("button.danger") as HTMLElement | null; + btn?.click(); + }); + await Bun.sleep(100); + const dismissedCancelReqs = triggeredRequests.filter((r) => r.url.endsWith("/api/cancel")); + results.push({ + name: "interaction-cancel-click-dismissed", + ok: dismissedCancelReqs.length === 0, + detail: `Cancel requests fired after dismissal: ${dismissedCancelReqs.length}`, + }); + + // D. Trigger bar triage click — exactly one POST /api/trigger + // {mode:"triage", issue} carrying the replay token. + triggeredRequests.length = 0; + await page.type(".rmp-trigger-bar input", "octo/widget#5"); + await page.evaluate(() => { + const btns = Array.from(document.querySelectorAll(".rmp-trigger-bar button")); + const triageBtn = btns.find(b => b.textContent?.trim() === "triage"); + triageBtn?.click(); + }); + await Bun.sleep(100); + const triageReqs = triggeredRequests.filter( + (r) => r.url.endsWith("/api/trigger") && r.body?.mode === "triage" && r.body?.issue === "octo/widget#5", + ); + results.push({ + name: "trigger-bar-triage-click", + ok: triageReqs.length === 1 && triageReqs[0].headers["x-robomp-replay-token"] === REPLAY_TOKEN, + detail: `Matching triage requests: ${triageReqs.length}, token: "${triageReqs[0]?.headers["x-robomp-replay-token"] ?? ""}"`, + }); + + // E. Trigger bar retry latest click — exactly one POST /api/trigger + // {mode:"retry", issue} carrying the replay token. + triggeredRequests.length = 0; + await page.evaluate(() => { + const btns = Array.from(document.querySelectorAll(".rmp-trigger-bar button")); + const retryBtn = btns.find(b => b.textContent?.trim() === "retry latest"); + retryBtn?.click(); + }); + await Bun.sleep(100); + const retryLatestReqs = triggeredRequests.filter( + (r) => r.url.endsWith("/api/trigger") && r.body?.mode === "retry" && r.body?.issue === "octo/widget#5", + ); + results.push({ + name: "trigger-bar-retry-latest-click", + ok: retryLatestReqs.length === 1 && retryLatestReqs[0].headers["x-robomp-replay-token"] === REPLAY_TOKEN, + detail: `Matching retry-latest requests: ${retryLatestReqs.length}, token: "${retryLatestReqs[0]?.headers["x-robomp-replay-token"] ?? ""}"`, + }); + + // F. Trigger bar Enter key — Enter in the input triages; exactly one POST + // /api/trigger {mode:"triage", issue} carrying the replay token. + triggeredRequests.length = 0; + await page.focus(".rmp-trigger-bar input"); + await page.keyboard.press("Enter"); + await Bun.sleep(100); + const enterReqs = triggeredRequests.filter( + (r) => r.url.endsWith("/api/trigger") && r.body?.mode === "triage" && r.body?.issue === "octo/widget#5", + ); + results.push({ + name: "trigger-bar-enter-press", + ok: enterReqs.length === 1 && enterReqs[0].headers["x-robomp-replay-token"] === REPLAY_TOKEN, + detail: `Matching enter requests: ${enterReqs.length}, token: "${enterReqs[0]?.headers["x-robomp-replay-token"] ?? ""}"`, + }); + + customIndexHtml = null; // restore real-index interception + + // Cleanup browser + await browser.close(); + + // Print results + console.log("\n--- Verification checks summary ---"); + let allPass = true; + for (const r of results) { + const status = r.ok ? "PASS" : "FAIL"; + console.log(`[${status}] ${r.name}: ${r.detail}`); + if (!r.ok) allPass = false; + } + + // Write report + const summaryJson = { + generatedAt: new Date().toISOString(), + pass: allPass, + checks: results.map(r => ({ name: r.name, ok: r.ok, detail: r.detail })), + }; + await fs.writeFile(path.join(outDir, "report.json"), JSON.stringify(summaryJson, null, 2), "utf-8"); + console.log(`\nReport written to ${path.join(outDir, "report.json")}`); + + process.exitCode = allPass ? 0 : 1; +} + +main().catch(err => { + console.error("Verification harness crashed:", err); + process.exit(1); +}); diff --git a/python/robomp/web/scripts/verify-live.ts b/python/robomp/web/scripts/verify-live.ts new file mode 100644 index 000000000..a45be1e11 --- /dev/null +++ b/python/robomp/web/scripts/verify-live.ts @@ -0,0 +1,178 @@ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import puppeteer from "puppeteer-core"; + +const VIEWPORT = { width: 1280, height: 720, deviceScaleFactor: 1 } as const; + +async function resolveChrome(explicit: string | undefined): Promise { + const candidates: string[] = []; + const fromEnv = explicit ?? process.env.PUPPETEER_EXECUTABLE_PATH ?? process.env.CHROME_PATH; + if (fromEnv) candidates.push(fromEnv); + for (const name of ["google-chrome-stable", "google-chrome", "chromium", "chromium-browser", "chrome"]) { + const found = Bun.which(name); + if (found) candidates.push(found); + } + candidates.push( + "/usr/bin/google-chrome-stable", + "/usr/bin/google-chrome", + "/usr/bin/chromium", + "/usr/bin/chromium-browser", + "/snap/bin/chromium", + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + ); + for (const candidate of candidates) { + try { + const stat = await fs.stat(candidate); + if (stat.isFile()) return candidate; + } catch { + // not present — try the next candidate + } + } + throw new Error("No Chrome found."); +} + +async function main(): Promise { + const outDir = path.resolve(import.meta.dir, "../verify-out/robomp-lifecycle-cards"); + await fs.mkdir(outDir, { recursive: true }); + + const chrome = await resolveChrome(undefined); + const browser = await puppeteer.launch({ + headless: true, + executablePath: chrome, + args: ["--no-sandbox", "--disable-setuid-sandbox"], + defaultViewport: { ...VIEWPORT } + }); + + const page = await browser.newPage(); + const BASE_URL = "http://127.0.0.1:8099/"; + // The server at BASE_URL must be serving a FRESHLY built bundle (run + // `bun run build` immediately before launching it); this script intentionally + // performs no build step of its own. + + // Load page, set dark theme, reload + await page.goto(BASE_URL, { waitUntil: "networkidle0" }); + await page.evaluate(() => { + localStorage.setItem("omp-robomp-theme", "dark"); + document.documentElement.dataset.theme = "dark"; + document.documentElement.style.colorScheme = "dark"; + }); + await page.reload({ waitUntil: "networkidle0" }); + + // 5c. Assertions on the seeded cards + const cards = await page.evaluate(() => { + const elements = document.querySelectorAll(".rmp-card"); + return Array.from(elements).map((el) => { + const key = el.querySelector(".rmp-card-id")?.textContent?.trim() ?? ""; + const bucket = el.getAttribute("data-bucket") ?? ""; + const metaText = el.querySelector(".rmp-card-meta")?.textContent?.trim() ?? ""; + return { key, bucket, metaText }; + }); + }); + + console.log("Seeded cards:", JSON.stringify(cards)); + + const runningCard = cards.find(c => c.key.includes("widget#2")); + const mergedCard = cards.find(c => c.key.includes("widget#4")); + + // Failed cards sort ahead of everything: the FIRST rendered card must be the + // seeded failed issue (widget#1), not merely present somewhere in the grid. + const failedSortedFirst = cards.length > 0 && cards[0].bucket === "failed" && cards[0].key.includes("widget#1"); + const runningCardCorrect = runningCard !== undefined && runningCard.bucket === "running" && runningCard.metaText.includes("claude-3-5-sonnet") && runningCard.metaText.includes("last action edit") && runningCard.metaText.includes("elapsed"); + const mergedExcluded = mergedCard === undefined; + + console.log(`Failed sorted first: ${failedSortedFirst}`); + console.log(`Running card correct: ${runningCardCorrect}`); + console.log(`Merged excluded: ${mergedExcluded}`); + + if (!failedSortedFirst || !runningCardCorrect || !mergedExcluded) { + throw new Error("Seeded cards visual checks failed."); + } + + await page.screenshot({ path: path.join(outDir, "live-dark.png") }); + console.log(`Live dark screenshot written to ${path.join(outDir, "live-dark.png")}`); + + // 5d. Real retry round-trip — capture the actual /api/trigger response and + // assert the HTTP contract (202 + body) and the rendered status line BEFORE + // confirming the DB transition, mirroring the cancel path below. + const retryResponsePromise = page.waitForResponse( + (resp) => new URL(resp.url()).pathname === "/api/trigger" && resp.request().method() === "POST", + { timeout: 5000 }, + ); + // Trigger retry on widget#1 (the failed card). + await page.evaluate(() => { + const card = Array.from(document.querySelectorAll(".rmp-card")).find(c => c.querySelector(".rmp-card-id")?.textContent?.includes("octo/widget#1")); + const btn = card?.querySelector("button") as HTMLElement | null; + btn?.click(); + }); + const retryResponse = await retryResponsePromise; + const retryStatus = retryResponse.status(); + const retryBody = await retryResponse.json() as { delivery: string; state: string; mode: string }; + console.log(`Live retry response: ${retryStatus}`, JSON.stringify(retryBody)); + + // Let the SPA render the status line from the response, then read it. + await Bun.sleep(500); + const retryStatusText = await page.evaluate(() => document.querySelector(".rmp-trigger-bar-status")?.textContent ?? ""); + console.log("Live retry status text:", retryStatusText); + + const retryContractOk = + retryStatus === 202 && + retryBody.delivery === "seeded-fail-1" && + retryBody.state === "queued" && + retryBody.mode === "retry" && + retryStatusText.includes("queued retry"); + if (!retryContractOk) { + throw new Error("Retry round-trip did not meet the live contract (HTTP 202 + body + status line)."); + } + + // Poll GET http://127.0.0.1:8099/events?limit=50 to confirm the DB transition. + const eventsResp = await fetch("http://127.0.0.1:8099/events?limit=50"); + const eventsData = await eventsResp.json() as { events: { delivery_id: string; state: string }[] }; + const retryEvent = eventsData.events.find(e => e.delivery_id === "seeded-fail-1"); + console.log("Seeded failed retry event state:", retryEvent?.state); + + const retryOk = retryEvent !== undefined && retryEvent.state !== "failed"; + if (!retryOk) { + throw new Error("Retry state transition did not happen in the live DB."); + } + + // 5e. Real cancel round-trip — capture the actual /api/cancel response and + // assert the HTTP contract (202 + body), not just the rendered status line. + const cancelResponsePromise = page.waitForResponse( + (resp) => new URL(resp.url()).pathname === "/api/cancel" && resp.request().method() === "POST", + { timeout: 5000 }, + ); + await page.evaluate(() => { + window.confirm = () => true; + const card = Array.from(document.querySelectorAll(".rmp-card")).find(c => c.querySelector(".rmp-card-id")?.textContent?.includes("octo/widget#2")); + const btn = card?.querySelector("button.danger") as HTMLElement | null; + btn?.click(); + }); + const cancelResponse = await cancelResponsePromise; + const cancelStatus = cancelResponse.status(); + const cancelBody = await cancelResponse.json() as { delivery: string; fired: boolean; previous_state: string }; + console.log(`Live cancel response: ${cancelStatus}`, JSON.stringify(cancelBody)); + + // Let the SPA render the status line from the response, then read it. + await Bun.sleep(500); + const statusText = await page.evaluate(() => document.querySelector(".rmp-trigger-bar-status")?.textContent ?? ""); + console.log("Live cancel status text:", statusText); + + const cancelOk = + cancelStatus === 202 && + cancelBody.delivery === "seeded-run-2" && + cancelBody.fired === false && + cancelBody.previous_state === "running" && + statusText.includes("cancel signaled: seeded-r (fired=false)"); + if (!cancelOk) { + throw new Error("Cancel round-trip did not meet the live contract (HTTP 202 + body + status line)."); + } + + console.log("Live capstone round-trip checks PASS!"); + + await browser.close(); +} + +main().catch(err => { + console.error("Live verification crashed:", err); + process.exit(1); +}); diff --git a/python/robomp/web/src/App.tsx b/python/robomp/web/src/App.tsx index 1b3703fc3..82f8beb80 100644 --- a/python/robomp/web/src/App.tsx +++ b/python/robomp/web/src/App.tsx @@ -1,15 +1,13 @@ import { type JSX, onCleanup, onMount } from "solid-js"; -import { Browse } from "./components/Browse"; -import { Events } from "./components/Events"; -import { Header } from "./components/Header"; -import { Issues } from "./components/Issues"; -import { Logs } from "./components/Logs"; -import { Stats } from "./components/Stats"; -import { Trigger } from "./components/Trigger"; -import { Working } from "./components/Working"; -import { runTrigger, startPolling, stopPolling } from "./state"; +import { Shell } from "./components/shell/Shell"; +import { startPolling, stopPolling } from "./state"; +// Thin root. Mounts the shell and keeps the polling lifecycle at the +// always-mounted root (load-bearing: views are pure readers — if +// startPolling/stopPolling moved into a view, switching away would stop the +// shared 3s poll). handleRetry now lives inside each view that owns retry +// buttons (Operations/Activity), reading the shared runTrigger passthrough. export function App(): JSX.Element { onMount(() => { startPolling(); @@ -18,35 +16,5 @@ export function App(): JSX.Element { stopPolling(); }); - const handleRetry = (deliveryId: string): void => { - void runTrigger({ mode: "retry", delivery_id: deliveryId }); - }; - - return ( -
-
- -
-
- - -
- - - -
- - -
- - - - - -
- robomp · self-hosted triage & fix · polling every 3s -
-
-
- ); + return ; } diff --git a/python/robomp/web/src/components/Browse.tsx b/python/robomp/web/src/components/Browse.tsx index 5cf047f64..b97d4f228 100644 --- a/python/robomp/web/src/components/Browse.tsx +++ b/python/robomp/web/src/components/Browse.tsx @@ -124,7 +124,7 @@ export function Browse(): JSX.Element { -
+
{errorMessage()}
@@ -132,7 +132,7 @@ export function Browse(): JSX.Element { {(err) => ( -
+
{err.repo} {err.error}
)} @@ -155,7 +155,7 @@ export function Browse(): JSX.Element { {(issue) => (
{issue.state} - - processed - + processed by {issue.author || "—"} updated {fmtAge(issue.updated_at)} diff --git a/python/robomp/web/src/components/GlassCard.tsx b/python/robomp/web/src/components/GlassCard.tsx index 841c99e19..e5e62d085 100644 --- a/python/robomp/web/src/components/GlassCard.tsx +++ b/python/robomp/web/src/components/GlassCard.tsx @@ -14,7 +14,7 @@ export interface GlassCardProps { // the inset content padding so tables/log lists can reach the edge. export function GlassCard(props: GlassCardProps): JSX.Element { const cls = (): string => { - const base = "glass glass-rise rounded-[22px] overflow-hidden"; + const base = "glass glass-rise rounded-lg overflow-hidden"; return props.class ? `${base} ${props.class}` : base; }; return ( diff --git a/python/robomp/web/src/components/Header.tsx b/python/robomp/web/src/components/Header.tsx deleted file mode 100644 index 3215b2809..000000000 --- a/python/robomp/web/src/components/Header.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { type JSX, Show } from "solid-js"; - -import { CONFIG } from "../config"; -import { fmtDuration } from "../format"; -import { isFetching, lastTickAt, lastTickError, statusResource } from "../state"; -import type { RuntimeInfo } from "../types"; - -function relativeAgo(ms: number): string { - const seconds = Math.max(0, (Date.now() - ms) / 1000); - if (seconds < 5) return "just now"; - return `${fmtDuration(seconds)} ago`; -} - -export function Header(): JSX.Element { - const runtime = (): RuntimeInfo | undefined => statusResource()?.runtime; - - return ( -
-
-
-

- robomp - -

- triage · fix · ship -
- -
- - - {isFetching() ? "syncing…" : `synced ${relativeAgo(lastTickAt())}`} - - } - > - - - {lastTickError()} - - -
-
- -
- - - - - - - read-only · trigger disabled - -
-
- ); -} - -interface MetaProps { - label: string; - value?: string; - mono?: boolean; - title?: string; -} - -function Meta(props: MetaProps): JSX.Element { - return ( - - {props.label} - - {props.value ?? "…"} - - - ); -} diff --git a/python/robomp/web/src/components/IssueCard.tsx b/python/robomp/web/src/components/IssueCard.tsx new file mode 100644 index 000000000..af7e8308e --- /dev/null +++ b/python/robomp/web/src/components/IssueCard.tsx @@ -0,0 +1,162 @@ +import { type JSX, Show } from "solid-js"; + +import { CONFIG } from "../config"; +import { fmtAge, fmtDuration, shortDelivery, shortText } from "../format"; +import { runCancel, runTrigger } from "../state"; +import type { WorkItem } from "../work-items"; +import { IssueLink, PrLink } from "./IssueLink"; +import { LifecycleStepper } from "./LifecycleStepper"; +import { Pill } from "./Pill"; + +export interface IssueCardProps { + item: WorkItem; +} + +// Verbatim cancel-confirm copy retained from the previous running table so the +// operator-facing wording does not drift. +const CANCEL_CONFIRM = + "Kill this running task? The omp subprocess dies and the row lands in 'failed'."; + +// One issue = one card. Full shape when the item has an issue ref (the +// common case); compact shape for orphan deliveries with no bound issue. +// State tint is carried by `data-bucket` + CSS — no side-stripe, no shadow. +export function IssueCard(props: IssueCardProps): JSX.Element { + const item = (): WorkItem => props.item; + const ref = () => item().ref; + const full = (): boolean => ref() !== null; + + return ( +
+ }> +
+
+ {item().classification ?? "issue"} + +
+ +
+ +
+ + +
+ {shortText(item().error, 200)} +
+
+ +
+ + + + + + +
+
+
+ ); +} + +function CompactHead(props: { item: WorkItem }): JSX.Element { + return ( +
+
+ {shortDelivery(props.item.deliveryId)} +
+ +
+ ); +} + +function ActivityPill(props: { item: WorkItem }): JSX.Element { + const state = (): string | undefined => + props.item.latestEvent?.state ?? (props.item.bucket === "running" ? "running" : undefined); + const label = (): string => + props.item.latestEvent?.state ?? + (props.item.bucket === "running" ? (props.item.inflightOnly ? "inflight" : "running") : "—"); + return ( + + {label()} + + ); +} + +function MetaRow(props: { item: WorkItem }): JSX.Element { + const item = (): WorkItem => props.item; + const live = () => item().live; + + // Only the applicable fragments render; the CSS layer draws a · separator + // before every non-first child, so we never emit leading/trailing dots. + return ( +
+ + + {live()!.model} + + + + last action {live()!.last_tool}{" "} + {fmtAge(live()!.last_tool_ts)} + + + + elapsed {liveElapsed(live()!.started_at)} + + attempt #{live()!.attempts} + + + held by pool + + + {item().branch} + + + + + + + {item().latestEvent!.event_type} · attempt #{item().latestEvent!.attempts} ·{" "} + {fmtAge(item().latestEvent!.received_at)} + + +
+ ); +} + +async function cancel(deliveryId: string): Promise { + if (!window.confirm(CANCEL_CONFIRM)) return; + await runCancel(deliveryId); +} + +// Elapsed for a live run. Guards against invalid/unparseable timestamps so we +// never render a NaN-duration — falls back to "—". +function liveElapsed(startedAt: string | null): string { + if (!startedAt) return "—"; + const t = Date.parse(startedAt); + if (Number.isNaN(t)) return "—"; + return fmtDuration((Date.now() - t) / 1000); +} diff --git a/python/robomp/web/src/components/Issues.tsx b/python/robomp/web/src/components/Issues.tsx deleted file mode 100644 index 7e0fec646..000000000 --- a/python/robomp/web/src/components/Issues.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { For, type JSX, Show } from "solid-js"; - -import { CONFIG } from "../config"; -import { fmtAge, shortText } from "../format"; -import { statusResource } from "../state"; -import { type IssueRow, type LatestEvent, TERMINAL_ISSUE_STATES } from "../types"; -import { GlassCard } from "./GlassCard"; -import { IssueLink, PrLink } from "./IssueLink"; -import { Pill } from "./Pill"; - -export interface IssuesProps { - onRetry: (deliveryId: string) => void; -} - -export function Issues(props: IssuesProps): JSX.Element { - const active = (): IssueRow[] => { - const s = statusResource(); - if (!s) return []; - return s.issues.filter((i) => !TERMINAL_ISSUE_STATES.has(i.state)); - }; - - return ( - {active().length}}> - no active issues
}> -
- - - - - - - - - - - - - - - {(issue) => } - - -
issuestatelast eventclassbranchprerror -
-
- - - ); -} - -interface RowProps { - issue: IssueRow; - onRetry: (deliveryId: string) => void; -} - -function IssueRowView(props: RowProps): JSX.Element { - const ev = (): LatestEvent | null => props.issue.latest_event; - - return ( - - - - - - {props.issue.state} - - - —}> - {(latest) => ( - <> - {latest().state} - - {latest().event_type} · attempt #{latest().attempts} ·{" "} - {fmtAge(latest().received_at)} - - - )} - - - {props.issue.classification ?? ""} - - {props.issue.branch ? ( - {props.issue.branch} - ) : ( - — - )} - - - - - - —} - > - {shortText(ev()?.last_error)} - - - - —} - > - - - - - ); -} diff --git a/python/robomp/web/src/components/LifecycleStepper.tsx b/python/robomp/web/src/components/LifecycleStepper.tsx new file mode 100644 index 000000000..e6305c36b --- /dev/null +++ b/python/robomp/web/src/components/LifecycleStepper.tsx @@ -0,0 +1,62 @@ +import { For, type JSX, Show } from "solid-js"; + +import { + CODE_STAGES, + SIMPLE_CLASSIFICATIONS, + SIMPLE_STAGES, + stageOrdinal, +} from "../work-items"; + +export interface LifecycleStepperProps { + state: string | null; + classification: string | null; + failed: boolean; + live: boolean; +} + +type NodeState = "complete" | "current" | "failed" | "pending"; + +// Horizontal lifecycle track. Picks the 2-node "simple" path for non-code +// classifications (questions, enhancements, proposals) and the 5-node code +// path otherwise. State is carried entirely by data attributes so the CSS +// layer owns colour + the running pulse; this component stays dumb. +// +// Layout: equal-width cells (flex:1) so dot centres are evenly spaced; the +// connector for cell i is absolutely positioned from this dot's centre to the +// next dot's centre (one cell width), and the dot paints over it. +export function LifecycleStepper(props: LifecycleStepperProps): JSX.Element { + const stages = (): readonly string[] => + props.classification != null && SIMPLE_CLASSIFICATIONS.has(props.classification) + ? SIMPLE_STAGES + : CODE_STAGES; + const ord = (): number => stageOrdinal(props.state); + + const nodeState = (i: number): NodeState => { + const o = ord(); + if (i < o) return "complete"; + if (i === o) return props.failed ? "failed" : "current"; + return "pending"; + }; + + return ( +
+ + {(stage, i) => ( +
+ +
+ +
+ + {stage} + +
+ )} + +
+ ); +} diff --git a/python/robomp/web/src/components/Pipeline.tsx b/python/robomp/web/src/components/Pipeline.tsx new file mode 100644 index 000000000..e014422c4 --- /dev/null +++ b/python/robomp/web/src/components/Pipeline.tsx @@ -0,0 +1,57 @@ +import { For, type JSX, Show } from "solid-js"; + +import { statusResource } from "../state"; +import { buildWorkItems, type WorkItem } from "../work-items"; +import { IssueCard } from "./IssueCard"; + +// The unified Operations surface: every active work item as one lifecycle +// card. Subsumes the old running, failed, and active issue tables. Failed +// items sort first; running items show live agent activity. No GlassCard +// wrapper — the cards ARE the card layer (no nested cards). +export function Pipeline(): JSX.Element { + const items = (): WorkItem[] => { + const s = statusResource(); + return s ? buildWorkItems(s) : []; + }; + + return ( + }> + 0} fallback={}> +
+ pipeline + {items().length} +
+
+ {(item) => } +
+
+
+ ); +} + +// First-load placeholder — skeleton, not spinner. Two cards so the grid shape +// is legible before data arrives. +function PipelineSkeleton(): JSX.Element { + return ( +
+
+
+
+ ); +} + +// Teaching empty state: names what the surface does and what it's waiting on, +// plus the watched repos so an idle console still explains itself. +function PipelineEmpty(): JSX.Element { + const allowlist = (): string[] => statusResource()?.runtime.repo_allowlist ?? []; + return ( +
+
+ No active work — robomp is idle, waiting for GitHub webhook events +
+ 0}> +
watching {allowlist().join(", ")}
+
+
+ ); +} diff --git a/python/robomp/web/src/components/Stats.tsx b/python/robomp/web/src/components/Stats.tsx deleted file mode 100644 index b5cd6648e..000000000 --- a/python/robomp/web/src/components/Stats.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { For, type JSX } from "solid-js"; - -import { statusResource } from "../state"; -import { EVENT_STATE_ORDER, type EventState } from "../types"; - -const ACCENT: Record = { - queued: "text-[#9ec9ff]", - running: "text-[#ffe26b]", - done: "text-[#7fe5a3]", - failed: "text-[#ff8e85]", - skipped: "text-ink-300", -}; - -export function Stats(): JSX.Element { - const counts = (): Record => { - const status = statusResource(); - if (!status) return { queued: 0, running: 0, done: 0, failed: 0, skipped: 0 }; - return status.issue_event_counts ?? status.event_counts; - }; - - return ( -
- - {(state) => ( -
- {state} - - {counts()[state] ?? 0} - -
- )} -
-
- ); -} diff --git a/python/robomp/web/src/components/ThemeToggle.tsx b/python/robomp/web/src/components/ThemeToggle.tsx new file mode 100644 index 000000000..8634b35d6 --- /dev/null +++ b/python/robomp/web/src/components/ThemeToggle.tsx @@ -0,0 +1,113 @@ +import type { JSX } from "solid-js"; + +import { + type ThemePreference, + preference, + setPreference, +} from "../theme"; + +// 3-way system → light → dark → system toggle. robomp has no icon library, so +// each preference renders an inline 16px SVG (monitor / sun / moon) styled with +// the existing `.btn.ghost.tiny` chrome. + +const NEXT_PREFERENCE: Record = { + system: "light", + light: "dark", + dark: "system", +}; + +const PREFERENCE_LABEL: Record = { + system: "System theme", + light: "Light theme", + dark: "Dark theme", +}; + +function MonitorIcon(): JSX.Element { + return ( + + ); +} + +function SunIcon(): JSX.Element { + return ( + + ); +} + +function MoonIcon(): JSX.Element { + return ( + + ); +} + +export function ThemeToggle(): JSX.Element { + const icon = (): JSX.Element => { + const p = preference(); + if (p === "light") return ; + if (p === "dark") return ; + return ; + }; + + const label = (): string => `${PREFERENCE_LABEL[preference()]} — click to switch`; + + return ( + + ); +} diff --git a/python/robomp/web/src/components/Trigger.tsx b/python/robomp/web/src/components/Trigger.tsx index a8fb9563a..7a57e30a4 100644 --- a/python/robomp/web/src/components/Trigger.tsx +++ b/python/robomp/web/src/components/Trigger.tsx @@ -7,11 +7,22 @@ import { GlassCard } from "./GlassCard"; const STATUS_TONE = { idle: "text-ink-400", pending: "text-ink-200", - ok: "text-[#7fe5a3]", - err: "text-[#ff8e85]", + ok: "text-ok", + err: "text-err", } as const; -export function Trigger(): JSX.Element { +export interface TriggerProps { + /** + * `bar` — slim one-line command row for the Operations landing view. + * `panel` — fuller form (current look) for the Triage view. + * Both gate on CONFIG.replayEnabled; read-only degrades to a slim inline + * chip (bar) or the first-run explainer panel (panel, handled by Triage). + */ + variant?: "bar" | "panel"; +} + +export function Trigger(props: TriggerProps): JSX.Element { + const variant = (): "bar" | "panel" => props.variant ?? "panel"; const [issue, setIssue] = createSignal(""); const validate = (): string | null => { @@ -32,8 +43,63 @@ export function Trigger(): JSX.Element { void runTrigger({ mode: "retry", issue: value }); }; + // ── bar variant: slim command row (Operations). Read-only → inline chip. ── return ( - owner/repo#NN or issue url}> + }> + + trigger disabled · read-only + set ROBOMP_REPLAY_TOKEN to enable +
+ } + > +
+ setIssue(ev.currentTarget.value)} + onKeyDown={(ev) => { + if (ev.key === "Enter") handleTriage(); + }} + class="flex-1 min-w-[200px] font-mono" + /> + + + + + {triggerStatus().text} + + +
+ + + ); +} + +interface PanelBodyProps { + issue: () => string; + setIssue: (v: string) => void; + handleTriage: () => void; + handleRetry: () => void; + validate: () => string | null; +} + +// ── panel variant: fuller form (Triage). Read-only handled by the Triage +// view's first-run panel; if replay is on, this is the active form. ── +function PanelBody(p: PanelBodyProps): JSX.Element { + return ( + setIssue(ev.currentTarget.value)} + value={p.issue()} + onInput={(ev) => p.setIssue(ev.currentTarget.value)} onKeyDown={(ev) => { - if (ev.key === "Enter") handleTriage(); + if (ev.key === "Enter") p.handleTriage(); }} class="flex-1 min-w-[220px] font-mono" /> - - +
{validate() ?? "ready"} + {p.validate() ?? "ready"} } > diff --git a/python/robomp/web/src/components/Working.tsx b/python/robomp/web/src/components/Working.tsx deleted file mode 100644 index 5e24c490b..000000000 --- a/python/robomp/web/src/components/Working.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import { For, type JSX, Show } from "solid-js"; - -import { CONFIG } from "../config"; -import { fmtAge, fmtDuration, shortDelivery, splitIssueKey } from "../format"; -import { runCancel, statusResource } from "../state"; -import type { RunningEvent } from "../types"; -import { GlassCard } from "./GlassCard"; -import { IssueLink } from "./IssueLink"; -import { Pill } from "./Pill"; - -interface Row { - key: string; - delivery_id: string; - issue_key: string | null; - event_type: string; - attempts: number; - model: string | null; - last_tool: string | null; - last_tool_ts: string | null; - started_at: string | null; - inflight_only: boolean; -} - -function rowsFor(running: RunningEvent[], inflight: string[]): Row[] { - const out: Row[] = []; - const seen = new Set(); - for (const e of running) { - const key = e.issue_key ?? e.delivery_id; - seen.add(key); - out.push({ - key, - delivery_id: e.delivery_id, - issue_key: e.issue_key, - event_type: e.event_type, - attempts: e.attempts, - model: e.model, - last_tool: e.last_tool, - last_tool_ts: e.last_tool_ts, - started_at: e.started_at ?? e.received_at, - inflight_only: false, - }); - } - for (const key of inflight) { - if (seen.has(key)) continue; - out.push({ - key, - delivery_id: "", - issue_key: key, - event_type: "", - attempts: 0, - model: null, - last_tool: null, - last_tool_ts: null, - started_at: null, - inflight_only: true, - }); - } - return out; -} - -async function cancelDelivery(deliveryId: string): Promise { - if ( - !window.confirm( - "Kill this running task? The omp subprocess dies and the row lands in 'failed'.", - ) - ) { - return; - } - await runCancel(deliveryId); -} - -function elapsed(startedAt: string | null): string { - if (!startedAt) return "—"; - const t = Date.parse(startedAt); - if (Number.isNaN(t)) return "—"; - return fmtDuration((Date.now() - t) / 1000); -} - -export function Working(): JSX.Element { - const rows = (): Row[] => { - const s = statusResource(); - return s ? rowsFor(s.running_events, s.inflight) : []; - }; - - return ( - {rows().length}}> - idle — waiting for events
}> -
- - - - - - - - - - - - - - {(r) => } - -
issueeventstateelapsedmodellast actionattempt -
-
- - - ); -} - -function WorkingRow(props: { row: Row }): JSX.Element { - const ref = (): { repo: string; number: string } => splitIssueKey(props.row.issue_key); - return ( - - - {shortDelivery(props.row.delivery_id)}}> - - - - {props.row.event_type || "—"} - - - {props.row.inflight_only ? "inflight" : "running"} - - - {elapsed(props.row.started_at)} - - {props.row.model ? ( - {props.row.model} - ) : ( - — - )} - - - {props.row.last_tool ? ( - - {props.row.last_tool} - {fmtAge(props.row.last_tool_ts)} - - ) : ( - {props.row.inflight_only ? "held by pool" : "—"} - )} - - - {props.row.inflight_only ? "—" : `#${props.row.attempts}`} - - - —} - > - - - - - ); -} diff --git a/python/robomp/web/src/components/shell/Rail.tsx b/python/robomp/web/src/components/shell/Rail.tsx new file mode 100644 index 000000000..9689a2eb9 --- /dev/null +++ b/python/robomp/web/src/components/shell/Rail.tsx @@ -0,0 +1,104 @@ +import { type JSX, Show } from "solid-js"; + +import { CONFIG } from "../../config"; +import { activeView, setActiveView, type View } from "../../view"; +import { ThemeToggle } from "../ThemeToggle"; +import { Vitals } from "./Vitals"; + +// Inline nav glyphs — robomp has no icon library. +function ActivityIcon(): JSX.Element { + return ( + + ); +} +function OpsIcon(): JSX.Element { + return ( + + ); +} +function TriageIcon(): JSX.Element { + return ( + + ); +} +function LockIcon(): JSX.Element { + return ( + + ); +} + +interface NavDef { + id: View; + label: string; + icon: () => JSX.Element; + locked: boolean; +} + +const NAV: NavDef[] = [ + { id: "operations", label: "Operations", icon: OpsIcon, locked: false }, + { id: "activity", label: "Activity", icon: ActivityIcon, locked: false }, + { id: "triage", label: "Triage", icon: TriageIcon, locked: !CONFIG.replayEnabled }, +]; + +export function Rail(): JSX.Element { + return ( + + ); +} diff --git a/python/robomp/web/src/components/shell/Shell.tsx b/python/robomp/web/src/components/shell/Shell.tsx new file mode 100644 index 000000000..8e7b653df --- /dev/null +++ b/python/robomp/web/src/components/shell/Shell.tsx @@ -0,0 +1,72 @@ +import { createEffect, createSignal, type JSX, Show } from "solid-js"; + +import { activeView } from "../../view"; +import { Activity } from "../views/Activity"; +import { Operations } from "../views/Operations"; +import { Triage } from "../views/Triage"; +import { Rail } from "./Rail"; +import { TopBar } from "./TopBar"; + +export function Shell(): JSX.Element { + const [drawerOpen, setDrawerOpen] = createSignal(false); + + // Close the mobile drawer whenever the active view changes (nav tap). + createEffect(() => { + activeView(); + setDrawerOpen(false); + }); + + const viewDisplay = (v: string): string => (activeView() === v ? "" : "none"); + + return ( +
+ {/* Desktop rail (hidden <1024px) */} +
+ +
+ + {/* Mobile drawer */} + +
setDrawerOpen(false)} + role="presentation" + > +
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-label="Navigation menu" + > + +
+
+
+ + {/* Main pane */} +
+ setDrawerOpen(true)} /> +
+
+ {/* All 3 views stay mounted; visibility toggled by activeView so + view-local UI state (Logs/Browse filters) persists and Browse + does not refetch on view switches. The enter animation + (rmp-view-enter) restarts automatically when a view flips + none→flex, firing on first paint + view-switch ONLY, never on + the 3s data poll (polls never change display). */} +
+ +
+
+ +
+
+ +
+
+
+
+
+ ); +} diff --git a/python/robomp/web/src/components/shell/TopBar.tsx b/python/robomp/web/src/components/shell/TopBar.tsx new file mode 100644 index 000000000..0df998e8d --- /dev/null +++ b/python/robomp/web/src/components/shell/TopBar.tsx @@ -0,0 +1,53 @@ +import { type JSX, Show } from "solid-js"; + +import { CONFIG } from "../../config"; +import { activeView, type View } from "../../view"; + +interface TopBarProps { + onMenuToggle: () => void; +} + +const TITLES: Record = { + operations: "Operations", + activity: "Activity", + triage: "Triage", +}; + +function MenuIcon(): JSX.Element { + return ( + + ); +} + +export function TopBar(props: TopBarProps): JSX.Element { + return ( +
+
+ +

{TITLES[activeView()]}

+
+ +
+ {/* View-contextual slot. Operations → read-only/replay chip. Sync + vitals live in the rail. */} + + read-only · replay off} + > + replay on + + +
+
+ ); +} diff --git a/python/robomp/web/src/components/shell/Vitals.tsx b/python/robomp/web/src/components/shell/Vitals.tsx new file mode 100644 index 000000000..a47e71019 --- /dev/null +++ b/python/robomp/web/src/components/shell/Vitals.tsx @@ -0,0 +1,168 @@ +import { type JSX, Show } from "solid-js"; + +import { fmtDuration } from "../../format"; +import { isFetching, lastTickAt, lastTickError, statusResource } from "../../state"; +import { EVENT_STATE_ORDER } from "../../types"; + +// Ported verbatim from Header.tsx (dissolved). +function relativeAgo(ms: number): string { + const seconds = Math.max(0, (Date.now() - ms) / 1000); + if (seconds < 5) return "just now"; + return `${fmtDuration(seconds)} ago`; +} + +const STATE_TONE: Record = { + queued: "var(--color-info)", + running: "var(--color-warn)", + done: "var(--color-ok)", + failed: "var(--color-err)", + skipped: "var(--color-ink-300)", +}; + +export function Vitals(): JSX.Element { + const runtime = () => statusResource()?.runtime; + + // issue_event_counts ?? event_counts — preserves the Stats.tsx accessor + title. + const counts = (): Record => { + const status = statusResource(); + if (!status) return { queued: 0, running: 0, done: 0, failed: 0, skipped: 0 }; + return status.issue_event_counts ?? status.event_counts; + }; + + // Match the pipeline derivation: running_events and the inflight snapshot overlap + // (the worker pool keys inflight by `issue_key || delivery_id`), so union by + // that same key instead of summing lengths — otherwise one live job counts as two. + const runningTotal = (): number => { + const s = statusResource(); + if (!s) return 0; + const keys = new Set(s.running_events.map((e) => e.issue_key ?? e.delivery_id)); + for (const key of s.inflight) keys.add(key); + return keys.size; + }; + + const failedCount = (): number => counts().failed ?? 0; + + const allowlist = (): string => { + const list = runtime()?.repo_allowlist; + return list && list.length ? list.join(", ") : "(none)"; + }; + + const syncTone = (): string => { + if (lastTickError()) return "err"; + if (isFetching()) return "warn"; + return "ok"; + }; + + const syncText = (): string => { + if (lastTickError()) return lastTickError()!; + if (isFetching()) return "syncing…"; + return `synced ${relativeAgo(lastTickAt())}`; + }; + + return ( +
+ {/* health / sync */} +
+ + {syncText()}} + > + {syncText()} + +
+ + {/* big running / failed */} +
+
+ running + 0 ? " running-nonzero" : "" + }`} + > + {runningTotal()} + +
+
+ failed + 0 ? " failed-nonzero" : "" + }`} + > + {failedCount()} + +
+
+ + {/* 5 state counts (compact 2-col) */} +
+ {EVENT_STATE_ORDER.map((state) => ( +
+ {state} + + {counts()[state] ?? 0} + +
+ ))} +
+ + {/* runtime meta */} +
+ + + + + +
+
+ ); +} + +interface RuntimeRowProps { + label: string; + value?: string; + mono?: boolean; + title?: string; +} + +function RuntimeRow(props: RuntimeRowProps): JSX.Element { + return ( +
+ {props.label} + + {props.value ?? "—"} + +
+ ); +} diff --git a/python/robomp/web/src/components/views/Activity.tsx b/python/robomp/web/src/components/views/Activity.tsx new file mode 100644 index 000000000..a928fa3bb --- /dev/null +++ b/python/robomp/web/src/components/views/Activity.tsx @@ -0,0 +1,20 @@ +import { type JSX } from "solid-js"; + +import { runTrigger } from "../../state"; +import { Events } from "../Events"; +import { Logs } from "../Logs"; + +// Activity (investigate mode). Full history table + full-height log stream. +// Logs finally get real vertical room. +export function Activity(): JSX.Element { + const handleRetry = (deliveryId: string): void => { + void runTrigger({ mode: "retry", delivery_id: deliveryId }); + }; + + return ( + <> + + + + ); +} diff --git a/python/robomp/web/src/components/views/Operations.tsx b/python/robomp/web/src/components/views/Operations.tsx new file mode 100644 index 000000000..56f0e1be5 --- /dev/null +++ b/python/robomp/web/src/components/views/Operations.tsx @@ -0,0 +1,19 @@ +import { type JSX } from "solid-js"; + +import { Pipeline } from "../Pipeline"; +import { Trigger } from "../Trigger"; + +// Operations (default landing view). A single unified pipeline: a slim +// trigger command row on top, then every active work item drawn as a +// lifecycle card (failed first, running live). Replaces the old three-table +// layout (working / needs-attention / active-issues) — Pipeline derives all +// three states from one status poll. Read-only collapses the trigger to a +// chip; the cards lose their retry/cancel actions. +export function Operations(): JSX.Element { + return ( + <> + + + + ); +} diff --git a/python/robomp/web/src/components/views/Triage.tsx b/python/robomp/web/src/components/views/Triage.tsx new file mode 100644 index 000000000..054f4b30c --- /dev/null +++ b/python/robomp/web/src/components/views/Triage.tsx @@ -0,0 +1,50 @@ +import { type JSX, Show } from "solid-js"; + +import { CONFIG } from "../../config"; +import { Browse } from "../Browse"; +import { GlassCard } from "../GlassCard"; +import { Trigger } from "../Trigger"; + +// Triage (act mode). Full trigger form + issue browser. When replay is off, +// renders ONE composed first-run panel (env + what unlocks) instead of two +// large disabled cards. The rail marks this nav item locked + dimmed. +export function Triage(): JSX.Element { + return ( + } + > + + + + ); +} + +function FirstRunPanel(): JSX.Element { + return ( + locked}> +
+
+ read-only +

manual actions are disabled

+
+

+ Triage and retry let you drive the bot by hand — fetch a fresh issue, re-run a failed + delivery, or browse the open backlog. These actions are gated behind a server-side + replay token so the dashboard is safe to expose read-only. +

+
    +
  • + set ROBOMP_REPLAY_TOKEN in the robomp server environment +
  • +
  • restart the server so the token is loaded
  • +
  • reload — the Operations trigger bar and this Triage view unlock
  • +
+

+ Monitoring stays fully live: Operations and Activity read the same 3s poll regardless of + this setting. +

+
+
+ ); +} diff --git a/python/robomp/web/src/styles/index.css b/python/robomp/web/src/styles/index.css index 56cec5267..81cbab075 100644 --- a/python/robomp/web/src/styles/index.css +++ b/python/robomp/web/src/styles/index.css @@ -4,6 +4,11 @@ Design tokens. Exposed both as plain CSS variables (for raw `var()` use) and as Tailwind theme keys so utilities like `bg-surface-1`, `text-ink-100`, `border-stroke` work without a separate config file. + + These map the stats dashboard's flat pink-on-purple palette onto robomp's + semantic ramp. Dark values are the @theme defaults; unlayered :root / + [data-theme] blocks below are the authoritative + switchable layer + (unlayered beats Tailwind's @layer theme so utilities flip at runtime). ────────────────────────────────────────────────────────────────────────── */ @theme { --font-sans: @@ -12,51 +17,151 @@ --font-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace; - --color-ink-50: #f8fafc; - --color-ink-100: #e8eaef; - --color-ink-200: #c6cad3; - --color-ink-300: #8b94a1; - --color-ink-400: #5e6772; - --color-ink-500: #3a4049; - --color-ink-700: #1c2026; - --color-ink-800: #14171d; - --color-ink-900: #0a0c11; - --color-ink-950: #07090c; + /* ink ramp: low = bright text, high = dark surface */ + --color-ink-50: oklch(0.97 0.006 307); + --color-ink-100: oklch(0.92 0.01 307); + --color-ink-200: oklch(0.82 0.013 307); + --color-ink-300: oklch(0.71 0.016 307); + --color-ink-400: oklch(0.53 0.018 307); + --color-ink-500: oklch(0.44 0.018 307); + --color-ink-700: oklch(0.30 0.02 307); + --color-ink-800: oklch(0.235 0.026 307); + --color-ink-900: oklch(0.19 0.022 307); + --color-ink-950: oklch(0.16 0.02 307); - --color-surface-1: rgba(255, 255, 255, 0.045); - --color-surface-2: rgba(255, 255, 255, 0.075); - --color-surface-3: rgba(255, 255, 255, 0.11); - --color-stroke: rgba(255, 255, 255, 0.10); - --color-stroke-soft: rgba(255, 255, 255, 0.06); + /* opaque flat surfaces (no translucency, no blur) */ + --color-surface-1: oklch(0.19 0.022 307); + --color-surface-2: oklch(0.235 0.026 307); + --color-surface-3: oklch(0.27 0.03 307); + --color-stroke: oklch(1 0 0 / 9%); + --color-stroke-soft: oklch(1 0 0 / 6%); - --color-accent: #0a84ff; - --color-accent-2: #5aa9ff; - --color-ok: #30d158; - --color-warn: #ffd60a; - --color-err: #ff453a; - --color-info: #64d2ff; + /* brand accents: pink + cyan link */ + --color-accent: oklch(0.674 0.23 341); + --color-accent-2: oklch(0.817 0.112 205); + --color-accent-fg: oklch(0.2 0.07 341); + --color-accent-muted: oklch(0.674 0.23 341 / 18%); + --color-ring: oklch(0.817 0.112 205 / 70%); - --radius-md: 12px; - --radius-lg: 16px; - --radius-xl: 22px; - --radius-2xl: 28px; + /* semantic states */ + --color-ok: oklch(0.76 0.14 150); + --color-warn: oklch(0.79 0.14 85); + --color-err: oklch(0.66 0.19 25); + --color-info: oklch(0.817 0.112 205); - --shadow-glass: - 0 24px 60px -20px rgba(0, 0, 0, 0.55), - 0 8px 24px -12px rgba(0, 0, 0, 0.35), - inset 0 1px 0 rgba(255, 255, 255, 0.04); - --shadow-soft: 0 8px 24px -12px rgba(0, 0, 0, 0.45); - - --blur-glass: blur(32px) saturate(180%); + --radius-sm: 3px; + --radius-md: 6px; + --radius-lg: 8px; } /* ────────────────────────────────────────────────────────────────────────── - Root + page chrome. The body gradient sits behind every glass card. + Authoritative theme layer (unlayered → beats @layer theme). Dark is the + default; [data-theme="light"] + the prefers-color media block flip every + utility + var() consumer at runtime. ────────────────────────────────────────────────────────────────────────── */ -:root { +:root, +[data-theme="dark"] { color-scheme: dark; + + --color-ink-50: oklch(0.97 0.006 307); + --color-ink-100: oklch(0.92 0.01 307); + --color-ink-200: oklch(0.82 0.013 307); + --color-ink-300: oklch(0.71 0.016 307); + --color-ink-400: oklch(0.53 0.018 307); + --color-ink-500: oklch(0.44 0.018 307); + --color-ink-700: oklch(0.30 0.02 307); + --color-ink-800: oklch(0.235 0.026 307); + --color-ink-900: oklch(0.19 0.022 307); + --color-ink-950: oklch(0.16 0.02 307); + + --color-surface-1: oklch(0.19 0.022 307); + --color-surface-2: oklch(0.235 0.026 307); + --color-surface-3: oklch(0.27 0.03 307); + --color-stroke: oklch(1 0 0 / 9%); + --color-stroke-soft: oklch(1 0 0 / 6%); + + --color-accent: oklch(0.674 0.23 341); + --color-accent-2: oklch(0.817 0.112 205); + --color-accent-fg: oklch(0.2 0.07 341); + --color-accent-muted: oklch(0.674 0.23 341 / 18%); + --color-ring: oklch(0.817 0.112 205 / 70%); + + --color-ok: oklch(0.76 0.14 150); + --color-warn: oklch(0.79 0.14 85); + --color-err: oklch(0.66 0.19 25); + --color-info: oklch(0.817 0.112 205); } +[data-theme="light"] { + color-scheme: light; + + /* light ramp inverted: low ink = dark text, high ink = light surface */ + --color-ink-50: oklch(0.22 0.03 307); + --color-ink-100: oklch(0.26 0.03 307); + --color-ink-200: oklch(0.36 0.03 307); + --color-ink-300: oklch(0.46 0.03 307); + --color-ink-400: oklch(0.58 0.025 307); + --color-ink-500: oklch(0.62 0.02 307); + --color-ink-700: oklch(0.80 0.01 307); + --color-ink-800: oklch(0.965 0.006 307); + --color-ink-900: oklch(1 0 0); + --color-ink-950: oklch(0.985 0.004 307); + + --color-surface-1: oklch(1 0 0); + --color-surface-2: oklch(0.965 0.006 307); + --color-surface-3: oklch(0.93 0.008 307); + --color-stroke: oklch(0 0 0 / 10%); + --color-stroke-soft: oklch(0 0 0 / 6%); + + --color-accent: oklch(0.62 0.23 341); + --color-accent-2: oklch(0.58 0.13 230); + --color-accent-fg: oklch(0.99 0.01 341); + --color-accent-muted: oklch(0.62 0.23 341 / 14%); + --color-ring: oklch(0.58 0.13 230 / 70%); + + --color-ok: oklch(0.55 0.13 150); + --color-warn: oklch(0.6 0.13 85); + --color-err: oklch(0.55 0.19 25); + --color-info: oklch(0.58 0.13 230); +} + +@media (prefers-color-scheme: light) { + :root:not([data-theme]) { + color-scheme: light; + + --color-ink-50: oklch(0.22 0.03 307); + --color-ink-100: oklch(0.26 0.03 307); + --color-ink-200: oklch(0.36 0.03 307); + --color-ink-300: oklch(0.46 0.03 307); + --color-ink-400: oklch(0.58 0.025 307); + --color-ink-500: oklch(0.62 0.02 307); + --color-ink-700: oklch(0.80 0.01 307); + --color-ink-800: oklch(0.965 0.006 307); + --color-ink-900: oklch(1 0 0); + --color-ink-950: oklch(0.985 0.004 307); + + --color-surface-1: oklch(1 0 0); + --color-surface-2: oklch(0.965 0.006 307); + --color-surface-3: oklch(0.93 0.008 307); + --color-stroke: oklch(0 0 0 / 10%); + --color-stroke-soft: oklch(0 0 0 / 6%); + + --color-accent: oklch(0.62 0.23 341); + --color-accent-2: oklch(0.58 0.13 230); + --color-accent-fg: oklch(0.99 0.01 341); + --color-accent-muted: oklch(0.62 0.23 341 / 14%); + --color-ring: oklch(0.58 0.13 230 / 70%); + + --color-ok: oklch(0.55 0.13 150); + --color-warn: oklch(0.6 0.13 85); + --color-err: oklch(0.55 0.19 25); + --color-info: oklch(0.58 0.13 230); + } +} + +/* ────────────────────────────────────────────────────────────────────────── + Root + page chrome. Flat page background — no gradient, no blur. + ────────────────────────────────────────────────────────────────────────── */ html, body, #app { @@ -71,20 +176,7 @@ body, } body { - background: - radial-gradient( - 120vw 70vh at 88% -12%, - rgba(10, 132, 255, 0.18), - transparent 60% - ), - radial-gradient( - 90vw 60vh at -10% 110%, - rgba(100, 210, 255, 0.10), - transparent 60% - ), - radial-gradient(60vw 40vh at 50% 50%, rgba(20, 24, 36, 0.45), transparent 70%), - linear-gradient(180deg, #05070a 0%, #090c12 55%, #050608 100%); - background-attachment: fixed; + background: var(--color-ink-950); } #app { @@ -93,8 +185,8 @@ body { } ::selection { - background: rgba(10, 132, 255, 0.45); - color: #fff; + background: var(--color-accent-muted); + color: var(--color-ink-50); } a { @@ -103,20 +195,13 @@ a { transition: color 150ms ease-out; } a:hover { - color: #9ec9ff; + color: var(--color-info); } /* ────────────────────────────────────────────────────────────────────────── Reusable utility classes layered on top of Tailwind. ────────────────────────────────────────────────────────────────────────── */ -.glass { - background: var(--color-surface-1); - backdrop-filter: var(--blur-glass); - -webkit-backdrop-filter: var(--blur-glass); - border: 1px solid var(--color-stroke); - box-shadow: var(--shadow-glass); -} - +.glass, .glass-flat { background: var(--color-surface-1); border: 1px solid var(--color-stroke); @@ -138,8 +223,8 @@ a:hover { } /* ────────────────────────────────────────────────────────────────────────── - Pills — capsule status tags, tabular, hairline outline. Tints are flat - (no glow); the only colour is the text + a hairline border. + Pills — capsule status tags. Tints are flat color-mix backgrounds with a + matching flat text colour and no border (stats StatusPill convention). ────────────────────────────────────────────────────────────────────────── */ .pill { display: inline-flex; @@ -150,46 +235,37 @@ a:hover { font-size: 11px; font-weight: 500; letter-spacing: 0.02em; - border: 1px solid var(--color-stroke); - background: rgba(255, 255, 255, 0.04); - color: var(--color-ink-200); + border: none; + background: var(--color-surface-2); + color: var(--color-ink-300); white-space: nowrap; font-variant-numeric: tabular-nums; } -.pill.queued { - color: #9ec9ff; - border-color: rgba(100, 175, 255, 0.32); - background: rgba(10, 132, 255, 0.10); +.pill.queued, +.pill.open, +.pill.processed { + background: color-mix(in oklch, var(--color-info) 18%, transparent); + color: var(--color-info); } .pill.running { - color: #ffe26b; - border-color: rgba(255, 214, 10, 0.32); - background: rgba(255, 214, 10, 0.08); + background: color-mix(in oklch, var(--color-warn) 18%, transparent); + color: var(--color-warn); } .pill.done { - color: #7fe5a3; - border-color: rgba(48, 209, 88, 0.32); - background: rgba(48, 209, 88, 0.08); + background: color-mix(in oklch, var(--color-ok) 18%, transparent); + color: var(--color-ok); } .pill.failed { - color: #ff8e85; - border-color: rgba(255, 69, 58, 0.36); - background: rgba(255, 69, 58, 0.08); + background: color-mix(in oklch, var(--color-err) 18%, transparent); + color: var(--color-err); } .pill.skipped { + background: var(--color-surface-2); color: var(--color-ink-300); - border-color: rgba(255, 255, 255, 0.10); - background: rgba(255, 255, 255, 0.03); -} -.pill.open { - color: #9ec9ff; - border-color: rgba(100, 175, 255, 0.32); - background: rgba(10, 132, 255, 0.08); } .pill.closed { - color: #d8b4ff; - border-color: rgba(186, 130, 255, 0.32); - background: rgba(186, 130, 255, 0.08); + background: color-mix(in oklch, var(--color-accent) 18%, transparent); + color: var(--color-accent); } .pill.dot::before { content: ""; @@ -209,6 +285,28 @@ a:hover { 50% { opacity: 0.55; transform: scale(0.75); } } +/* Light-mode pill text: the bright dark-mode state colors (warn/info/ok/err/ + accent at L~0.55-0.62) wash out on their own light tints, so darken the TEXT + to the same hue at a lower L for AA legibility. Dark mode keeps the bright + tokens. The anti-flash script always sets data-theme, but mirror the system + fallback for the no-JS first paint. */ +[data-theme="light"] .pill.running { color: oklch(0.5 0.13 85); } +[data-theme="light"] .pill.queued, +[data-theme="light"] .pill.open, +[data-theme="light"] .pill.processed { color: oklch(0.36 0.13 230); } +[data-theme="light"] .pill.done { color: oklch(0.47 0.13 150); } +[data-theme="light"] .pill.failed { color: oklch(0.48 0.19 25); } +[data-theme="light"] .pill.closed { color: oklch(0.5 0.2 341); } +@media (prefers-color-scheme: light) { + :root:not([data-theme]) .pill.running { color: oklch(0.5 0.13 85); } + :root:not([data-theme]) .pill.queued, + :root:not([data-theme]) .pill.open, + :root:not([data-theme]) .pill.processed { color: oklch(0.36 0.13 230); } + :root:not([data-theme]) .pill.done { color: oklch(0.47 0.13 150); } + :root:not([data-theme]) .pill.failed { color: oklch(0.48 0.19 25); } + :root:not([data-theme]) .pill.closed { color: oklch(0.5 0.2 341); } +} + /* ────────────────────────────────────────────────────────────────────────── Buttons. ────────────────────────────────────────────────────────────────────────── */ @@ -221,7 +319,7 @@ button, font: inherit; font-weight: 500; padding: 7px 14px; - border-radius: 10px; + border-radius: var(--radius-md); border: 1px solid var(--color-stroke); background: var(--color-surface-1); color: var(--color-ink-100); @@ -236,7 +334,7 @@ button, button:hover:not(:disabled), .btn:hover:not(:disabled) { background: var(--color-surface-2); - border-color: rgba(255, 255, 255, 0.18); + border-color: var(--color-stroke); } button:active:not(:disabled), .btn:active:not(:disabled) { @@ -249,16 +347,13 @@ button:disabled, } button.primary, .btn.primary { - background: linear-gradient(180deg, #1f95ff 0%, #0a84ff 100%); - border-color: rgba(10, 132, 255, 0.7); - color: #fff; - box-shadow: - 0 6px 16px -6px rgba(10, 132, 255, 0.55), - inset 0 1px 0 rgba(255, 255, 255, 0.18); + background: var(--color-accent); + border-color: var(--color-accent); + color: var(--color-accent-fg); } button.primary:hover:not(:disabled), .btn.primary:hover:not(:disabled) { - background: linear-gradient(180deg, #34a0ff 0%, #1d8eff 100%); + filter: brightness(1.05); } button.ghost, .btn.ghost { @@ -274,21 +369,21 @@ button.ghost:hover:not(:disabled), } button.danger, .btn.danger { - color: #ff8e85; - border-color: rgba(255, 69, 58, 0.32); - background: rgba(255, 69, 58, 0.08); + color: var(--color-err); + border-color: color-mix(in oklch, var(--color-err) 32%, transparent); + background: color-mix(in oklch, var(--color-err) 10%, transparent); } button.danger:hover:not(:disabled), .btn.danger:hover:not(:disabled) { - background: rgba(255, 69, 58, 0.16); - border-color: rgba(255, 69, 58, 0.6); - color: #ffb1a8; + background: color-mix(in oklch, var(--color-err) 18%, transparent); + border-color: color-mix(in oklch, var(--color-err) 60%, transparent); + color: var(--color-err); } button.tiny, .btn.tiny { padding: 3px 9px; font-size: 11.5px; - border-radius: 8px; + border-radius: var(--radius-sm); } /* ────────────────────────────────────────────────────────────────────────── @@ -299,10 +394,10 @@ input[type="search"], input[type="password"], select { font: inherit; - background: rgba(0, 0, 0, 0.28); + background: var(--color-surface-1); color: var(--color-ink-100); border: 1px solid var(--color-stroke); - border-radius: 10px; + border-radius: var(--radius-md); padding: 7px 11px; outline: none; transition: @@ -318,8 +413,8 @@ input[type="text"]:focus, input[type="search"]:focus, input[type="password"]:focus, select:focus { - border-color: rgba(10, 132, 255, 0.55); - box-shadow: 0 0 0 3px rgba(10, 132, 255, 0.16); + border-color: var(--color-accent); + box-shadow: 0 0 0 3px color-mix(in oklch, var(--color-ring) 45%, transparent); } select { appearance: none; @@ -353,10 +448,10 @@ table.t thead th { font-weight: 500; letter-spacing: 0.14em; text-transform: uppercase; - color: var(--color-ink-400); + color: var(--color-ink-300); padding: 9px 14px; border-bottom: 1px solid var(--color-stroke); - background: rgba(255, 255, 255, 0.015); + background: var(--color-surface-2); white-space: nowrap; } table.t tbody td { @@ -369,7 +464,7 @@ table.t tbody tr { transition: background 120ms ease-out; } table.t tbody tr:hover td { - background: rgba(255, 255, 255, 0.025); + background: var(--color-surface-2); } table.t tbody tr:last-child td { border-bottom: none; @@ -377,11 +472,11 @@ table.t tbody tr:last-child td { table.t td .meta-line { display: block; margin-top: 2px; - color: var(--color-ink-400); + color: var(--color-ink-300); font-size: 11px; } .err-cell { - color: #ffa39c; + color: var(--color-err); white-space: pre-wrap; word-break: break-word; max-width: 460px; @@ -395,19 +490,26 @@ code, .code { font-family: var(--font-mono); font-size: 11.5px; - background: rgba(255, 255, 255, 0.05); + background: var(--color-surface-2); border: 1px solid var(--color-stroke-soft); padding: 1px 6px; - border-radius: 6px; + border-radius: var(--radius-md); color: var(--color-ink-200); } /* ────────────────────────────────────────────────────────────────────────── - Scrollbar styling on opt-in containers. + Error tint — flat 10% background for the needs-attention rows. + ────────────────────────────────────────────────────────────────────────── */ +.tint-err { + background: color-mix(in oklch, var(--color-err) 10%, transparent); +} + +/* ────────────────────────────────────────────────────────────────────────── + Scrollbar styling on opt-in containers — stats capsule look. ────────────────────────────────────────────────────────────────────────── */ .scrollable { scrollbar-width: thin; - scrollbar-color: rgba(255, 255, 255, 0.12) transparent; + scrollbar-color: var(--color-stroke) transparent; } .scrollable::-webkit-scrollbar { width: 8px; @@ -417,11 +519,11 @@ code, background: transparent; } .scrollable::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.08); + background: var(--color-stroke); border-radius: 999px; } .scrollable::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.18); + background: var(--color-ink-400); } /* ────────────────────────────────────────────────────────────────────────── @@ -436,14 +538,14 @@ code, } .log-row { display: grid; - grid-template-columns: 80px 60px 160px 1fr; + grid-template-columns: 160px 56px 150px 1fr; gap: 12px; padding: 4px 16px; border-bottom: 1px solid var(--color-stroke-soft); align-items: baseline; } .log-row:hover { - background: rgba(255, 255, 255, 0.025); + background: var(--color-surface-2); } .log-row .ts { color: var(--color-ink-400); @@ -473,15 +575,15 @@ code, font-weight: 500; } .log-row .exc { - color: #ffa39c; + color: var(--color-err); white-space: pre-wrap; display: block; margin-top: 4px; } -.lvl.INFO { color: #6fb0ff; } +.lvl.INFO { color: var(--color-info); } .lvl.DEBUG { color: var(--color-ink-400); } -.lvl.WARNING { color: #ffd66b; } -.lvl.ERROR { color: #ff8e85; } +.lvl.WARNING { color: var(--color-warn); } +.lvl.ERROR { color: var(--color-err); } .lvl.RAW { color: var(--color-ink-400); } /* ────────────────────────────────────────────────────────────────────────── @@ -549,3 +651,651 @@ code, font-style: italic; text-align: center; } + +/* ────────────────────────────────────────────────────────────────────────── + App-shell classes (rail + main + topbar + content). Unlayered so Tailwind + v4 does not purge them; they consume only existing color and radius tokens + so light + dark both resolve. Ports the stats shell skeleton (stats- classes + renamed to rmp-). + ────────────────────────────────────────────────────────────────────────── */ +.rmp-app { + display: flex; + height: 100vh; + overflow: hidden; + background: var(--color-ink-950); + color: var(--color-ink-100); +} + +.rmp-rail { + width: 248px; + background: var(--color-surface-1); + border-right: 1px solid var(--color-stroke); + display: flex; + flex-direction: column; + flex-shrink: 0; + height: 100%; + overflow-y: auto; +} +.rmp-desktop-nav { display: flex; } +@media (max-width: 1023px) { + .rmp-desktop-nav { display: none; } +} + +/* Brand lockup */ +.rmp-rail-brand { + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + column-gap: 10px; + padding: 16px 20px; + border-bottom: 1px solid var(--color-stroke); + min-height: 56px; +} +.rmp-rail-mark { + width: 16px; + height: 16px; + border-radius: 4px; + background: linear-gradient(135deg, #ed4abf 0%, #9b4dff 50%, #5ad8e6 100%); + flex-shrink: 0; +} +.rmp-rail-wordmark { + display: flex; + flex-direction: column; + min-width: 0; +} +.rmp-rail-name { + font-size: 14px; + font-weight: 700; + letter-spacing: 0.04em; + color: var(--color-ink-50); + line-height: 1.1; +} +.rmp-rail-tag { + font-size: 9.5px; + text-transform: uppercase; + letter-spacing: 0.14em; + color: var(--color-ink-400); + line-height: 1.3; +} + +/* Vitals block — always-on glanceable signal */ +.rmp-vitals { + padding: 16px 20px; + display: flex; + flex-direction: column; + gap: 14px; + border-bottom: 1px solid var(--color-stroke); +} +.rmp-vital-sync { + display: flex; + align-items: center; + gap: 8px; + font-size: 11px; + color: var(--color-ink-300); + font-variant-numeric: tabular-nums; +} +.rmp-vital-dot { + width: 7px; + height: 7px; + border-radius: 999px; + flex-shrink: 0; +} +.rmp-vital-dot.ok { background: var(--color-ok); } +.rmp-vital-dot.warn { background: var(--color-warn); } +.rmp-vital-dot.err { background: var(--color-err); } +.rmp-vital-dot.pulse { animation: pulse-dot 1.8s ease-in-out infinite; } + +.rmp-vital-big { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} +.rmp-vital-big-cell { display: flex; flex-direction: column; gap: 3px; } +.rmp-vital-big-label { + font-size: 9.5px; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--color-ink-400); +} +.rmp-vital-big-num { + font-size: 22px; + font-weight: 600; + line-height: 1; + font-variant-numeric: tabular-nums; + color: var(--color-ink-100); +} +.rmp-vital-big-num.failed-nonzero { color: var(--color-err); } +.rmp-vital-big-num.running-nonzero { color: var(--color-warn); } + +.rmp-vital-counts { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 9px 14px; +} +.rmp-vital-count { display: flex; flex-direction: column; gap: 1px; } +.rmp-vital-count-label { + font-size: 9px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--color-ink-400); +} +.rmp-vital-count-num { + font-size: 14px; + font-weight: 500; + font-variant-numeric: tabular-nums; + color: var(--color-ink-200); +} + +.rmp-vital-runtime { + display: flex; + flex-direction: column; + gap: 5px; +} +.rmp-vital-runtime-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} +.rmp-vital-runtime-label { + font-size: 9px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--color-ink-400); + flex-shrink: 0; +} +.rmp-vital-runtime-value { + font-size: 11px; + color: var(--color-ink-200); + text-align: right; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} +.rmp-vital-runtime-value.mono { font-family: var(--font-mono); font-size: 10.5px; } + +/* Nav */ +.rmp-nav { + flex: 1; + padding: 12px; + display: flex; + flex-direction: column; + gap: 2px; +} +.rmp-nav-item { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 14px; + font-size: 13px; + font-weight: 500; + color: var(--color-ink-300); + background: transparent; + border: none; + border-radius: var(--radius-md); + cursor: pointer; + text-align: left; + width: 100%; + transition: color 150ms ease, background 150ms ease; +} +.rmp-nav-item:hover { + color: var(--color-ink-100); + background: var(--color-surface-2); +} +.rmp-nav-item[data-active="true"] { + color: var(--color-accent); + background: var(--color-accent-muted); + position: relative; +} +.rmp-nav-item[data-active="true"]::before { + content: ""; + position: absolute; + left: 0; + top: 50%; + height: 18px; + width: 3px; + border-radius: 0 3px 3px 0; + background: var(--color-accent); + transform: translateY(-50%); +} +.rmp-nav-item[data-locked="true"] { opacity: 0.5; } +.rmp-nav-item-icon { flex-shrink: 0; display: inline-flex; } +.rmp-nav-item-label { flex: 1; min-width: 0; } +.rmp-nav-item-lock { flex-shrink: 0; display: inline-flex; color: var(--color-ink-400); } + +/* Footer */ +.rmp-rail-footer { + padding: 14px 20px; + border-top: 1px solid var(--color-stroke); + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} +.rmp-rail-footer-tag { + font-size: 9.5px; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--color-ink-500); + font-variant-numeric: tabular-nums; +} + +/* Main pane */ +.rmp-main { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + height: 100%; +} +.rmp-topbar { + height: 56px; + background: var(--color-surface-1); + border-bottom: 1px solid var(--color-stroke); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 24px; + flex-shrink: 0; +} +.rmp-topbar-left { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} +.rmp-topbar-right { + display: flex; + align-items: center; + gap: 12px; +} +.rmp-page-title { + font-size: 15px; + font-weight: 600; + color: var(--color-ink-50); + margin: 0; +} +.rmp-mobile-menu-btn { + display: none; + background: transparent; + border: none; + color: var(--color-ink-200); + padding: 4px; + cursor: pointer; + border-radius: var(--radius-sm); +} +@media (max-width: 1023px) { + .rmp-mobile-menu-btn { display: inline-flex; } +} + +/* Content — the only vertical scroller */ +.rmp-content { + flex: 1; + overflow-y: auto; +} +.rmp-content-inner { + max-width: 1400px; + margin: 0 auto; + padding: 24px; + display: flex; + flex-direction: column; + gap: 20px; +} +@media (max-width: 767px) { + .rmp-content-inner { padding: 16px; gap: 16px; } + .rmp-topbar { padding: 0 16px; } +} + +/* View panes — all mounted, visibility toggled (state persists, no refetch). + Base is flex; inactive views get inline display:none. The enter animation + restarts automatically when an element flips none→flex (CSS animations + restart when re-entering the render tree) — so it fires on first paint + + view-switch ONLY, never on the 3s poll (polls never change display). */ +.rmp-view { display: flex; flex-direction: column; gap: 20px; } +@media (max-width: 767px) { .rmp-view { gap: 16px; } } + +@keyframes rmp-view-rise { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} +.rmp-view-enter { + animation: rmp-view-rise 220ms cubic-bezier(0.16, 1, 0.3, 1) both; +} + +/* Trigger bar — slim command row for Operations */ +.rmp-trigger-bar { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + background: var(--color-surface-1); + border: 1px solid var(--color-stroke); + border-radius: var(--radius-lg); + flex-wrap: wrap; +} +.rmp-trigger-bar-status { + font-size: 11.5px; + color: var(--color-ink-300); + font-variant-numeric: tabular-nums; + margin-left: auto; +} + +/* Mobile drawer */ +.rmp-mobile-drawer-overlay { + position: fixed; + inset: 0; + background: oklch(0 0 0 / 45%); + z-index: 100; +} +.rmp-mobile-drawer { + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 280px; + max-width: 86vw; + background: var(--color-surface-1); + border-right: 1px solid var(--color-stroke); + display: flex; + flex-direction: column; + z-index: 101; + animation: rmp-slide-in 0.2s cubic-bezier(0.16, 1, 0.3, 1); +} +/* Inside the drawer the rail fills and is non-sticky */ +.rmp-mobile-drawer .rmp-rail { + width: 100%; + position: relative; + height: 100%; + border-right: none; +} +@keyframes rmp-slide-in { + from { transform: translateX(-100%); } + to { transform: translateX(0); } +} +.rmp-drawer-close { + background: transparent; + border: none; + color: var(--color-ink-300); + cursor: pointer; + padding: 4px; + display: inline-flex; + border-radius: var(--radius-sm); +} +.rmp-drawer-close:hover { color: var(--color-ink-100); background: var(--color-surface-2); } + +/* Read-only chip */ +.rmp-readonly-chip { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 9px; + font-size: 10.5px; + letter-spacing: 0.08em; + text-transform: uppercase; + border-radius: 999px; + background: var(--color-surface-2); + color: var(--color-ink-300); + border: 1px solid var(--color-stroke); + white-space: nowrap; +} + +/* First-run panel (Triage read-only) */ +.rmp-firstrun { + padding: 32px 28px; + display: flex; + flex-direction: column; + gap: 14px; +} +.rmp-firstrun-head { + display: flex; + align-items: center; + gap: 10px; +} +.rmp-firstrun-title { + font-size: 14px; + font-weight: 600; + color: var(--color-ink-100); + margin: 0; +} +.rmp-firstrun-body { + font-size: 13px; + color: var(--color-ink-300); + line-height: 1.6; + margin: 0; +} +.rmp-firstrun-steps { + display: flex; + flex-direction: column; + gap: 8px; + margin: 4px 0 0 0; + padding: 0; + list-style: none; +} +.rmp-firstrun-steps li { + font-size: 12.5px; + color: var(--color-ink-300); + padding-left: 18px; + position: relative; + line-height: 1.5; +} +.rmp-firstrun-steps li::before { + content: ""; + position: absolute; + left: 0; + top: 7px; + width: 5px; + height: 5px; + border-radius: 999px; + background: var(--color-accent); +} +.rmp-firstrun-note { + font-size: 11.5px; + color: var(--color-ink-400); + margin: 0; +} + +/* ────────────────────────────────────────────────────────────────────────── + Operations pipeline — unified issue-lifecycle cards. Replaces the old + working / needs-attention / active-issues tables. Token-only colours so + both themes resolve; flat (no shadow), full borders (no side-stripe), + radius capped at var(--radius-lg). State tint via [data-bucket]. + ────────────────────────────────────────────────────────────────────────── */ +.rmp-pipeline-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 16px; + padding: 2px 2px 0; +} +.rmp-pipeline-head-label { + font-size: 11px; + font-weight: 500; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--color-ink-300); +} +.rmp-pipeline-head-count { + font-size: 11px; + color: var(--color-ink-400); +} + +/* Breakpoint-free responsive grid (impeccable). */ +.rmp-card-grid { + display: grid; + gap: 16px; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 380px), 1fr)); +} + +.rmp-card { + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px; + border-radius: var(--radius-lg); + background: var(--color-surface-1); + border: 1px solid var(--color-stroke); + transition: + border-color 160ms cubic-bezier(0.16, 1, 0.3, 1), + background-color 160ms cubic-bezier(0.16, 1, 0.3, 1); +} +.rmp-card:hover { + border-color: color-mix(in oklch, var(--color-ink-400) 30%, var(--color-stroke)); +} +.rmp-card[data-bucket="failed"] { + background: color-mix(in oklch, var(--color-err) 9%, var(--color-surface-1)); + border-color: color-mix(in oklch, var(--color-err) 22%, var(--color-stroke)); +} +.rmp-card[data-bucket="running"] { + border-color: color-mix(in oklch, var(--color-accent) 22%, var(--color-stroke)); +} + +.rmp-card-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + flex-wrap: wrap; +} +.rmp-card-id { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + min-width: 0; +} + +.rmp-card-meta { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 0; + font-size: 11.5px; + line-height: 1.5; + color: var(--color-ink-200); +} +.rmp-card-meta > :not(:first-child)::before { + content: "·"; + margin: 0 8px; + color: var(--color-ink-500); +} +.rmp-card-meta-dim { + color: var(--color-ink-200); +} + +.rmp-card-error { + margin-top: 0; + max-width: none; +} +.rmp-card-actions { + display: flex; + gap: 8px; +} + +/* Lifecycle stepper — horizontal dot/segment/label track. Equal-width cells + keep dot centres evenly spaced; each cell's connector is absolutely placed + from its dot centre to the next, and the dot paints over it. */ +.rmp-step { + display: flex; + align-items: flex-start; +} +.rmp-step-cell { + flex: 1 1 0; + min-width: 0; + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 5px; +} +.rmp-step-seg { + position: absolute; + top: 4px; + left: 50%; + width: 100%; + height: 1px; + background: var(--color-stroke); +} +.rmp-step-seg[data-state="complete"] { + background: var(--color-accent); +} +.rmp-step-node { + position: relative; + width: 9px; + height: 9px; + border-radius: 999px; + box-sizing: border-box; + flex-shrink: 0; +} +.rmp-step-node[data-state="complete"] { + background: var(--color-accent); +} +.rmp-step-node[data-state="current"] { + background: var(--color-surface-1); + border: 2px solid var(--color-accent); +} +.rmp-step-node[data-state="failed"] { + background: var(--color-err); +} +.rmp-step-node[data-state="pending"] { + background: var(--color-surface-1); + border: 1px solid var(--color-ink-500); +} +.rmp-step-node[data-live="true"] { + animation: pulse-dot 1.8s ease-in-out infinite; +} +.rmp-step-label { + max-width: 100%; + font-size: 10.5px; + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--color-ink-200); +} +.rmp-step-label[data-reached="true"] { + color: var(--color-ink-200); +} + +/* First-load skeleton — calm opacity pulse, never a spinner. */ +@keyframes rmp-skeleton { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.55; } +} +.rmp-card-skeleton { + height: 138px; + border-radius: var(--radius-lg); + border: 1px solid var(--color-stroke); + background: var(--color-surface-1); + animation: rmp-skeleton 1.8s ease-in-out infinite; +} + +/* Teaching empty state. */ +.rmp-pipeline-empty { + display: flex; + flex-direction: column; + gap: 6px; + padding: 32px 20px; + text-align: center; +} +.rmp-pipeline-empty-title { + font-size: 13px; + color: var(--color-ink-300); +} +.rmp-pipeline-empty-sub { + font-size: 12px; + color: var(--color-ink-400); +} + +@media (prefers-reduced-motion: reduce) { + .rmp-nav-item, + .rmp-mobile-menu-btn, + .rmp-drawer-close, + .rmp-readonly-chip { transition: none; } + .rmp-mobile-drawer, + .rmp-view-enter { animation: none; } + .rmp-vital-dot.pulse { animation: none; } + .rmp-card { transition: none; } + .rmp-card-skeleton, + .rmp-step-node[data-live="true"] { animation: none; } +} diff --git a/python/robomp/web/src/theme.ts b/python/robomp/web/src/theme.ts new file mode 100644 index 000000000..89fa588d7 --- /dev/null +++ b/python/robomp/web/src/theme.ts @@ -0,0 +1,60 @@ +import { createSignal } from "solid-js"; + +// Theme preference + resolved-theme store for robomp. Ports the stats dashboard +// `useSystemTheme` module-store logic onto SolidJS signals so the toggle (writer) +// and every consumer (reader) resolve through one source. The anti-flash script +// in index.html applies the persisted/system theme before first paint; this +// module re-reads the same key so the toggle stays in sync across reloads. + +export type SystemTheme = "light" | "dark"; +export type ThemePreference = "system" | "light" | "dark"; + +export const STORAGE_KEY = "omp-robomp-theme"; +const DARK_SCHEME_QUERY = "(prefers-color-scheme: dark)"; + +function readStoredPreference(): ThemePreference { + if (typeof localStorage === "undefined") return "system"; + const stored = localStorage.getItem(STORAGE_KEY); + return stored === "light" || stored === "dark" || stored === "system" ? stored : "system"; +} + +function getSystemTheme(): SystemTheme { + if (typeof window === "undefined") return "dark"; + return window.matchMedia(DARK_SCHEME_QUERY).matches ? "dark" : "light"; +} + +const initialPreference: ThemePreference = readStoredPreference(); +const initialResolved: SystemTheme = + initialPreference === "system" ? getSystemTheme() : initialPreference; + +const [preference, setPreferenceSignal] = + createSignal(initialPreference); +const [resolved, setResolvedSignal] = createSignal(initialResolved); + +function applyResolvedTheme(): void { + const pref = preference(); + const next: SystemTheme = pref === "system" ? getSystemTheme() : pref; + setResolvedSignal(next); + if (typeof document !== "undefined") { + document.documentElement.dataset.theme = next; + document.documentElement.style.colorScheme = next; + } +} + +// Re-resolve when the OS theme flips while following the system default. +if (typeof window !== "undefined") { + applyResolvedTheme(); + window.matchMedia(DARK_SCHEME_QUERY).addEventListener("change", () => { + if (preference() === "system") { + applyResolvedTheme(); + } + }); +} + +export function setPreference(next: ThemePreference): void { + setPreferenceSignal(next); + if (typeof localStorage !== "undefined") localStorage.setItem(STORAGE_KEY, next); + applyResolvedTheme(); +} + +export { preference, resolved }; diff --git a/python/robomp/web/src/view.ts b/python/robomp/web/src/view.ts new file mode 100644 index 000000000..5ce039c41 --- /dev/null +++ b/python/robomp/web/src/view.ts @@ -0,0 +1,12 @@ +import { createSignal } from "solid-js"; + +// Active-view module signal. Mirrors the existing state.ts / theme.ts idiom: +// rail + topbar + views all read/write through one source — no prop-drilling. +// Views stay mounted; Shell toggles visibility so filter state + Browse's +// createResource survive view switches. + +export type View = "operations" | "activity" | "triage"; + +const [activeView, setActiveView] = createSignal("operations"); + +export { activeView, setActiveView }; diff --git a/python/robomp/web/src/work-items.contract.test.ts b/python/robomp/web/src/work-items.contract.test.ts new file mode 100644 index 000000000..a439c3247 --- /dev/null +++ b/python/robomp/web/src/work-items.contract.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test"; +import { buildWorkItems } from "./work-items"; +import type { StatusResponse } from "./types"; + +// Read from test/fixtures/status-contract.json relative to src/ +const status = await Bun.file( + new URL("../test/fixtures/status-contract.json", import.meta.url).pathname +).json() as StatusResponse; + +describe("buildWorkItems contract validation", () => { + test("satisfies relational properties on real status-contract payload", () => { + const items = buildWorkItems(status); + + // 1. The merged issue key is absent from results + const mergedKey = "octo/widget#4"; + expect(items.some((item) => item.key === mergedKey)).toBe(false); + + // 2. The superseded older-failed delivery id is absent + const supersededId = "superseded-failed"; + expect(items.some((item) => item.deliveryId === supersededId)).toBe(false); + + // 3. The currently-failed issue is present with bucket: "failed" + const failedItem = items.find((item) => item.key === "octo/widget#2"); + expect(failedItem).toBeDefined(); + expect(failedItem?.bucket).toBe("failed"); + expect(failedItem?.error).toBe("repro diverged"); + + // and sorts before any running card + const failedIndices = items + .map((item, idx) => (item.bucket === "failed" ? idx : null)) + .filter((idx): idx is number => idx !== null); + const runningIndices = items + .map((item, idx) => (item.bucket === "running" ? idx : null)) + .filter((idx): idx is number => idx !== null); + + if (failedIndices.length > 0 && runningIndices.length > 0) { + const maxFailedIndex = Math.max(...failedIndices); + const minRunningIndex = Math.min(...runningIndices); + expect(maxFailedIndex).toBeLessThan(minRunningIndex); + } + + // 4. The issue-less failed delivery is present with ref: null, bucket: "failed" + const orphanFailed = items.find((item) => item.deliveryId === "orphan-failed-x"); + expect(orphanFailed).toBeDefined(); + expect(orphanFailed?.ref).toBeNull(); + expect(orphanFailed?.bucket).toBe("failed"); + expect(orphanFailed?.error).toBe("orphan failed error"); + + // 5. The running issue appears exactly once with bucket: "running" + const runningItems = items.filter((item) => item.key === "octo/widget#1"); + expect(runningItems).toHaveLength(1); + const runningItem = runningItems[0]; + expect(runningItem.bucket).toBe("running"); + expect(runningItem.live).not.toBeNull(); + expect(runningItem.live?.model).toBe("anthropic/claude-3-5-sonnet"); + expect(runningItem.live?.last_tool).toBe("edit"); + + // 6. Bucket order is non-decreasing by rank (failed < running < queued < active) + const ranks = { failed: 0, running: 1, queued: 2, active: 3 }; + for (let i = 0; i < items.length - 1; i++) { + const rankA = ranks[items[i].bucket]; + const rankB = ranks[items[i + 1].bucket]; + expect(rankA).toBeLessThanOrEqual(rankB); + } + }); +}); diff --git a/python/robomp/web/src/work-items.test.ts b/python/robomp/web/src/work-items.test.ts new file mode 100644 index 000000000..b5ea0a98e --- /dev/null +++ b/python/robomp/web/src/work-items.test.ts @@ -0,0 +1,500 @@ +import { describe, expect, test } from "bun:test"; + +import { buildWorkItems, stageOrdinal } from "./work-items"; +import type { + EventState, + IssueRow, + LatestEvent, + RecentEvent, + RunningEvent, + RuntimeInfo, + StatusResponse, +} from "./types"; + +const BASE_RUNTIME: RuntimeInfo = { + bot_login: "robomp", + repo_allowlist: [], + max_concurrency: 1, + model: "test-model", + thinking_level: "low", + uptime_seconds: 0, +}; + +function eventCounts(): Record { + return { + queued: 0, + running: 0, + done: 0, + failed: 0, + skipped: 0, + }; +} + +function status(overrides: Partial = {}): StatusResponse { + return { + runtime: BASE_RUNTIME, + event_counts: eventCounts(), + issue_event_counts: eventCounts(), + running_events: [], + inflight: [], + issues: [], + recent_events: [], + ...overrides, + }; +} + +function issue(overrides: Partial = {}): IssueRow { + return { + key: "owner/repo#1", + repo: "owner/repo", + number: 1, + branch: null, + pr_number: null, + state: "new", + classification: "bug", + updated_at: "2026-06-17T00:00:00Z", + latest_event: null, + ...overrides, + }; +} + +function latestEvent(overrides: Partial = {}): LatestEvent { + return { + delivery_id: "delivery-1", + event_type: "issues", + state: "queued", + attempts: 1, + received_at: "2026-06-17T00:00:00Z", + last_error: null, + ...overrides, + }; +} + +function runningEvent(overrides: Partial = {}): RunningEvent { + return { + delivery_id: "delivery-1", + event_type: "issues", + repo: "owner/repo", + issue_key: "owner/repo#1", + received_at: "2026-06-17T00:00:00Z", + started_at: "2026-06-17T00:01:00Z", + attempts: 1, + model: "test-model", + last_tool: null, + last_tool_ts: null, + ...overrides, + }; +} + +function recentEvent(overrides: Partial = {}): RecentEvent { + return { + delivery_id: "failed-delivery", + event_type: "issues", + repo: "owner/repo", + issue_key: null, + state: "failed", + attempts: 1, + received_at: "2026-06-17T00:00:00Z", + last_error: "boom", + ...overrides, + }; +} + +describe("buildWorkItems", () => { + test("deduplicates a running issue that is also inflight", () => { + const items = buildWorkItems( + status({ + issues: [ + issue({ + key: "owner/repo#7", + number: 7, + latest_event: latestEvent({ + delivery_id: "delivery-7", + state: "running", + }), + }), + ], + running_events: [ + runningEvent({ + delivery_id: "delivery-7", + issue_key: "owner/repo#7", + }), + ], + inflight: ["owner/repo#7"], + }), + ); + + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ + key: "owner/repo#7", + deliveryId: "delivery-7", + bucket: "running", + inflightOnly: false, + }); + expect(items[0].live?.delivery_id).toBe("delivery-7"); + }); + + test("excludes terminal issues", () => { + const items = buildWorkItems( + status({ + issues: [ + issue({ + key: "owner/repo#2", + number: 2, + state: "merged", + latest_event: latestEvent({ delivery_id: "done-2", state: "done" }), + }), + ], + }), + ); + + expect(items).toEqual([]); + }); + + test("uses issue latest_event as authority for failed issue rows", () => { + const items = buildWorkItems( + status({ + issues: [ + issue({ + key: "owner/repo#3", + number: 3, + latest_event: latestEvent({ + delivery_id: "done-3", + state: "done", + received_at: "2026-06-17T00:03:00Z", + }), + }), + issue({ + key: "owner/repo#4", + number: 4, + latest_event: latestEvent({ + delivery_id: "failed-4", + state: "failed", + received_at: "2026-06-17T00:04:00Z", + last_error: "current failure", + }), + }), + ], + recent_events: [ + recentEvent({ + delivery_id: "old-failed-3", + issue_key: "owner/repo#3", + received_at: "2026-06-17T00:02:00Z", + last_error: "superseded failure", + }), + recentEvent({ + delivery_id: "failed-4", + issue_key: "owner/repo#4", + received_at: "2026-06-17T00:04:00Z", + last_error: "current failure", + }), + ], + }), + ); + + expect(items.some((item) => item.deliveryId === "old-failed-3")).toBe(false); + expect(items.filter((item) => item.deliveryId === "failed-4")).toHaveLength(1); + expect(items.find((item) => item.deliveryId === "failed-4")).toMatchObject({ + key: "owner/repo#4", + bucket: "failed", + error: "current failure", + }); + }); + + test("includes issue-less failed recent events as orphan failed items", () => { + const items = buildWorkItems( + status({ + recent_events: [ + recentEvent({ + delivery_id: "orphan-failed", + issue_key: null, + received_at: "2026-06-17T00:05:00Z", + last_error: "orphan failure", + }), + ], + }), + ); + + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ + key: "orphan-failed", + ref: null, + deliveryId: "orphan-failed", + bucket: "failed", + error: "orphan failure", + latestEvent: { + delivery_id: "orphan-failed", + state: "failed", + last_error: "orphan failure", + }, + }); + }); + + test("orders buckets by severity before ordering newest first within a bucket", () => { + const items = buildWorkItems( + status({ + issues: [ + issue({ + key: "owner/repo#1", + number: 1, + latest_event: latestEvent({ + delivery_id: "failed-old", + state: "failed", + received_at: "2026-06-17T00:01:00Z", + }), + }), + issue({ + key: "owner/repo#2", + number: 2, + latest_event: latestEvent({ + delivery_id: "running-2", + state: "running", + received_at: "2026-06-17T00:02:00Z", + }), + }), + issue({ + key: "owner/repo#3", + number: 3, + latest_event: latestEvent({ + delivery_id: "queued-3", + state: "queued", + received_at: "2026-06-17T00:03:00Z", + }), + }), + issue({ + key: "owner/repo#4", + number: 4, + updated_at: "2026-06-17T00:04:00Z", + }), + issue({ + key: "owner/repo#5", + number: 5, + latest_event: latestEvent({ + delivery_id: "failed-new", + state: "failed", + received_at: "2026-06-17T00:05:00Z", + }), + }), + ], + }), + ); + + expect(items.map((item) => `${item.bucket}:${item.key}`)).toEqual([ + "failed:owner/repo#5", + "failed:owner/repo#1", + "running:owner/repo#2", + "queued:owner/repo#3", + "active:owner/repo#4", + ]); + }); + + test("returns no work items for an empty status response", () => { + expect(buildWorkItems(status())).toEqual([]); + }); + + test("1. inflight-only active issue", () => { + const items = buildWorkItems( + status({ + issues: [ + issue({ + key: "owner/repo#12", + number: 12, + latest_event: null, + }), + ], + inflight: ["owner/repo#12"], + }), + ); + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ + key: "owner/repo#12", + bucket: "running", + inflightOnly: true, + live: null, + }); + }); + + test("2. orphan running delivery, issue_key present but no issue row", () => { + const items = buildWorkItems( + status({ + running_events: [ + runningEvent({ + issue_key: "octo/widget#999", + delivery_id: "run-x", + }), + ], + }), + ); + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ + ref: null, + bucket: "running", + deliveryId: "run-x", + inflightOnly: false, + }); + expect(items[0].live).not.toBeNull(); + }); + + test("3. orphan inflight key, no issue & no running event", () => { + const items = buildWorkItems( + status({ + inflight: ["octo/widget#888"], + }), + ); + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ + ref: null, + bucket: "running", + inflightOnly: true, + live: null, + deliveryId: "octo/widget#888", + }); + }); + + test("4. failed issue with last_error:null", () => { + const items = buildWorkItems( + status({ + issues: [ + issue({ + key: "owner/repo#14", + latest_event: latestEvent({ + state: "failed", + last_error: null, + }), + }), + ], + }), + ); + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ + bucket: "failed", + error: null, + }); + }); + + test("5. recent failed event with empty delivery_id", () => { + const items = buildWorkItems( + status({ + recent_events: [ + recentEvent({ + delivery_id: "", + issue_key: null, + }), + ], + }), + ); + expect(items).toEqual([]); + }); + + test("6. recent failed event duplicating an already-seen delivery_id", () => { + const items = buildWorkItems( + status({ + issues: [ + issue({ + key: "owner/repo#15", + latest_event: latestEvent({ + delivery_id: "dup", + state: "failed", + }), + }), + ], + recent_events: [ + recentEvent({ + delivery_id: "dup", + issue_key: "owner/repo#15", + }), + ], + }), + ); + expect(items).toHaveLength(1); + expect(items[0].deliveryId).toBe("dup"); + }); + + test("7. recent failed event for an already-seen issue_key, different delivery, issue latest is that same failed delivery", () => { + const items = buildWorkItems( + status({ + issues: [ + issue({ + key: "owner/repo#16", + latest_event: latestEvent({ + delivery_id: "A", + state: "failed", + }), + }), + ], + recent_events: [ + recentEvent({ + delivery_id: "B", + issue_key: "owner/repo#16", + }), + ], + }), + ); + expect(items).toHaveLength(1); + expect(items[0].deliveryId).toBe("A"); + }); + + test("8. orphan failed event, issue_key non-numeric after # / plainstring", () => { + const items1 = buildWorkItems( + status({ + recent_events: [ + recentEvent({ + issue_key: "weird#abc", + delivery_id: "w1", + }), + ], + }), + ); + expect(items1).toHaveLength(1); + expect(items1[0].ref).toBeNull(); + + const items2 = buildWorkItems( + status({ + recent_events: [ + recentEvent({ + issue_key: "plainstring", + delivery_id: "w2", + }), + ], + }), + ); + expect(items2).toHaveLength(1); + expect(items2[0].ref).toEqual({ + repo: "plainstring", + number: 0, + }); + }); + + test("9. stageOrdinal direct", () => { + expect(stageOrdinal(null)).toBe(0); + expect(stageOrdinal("nonsense")).toBe(0); + expect(stageOrdinal("new")).toBe(0); + expect(stageOrdinal("reproducing")).toBe(1); + expect(stageOrdinal("fixing")).toBe(2); + expect(stageOrdinal("opened")).toBe(3); + expect(stageOrdinal("merged")).toBe(4); + }); + + test("10. parseTs invalid -> 0 tiebreak", () => { + const items = buildWorkItems( + status({ + running_events: [ + runningEvent({ + delivery_id: "run-invalid-ts", + issue_key: null, + started_at: null, + received_at: "invalid-date", + }), + runningEvent({ + delivery_id: "run-valid-ts", + issue_key: null, + started_at: "2026-06-17T00:01:00Z", + }), + ], + }), + ); + expect(items).toHaveLength(2); + expect(items.map(i => i.deliveryId)).toEqual(["run-valid-ts", "run-invalid-ts"]); + }); + +}); diff --git a/python/robomp/web/src/work-items.ts b/python/robomp/web/src/work-items.ts new file mode 100644 index 000000000..b7e9556c0 --- /dev/null +++ b/python/robomp/web/src/work-items.ts @@ -0,0 +1,189 @@ +import { splitIssueKey } from "./format"; +import { + type LatestEvent, + type RecentEvent, + type RunningEvent, + type StatusResponse, + TERMINAL_ISSUE_STATES, +} from "./types"; + +export type WorkBucket = "failed" | "running" | "queued" | "active"; + +export interface WorkItem { + key: string; + ref: { repo: string; number: number } | null; + deliveryId: string; + issueState: string | null; + classification: string | null; + branch: string | null; + prNumber: number | null; + latestEvent: LatestEvent | null; + live: RunningEvent | null; + inflightOnly: boolean; + bucket: WorkBucket; + error: string | null; + sortTs: number; +} + +export const CODE_STAGES = ["new", "reproducing", "fixing", "PR", "done"] as const; +export const SIMPLE_STAGES = ["triaged", "resolved"] as const; +export const SIMPLE_CLASSIFICATIONS: ReadonlySet = new Set([ + "question", + "enhancement", + "proposal", + "invalid", + "duplicate", +]); + +const STATE_ORDINAL: Record = { + new: 0, + reproducing: 1, + fixing: 2, + opened: 3, + merged: 4, + closed: 4, + abandoned: 4, +}; + +const BUCKET_RANK: Record = { + failed: 0, + running: 1, + queued: 2, + active: 3, +}; + +export function stageOrdinal(state: string | null): number { + return state ? STATE_ORDINAL[state] ?? 0 : 0; +} + +export function buildWorkItems(status: StatusResponse): WorkItem[] { + const runningByKey = new Map(); + for (const event of status.running_events) { + runningByKey.set(event.issue_key ?? event.delivery_id, event); + } + + const inflightSet = new Set(status.inflight); + const issueByKey = new Map(status.issues.map((issue) => [issue.key, issue])); + const seen = new Set(); + const items: WorkItem[] = []; + + for (const issue of status.issues) { + if (TERMINAL_ISSUE_STATES.has(issue.state)) continue; + + const key = issue.key; + seen.add(key); + + const live = runningByKey.get(key) ?? null; + const inflightOnly = !live && inflightSet.has(key); + const latest = issue.latest_event; + const latestState = latest?.state; + const bucket: WorkBucket = + latestState === "failed" + ? "failed" + : live || inflightOnly || latestState === "running" + ? "running" + : latestState === "queued" + ? "queued" + : "active"; + + items.push({ + key, + ref: { repo: issue.repo, number: issue.number }, + deliveryId: latest?.delivery_id ?? "", + issueState: issue.state, + classification: issue.classification, + branch: issue.branch, + prNumber: issue.pr_number, + latestEvent: latest, + live, + inflightOnly, + bucket, + error: latestState === "failed" ? (latest?.last_error ?? null) : null, + sortTs: parseTs(live?.started_at ?? latest?.received_at ?? issue.updated_at), + }); + } + + for (const [key, event] of runningByKey) { + if (seen.has(key)) continue; + seen.add(key); + items.push(orphanLiveItem(key, event, false)); + } + + for (const key of inflightSet) { + if (seen.has(key)) continue; + seen.add(key); + items.push(orphanLiveItem(key, null, true)); + } + + for (const event of status.recent_events) { + if (event.state !== "failed" || !event.delivery_id || seen.has(event.delivery_id)) continue; + if (event.issue_key) { + const latest = issueByKey.get(event.issue_key)?.latest_event; + if (latest && (latest.delivery_id !== event.delivery_id || latest.state !== "failed")) { + continue; + } + if (seen.has(event.issue_key)) continue; + } + + seen.add(event.delivery_id); + items.push({ + key: event.issue_key ?? event.delivery_id, + ref: splitRef(event.issue_key), + deliveryId: event.delivery_id, + issueState: null, + classification: null, + branch: null, + prNumber: null, + latestEvent: latestEventFromRecent(event), + live: null, + inflightOnly: false, + bucket: "failed", + error: event.last_error, + sortTs: parseTs(event.received_at), + }); + } + + items.sort((a, b) => BUCKET_RANK[a.bucket] - BUCKET_RANK[b.bucket] || b.sortTs - a.sortTs); + return items; +} + +function orphanLiveItem(key: string, event: RunningEvent | null, inflightOnly: boolean): WorkItem { + return { + key, + ref: null, + deliveryId: event?.delivery_id ?? key, + issueState: null, + classification: null, + branch: null, + prNumber: null, + latestEvent: null, + live: event, + inflightOnly, + bucket: "running", + error: null, + sortTs: parseTs(event?.started_at ?? event?.received_at), + }; +} + +function latestEventFromRecent(event: RecentEvent): LatestEvent { + return { + delivery_id: event.delivery_id, + event_type: event.event_type, + state: event.state, + attempts: event.attempts, + received_at: event.received_at, + last_error: event.last_error, + }; +} + +function parseTs(value: string | null | undefined): number { + const time = Date.parse(value ?? ""); + return Number.isNaN(time) ? 0 : time; +} + +function splitRef(issueKey: string | null): { repo: string; number: number } | null { + if (!issueKey) return null; + const ref = splitIssueKey(issueKey); + const number = Number(ref.number); + return Number.isNaN(number) ? null : { repo: ref.repo, number }; +} diff --git a/python/robomp/web/test/fixtures/status-contract.json b/python/robomp/web/test/fixtures/status-contract.json new file mode 100644 index 000000000..d853abb99 --- /dev/null +++ b/python/robomp/web/test/fixtures/status-contract.json @@ -0,0 +1,216 @@ +{ + "runtime": { + "bot_login": "robomp-bot", + "repo_allowlist": [ + "octo/widget" + ], + "max_concurrency": 8, + "model": "anthropic/claude-sonnet-4-5", + "thinking_level": "high", + "uptime_seconds": 0.0 + }, + "inflight": [], + "event_counts": { + "queued": 1, + "running": 1, + "done": 2, + "failed": 3, + "skipped": 0 + }, + "issue_event_counts": { + "queued": 1, + "running": 1, + "done": 2, + "failed": 1, + "skipped": 0 + }, + "running_events": [ + { + "delivery_id": "run-x", + "event_type": "issue_comment", + "repo": "octo/widget", + "issue_key": "octo/widget#1", + "received_at": "2024-01-01T00:00:00+00:00", + "started_at": "2024-01-01T00:00:00+00:00", + "attempts": 1, + "model": "anthropic/claude-3-5-sonnet", + "last_tool": "edit", + "last_tool_ts": "2024-01-01T00:00:00+00:00" + } + ], + "issues": [ + { + "key": "octo/widget#6", + "repo": "octo/widget", + "number": 6, + "branch": null, + "pr_number": null, + "state": "new", + "classification": null, + "updated_at": "2024-01-01T00:00:00+00:00", + "latest_event": null + }, + { + "key": "octo/widget#5", + "repo": "octo/widget", + "number": 5, + "branch": null, + "pr_number": null, + "state": "new", + "classification": null, + "updated_at": "2024-01-01T00:00:00+00:00", + "latest_event": { + "delivery_id": "queued-x", + "event_type": "issues", + "state": "queued", + "attempts": 0, + "received_at": "2024-01-01T00:00:00+00:00", + "last_error": null + } + }, + { + "key": "octo/widget#4", + "repo": "octo/widget", + "number": 4, + "branch": null, + "pr_number": null, + "state": "merged", + "classification": null, + "updated_at": "2024-01-01T00:00:00+00:00", + "latest_event": { + "delivery_id": "terminal-done", + "event_type": "issues", + "state": "done", + "attempts": 0, + "received_at": "2024-01-01T00:00:00+00:00", + "last_error": null + } + }, + { + "key": "octo/widget#3", + "repo": "octo/widget", + "number": 3, + "branch": null, + "pr_number": null, + "state": "fixing", + "classification": null, + "updated_at": "2024-01-01T00:00:00+00:00", + "latest_event": { + "delivery_id": "new-done", + "event_type": "issues", + "state": "done", + "attempts": 0, + "received_at": "2024-01-01T00:00:00+00:00", + "last_error": null + } + }, + { + "key": "octo/widget#2", + "repo": "octo/widget", + "number": 2, + "branch": null, + "pr_number": null, + "state": "fixing", + "classification": null, + "updated_at": "2024-01-01T00:00:00+00:00", + "latest_event": { + "delivery_id": "failed-x", + "event_type": "issues", + "state": "failed", + "attempts": 0, + "received_at": "2024-01-01T00:00:00+00:00", + "last_error": "repro diverged" + } + }, + { + "key": "octo/widget#1", + "repo": "octo/widget", + "number": 1, + "branch": "farm/abc12345/fix", + "pr_number": 77, + "state": "reproducing", + "classification": null, + "updated_at": "2024-01-01T00:00:00+00:00", + "latest_event": { + "delivery_id": "run-x", + "event_type": "issue_comment", + "state": "running", + "attempts": 1, + "received_at": "2024-01-01T00:00:00+00:00", + "last_error": null + } + } + ], + "recent_events": [ + { + "delivery_id": "queued-x", + "event_type": "issues", + "repo": "octo/widget", + "issue_key": "octo/widget#5", + "state": "queued", + "attempts": 0, + "received_at": "2024-01-01T00:00:00+00:00", + "last_error": null + }, + { + "delivery_id": "terminal-done", + "event_type": "issues", + "repo": "octo/widget", + "issue_key": "octo/widget#4", + "state": "done", + "attempts": 0, + "received_at": "2024-01-01T00:00:00+00:00", + "last_error": null + }, + { + "delivery_id": "orphan-failed-x", + "event_type": "issues", + "repo": "octo/widget", + "issue_key": null, + "state": "failed", + "attempts": 0, + "received_at": "2024-01-01T00:00:00+00:00", + "last_error": "orphan failed error" + }, + { + "delivery_id": "new-done", + "event_type": "issues", + "repo": "octo/widget", + "issue_key": "octo/widget#3", + "state": "done", + "attempts": 0, + "received_at": "2024-01-01T00:00:00+00:00", + "last_error": null + }, + { + "delivery_id": "superseded-failed", + "event_type": "issues", + "repo": "octo/widget", + "issue_key": "octo/widget#3", + "state": "failed", + "attempts": 0, + "received_at": "2024-01-01T00:00:00+00:00", + "last_error": "old error" + }, + { + "delivery_id": "failed-x", + "event_type": "issues", + "repo": "octo/widget", + "issue_key": "octo/widget#2", + "state": "failed", + "attempts": 0, + "received_at": "2024-01-01T00:00:00+00:00", + "last_error": "repro diverged" + }, + { + "delivery_id": "run-x", + "event_type": "issue_comment", + "repo": "octo/widget", + "issue_key": "octo/widget#1", + "state": "running", + "attempts": 1, + "received_at": "2024-01-01T00:00:00+00:00", + "last_error": null + } + ] +} \ No newline at end of file