Merge pull request #2926 from lyc-aon/feat/robomp-lifecycle-cards
Replace Robomp status tables with lifecycle cards
This commit is contained in:
@@ -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
|
||||
],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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,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:"
|
||||
|
||||
Executable
+1194
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
});
|
||||
@@ -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 & fix · polling every 3s
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
return <Shell />;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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
@@ -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 };
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user