diff --git a/Dockerfile b/Dockerfile index 72180df..5aea24b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,6 +13,9 @@ FROM node:20-bookworm-slim AS runner WORKDIR /app ENV NODE_ENV=production 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/.next/standalone ./ diff --git a/README.md b/README.md index 7e19a23..cea838a 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,9 @@ It tracks and visualizes: - OpenClaw swarm agents on `ubuntu` - 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 +- dispatch audit history, failure queues, heartbeat overlays, and task templates ## Stack @@ -21,6 +22,9 @@ It tracks and visualizes: - `/tasks` - unified Kanban board - `/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 - `/wiki` - markdown-backed runbooks and generated docs - `/usage` - usage aggregates from the local tracking table @@ -55,10 +59,15 @@ It tracks and visualizes: - `DB_PATH` - `WIKI_DIR` - `AGENTS_DIR` -- `SESSIONS_DIR` +- `SWARM_TASKS_FILE` +- `SWARM_REPO_MAP_FILE` +- `SWARM_WORKTREES_DIR` +- `REPO_ACCESS_ROOTS` - `OPENCLAW_CONFIG` -- `ZEROCLAW_PRIMARY_DIR` -- `ZEROCLAW_CONTROL_DIR` +- `ZEROCLAW_GRIZZLEY_URL` +- `ZEROCLAW_GRIZZLEY_TOKEN` +- `ZEROCLAW_ICE_URL` +- `ZEROCLAW_ICE_TOKEN` ## Development diff --git a/app/api/dispatch-history/route.ts b/app/api/dispatch-history/route.ts new file mode 100644 index 0000000..eda7462 --- /dev/null +++ b/app/api/dispatch-history/route.ts @@ -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)); +} diff --git a/app/api/tasks/[id]/ack/route.ts b/app/api/tasks/[id]/ack/route.ts new file mode 100644 index 0000000..9d7ecce --- /dev/null +++ b/app/api/tasks/[id]/ack/route.ts @@ -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); +} diff --git a/app/api/tasks/[id]/dispatch/route.ts b/app/api/tasks/[id]/dispatch/route.ts new file mode 100644 index 0000000..1052f4e --- /dev/null +++ b/app/api/tasks/[id]/dispatch/route.ts @@ -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 }); + } +} diff --git a/app/api/tasks/[id]/events/route.ts b/app/api/tasks/[id]/events/route.ts new file mode 100644 index 0000000..cc0dbbe --- /dev/null +++ b/app/api/tasks/[id]/events/route.ts @@ -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)); +} diff --git a/app/api/tasks/[id]/route.ts b/app/api/tasks/[id]/route.ts index 5ac25b3..76e3f13 100644 --- a/app/api/tasks/[id]/route.ts +++ b/app/api/tasks/[id]/route.ts @@ -22,8 +22,34 @@ export async function PATCH( title: typeof payload.title === "string" ? payload.title : undefined, description: typeof payload.description === "string" ? payload.description : 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, 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, }); diff --git a/app/api/tasks/route.ts b/app/api/tasks/route.ts index 5320a96..e3cedf2 100644 --- a/app/api/tasks/route.ts +++ b/app/api/tasks/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; +import { findAgentByAssignmentKey } from "@/lib/agents"; import { createTask, listTasks, validateTaskPayload } from "@/lib/tasks"; export async function GET() { @@ -13,10 +14,26 @@ export async function POST(request: Request) { 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({ title: String(payload.title), 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, status: (payload.status as never) || "Backlog", tags: Array.isArray(payload.tags) ? payload.tags.filter((tag) => typeof tag === "string") : [], diff --git a/app/api/tasks/templates/route.ts b/app/api/tasks/templates/route.ts new file mode 100644 index 0000000..0f70858 --- /dev/null +++ b/app/api/tasks/templates/route.ts @@ -0,0 +1,7 @@ +import { NextResponse } from "next/server"; + +import { listTaskTemplates } from "@/lib/tasks"; + +export async function GET() { + return NextResponse.json(await listTaskTemplates()); +} diff --git a/app/dispatch/page.tsx b/app/dispatch/page.tsx new file mode 100644 index 0000000..c38089c --- /dev/null +++ b/app/dispatch/page.tsx @@ -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 ; +} diff --git a/app/openclaw/page.tsx b/app/openclaw/page.tsx new file mode 100644 index 0000000..d04a971 --- /dev/null +++ b/app/openclaw/page.tsx @@ -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 ; +} diff --git a/app/tasks/page.tsx b/app/tasks/page.tsx index 24b58ce..c28fa99 100644 --- a/app/tasks/page.tsx +++ b/app/tasks/page.tsx @@ -1,10 +1,15 @@ import { TasksClient } from "@/components/tasks-client"; import { listFleetAgents } from "@/lib/agents"; -import { listTasks } from "@/lib/tasks"; +import { listTaskEvents, listTaskTemplates, listTasks } from "@/lib/tasks"; export const dynamic = "force-dynamic"; export default async function TasksPage() { - const [tasks, agents] = await Promise.all([listTasks(), listFleetAgents()]); - return ; + const [tasks, agents, templates, events] = await Promise.all([ + listTasks(), + listFleetAgents(), + listTaskTemplates(), + listTaskEvents(undefined, 12), + ]); + return ; } diff --git a/app/zeroclaw/page.tsx b/app/zeroclaw/page.tsx new file mode 100644 index 0000000..dd8b015 --- /dev/null +++ b/app/zeroclaw/page.tsx @@ -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 ; +} diff --git a/components/agents-client.tsx b/components/agents-client.tsx index 43d4c8d..48dc9df 100644 --- a/components/agents-client.tsx +++ b/components/agents-client.tsx @@ -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(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 (
@@ -30,12 +45,16 @@ export function AgentsClient({ agents }: { agents: FleetAgent[] }) { Configured Agent Runtimes - 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. - setQuery(event.target.value)} /> - setQuery(event.target.value)} + /> + applyTemplate(event.target.value)}> + + {templates.map((template) => ( + + ))} + + + setFormState((current) => ({ ...current, title: event.target.value }))} + /> + + setFormState((current) => ({ ...current, repoSlug: event.target.value }))} + /> + setFormState((current) => ({ ...current, baseBranch: event.target.value }))} + /> + setFormState((current) => ({ ...current, preferredAgent: event.target.value }))} + /> + setFormState((current) => ({ ...current, reasoningEffort: event.target.value }))} + /> + setFormState((current) => ({ ...current, tags: event.target.value }))} + /> + setFormState((current) => ({ ...current, modelHint: event.target.value }))} + /> +
+