[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

@@ -13,6 +13,9 @@ FROM node:20-bookworm-slim AS runner
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PORT=8395 ENV PORT=8395
RUN apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/standalone ./

View File

@@ -6,8 +6,9 @@ It tracks and visualizes:
- OpenClaw swarm agents on `ubuntu` - OpenClaw swarm agents on `ubuntu`
- ZeroClaw host runtimes on `grizzley` and `ice` - ZeroClaw host runtimes on `grizzley` and `ice`
- shared task assignment across both families - shared task assignment and dispatch across both families
- wiki pages and architecture documentation rendered in the UI - wiki pages and architecture documentation rendered in the UI
- dispatch audit history, failure queues, heartbeat overlays, and task templates
## Stack ## Stack
@@ -21,6 +22,9 @@ It tracks and visualizes:
- `/tasks` - unified Kanban board - `/tasks` - unified Kanban board
- `/agents` - configured OpenClaw and ZeroClaw runtimes - `/agents` - configured OpenClaw and ZeroClaw runtimes
- `/openclaw` - focused OpenClaw swarm view
- `/zeroclaw` - focused ZeroClaw host-runtime view
- `/dispatch` - dispatch audit log and failure queue
- `/architecture` - deployed architecture documentation with ASCII topology - `/architecture` - deployed architecture documentation with ASCII topology
- `/wiki` - markdown-backed runbooks and generated docs - `/wiki` - markdown-backed runbooks and generated docs
- `/usage` - usage aggregates from the local tracking table - `/usage` - usage aggregates from the local tracking table
@@ -55,10 +59,15 @@ It tracks and visualizes:
- `DB_PATH` - `DB_PATH`
- `WIKI_DIR` - `WIKI_DIR`
- `AGENTS_DIR` - `AGENTS_DIR`
- `SESSIONS_DIR` - `SWARM_TASKS_FILE`
- `SWARM_REPO_MAP_FILE`
- `SWARM_WORKTREES_DIR`
- `REPO_ACCESS_ROOTS`
- `OPENCLAW_CONFIG` - `OPENCLAW_CONFIG`
- `ZEROCLAW_PRIMARY_DIR` - `ZEROCLAW_GRIZZLEY_URL`
- `ZEROCLAW_CONTROL_DIR` - `ZEROCLAW_GRIZZLEY_TOKEN`
- `ZEROCLAW_ICE_URL`
- `ZEROCLAW_ICE_TOKEN`
## Development ## Development

View File

@@ -0,0 +1,7 @@
import { NextResponse } from "next/server";
import { listTaskEvents } from "@/lib/tasks";
export async function GET() {
return NextResponse.json(await listTaskEvents(undefined, 100));
}

View File

@@ -0,0 +1,26 @@
import { NextResponse } from "next/server";
import { updateTask } from "@/lib/tasks";
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const numericId = Number(id);
if (!Number.isInteger(numericId) || numericId <= 0) {
return NextResponse.json({ error: "invalid_task_id" }, { status: 400 });
}
const task = await updateTask(numericId, {
dispatch_state: "acknowledged",
acknowledged_at: new Date().toISOString(),
status: "In Progress",
});
if (!task) {
return NextResponse.json({ error: "task_not_found" }, { status: 404 });
}
return NextResponse.json(task);
}

View File

@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { dispatchTask } from "@/lib/dispatch";
export async function POST(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const numericId = Number(id);
if (!Number.isInteger(numericId) || numericId <= 0) {
return NextResponse.json({ error: "invalid_task_id" }, { status: 400 });
}
try {
return NextResponse.json(await dispatchTask(numericId));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return NextResponse.json({ error: "dispatch_failed", detail: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { listTaskEvents } from "@/lib/tasks";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const numericId = Number(id);
if (!Number.isInteger(numericId) || numericId <= 0) {
return NextResponse.json({ error: "invalid_task_id" }, { status: 400 });
}
return NextResponse.json(await listTaskEvents(numericId, 50));
}

View File

@@ -22,8 +22,34 @@ export async function PATCH(
title: typeof payload.title === "string" ? payload.title : undefined, title: typeof payload.title === "string" ? payload.title : undefined,
description: typeof payload.description === "string" ? payload.description : undefined, description: typeof payload.description === "string" ? payload.description : undefined,
assignee: typeof payload.assignee === "string" ? payload.assignee : undefined, assignee: typeof payload.assignee === "string" ? payload.assignee : undefined,
family:
payload.family === null || typeof payload.family === "string"
? (payload.family as never)
: undefined,
target_host: typeof payload.target_host === "string" ? payload.target_host : undefined,
target_channel: typeof payload.target_channel === "string" ? payload.target_channel : undefined,
dispatch_method: payload.dispatch_method as never,
dispatch_state: payload.dispatch_state as never,
template_key: typeof payload.template_key === "string" ? payload.template_key : undefined,
repo_slug: typeof payload.repo_slug === "string" ? payload.repo_slug : undefined,
base_branch: typeof payload.base_branch === "string" ? payload.base_branch : undefined,
preferred_agent:
typeof payload.preferred_agent === "string" ? payload.preferred_agent : undefined,
reasoning_effort:
typeof payload.reasoning_effort === "string" ? payload.reasoning_effort : undefined,
model_hint: typeof payload.model_hint === "string" ? payload.model_hint : undefined,
priority: payload.priority as never, priority: payload.priority as never,
status: payload.status as never, status: payload.status as never,
last_dispatch_at:
typeof payload.last_dispatch_at === "string" ? payload.last_dispatch_at : undefined,
acknowledged_at:
payload.acknowledged_at === null || typeof payload.acknowledged_at === "string"
? (payload.acknowledged_at as never)
: undefined,
last_error:
payload.last_error === null || typeof payload.last_error === "string"
? (payload.last_error as never)
: undefined,
tags: Array.isArray(payload.tags) ? payload.tags.filter((tag) => typeof tag === "string") : undefined, tags: Array.isArray(payload.tags) ? payload.tags.filter((tag) => typeof tag === "string") : undefined,
}); });

View File

@@ -1,5 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { findAgentByAssignmentKey } from "@/lib/agents";
import { createTask, listTasks, validateTaskPayload } from "@/lib/tasks"; import { createTask, listTasks, validateTaskPayload } from "@/lib/tasks";
export async function GET() { export async function GET() {
@@ -13,10 +14,26 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "validation_error", details: errors }, { status: 400 }); return NextResponse.json({ error: "validation_error", details: errors }, { status: 400 });
} }
const assignee = typeof payload.assignee === "string" ? payload.assignee : "";
const assigneeAgent = assignee ? await findAgentByAssignmentKey(assignee) : null;
const task = await createTask({ const task = await createTask({
title: String(payload.title), title: String(payload.title),
description: typeof payload.description === "string" ? payload.description : "", description: typeof payload.description === "string" ? payload.description : "",
assignee: typeof payload.assignee === "string" ? payload.assignee : "", assignee,
family: (payload.family as never) || assigneeAgent?.family || null,
target_host: typeof payload.target_host === "string" ? payload.target_host : assigneeAgent?.host || "",
target_channel:
typeof payload.target_channel === "string"
? payload.target_channel
: assigneeAgent?.channels[0]?.value || "",
dispatch_method:
(payload.dispatch_method as never) || assigneeAgent?.defaultDispatchMethod || "manual",
template_key: typeof payload.template_key === "string" ? payload.template_key : null,
repo_slug: typeof payload.repo_slug === "string" ? payload.repo_slug : null,
base_branch: typeof payload.base_branch === "string" ? payload.base_branch : null,
preferred_agent: typeof payload.preferred_agent === "string" ? payload.preferred_agent : null,
reasoning_effort: typeof payload.reasoning_effort === "string" ? payload.reasoning_effort : null,
model_hint: typeof payload.model_hint === "string" ? payload.model_hint : null,
priority: payload.priority as never, priority: payload.priority as never,
status: (payload.status as never) || "Backlog", status: (payload.status as never) || "Backlog",
tags: Array.isArray(payload.tags) ? payload.tags.filter((tag) => typeof tag === "string") : [], tags: Array.isArray(payload.tags) ? payload.tags.filter((tag) => typeof tag === "string") : [],

View File

@@ -0,0 +1,7 @@
import { NextResponse } from "next/server";
import { listTaskTemplates } from "@/lib/tasks";
export async function GET() {
return NextResponse.json(await listTaskTemplates());
}

13
app/dispatch/page.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { DispatchHistory } from "@/components/dispatch-history";
import { listFailedTasks, listTaskEvents } from "@/lib/tasks";
export const dynamic = "force-dynamic";
export default async function DispatchPage() {
const [events, failedTasks] = await Promise.all([
listTaskEvents(undefined, 50),
listFailedTasks(),
]);
return <DispatchHistory events={events} failedTasks={failedTasks} />;
}

9
app/openclaw/page.tsx Normal file
View File

@@ -0,0 +1,9 @@
import { AgentsClient } from "@/components/agents-client";
import { listFleetAgents } from "@/lib/agents";
export const dynamic = "force-dynamic";
export default async function OpenClawPage() {
const agents = await listFleetAgents();
return <AgentsClient agents={agents} defaultFamily="openclaw" />;
}

View File

@@ -1,10 +1,15 @@
import { TasksClient } from "@/components/tasks-client"; import { TasksClient } from "@/components/tasks-client";
import { listFleetAgents } from "@/lib/agents"; import { listFleetAgents } from "@/lib/agents";
import { listTasks } from "@/lib/tasks"; import { listTaskEvents, listTaskTemplates, listTasks } from "@/lib/tasks";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function TasksPage() { export default async function TasksPage() {
const [tasks, agents] = await Promise.all([listTasks(), listFleetAgents()]); const [tasks, agents, templates, events] = await Promise.all([
return <TasksClient initialTasks={tasks} agents={agents} />; listTasks(),
listFleetAgents(),
listTaskTemplates(),
listTaskEvents(undefined, 12),
]);
return <TasksClient initialTasks={tasks} initialEvents={events} agents={agents} templates={templates} />;
} }

9
app/zeroclaw/page.tsx Normal file
View File

@@ -0,0 +1,9 @@
import { AgentsClient } from "@/components/agents-client";
import { listFleetAgents } from "@/lib/agents";
export const dynamic = "force-dynamic";
export default async function ZeroClawPage() {
const agents = await listFleetAgents();
return <AgentsClient agents={agents} defaultFamily="zeroclaw" />;
}

View File

@@ -1,28 +1,43 @@
"use client"; "use client";
import { useMemo, useState } from "react"; import { useDeferredValue, useState } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select"; 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 [query, setQuery] = useState("");
const [family, setFamily] = useState(""); const [family, setFamily] = useState<AgentFamily | "">(defaultFamily);
const deferredQuery = useDeferredValue(query);
const filteredAgents = useMemo(() => { const filteredAgents = agents.filter((agent) => {
return agents.filter((agent) => {
const matchesQuery = const matchesQuery =
query.length === 0 || deferredQuery.length === 0 ||
agent.name.toLowerCase().includes(query.toLowerCase()) || agent.name.toLowerCase().includes(deferredQuery.toLowerCase()) ||
agent.host.toLowerCase().includes(query.toLowerCase()) || agent.host.toLowerCase().includes(deferredQuery.toLowerCase()) ||
agent.role.toLowerCase().includes(query.toLowerCase()); agent.role.toLowerCase().includes(deferredQuery.toLowerCase());
const matchesFamily = family.length === 0 || agent.family === family; const matchesFamily = family.length === 0 || agent.family === family;
return matchesQuery && matchesFamily; return matchesQuery && matchesFamily;
}); });
}, [agents, family, query]);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -30,12 +45,16 @@ export function AgentsClient({ agents }: { agents: FleetAgent[] }) {
<CardHeader> <CardHeader>
<CardTitle>Configured Agent Runtimes</CardTitle> <CardTitle>Configured Agent Runtimes</CardTitle>
<CardDescription> <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> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="grid gap-3 md:grid-cols-[1fr_220px]"> <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)} /> <Input
<Select value={family} onChange={(event) => setFamily(event.target.value)}> 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="">All families</option>
<option value="openclaw">OpenClaw</option> <option value="openclaw">OpenClaw</option>
<option value="zeroclaw">ZeroClaw</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> <dt className="text-xs uppercase tracking-[0.2em] text-slate-500">Model</dt>
<dd>{agent.model || "Host-local/runtime-defined"}</dd> <dd>{agent.model || "Host-local/runtime-defined"}</dd>
</div> </div>
<div className="md:col-span-2"> <div>
<dt className="text-xs uppercase tracking-[0.2em] text-slate-500">Runtime</dt> <dt className="text-xs uppercase tracking-[0.2em] text-slate-500">Dispatch</dt>
<dd className="font-mono text-xs text-cyan-100">{agent.runtimePath}</dd> <dd>{agent.defaultDispatchMethod}</dd>
</div> </div>
<div> <div>
<dt className="text-xs uppercase tracking-[0.2em] text-slate-500">Workload</dt> <dt className="text-xs uppercase tracking-[0.2em] text-slate-500">Workload</dt>
<dd>{agent.workload} active</dd> <dd>{agent.workload} active</dd>
</div> </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> <div>
<dt className="text-xs uppercase tracking-[0.2em] text-slate-500">Current task</dt> <dt className="text-xs uppercase tracking-[0.2em] text-slate-500">Current task</dt>
<dd>{agent.currentTask || "No heartbeat task"}</dd> <dd>{agent.currentTask || "No heartbeat task"}</dd>
</div> </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> </dl>
<div> <div>
@@ -96,10 +127,36 @@ export function AgentsClient({ agents }: { agents: FleetAgent[] }) {
</div> </div>
</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> <div>
<p className="mb-2 text-xs uppercase tracking-[0.2em] text-slate-500">Tools</p> <p className="mb-2 text-xs uppercase tracking-[0.2em] text-slate-500">Tools</p>
<div className="flex flex-wrap gap-2"> <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>
</div> </div>
</CardContent> </CardContent>

View File

@@ -1,11 +1,14 @@
import Link from "next/link"; 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"; import { cn } from "@/lib/utils";
const navItems = [ const navItems = [
{ href: "/tasks", label: "Tasks", icon: PanelsTopLeft }, { href: "/tasks", label: "Tasks", icon: PanelsTopLeft },
{ href: "/agents", label: "Agents", icon: UsersRound }, { 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: "/architecture", label: "Architecture", icon: Network },
{ href: "/wiki", label: "Wiki", icon: NotebookTabs }, { href: "/wiki", label: "Wiki", icon: NotebookTabs },
{ href: "/usage", label: "Usage", icon: ScrollText }, { 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 { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select"; import { Select } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea"; 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 COLUMNS: TaskStatus[] = ["Backlog", "Todo", "In Progress", "Review", "Done"];
const PRIORITIES: TaskPriority[] = ["Low", "Medium", "High", "Critical"]; 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({ export function TasksClient({
initialTasks, initialTasks,
initialEvents,
agents, agents,
templates,
}: { }: {
initialTasks: TaskRecord[]; initialTasks: TaskRecord[];
initialEvents: TaskEvent[];
agents: FleetAgent[]; agents: FleetAgent[];
templates: TaskTemplate[];
}) { }) {
const [tasks, setTasks] = useState(initialTasks); const [tasks, setTasks] = useState(initialTasks);
const [events, setEvents] = useState(initialEvents);
const [formState, setFormState] = useState({ const [formState, setFormState] = useState({
templateKey: "",
title: "", title: "",
description: "", description: "",
assignee: "", assignee: "",
priority: "Medium" as TaskPriority, priority: "Medium" as TaskPriority,
tags: "", tags: "",
repoSlug: "",
baseBranch: "main",
preferredAgent: "codex",
reasoningEffort: "high",
modelHint: "",
}); });
async function refreshTasks() { const selectedTemplate = templates.find((template) => template.key === formState.templateKey) || null;
const response = await fetch("/api/tasks"); const selectedAgent = agents.find((agent) => agent.assignmentKey === formState.assignee) || null;
const nextTasks = (await response.json()) as TaskRecord[]; const failedTasks = tasks.filter((task) => task.dispatch_state === "failed");
setTasks(nextTasks);
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>) { async function createTask(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
const tags = formState.tags
.split(",")
.map((tag) => tag.trim())
.filter(Boolean);
await fetch("/api/tasks", { await fetch("/api/tasks", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -45,49 +103,75 @@ export function TasksClient({
description: formState.description, description: formState.description,
assignee: formState.assignee, assignee: formState.assignee,
priority: formState.priority, priority: formState.priority,
tags: formState.tags tags,
.split(",") template_key: formState.templateKey || null,
.map((tag) => tag.trim()) repo_slug: formState.repoSlug || null,
.filter(Boolean), 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({ setFormState({
templateKey: "",
title: "", title: "",
description: "", description: "",
assignee: "", assignee: "",
priority: "Medium", priority: "Medium",
tags: "", 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}`, { await fetch(`/api/tasks/${taskId}`, {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.2fr)_360px]">
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Unified Task Intake</CardTitle> <CardTitle>Unified Task Intake</CardTitle>
<CardDescription> <CardDescription>
Assign work to OpenClaw swarm agents or ZeroClaw host runtimes from a single board. Create typed tasks, apply dispatch templates, and route work to OpenClaw or ZeroClaw.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form className="grid gap-3 md:grid-cols-2" onSubmit={createTask}> <form className="grid gap-3 md:grid-cols-2" onSubmit={createTask}>
<Input <Select value={formState.templateKey} onChange={(event) => applyTemplate(event.target.value)}>
placeholder="Task title" <option value="">Select template</option>
required {templates.map((template) => (
value={formState.title} <option key={template.key} value={template.key}>
onChange={(event) => setFormState((current) => ({ ...current, title: event.target.value }))} {template.title} {template.family}
/> </option>
))}
</Select>
<Select <Select
value={formState.assignee} value={formState.assignee}
onChange={(event) => setFormState((current) => ({ ...current, assignee: event.target.value }))} onChange={(event) => setFormState((current) => ({ ...current, assignee: event.target.value }))}
@@ -99,6 +183,12 @@ export function TasksClient({
</option> </option>
))} ))}
</Select> </Select>
<Input
placeholder="Task title"
required
value={formState.title}
onChange={(event) => setFormState((current) => ({ ...current, title: event.target.value }))}
/>
<Select <Select
value={formState.priority} value={formState.priority}
onChange={(event) => onChange={(event) =>
@@ -112,26 +202,110 @@ export function TasksClient({
))} ))}
</Select> </Select>
<Input <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)" placeholder="Tags (comma-separated)"
value={formState.tags} value={formState.tags}
onChange={(event) => setFormState((current) => ({ ...current, tags: event.target.value }))} 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"> <div className="md:col-span-2">
<Textarea <Textarea
placeholder="Describe the task, host target, and expected outcome" placeholder="Describe the task, target host, expected outcome, and any validation steps."
value={formState.description} value={formState.description}
onChange={(event) => onChange={(event) => setFormState((current) => ({ ...current, description: event.target.value }))}
setFormState((current) => ({ ...current, description: event.target.value }))
}
/> />
</div> </div>
<div className="md:col-span-2"> <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> <Button type="submit">Create Task</Button>
</div> </div>
</form> </form>
</CardContent> </CardContent>
</Card> </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>Recent Dispatch Activity</CardTitle>
<CardDescription>
Latest control-plane events across both families.
</CardDescription>
</CardHeader>
<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>
))}
</CardContent>
</Card>
<div className="grid gap-4 xl:grid-cols-5"> <div className="grid gap-4 xl:grid-cols-5">
{COLUMNS.map((column) => { {COLUMNS.map((column) => {
const columnTasks = tasks.filter((task) => task.status === column); const columnTasks = tasks.filter((task) => task.status === column);
@@ -154,19 +328,43 @@ export function TasksClient({
</div> </div>
<p className="mt-2 text-sm text-slate-300">{task.description || "No description"}</p> <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"> <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="secondary">{task.assignee || "Unassigned"}</Badge>
<Badge variant={dispatchVariant(task.dispatch_state)}>{task.dispatch_state}</Badge>
{task.tags.map((tag) => ( {task.tags.map((tag) => (
<Badge key={tag} variant="outline"> <Badge key={tag} variant="outline">
{tag} {tag}
</Badge> </Badge>
))} ))}
</div> </div>
<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" ? ( {task.status !== "Done" ? (
<Button className="mt-4 w-full" size="sm" variant="outline" onClick={() => moveToDone(task.id)}> <Button className="w-full" size="sm" variant="outline" onClick={() => patchTask(task.id, { status: "Done" })}>
Mark Done Mark Done
</Button> </Button>
) : null} ) : null}
</div> </div>
</div>
))} ))}
</CardContent> </CardContent>
</Card> </Card>

121
config/fleet.json Normal file
View File

@@ -0,0 +1,121 @@
{
"title": "Claw Fleet Architecture",
"overview": [
"OpenClaw is the ubuntu-local orchestration layer and Telegram HQ entrypoint.",
"ZeroClaw provides host-scoped remote administration on grizzley and ice.",
"The taskboard is the shared planning, dispatch, and audit surface across both families."
],
"topologyDiagram": " Telegram / Forum Topics\n |\n +----------------+----------------+\n | |\n v v\n OpenClaw gateway ZeroClaw control\n ubuntu :18789 ice zeroclaw-admin\n local swarm topic router / paired gateway\n | |\n +------------+--------------------+\n |\n v\n shared taskboard UI\n |\n +-----------+-----------+\n | |\n v v\n OpenClaw agents ZeroClaw runtimes\n ubuntu-local swarm grizzley / ice\n",
"sections": [
{
"id": "openclaw",
"title": "OpenClaw",
"summary": "Primary orchestration family on ubuntu. Owns local swarm execution, HQ Telegram bindings, and ubuntu-host workflows.",
"runtime": [
{ "label": "Host", "value": "ubuntu (192.168.50.61)" },
{ "label": "Service", "value": "openclaw.service" },
{ "label": "Runtime", "value": "/srv/state/openclaw/current" },
{ "label": "Config", "value": "/home/bear/.openclaw/openclaw.json" }
],
"channels": [
{ "label": "Telegram DM", "value": "allowlist: tg:5512934365" },
{ "label": "Forum Group", "value": "Homelab HQ (-1003809447066)" },
{ "label": "Gateway", "value": "LAN bind :18789 with token auth" }
],
"configuredAgents": [
"main",
"ubuntu",
"docs",
"gitea-admin",
"planner",
"builder",
"reviewer"
],
"diagram": "OpenClaw HQ topics\n topic 2 -> ubuntu\n topic 3 -> docs\n topic 4 -> gitea-admin\n topics 5-9 -> main, then delegate to host-scoped ZeroClaw paths\n\nmain\n|- ubuntu\n|- docs\n|- gitea-admin\n|- planner\n|- builder\n\\- reviewer\n",
"notes": [
"Remote host personas were removed from OpenClaw.",
"OpenClaw remains gateway-only on ubuntu.",
"Swarm dispatch requires a repo slug that resolves through ~/.clawdbot/repo-map.json."
]
},
{
"id": "zeroclaw",
"title": "ZeroClaw",
"summary": "Host-scoped runtime family for remote administration. Grizzley is the primary active gateway. Ice is the control-plane runtime and topic router.",
"runtime": [
{ "label": "Primary", "value": "/srv/state/zeroclaw/current on grizzley" },
{ "label": "Control", "value": "/home/bear/.zeroclaw-admin on ice" },
{ "label": "Primary Service", "value": "zeroclaw.service" },
{ "label": "Control Service", "value": "zeroclaw-admin.service" }
],
"channels": [
{ "label": "Grizzley Gateway", "value": "HTTP gateway :3000, pairing required" },
{ "label": "Ice Telegram", "value": "Homelab-Ice (-1003728617160)" },
{ "label": "Remote Routing", "value": "paired status/webhook to grizzley and pve" }
],
"configuredAgents": [
"grizzley-zeroclaw",
"ice-zeroclaw"
],
"diagram": "Homelab-Ice topics\n 11 -> local ice operations\n 12 -> grizzley paired gateway\n 13 -> pve paired gateway\n 14 -> truenas blocker message\n 15 -> panda rollout pending\n\nice zeroclaw-admin\n -> zeroclaw-remote-gateway.sh status grizzley|pve\n -> zeroclaw-remote-gateway.sh webhook grizzley|pve \"<message>\"\n",
"notes": [
"Grizzley is host-scoped and should not proxy other hosts directly.",
"Ice still uses host-local secret and encryption state under /home/bear/.zeroclaw-admin."
]
}
],
"zeroclawAgents": [
{
"slug": "grizzley-zeroclaw",
"assignmentKey": "grizzley-zeroclaw",
"aliases": ["grizzley-zeroclaw", "ZeroClaw Grizzley", "grizzley"],
"name": "ZeroClaw Grizzley",
"host": "grizzley",
"role": "Edge host operator for grizzley",
"runtimePath": "/app/zeroclaw/grizzley",
"configPath": "/app/zeroclaw/grizzley/config.toml",
"model": "glm-4.7",
"emoji": "S",
"channels": [
{ "label": "Gateway", "value": "HTTP gateway :3000" },
{ "label": "Access", "value": "paired remote gateway via ice" }
],
"notes": [
"Host-scoped runtime for Traefik, OpenCode, and local services."
],
"dispatch": {
"method": "zeroclaw-webhook",
"urlEnv": "ZEROCLAW_GRIZZLEY_URL",
"tokenEnv": "ZEROCLAW_GRIZZLEY_TOKEN",
"targetChannel": "grizzley gateway",
"description": "Posts JSON webhook payloads to the grizzley ZeroClaw runtime."
}
},
{
"slug": "ice-zeroclaw",
"assignmentKey": "ice-zeroclaw",
"aliases": ["ice-zeroclaw", "ZeroClaw Ice", "ZeroClaw Admin", "ice"],
"name": "ZeroClaw Ice",
"host": "ice",
"role": "Control-plane operator for ice",
"runtimePath": "/app/zeroclaw/ice",
"configPath": "/app/zeroclaw/ice/config.toml",
"model": "glm-5",
"emoji": "I",
"channels": [
{ "label": "Telegram", "value": "Homelab-Ice topics 11-15" },
{ "label": "Gateway", "value": "paired webhook + status routing" }
],
"notes": [
"Control-plane runtime and topic router for remote host delegation."
],
"dispatch": {
"method": "zeroclaw-webhook",
"urlEnv": "ZEROCLAW_ICE_URL",
"tokenEnv": "ZEROCLAW_ICE_TOKEN",
"targetChannel": "Homelab-Ice topic router",
"description": "Posts JSON webhook payloads to the ice ZeroClaw runtime."
}
}
]
}

View File

@@ -0,0 +1,59 @@
[
{
"key": "openclaw-code-change",
"title": "OpenClaw code change",
"summary": "Create a swarm task against a tracked git repo with review-ready defaults.",
"family": "openclaw",
"tags": ["swarm", "repo:TopherMayor/openclaw-taskboard", "agent:codex", "base:main", "reasoning:high"],
"defaults": {
"priority": "High",
"dispatchMethod": "openclaw-swarm",
"targetHost": "ubuntu",
"targetChannel": "OpenClaw swarm registry",
"repoSlug": "TopherMayor/openclaw-taskboard",
"baseBranch": "main",
"preferredAgent": "codex",
"reasoningEffort": "high"
}
},
{
"key": "openclaw-review",
"title": "OpenClaw review pass",
"summary": "Send a repo review task to the OpenClaw swarm with lower-cost defaults.",
"family": "openclaw",
"tags": ["swarm", "agent:codex", "base:main", "reasoning:medium"],
"defaults": {
"priority": "Medium",
"dispatchMethod": "openclaw-swarm",
"targetHost": "ubuntu",
"targetChannel": "OpenClaw swarm registry",
"baseBranch": "main",
"preferredAgent": "codex",
"reasoningEffort": "medium"
}
},
{
"key": "zeroclaw-host-ops",
"title": "ZeroClaw host operation",
"summary": "Dispatch a remote host action through a ZeroClaw webhook runtime.",
"family": "zeroclaw",
"tags": ["host-ops", "dispatch:webhook"],
"defaults": {
"priority": "Medium",
"dispatchMethod": "zeroclaw-webhook",
"reasoningEffort": "medium"
}
},
{
"key": "zeroclaw-service-check",
"title": "ZeroClaw service verification",
"summary": "Send a targeted service health or log inspection task to a host-scoped ZeroClaw runtime.",
"family": "zeroclaw",
"tags": ["host-ops", "service-check", "dispatch:webhook"],
"defaults": {
"priority": "High",
"dispatchMethod": "zeroclaw-webhook",
"reasoningEffort": "medium"
}
}
]

View File

@@ -3,6 +3,7 @@ import path from "node:path";
import { import {
ARCHITECTURE_DOCUMENT, ARCHITECTURE_DOCUMENT,
FLEET_CONFIG,
OPENCLAW_AGENTS_DIR, OPENCLAW_AGENTS_DIR,
OPENCLAW_CONFIG_PATH, OPENCLAW_CONFIG_PATH,
ZEROCLAW_CONTROL_DIR, ZEROCLAW_CONTROL_DIR,
@@ -10,7 +11,7 @@ import {
} from "@/lib/fleet-config"; } from "@/lib/fleet-config";
import { all } from "@/lib/db"; import { all } from "@/lib/db";
import { normalizeTask } from "@/lib/tasks"; import { normalizeTask } from "@/lib/tasks";
import type { AgentStatus, FleetAgent, TaskRecord } from "@/lib/types"; import type { AgentStatus, FleetAgent, TaskEvent, TaskRecord } from "@/lib/types";
type OpenClawAgentConfig = { type OpenClawAgentConfig = {
id: string; id: string;
@@ -58,6 +59,24 @@ function parseRoleFromAgentsMd(content: string) {
return "Host-scoped agent"; return "Host-scoped agent";
} }
function deriveHeartbeatTimestamp(heartbeatPath: string, heartbeatMd: string) {
const timestampMatch = heartbeatMd.match(
/(Last Heartbeat|Updated|Timestamp):\s*([0-9TZ:.\-+ ]+)/i,
);
if (timestampMatch) {
const parsed = new Date(timestampMatch[2].trim());
if (!Number.isNaN(parsed.getTime())) {
return parsed.toISOString();
}
}
if (!fs.existsSync(heartbeatPath)) {
return null;
}
return fs.statSync(heartbeatPath).mtime.toISOString();
}
function parseResponsibilities(content: string) { function parseResponsibilities(content: string) {
const sectionMatch = content.match(/## Responsibilities([\s\S]*?)(##|$)/); const sectionMatch = content.match(/## Responsibilities([\s\S]*?)(##|$)/);
return sectionMatch ? parseBulletValues(sectionMatch[1]) : []; return sectionMatch ? parseBulletValues(sectionMatch[1]) : [];
@@ -68,11 +87,13 @@ function readWorkspaceAgent(agentRoot: string, fallbackName: string) {
const agentsMd = readTextFile(path.join(workspaceRoot, "AGENTS.md")); const agentsMd = readTextFile(path.join(workspaceRoot, "AGENTS.md"));
const toolsMd = readTextFile(path.join(workspaceRoot, "TOOLS.md")); const toolsMd = readTextFile(path.join(workspaceRoot, "TOOLS.md"));
const identityMd = readTextFile(path.join(workspaceRoot, "IDENTITY.md")); const identityMd = readTextFile(path.join(workspaceRoot, "IDENTITY.md"));
const heartbeatMd = readTextFile(path.join(workspaceRoot, "HEARTBEAT.md")); const heartbeatPath = path.join(workspaceRoot, "HEARTBEAT.md");
const heartbeatMd = readTextFile(heartbeatPath);
const tools = parseBulletValues(toolsMd); const tools = parseBulletValues(toolsMd);
const capabilities = parseResponsibilities(agentsMd); const capabilities = parseResponsibilities(agentsMd);
const currentTaskMatch = heartbeatMd.match(/Current Task:\s*(.+)/i); const currentTaskMatch = heartbeatMd.match(/Current Task:\s*(.+)/i);
const heartbeatAt = deriveHeartbeatTimestamp(heartbeatPath, heartbeatMd);
return { return {
files: ["AGENTS.md", "TOOLS.md", "IDENTITY.md"].filter((fileName) => files: ["AGENTS.md", "TOOLS.md", "IDENTITY.md"].filter((fileName) =>
@@ -81,6 +102,7 @@ function readWorkspaceAgent(agentRoot: string, fallbackName: string) {
tools, tools,
capabilities, capabilities,
currentTask: currentTaskMatch ? currentTaskMatch[1].trim() : null, currentTask: currentTaskMatch ? currentTaskMatch[1].trim() : null,
heartbeatAt,
role: parseRoleFromAgentsMd(agentsMd), role: parseRoleFromAgentsMd(agentsMd),
noteValues: parseBulletValues(identityMd), noteValues: parseBulletValues(identityMd),
workspaceRoot, workspaceRoot,
@@ -141,6 +163,45 @@ async function fetchTaskBuckets(aliases: string[]) {
}; };
} }
async function fetchAgentEventSummary(aliases: string[]) {
const placeholders = aliases.map(() => "?").join(", ");
const latestEvent = await all<TaskEvent>(
`SELECT * FROM task_events WHERE assignee IN (${placeholders}) ORDER BY created_at DESC LIMIT 1`,
aliases,
);
const failureRows = await all<{ count: number }>(
`SELECT COUNT(*) as count FROM task_events
WHERE assignee IN (${placeholders}) AND event_type = 'dispatch_failed'`,
aliases,
);
return {
lastEvent: latestEvent[0] || null,
failureStreak: failureRows[0]?.count || 0,
};
}
function deriveHeartbeatAgeMinutes(heartbeatAt: string | null) {
if (!heartbeatAt) {
return null;
}
const diffMs = Date.now() - new Date(heartbeatAt).getTime();
return Math.max(0, Math.round(diffMs / 60000));
}
function deriveStatus(activeTaskCount: number, heartbeatAt: string | null): AgentStatus {
if (activeTaskCount > 0) {
return "busy";
}
const heartbeatAge = deriveHeartbeatAgeMinutes(heartbeatAt);
if (heartbeatAge !== null && heartbeatAge > 180) {
return "idle";
}
return "active";
}
async function buildOpenClawAgents() { async function buildOpenClawAgents() {
const config = readOpenClawConfig(); const config = readOpenClawConfig();
const agents = config.agents?.list || []; const agents = config.agents?.list || [];
@@ -154,6 +215,8 @@ async function buildOpenClawAgents() {
agentConfig.identity?.name || agentConfig.name || agentConfig.id, agentConfig.identity?.name || agentConfig.name || agentConfig.id,
]; ];
const taskBuckets = await fetchTaskBuckets(aliases); const taskBuckets = await fetchTaskBuckets(aliases);
const eventSummary = await fetchAgentEventSummary(aliases);
const heartbeatAgeMinutes = deriveHeartbeatAgeMinutes(workspace.heartbeatAt);
return { return {
slug: agentConfig.id, slug: agentConfig.id,
@@ -165,17 +228,22 @@ async function buildOpenClawAgents() {
role: agentConfig.identity?.theme || workspace.role, role: agentConfig.identity?.theme || workspace.role,
runtimePath: workspace.workspaceRoot || OPENCLAW_AGENTS_DIR, runtimePath: workspace.workspaceRoot || OPENCLAW_AGENTS_DIR,
configPath: OPENCLAW_CONFIG_PATH, configPath: OPENCLAW_CONFIG_PATH,
defaultDispatchMethod: "openclaw-swarm",
model: agentConfig.model?.primary || null, model: agentConfig.model?.primary || null,
emoji: agentConfig.identity?.emoji || "🦞", emoji: agentConfig.identity?.emoji || "🦞",
channels: getOpenClawChannels(agentConfig.id, config), channels: getOpenClawChannels(agentConfig.id, config),
tools: workspace.tools, tools: workspace.tools,
capabilities: workspace.capabilities, capabilities: workspace.capabilities,
files: workspace.files, files: workspace.files,
status: deriveStatus(taskBuckets.activeTasks.length), status: deriveStatus(taskBuckets.activeTasks.length, workspace.heartbeatAt),
workload: taskBuckets.activeTasks.length, workload: taskBuckets.activeTasks.length,
activeTasks: taskBuckets.activeTasks, activeTasks: taskBuckets.activeTasks,
completedTasks: taskBuckets.completedTasks, completedTasks: taskBuckets.completedTasks,
currentTask: workspace.currentTask, currentTask: workspace.currentTask,
heartbeatAt: workspace.heartbeatAt,
heartbeatAgeMinutes,
lastEvent: eventSummary.lastEvent,
failureStreak: eventSummary.failureStreak,
notes: workspace.noteValues, notes: workspace.noteValues,
} satisfies FleetAgent; } satisfies FleetAgent;
}), }),
@@ -183,58 +251,38 @@ async function buildOpenClawAgents() {
} }
async function buildZeroClawAgents() { async function buildZeroClawAgents() {
const configuredAgents = [ const configuredAgents = FLEET_CONFIG.zeroclawAgents.map((agent) => ({
{ ...agent,
slug: "grizzley-zeroclaw", runtimePath:
assignmentKey: "grizzley-zeroclaw", agent.slug === "grizzley-zeroclaw"
aliases: ["grizzley-zeroclaw", "ZeroClaw Grizzley", "grizzley"], ? ZEROCLAW_PRIMARY_DIR
name: "ZeroClaw Grizzley", : agent.slug === "ice-zeroclaw"
host: "grizzley", ? ZEROCLAW_CONTROL_DIR
role: "Edge host operator for grizzley", : agent.runtimePath,
runtimePath: ZEROCLAW_PRIMARY_DIR, }));
configPath: path.join(ZEROCLAW_PRIMARY_DIR, "config.toml"),
model: "glm-4.7",
emoji: "🛰️",
channels: [
{ label: "Gateway", value: "HTTP gateway :3000" },
{ label: "Access", value: "paired remote gateway via ice" },
],
notes: ["Host-scoped runtime for Traefik, OpenCode, and local services."],
},
{
slug: "ice-zeroclaw",
assignmentKey: "ice-zeroclaw",
aliases: ["ice-zeroclaw", "ZeroClaw Ice", "ZeroClaw Admin", "ice"],
name: "ZeroClaw Ice",
host: "ice",
role: "Control-plane operator for ice",
runtimePath: ZEROCLAW_CONTROL_DIR,
configPath: path.join(ZEROCLAW_CONTROL_DIR, "config.toml"),
model: "glm-5",
emoji: "🧊",
channels: [
{ label: "Telegram", value: "Homelab-Ice topics 11-15" },
{ label: "Gateway", value: "paired webhook + status routing" },
],
notes: ["Control-plane runtime and topic router for remote host delegation."],
},
];
return Promise.all( return Promise.all(
configuredAgents.map(async (configuredAgent) => { configuredAgents.map(async (configuredAgent) => {
const workspace = readWorkspaceAgent(configuredAgent.runtimePath, configuredAgent.name); const workspace = readWorkspaceAgent(configuredAgent.runtimePath, configuredAgent.name);
const taskBuckets = await fetchTaskBuckets(configuredAgent.aliases); const taskBuckets = await fetchTaskBuckets(configuredAgent.aliases);
const eventSummary = await fetchAgentEventSummary(configuredAgent.aliases);
const heartbeatAgeMinutes = deriveHeartbeatAgeMinutes(workspace.heartbeatAt);
return { return {
...configuredAgent, ...configuredAgent,
family: "zeroclaw" as const, family: "zeroclaw" as const,
defaultDispatchMethod: configuredAgent.dispatch.method,
tools: workspace.tools, tools: workspace.tools,
capabilities: workspace.capabilities, capabilities: workspace.capabilities,
files: workspace.files, files: workspace.files,
status: deriveStatus(taskBuckets.activeTasks.length), status: deriveStatus(taskBuckets.activeTasks.length, workspace.heartbeatAt),
workload: taskBuckets.activeTasks.length, workload: taskBuckets.activeTasks.length,
activeTasks: taskBuckets.activeTasks, activeTasks: taskBuckets.activeTasks,
completedTasks: taskBuckets.completedTasks, completedTasks: taskBuckets.completedTasks,
currentTask: workspace.currentTask, currentTask: workspace.currentTask,
heartbeatAt: workspace.heartbeatAt,
heartbeatAgeMinutes,
lastEvent: eventSummary.lastEvent,
failureStreak: eventSummary.failureStreak,
notes: [...configuredAgent.notes, ...workspace.noteValues], notes: [...configuredAgent.notes, ...workspace.noteValues],
}; };
}), }),
@@ -250,6 +298,11 @@ export async function listFleetAgents() {
return [...openclawAgents, ...zeroclawAgents]; return [...openclawAgents, ...zeroclawAgents];
} }
export async function findAgentByAssignmentKey(assignmentKey: string) {
const agents = await listFleetAgents();
return agents.find((agent) => agent.assignmentKey === assignmentKey || agent.aliases.includes(assignmentKey)) || null;
}
export async function listArchitecture() { export async function listArchitecture() {
const agents = await listFleetAgents(); const agents = await listFleetAgents();
return { return {
@@ -263,6 +316,3 @@ export async function listArchitecture() {
})), })),
}; };
} }
function deriveStatus(activeTaskCount: number): AgentStatus {
return activeTaskCount > 0 ? "busy" : "active";
}

View File

@@ -7,6 +7,41 @@ const DB_PATH = process.env.DB_PATH || path.join(process.cwd(), "data", "tasks.d
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true }); fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
let database: sqlite3.Database | null = null; let database: sqlite3.Database | null = null;
let databaseReady: Promise<void> | null = null;
function getColumnNames(db: sqlite3.Database, tableName: string) {
return new Promise<string[]>((resolve, reject) => {
db.all<{ name: string }>(`PRAGMA table_info(${tableName})`, [], (error, rows) => {
if (error) {
reject(error);
return;
}
resolve(rows.map((row) => row.name));
});
});
}
async function ensureColumn(
db: sqlite3.Database,
tableName: string,
columnName: string,
definition: string,
) {
const columnNames = await getColumnNames(db, tableName);
if (columnNames.includes(columnName)) {
return;
}
await new Promise<void>((resolve, reject) => {
db.run(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${definition}`, (error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
function getDatabase() { function getDatabase() {
if (database) { if (database) {
@@ -41,12 +76,63 @@ function getDatabase() {
timestamp TEXT NOT NULL DEFAULT (datetime('now')) timestamp TEXT NOT NULL DEFAULT (datetime('now'))
) )
`); `);
database?.run(`
CREATE TABLE IF NOT EXISTS task_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL,
assignee TEXT NOT NULL DEFAULT '',
family TEXT,
host TEXT NOT NULL DEFAULT '',
event_type TEXT NOT NULL,
state TEXT,
summary TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
database?.run(`
CREATE INDEX IF NOT EXISTS idx_task_events_task_time
ON task_events(task_id, created_at DESC)
`);
database?.run(`
CREATE INDEX IF NOT EXISTS idx_task_events_assignee_time
ON task_events(assignee, created_at DESC)
`);
}); });
databaseReady = (async () => {
if (!database) {
return;
}
await ensureColumn(database, "tasks", "family", "TEXT");
await ensureColumn(database, "tasks", "target_host", "TEXT NOT NULL DEFAULT ''");
await ensureColumn(database, "tasks", "target_channel", "TEXT NOT NULL DEFAULT ''");
await ensureColumn(database, "tasks", "dispatch_method", "TEXT NOT NULL DEFAULT 'manual'");
await ensureColumn(database, "tasks", "dispatch_state", "TEXT NOT NULL DEFAULT 'planned'");
await ensureColumn(database, "tasks", "template_key", "TEXT");
await ensureColumn(database, "tasks", "repo_slug", "TEXT");
await ensureColumn(database, "tasks", "base_branch", "TEXT");
await ensureColumn(database, "tasks", "preferred_agent", "TEXT");
await ensureColumn(database, "tasks", "reasoning_effort", "TEXT");
await ensureColumn(database, "tasks", "model_hint", "TEXT");
await ensureColumn(database, "tasks", "last_dispatch_at", "TEXT");
await ensureColumn(database, "tasks", "acknowledged_at", "TEXT");
await ensureColumn(database, "tasks", "last_error", "TEXT");
})();
return database; return database;
} }
export function all<T>(sql: string, params: unknown[] = []) { async function ensureReady() {
getDatabase();
if (databaseReady) {
await databaseReady;
}
}
export async function all<T>(sql: string, params: unknown[] = []) {
await ensureReady();
const db = getDatabase(); const db = getDatabase();
return new Promise<T[]>((resolve, reject) => { return new Promise<T[]>((resolve, reject) => {
db.all(sql, params, (error, rows) => { db.all(sql, params, (error, rows) => {
@@ -59,7 +145,8 @@ export function all<T>(sql: string, params: unknown[] = []) {
}); });
} }
export function get<T>(sql: string, params: unknown[] = []) { export async function get<T>(sql: string, params: unknown[] = []) {
await ensureReady();
const db = getDatabase(); const db = getDatabase();
return new Promise<T | undefined>((resolve, reject) => { return new Promise<T | undefined>((resolve, reject) => {
db.get(sql, params, (error, row) => { db.get(sql, params, (error, row) => {
@@ -72,7 +159,8 @@ export function get<T>(sql: string, params: unknown[] = []) {
}); });
} }
export function run(sql: string, params: unknown[] = []) { export async function run(sql: string, params: unknown[] = []) {
await ensureReady();
const db = getDatabase(); const db = getDatabase();
return new Promise<{ lastID: number; changes: number }>((resolve, reject) => { return new Promise<{ lastID: number; changes: number }>((resolve, reject) => {
db.run(sql, params, function onRun(error) { db.run(sql, params, function onRun(error) {

254
lib/dispatch.ts Normal file
View File

@@ -0,0 +1,254 @@
import fs from "node:fs";
import path from "node:path";
import { promisify } from "node:util";
import { execFile } from "node:child_process";
import {
REPO_ACCESS_ROOTS,
SWARM_REPO_MAP_FILE,
SWARM_TASKS_FILE,
SWARM_WORKTREES_DIR,
ZEROCLAW_WEBHOOK_TIMEOUT_MS,
} from "@/lib/fleet-config";
import { findAgentByAssignmentKey } from "@/lib/agents";
import { appendTaskEvent, findTask, updateTask } from "@/lib/tasks";
import type { DispatchState } from "@/lib/types";
const execFileAsync = promisify(execFile);
type DispatchResult = {
state: DispatchState;
summary: string;
detail: string;
};
function defaultModelForAgent(agent: string) {
switch (agent) {
case "opencode":
return "zai-coding-plan/glm-4.7";
case "gemini":
return "gemini-3.1-pro";
default:
return "gpt-5.3-codex";
}
}
function readRepoMap() {
if (!fs.existsSync(SWARM_REPO_MAP_FILE)) {
throw new Error(`missing_repo_map:${SWARM_REPO_MAP_FILE}`);
}
return JSON.parse(fs.readFileSync(SWARM_REPO_MAP_FILE, "utf8")) as Record<string, string>;
}
function ensureAllowedRepoPath(repoPath: string) {
const resolved = path.resolve(repoPath);
const allowed = REPO_ACCESS_ROOTS.some((root) => resolved.startsWith(path.resolve(root)));
if (!allowed) {
throw new Error(`repo_path_not_allowed:${resolved}`);
}
return resolved;
}
function ensureSwarmRegistry() {
fs.mkdirSync(path.dirname(SWARM_TASKS_FILE), { recursive: true });
if (!fs.existsSync(SWARM_TASKS_FILE)) {
fs.writeFileSync(SWARM_TASKS_FILE, JSON.stringify({ tasks: [] }, null, 2));
}
}
async function dispatchOpenClawTask(taskId: number) {
const task = await findTask(taskId);
if (!task) {
throw new Error("task_not_found");
}
if (!task.repo_slug) {
throw new Error("repo_slug_required_for_openclaw_dispatch");
}
const repoMap = readRepoMap();
const repoPath = ensureAllowedRepoPath(repoMap[task.repo_slug] || "");
if (!repoPath || !fs.existsSync(path.join(repoPath, ".git"))) {
throw new Error(`repo_not_available:${task.repo_slug}`);
}
const agentName = task.preferred_agent || "codex";
const taskKey = `taskboard-${task.id}`;
const repoName = path.basename(repoPath);
const worktree = path.join(SWARM_WORKTREES_DIR, repoName, taskKey);
const branch = `feat/taskboard-${task.id}`;
const baseBranch = task.base_branch || "main";
fs.mkdirSync(path.dirname(worktree), { recursive: true });
if (!fs.existsSync(worktree)) {
try {
await execFileAsync("git", ["-C", repoPath, "fetch", "origin", baseBranch]);
} catch {
// Keep going. Many local repos already have the base branch available.
}
await execFileAsync("git", ["-C", repoPath, "worktree", "add", worktree, "-b", branch, `origin/${baseBranch}`]);
}
ensureSwarmRegistry();
const registry = JSON.parse(fs.readFileSync(SWARM_TASKS_FILE, "utf8")) as { tasks?: Array<Record<string, unknown>> };
const tasks = Array.isArray(registry.tasks) ? registry.tasks : [];
const existing = tasks.find((entry) => entry.taskboardTaskId === task.id);
if (!existing) {
tasks.push({
id: taskKey,
agent: agentName,
repo: repoName,
repoPath,
repoSlug: task.repo_slug,
worktree,
branch,
baseBranch,
tmuxSession: `${agentName}-${taskKey}`,
description: task.description,
prompt: `Taskboard task #${task.id}: ${task.title}\n\n${task.description}`,
status: "queued",
notifyOnComplete: true,
attempts: 0,
maxAttempts: 3,
model: task.model_hint || defaultModelForAgent(agentName),
reasoning: task.reasoning_effort || "high",
taskboardTaskId: task.id,
startedAt: null,
completedAt: null,
createdAt: Date.now(),
checks: {
prCreated: false,
ciPassed: false,
reviewPassed: false,
},
});
}
fs.writeFileSync(SWARM_TASKS_FILE, JSON.stringify({ tasks }, null, 2));
return {
state: "dispatched" as const,
summary: `Queued in OpenClaw swarm for ${agentName}`,
detail: `${task.repo_slug} -> ${worktree}`,
};
}
async function dispatchZeroClawTask(taskId: number) {
const task = await findTask(taskId);
if (!task) {
throw new Error("task_not_found");
}
const agent = await findAgentByAssignmentKey(task.assignee);
if (!agent) {
throw new Error("assignee_not_found");
}
const urlEnv = agent.slug === "grizzley-zeroclaw" ? "ZEROCLAW_GRIZZLEY_URL" : "ZEROCLAW_ICE_URL";
const tokenEnv = agent.slug === "grizzley-zeroclaw" ? "ZEROCLAW_GRIZZLEY_TOKEN" : "ZEROCLAW_ICE_TOKEN";
const baseUrl = process.env[urlEnv];
if (!baseUrl) {
throw new Error(`missing_gateway_url:${urlEnv}`);
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), ZEROCLAW_WEBHOOK_TIMEOUT_MS);
const response = await fetch(`${baseUrl.replace(/\/$/, "")}/webhook`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(process.env[tokenEnv] ? { Authorization: `Bearer ${process.env[tokenEnv]}` } : {}),
},
body: JSON.stringify({
message: `Taskboard task #${task.id}: ${task.title}\nHost: ${task.target_host || agent.host}\nChannel: ${task.target_channel || "n/a"}\n\n${task.description}`,
}),
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) {
const responseText = await response.text();
throw new Error(`webhook_failed:${response.status}:${responseText}`);
}
const responseText = await response.text();
return {
state: "dispatched" as const,
summary: `Posted to ${agent.name} webhook`,
detail: responseText || `${agent.host} webhook accepted`,
};
}
export async function dispatchTask(taskId: number) {
const task = await findTask(taskId);
if (!task) {
throw new Error("task_not_found");
}
if (!task.assignee) {
throw new Error("assignee_required");
}
const agent = await findAgentByAssignmentKey(task.assignee);
if (!agent) {
throw new Error("assignee_not_found");
}
await appendTaskEvent({
taskId,
assignee: task.assignee,
family: task.family,
host: task.target_host,
eventType: "dispatch_requested",
state: task.dispatch_state,
summary: `Dispatch requested for ${agent.name}`,
detail: task.description,
});
try {
const result =
agent.family === "openclaw" ? await dispatchOpenClawTask(taskId) : await dispatchZeroClawTask(taskId);
const updated = await updateTask(taskId, {
status: task.status === "Backlog" ? "Todo" : task.status,
dispatch_state: result.state,
last_dispatch_at: new Date().toISOString(),
last_error: null,
});
if (!updated) {
throw new Error("task_not_found_after_dispatch");
}
await appendTaskEvent({
taskId,
assignee: updated.assignee,
family: updated.family,
host: updated.target_host,
eventType: "dispatch_succeeded",
state: updated.dispatch_state,
summary: result.summary,
detail: result.detail,
});
return updated;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const updated = await updateTask(taskId, {
dispatch_state: "failed",
last_error: message,
});
await appendTaskEvent({
taskId,
assignee: task.assignee,
family: task.family,
host: task.target_host,
eventType: "dispatch_failed",
state: "failed",
summary: "Dispatch failed",
detail: message,
});
if (!updated) {
throw error;
}
return updated;
}
}

View File

@@ -1,124 +1,48 @@
import type { ArchitectureDocument } from "@/lib/types"; import fs from "node:fs";
import path from "node:path";
export const OPENCLAW_RUNTIME_ROOT = import type { FleetConfig, TaskTemplate } from "@/lib/types";
process.env.OPENCLAW_RUNTIME_ROOT || "/home/bear/.openclaw";
export const OPENCLAW_AGENTS_DIR =
process.env.AGENTS_DIR || "/home/bear/.openclaw/agents";
export const OPENCLAW_CONFIG_PATH =
process.env.OPENCLAW_CONFIG || "/home/bear/.openclaw/openclaw.json";
export const ZEROCLAW_PRIMARY_DIR =
process.env.ZEROCLAW_PRIMARY_DIR || "/app/zeroclaw/grizzley";
export const ZEROCLAW_CONTROL_DIR =
process.env.ZEROCLAW_CONTROL_DIR || "/app/zeroclaw/ice";
export const ARCHITECTURE_DOCUMENT: ArchitectureDocument = { export const OPENCLAW_RUNTIME_ROOT = process.env.OPENCLAW_RUNTIME_ROOT || "/home/bear/.openclaw";
generatedAt: new Date().toISOString(), export const OPENCLAW_AGENTS_DIR = process.env.AGENTS_DIR || "/home/bear/.openclaw/agents";
export const OPENCLAW_CONFIG_PATH = process.env.OPENCLAW_CONFIG || "/home/bear/.openclaw/openclaw.json";
export const SWARM_TASKS_FILE = process.env.SWARM_TASKS_FILE || "/app/swarm/active-tasks.json";
export const SWARM_REPO_MAP_FILE = process.env.SWARM_REPO_MAP_FILE || "/app/swarm/repo-map.json";
export const SWARM_WORKTREES_DIR = process.env.SWARM_WORKTREES_DIR || "/app/swarm/worktrees";
export const REPO_ACCESS_ROOTS = (process.env.REPO_ACCESS_ROOTS || "/srv/apps,/home/bear")
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
export const ZEROCLAW_PRIMARY_DIR = process.env.ZEROCLAW_PRIMARY_DIR || "/app/zeroclaw/grizzley";
export const ZEROCLAW_CONTROL_DIR = process.env.ZEROCLAW_CONTROL_DIR || "/app/zeroclaw/ice";
export const ZEROCLAW_WEBHOOK_TIMEOUT_MS = Number(process.env.ZEROCLAW_WEBHOOK_TIMEOUT_MS || "15000");
const CONFIG_DIR = path.join(process.cwd(), "config");
const FLEET_CONFIG_PATH = path.join(CONFIG_DIR, "fleet.json");
const TASK_TEMPLATE_PATH = path.join(CONFIG_DIR, "task-templates.json");
function readJsonFile<T>(filePath: string, fallback: T): T {
try {
return JSON.parse(fs.readFileSync(filePath, "utf8")) as T;
} catch {
return fallback;
}
}
export const FLEET_CONFIG = readJsonFile<FleetConfig>(FLEET_CONFIG_PATH, {
title: "Claw Fleet Architecture", title: "Claw Fleet Architecture",
overview: [ overview: [],
"OpenClaw is the ubuntu-local orchestration layer and Telegram HQ entrypoint.", topologyDiagram: "",
"ZeroClaw provides host-scoped remote administration on grizzley and ice.", sections: [],
"Task assignment is shared across both families in a single board.", zeroclawAgents: [],
], });
topologyDiagram: String.raw`
Telegram / Forum Topics
|
+----------------+----------------+
| |
v v
OpenClaw gateway ZeroClaw control
ubuntu :18789 ice zeroclaw-admin
local swarm topic router / paired gateway
| |
+------------+--------------------+
|
v
shared taskboard UI
|
+-----------+-----------+
| |
v v
OpenClaw agents ZeroClaw runtimes
ubuntu-local swarm grizzley / ice
`,
sections: [
{
id: "openclaw",
title: "OpenClaw",
summary:
"Primary orchestration family on ubuntu. Owns local swarm execution, HQ Telegram bindings, and ubuntu-host workflows.",
runtime: [
{ label: "Host", value: "ubuntu (192.168.50.61)" },
{ label: "Service", value: "openclaw.service" },
{ label: "Runtime", value: "/srv/state/openclaw/current" },
{ label: "Config", value: "/home/bear/.openclaw/openclaw.json" },
],
channels: [
{ label: "Telegram DM", value: "allowlist: tg:5512934365" },
{ label: "Forum Group", value: "Homelab HQ (-1003809447066)" },
{ label: "Gateway", value: "LAN bind :18789 with token auth" },
],
configuredAgents: [
"main",
"ubuntu",
"docs",
"gitea-admin",
"planner",
"builder",
"reviewer",
],
diagram: String.raw`
OpenClaw HQ topics
topic 2 -> ubuntu
topic 3 -> docs
topic 4 -> gitea-admin
topics 5-9 -> main, then delegate to host-scoped ZeroClaw paths
main export const TASK_TEMPLATES = readJsonFile<TaskTemplate[]>(TASK_TEMPLATE_PATH, []);
|- ubuntu
|- docs
|- gitea-admin
|- planner
|- builder
\- reviewer
`,
notes: [
"Remote host personas were removed from OpenClaw.",
"OpenClaw remains gateway-only on ubuntu.",
],
},
{
id: "zeroclaw",
title: "ZeroClaw",
summary:
"Host-scoped runtime family for remote administration. Grizzley is the primary active gateway. Ice is the control-plane runtime and topic router.",
runtime: [
{ label: "Primary", value: "/srv/state/zeroclaw/current on grizzley" },
{ label: "Control", value: "/home/bear/.zeroclaw-admin on ice" },
{ label: "Primary Service", value: "zeroclaw.service" },
{ label: "Control Service", value: "zeroclaw-admin.service" },
],
channels: [
{ label: "Grizzley Gateway", value: "HTTP gateway :3000, pairing required" },
{ label: "Ice Telegram", value: "Homelab-Ice (-1003728617160)" },
{ label: "Remote Routing", value: "paired status/webhook to grizzley and pve" },
],
configuredAgents: ["grizzley-zeroclaw", "ice-zeroclaw"],
diagram: String.raw`
Homelab-Ice topics
11 -> local ice operations
12 -> grizzley paired gateway
13 -> pve paired gateway
14 -> truenas blocker message
15 -> panda rollout pending
ice zeroclaw-admin export const ARCHITECTURE_DOCUMENT = {
-> zeroclaw-remote-gateway.sh status grizzley|pve generatedAt: new Date().toISOString(),
-> zeroclaw-remote-gateway.sh webhook grizzley|pve "<message>" title: FLEET_CONFIG.title,
`, overview: FLEET_CONFIG.overview,
notes: [ sections: FLEET_CONFIG.sections,
"Grizzley is host-scoped and should not proxy other hosts directly.", topologyDiagram: FLEET_CONFIG.topologyDiagram,
"Ice still uses host-local secret/encryption state under /home/bear/.zeroclaw-admin.",
],
},
],
}; };

View File

@@ -2,39 +2,133 @@ import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { all, get, run } from "@/lib/db"; import { all, get, run } from "@/lib/db";
import type { TaskPriority, TaskRecord, TaskStatus } from "@/lib/types"; import { TASK_TEMPLATES } from "@/lib/fleet-config";
import type {
const VALID_STATUSES: TaskStatus[] = [ AgentFamily,
"Backlog", DispatchMethod,
"Todo", DispatchState,
"In Progress", TaskEvent,
"Review", TaskEventType,
"Done", TaskPriority,
]; TaskRecord,
TaskStatus,
TaskTemplate,
} from "@/lib/types";
const VALID_STATUSES: TaskStatus[] = ["Backlog", "Todo", "In Progress", "Review", "Done"];
const VALID_PRIORITIES: TaskPriority[] = ["Low", "Medium", "High", "Critical"]; const VALID_PRIORITIES: TaskPriority[] = ["Low", "Medium", "High", "Critical"];
const VALID_FAMILIES: AgentFamily[] = ["openclaw", "zeroclaw"];
const VALID_DISPATCH_METHODS: DispatchMethod[] = ["manual", "openclaw-swarm", "zeroclaw-webhook"];
const VALID_DISPATCH_STATES: DispatchState[] = [
"planned",
"assigned",
"dispatched",
"acknowledged",
"completed",
"failed",
];
const WIKI_DIR = process.env.WIKI_DIR || path.join(process.cwd(), "wiki"); const WIKI_DIR = process.env.WIKI_DIR || path.join(process.cwd(), "wiki");
fs.mkdirSync(WIKI_DIR, { recursive: true }); fs.mkdirSync(WIKI_DIR, { recursive: true });
type DatabaseTaskRow = Omit<TaskRecord, "tags"> & { tags: string }; type DatabaseTaskRow = Omit<TaskRecord, "tags"> & { tags: string };
export function normalizeTask(row: DatabaseTaskRow): TaskRecord { function parseTags(raw: string) {
let tags: string[] = [];
try { try {
tags = JSON.parse(row.tags || "[]"); const parsed = JSON.parse(raw || "[]");
return Array.isArray(parsed) ? parsed.filter((tag) => typeof tag === "string") : [];
} catch { } catch {
tags = []; return [];
}
}
function extractTagValue(tags: string[], prefix: string) {
const match = tags.find((tag) => tag.startsWith(prefix));
return match ? match.slice(prefix.length) : null;
}
function deriveDispatchState(task: Partial<TaskRecord>, existing?: TaskRecord): DispatchState {
if (task.dispatch_state && VALID_DISPATCH_STATES.includes(task.dispatch_state)) {
return task.dispatch_state;
} }
if (task.status === "Done") {
return "completed";
}
const status = task.status ?? existing?.status;
const priorState = existing?.dispatch_state ?? "planned";
if (status === "In Progress" || status === "Review") {
return priorState === "failed" ? priorState : "acknowledged";
}
if (status === "Todo" && (priorState === "planned" || priorState === "assigned")) {
return existing?.assignee || task.assignee ? "assigned" : "planned";
}
return existing?.assignee || task.assignee ? priorState === "planned" ? "assigned" : priorState : "planned";
}
function deriveAcknowledgedAt(
nextState: DispatchState,
existing?: TaskRecord,
explicitValue?: string | null,
) {
if (explicitValue !== undefined) {
return explicitValue;
}
if (nextState === "acknowledged" || nextState === "completed") {
return existing?.acknowledged_at || new Date().toISOString();
}
return null;
}
function normalizeNullableString(value: unknown) {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
export function normalizeTask(row: DatabaseTaskRow): TaskRecord {
return { return {
...row, ...row,
tags, tags: parseTags(row.tags),
family: row.family || null,
target_host: row.target_host || "",
target_channel: row.target_channel || "",
dispatch_method: row.dispatch_method || "manual",
dispatch_state: row.dispatch_state || "planned",
template_key: row.template_key || null,
repo_slug: row.repo_slug || null,
base_branch: row.base_branch || null,
preferred_agent: row.preferred_agent || null,
reasoning_effort: row.reasoning_effort || null,
model_hint: row.model_hint || null,
last_dispatch_at: row.last_dispatch_at || null,
acknowledged_at: row.acknowledged_at || null,
last_error: row.last_error || null,
}; };
} }
export async function listTasks() { export async function listTasks() {
const rows = await all<DatabaseTaskRow>("SELECT * FROM tasks ORDER BY id DESC"); const rows = await all<DatabaseTaskRow>(
`SELECT * FROM tasks
ORDER BY
CASE dispatch_state WHEN 'failed' THEN 0 ELSE 1 END,
CASE status
WHEN 'In Progress' THEN 0
WHEN 'Review' THEN 1
WHEN 'Todo' THEN 2
WHEN 'Backlog' THEN 3
ELSE 4
END,
id DESC`,
);
return rows.map(normalizeTask);
}
export async function listFailedTasks() {
const rows = await all<DatabaseTaskRow>(
"SELECT * FROM tasks WHERE dispatch_state = 'failed' ORDER BY updated_at DESC",
);
return rows.map(normalizeTask); return rows.map(normalizeTask);
} }
@@ -43,10 +137,7 @@ export async function findTask(id: number) {
return row ? normalizeTask(row) : null; return row ? normalizeTask(row) : null;
} }
export function validateTaskPayload( export function validateTaskPayload(payload: Partial<TaskRecord> & { tags?: unknown }, partial = false) {
payload: Partial<TaskRecord> & { tags?: unknown },
partial = false,
) {
const errors: string[] = []; const errors: string[] = [];
if (!partial || payload.title !== undefined) { if (!partial || payload.title !== undefined) {
@@ -63,6 +154,18 @@ export function validateTaskPayload(
errors.push(`priority must be one of: ${VALID_PRIORITIES.join(", ")}`); errors.push(`priority must be one of: ${VALID_PRIORITIES.join(", ")}`);
} }
if (payload.family !== undefined && payload.family !== null && !VALID_FAMILIES.includes(payload.family)) {
errors.push(`family must be one of: ${VALID_FAMILIES.join(", ")}`);
}
if (payload.dispatch_method !== undefined && !VALID_DISPATCH_METHODS.includes(payload.dispatch_method)) {
errors.push(`dispatch_method must be one of: ${VALID_DISPATCH_METHODS.join(", ")}`);
}
if (payload.dispatch_state !== undefined && !VALID_DISPATCH_STATES.includes(payload.dispatch_state)) {
errors.push(`dispatch_state must be one of: ${VALID_DISPATCH_STATES.join(", ")}`);
}
if (payload.tags !== undefined && !Array.isArray(payload.tags)) { if (payload.tags !== undefined && !Array.isArray(payload.tags)) {
errors.push("tags must be an array of strings"); errors.push("tags must be an array of strings");
} }
@@ -78,6 +181,9 @@ function buildWikiMarkdown(task: TaskRecord) {
- Assignee: ${task.assignee || "Unassigned"} - Assignee: ${task.assignee || "Unassigned"}
- Priority: ${task.priority} - Priority: ${task.priority}
- Status: ${task.status} - Status: ${task.status}
- Dispatch: ${task.dispatch_method} / ${task.dispatch_state}
- Host: ${task.target_host || "n/a"}
- Channel: ${task.target_channel || "n/a"}
- Tags: ${renderedTags} - Tags: ${renderedTags}
- Created: ${task.created_at} - Created: ${task.created_at}
- Completed: ${task.completed_at || new Date().toISOString()} - Completed: ${task.completed_at || new Date().toISOString()}
@@ -98,17 +204,77 @@ async function writeWikiForTask(task: TaskRecord) {
fs.writeFileSync(path.join(WIKI_DIR, fileName), buildWikiMarkdown(task), "utf8"); fs.writeFileSync(path.join(WIKI_DIR, fileName), buildWikiMarkdown(task), "utf8");
} }
export async function appendTaskEvent(input: {
taskId: number;
assignee?: string;
family?: AgentFamily | null;
host?: string;
eventType: TaskEventType;
state?: DispatchState | null;
summary: string;
detail?: string;
}) {
await run(
`INSERT INTO task_events (task_id, assignee, family, host, event_type, state, summary, detail)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
input.taskId,
input.assignee || "",
input.family || null,
input.host || "",
input.eventType,
input.state || null,
input.summary,
input.detail || "",
],
);
}
export async function listTaskEvents(taskId?: number, limit = 50) {
const rows = taskId
? await all<TaskEvent>(
"SELECT * FROM task_events WHERE task_id = ? ORDER BY created_at DESC LIMIT ?",
[taskId, limit],
)
: await all<TaskEvent>("SELECT * FROM task_events ORDER BY created_at DESC LIMIT ?", [limit]);
return rows;
}
export async function listTaskTemplates(): Promise<TaskTemplate[]> {
return TASK_TEMPLATES;
}
export async function createTask(input: Partial<TaskRecord>) { export async function createTask(input: Partial<TaskRecord>) {
const tags = Array.isArray(input.tags) ? input.tags.filter((tag) => typeof tag === "string") : [];
const dispatchState = deriveDispatchState(input);
const result = await run( const result = await run(
`INSERT INTO tasks (title, description, assignee, priority, status, tags) `INSERT INTO tasks (
VALUES (?, ?, ?, ?, ?, ?)`, title, description, assignee, family, target_host, target_channel,
dispatch_method, dispatch_state, template_key, repo_slug, base_branch,
preferred_agent, reasoning_effort, model_hint, priority, status, tags,
last_dispatch_at, acknowledged_at, last_error
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[ [
input.title?.trim() || "", input.title?.trim() || "",
input.description || "", input.description || "",
input.assignee || "", input.assignee || "",
input.family || null,
input.target_host || "",
input.target_channel || "",
input.dispatch_method || "manual",
dispatchState,
normalizeNullableString(input.template_key),
normalizeNullableString(input.repo_slug) || extractTagValue(tags, "repo:"),
normalizeNullableString(input.base_branch) || extractTagValue(tags, "base:"),
normalizeNullableString(input.preferred_agent) || extractTagValue(tags, "agent:"),
normalizeNullableString(input.reasoning_effort) || extractTagValue(tags, "reasoning:"),
normalizeNullableString(input.model_hint) || extractTagValue(tags, "model:"),
input.priority || "Medium", input.priority || "Medium",
input.status || "Backlog", input.status || "Backlog",
JSON.stringify(Array.isArray(input.tags) ? input.tags.filter((tag) => typeof tag === "string") : []), JSON.stringify(tags),
input.last_dispatch_at || null,
deriveAcknowledgedAt(dispatchState),
normalizeNullableString(input.last_error),
], ],
); );
@@ -117,6 +283,17 @@ export async function createTask(input: Partial<TaskRecord>) {
throw new Error("failed_to_fetch_created_task"); throw new Error("failed_to_fetch_created_task");
} }
await appendTaskEvent({
taskId: task.id,
assignee: task.assignee,
family: task.family,
host: task.target_host,
eventType: "created",
state: task.dispatch_state,
summary: `Task created for ${task.assignee || "unassigned"} flow`,
detail: task.description,
});
return task; return task;
} }
@@ -126,24 +303,40 @@ export async function updateTask(id: number, input: Partial<TaskRecord>) {
return null; return null;
} }
const mergedTags = Array.isArray(input.tags) ? input.tags.filter((tag) => typeof tag === "string") : existing.tags;
const nextStatus = input.status ?? existing.status; const nextStatus = input.status ?? existing.status;
const completedAt = const nextDispatchState = deriveDispatchState({ ...existing, ...input, tags: mergedTags }, existing);
nextStatus === "Done" const completedAt = nextStatus === "Done" ? existing.completed_at || new Date().toISOString() : null;
? existing.completed_at || new Date().toISOString() const acknowledgedAt = deriveAcknowledgedAt(nextDispatchState, existing, input.acknowledged_at);
: null;
await run( await run(
`UPDATE tasks `UPDATE tasks
SET title = ?, description = ?, assignee = ?, priority = ?, status = ?, tags = ?, SET title = ?, description = ?, assignee = ?, family = ?, target_host = ?, target_channel = ?,
completed_at = ?, updated_at = datetime('now') dispatch_method = ?, dispatch_state = ?, template_key = ?, repo_slug = ?, base_branch = ?,
preferred_agent = ?, reasoning_effort = ?, model_hint = ?, priority = ?, status = ?, tags = ?,
last_dispatch_at = ?, acknowledged_at = ?, last_error = ?, completed_at = ?, updated_at = datetime('now')
WHERE id = ?`, WHERE id = ?`,
[ [
input.title?.trim() || existing.title, input.title?.trim() || existing.title,
input.description ?? existing.description, input.description ?? existing.description,
input.assignee ?? existing.assignee, input.assignee ?? existing.assignee,
input.family ?? existing.family,
input.target_host ?? existing.target_host,
input.target_channel ?? existing.target_channel,
input.dispatch_method ?? existing.dispatch_method,
nextDispatchState,
input.template_key ?? existing.template_key,
input.repo_slug ?? existing.repo_slug,
input.base_branch ?? existing.base_branch,
input.preferred_agent ?? existing.preferred_agent,
input.reasoning_effort ?? existing.reasoning_effort,
input.model_hint ?? existing.model_hint,
input.priority ?? existing.priority, input.priority ?? existing.priority,
nextStatus, nextStatus,
JSON.stringify(input.tags ?? existing.tags), JSON.stringify(mergedTags),
input.last_dispatch_at ?? existing.last_dispatch_at,
acknowledgedAt,
input.last_error ?? existing.last_error,
completedAt, completedAt,
id, id,
], ],
@@ -154,6 +347,23 @@ export async function updateTask(id: number, input: Partial<TaskRecord>) {
throw new Error("failed_to_fetch_updated_task"); throw new Error("failed_to_fetch_updated_task");
} }
const eventType: TaskEventType =
updated.dispatch_state === "acknowledged" && existing.dispatch_state !== "acknowledged"
? "acknowledged"
: updated.status !== existing.status
? "status_changed"
: "updated";
await appendTaskEvent({
taskId: updated.id,
assignee: updated.assignee,
family: updated.family,
host: updated.target_host,
eventType,
state: updated.dispatch_state,
summary: `${eventType.replace(/_/g, " ")} -> ${updated.status} / ${updated.dispatch_state}`,
detail: updated.description,
});
if (nextStatus === "Done" && existing.status !== "Done") { if (nextStatus === "Done" && existing.status !== "Done") {
await writeWikiForTask(updated); await writeWikiForTask(updated);
} }

View File

@@ -2,20 +2,82 @@ export type TaskStatus = "Backlog" | "Todo" | "In Progress" | "Review" | "Done";
export type TaskPriority = "Low" | "Medium" | "High" | "Critical"; export type TaskPriority = "Low" | "Medium" | "High" | "Critical";
export type AgentFamily = "openclaw" | "zeroclaw"; export type AgentFamily = "openclaw" | "zeroclaw";
export type AgentStatus = "active" | "busy" | "idle"; export type AgentStatus = "active" | "busy" | "idle";
export type DispatchMethod = "openclaw-swarm" | "zeroclaw-webhook" | "manual";
export type DispatchState =
| "planned"
| "assigned"
| "dispatched"
| "acknowledged"
| "completed"
| "failed";
export type TaskRecord = { export type TaskRecord = {
id: number; id: number;
title: string; title: string;
description: string; description: string;
assignee: string; assignee: string;
family: AgentFamily | null;
target_host: string;
target_channel: string;
dispatch_method: DispatchMethod;
dispatch_state: DispatchState;
template_key: string | null;
repo_slug: string | null;
base_branch: string | null;
preferred_agent: string | null;
reasoning_effort: string | null;
model_hint: string | null;
priority: TaskPriority; priority: TaskPriority;
status: TaskStatus; status: TaskStatus;
tags: string[]; tags: string[];
last_dispatch_at: string | null;
acknowledged_at: string | null;
last_error: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
completed_at: string | null; completed_at: string | null;
}; };
export type TaskTemplate = {
key: string;
title: string;
summary: string;
family: AgentFamily;
tags: string[];
defaults: {
priority: TaskPriority;
dispatchMethod: DispatchMethod;
targetHost?: string;
targetChannel?: string;
repoSlug?: string;
baseBranch?: string;
preferredAgent?: string;
reasoningEffort?: string;
};
};
export type TaskEventType =
| "created"
| "updated"
| "status_changed"
| "dispatch_requested"
| "dispatch_succeeded"
| "dispatch_failed"
| "acknowledged";
export type TaskEvent = {
id: number;
task_id: number;
assignee: string;
family: AgentFamily | null;
host: string;
event_type: TaskEventType;
state: DispatchState | null;
summary: string;
detail: string;
created_at: string;
};
export type WikiPageSummary = { export type WikiPageSummary = {
filename: string; filename: string;
title: string; title: string;
@@ -50,6 +112,7 @@ export type FleetAgent = {
role: string; role: string;
runtimePath: string; runtimePath: string;
configPath: string | null; configPath: string | null;
defaultDispatchMethod: DispatchMethod;
model: string | null; model: string | null;
emoji: string; emoji: string;
channels: AgentRouteSummary[]; channels: AgentRouteSummary[];
@@ -61,6 +124,10 @@ export type FleetAgent = {
activeTasks: TaskRecord[]; activeTasks: TaskRecord[];
completedTasks: TaskRecord[]; completedTasks: TaskRecord[];
currentTask: string | null; currentTask: string | null;
heartbeatAt: string | null;
heartbeatAgeMinutes: number | null;
lastEvent: TaskEvent | null;
failureStreak: number;
notes: string[]; notes: string[];
}; };
@@ -82,3 +149,33 @@ export type ArchitectureDocument = {
sections: FleetSection[]; sections: FleetSection[];
topologyDiagram: string; topologyDiagram: string;
}; };
export type ZeroClawAgentDefinition = {
slug: string;
assignmentKey: string;
aliases: string[];
name: string;
host: string;
role: string;
runtimePath: string;
configPath: string;
model: string;
emoji: string;
channels: AgentRouteSummary[];
notes: string[];
dispatch: {
method: DispatchMethod;
urlEnv: string;
tokenEnv: string;
targetChannel: string;
description: string;
};
};
export type FleetConfig = {
title: string;
overview: string[];
topologyDiagram: string;
sections: FleetSection[];
zeroclawAgents: ZeroClawAgentDefinition[];
};