Add 'python/robomp/' from commit '553fd1cfcf59e4c501c54fc81bc083ffd2ca007b'

git-subtree-dir: python/robomp
git-subtree-mainline: 4f6e70f779
git-subtree-split: 553fd1cfcf
This commit is contained in:
can1357
2026-05-16 21:00:42 +02:00
108 changed files with 28172 additions and 0 deletions
+220
View File
@@ -0,0 +1,220 @@
import {
createMemo,
createResource,
createSignal,
For,
type JSX,
type ResourceReturn,
Show,
} from "solid-js";
import { ApiError, api } from "../api";
import { CONFIG } from "../config";
import { fmtAge } from "../format";
import { runTrigger } from "../state";
import type { BrowseResponse } from "../types";
import { GlassCard } from "./GlassCard";
import { Pill } from "./Pill";
interface BrowseQuery {
state: string;
refreshCount: number;
}
const EMPTY_RESPONSE: BrowseResponse = {
issues: [],
errors: [],
repos: [],
cache: { hit: false, fetched_at: 0 },
};
export function Browse(): JSX.Element {
const [state, setState] = createSignal<string>("open");
const [refreshCount, setRefreshCount] = createSignal<number>(0);
const [filter, setFilter] = createSignal<string>("");
const [hideProcessed, setHideProcessed] = createSignal<boolean>(true);
const fetchBrowse = async (query: BrowseQuery): Promise<BrowseResponse> => {
return api.browse(query.state, query.refreshCount > 0);
};
const tuple: ResourceReturn<BrowseResponse> = createResource<BrowseResponse, BrowseQuery>(
() => ({ state: state(), refreshCount: refreshCount() }),
fetchBrowse,
);
const [browseResource] = tuple;
const data = createMemo<BrowseResponse>(() => browseResource.latest ?? EMPTY_RESPONSE);
const filtered = createMemo(() => {
const all = data().issues;
const needle = filter().trim().toLowerCase();
const hidden = hideProcessed();
const list = hidden ? all.filter((i) => !i.processed) : all;
if (!needle) return list;
return list.filter((i) => `${i.repo} ${i.title} #${i.number}`.toLowerCase().includes(needle));
});
const processedCount = createMemo(() => data().issues.filter((i) => i.processed).length);
const errorMessage = (): string | null => {
const err = browseResource.error;
if (!err) return null;
if (err instanceof ApiError) return `error ${err.status}: ${err.message}`;
if (err instanceof Error) return err.message;
return String(err);
};
const meta = (): string => {
const d = data();
const totalRepos = d.repos.length ? d.repos.join(", ") : "(allowlist empty)";
const ageSeconds =
d.cache.fetched_at > 0 ? Math.max(0, (Date.now() - d.cache.fetched_at * 1000) / 1000) : 0;
const cacheInfo =
d.cache.fetched_at > 0
? ` · ${d.cache.hit ? "cached" : "loaded"} ${ageSeconds.toFixed(0)}s ago`
: "";
const hidden =
hideProcessed() && processedCount() > 0 ? ` · ${processedCount()} processed hidden` : "";
return `${filtered().length}/${d.issues.length} from ${totalRepos}${cacheInfo}${hidden}`;
};
const triggerFor = (mode: "triage" | "retry", repo: string, number: number): void => {
void runTrigger({ mode, issue: `${repo}#${number}` });
};
return (
<GlassCard heading="browse" accessory={<span class="text-[11px] text-ink-400">{meta()}</span>}>
<Show
when={CONFIG.replayEnabled}
fallback={
<div class="px-5 py-7 text-ink-300 text-[13px] leading-relaxed">
issue browser disabled — same gate as the trigger surface.
</div>
}
>
<div class="px-4 pb-3 pt-1 flex flex-wrap items-center gap-3 border-b border-stroke-soft">
<select value={state()} onChange={(ev) => setState(ev.currentTarget.value)}>
<option value="open">open</option>
<option value="closed">closed</option>
<option value="all">all</option>
</select>
<input
type="search"
class="flex-1 min-w-[180px]"
placeholder="filter title or repo"
value={filter()}
onInput={(ev) => setFilter(ev.currentTarget.value)}
/>
<label class="flex items-center gap-2 text-[12px] text-ink-300 select-none cursor-pointer">
<input
type="checkbox"
checked={hideProcessed()}
onChange={(ev) => setHideProcessed(ev.currentTarget.checked)}
/>
hide processed
</label>
<button
class="ghost"
disabled={browseResource.loading}
onClick={() => setRefreshCount((n) => n + 1)}
>
{browseResource.loading ? "loading…" : "refresh"}
</button>
</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)]">
{errorMessage()}
</div>
</Show>
<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)]">
<code>{err.repo}</code> <span class="text-ink-300">{err.error}</span>
</div>
)}
</For>
</Show>
<div class="max-h-[44vh] overflow-y-auto scrollable">
<Show
when={filtered().length}
fallback={
<div class="empty">
{hideProcessed() &&
processedCount() > 0 &&
processedCount() === data().issues.length
? `all ${processedCount()} issues already processed — uncheck "hide processed" to see them`
: "no issues"}
</div>
}
>
<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"
style={{
"grid-template-columns": "1fr auto",
opacity: issue.processed ? 0.55 : 1,
}}
>
<div class="min-w-0">
<div class="text-[13px] flex items-center gap-2 flex-wrap">
<a
class="font-medium text-ink-100 hover:text-accent-2 truncate"
href={issue.html_url}
target="_blank"
rel="noopener"
>
<span class="font-mono text-[12px] text-ink-300 mr-2">
{issue.repo}#{issue.number}
</span>
{issue.title}
</a>
</div>
<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>
</Show>
<span>by {issue.author || "—"}</span>
<span>updated {fmtAge(issue.updated_at)}</span>
<span>{issue.comments} comments</span>
<For each={issue.labels.slice(0, 6)}>{(label) => <code>{label}</code>}</For>
</div>
</div>
<div class="flex gap-2 flex-shrink-0">
<button
class="primary tiny"
onClick={() => triggerFor("triage", issue.repo, issue.number)}
>
triage
</button>
<button
class="tiny"
onClick={() => triggerFor("retry", issue.repo, issue.number)}
>
retry
</button>
</div>
</div>
)}
</For>
</Show>
</div>
</Show>
</GlassCard>
);
}
@@ -0,0 +1,84 @@
import { For, type JSX, Show } from "solid-js";
import { CONFIG } from "../config";
import { fmtAge, splitIssueKey } from "../format";
import { statusResource } from "../state";
import type { RecentEvent } from "../types";
import { GlassCard } from "./GlassCard";
import { IssueLink } from "./IssueLink";
import { Pill } from "./Pill";
export interface EventsProps {
onRetry: (deliveryId: string) => void;
}
export function Events(props: EventsProps): JSX.Element {
const events = (): RecentEvent[] => statusResource()?.recent_events ?? [];
return (
<GlassCard heading="recent events" accessory={<span class="tabular">{events().length}</span>}>
<Show when={events().length} fallback={<div class="empty">no events recorded yet</div>}>
<div class="overflow-x-auto scrollable">
<table class="t">
<thead>
<tr>
<th>received</th>
<th>event</th>
<th>where</th>
<th>state</th>
<th>tries</th>
<th>error</th>
<th />
</tr>
</thead>
<tbody>
<For each={events()}>
{(event) => <EventRow event={event} onRetry={props.onRetry} />}
</For>
</tbody>
</table>
</div>
</Show>
</GlassCard>
);
}
interface RowProps {
event: RecentEvent;
onRetry: (deliveryId: string) => void;
}
function EventRow(props: RowProps): JSX.Element {
const ref = (): { repo: string; number: string } => splitIssueKey(props.event.issue_key);
const canRetry = (): boolean => props.event.state === "failed" || props.event.state === "done";
return (
<tr>
<td class="text-ink-300 tabular whitespace-nowrap">{fmtAge(props.event.received_at)}</td>
<td class="text-ink-200">{props.event.event_type}</td>
<td>
<Show
when={ref().number}
fallback={<span class="text-ink-300">{props.event.repo ?? "—"}</span>}
>
<IssueLink repo={ref().repo} number={ref().number} />
</Show>
</td>
<td>
<Pill state={props.event.state}>{props.event.state}</Pill>
</td>
<td class="text-ink-300 tabular">{props.event.attempts}</td>
<td class="err-cell">{props.event.last_error ?? ""}</td>
<td>
<Show
when={CONFIG.replayEnabled && canRetry()}
fallback={<span class="text-ink-400">—</span>}
>
<button class="tiny" onClick={() => props.onRetry(props.event.delivery_id)}>
retry
</button>
</Show>
</td>
</tr>
);
}
@@ -0,0 +1,31 @@
import type { JSX } from "solid-js";
export interface GlassCardProps {
heading?: string;
accessory?: JSX.Element;
class?: string;
contentClass?: string;
bare?: boolean;
children: JSX.Element;
style?: JSX.CSSProperties;
}
// Single glass surface used for every section card. The `bare` variant skips
// 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";
return props.class ? `${base} ${props.class}` : base;
};
return (
<section class={cls()} style={props.style}>
{props.heading != null && (
<div class="section-heading">
<h2>{props.heading}</h2>
{props.accessory && <div class="accessory">{props.accessory}</div>}
</div>
)}
<div class={props.contentClass}>{props.children}</div>
</section>
);
}
+106
View File
@@ -0,0 +1,106 @@
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,44 @@
import type { JSX } from "solid-js";
import { issueUrl, prUrl } from "../format";
export interface IssueLinkProps {
repo: string;
number: number | string;
}
export function IssueLink(props: IssueLinkProps): JSX.Element {
return (
<a
class="font-mono text-[12px] text-ink-100 hover:text-accent-2"
href={issueUrl(props.repo, props.number)}
target="_blank"
rel="noopener"
>
{props.repo}
<span class="text-ink-400">#</span>
{props.number}
</a>
);
}
export interface PrLinkProps {
repo: string;
number: number | string | null | undefined;
}
export function PrLink(props: PrLinkProps): JSX.Element {
if (props.number == null || props.number === "") {
return <span class="text-ink-400">—</span>;
}
return (
<a
class="font-mono text-[12px] text-accent-2 hover:underline"
href={prUrl(props.repo, props.number)}
target="_blank"
rel="noopener"
>
#{props.number}
</a>
);
}
+117
View File
@@ -0,0 +1,117 @@
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>
);
}
+168
View File
@@ -0,0 +1,168 @@
import { createEffect, createSignal, For, type JSX, Show } from "solid-js";
import { fmtTimestamp } from "../format";
import { logsResource } from "../state";
import { LEVEL_ORDER, type LogEntry } from "../types";
import { GlassCard } from "./GlassCard";
const RESERVED_LOG_FIELDS = new Set(["ts", "level", "logger", "msg", "exc"]);
interface Extra {
key: string;
value: string;
}
interface FormattedRow {
index: number;
ts: string;
level: string;
logger: string;
message: string;
extras: Extra[];
exc: string | null;
}
function formatExtraValue(value: unknown): string {
if (value == null) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function buildExtras(entry: LogEntry): Extra[] {
const out: Extra[] = [];
for (const [key, value] of Object.entries(entry)) {
if (RESERVED_LOG_FIELDS.has(key)) continue;
out.push({ key, value: formatExtraValue(value) });
}
return out;
}
export function Logs(): JSX.Element {
const [level, setLevel] = createSignal<string>("INFO");
const [filter, setFilter] = createSignal<string>("");
const [follow, setFollow] = createSignal<boolean>(true);
let scrollEl: HTMLDivElement | undefined;
const allEntries = (): LogEntry[] => logsResource()?.entries ?? [];
const rows = (): FormattedRow[] => {
const wantLevel = level();
const minOrd = wantLevel ? (LEVEL_ORDER[wantLevel] ?? 0) : 0;
const needle = filter().trim().toLowerCase();
const out: FormattedRow[] = [];
let index = 0;
for (const entry of allEntries()) {
const lvl = entry.level ?? "INFO";
if ((LEVEL_ORDER[lvl] ?? 20) < minOrd) continue;
const msg = entry.msg ?? "";
const extras = buildExtras(entry);
if (needle) {
const haystack = (
msg +
" " +
extras.map((e) => `${e.key}=${e.value}`).join(" ")
).toLowerCase();
if (!haystack.includes(needle)) continue;
}
out.push({
index: index++,
ts: fmtTimestamp(entry.ts),
level: lvl,
logger: entry.logger ?? "",
message: msg,
extras,
exc: entry.exc ?? null,
});
}
return out;
};
createEffect(() => {
// Touch dependencies so effect re-runs on new data / toggles.
rows();
if (follow() && scrollEl) {
scrollEl.scrollTop = scrollEl.scrollHeight;
}
});
return (
<GlassCard
heading="agent logs"
accessory={
<span class="tabular">
{rows().length} / {allEntries().length}
</span>
}
>
<div class="px-4 pb-3 pt-1 flex flex-wrap items-center gap-3 border-b border-stroke-soft">
<label class="flex items-center gap-2 text-[11px] uppercase tracking-[0.14em] text-ink-400">
level
<select value={level()} onChange={(ev) => setLevel(ev.currentTarget.value)}>
<option value="">all</option>
<option value="DEBUG">debug+</option>
<option value="INFO">info+</option>
<option value="WARNING">warn+</option>
<option value="ERROR">error</option>
</select>
</label>
<label class="flex items-center gap-2 text-[11px] uppercase tracking-[0.14em] text-ink-400 flex-1 min-w-[200px]">
filter
<input
type="search"
value={filter()}
placeholder="substring"
onInput={(ev) => setFilter(ev.currentTarget.value)}
class="flex-1"
/>
</label>
<label class="flex items-center gap-2 text-[12px] text-ink-300 select-none cursor-pointer">
<input
type="checkbox"
checked={follow()}
onChange={(ev) => setFollow(ev.currentTarget.checked)}
/>
follow tail
</label>
</div>
<div class="logs scrollable" ref={(el) => (scrollEl = el)}>
<Show when={rows().length} fallback={<div class="empty">no log entries match</div>}>
<For each={rows()}>
{(row) => (
<div class="log-row">
<span class="ts">{row.ts}</span>
<span class={`lvl ${row.level}`}>{row.level}</span>
<span class="logger">{row.logger}</span>
<span>
<span class="msg">{row.message}</span>
<Show when={row.extras.length}>
<span class="extras">
<For each={row.extras}>
{(extra) => (
<span>
{" "}
<b>{extra.key}</b>={extra.value}
</span>
)}
</For>
</span>
</Show>
<Show when={row.exc}>
<span class="exc">{row.exc}</span>
</Show>
</span>
</div>
)}
</For>
</Show>
</div>
</GlassCard>
);
}
+24
View File
@@ -0,0 +1,24 @@
import type { JSX } from "solid-js";
export interface PillProps {
state?: string;
dot?: boolean;
title?: string;
class?: string;
children?: JSX.Element;
}
export function Pill(props: PillProps): JSX.Element {
const className = (): string => {
const parts = ["pill"];
if (props.state) parts.push(props.state);
if (props.dot) parts.push("dot");
if (props.class) parts.push(props.class);
return parts.join(" ");
};
return (
<span class={className()} title={props.title}>
{props.children}
</span>
);
}
@@ -0,0 +1,45 @@
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,79 @@
import { createSignal, type JSX, Show } from "solid-js";
import { CONFIG } from "../config";
import { runTrigger, triggerStatus } from "../state";
import { GlassCard } from "./GlassCard";
const STATUS_TONE = {
idle: "text-ink-400",
pending: "text-ink-200",
ok: "text-[#7fe5a3]",
err: "text-[#ff8e85]",
} as const;
export function Trigger(): JSX.Element {
const [issue, setIssue] = createSignal<string>("");
const validate = (): string | null => {
const value = issue().trim();
if (!value) return "enter owner/repo#NN";
return null;
};
const handleTriage = (): void => {
const value = issue().trim();
if (!value) return;
void runTrigger({ mode: "triage", issue: value });
};
const handleRetry = (): void => {
const value = issue().trim();
if (!value) return;
void runTrigger({ mode: "retry", issue: value });
};
return (
<GlassCard heading="trigger" accessory={<span class="text-ink-400">owner/repo#NN</span>}>
<Show
when={CONFIG.replayEnabled}
fallback={
<div class="px-5 py-7 text-ink-300 text-[13px] leading-relaxed">
trigger disabled. set <code>ROBOMP_REPLAY_TOKEN</code> in the server env to enable
manual triage and retry actions.
</div>
}
>
<div class="px-5 pb-5 pt-1 flex flex-col gap-4">
<div class="form-row">
<input
type="text"
spellcheck={false}
placeholder="octo/widget#42"
autocomplete="off"
value={issue()}
onInput={(ev) => setIssue(ev.currentTarget.value)}
onKeyDown={(ev) => {
if (ev.key === "Enter") handleTriage();
}}
class="flex-1 min-w-[220px] font-mono"
/>
<button class="primary" onClick={handleTriage}>
fetch &amp; triage
</button>
<button onClick={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[triggerStatus().kind]}`}>
{triggerStatus().text}
</span>
</Show>
</div>
</Show>
</GlassCard>
);
}
@@ -0,0 +1,160 @@
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>
);
}