Merge pull request #2926 from lyc-aon/feat/robomp-lifecycle-cards

Replace Robomp status tables with lifecycle cards
This commit is contained in:
Can Bölük
2026-06-20 23:43:06 +02:00
committed by GitHub
35 changed files with 5161 additions and 621 deletions
+20
View File
@@ -655,6 +655,8 @@ def create_app(settings: Settings | None = None) -> FastAPI:
event = db.get_event(delivery_id)
if event is None:
raise HTTPException(404, f"unknown delivery {delivery_id}")
if event.state != "running":
raise HTTPException(409, f"delivery {delivery_id} is {event.state}; only running deliveries can be cancelled")
pool: WorkerPool = bag["pool"]
fired = await pool.cancel_event(delivery_id)
@@ -742,6 +744,23 @@ def create_app(settings: Settings | None = None) -> FastAPI:
}
events_rows = db.list_events(limit=25)
# Recent events can reference issues outside the capped 200-issue
# `issues` window above. Surface each event's current DB issue state
# so the dashboard can suppress failures for issues that have since
# gone terminal (merged/closed/abandoned) without a row to consult.
# Reuse the already-loaded issue states; fall back to a per-key
# lookup (bounded by the 25-event cap) for keys outside the window.
issue_state_by_key: dict[str, str | None] = {r.key: r.state for r in issues_rows}
def _recent_issue_state(issue_key: str | None) -> str | None:
if not issue_key:
return None
if issue_key not in issue_state_by_key:
row = db.get_issue(issue_key)
issue_state_by_key[issue_key] = row.state if row is not None else None
return issue_state_by_key[issue_key]
return {
"event_counts": db.event_state_counts(),
"issue_event_counts": db.latest_issue_event_state_counts(),
@@ -770,6 +789,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
"attempts": r.attempts,
"received_at": r.received_at,
"last_error": r.last_error,
"issue_state": _recent_issue_state(r.issue_key),
}
for r in events_rows
],
+33
View File
@@ -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",
+361
View File
@@ -0,0 +1,361 @@
"""Contract test for the robomp server API response vs status-contract.json.
Regenerate the committed fixture with:
ROBOMP_UPDATE_STATUS_CONTRACT=1 pytest tests/test_status_contract.py
"""
from __future__ import annotations
import json
import os
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 the live payload
# can be compared against (or regenerated into) a byte-stable committed fixture.
_VOLATILE_TS_KEYS = {"received_at", "started_at", "last_tool_ts", "updated_at"}
_FIXED_TS = "2024-01-01T00:00:00+00:00"
_UPDATE_ENV = "ROBOMP_UPDATE_STATUS_CONTRACT"
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"]
# Compare the normalized payload against the committed fixture. Normal
# pytest runs assert equality; regenerate only when ROBOMP_UPDATE_STATUS_CONTRACT=1.
fixture_path = Path(__file__).parent.parent / "web/test/fixtures/status-contract.json"
actual = _normalize_for_fixture(data)
if os.environ.get(_UPDATE_ENV) == "1":
fixture_path.parent.mkdir(parents=True, exist_ok=True)
fixture_path.write_text(json.dumps(actual, indent=2), encoding="utf-8")
else:
expected = json.loads(fixture_path.read_text(encoding="utf-8"))
assert actual == expected
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
# 409 queued/non-running delivery: do not poison WorkerPool._cancelled.
resp = client.post(
"/api/cancel",
json={"delivery_id": "run-cancel-2"},
headers={"X-Robomp-Replay-Token": token},
)
assert resp.status_code == 409
queued = db.get_event("run-cancel-2")
assert queued is not None
assert queued.state == "queued"
# 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"
+3 -2
View File
@@ -3,17 +3,18 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="dark" />
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" content="#07090c" />
<title>robomp</title>
<link
rel="icon"
href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><circle cx='32' cy='32' r='28' fill='%230a84ff'/><circle cx='32' cy='32' r='12' fill='%23ffffff'/></svg>"
href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'><circle cx='32' cy='32' r='28' fill='%23ed4abf'/><circle cx='32' cy='32' r='12' fill='%23ffffff'/></svg>"
/>
</head>
<body>
<div id="app"></div>
<script id="robomp-config" type="application/json">__ROBOMP_CONFIG__</script>
<script>(function(){try{var s=localStorage.getItem("omp-robomp-theme");var sys=matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light";var t=(s==="light"||s==="dark")?s:sys;document.documentElement.dataset.theme=t;document.documentElement.style.colorScheme=t;}catch(e){}})();</script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3 -2
View File
@@ -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:"
File diff suppressed because it is too large Load Diff
+178
View File
@@ -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<string> {
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<void> {
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);
});
+8 -40
View File
@@ -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 (
<div class="mx-auto w-full max-w-[1400px]">
<Header />
<main class="px-6 lg:px-10 pb-16 flex flex-col gap-5">
<div class="grid gap-5" style={{ "grid-template-columns": "minmax(0, 1fr)" }}>
<Trigger />
<Browse />
</div>
<Stats />
<div class="grid gap-5 grid-cols-1 xl:grid-cols-2">
<Working />
<Issues onRetry={handleRetry} />
</div>
<Events onRetry={handleRetry} />
<Logs />
<footer class="text-center text-[11px] text-ink-500 pt-2 pb-1">
robomp · self-hosted triage &amp; fix · polling every 3s
</footer>
</main>
</div>
);
return <Shell />;
}
+4 -13
View File
@@ -124,7 +124,7 @@ export function Browse(): JSX.Element {
</div>
<Show when={errorMessage()}>
<div class="px-5 py-2.5 text-[12px] text-err border-b border-stroke-soft bg-[rgba(255,69,58,0.05)]">
<div class="px-5 py-2.5 text-[12px] text-err border-b border-stroke-soft tint-err">
{errorMessage()}
</div>
</Show>
@@ -132,7 +132,7 @@ export function Browse(): JSX.Element {
<Show when={data().errors.length}>
<For each={data().errors}>
{(err) => (
<div class="px-5 py-2 text-[12px] text-err border-b border-stroke-soft bg-[rgba(255,69,58,0.04)]">
<div class="px-5 py-2 text-[12px] text-err border-b border-stroke-soft tint-err">
<code>{err.repo}</code> <span class="text-ink-300">{err.error}</span>
</div>
)}
@@ -155,7 +155,7 @@ export function Browse(): JSX.Element {
<For each={filtered()}>
{(issue) => (
<div
class="grid items-start gap-4 px-5 py-3.5 border-b border-stroke-soft hover:bg-white/[0.025] transition-colors"
class="grid items-start gap-4 px-5 py-3.5 border-b border-stroke-soft hover:bg-surface-2 transition-colors"
style={{
"grid-template-columns": "1fr auto",
opacity: issue.processed ? 0.55 : 1,
@@ -178,16 +178,7 @@ export function Browse(): JSX.Element {
<div class="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-ink-400">
<Pill state={issue.state}>{issue.state}</Pill>
<Show when={issue.processed}>
<span
class="pill"
style={{
color: "#9ec9ff",
"border-color": "rgba(100,175,255,0.32)",
"background-color": "rgba(10,132,255,0.10)",
}}
>
processed
</span>
<Pill state="processed">processed</Pill>
</Show>
<span>by {issue.author || "—"}</span>
<span>updated {fmtAge(issue.updated_at)}</span>
@@ -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 (
-106
View File
@@ -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 (
<header class="px-6 lg:px-10 pt-8 pb-6 flex flex-col gap-5">
<div class="flex flex-wrap items-end gap-x-6 gap-y-3 justify-between">
<div class="flex items-baseline gap-3">
<h1 class="text-[26px] font-semibold tracking-tight leading-none">
<span class="text-ink-50">robomp</span>
<span class="inline-block ml-2 size-[7px] rounded-full align-middle bg-accent shadow-[0_0_10px_rgba(10,132,255,0.7)]" />
</h1>
<span class="eyebrow">triage · fix · ship</span>
</div>
<div class="flex items-center gap-2 text-[11px] text-ink-300">
<Show
when={lastTickError()}
fallback={
<span class="flex items-center gap-2">
<span
class={`inline-block size-[7px] rounded-full ${
isFetching() ? "bg-warn" : "bg-ok"
}`}
style={{
"box-shadow": isFetching()
? "0 0 8px rgba(255,214,10,0.6)"
: "0 0 8px rgba(48,209,88,0.6)",
}}
/>
{isFetching() ? "syncing…" : `synced ${relativeAgo(lastTickAt())}`}
</span>
}
>
<span class="flex items-center gap-2 text-err">
<span class="inline-block size-[7px] rounded-full bg-err" />
{lastTickError()}
</span>
</Show>
</div>
</div>
<div class="flex flex-wrap items-center gap-x-7 gap-y-2 text-[12px] text-ink-300 tabular">
<Meta label="bot" value={runtime()?.bot_login} mono />
<Meta
label="model"
value={runtime()?.model}
mono
title={runtime()?.thinking_level ? `thinking ${runtime()?.thinking_level}` : undefined}
/>
<Meta
label="concurrency"
value={
runtime()?.max_concurrency != null ? String(runtime()?.max_concurrency) : undefined
}
/>
<Meta
label="uptime"
value={
runtime()?.uptime_seconds != null ? fmtDuration(runtime()?.uptime_seconds) : undefined
}
/>
<Meta
label="allowlist"
value={
runtime()?.repo_allowlist?.length ? runtime()!.repo_allowlist.join(", ") : "(none)"
}
mono
/>
<Show when={!CONFIG.replayEnabled}>
<span class="pill skipped">read-only · trigger disabled</span>
</Show>
</div>
</header>
);
}
interface MetaProps {
label: string;
value?: string;
mono?: boolean;
title?: string;
}
function Meta(props: MetaProps): JSX.Element {
return (
<span class="inline-flex items-baseline gap-1.5" title={props.title}>
<span class="text-ink-500 uppercase tracking-[0.14em] text-[10px]">{props.label}</span>
<span class={props.mono ? "font-mono text-[12px] text-ink-100" : "text-ink-100"}>
{props.value ?? "…"}
</span>
</span>
);
}
@@ -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 (
<article class="rmp-card" data-bucket={item().bucket}>
<Show when={full()} fallback={<CompactHead item={item()} />}>
<div class="rmp-card-head">
<div class="rmp-card-id">
<Pill>{item().classification ?? "issue"}</Pill>
<IssueLink repo={ref()!.repo} number={String(ref()!.number)} />
</div>
<ActivityPill item={item()} />
</div>
<LifecycleStepper
state={item().issueState}
classification={item().classification}
failed={item().bucket === "failed"}
live={item().live !== null || item().inflightOnly}
/>
</Show>
<MetaRow item={item()} />
<Show when={item().bucket === "failed" && item().error}>
<div class="rmp-card-error err-cell" title={item().error ?? ""}>
{shortText(item().error, 200)}
</div>
</Show>
<Show
when={
CONFIG.replayEnabled &&
(item().bucket === "failed" ||
(item().bucket === "running" && item().live !== null))
}
>
<div class="rmp-card-actions">
<Show when={item().bucket === "failed" && item().deliveryId}>
<button
class="tiny"
onClick={() => void runTrigger({ mode: "retry", delivery_id: item().deliveryId })}
>
retry
</button>
</Show>
<Show when={item().bucket === "running" && item().live !== null && item().deliveryId}>
<button class="tiny danger" onClick={() => void cancel(item().deliveryId)}>
cancel
</button>
</Show>
</div>
</Show>
</article>
);
}
function CompactHead(props: { item: WorkItem }): JSX.Element {
return (
<div class="rmp-card-head">
<div class="rmp-card-id">
<code>{shortDelivery(props.item.deliveryId)}</code>
</div>
<ActivityPill item={props.item} />
</div>
);
}
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 (
<Pill state={state()} dot={props.item.bucket === "running"}>
{label()}
</Pill>
);
}
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 (
<div class="rmp-card-meta">
<Show when={live()}>
<Show when={live()!.model}>
<code title={live()!.model ?? ""}>{live()!.model}</code>
</Show>
<Show when={live()!.last_tool}>
<span>
last action <code>{live()!.last_tool}</code>{" "}
<span class="rmp-card-meta-dim">{fmtAge(live()!.last_tool_ts)}</span>
</span>
</Show>
<span>
elapsed <span class="tabular">{liveElapsed(live()!.started_at)}</span>
</span>
<span>attempt #{live()!.attempts}</span>
</Show>
<Show when={!live() && item().inflightOnly}>
<span>held by pool</span>
</Show>
<Show when={item().branch}>
<code>{item().branch}</code>
</Show>
<Show when={item().ref && item().prNumber != null}>
<PrLink repo={item().ref!.repo} number={item().prNumber} />
</Show>
<Show
when={
item().bucket !== "running" &&
item().bucket !== "failed" &&
item().latestEvent
}
>
<span>
{item().latestEvent!.event_type} · attempt #{item().latestEvent!.attempts} ·{" "}
{fmtAge(item().latestEvent!.received_at)}
</span>
</Show>
</div>
);
}
async function cancel(deliveryId: string): Promise<void> {
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);
}
-117
View File
@@ -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 (
<GlassCard heading="active issues" accessory={<span class="tabular">{active().length}</span>}>
<Show when={active().length} fallback={<div class="empty">no active issues</div>}>
<div class="overflow-x-auto scrollable">
<table class="t">
<thead>
<tr>
<th>issue</th>
<th>state</th>
<th>last event</th>
<th>class</th>
<th>branch</th>
<th>pr</th>
<th>error</th>
<th />
</tr>
</thead>
<tbody>
<For each={active()}>
{(issue) => <IssueRowView issue={issue} onRetry={props.onRetry} />}
</For>
</tbody>
</table>
</div>
</Show>
</GlassCard>
);
}
interface RowProps {
issue: IssueRow;
onRetry: (deliveryId: string) => void;
}
function IssueRowView(props: RowProps): JSX.Element {
const ev = (): LatestEvent | null => props.issue.latest_event;
return (
<tr>
<td>
<IssueLink repo={props.issue.repo} number={props.issue.number} />
</td>
<td>
<Pill>{props.issue.state}</Pill>
</td>
<td>
<Show when={ev()} fallback={<span class="text-ink-400">—</span>}>
{(latest) => (
<>
<Pill state={latest().state}>{latest().state}</Pill>
<span class="meta-line">
{latest().event_type} · attempt #{latest().attempts} ·{" "}
{fmtAge(latest().received_at)}
</span>
</>
)}
</Show>
</td>
<td class="text-ink-300">{props.issue.classification ?? ""}</td>
<td>
{props.issue.branch ? (
<code>{props.issue.branch}</code>
) : (
<span class="text-ink-400">—</span>
)}
</td>
<td>
<PrLink repo={props.issue.repo} number={props.issue.pr_number} />
</td>
<td class="err-cell">
<Show
when={ev()?.state === "failed" && ev()?.last_error}
fallback={<span class="text-ink-400">—</span>}
>
<span title={ev()?.last_error ?? ""}>{shortText(ev()?.last_error)}</span>
</Show>
</td>
<td>
<Show
when={CONFIG.replayEnabled && ev()?.state === "failed"}
fallback={<span class="text-ink-400">—</span>}
>
<button
class="tiny"
onClick={() => {
const latest = ev();
if (latest) props.onRetry(latest.delivery_id);
}}
>
retry
</button>
</Show>
</td>
</tr>
);
}
@@ -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 (
<div class="rmp-step">
<For each={stages()}>
{(stage, i) => (
<div class="rmp-step-cell">
<Show when={i() < stages().length - 1}>
<div class="rmp-step-seg" data-state={i() < ord() ? "complete" : "pending"} />
</Show>
<div
class="rmp-step-node"
data-state={nodeState(i())}
data-live={nodeState(i()) === "current" && props.live}
/>
<span class="rmp-step-label" data-reached={i() <= ord()}>
{stage}
</span>
</div>
)}
</For>
</div>
);
}
@@ -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 (
<Show when={statusResource() !== undefined} fallback={<PipelineSkeleton />}>
<Show when={items().length > 0} fallback={<PipelineEmpty />}>
<div class="rmp-pipeline-head">
<span class="rmp-pipeline-head-label">pipeline</span>
<span class="rmp-pipeline-head-count tabular">{items().length}</span>
</div>
<div class="rmp-card-grid">
<For each={items()}>{(item) => <IssueCard item={item} />}</For>
</div>
</Show>
</Show>
);
}
// First-load placeholder — skeleton, not spinner. Two cards so the grid shape
// is legible before data arrives.
function PipelineSkeleton(): JSX.Element {
return (
<div class="rmp-card-grid">
<div class="rmp-card-skeleton" />
<div class="rmp-card-skeleton" />
</div>
);
}
// 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 (
<div class="rmp-pipeline-empty">
<div class="rmp-pipeline-empty-title">
No active work — robomp is idle, waiting for GitHub webhook events
</div>
<Show when={allowlist().length > 0}>
<div class="rmp-pipeline-empty-sub">watching {allowlist().join(", ")}</div>
</Show>
</div>
);
}
@@ -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<EventState, string> = {
queued: "text-[#9ec9ff]",
running: "text-[#ffe26b]",
done: "text-[#7fe5a3]",
failed: "text-[#ff8e85]",
skipped: "text-ink-300",
};
export function Stats(): JSX.Element {
const counts = (): Record<EventState, number> => {
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 (
<section
class="glass glass-rise rounded-[22px] grid gap-px overflow-hidden"
style={{
"grid-template-columns": "repeat(5, minmax(0, 1fr))",
"background-color": "rgba(255, 255, 255, 0.05)",
}}
title="newest non-skipped event per issue"
>
<For each={EVENT_STATE_ORDER}>
{(state) => (
<div
class="px-5 py-5 flex flex-col gap-1.5"
style={{ "background-color": "rgba(8, 11, 16, 0.55)" }}
>
<span class="eyebrow">{state}</span>
<span class={`text-[34px] leading-none font-semibold tabular ${ACCENT[state]}`}>
{counts()[state] ?? 0}
</span>
</div>
)}
</For>
</section>
);
}
@@ -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<ThemePreference, ThemePreference> = {
system: "light",
light: "dark",
dark: "system",
};
const PREFERENCE_LABEL: Record<ThemePreference, string> = {
system: "System theme",
light: "Light theme",
dark: "Dark theme",
};
function MonitorIcon(): JSX.Element {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<rect x="2" y="3" width="20" height="14" rx="2" />
<path d="M8 21h8" />
<path d="M12 17v4" />
</svg>
);
}
function SunIcon(): JSX.Element {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.93 4.93 1.41 1.41" />
<path d="m17.66 17.66 1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.34 17.66-1.41 1.41" />
<path d="m19.07 4.93-1.41 1.41" />
</svg>
);
}
function MoonIcon(): JSX.Element {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</svg>
);
}
export function ThemeToggle(): JSX.Element {
const icon = (): JSX.Element => {
const p = preference();
if (p === "light") return <SunIcon />;
if (p === "dark") return <MoonIcon />;
return <MonitorIcon />;
};
const label = (): string => `${PREFERENCE_LABEL[preference()]} — click to switch`;
return (
<button
type="button"
class="btn ghost tiny"
onClick={() => setPreference(NEXT_PREFERENCE[preference()])}
aria-label={label()}
title={label()}
>
{icon()}
</button>
);
}
+76 -10
View File
@@ -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<string>("");
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 (
<GlassCard heading="trigger" accessory={<span class="text-ink-400">owner/repo#NN or issue url</span>}>
<Show when={variant() === "bar"} fallback={<PanelBody issue={issue} setIssue={setIssue} handleTriage={handleTriage} handleRetry={handleRetry} validate={validate} />}>
<Show
when={CONFIG.replayEnabled}
fallback={
<div class="rmp-trigger-bar">
<span class="rmp-readonly-chip">trigger disabled · read-only</span>
<span class="rmp-trigger-bar-status">set ROBOMP_REPLAY_TOKEN to enable</span>
</div>
}
>
<div class="rmp-trigger-bar">
<input
type="text"
spellcheck={false}
placeholder="owner/repo#NN or issue url"
autocomplete="off"
value={issue()}
onInput={(ev) => setIssue(ev.currentTarget.value)}
onKeyDown={(ev) => {
if (ev.key === "Enter") handleTriage();
}}
class="flex-1 min-w-[200px] font-mono"
/>
<button class="primary tiny" onClick={handleTriage}>
triage
</button>
<button class="tiny" onClick={handleRetry}>
retry latest
</button>
<Show when={triggerStatus().text}>
<span class={`rmp-trigger-bar-status ${STATUS_TONE[triggerStatus().kind]}`}>
{triggerStatus().text}
</span>
</Show>
</div>
</Show>
</Show>
);
}
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 (
<GlassCard
heading="trigger"
>
<Show
when={CONFIG.replayEnabled}
fallback={
@@ -50,22 +116,22 @@ export function Trigger(): JSX.Element {
spellcheck={false}
placeholder="octo/widget#42 or https://github.com/owner/repo/issues/42"
autocomplete="off"
value={issue()}
onInput={(ev) => 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"
/>
<button class="primary" onClick={handleTriage}>
<button class="primary" onClick={p.handleTriage}>
fetch &amp; triage
</button>
<button onClick={handleRetry}>retry latest run</button>
<button onClick={p.handleRetry}>retry latest run</button>
</div>
<Show
when={triggerStatus().text}
fallback={
<span class={`text-[12px] ${STATUS_TONE.idle}`}>{validate() ?? "ready"}</span>
<span class={`text-[12px] ${STATUS_TONE.idle}`}>{p.validate() ?? "ready"}</span>
}
>
<span class={`text-[12px] ${STATUS_TONE[triggerStatus().kind]}`}>
@@ -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<string>();
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<void> {
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 (
<GlassCard heading="currently working" accessory={<span class="tabular">{rows().length}</span>}>
<Show when={rows().length} fallback={<div class="empty">idle — waiting for events</div>}>
<div class="overflow-x-auto scrollable">
<table class="t">
<thead>
<tr>
<th>issue</th>
<th>event</th>
<th>state</th>
<th>elapsed</th>
<th>model</th>
<th>last action</th>
<th>attempt</th>
<th />
</tr>
</thead>
<tbody>
<For each={rows()}>{(r) => <WorkingRow row={r} />}</For>
</tbody>
</table>
</div>
</Show>
</GlassCard>
);
}
function WorkingRow(props: { row: Row }): JSX.Element {
const ref = (): { repo: string; number: string } => splitIssueKey(props.row.issue_key);
return (
<tr>
<td>
<Show when={ref().number} fallback={<code>{shortDelivery(props.row.delivery_id)}</code>}>
<IssueLink repo={ref().repo} number={ref().number} />
</Show>
</td>
<td class="text-ink-300">{props.row.event_type || "—"}</td>
<td>
<Pill state="running" dot>
{props.row.inflight_only ? "inflight" : "running"}
</Pill>
</td>
<td class="tabular">{elapsed(props.row.started_at)}</td>
<td>
{props.row.model ? (
<code title={props.row.model}>{props.row.model}</code>
) : (
<span class="text-ink-400">—</span>
)}
</td>
<td>
{props.row.last_tool ? (
<span>
<code>{props.row.last_tool}</code>
<span class="text-ink-400 ml-2">{fmtAge(props.row.last_tool_ts)}</span>
</span>
) : (
<span class="text-ink-400">{props.row.inflight_only ? "held by pool" : "—"}</span>
)}
</td>
<td class="text-ink-300 tabular">
{props.row.inflight_only ? "—" : `#${props.row.attempts}`}
</td>
<td>
<Show
when={CONFIG.replayEnabled && !props.row.inflight_only}
fallback={<span class="text-ink-400">—</span>}
>
<button class="tiny danger" onClick={() => cancelDelivery(props.row.delivery_id)}>
cancel
</button>
</Show>
</td>
</tr>
);
}
@@ -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 (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
);
}
function OpsIcon(): JSX.Element {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="2" y="3" width="20" height="6" rx="1.5" />
<rect x="2" y="13" width="14" height="8" rx="1.5" />
<path d="M20 17h2" />
</svg>
);
}
function TriageIcon(): JSX.Element {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m14 4 6 6-10 10H4v-6L14 4Z" />
<path d="m13 5 6 6" />
</svg>
);
}
function LockIcon(): JSX.Element {
return (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="4" y="11" width="16" height="10" rx="2" />
<path d="M8 11V7a4 4 0 1 1 8 0v4" />
</svg>
);
}
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 (
<aside class="rmp-rail">
{/* Brand lockup: gradient mark + wordmark + tag */}
<div class="rmp-rail-brand">
<span class="rmp-rail-mark" aria-hidden="true" />
<span class="rmp-rail-wordmark">
<span class="rmp-rail-name">robomp</span>
<span class="rmp-rail-tag">triage · fix · ship</span>
</span>
</div>
{/* Always-on vitals: health/sync + running# + failed# + 5 counts + runtime meta */}
<Vitals />
{/* Nav: Operations / Activity / Triage */}
<nav class="rmp-nav">
{NAV.map((item) => (
<button
type="button"
class="rmp-nav-item"
data-active={activeView() === item.id ? "true" : "false"}
data-locked={item.locked ? "true" : "false"}
aria-current={activeView() === item.id ? "page" : undefined}
title={item.locked ? "Triage — locked (set ROBOMP_REPLAY_TOKEN to enable)" : item.label}
onClick={() => setActiveView(item.id)}
>
<span class="rmp-nav-item-icon">{item.icon()}</span>
<span class="rmp-nav-item-label">{item.label}</span>
<Show when={item.locked}>
<span class="rmp-nav-item-lock" title="read-only">
<LockIcon />
</span>
</Show>
</button>
))}
</nav>
{/* Footer: theme toggle + polling tag */}
<div class="rmp-rail-footer">
<ThemeToggle />
<span class="rmp-rail-footer-tag">polling 3s</span>
</div>
</aside>
);
}
@@ -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 (
<div class="rmp-app">
{/* Desktop rail (hidden <1024px) */}
<div class="rmp-desktop-nav">
<Rail />
</div>
{/* Mobile drawer */}
<Show when={drawerOpen()}>
<div
class="rmp-mobile-drawer-overlay"
onClick={() => setDrawerOpen(false)}
role="presentation"
>
<div
class="rmp-mobile-drawer"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label="Navigation menu"
>
<Rail />
</div>
</div>
</Show>
{/* Main pane */}
<div class="rmp-main">
<TopBar onMenuToggle={() => setDrawerOpen(true)} />
<main class="rmp-content scrollable">
<div class="rmp-content-inner">
{/* 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). */}
<div class="rmp-view rmp-view-enter" style={{ display: viewDisplay("operations") }}>
<Operations />
</div>
<div class="rmp-view rmp-view-enter" style={{ display: viewDisplay("activity") }}>
<Activity />
</div>
<div class="rmp-view rmp-view-enter" style={{ display: viewDisplay("triage") }}>
<Triage />
</div>
</div>
</main>
</div>
</div>
);
}
@@ -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<View, string> = {
operations: "Operations",
activity: "Activity",
triage: "Triage",
};
function MenuIcon(): JSX.Element {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 6h18M3 12h18M3 18h18" />
</svg>
);
}
export function TopBar(props: TopBarProps): JSX.Element {
return (
<header class="rmp-topbar">
<div class="rmp-topbar-left">
<button
type="button"
class="rmp-mobile-menu-btn"
aria-label="Open navigation menu"
onClick={() => props.onMenuToggle()}
>
<MenuIcon />
</button>
<h1 class="rmp-page-title">{TITLES[activeView()]}</h1>
</div>
<div class="rmp-topbar-right">
{/* View-contextual slot. Operations → read-only/replay chip. Sync + vitals live in the rail. */}
<Show when={activeView() === "operations"}>
<Show
when={CONFIG.replayEnabled}
fallback={<span class="rmp-readonly-chip">read-only · replay off</span>}
>
<span class="rmp-readonly-chip">replay on</span>
</Show>
</Show>
</div>
</header>
);
}
@@ -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<string, string> = {
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<string, number> => {
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 (
<div class="rmp-vitals">
{/* health / sync */}
<div class="rmp-vital-sync" title={lastTickError() ?? undefined}>
<span
class={`rmp-vital-dot ${syncTone()}${isFetching() ? " pulse" : ""}`}
/>
<Show
when={lastTickError()}
fallback={<span>{syncText()}</span>}
>
<span class="text-err truncate">{syncText()}</span>
</Show>
</div>
{/* big running / failed */}
<div class="rmp-vital-big">
<div class="rmp-vital-big-cell">
<span class="rmp-vital-big-label">running</span>
<span
class={`rmp-vital-big-num${
runningTotal() > 0 ? " running-nonzero" : ""
}`}
>
{runningTotal()}
</span>
</div>
<div class="rmp-vital-big-cell">
<span class="rmp-vital-big-label">failed</span>
<span
class={`rmp-vital-big-num${
failedCount() > 0 ? " failed-nonzero" : ""
}`}
>
{failedCount()}
</span>
</div>
</div>
{/* 5 state counts (compact 2-col) */}
<div class="rmp-vital-counts" title="newest non-skipped event per issue">
{EVENT_STATE_ORDER.map((state) => (
<div class="rmp-vital-count">
<span class="rmp-vital-count-label">{state}</span>
<span
class="rmp-vital-count-num"
style={{ color: STATE_TONE[state] ?? "var(--color-ink-200)" }}
>
{counts()[state] ?? 0}
</span>
</div>
))}
</div>
{/* runtime meta */}
<div class="rmp-vital-runtime">
<RuntimeRow label="bot" value={runtime()?.bot_login} mono />
<RuntimeRow
label="model"
value={runtime()?.model}
mono
title={
runtime()?.thinking_level ? `thinking ${runtime()?.thinking_level}` : undefined
}
/>
<RuntimeRow
label="conc"
value={
runtime()?.max_concurrency != null
? String(runtime()?.max_concurrency)
: undefined
}
/>
<RuntimeRow
label="up"
value={
runtime()?.uptime_seconds != null
? fmtDuration(runtime()?.uptime_seconds)
: undefined
}
/>
<RuntimeRow label="repos" value={allowlist()} mono title={allowlist()} />
</div>
</div>
);
}
interface RuntimeRowProps {
label: string;
value?: string;
mono?: boolean;
title?: string;
}
function RuntimeRow(props: RuntimeRowProps): JSX.Element {
return (
<div class="rmp-vital-runtime-row" title={props.title}>
<span class="rmp-vital-runtime-label">{props.label}</span>
<span
class={`rmp-vital-runtime-value${props.mono ? " mono" : ""}`}
title={props.value}
>
{props.value ?? "—"}
</span>
</div>
);
}
@@ -0,0 +1,38 @@
import { type JSX, Show } from "solid-js";
import { runTrigger, triggerStatus } from "../../state";
import { Events } from "../Events";
import { Logs } from "../Logs";
const STATUS_TONE = {
idle: "text-ink-400",
pending: "text-ink-200",
ok: "text-ok",
err: "text-err",
} as const;
// Activity (investigate mode). Full history table + full-height log stream.
// Logs finally get real vertical room. The shared trigger status renders right
// under the events table so a retry fired from this view surfaces its
// success/error where the button was clicked — the Trigger bar lives only on
// Operations/Triage, so without this the Activity retry feedback is invisible.
export function Activity(): JSX.Element {
const handleRetry = (deliveryId: string): void => {
void runTrigger({ mode: "retry", delivery_id: deliveryId });
};
return (
<>
<Events onRetry={handleRetry} />
<Show when={triggerStatus().text}>
<span
class={`rmp-activity-status ${STATUS_TONE[triggerStatus().kind]}`}
role={triggerStatus().kind === "err" ? "alert" : "status"}
>
{triggerStatus().text}
</span>
</Show>
<Logs />
</>
);
}
@@ -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 (
<>
<Trigger variant="bar" />
<Pipeline />
</>
);
}
@@ -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 (
<Show
when={CONFIG.replayEnabled}
fallback={<FirstRunPanel />}
>
<Trigger variant="panel" />
<Browse />
</Show>
);
}
function FirstRunPanel(): JSX.Element {
return (
<GlassCard heading="triage" accessory={<span class="rmp-readonly-chip">locked</span>}>
<div class="rmp-firstrun">
<div class="rmp-firstrun-head">
<span class="rmp-readonly-chip">read-only</span>
<h3 class="rmp-firstrun-title">manual actions are disabled</h3>
</div>
<p class="rmp-firstrun-body">
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.
</p>
<ul class="rmp-firstrun-steps">
<li>
set <code>ROBOMP_REPLAY_TOKEN</code> in the robomp server environment
</li>
<li>restart the server so the token is loaded</li>
<li>reload — the Operations trigger bar and this Triage view unlock</li>
</ul>
<p class="rmp-firstrun-note">
Monitoring stays fully live: Operations and Activity read the same 3s poll regardless of
this setting.
</p>
</div>
</GlassCard>
);
}
File diff suppressed because it is too large Load Diff
+60
View File
@@ -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<ThemePreference>(initialPreference);
const [resolved, setResolvedSignal] = createSignal<SystemTheme>(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 };
+4 -1
View File
@@ -8,9 +8,11 @@ export type IssueState =
| "new"
| "reproducing"
| "fixing"
| "reviewing"
| "opened"
| "merged"
| "closed"
| "needs_info"
| "abandoned";
export interface RuntimeInfo {
@@ -37,7 +39,7 @@ export interface IssueRow {
number: number;
branch: string | null;
pr_number: number | null;
state: IssueState | string;
state: IssueState;
classification: string | null;
updated_at: string;
latest_event: LatestEvent | null;
@@ -65,6 +67,7 @@ export interface RecentEvent {
attempts: number;
received_at: string;
last_error: string | null;
issue_state: IssueState | null;
}
export interface StatusResponse {
+12
View File
@@ -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<View>("operations");
export { activeView, setActiveView };
@@ -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);
}
});
});
+870
View File
@@ -0,0 +1,870 @@
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<EventState, number> {
return {
queued: 0,
running: 0,
done: 0,
failed: 0,
skipped: 0,
};
}
function status(overrides: Partial<StatusResponse> = {}): StatusResponse {
return {
runtime: BASE_RUNTIME,
event_counts: eventCounts(),
issue_event_counts: eventCounts(),
running_events: [],
inflight: [],
issues: [],
recent_events: [],
...overrides,
};
}
function issue(overrides: Partial<IssueRow> = {}): 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> = {}): 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> = {}): 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> = {}): 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",
issue_state: null,
...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("keeps live terminal issues visible until the running delivery disappears", () => {
const items = buildWorkItems(
status({
issues: [
issue({
key: "owner/repo#223",
number: 223,
state: "merged",
latest_event: latestEvent({ delivery_id: "done-223", state: "done" }),
}),
],
running_events: [
runningEvent({
delivery_id: "live-terminal-223",
issue_key: "owner/repo#223",
}),
],
}),
);
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({
key: "owner/repo#223",
deliveryId: "live-terminal-223",
issueState: "merged",
bucket: "running",
inflightOnly: false,
});
expect(items[0].live?.delivery_id).toBe("live-terminal-223");
});
test("terminal issues stay excluded from orphan recent-event fallback", () => {
const items = buildWorkItems(
status({
issues: [
issue({
key: "owner/repo#222",
number: 222,
state: "abandoned",
latest_event: latestEvent({
delivery_id: "failed-terminal",
state: "failed",
last_error: "terminal failure",
}),
}),
],
recent_events: [
recentEvent({
delivery_id: "failed-terminal",
issue_key: "owner/repo#222",
received_at: "2026-06-17T00:07:00Z",
last_error: "terminal failure",
}),
],
}),
);
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 issue key absent from issues yields ref and keeps delivery/action data", () => {
const items = buildWorkItems(
status({
running_events: [
runningEvent({
issue_key: "octo/widget#999",
delivery_id: "run-x",
last_tool: "edit",
last_tool_ts: "2026-06-17T00:02:00Z",
}),
],
}),
);
expect(items).toHaveLength(1);
// The issue row is absent, but the issue-shaped key recovers the ref so the
// orphan card can still link to octo/widget#999.
expect(items[0]).toMatchObject({
ref: { repo: "octo/widget", number: 999 },
bucket: "running",
deliveryId: "run-x",
inflightOnly: false,
});
// Delivery id and live action data survive the orphan path.
expect(items[0].live?.delivery_id).toBe("run-x");
expect(items[0].live?.last_tool).toBe("edit");
expect(items[0].live?.model).toBe("test-model");
});
test("3. orphan inflight-only issue key absent from issues yields ref with no real delivery", () => {
const items = buildWorkItems(
status({
inflight: ["octo/widget#888"],
}),
);
expect(items).toHaveLength(1);
// Issue-shaped inflight key recovers the ref even with no issue row.
expect(items[0]).toMatchObject({
ref: { repo: "octo/widget", number: 888 },
bucket: "running",
inflightOnly: true,
live: null,
});
// No running event, so there is no real delivery id; it falls back to the key.
expect(items[0].deliveryId).toBe("octo/widget#888");
});
test("3b. non-issue orphan delivery/key keeps ref null", () => {
const items = buildWorkItems(
status({
running_events: [
runningEvent({
issue_key: null,
delivery_id: "run-bare-uuid",
}),
],
inflight: ["inflight-bare-uuid"],
}),
);
expect(items).toHaveLength(2);
// Neither key is issue-shaped (no `#`), so both stay ref: null.
const running = items.find((i) => i.deliveryId === "run-bare-uuid");
const inflight = items.find((i) => i.key === "inflight-bare-uuid");
expect(running?.ref).toBeNull();
expect(inflight?.ref).toBeNull();
expect(inflight?.inflightOnly).toBe(true);
});
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);
// needs_info is set by mark_unable_to_reproduce during reproduction and
// cleared back to reproducing by repro_record in host_tools; it stays in
// the reproduction phase (ordinal 1), not advanced past fixing.
expect(stageOrdinal("needs_info")).toBe(1);
expect(stageOrdinal("fixing")).toBe(2);
// reviewing is PR review work (tasks.py sets it on PR-opened issues); it
// lives on the PR step, same stage as opened (ordinal 3).
expect(stageOrdinal("opened")).toBe(3);
expect(stageOrdinal("reviewing")).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"]);
});
test("live running event outranks a newer failed latest_event for the same issue", () => {
const items = buildWorkItems(
status({
issues: [
issue({
key: "owner/repo#20",
number: 20,
latest_event: latestEvent({
delivery_id: "failed-newer",
state: "failed",
received_at: "2026-06-17T00:09:00Z",
last_error: "stale failure",
}),
}),
],
running_events: [
runningEvent({
delivery_id: "live-older",
issue_key: "owner/repo#20",
received_at: "2026-06-17T00:01:00Z",
started_at: "2026-06-17T00:02:00Z",
}),
],
}),
);
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({
key: "owner/repo#20",
bucket: "running",
deliveryId: "live-older",
inflightOnly: false,
error: null,
});
// Cancel button targets the live delivery, not the stale failed row.
expect(items[0].live?.delivery_id).toBe("live-older");
// ActivityPill renders running, never the superseded failed state.
expect(items[0].latestEvent?.state).toBe("running");
});
test("live running event outranks a newer done latest_event for the same issue", () => {
const items = buildWorkItems(
status({
issues: [
issue({
key: "owner/repo#21",
number: 21,
latest_event: latestEvent({
delivery_id: "done-newer",
state: "done",
received_at: "2026-06-17T00:09:00Z",
}),
}),
],
running_events: [
runningEvent({
delivery_id: "live-older-2",
issue_key: "owner/repo#21",
received_at: "2026-06-17T00:01:00Z",
started_at: "2026-06-17T00:02:00Z",
}),
],
}),
);
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({
key: "owner/repo#21",
bucket: "running",
deliveryId: "live-older-2",
});
expect(items[0].live?.delivery_id).toBe("live-older-2");
expect(items[0].latestEvent?.state).toBe("running");
});
test("live running event outranks a newer queued latest_event for the same issue", () => {
const items = buildWorkItems(
status({
issues: [
issue({
key: "owner/repo#22",
number: 22,
latest_event: latestEvent({
delivery_id: "queued-newer",
state: "queued",
received_at: "2026-06-17T00:09:00Z",
}),
}),
],
running_events: [
runningEvent({
delivery_id: "live-older-3",
issue_key: "owner/repo#22",
received_at: "2026-06-17T00:01:00Z",
started_at: "2026-06-17T00:02:00Z",
}),
],
}),
);
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({
key: "owner/repo#22",
bucket: "running",
deliveryId: "live-older-3",
inflightOnly: false,
error: null,
});
expect(items[0].live?.delivery_id).toBe("live-older-3");
expect(items[0].latestEvent?.state).toBe("running");
expect(items[0].latestEvent?.delivery_id).toBe("live-older-3");
});
test("suppresses an orphan failed recent event superseded by a newer done recent event for the same absent issue", () => {
const items = buildWorkItems(
status({
recent_events: [
recentEvent({
delivery_id: "done-newer-orphan",
issue_key: "owner/repo#30",
state: "done",
received_at: "2026-06-17T00:06:00Z",
last_error: null,
}),
recentEvent({
delivery_id: "failed-older-orphan",
issue_key: "owner/repo#30",
received_at: "2026-06-17T00:04:00Z",
last_error: "superseded orphan failure",
}),
],
}),
);
expect(items.some((item) => item.deliveryId === "failed-older-orphan")).toBe(false);
expect(items).toEqual([]);
});
test("suppresses an orphan failed recent event when a newer queued event appears later in the list", () => {
const items = buildWorkItems(
status({
recent_events: [
recentEvent({
delivery_id: "failed-older-out-of-order",
issue_key: "owner/repo#32",
received_at: "2026-06-17T00:04:00Z",
last_error: "older failure",
}),
recentEvent({
delivery_id: "queued-newer-out-of-order",
issue_key: "owner/repo#32",
state: "queued",
received_at: "2026-06-17T00:06:00Z",
last_error: null,
}),
],
}),
);
expect(items).toEqual([]);
});
test("suppresses a same-timestamp orphan failure when a non-failed recent event exists", () => {
const items = buildWorkItems(
status({
recent_events: [
recentEvent({
delivery_id: "failed-same-time",
issue_key: "owner/repo#33",
received_at: "2026-06-17T00:06:00Z",
last_error: "ambiguous failure",
}),
recentEvent({
delivery_id: "done-same-time",
issue_key: "owner/repo#33",
state: "done",
received_at: "2026-06-17T00:06:00Z",
last_error: null,
}),
],
}),
);
expect(items).toEqual([]);
});
test("does not let a newer skipped event suppress a retryable orphan failure", () => {
const items = buildWorkItems(
status({
recent_events: [
recentEvent({
delivery_id: "failed-before-skipped",
issue_key: "owner/repo#34",
received_at: "2026-06-17T00:04:00Z",
last_error: "real failure",
}),
recentEvent({
delivery_id: "skipped-newer-noise",
issue_key: "owner/repo#34",
state: "skipped",
received_at: "2026-06-17T00:06:00Z",
last_error: null,
}),
],
}),
);
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({
deliveryId: "failed-before-skipped",
bucket: "failed",
error: "real failure",
});
});
test("renders an orphan failed recent event that is the newest for an absent issue", () => {
const items = buildWorkItems(
status({
recent_events: [
recentEvent({
delivery_id: "failed-newest-orphan",
issue_key: "owner/repo#31",
received_at: "2026-06-17T00:06:00Z",
last_error: "current orphan failure",
}),
recentEvent({
delivery_id: "done-older-orphan",
issue_key: "owner/repo#31",
state: "done",
received_at: "2026-06-17T00:04:00Z",
last_error: null,
}),
],
}),
);
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({
key: "owner/repo#31",
deliveryId: "failed-newest-orphan",
bucket: "failed",
error: "current orphan failure",
});
});
test("suppresses a failed recent event for an absent issue whose issue_state is terminal", () => {
const items = buildWorkItems(
status({
recent_events: [
// octo/widget#900 is outside the capped status.issues window, so the
// only authority for its lifecycle is the issue_state /api/status
// attached. It is "merged", so this stale failure must not surface.
recentEvent({
delivery_id: "failed-terminal-orphan",
issue_key: "octo/widget#900",
issue_state: "merged",
received_at: "2026-06-17T00:06:00Z",
last_error: "stale terminal failure",
}),
// A retryable failure for a different, non-terminal absent issue must
// still render: the terminal skip only drops its own event.
recentEvent({
delivery_id: "failed-retryable-orphan",
issue_key: "octo/widget#901",
issue_state: "fixing",
received_at: "2026-06-17T00:05:00Z",
last_error: "live failure",
}),
],
}),
);
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({
key: "octo/widget#901",
deliveryId: "failed-retryable-orphan",
bucket: "failed",
issueState: "fixing",
error: "live failure",
});
});
});
+263
View File
@@ -0,0 +1,263 @@
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<string> = new Set([
"question",
"enhancement",
"proposal",
"invalid",
"duplicate",
]);
const STATE_ORDINAL: Record<string, number> = {
new: 0,
reproducing: 1,
needs_info: 1,
fixing: 2,
opened: 3,
reviewing: 3,
merged: 4,
closed: 4,
abandoned: 4,
};
const BUCKET_RANK: Record<WorkBucket, number> = {
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<string, RunningEvent>();
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<string>();
const items: WorkItem[] = [];
for (const issue of status.issues) {
const key = issue.key;
const live = runningByKey.get(key) ?? null;
const inflightOnly = !live && inflightSet.has(key);
if (TERMINAL_ISSUE_STATES.has(issue.state) && !live && !inflightOnly) {
seen.add(key);
continue;
}
seen.add(key);
const latest = issue.latest_event;
// A matching live running_events entry is authoritative over the issue's
// own latest_event. That summary row can be a newer queued retry/comment,
// or a failed/done row the live run has not superseded yet. Render the live
// run so the card stays running, cancel-capable (deliveryId from the live
// delivery), and free of stale latest-event state.
if (live) {
items.push({
key,
ref: { repo: issue.repo, number: issue.number },
deliveryId: live.delivery_id,
issueState: issue.state,
classification: issue.classification,
branch: issue.branch,
prNumber: issue.pr_number,
latestEvent: latestEventFromRunning(live),
live,
inflightOnly: false,
bucket: "running",
error: null,
sortTs: parseTs(live.started_at ?? live.received_at ?? issue.updated_at),
});
continue;
}
const latestState = latest?.state;
const bucket: WorkBucket =
latestState === "failed"
? "failed"
: 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: null,
inflightOnly,
bucket,
error: latestState === "failed" ? (latest?.last_error ?? null) : null,
sortTs: parseTs(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));
}
const newestRecentByKey = new Map<string, RecentEvent>();
for (const event of status.recent_events) {
if (!event.issue_key || event.state === "skipped") continue;
const current = newestRecentByKey.get(event.issue_key);
const eventTs = parseTs(event.received_at);
const currentTs = current ? parseTs(current.received_at) : 0;
const nonFailedBreaksTie =
current != null && eventTs === currentTs && current.state === "failed" && event.state !== "failed";
if (!current || eventTs > currentTs || nonFailedBreaksTie) {
newestRecentByKey.set(event.issue_key, event);
}
}
for (const event of status.recent_events) {
if (event.state !== "failed" || !event.delivery_id || seen.has(event.delivery_id)) continue;
// Suppress failures for issues that have since gone terminal
// (merged/closed/abandoned). For issues outside the capped `status.issues`
// window there is no row to consult, so the event's own issue_state — the
// current DB state attached by /api/status — is the only authority. This
// skip only drops the terminal event itself; it never marks the issue_key
// as seen, so a retryable failure for any other issue is untouched.
if (event.issue_state && TERMINAL_ISSUE_STATES.has(event.issue_state)) 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;
}
// For issues outside the capped `status.issues` window, fall back to the
// newest recent event for this issue_key. If a newer (or non-failed)
// delivery exists for the same issue, this older failed orphan is stale.
const newestRecent = newestRecentByKey.get(event.issue_key);
if (newestRecent && (newestRecent.delivery_id !== event.delivery_id || newestRecent.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: event.issue_state,
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;
}
// Orphan live/inflight rows have no matching issue row, but their key is still
// the canonical issue key when the row came from an `issue_key` (running event)
// or an issue-shaped inflight entry. Recover the ref via splitRef so the card
// can still link to the issue; splitRef returns null when the `#N` suffix is
// missing or non-numeric (e.g. a bare delivery-id key), keeping ref: null.
function orphanLiveItem(key: string, event: RunningEvent | null, inflightOnly: boolean): WorkItem {
return {
key,
ref: key.includes("#") ? splitRef(key) : 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,
};
}
// Synthesizes an ActivityPill-compatible latest event from a live running_events
// entry. State is pinned to "running" so a stale failed/done issue.latest_event
// cannot leak a terminal pill onto a card the live run still owns.
function latestEventFromRunning(event: RunningEvent): LatestEvent {
return {
delivery_id: event.delivery_id,
event_type: event.event_type,
state: "running",
attempts: event.attempts,
received_at: event.received_at,
last_error: null,
};
}
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 };
}
+223
View File
@@ -0,0 +1,223 @@
{
"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,
"issue_state": "new"
},
{
"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,
"issue_state": "merged"
},
{
"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",
"issue_state": null
},
{
"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,
"issue_state": "fixing"
},
{
"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",
"issue_state": "fixing"
},
{
"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",
"issue_state": "fixing"
},
{
"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,
"issue_state": "reproducing"
}
]
}