[taskboard] add dispatch control plane

This commit is contained in:
2026-03-06 15:21:19 -08:00
parent 1699f0f2b7
commit be1cf8ca8d
25 changed files with 1594 additions and 292 deletions

View File

@@ -1,28 +1,43 @@
"use client";
import { useMemo, useState } from "react";
import { useDeferredValue, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import type { FleetAgent } from "@/lib/types";
import type { AgentFamily, FleetAgent } from "@/lib/types";
export function AgentsClient({ agents }: { agents: FleetAgent[] }) {
function heartbeatTone(agent: FleetAgent) {
if (agent.heartbeatAgeMinutes === null) {
return "warning";
}
if (agent.heartbeatAgeMinutes > 180) {
return "warning";
}
return "success";
}
export function AgentsClient({
agents,
defaultFamily = "",
}: {
agents: FleetAgent[];
defaultFamily?: AgentFamily | "";
}) {
const [query, setQuery] = useState("");
const [family, setFamily] = useState("");
const [family, setFamily] = useState<AgentFamily | "">(defaultFamily);
const deferredQuery = useDeferredValue(query);
const filteredAgents = useMemo(() => {
return agents.filter((agent) => {
const matchesQuery =
query.length === 0 ||
agent.name.toLowerCase().includes(query.toLowerCase()) ||
agent.host.toLowerCase().includes(query.toLowerCase()) ||
agent.role.toLowerCase().includes(query.toLowerCase());
const matchesFamily = family.length === 0 || agent.family === family;
return matchesQuery && matchesFamily;
});
}, [agents, family, query]);
const filteredAgents = agents.filter((agent) => {
const matchesQuery =
deferredQuery.length === 0 ||
agent.name.toLowerCase().includes(deferredQuery.toLowerCase()) ||
agent.host.toLowerCase().includes(deferredQuery.toLowerCase()) ||
agent.role.toLowerCase().includes(deferredQuery.toLowerCase());
const matchesFamily = family.length === 0 || agent.family === family;
return matchesQuery && matchesFamily;
});
return (
<div className="space-y-6">
@@ -30,12 +45,16 @@ export function AgentsClient({ agents }: { agents: FleetAgent[] }) {
<CardHeader>
<CardTitle>Configured Agent Runtimes</CardTitle>
<CardDescription>
OpenClaw swarm members and ZeroClaw host runtimes are shown from the deployed fleet model.
OpenClaw swarm members and ZeroClaw host runtimes from the tracked fleet model with heartbeat and dispatch overlays.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-[1fr_220px]">
<Input placeholder="Search by name, host, or role" value={query} onChange={(event) => setQuery(event.target.value)} />
<Select value={family} onChange={(event) => setFamily(event.target.value)}>
<Input
placeholder="Search by name, host, or role"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<Select value={family} onChange={(event) => setFamily(event.target.value as AgentFamily | "")}>
<option value="">All families</option>
<option value="openclaw">OpenClaw</option>
<option value="zeroclaw">ZeroClaw</option>
@@ -71,18 +90,30 @@ export function AgentsClient({ agents }: { agents: FleetAgent[] }) {
<dt className="text-xs uppercase tracking-[0.2em] text-slate-500">Model</dt>
<dd>{agent.model || "Host-local/runtime-defined"}</dd>
</div>
<div className="md:col-span-2">
<dt className="text-xs uppercase tracking-[0.2em] text-slate-500">Runtime</dt>
<dd className="font-mono text-xs text-cyan-100">{agent.runtimePath}</dd>
<div>
<dt className="text-xs uppercase tracking-[0.2em] text-slate-500">Dispatch</dt>
<dd>{agent.defaultDispatchMethod}</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-[0.2em] text-slate-500">Workload</dt>
<dd>{agent.workload} active</dd>
</div>
<div className="md:col-span-2">
<dt className="text-xs uppercase tracking-[0.2em] text-slate-500">Runtime</dt>
<dd className="font-mono text-xs text-cyan-100">{agent.runtimePath}</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-[0.2em] text-slate-500">Current task</dt>
<dd>{agent.currentTask || "No heartbeat task"}</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-[0.2em] text-slate-500">Heartbeat</dt>
<dd>
<Badge variant={heartbeatTone(agent)}>
{agent.heartbeatAgeMinutes === null ? "No heartbeat" : `${agent.heartbeatAgeMinutes}m ago`}
</Badge>
</dd>
</div>
</dl>
<div>
@@ -96,10 +127,36 @@ export function AgentsClient({ agents }: { agents: FleetAgent[] }) {
</div>
</div>
<div>
<p className="mb-2 text-xs uppercase tracking-[0.2em] text-slate-500">Last dispatch event</p>
{agent.lastEvent ? (
<div className="rounded-xl border border-white/10 bg-slate-950/40 p-3">
<p className="font-medium text-white">{agent.lastEvent.summary}</p>
<p className="mt-1 text-sm text-slate-300">{agent.lastEvent.detail || "No detail captured."}</p>
<div className="mt-2 flex flex-wrap gap-2">
<Badge variant={agent.lastEvent.event_type === "dispatch_failed" ? "warning" : "secondary"}>
{agent.lastEvent.event_type}
</Badge>
<Badge variant="outline">Failures: {agent.failureStreak}</Badge>
</div>
</div>
) : (
<p className="text-sm text-slate-400">No audit events recorded yet.</p>
)}
</div>
<div>
<p className="mb-2 text-xs uppercase tracking-[0.2em] text-slate-500">Tools</p>
<div className="flex flex-wrap gap-2">
{agent.tools.length ? agent.tools.map((tool) => <Badge key={tool} variant="secondary">{tool}</Badge>) : <span className="text-sm text-slate-400">No parsed tools.</span>}
{agent.tools.length ? (
agent.tools.map((tool) => (
<Badge key={tool} variant="secondary">
{tool}
</Badge>
))
) : (
<span className="text-sm text-slate-400">No parsed tools.</span>
)}
</div>
</div>
</CardContent>

View File

@@ -1,11 +1,14 @@
import Link from "next/link";
import { Network, NotebookTabs, PanelsTopLeft, ScrollText, Settings2, UsersRound } from "lucide-react";
import { Network, NotebookTabs, PanelsTopLeft, ScrollText, Send, Settings2, ShieldEllipsis, UsersRound } from "lucide-react";
import { cn } from "@/lib/utils";
const navItems = [
{ href: "/tasks", label: "Tasks", icon: PanelsTopLeft },
{ href: "/agents", label: "Agents", icon: UsersRound },
{ href: "/openclaw", label: "OpenClaw", icon: ShieldEllipsis },
{ href: "/zeroclaw", label: "ZeroClaw", icon: Send },
{ href: "/dispatch", label: "Dispatch", icon: Send },
{ href: "/architecture", label: "Architecture", icon: Network },
{ href: "/wiki", label: "Wiki", icon: NotebookTabs },
{ href: "/usage", label: "Usage", icon: ScrollText },

View File

@@ -0,0 +1,73 @@
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import type { TaskEvent, TaskRecord } from "@/lib/types";
export function DispatchHistory({
events,
failedTasks,
}: {
events: TaskEvent[];
failedTasks: TaskRecord[];
}) {
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Dispatch History</CardTitle>
<CardDescription>
Every dispatch request, success, failure, and acknowledgement recorded by the control plane.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{events.map((event) => (
<div className="rounded-xl border border-white/10 bg-slate-950/40 p-4" key={event.id}>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="font-medium text-white">{event.summary}</p>
<p className="text-sm text-slate-400">Task #{event.task_id} {event.assignee || "unassigned"} {event.host || "n/a"}</p>
</div>
<div className="flex gap-2">
<Badge variant={event.family === "zeroclaw" ? "success" : "default"}>
{event.family || "manual"}
</Badge>
<Badge variant={event.event_type === "dispatch_failed" ? "warning" : "secondary"}>
{event.event_type}
</Badge>
</div>
</div>
{event.detail ? <p className="mt-2 text-sm text-slate-300">{event.detail}</p> : null}
<p className="mt-2 text-xs uppercase tracking-[0.2em] text-slate-500">{event.created_at}</p>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Failure Queue</CardTitle>
<CardDescription>
Tasks with a failed dispatch state that still require operator review or retry.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{failedTasks.length === 0 ? (
<p className="text-sm text-slate-400">No failed dispatches recorded.</p>
) : (
failedTasks.map((task) => (
<div className="rounded-xl border border-amber-400/20 bg-amber-400/5 p-4" key={task.id}>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="font-medium text-white">{task.title}</p>
<p className="text-sm text-slate-400">{task.assignee || "Unassigned"} {task.target_host || "n/a"}</p>
</div>
<Badge variant="warning">{task.dispatch_state}</Badge>
</div>
<p className="mt-2 text-sm text-slate-300">{task.last_error || "No error text captured."}</p>
</div>
))
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -8,35 +8,93 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import type { FleetAgent, TaskPriority, TaskRecord, TaskStatus } from "@/lib/types";
import type {
FleetAgent,
TaskEvent,
TaskPriority,
TaskRecord,
TaskStatus,
TaskTemplate,
} from "@/lib/types";
const COLUMNS: TaskStatus[] = ["Backlog", "Todo", "In Progress", "Review", "Done"];
const PRIORITIES: TaskPriority[] = ["Low", "Medium", "High", "Critical"];
function familyVariant(family: string | null) {
return family === "zeroclaw" ? "success" : "default";
}
function dispatchVariant(state: TaskRecord["dispatch_state"]) {
return state === "failed" ? "warning" : state === "completed" ? "success" : "secondary";
}
export function TasksClient({
initialTasks,
initialEvents,
agents,
templates,
}: {
initialTasks: TaskRecord[];
initialEvents: TaskEvent[];
agents: FleetAgent[];
templates: TaskTemplate[];
}) {
const [tasks, setTasks] = useState(initialTasks);
const [events, setEvents] = useState(initialEvents);
const [formState, setFormState] = useState({
templateKey: "",
title: "",
description: "",
assignee: "",
priority: "Medium" as TaskPriority,
tags: "",
repoSlug: "",
baseBranch: "main",
preferredAgent: "codex",
reasoningEffort: "high",
modelHint: "",
});
async function refreshTasks() {
const response = await fetch("/api/tasks");
const nextTasks = (await response.json()) as TaskRecord[];
setTasks(nextTasks);
const selectedTemplate = templates.find((template) => template.key === formState.templateKey) || null;
const selectedAgent = agents.find((agent) => agent.assignmentKey === formState.assignee) || null;
const failedTasks = tasks.filter((task) => task.dispatch_state === "failed");
async function refreshData() {
const [taskResponse, eventResponse] = await Promise.all([
fetch("/api/tasks"),
fetch("/api/dispatch-history"),
]);
setTasks((await taskResponse.json()) as TaskRecord[]);
setEvents((await eventResponse.json()) as TaskEvent[]);
}
function applyTemplate(templateKey: string) {
const template = templates.find((entry) => entry.key === templateKey) || null;
if (!template) {
setFormState((current) => ({ ...current, templateKey }));
return;
}
setFormState((current) => ({
...current,
templateKey,
title: current.title || template.title,
priority: template.defaults.priority,
tags: template.tags.join(", "),
repoSlug: template.defaults.repoSlug || current.repoSlug,
baseBranch: template.defaults.baseBranch || current.baseBranch,
preferredAgent: template.defaults.preferredAgent || current.preferredAgent,
reasoningEffort: template.defaults.reasoningEffort || current.reasoningEffort,
}));
}
async function createTask(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const tags = formState.tags
.split(",")
.map((tag) => tag.trim())
.filter(Boolean);
await fetch("/api/tasks", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -45,90 +103,206 @@ export function TasksClient({
description: formState.description,
assignee: formState.assignee,
priority: formState.priority,
tags: formState.tags
.split(",")
.map((tag) => tag.trim())
.filter(Boolean),
tags,
template_key: formState.templateKey || null,
repo_slug: formState.repoSlug || null,
base_branch: formState.baseBranch || null,
preferred_agent: formState.preferredAgent || null,
reasoning_effort: formState.reasoningEffort || null,
model_hint: formState.modelHint || null,
family: selectedAgent?.family || selectedTemplate?.family || null,
target_host: selectedAgent?.host || selectedTemplate?.defaults.targetHost || "",
target_channel: selectedAgent?.channels[0]?.value || selectedTemplate?.defaults.targetChannel || "",
dispatch_method: selectedAgent?.defaultDispatchMethod || selectedTemplate?.defaults.dispatchMethod || "manual",
}),
});
setFormState({
templateKey: "",
title: "",
description: "",
assignee: "",
priority: "Medium",
tags: "",
repoSlug: "",
baseBranch: "main",
preferredAgent: "codex",
reasoningEffort: "high",
modelHint: "",
});
await refreshTasks();
await refreshData();
}
async function moveToDone(taskId: number) {
async function patchTask(taskId: number, payload: Partial<TaskRecord>) {
await fetch(`/api/tasks/${taskId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: "Done" }),
body: JSON.stringify(payload),
});
await refreshTasks();
await refreshData();
}
async function dispatchTask(taskId: number) {
await fetch(`/api/tasks/${taskId}/dispatch`, { method: "POST" });
await refreshData();
}
async function acknowledgeTask(taskId: number) {
await fetch(`/api/tasks/${taskId}/ack`, { method: "POST" });
await refreshData();
}
return (
<div className="space-y-6">
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.2fr)_360px]">
<Card>
<CardHeader>
<CardTitle>Unified Task Intake</CardTitle>
<CardDescription>
Create typed tasks, apply dispatch templates, and route work to OpenClaw or ZeroClaw.
</CardDescription>
</CardHeader>
<CardContent>
<form className="grid gap-3 md:grid-cols-2" onSubmit={createTask}>
<Select value={formState.templateKey} onChange={(event) => applyTemplate(event.target.value)}>
<option value="">Select template</option>
{templates.map((template) => (
<option key={template.key} value={template.key}>
{template.title} {template.family}
</option>
))}
</Select>
<Select
value={formState.assignee}
onChange={(event) => setFormState((current) => ({ ...current, assignee: event.target.value }))}
>
<option value="">Select agent</option>
{agents.map((agent) => (
<option key={agent.slug} value={agent.assignmentKey}>
{agent.name} {agent.family} {agent.host}
</option>
))}
</Select>
<Input
placeholder="Task title"
required
value={formState.title}
onChange={(event) => setFormState((current) => ({ ...current, title: event.target.value }))}
/>
<Select
value={formState.priority}
onChange={(event) =>
setFormState((current) => ({ ...current, priority: event.target.value as TaskPriority }))
}
>
{PRIORITIES.map((priority) => (
<option key={priority} value={priority}>
{priority}
</option>
))}
</Select>
<Input
placeholder="Repo slug from repo-map.json (for example TopherMayor/openclaw-taskboard)"
value={formState.repoSlug}
onChange={(event) => setFormState((current) => ({ ...current, repoSlug: event.target.value }))}
/>
<Input
placeholder="Base branch"
value={formState.baseBranch}
onChange={(event) => setFormState((current) => ({ ...current, baseBranch: event.target.value }))}
/>
<Input
placeholder="Preferred swarm agent"
value={formState.preferredAgent}
onChange={(event) => setFormState((current) => ({ ...current, preferredAgent: event.target.value }))}
/>
<Input
placeholder="Reasoning effort"
value={formState.reasoningEffort}
onChange={(event) => setFormState((current) => ({ ...current, reasoningEffort: event.target.value }))}
/>
<Input
className="md:col-span-2"
placeholder="Tags (comma-separated)"
value={formState.tags}
onChange={(event) => setFormState((current) => ({ ...current, tags: event.target.value }))}
/>
<Input
className="md:col-span-2"
placeholder="Model hint (optional)"
value={formState.modelHint}
onChange={(event) => setFormState((current) => ({ ...current, modelHint: event.target.value }))}
/>
<div className="md:col-span-2">
<Textarea
placeholder="Describe the task, target host, expected outcome, and any validation steps."
value={formState.description}
onChange={(event) => setFormState((current) => ({ ...current, description: event.target.value }))}
/>
</div>
<div className="md:col-span-2 flex items-center justify-between gap-3">
<p className="text-sm text-slate-400">
{selectedAgent
? `Dispatch target: ${selectedAgent.family} on ${selectedAgent.host}`
: selectedTemplate
? `Template dispatch: ${selectedTemplate.defaults.dispatchMethod}`
: "Select an agent or template to prefill dispatch metadata."}
</p>
<Button type="submit">Create Task</Button>
</div>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Failure Queue</CardTitle>
<CardDescription>
Failed dispatches stay visible until retried or completed manually.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{failedTasks.length === 0 ? (
<p className="text-sm text-slate-400">No failed dispatches.</p>
) : (
failedTasks.map((task) => (
<div className="rounded-xl border border-amber-400/20 bg-amber-400/5 p-4" key={task.id}>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="font-medium text-white">{task.title}</p>
<Badge variant="warning">{task.dispatch_state}</Badge>
</div>
<p className="mt-2 text-sm text-slate-300">{task.last_error || "No error text captured."}</p>
<Button className="mt-3 w-full" size="sm" variant="outline" onClick={() => dispatchTask(task.id)}>
Retry Dispatch
</Button>
</div>
))
)}
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Unified Task Intake</CardTitle>
<CardTitle>Recent Dispatch Activity</CardTitle>
<CardDescription>
Assign work to OpenClaw swarm agents or ZeroClaw host runtimes from a single board.
Latest control-plane events across both families.
</CardDescription>
</CardHeader>
<CardContent>
<form className="grid gap-3 md:grid-cols-2" onSubmit={createTask}>
<Input
placeholder="Task title"
required
value={formState.title}
onChange={(event) => setFormState((current) => ({ ...current, title: event.target.value }))}
/>
<Select
value={formState.assignee}
onChange={(event) => setFormState((current) => ({ ...current, assignee: event.target.value }))}
>
<option value="">Select agent</option>
{agents.map((agent) => (
<option key={agent.slug} value={agent.assignmentKey}>
{agent.name} {agent.family} {agent.host}
</option>
))}
</Select>
<Select
value={formState.priority}
onChange={(event) =>
setFormState((current) => ({ ...current, priority: event.target.value as TaskPriority }))
}
>
{PRIORITIES.map((priority) => (
<option key={priority} value={priority}>
{priority}
</option>
))}
</Select>
<Input
placeholder="Tags (comma-separated)"
value={formState.tags}
onChange={(event) => setFormState((current) => ({ ...current, tags: event.target.value }))}
/>
<div className="md:col-span-2">
<Textarea
placeholder="Describe the task, host target, and expected outcome"
value={formState.description}
onChange={(event) =>
setFormState((current) => ({ ...current, description: event.target.value }))
}
/>
<CardContent className="grid gap-3 lg:grid-cols-3">
{events.slice(0, 6).map((event) => (
<div className="rounded-xl border border-white/10 bg-slate-950/40 p-4" key={event.id}>
<div className="flex flex-wrap gap-2">
<Badge variant={familyVariant(event.family)}>{event.family || "manual"}</Badge>
<Badge variant={event.event_type === "dispatch_failed" ? "warning" : "secondary"}>
{event.event_type}
</Badge>
</div>
<p className="mt-3 font-medium text-white">{event.summary}</p>
<p className="mt-2 text-sm text-slate-300">{event.detail || "No detail captured."}</p>
<p className="mt-2 text-xs uppercase tracking-[0.2em] text-slate-500">{event.created_at}</p>
</div>
<div className="md:col-span-2">
<Button type="submit">Create Task</Button>
</div>
</form>
))}
</CardContent>
</Card>
@@ -154,18 +328,42 @@ export function TasksClient({
</div>
<p className="mt-2 text-sm text-slate-300">{task.description || "No description"}</p>
<div className="mt-3 flex flex-wrap gap-2 text-xs text-slate-400">
<Badge variant={familyVariant(task.family)}>{task.family || "manual"}</Badge>
<Badge variant="secondary">{task.assignee || "Unassigned"}</Badge>
<Badge variant={dispatchVariant(task.dispatch_state)}>{task.dispatch_state}</Badge>
{task.tags.map((tag) => (
<Badge key={tag} variant="outline">
{tag}
</Badge>
))}
</div>
{task.status !== "Done" ? (
<Button className="mt-4 w-full" size="sm" variant="outline" onClick={() => moveToDone(task.id)}>
Mark Done
</Button>
) : null}
<dl className="mt-3 grid gap-1 text-xs text-slate-400">
<div className="flex justify-between gap-2">
<dt>Host</dt>
<dd>{task.target_host || "n/a"}</dd>
</div>
<div className="flex justify-between gap-2">
<dt>Channel</dt>
<dd className="text-right">{task.target_channel || "n/a"}</dd>
</div>
</dl>
<div className="mt-4 space-y-2">
{task.dispatch_state !== "dispatched" && task.dispatch_state !== "completed" ? (
<Button className="w-full" size="sm" onClick={() => dispatchTask(task.id)}>
Dispatch
</Button>
) : null}
{task.dispatch_state === "dispatched" ? (
<Button className="w-full" size="sm" variant="outline" onClick={() => acknowledgeTask(task.id)}>
Mark Acknowledged
</Button>
) : null}
{task.status !== "Done" ? (
<Button className="w-full" size="sm" variant="outline" onClick={() => patchTask(task.id, { status: "Done" })}>
Mark Done
</Button>
) : null}
</div>
</div>
))}
</CardContent>