Publish curated projectbrain source from 88fab8df1542019289e947f092c2b387e0825c60
This commit is contained in:
1 parent
48e72d07e0
commit
eca2891b07
45 files changed
+2697
-328
No files matched your search
@@ -0,0 +1,105 @@
|
||||
import * as React from "react";
|
||||
import { ArrowDown, ArrowUp, GripVertical, LockKeyhole } from "lucide-react";
|
||||
import { Badge, Button } from "@/components/ui";
|
||||
import type { AutoModeItem } from "@/shared/api/autonomyApi";
|
||||
|
||||
export type SortableQueueEntry = {
|
||||
item: AutoModeItem;
|
||||
phaseTitle: string;
|
||||
blockedReason?: string;
|
||||
};
|
||||
|
||||
export const SortableWorkQueue = ({
|
||||
entries,
|
||||
saving,
|
||||
manualOrdering,
|
||||
onReorder,
|
||||
onReset,
|
||||
}: {
|
||||
entries: SortableQueueEntry[];
|
||||
saving: boolean;
|
||||
manualOrdering: boolean;
|
||||
onReorder: (itemIds: string[]) => void;
|
||||
onReset: () => void;
|
||||
}) => {
|
||||
const [draggedId, setDraggedId] = React.useState<string>();
|
||||
const [dragOverId, setDragOverId] = React.useState<string>();
|
||||
|
||||
const move = React.useCallback((itemId: string, targetIndex: number) => {
|
||||
const currentIndex = entries.findIndex((entry) => entry.item.id === itemId);
|
||||
if (currentIndex < 0 || targetIndex < 0 || targetIndex >= entries.length || currentIndex === targetIndex || saving) return;
|
||||
const ids = entries.map((entry) => entry.item.id);
|
||||
const [moved] = ids.splice(currentIndex, 1);
|
||||
ids.splice(targetIndex, 0, moved);
|
||||
onReorder(ids);
|
||||
}, [entries, onReorder, saving]);
|
||||
|
||||
if (!entries.length) {
|
||||
return <div className="rounded-lg border border-dashed bg-muted/20 p-3 text-sm text-muted-foreground">Geen pending committed werk om te prioriteren.</div>;
|
||||
}
|
||||
|
||||
return <section className="space-y-2" aria-labelledby="auto-queue-order-heading">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<h3 id="auto-queue-order-heading" className="text-sm font-semibold">Uitvoeringsvolgorde</h3>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">{manualOrdering ? "Dit is de expliciete uitvoeringsvolgorde voor pending werk; dependencies blijven altijd een harde grens." : "Dit toont de roadmapvolgorde. ProjectBrain kiest automatisch de beste veilige volgende stap; sleep een item om een expliciete volgorde vast te leggen."}</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{manualOrdering ? <Button type="button" variant="outline" size="sm" disabled={saving} onClick={onReset}>Automatische volgorde herstellen</Button> : null}
|
||||
<Badge variant={saving ? "secondary" : manualOrdering ? "secondary" : "outline"}>{saving ? "Opslaan…" : manualOrdering ? "Handmatige volgorde" : `${entries.length} in wachtrij`}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{entries.map((entry, index) => {
|
||||
const isDragged = draggedId === entry.item.id;
|
||||
const isOver = dragOverId === entry.item.id && draggedId !== entry.item.id;
|
||||
return <div
|
||||
key={entry.item.id}
|
||||
onDragOver={(event) => {
|
||||
if (!draggedId || saving) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
setDragOverId(entry.item.id);
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
if (!draggedId || saving) return;
|
||||
move(draggedId, index);
|
||||
setDraggedId(undefined);
|
||||
setDragOverId(undefined);
|
||||
}}
|
||||
className={`flex min-w-0 items-center gap-2 rounded-lg border bg-background p-2 transition ${isDragged ? "opacity-50" : ""} ${isOver ? "border-primary ring-1 ring-primary/30" : ""}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
draggable={!saving}
|
||||
disabled={saving}
|
||||
onDragStart={(event) => {
|
||||
setDraggedId(entry.item.id);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData("text/plain", entry.item.id);
|
||||
}}
|
||||
onDragEnd={() => { setDraggedId(undefined); setDragOverId(undefined); }}
|
||||
className="flex h-9 w-9 shrink-0 cursor-grab items-center justify-center rounded-md text-muted-foreground outline-none transition hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 active:cursor-grabbing"
|
||||
aria-label={`Versleep ${entry.item.title}`}
|
||||
title="Vastnemen en verslepen"
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="w-6 shrink-0 text-center text-xs font-semibold tabular-nums text-muted-foreground">{index + 1}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<p className="min-w-0 break-words text-sm font-medium [overflow-wrap:anywhere]">{entry.item.title}</p>
|
||||
{entry.blockedReason ? <Badge variant="outline" className="gap-1 text-[10px]"><LockKeyhole className="h-3 w-3" />{entry.blockedReason}</Badge> : null}
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">{entry.phaseTitle}{entry.item.source ? ` · ${entry.item.source.replaceAll("_", " ")}` : ""}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button type="button" variant="ghost" size="icon" disabled={saving || index === 0} onClick={() => move(entry.item.id, index - 1)} aria-label={`${entry.item.title} omhoog`} title="Omhoog"><ArrowUp className="h-4 w-4" /></Button>
|
||||
<Button type="button" variant="ghost" size="icon" disabled={saving || index === entries.length - 1} onClick={() => move(entry.item.id, index + 1)} aria-label={`${entry.item.title} omlaag`} title="Omlaag"><ArrowDown className="h-4 w-4" /></Button>
|
||||
</div>
|
||||
</div>;
|
||||
})}
|
||||
</div>
|
||||
</section>;
|
||||
};
|
||||
@@ -5,6 +5,7 @@ import { useNavigate, useParams } from "react-router-dom";
|
||||
import { ArrowRight, Bot, BrainCircuit, CheckCircle2, ListTodo, RefreshCw, RotateCcw, ShieldAlert, Upload } from "lucide-react";
|
||||
import { Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Input, Label, Progress, Switch, Textarea } from "@/components/ui";
|
||||
import { autonomyApi, type AutoModePhase, type AutoModeState } from "@/shared/api/autonomyApi";
|
||||
import { SortableWorkQueue } from "@/features/autonomy/components/SortableWorkQueue";
|
||||
import { ApiError } from "@/shared/api/httpClient";
|
||||
import { autoModeItemLabel, autoModePhaseLabel, autoModeStatusLabel, getAutoModeOperationalState } from "@/shared/lib/autoModePresentation";
|
||||
import { autonomyProfileDefinitions, autonomyProfileOrder } from "@/shared/lib/autonomyProfiles";
|
||||
@@ -19,6 +20,7 @@ const AutoMode = () => {
|
||||
const [state, setState] = React.useState<AutoModeState>();
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [queueSaving, setQueueSaving] = React.useState(false);
|
||||
const [error, setError] = React.useState<string>();
|
||||
const [roadmapTitle, setRoadmapTitle] = React.useState("");
|
||||
const [roadmapMarkdown, setRoadmapMarkdown] = React.useState("");
|
||||
@@ -71,25 +73,111 @@ const AutoMode = () => {
|
||||
};
|
||||
const savePolicy = async () => { if (await configure(policy)) showSuccess("Autonomiebeleid opgeslagen"); };
|
||||
|
||||
const phases = state?.roadmap?.phases ?? [];
|
||||
const items = phases.flatMap((phase) => phase.items);
|
||||
const phases = React.useMemo(() => state?.roadmap?.phases ?? [], [state?.roadmap?.phases]);
|
||||
const items = React.useMemo(() => phases.flatMap((phase) => phase.items), [phases]);
|
||||
const completedItems = items.filter((item) => item.status === "completed").length;
|
||||
const openItems = items.filter((item) => item.status !== "completed");
|
||||
const progress = items.length ? Math.round((completedItems / items.length) * 100) : state?.status === "completed" ? 100 : 0;
|
||||
const activePhase = state?.roadmap ? phases[state.roadmap.currentPhaseIndex] : undefined;
|
||||
const inProgressItem = activePhase?.items.find((item) => item.status === "in_progress");
|
||||
const activeItem = inProgressItem ?? activePhase?.items.find((item) => item.status !== "completed");
|
||||
const nextItem = phases.flatMap((phase) => phase.items).find((item) => item.status === "pending");
|
||||
const queueEntries = React.useMemo(() => {
|
||||
const completedIds = new Set(items.filter((item) => item.status === "completed").map((item) => item.id));
|
||||
return phases.flatMap((phase, phaseIndex) => phase.items.map((item, itemIndex) => ({
|
||||
item,
|
||||
phaseTitle: phase.title,
|
||||
phaseIndex,
|
||||
itemIndex,
|
||||
blockedReason: item.waitingExternal
|
||||
? "wacht extern"
|
||||
: (item.dependsOn ?? []).some((dependencyId) => !completedIds.has(dependencyId))
|
||||
? "dependency"
|
||||
: undefined,
|
||||
})))
|
||||
.filter((entry) => entry.item.status === "pending" && entry.item.scope !== "future" && entry.item.scope !== "stretch")
|
||||
.sort((left, right) => {
|
||||
const leftOrder = left.item.manualOrder;
|
||||
const rightOrder = right.item.manualOrder;
|
||||
if (typeof leftOrder === "number" || typeof rightOrder === "number") {
|
||||
if (typeof leftOrder !== "number") return 1;
|
||||
if (typeof rightOrder !== "number") return -1;
|
||||
if (leftOrder !== rightOrder) return leftOrder - rightOrder;
|
||||
}
|
||||
return left.phaseIndex - right.phaseIndex || left.itemIndex - right.itemIndex;
|
||||
});
|
||||
}, [items, phases]);
|
||||
const queueHasManualOrder = queueEntries.some((entry) => typeof entry.item.manualOrder === "number");
|
||||
const nextItem = queueHasManualOrder ? queueEntries.find((entry) => !entry.blockedReason)?.item : undefined;
|
||||
const operational = state ? getAutoModeOperationalState(state) : undefined;
|
||||
const policyDirty = Boolean(state && (state.profile !== policy.profile || state.autoBrainstorm !== policy.autoBrainstorm || state.maxBrainstormIterations !== policy.maxBrainstormIterations || state.maxConsecutiveFailures !== policy.maxConsecutiveFailures || state.leaseMinutes !== policy.leaseMinutes || state.maxWorkItemsPerRun !== policy.maxWorkItemsPerRun || state.maxRunMinutes !== policy.maxRunMinutes || state.maxRecoveryMinutes !== policy.maxRecoveryMinutes));
|
||||
const updatePolicyNumber = (key: "maxBrainstormIterations" | "maxConsecutiveFailures" | "leaseMinutes" | "maxWorkItemsPerRun" | "maxRunMinutes" | "maxRecoveryMinutes", value: string, minimum: number, maximum: number) => { const parsed = Number(value); if (Number.isFinite(parsed)) setPolicy((current) => ({ ...current, profile: "custom", [key]: Math.max(minimum, Math.min(maximum, Math.trunc(parsed))) })); };
|
||||
const selectProfile = (profile: AutoModeState["profile"]) => { const settings = autonomyProfileDefinitions[profile].settings; setPolicy((current) => settings ? ({ ...current, profile, ...settings }) : ({ ...current, profile: "custom" })); };
|
||||
|
||||
const reorderQueue = async (itemIds: string[]) => {
|
||||
if (!projectId || !state?.roadmap || queueSaving) return;
|
||||
const previous = state;
|
||||
const positions = new Map(itemIds.map((id, index) => [id, index]));
|
||||
setState({
|
||||
...state,
|
||||
roadmap: {
|
||||
...state.roadmap,
|
||||
phases: state.roadmap.phases.map((phase) => ({
|
||||
...phase,
|
||||
items: phase.items.map((item) => positions.has(item.id) ? { ...item, manualOrder: positions.get(item.id) } : item),
|
||||
})),
|
||||
},
|
||||
});
|
||||
setQueueSaving(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const next = await autonomyApi.reorderQueue(projectId, itemIds);
|
||||
loadGeneration.current += 1;
|
||||
setLoading(false);
|
||||
setState(next);
|
||||
showSuccess("Wachtrijvolgorde opgeslagen");
|
||||
} catch (nextError) {
|
||||
setState(previous);
|
||||
setError(readableError(nextError));
|
||||
void load(true);
|
||||
} finally {
|
||||
setQueueSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetQueueOrder = async () => {
|
||||
if (!projectId || !state?.roadmap || queueSaving) return;
|
||||
const previous = state;
|
||||
setState({
|
||||
...state,
|
||||
roadmap: {
|
||||
...state.roadmap,
|
||||
phases: state.roadmap.phases.map((phase) => ({
|
||||
...phase,
|
||||
items: phase.items.map((item) => ({ ...item, manualOrder: undefined })),
|
||||
})),
|
||||
},
|
||||
});
|
||||
setQueueSaving(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const next = await autonomyApi.resetQueueOrder(projectId);
|
||||
loadGeneration.current += 1;
|
||||
setLoading(false);
|
||||
setState(next);
|
||||
showSuccess("Automatische prioritering hersteld");
|
||||
} catch (nextError) {
|
||||
setState(previous);
|
||||
setError(readableError(nextError));
|
||||
void load(true);
|
||||
} finally {
|
||||
setQueueSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!projectId) return;
|
||||
const interval = window.setInterval(() => { if (document.visibilityState === "visible" && !saving && !policyDirty) void load(true); }, 10_000);
|
||||
const interval = window.setInterval(() => { if (document.visibilityState === "visible" && !saving && !queueSaving && !policyDirty) void load(true); }, 10_000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [load, policyDirty, projectId, saving]);
|
||||
}, [load, policyDirty, projectId, queueSaving, saving]);
|
||||
|
||||
if (loading && !state) return <div className="rounded-xl border bg-card p-6 text-sm text-muted-foreground">Auto Mode laden…</div>;
|
||||
const controllerClass = operational?.tone === "blocked" ? "border-destructive/40" : operational?.tone === "retry" ? "border-amber-300" : operational?.tone === "active" ? "border-primary/50" : operational?.tone === "complete" ? "border-emerald-300" : "border-dashed";
|
||||
@@ -104,7 +192,7 @@ const AutoMode = () => {
|
||||
<div className="flex min-w-0 gap-3"><span className="shrink-0 rounded-full bg-muted p-2">{operational.tone === "blocked" ? <ShieldAlert className="h-5 w-5 text-destructive" /> : operational.tone === "retry" ? <RefreshCw className="h-5 w-5 text-amber-700" /> : <Bot className="h-5 w-5" />}</span><div className="min-w-0"><div className="flex flex-wrap gap-2"><Badge variant="outline" className="max-w-full whitespace-normal [overflow-wrap:anywhere]">{operational.label}</Badge><Badge variant={state.enabled ? "default" : "outline"} className="max-w-full whitespace-normal [overflow-wrap:anywhere]">Auto {state.enabled ? "aan" : "uit"}</Badge></div><h2 className="mt-2 text-xl font-semibold">{operational.headline}</h2><p className="mt-1 max-w-3xl text-sm leading-6 text-muted-foreground">{operational.detail}</p></div></div>
|
||||
<div className="flex max-w-full flex-wrap gap-2"><div className="flex items-center gap-3 rounded-lg border px-4 py-3"><Label htmlFor="auto-mode-toggle" className="font-semibold">Auto</Label><Switch id="auto-mode-toggle" checked={state.enabled} disabled={saving} onCheckedChange={(enabled) => void configure({ enabled })} /></div>{state.enabled ? <Button variant="outline" onClick={() => void configure({ pauseAfterCurrent: true })} disabled={saving || state.pauseAfterCurrent}>{state.pauseAfterCurrent ? "Pauze gepland" : "Pauzeer na huidig werk"}</Button> : null}</div>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-4"><div className="rounded-lg border p-3"><p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Nu</p><p className="mt-1 font-semibold">{inProgressItem ? inProgressItem.title : state.activeLease ? "Compatibiliteitslease actief" : state.enabled ? "Wacht op Gitea-branch" : "Auto staat uit"}</p></div><div className="rounded-lg border p-3"><p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Hierna</p><p className="mt-1 text-sm">{nextItem?.title ?? (openItems.length ? activePhase?.title ?? "Volgende veilige stap wordt bepaald" : "Geen open committed werk")}</p></div><div className="rounded-lg border p-3"><p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Voortgang</p><p className="mt-1 font-semibold">{completedItems}/{items.length} · {progress}%</p><Progress className="mt-2 h-1.5" value={progress} aria-valuetext={items.length ? `${completedItems} van ${items.length} werkitems voltooid (${progress}%)` : `${progress}%`} /></div><div className="rounded-lg border p-3"><p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Open</p><p className="mt-1 font-semibold">{openItems.length} werkitem{openItems.length === 1 ? "" : "s"}</p><p className="mt-1 text-xs text-muted-foreground">{activePhase ? `Fase: ${activePhase.title}` : "Geen actieve fase"}</p></div></div>
|
||||
<div className="grid gap-3 md:grid-cols-4"><div className="rounded-lg border p-3"><p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Nu</p><p className="mt-1 font-semibold">{inProgressItem ? inProgressItem.title : state.activeLease ? "Compatibiliteitslease actief" : state.enabled ? "Wacht op Gitea-branch" : "Auto staat uit"}</p></div><div className="rounded-lg border p-3"><p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Hierna</p><p className="mt-1 text-sm">{nextItem?.title ?? (openItems.length ? queueHasManualOrder ? activePhase?.title ?? "Volgende handmatige stap wordt bepaald" : "Slimme scheduler bepaalt de volgende veilige stap" : "Geen open committed werk")}</p></div><div className="rounded-lg border p-3"><p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Voortgang</p><p className="mt-1 font-semibold">{completedItems}/{items.length} · {progress}%</p><Progress className="mt-2 h-1.5" value={progress} aria-valuetext={items.length ? `${completedItems} van ${items.length} werkitems voltooid (${progress}%)` : `${progress}%`} /></div><div className="rounded-lg border p-3"><p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Open</p><p className="mt-1 font-semibold">{openItems.length} werkitem{openItems.length === 1 ? "" : "s"}</p><p className="mt-1 text-xs text-muted-foreground">{activePhase ? `Fase: ${activePhase.title}` : "Geen actieve fase"}</p></div></div>
|
||||
<p className="text-xs leading-5 text-muted-foreground">Repositorywerk wordt atomair geclaimd via <code className="[overflow-wrap:anywhere]">projectbrain/auto/<work-id></code>. Een item wordt pas voltooid na succesvolle exact-head Gitea Actions-validatie en merge van de canonieke PR. Leases en callbacks blijven alleen voor compatibiliteit en herstel.</p>
|
||||
</CardContent>
|
||||
</Card> : null}
|
||||
@@ -112,7 +200,7 @@ const AutoMode = () => {
|
||||
{state?.blocker?.kind === "retryable" ? <Card className="border-amber-300 bg-amber-50/30"><CardContent className="flex flex-col gap-4 p-4 sm:flex-row sm:items-center sm:justify-between"><div><p className="font-semibold">Automatisch herstel</p><p className="mt-1 text-sm text-muted-foreground">{state.blocker.reason} ProjectBrain probeert dit automatisch opnieuw; jouw actie is niet nodig.</p>{state.blocker.retryAt ? <p className="mt-1 text-xs text-muted-foreground">Opnieuw uitvoerbaar vanaf {new Date(state.blocker.retryAt).toLocaleString("nl-BE")}</p> : null}</div><Button variant="outline" onClick={() => void resume()} disabled={saving}><RotateCcw className="mr-2 h-4 w-4" />Nu opnieuw proberen</Button></CardContent></Card> : null}
|
||||
{state?.blocker?.kind === "human" ? <Card className="border-destructive/40"><CardContent className="flex min-w-0 flex-col gap-4 p-4 sm:flex-row sm:items-center sm:justify-between"><div className="min-w-0"><p className="font-semibold text-destructive">Jouw beslissing of toegang nodig</p><p className="mt-1 text-sm text-muted-foreground">{state.blocker.reason}</p></div><div className="flex max-w-full flex-wrap gap-2"><Button variant="destructive" onClick={() => navigate(`/projects/${projectId}/attention`)}>Jouw aandacht <ArrowRight size={14} /></Button><Button variant="outline" onClick={() => void resume()} disabled={saving}>Na oplossing hervatten</Button></div></CardContent></Card> : null}
|
||||
|
||||
<details className="rounded-xl border bg-card"><summary className="cursor-pointer p-4 font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">Roadmap en wachtrij</summary><div className="space-y-4 border-t p-4"><div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-end sm:justify-between"><div className="min-w-0"><p className="font-semibold">Committed werk</p><p className="text-sm text-muted-foreground">{state?.roadmap ? `${openItems.length} open · ${completedItems}/${items.length} voltooid` : "Nog geen roadmap of opdrachten."}</p></div><span className="text-2xl font-bold tabular-nums">{progress}%</span></div>{phases.map((phase, index) => { const done = phase.items.filter((item) => item.status === "completed").length; return <div key={phase.id} className="min-w-0 rounded-lg border p-4"><div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-start sm:justify-between"><div className="min-w-0"><p className="font-semibold">{index + 1}. {phase.title}</p><p className="mt-1 text-sm text-muted-foreground">{phase.objective}</p></div><Badge variant={phaseVariant(phase)} className="max-w-full whitespace-normal [overflow-wrap:anywhere]">{autoModePhaseLabel[phase.status]}</Badge></div><p className="mt-2 text-xs text-muted-foreground">{done}/{phase.items.length} items</p>{phase.items.length ? <div className="mt-3 grid min-w-0 gap-2 md:grid-cols-2">{phase.items.map((item) => <div key={item.id} className="min-w-0 rounded-md bg-muted/35 px-3 py-2 text-sm"><div className="flex min-w-0 items-start gap-2">{item.status === "completed" ? <CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-primary" /> : <span className={`mt-1 h-2 w-2 shrink-0 rounded-full ${item.status === "blocked" ? "bg-destructive" : item.status === "in_progress" ? "bg-primary" : "bg-muted-foreground/50"}`} />}<span className="min-w-0">{item.title}</span></div><p className="mt-1 pl-6 text-xs text-muted-foreground">{autoModeItemLabel[item.status]} · {item.attempts} poging(en)</p></div>)}</div> : null}</div>; })}{!items.length ? <Button variant="outline" onClick={() => navigate(`/projects/${projectId}/workspace`)}>Opdracht geven <ArrowRight className="ml-2 h-4 w-4" /></Button> : null}</div></details>
|
||||
<details className="rounded-xl border bg-card"><summary className="cursor-pointer p-4 font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">Roadmap en wachtrij</summary><div className="space-y-4 border-t p-4"><SortableWorkQueue entries={queueEntries} saving={queueSaving} manualOrdering={queueHasManualOrder} onReorder={(itemIds) => void reorderQueue(itemIds)} onReset={() => void resetQueueOrder()} /><div className="flex min-w-0 flex-col gap-3 sm:flex-row sm:items-end sm:justify-between"><div className="min-w-0"><p className="font-semibold">Committed werk</p><p className="text-sm text-muted-foreground">{state?.roadmap ? `${openItems.length} open · ${completedItems}/${items.length} voltooid` : "Nog geen roadmap of opdrachten."}</p></div><span className="text-2xl font-bold tabular-nums">{progress}%</span></div>{phases.map((phase, index) => { const done = phase.items.filter((item) => item.status === "completed").length; return <div key={phase.id} className="min-w-0 rounded-lg border p-4"><div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-start sm:justify-between"><div className="min-w-0"><p className="font-semibold">{index + 1}. {phase.title}</p><p className="mt-1 text-sm text-muted-foreground">{phase.objective}</p></div><Badge variant={phaseVariant(phase)} className="max-w-full whitespace-normal [overflow-wrap:anywhere]">{autoModePhaseLabel[phase.status]}</Badge></div><p className="mt-2 text-xs text-muted-foreground">{done}/{phase.items.length} items</p>{phase.items.length ? <div className="mt-3 grid min-w-0 gap-2 md:grid-cols-2">{phase.items.map((item) => <div key={item.id} className="min-w-0 rounded-md bg-muted/35 px-3 py-2 text-sm"><div className="flex min-w-0 items-start gap-2">{item.status === "completed" ? <CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-primary" /> : <span className={`mt-1 h-2 w-2 shrink-0 rounded-full ${item.status === "blocked" ? "bg-destructive" : item.status === "in_progress" ? "bg-primary" : "bg-muted-foreground/50"}`} />}<span className="min-w-0">{item.title}</span></div><p className="mt-1 pl-6 text-xs text-muted-foreground">{autoModeItemLabel[item.status]} · {item.attempts} poging(en)</p></div>)}</div> : null}</div>; })}{!items.length ? <Button variant="outline" onClick={() => navigate(`/projects/${projectId}/workspace`)}>Opdracht geven <ArrowRight className="ml-2 h-4 w-4" /></Button> : null}</div></details>
|
||||
|
||||
<details className="rounded-xl border bg-card"><summary className="cursor-pointer p-4 font-semibold focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">Autonomiebeleid en geavanceerde instellingen</summary><div className="grid min-w-0 gap-6 border-t p-4 xl:grid-cols-2"><Card className="min-w-0"><CardHeader><CardTitle>Autonomieprofiel</CardTitle><CardDescription>Routinewerk automatiseren zonder echte risico- of productbeslissingen te verbergen.</CardDescription></CardHeader><CardContent className="space-y-4"><div className="grid min-w-0 gap-2 sm:grid-cols-2">{autonomyProfileOrder.map((profile) => { const definition = autonomyProfileDefinitions[profile]; return <Button key={profile} type="button" variant={policy.profile === profile ? "default" : "outline"} className="h-auto min-h-16 w-full min-w-0 max-w-full justify-start whitespace-normal text-left [overflow-wrap:anywhere]" onClick={() => selectProfile(profile)}><span className="min-w-0"><span className="block font-semibold">{definition.label}</span><span className="mt-1 block text-xs font-normal opacity-80">{definition.description}</span></span></Button>; })}</div><details className="rounded-lg border"><summary className="cursor-pointer p-3 text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">Geavanceerde grenzen</summary><div className="grid min-w-0 gap-4 border-t p-3 sm:grid-cols-2"><div className="min-w-0 space-y-2"><Label htmlFor="brainstorm-iterations">Max. brainstormrondes</Label><Input id="brainstorm-iterations" type="number" min={1} max={10} value={policy.maxBrainstormIterations} onChange={(event) => updatePolicyNumber("maxBrainstormIterations", event.target.value, 1, 10)} /></div><div className="min-w-0 space-y-2"><Label htmlFor="failure-limit">Retry-drempel</Label><Input id="failure-limit" type="number" min={1} max={10} value={policy.maxConsecutiveFailures} onChange={(event) => updatePolicyNumber("maxConsecutiveFailures", event.target.value, 1, 10)} /></div><div className="min-w-0 space-y-2"><Label htmlFor="lease-minutes">Compatibiliteitslease (min.)</Label><Input id="lease-minutes" type="number" min={5} max={120} value={policy.leaseMinutes} onChange={(event) => updatePolicyNumber("leaseMinutes", event.target.value, 5, 120)} /></div><div className="min-w-0 space-y-2"><Label htmlFor="run-items">Werkitems per run</Label><Input id="run-items" type="number" min={1} max={20} value={policy.maxWorkItemsPerRun} onChange={(event) => updatePolicyNumber("maxWorkItemsPerRun", event.target.value, 1, 20)} /></div><div className="min-w-0 space-y-2"><Label htmlFor="run-minutes">Looptijd per run</Label><Input id="run-minutes" type="number" min={5} max={55} value={policy.maxRunMinutes} onChange={(event) => updatePolicyNumber("maxRunMinutes", event.target.value, 5, 55)} /></div><div className="min-w-0 space-y-2"><Label htmlFor="recovery-minutes">Max. recoveryduur</Label><Input id="recovery-minutes" type="number" min={15} max={1440} value={policy.maxRecoveryMinutes} onChange={(event) => updatePolicyNumber("maxRecoveryMinutes", event.target.value, 15, 1440)} /></div><div className="flex min-w-0 items-center justify-between gap-4 sm:col-span-2"><div className="min-w-0"><Label htmlFor="auto-brainstorm">Convergence-brainstorm</Label><p className="text-xs text-muted-foreground">Zoek na concrete taken nog naar echte gaten.</p></div><Switch id="auto-brainstorm" checked={policy.autoBrainstorm} onCheckedChange={(checked) => setPolicy((current) => ({ ...current, profile: "custom", autoBrainstorm: checked }))} /></div></div></details><Button variant="outline" className="w-full" onClick={() => void savePolicy()} disabled={saving || !policyDirty}>Beleid opslaan</Button></CardContent></Card><Card className="min-w-0"><CardHeader><CardTitle>Roadmap handmatig importeren</CardTitle><CardDescription>Alleen voor geavanceerd beheer; gewone opdrachten gaan automatisch naar de duurzame wachtrij.</CardDescription></CardHeader><CardContent className="space-y-4"><div className="min-w-0 space-y-2"><Label htmlFor="roadmap-title">Titel</Label><Input id="roadmap-title" value={roadmapTitle} onChange={(event) => setRoadmapTitle(event.target.value)} /></div><div className="min-w-0 space-y-2"><Label htmlFor="roadmap-markdown">Roadmap</Label><Textarea id="roadmap-markdown" value={roadmapMarkdown} onChange={(event) => { setRoadmapMarkdown(event.target.value); setConfirmRoadmapReplacement(false); }} rows={10} className="max-w-full min-w-0 font-mono text-sm" /></div>{confirmRoadmapReplacement ? <div role="alert" className="min-w-0 rounded-lg border border-amber-300 bg-amber-50/40 p-4"><p className="font-semibold">Bestaande roadmap en voortgang vervangen?</p><div className="mt-3 flex max-w-full flex-wrap gap-2"><Button variant="destructive" onClick={() => void importRoadmap()} disabled={saving}>Definitief vervangen</Button><Button variant="outline" onClick={() => setConfirmRoadmapReplaLine truncated
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Activity, AlertTriangle, ArrowRight, CheckCircle2, Clock3, Loader2, Ref
|
||||
import { Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui";
|
||||
import { realClient, type PortfolioRuntime } from "@/shared/api/realClient";
|
||||
import type { RuntimeJob, RuntimeRunner } from "@/shared/api/portfolioTypes";
|
||||
import { runtimeJobGroups, runtimeJobLabel, runtimeRunnerCapacity } from "@/shared/lib/activityRuntimePresentation";
|
||||
import { oldestRunnableRuntimeJob, runtimeJobGroups, runtimeJobLabel, runtimeRunnerCapacity } from "@/shared/lib/activityRuntimePresentation";
|
||||
import { RuntimeJobLane } from "./RuntimeJobLane";
|
||||
|
||||
const onlineRunner = (runner: RuntimeRunner) => !runner.disabled && (runner.status === "online" || runner.status === "observed");
|
||||
@@ -81,9 +81,27 @@ export const PortfolioExecutionPulse = () => {
|
||||
const groups = useMemo(() => runtimeJobGroups(runtime?.jobs ?? []), [runtime?.jobs]);
|
||||
const capacity = useMemo(() => runtimeRunnerCapacity(runtime?.runners ?? []), [runtime?.runners]);
|
||||
const runners = useMemo(() => [...(runtime?.runners ?? [])].sort((left, right) => Number(right.busy) - Number(left.busy) || Number(onlineRunner(right)) - Number(onlineRunner(left)) || left.name.localeCompare(right.name)), [runtime?.runners]);
|
||||
const nextJob = groups.runnable[0] ?? groups.dependencyBlocked[0];
|
||||
const oldestRunnableJob = oldestRunnableRuntimeJob(runtime?.jobs ?? []);
|
||||
const connectedAutoExecutors = runtime?.autonomy.executors?.filter((executor) => executor.status !== "stale")
|
||||
?? (runtime?.autonomy.executor && runtime.autonomy.executor.status !== "stale" ? [runtime.autonomy.executor] : []);
|
||||
const queuePreviewTitle = oldestRunnableJob
|
||||
? "Eerst getoond in wachtrij"
|
||||
: runtime?.counts.runnable
|
||||
? "Wachtende jobs buiten dit overzicht"
|
||||
: runtime?.counts.dependencyBlocked
|
||||
? "Geen job wacht op een runner"
|
||||
: connectedAutoExecutors.length
|
||||
? "Autonome uitvoerders verbonden"
|
||||
: "Geen job wacht op een runner";
|
||||
const queuePreviewDetail = oldestRunnableJob
|
||||
? `${oldestRunnableJob.repository ?? "Repository"} · ${oldestRunnableJob.name} · ${runtimeJobLabel(oldestRunnableJob)}`
|
||||
: runtime?.counts.runnable
|
||||
? `${runtime.counts.runnable} job(s) wachten op een runner, maar staan niet in dit overzicht.`
|
||||
: runtime?.counts.dependencyBlocked
|
||||
? `${runtime.counts.dependencyBlocked} job(s) wachten op een eerdere stap.`
|
||||
: connectedAutoExecutors.length
|
||||
? `${connectedAutoExecutors.length} Auto-uitvoerder(s) recent gezien · ${runtime?.autonomy.counts.pendingWorkItems ?? 0} open Auto-werkitems`
|
||||
: "Er staat geen job klaar om door een runner opgepakt te worden.";
|
||||
const executionMismatch = Boolean(runtime && runtime.counts.runnable > 0 && capacity.idle > 0);
|
||||
|
||||
return <Card className={runtime?.availability === "unavailable" || executionMismatch ? "border-amber-300" : "border-primary/25"}>
|
||||
@@ -135,7 +153,7 @@ export const PortfolioExecutionPulse = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 rounded-lg border bg-muted/20 p-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="min-w-0"><p className="font-semibold">{nextJob ? "Hierna" : connectedAutoExecutors.length ? "Autonome uitvoerders verbonden" : "Geen volgende job bevestigd"}</p><p className="mt-1 truncate text-sm text-muted-foreground">{nextJob ? `${nextJob.repository ?? "Repository"} · ${nextJob.name} · ${runtimeJobLabel(nextJob)}` : connectedAutoExecutors.length ? `${connectedAutoExecutors.length} Auto-uitvoerder(s) recent gezien · ${runtime.autonomy.counts.pendingWorkItems} open Auto-werkitems` : "ProjectBrain toont pas een volgende execution wanneer die uit de actuele runtime blijkt."}</p></div>
|
||||
<div className="min-w-0"><p className="font-semibold">{queuePreviewTitle}</p><p className="mt-1 truncate text-sm text-muted-foreground">{queuePreviewDetail}</p><p className="mt-1 text-xs text-muted-foreground">Gitea kiest welke geschikte job wordt uitgevoerd. Dit overzicht is alleen-lezen en toont de oudste aanmaaktijd eerst.</p></div>
|
||||
<Badge variant="secondary">{runtime.availability === "live" ? `Actueel · ${new Date(runtime.generatedAt).toLocaleTimeString("nl-BE", { hour: "2-digit", minute: "2-digit" })}` : runtime.availability}</Badge>
|
||||
</div>
|
||||
</> : null}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
runtimeJobLabel,
|
||||
runtimeQueueExplanation,
|
||||
runtimeRunnerCapacity,
|
||||
runtimeRunnerCapacityPresentation,
|
||||
} from "@/shared/lib/activityRuntimePresentation";
|
||||
import { projectEntryPath } from "@/shared/lib/routeRegistry";
|
||||
import { RuntimeJobCard, RuntimeJobLane } from "./RuntimeJobLane";
|
||||
@@ -108,6 +109,10 @@ export const PortfolioRuntimeDashboard = () => {
|
||||
const stalledProjects = autonomy?.counts.stalledProjects ?? 0;
|
||||
const stalledAutoProjects = (autonomy?.projects ?? []).filter((project) => project.stalled);
|
||||
const retryingProjects = (autonomy?.projects ?? []).filter((project) => project.blockerKind === "retryable").length;
|
||||
const claimHealth = runtime?.claimHealth;
|
||||
const runnerInventoryComplete = claimHealth?.runnerInventoryComplete === true;
|
||||
const capacityDisplay = runtimeRunnerCapacityPresentation(capacity, runnerInventoryComplete);
|
||||
const oldestPollingStallMinutes = Math.floor((claimHealth?.oldestClaimableWaitSeconds ?? 0) / 60);
|
||||
|
||||
return <Card id="portfolio-runners" className="scroll-mt-24">
|
||||
<CardHeader>
|
||||
@@ -156,12 +161,46 @@ export const PortfolioRuntimeDashboard = () => {
|
||||
<div className="rounded-lg border border-primary/25 bg-primary/5 p-3"><strong className="text-2xl">{runtime.counts.running}</strong><p className="text-xs text-muted-foreground">nu bezig</p></div>
|
||||
<div className="rounded-lg border p-3"><strong className="text-2xl">{runtime.counts.runnable}</strong><p className="text-xs text-muted-foreground">wacht op runner</p></div>
|
||||
<div className="rounded-lg border p-3"><strong className="text-2xl">{runtime.counts.dependencyBlocked}</strong><p className="text-xs text-muted-foreground">wacht op eerdere job</p></div>
|
||||
<div className="rounded-lg border p-3"><strong className="text-2xl">{capacity.idle}/{capacity.online}</strong><p className="text-xs text-muted-foreground">online runners vrij</p></div>
|
||||
<div className="rounded-lg border p-3"><strong className="text-2xl">{capacityDisplay.value}</strong><p className="text-xs text-muted-foreground">{capacityDisplay.label}</p></div>
|
||||
<div className="rounded-lg border p-3"><strong className="text-lg capitalize">{runtime.worker?.status ?? "niet actief"}</strong><p className="text-xs text-muted-foreground">analyseworker</p></div>
|
||||
</div>
|
||||
|
||||
<div className={`rounded-lg border p-3 text-sm ${runtime.counts.runnable ? "border-amber-300 bg-amber-50 text-amber-950" : "bg-muted/30 text-muted-foreground"}`}>
|
||||
{runtimeQueueExplanation(runtime.counts.runnable, runtime.counts.dependencyBlocked, capacity.idle)}
|
||||
{runtimeQueueExplanation(runtime.counts.runnable, runtime.counts.dependencyBlocked, capacity.idle, runnerInventoryComplete, capacity.online)}
|
||||
</div>
|
||||
|
||||
{claimHealth && !claimHealth.coverage.complete ? <div role="status" className="rounded-lg border border-amber-300 bg-amber-50/70 p-3 text-sm text-amber-950">
|
||||
<p className="font-semibold">Runner claim-health: scan onvolledig</p>
|
||||
<p className="mt-1 text-xs leading-5">{claimHealth.coverage.observed} queued jobs onderzocht{claimHealth.coverage.total === null ? "; het totaal is onbekend" : ` van ${claimHealth.coverage.total}`}. Bevindingen en wachttijden hieronder gelden alleen voor de onderzochte jobs; over de overige jobs is geen conclusie mogelijk.</p>
|
||||
</div> : null}
|
||||
|
||||
{claimHealth && !claimHealth.runnerInventoryComplete ? <div role="status" className="rounded-lg border border-amber-300 bg-amber-50/70 p-3 text-sm text-amber-950">
|
||||
<p className="font-semibold">Runner claim-health: runnerinventaris niet beschikbaar</p>
|
||||
<p className="mt-1 text-xs leading-5">Wachtende jobs zijn zichtbaar, maar labels en capaciteit kunnen niet betrouwbaar worden vastgesteld. Er wordt daarom geen labelmismatch of pollingstall afgeleid.</p>
|
||||
</div> : null}
|
||||
|
||||
{claimHealth?.counts.pollingStalled ? <div className="rounded-lg border border-amber-400 bg-amber-50 p-3 text-sm text-amber-950">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div><p className="flex items-center gap-2 font-semibold"><AlertTriangle size={15} />Runner claim-health: polling gestald</p>
|
||||
<p className="mt-1 text-xs leading-5">{claimHealth.coverage.complete ? "" : "Minstens "}{claimHealth.counts.pollingStalled} wachtende {claimHealth.counts.pollingStalled === 1 ? "job heeft" : "jobs hebben"} een compatibele vrije runner maar {claimHealth.counts.pollingStalled === 1 ? "wordt" : "worden"} niet geclaimd. Langste waargenomen claimbare wachttijd: {oldestPollingStallMinutes} min.</p></div>
|
||||
<Badge variant="secondary">Herstel: AppOps runner fleet</Badge>
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-5">ProjectBrain detecteert de stall maar herstart geen nog draaiende runner rechtstreeks. Capaciteit, runnerstart/stop en herstel blijven bij de governed AppOps runner-fleetcontroller.</p>
|
||||
</div> : claimHealth?.counts.labelBlocked ? <div className="rounded-lg border border-amber-300 bg-amber-50/70 p-3 text-sm text-amber-950">
|
||||
<p className="font-semibold">Runner claim-health: geen labelmatch</p>
|
||||
<p className="mt-1 text-xs leading-5">{claimHealth.coverage.complete ? "" : "Minstens "}{claimHealth.counts.labelBlocked} wachtende {claimHealth.counts.labelBlocked === 1 ? "job heeft" : "jobs hebben"} geen compatibele online runner. Vergelijk de vereiste joblabels met de runnerlabels hieronder.</p>
|
||||
</div> : null}
|
||||
|
||||
<div className="rounded-lg border bg-background p-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">Volgorde vóór dispatch beheren</p>
|
||||
<p className="mt-1 text-xs leading-5 text-muted-foreground">Gitea-jobs die hier al als <strong>wacht op runner</strong> staan zijn downstream en alleen-lezen. Versleep de ProjectBrain-wachtrij in Auto Mode om te bepalen welk klaar werk als volgende wordt aangeboden; dependencies blijven altijd leidend.</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap gap-2">
|
||||
{(autonomy?.projects ?? []).filter((project) => project.enabled && project.pendingWorkCount > 0).slice(0, 3).map((project) => <Button key={project.projectId} variant="outline" size="sm" asChild><Link to={`/projects/${project.projectSlug}/auto`}>{project.projectName} wachtrij</Link></Button>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -196,7 +235,7 @@ export const PortfolioRuntimeDashboard = () => {
|
||||
</div>
|
||||
{!activeTotal ? <p className="flex items-center gap-2 rounded-lg bg-muted/40 p-3 text-sm text-muted-foreground"><CheckCircle2 size={15} /> Er zijn geen actieve of wachtende jobs.</p> : null}
|
||||
<RuntimeJobLane state="running" jobs={groups.running} defaultExpanded />
|
||||
<RuntimeJobLane state="runnable" jobs={groups.runnable} />
|
||||
<RuntimeJobLane state="runnable" jobs={groups.runnable} defaultExpanded />
|
||||
<RuntimeJobLane state="dependency-blocked" jobs={groups.dependencyBlocked} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ChevronDown, CircleDot, Clock3, ExternalLink, PauseCircle } from "lucid
|
||||
import { Badge } from "@/components/ui";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import type { RuntimeJob, RuntimeQueueState } from "@/shared/api/portfolioTypes";
|
||||
import { runtimeJobDetail, runtimeJobLabel, runtimeQueueLabel } from "@/shared/lib/activityRuntimePresentation";
|
||||
import { runtimeJobDetail, runtimeJobLabel, runtimeJobTimingLabel, runtimeQueueLabel } from "@/shared/lib/activityRuntimePresentation";
|
||||
|
||||
const failureStatuses = new Set(["failed", "failure", "denied", "cancelled"]);
|
||||
|
||||
@@ -19,6 +19,7 @@ export const RuntimeJobCard = ({ job }: { job: RuntimeJob }) => {
|
||||
const progress = job.totalSteps > 0
|
||||
? Math.max(0, Math.min(100, job.completedSteps / job.totalSteps * 100))
|
||||
: 0;
|
||||
const timing = runtimeJobTimingLabel(job);
|
||||
|
||||
return <a href={job.url} target="_blank" rel="noreferrer" className="block min-w-0 rounded-lg border p-3 transition hover:border-primary/40 hover:bg-muted/30">
|
||||
<div className="flex min-w-0 flex-col items-start gap-2 sm:flex-row sm:justify-between">
|
||||
@@ -28,7 +29,8 @@ export const RuntimeJobCard = ({ job }: { job: RuntimeJob }) => {
|
||||
</div>
|
||||
<Badge className="max-w-full shrink-0 self-start" variant={failed ? "destructive" : job.queueState === "running" ? "default" : "secondary"}>{label}</Badge>
|
||||
</div>
|
||||
<p className="mt-2 min-w-0 break-words text-xs text-muted-foreground [overflow-wrap:anywhere]">{runtimeJobDetail(job)}{job.totalSteps > 0 ? ` · ${job.completedSteps}/${job.totalSteps} stappen` : ""}</p>
|
||||
<p className="mt-2 min-w-0 break-words text-xs text-muted-foreground [overflow-wrap:anywhere]">{runtimeJobDetail(job)}{job.totalSteps > 0 ? ` · ${job.completedSteps}/${job.totalSteps} stappen` : ""}{timing ? ` · ${timing}` : ""}</p>
|
||||
{job.labels?.length ? <div className="mt-2 flex min-w-0 flex-wrap gap-1" aria-label="Vereiste runnerlabels">{job.labels.map((label) => <Badge key={label} variant="outline" className="max-w-full whitespace-normal text-[10px] font-normal [overflow-wrap:anywhere]">{label}</Badge>)}</div> : null}
|
||||
{job.queueState === "running" && job.totalSteps > 0 ? <div className="mt-3 space-y-1">
|
||||
<Progress value={progress} className="h-1.5" aria-label={`Voortgang ${job.name}`} />
|
||||
</div> : null}
|
||||
|
||||
@@ -14,6 +14,7 @@ export type AutoModeItem = {
|
||||
createdAt?: string;
|
||||
status: AutoModeItemStatus;
|
||||
attempts: number;
|
||||
manualOrder?: number;
|
||||
scope?: "required" | "committed" | "stretch" | "future";
|
||||
risk?: "low" | "normal" | "medium" | "high" | "critical";
|
||||
releaseBlocking?: boolean;
|
||||
@@ -69,5 +70,9 @@ export const autonomyApi = {
|
||||
(await httpClient.post<AutoModeState>(`/api/projects/${encodeURIComponent(projectId)}/autonomy/roadmap`, input)).data,
|
||||
enqueueTask: async (projectId: string, input: { title?: string; instruction: string }) =>
|
||||
(await httpClient.post<{ state: AutoModeState; task: AutoModeItem; autoModeEnabled: boolean }>(`/api/projects/${encodeURIComponent(projectId)}/autonomy/tasks`, input)).data,
|
||||
reorderQueue: async (projectId: string, itemIds: string[]) =>
|
||||
(await httpClient.put<AutoModeState>(`/api/projects/${encodeURIComponent(projectId)}/autonomy/queue`, { itemIds })).data,
|
||||
resetQueueOrder: async (projectId: string) =>
|
||||
(await httpClient.delete<AutoModeState>(`/api/projects/${encodeURIComponent(projectId)}/autonomy/queue`)).data,
|
||||
resume: async (projectId: string) => (await httpClient.post<AutoModeState>(`/api/projects/${encodeURIComponent(projectId)}/autonomy/resume`)).data,
|
||||
};
|
||||
@@ -177,6 +177,7 @@ export type RuntimeJob = {
|
||||
queueState: RuntimeQueueState;
|
||||
conclusion?: string;
|
||||
runnerName?: string;
|
||||
labels?: string[];
|
||||
currentStep?: string;
|
||||
completedSteps: number;
|
||||
totalSteps: number;
|
||||
@@ -186,6 +187,43 @@ export type RuntimeJob = {
|
||||
completedAt?: string;
|
||||
};
|
||||
|
||||
export type RunnerClaimDiagnosis = {
|
||||
jobId: number;
|
||||
jobName: string;
|
||||
repository?: string;
|
||||
waitSeconds: number;
|
||||
claimableWaitSeconds: number;
|
||||
state: "claimable" | "capacity-wait" | "label-blocked" | "polling-stalled" | "unknown";
|
||||
requiredLabels: string[];
|
||||
compatibleRunnerNames: string[];
|
||||
compatibleIdleRunnerNames: string[];
|
||||
};
|
||||
|
||||
export type RunnerClaimHealth = {
|
||||
status: "healthy" | "degraded" | "stalled" | "incomplete";
|
||||
thresholdSeconds: number;
|
||||
generatedAt: string;
|
||||
oldestWaitSeconds: number;
|
||||
oldestClaimableWaitSeconds: number;
|
||||
coverage: {
|
||||
complete: boolean;
|
||||
observed: number;
|
||||
total: number | null;
|
||||
};
|
||||
runnerInventoryComplete: boolean;
|
||||
recoveryOwner: "appops-runner-fleet";
|
||||
recoveryPolicy: "controller-owned";
|
||||
counts: {
|
||||
queued: number;
|
||||
claimable: number;
|
||||
capacityWait: number;
|
||||
labelBlocked: number;
|
||||
pollingStalled: number;
|
||||
unknown: number;
|
||||
};
|
||||
jobs: RunnerClaimDiagnosis[];
|
||||
};
|
||||
|
||||
export type AutonomyExecutorRuntime = {
|
||||
status: "idle" | "working" | "degraded" | "stale";
|
||||
instanceId: string;
|
||||
@@ -228,6 +266,7 @@ export type PortfolioRuntime = {
|
||||
generatedAt: string;
|
||||
stateVersion: string;
|
||||
warning?: string;
|
||||
claimHealth?: RunnerClaimHealth;
|
||||
pollAfterMs: number;
|
||||
worker?: { status: string; activeJobId?: string; detail?: string; lastSeenAt: string };
|
||||
autonomy: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { RuntimeJob, RuntimeRunner } from "@/shared/api/portfolioTypes";
|
||||
import { getActivityOperationSections, latestCompletedRuntimeJobByRunner, prioritizeRuntimeJobs, runtimeJobGroups, runtimeJobLabel, runtimeQueueExplanation, runtimeRunnerCapacity } from "@/shared/lib/activityRuntimePresentation";
|
||||
import { getActivityOperationSections, latestCompletedRuntimeJobByRunner, oldestRunnableRuntimeJob, prioritizeRuntimeJobs, runtimeCompatibleIdleRunnerCount, runtimeJobAgeMinutes, runtimeJobDetail, runtimeJobGroups, runtimeJobLabel, runtimeJobTimingLabel, runtimeQueueExplanation, runtimeRunnerCapacity, runtimeRunnerCapacityPresentation } from "@/shared/lib/activityRuntimePresentation";
|
||||
|
||||
const job = (queueState: RuntimeJob["queueState"], input: Partial<RuntimeJob> = {}): RuntimeJob => ({
|
||||
id: 1,
|
||||
@@ -22,11 +22,83 @@ describe("activity runtime presentation", () => {
|
||||
expect(prioritizeRuntimeJobs([job("completed", { id: 9 }), job("dependency-blocked", { id: 8 }), job("runnable", { id: 7 }), job("running", { id: 6 })]).map((item) => item.id)).toEqual([6, 7, 8, 9]);
|
||||
});
|
||||
|
||||
it("orders queued work by actual waiting age rather than newest id", () => {
|
||||
const ordered = prioritizeRuntimeJobs([
|
||||
job("runnable", { id: 9, createdAt: "2026-09-01T12:09:00Z" }),
|
||||
job("runnable", { id: 7, createdAt: "2026-09-01T12:03:00Z" }),
|
||||
job("runnable", { id: 8, createdAt: "2026-09-01T12:06:00Z" }),
|
||||
]);
|
||||
expect(ordered.map((item) => item.id)).toEqual([7, 8, 9]);
|
||||
});
|
||||
|
||||
it("uses oldest creation time, ascending id ties, and unknown timestamps last for queued jobs", () => {
|
||||
const ordered = prioritizeRuntimeJobs([
|
||||
job("runnable", { id: 9, createdAt: "2026-09-01T12:03:00Z" }),
|
||||
job("runnable", { id: 4, createdAt: "2026-09-01T12:03:00Z" }),
|
||||
job("runnable", { id: 7, createdAt: "2026-09-01T12:06:00Z" }),
|
||||
job("runnable", { id: 2, createdAt: "not-a-timestamp" }),
|
||||
]);
|
||||
|
||||
expect(ordered.map((item) => item.id)).toEqual([4, 9, 7, 2]);
|
||||
});
|
||||
|
||||
it("does not present dependency-blocked work as the oldest runnable job", () => {
|
||||
const blocked = job("dependency-blocked", { id: 5, status: "requested", createdAt: "2026-09-01T12:00:00Z" });
|
||||
expect(runtimeJobLabel(blocked)).toBe("Wacht op eerdere job");
|
||||
expect(runtimeJobDetail(blocked)).toBe("Start zodra de vereiste eerdere job is afgerond");
|
||||
expect(oldestRunnableRuntimeJob([
|
||||
blocked,
|
||||
job("dependency-blocked", { id: 4, createdAt: "2026-09-01T11:00:00Z" }),
|
||||
])).toBeUndefined();
|
||||
});
|
||||
|
||||
it("selects the oldest visible runnable job while ignoring dependency-blocked jobs", () => {
|
||||
const selected = oldestRunnableRuntimeJob([
|
||||
job("dependency-blocked", { id: 1, createdAt: "2026-09-01T11:00:00Z" }),
|
||||
job("runnable", { id: 9, createdAt: "2026-09-01T12:09:00Z" }),
|
||||
job("runnable", { id: 4, createdAt: "2026-09-01T12:03:00Z" }),
|
||||
]);
|
||||
|
||||
expect(selected?.id).toBe(4);
|
||||
});
|
||||
|
||||
it("reports waiting age and compatible idle runner capacity", () => {
|
||||
const queued = job("runnable", { labels: ["linux-validation"], createdAt: "2026-09-01T12:00:00Z" });
|
||||
const runners: RuntimeRunner[] = [
|
||||
{ id: 1, name: "validation", status: "online", busy: false, disabled: false, labels: ["linux-validation"] },
|
||||
{ id: 2, name: "security", status: "online", busy: false, disabled: false, labels: ["security"] },
|
||||
{ id: 3, name: "busy-validation", status: "online", busy: true, disabled: false, labels: ["linux-validation"] },
|
||||
];
|
||||
const now = Date.parse("2026-09-01T12:07:30Z");
|
||||
expect(runtimeJobAgeMinutes(queued, now)).toBe(7);
|
||||
expect(runtimeJobTimingLabel(queued, now)).toBe("Wacht 7 min");
|
||||
expect(runtimeCompatibleIdleRunnerCount(queued, runners)).toBe(1);
|
||||
expect(runtimeCompatibleIdleRunnerCount(job("runnable", { labels: ["missing"] }), runners)).toBe(0);
|
||||
});
|
||||
|
||||
it("never presents a queued job as completed", () => {
|
||||
expect(runtimeJobLabel(job("runnable"))).toBe("Wacht op runner");
|
||||
expect(runtimeQueueExplanation(38, 0, 0)).toContain("nog geen afgeronde jobs");
|
||||
});
|
||||
|
||||
it("keeps capacity unknown when runner discovery is incomplete", () => {
|
||||
const display = runtimeRunnerCapacityPresentation(runtimeRunnerCapacity([]), false);
|
||||
expect(display).toEqual({ value: "?", label: "runnercapaciteit onbekend" });
|
||||
const explanation = runtimeQueueExplanation(2, 0, 0, false);
|
||||
expect(explanation).toContain("capaciteit is onbekend");
|
||||
expect(explanation).not.toContain("alle online runners zijn momenteel bezig");
|
||||
});
|
||||
|
||||
it("does not describe a known-empty runner inventory as busy capacity", () => {
|
||||
const explanation = runtimeQueueExplanation(2, 0, 0, true, 0);
|
||||
expect(explanation).toContain("geen online runners beschikbaar");
|
||||
expect(explanation).not.toContain("alle online runners zijn momenteel bezig");
|
||||
});
|
||||
|
||||
it("describes positive online capacity with no idle runners as busy", () => {
|
||||
expect(runtimeQueueExplanation(2, 0, 0, true, 3)).toContain("alle online runners zijn momenteel bezig");
|
||||
});
|
||||
|
||||
it("explains persistent queueing when free runners exist", () => {
|
||||
expect(runtimeQueueExplanation(2, 0, 1)).toContain("1 vrije runner");
|
||||
expect(runtimeQueueExplanation(2, 0, 2)).toContain("joblabels");
|
||||
|
||||
@@ -62,9 +62,68 @@ export const getActivityOperationSections = <T extends RuntimeJob>(jobs: T[]) =>
|
||||
};
|
||||
};
|
||||
|
||||
const activeRuntimeJobTimestamp = (job: RuntimeJob) => {
|
||||
const value = job.queueState === "running" ? job.startedAt ?? job.createdAt : job.createdAt;
|
||||
const timestamp = Date.parse(value);
|
||||
return Number.isFinite(timestamp) && timestamp > 0 ? timestamp : Number.MAX_SAFE_INTEGER;
|
||||
};
|
||||
|
||||
const runnableJobCreatedAtTimestamp = (job: RuntimeJob) => {
|
||||
const timestamp = Date.parse(job.createdAt);
|
||||
return Number.isFinite(timestamp) && timestamp > 0 ? timestamp : Number.MAX_SAFE_INTEGER;
|
||||
};
|
||||
|
||||
export const prioritizeRuntimeJobs = <T extends RuntimeJob>(jobs: T[]): T[] => {
|
||||
const rank = { running: 0, runnable: 1, "dependency-blocked": 2, completed: 3 };
|
||||
return [...jobs].sort((a, b) => rank[a.queueState] - rank[b.queueState] || b.id - a.id);
|
||||
return [...jobs].sort((a, b) => {
|
||||
const rankDifference = rank[a.queueState] - rank[b.queueState];
|
||||
if (rankDifference) return rankDifference;
|
||||
if (a.queueState === "runnable") {
|
||||
return runnableJobCreatedAtTimestamp(a) - runnableJobCreatedAtTimestamp(b) || a.id - b.id;
|
||||
}
|
||||
const timeDifference = activeRuntimeJobTimestamp(a) - activeRuntimeJobTimestamp(b);
|
||||
return a.queueState === "completed"
|
||||
? (timeDifference ? -timeDifference : b.id - a.id)
|
||||
: (timeDifference || b.id - a.id);
|
||||
});
|
||||
};
|
||||
|
||||
export const oldestRunnableRuntimeJob = <T extends RuntimeJob>(jobs: T[]): T | undefined =>
|
||||
prioritizeRuntimeJobs(jobs.filter((job) => job.queueState === "runnable"))[0];
|
||||
|
||||
const normalizedRunnerLabel = (label: string) => label.trim().toLowerCase();
|
||||
|
||||
export const runtimeCompatibleIdleRunnerCount = (job: RuntimeJob, runners: RuntimeRunner[]) => {
|
||||
const required = (job.labels ?? []).map(normalizedRunnerLabel).filter(Boolean);
|
||||
return runners.filter((runner) => {
|
||||
if (runner.disabled || runner.busy || (runner.status !== "online" && runner.status !== "observed")) return false;
|
||||
if (!required.length) return true;
|
||||
const available = new Set(runner.labels.map(normalizedRunnerLabel).filter(Boolean));
|
||||
return required.every((label) => available.has(label));
|
||||
}).length;
|
||||
};
|
||||
|
||||
export const runtimeJobAgeMinutes = (job: RuntimeJob, now = Date.now()) => {
|
||||
const timestamp = activeRuntimeJobTimestamp(job);
|
||||
if (!Number.isFinite(timestamp) || timestamp === Number.MAX_SAFE_INTEGER || timestamp > now) return undefined;
|
||||
return Math.max(0, Math.floor((now - timestamp) / 60_000));
|
||||
};
|
||||
|
||||
const durationLabel = (minutes: number) => {
|
||||
if (minutes < 1) return "< 1 min";
|
||||
if (minutes < 60) return `${minutes} min`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remainder = minutes % 60;
|
||||
return remainder ? `${hours}u ${remainder}m` : `${hours}u`;
|
||||
};
|
||||
|
||||
export const runtimeJobTimingLabel = (job: RuntimeJob, now = Date.now()) => {
|
||||
const minutes = runtimeJobAgeMinutes(job, now);
|
||||
if (minutes === undefined) return undefined;
|
||||
if (job.queueState === "runnable") return `Wacht ${durationLabel(minutes)}`;
|
||||
if (job.queueState === "dependency-blocked") return `Geblokkeerd ${durationLabel(minutes)}`;
|
||||
if (job.queueState === "running") return `Bezig ${durationLabel(minutes)}`;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const runtimeRunnerCapacity = (runners: RuntimeRunner[]) => {
|
||||
@@ -81,7 +140,26 @@ export const runtimeRunnerCapacity = (runners: RuntimeRunner[]) => {
|
||||
};
|
||||
};
|
||||
|
||||
export const runtimeQueueExplanation = (queued: number, dependencyBlocked: number, idleRunners: number) => {
|
||||
export const runtimeRunnerCapacityPresentation = (
|
||||
capacity: ReturnType<typeof runtimeRunnerCapacity>,
|
||||
inventoryComplete: boolean,
|
||||
) => inventoryComplete
|
||||
? { value: `${capacity.idle}/${capacity.online}`, label: "online runners vrij" }
|
||||
: { value: "?", label: "runnercapaciteit onbekend" };
|
||||
|
||||
export const runtimeQueueExplanation = (
|
||||
queued: number,
|
||||
dependencyBlocked: number,
|
||||
idleRunners: number,
|
||||
runnerInventoryComplete = true,
|
||||
onlineRunners?: number,
|
||||
) => {
|
||||
if (queued > 0 && !runnerInventoryComplete) {
|
||||
return `${queued} ${queued === 1 ? "job wacht" : "jobs wachten"} op een runner; de runnerinventaris is niet beschikbaar, dus vrije capaciteit is onbekend.`;
|
||||
}
|
||||
if (queued > 0 && onlineRunners === 0) {
|
||||
return `${queued} ${queued === 1 ? "job wacht" : "jobs wachten"} op een runner; er zijn momenteel geen online runners beschikbaar. Dit zijn nog geen afgeronde jobs.`;
|
||||
}
|
||||
if (queued > 0 && idleRunners === 0) {
|
||||
return `${queued} ${queued === 1 ? "job wacht" : "jobs wachten"} op een runner; alle online runners zijn momenteel bezig. Dit zijn nog geen afgeronde jobs.`;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user