[taskboard] add dispatch control plane
This commit is contained in:
@@ -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 ./
|
||||||
|
|||||||
17
README.md
17
README.md
@@ -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
|
||||||
|
|
||||||
|
|||||||
7
app/api/dispatch-history/route.ts
Normal file
7
app/api/dispatch-history/route.ts
Normal 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));
|
||||||
|
}
|
||||||
26
app/api/tasks/[id]/ack/route.ts
Normal file
26
app/api/tasks/[id]/ack/route.ts
Normal 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);
|
||||||
|
}
|
||||||
21
app/api/tasks/[id]/dispatch/route.ts
Normal file
21
app/api/tasks/[id]/dispatch/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
16
app/api/tasks/[id]/events/route.ts
Normal file
16
app/api/tasks/[id]/events/route.ts
Normal 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));
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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") : [],
|
||||||
|
|||||||
7
app/api/tasks/templates/route.ts
Normal file
7
app/api/tasks/templates/route.ts
Normal 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
13
app/dispatch/page.tsx
Normal 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
9
app/openclaw/page.tsx
Normal 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" />;
|
||||||
|
}
|
||||||
@@ -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
9
app/zeroclaw/page.tsx
Normal 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" />;
|
||||||
|
}
|
||||||
@@ -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 =
|
deferredQuery.length === 0 ||
|
||||||
query.length === 0 ||
|
agent.name.toLowerCase().includes(deferredQuery.toLowerCase()) ||
|
||||||
agent.name.toLowerCase().includes(query.toLowerCase()) ||
|
agent.host.toLowerCase().includes(deferredQuery.toLowerCase()) ||
|
||||||
agent.host.toLowerCase().includes(query.toLowerCase()) ||
|
agent.role.toLowerCase().includes(deferredQuery.toLowerCase());
|
||||||
agent.role.toLowerCase().includes(query.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>
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
73
components/dispatch-history.tsx
Normal file
73
components/dispatch-history.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,90 +103,206 @@ 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>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Unified Task Intake</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Create typed tasks, apply dispatch templates, and route work to OpenClaw or ZeroClaw.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form className="grid gap-3 md:grid-cols-2" onSubmit={createTask}>
|
||||||
|
<Select value={formState.templateKey} onChange={(event) => applyTemplate(event.target.value)}>
|
||||||
|
<option value="">Select template</option>
|
||||||
|
{templates.map((template) => (
|
||||||
|
<option key={template.key} value={template.key}>
|
||||||
|
{template.title} • {template.family}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Select
|
||||||
|
value={formState.assignee}
|
||||||
|
onChange={(event) => setFormState((current) => ({ ...current, assignee: event.target.value }))}
|
||||||
|
>
|
||||||
|
<option value="">Select agent</option>
|
||||||
|
{agents.map((agent) => (
|
||||||
|
<option key={agent.slug} value={agent.assignmentKey}>
|
||||||
|
{agent.name} • {agent.family} • {agent.host}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Input
|
||||||
|
placeholder="Task title"
|
||||||
|
required
|
||||||
|
value={formState.title}
|
||||||
|
onChange={(event) => setFormState((current) => ({ ...current, title: event.target.value }))}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={formState.priority}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFormState((current) => ({ ...current, priority: event.target.value as TaskPriority }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{PRIORITIES.map((priority) => (
|
||||||
|
<option key={priority} value={priority}>
|
||||||
|
{priority}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Input
|
||||||
|
placeholder="Repo slug from repo-map.json (for example TopherMayor/openclaw-taskboard)"
|
||||||
|
value={formState.repoSlug}
|
||||||
|
onChange={(event) => setFormState((current) => ({ ...current, repoSlug: event.target.value }))}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="Base branch"
|
||||||
|
value={formState.baseBranch}
|
||||||
|
onChange={(event) => setFormState((current) => ({ ...current, baseBranch: event.target.value }))}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="Preferred swarm agent"
|
||||||
|
value={formState.preferredAgent}
|
||||||
|
onChange={(event) => setFormState((current) => ({ ...current, preferredAgent: event.target.value }))}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="Reasoning effort"
|
||||||
|
value={formState.reasoningEffort}
|
||||||
|
onChange={(event) => setFormState((current) => ({ ...current, reasoningEffort: event.target.value }))}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
className="md:col-span-2"
|
||||||
|
placeholder="Tags (comma-separated)"
|
||||||
|
value={formState.tags}
|
||||||
|
onChange={(event) => setFormState((current) => ({ ...current, tags: event.target.value }))}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
className="md:col-span-2"
|
||||||
|
placeholder="Model hint (optional)"
|
||||||
|
value={formState.modelHint}
|
||||||
|
onChange={(event) => setFormState((current) => ({ ...current, modelHint: event.target.value }))}
|
||||||
|
/>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<Textarea
|
||||||
|
placeholder="Describe the task, target host, expected outcome, and any validation steps."
|
||||||
|
value={formState.description}
|
||||||
|
onChange={(event) => setFormState((current) => ({ ...current, description: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2 flex items-center justify-between gap-3">
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
{selectedAgent
|
||||||
|
? `Dispatch target: ${selectedAgent.family} on ${selectedAgent.host}`
|
||||||
|
: selectedTemplate
|
||||||
|
? `Template dispatch: ${selectedTemplate.defaults.dispatchMethod}`
|
||||||
|
: "Select an agent or template to prefill dispatch metadata."}
|
||||||
|
</p>
|
||||||
|
<Button type="submit">Create Task</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Failure Queue</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Failed dispatches stay visible until retried or completed manually.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{failedTasks.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-400">No failed dispatches.</p>
|
||||||
|
) : (
|
||||||
|
failedTasks.map((task) => (
|
||||||
|
<div className="rounded-xl border border-amber-400/20 bg-amber-400/5 p-4" key={task.id}>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<p className="font-medium text-white">{task.title}</p>
|
||||||
|
<Badge variant="warning">{task.dispatch_state}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm text-slate-300">{task.last_error || "No error text captured."}</p>
|
||||||
|
<Button className="mt-3 w-full" size="sm" variant="outline" onClick={() => dispatchTask(task.id)}>
|
||||||
|
Retry Dispatch
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Unified Task Intake</CardTitle>
|
<CardTitle>Recent Dispatch Activity</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Assign work to OpenClaw swarm agents or ZeroClaw host runtimes from a single board.
|
Latest control-plane events across both families.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="grid gap-3 lg:grid-cols-3">
|
||||||
<form className="grid gap-3 md:grid-cols-2" onSubmit={createTask}>
|
{events.slice(0, 6).map((event) => (
|
||||||
<Input
|
<div className="rounded-xl border border-white/10 bg-slate-950/40 p-4" key={event.id}>
|
||||||
placeholder="Task title"
|
<div className="flex flex-wrap gap-2">
|
||||||
required
|
<Badge variant={familyVariant(event.family)}>{event.family || "manual"}</Badge>
|
||||||
value={formState.title}
|
<Badge variant={event.event_type === "dispatch_failed" ? "warning" : "secondary"}>
|
||||||
onChange={(event) => setFormState((current) => ({ ...current, title: event.target.value }))}
|
{event.event_type}
|
||||||
/>
|
</Badge>
|
||||||
<Select
|
</div>
|
||||||
value={formState.assignee}
|
<p className="mt-3 font-medium text-white">{event.summary}</p>
|
||||||
onChange={(event) => setFormState((current) => ({ ...current, assignee: event.target.value }))}
|
<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>
|
||||||
<option value="">Select agent</option>
|
|
||||||
{agents.map((agent) => (
|
|
||||||
<option key={agent.slug} value={agent.assignmentKey}>
|
|
||||||
{agent.name} • {agent.family} • {agent.host}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
<Select
|
|
||||||
value={formState.priority}
|
|
||||||
onChange={(event) =>
|
|
||||||
setFormState((current) => ({ ...current, priority: event.target.value as TaskPriority }))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{PRIORITIES.map((priority) => (
|
|
||||||
<option key={priority} value={priority}>
|
|
||||||
{priority}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
<Input
|
|
||||||
placeholder="Tags (comma-separated)"
|
|
||||||
value={formState.tags}
|
|
||||||
onChange={(event) => setFormState((current) => ({ ...current, tags: event.target.value }))}
|
|
||||||
/>
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<Textarea
|
|
||||||
placeholder="Describe the task, host target, and expected outcome"
|
|
||||||
value={formState.description}
|
|
||||||
onChange={(event) =>
|
|
||||||
setFormState((current) => ({ ...current, description: event.target.value }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="md:col-span-2">
|
))}
|
||||||
<Button type="submit">Create Task</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -154,18 +328,42 @@ 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>
|
||||||
{task.status !== "Done" ? (
|
<dl className="mt-3 grid gap-1 text-xs text-slate-400">
|
||||||
<Button className="mt-4 w-full" size="sm" variant="outline" onClick={() => moveToDone(task.id)}>
|
<div className="flex justify-between gap-2">
|
||||||
Mark Done
|
<dt>Host</dt>
|
||||||
</Button>
|
<dd>{task.target_host || "n/a"}</dd>
|
||||||
) : null}
|
</div>
|
||||||
|
<div className="flex justify-between gap-2">
|
||||||
|
<dt>Channel</dt>
|
||||||
|
<dd className="text-right">{task.target_channel || "n/a"}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
{task.dispatch_state !== "dispatched" && task.dispatch_state !== "completed" ? (
|
||||||
|
<Button className="w-full" size="sm" onClick={() => dispatchTask(task.id)}>
|
||||||
|
Dispatch
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{task.dispatch_state === "dispatched" ? (
|
||||||
|
<Button className="w-full" size="sm" variant="outline" onClick={() => acknowledgeTask(task.id)}>
|
||||||
|
Mark Acknowledged
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{task.status !== "Done" ? (
|
||||||
|
<Button className="w-full" size="sm" variant="outline" onClick={() => patchTask(task.id, { status: "Done" })}>
|
||||||
|
Mark Done
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
121
config/fleet.json
Normal file
121
config/fleet.json
Normal 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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
59
config/task-templates.json
Normal file
59
config/task-templates.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
136
lib/agents.ts
136
lib/agents.ts
@@ -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";
|
|
||||||
}
|
|
||||||
|
|||||||
94
lib/db.ts
94
lib/db.ts
@@ -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
254
lib/dispatch.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|||||||
268
lib/tasks.ts
268
lib/tasks.ts
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
97
lib/types.ts
97
lib/types.ts
@@ -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[];
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user