1 Commits

Author SHA1 Message Date
20f67e0177 feat: separate-routes 2026-03-03 19:25:42 -08:00
101 changed files with 1803 additions and 15910 deletions

3
.gitignore vendored
View File

@@ -1,7 +1,4 @@
node_modules/ node_modules/
.next/
out/
*.tsbuildinfo
data/*.db data/*.db
data/*.db-wal data/*.db-wal
data/*.db-shm data/*.db-shm

View File

@@ -1,26 +1,15 @@
FROM node:20-bookworm-slim AS deps FROM node:20-bookworm-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:20-bookworm-slim AS builder
WORKDIR /app WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY package.json ./
RUN npm install --omit=dev
COPY . . COPY . .
RUN npm run build
FROM node:20-bookworm-slim AS runner
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 openssh-client \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
EXPOSE 8395 EXPOSE 8395
CMD ["node", "server.js"] CMD ["npm", "start"]

249
README.md
View File

@@ -1,159 +1,138 @@
# Claw Fleet Console # OpenClaw Agent Fleet Dashboard
`openclaw-taskboard` is now a `Next.js + React + Tailwind + shadcn-style` dashboard for the deployed Claw fleet. A real-time task coordination board for the OpenClaw agent fleet.
It tracks and visualizes: ## Features
- OpenClaw swarm agents on `ubuntu` - **Kanban Board**: Backlog → Todo → In Progress → Review → Done
- ZeroClaw host runtimes on `grizzley` and `ice` - **Agent Assignment**: Assign tasks to specific OpenClaw agents
- direct SSH host targets for `pve`, `truenas`, and `panda` - **Priority Levels**: High, Medium, Low
- shared task assignment and dispatch across all families - **Tags**: Categorize tasks with tags
- wiki pages and architecture documentation rendered in the UI - **Wiki Auto-Generation**: Completed tasks generate wiki documentation
- dispatch audit history, failure queues, heartbeat overlays, and task templates - **Real-time Updates**: WebSocket-powered live updates
- **REST API**: For agent heartbeat integration
## Stack ## Quick Start
- Next.js App Router
- React 19
- Tailwind CSS
- shadcn-style UI components under `components/ui`
- SQLite task storage
## Key Pages
- `/tasks` - unified Kanban board
- `/agents` - configured OpenClaw, ZeroClaw, and direct host targets
- `/openclaw` - focused OpenClaw swarm view
- `/zeroclaw` - focused ZeroClaw host-runtime view
- `/dispatch` - dispatch audit log and failure queue
- `/architecture` - deployed architecture documentation with ASCII topology
- `/wiki` - markdown-backed runbooks and generated docs
- `/usage` - usage aggregates from the local tracking table
## Control Plane Features
- typed fleet config and task template config
- dispatch lifecycle states and SQLite audit history
- OpenClaw swarm dispatch into `~/.clawdbot/active-tasks.json`
- ZeroClaw webhook dispatch for `grizzley` and `ice`
- direct SSH dispatch for `pve`, `truenas`, and `panda`
- task callback API for remote completion/result sync
- OpenClaw registry sync API for swarm task state reconciliation
- heartbeat pickup API at `/api/heartbeat/{agent}` for queue inspection and self-dispatch
- failure queue and dispatch history views
- family-specific runtime views for OpenClaw and ZeroClaw plus unified direct-host visibility
- architecture documentation rendered directly from tracked config
## Fleet Model
### OpenClaw
- Host: `ubuntu`
- Service: `openclaw.service`
- Runtime: `/srv/state/openclaw/current`
- Config: `~/.openclaw/openclaw.json`
- Channels:
- Telegram DM allowlist
- Homelab HQ forum topics
- local gateway on `:18789`
### ZeroClaw
- Primary runtime: `grizzley`
- Control-plane runtime: `ice`
- Runtime roots:
- `/srv/state/zeroclaw/current`
- `/home/bear/.zeroclaw-admin`
- Channels:
- paired HTTP gateway access
- Homelab-Ice forum topics
- remote gateway routing from `ice`
### Direct SSH Targets
- Execution host: `ubuntu` taskboard container
- Transport: `ssh` using the mounted host key
- Configured targets:
- `pve` via `root@192.168.50.11`
- `truenas` via `christopher@192.168.50.12`
- `panda` via `bear@192.168.50.196`
- Dispatch model:
- select a direct target agent
- dispatch a built-in safe action
- capture stdout/stderr
- write completion through the same callback pipeline as remote runtimes
## Important Environment Variables
- `DB_PATH`
- `WIKI_DIR`
- `AGENTS_DIR`
- `SWARM_TASKS_FILE`
- `SWARM_REPO_MAP_FILE`
- `SWARM_WORKTREES_DIR`
- `REPO_ACCESS_ROOTS`
- `OPENCLAW_CONFIG`
- `ZEROCLAW_GRIZZLEY_URL`
- `ZEROCLAW_GRIZZLEY_TOKEN`
- `ZEROCLAW_ICE_URL`
- `ZEROCLAW_ICE_TOKEN`
- `DIRECT_SSH_KEY_PATH`
- `DIRECT_SSH_TIMEOUT_MS`
## Heartbeat Pickup
Configured OpenClaw and ZeroClaw runtimes can hit:
```bash ```bash
curl -s http://127.0.0.1:8395/api/heartbeat/<agent> cd /home/bear/homelab/ubuntu/taskboard
docker compose up -d --build
``` ```
The heartbeat endpoint will: Access at: https://agentdash.local.tophermayor.com
- sync OpenClaw swarm state before scheduling ## API Endpoints
- inspect the agent's assigned tasks
- skip tasks blocked by `depends-on:<task-id>` or `dependency:<task-id>` tags
- auto-dispatch the next runnable task when the agent does not already have an active unblocked task
- return queue state, blocked items, and any task that was dispatched during the heartbeat
This is the canonical path for agent-driven task pickup. Assignment alone does not start work; heartbeat pickup or an explicit dispatch does. ### Tasks
## Development | Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/tasks` | List all tasks (filter: `?assignee=ubuntu&status=todo`) |
| GET | `/api/tasks/:id` | Get single task |
| POST | `/api/tasks` | Create task |
| PATCH | `/api/tasks/:id` | Update task |
| POST | `/api/tasks/:id/complete` | Complete task (creates wiki) |
| DELETE | `/api/tasks/:id` | Delete task |
### Wiki
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/wiki` | List wiki pages |
| GET | `/api/wiki/:filename` | Get wiki page content |
### Agent Heartbeat
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/heartbeat/:agent` | Get pending tasks for agent |
## Agent Integration
Add to agent's HEARTBEAT.md:
```bash ```bash
npm install # Check for assigned tasks
npm run dev TASKS=$(curl -s http://192.168.50.61:8395/api/heartbeat/ubuntu)
# If tasks pending, process them
if echo "$TASKS" | jq -e '.pending_tasks > 0' > /dev/null; then
echo "Processing assigned tasks..."
# Process tasks...
fi
``` ```
## Production Build ## Example: Create Task via API
```bash ```bash
npm run build curl -X POST http://localhost:8395/api/tasks \
npm start -H "Content-Type: application/json" \
-d '{
"title": "Restart PostgreSQL container",
"description": "The postgres-shared container needs a restart for config changes",
"assignee": "ubuntu",
"priority": "high",
"tags": ["docker", "database"]
}'
``` ```
## Deployment Shape On Ubuntu ## Example: Complete Task with Wiki
- app source checkout: `/srv/apps/openclaw-taskboard/current` ```bash
- taskboard data: `/srv/state/openclaw-taskboard/data` curl -X POST http://localhost:8395/api/tasks/TASK_ID/complete \
- OpenClaw mounts: -H "Content-Type: application/json" \
- `/home/bear/.openclaw/agents` -d '{
- `/home/bear/.openclaw/openclaw.json` "implementation_details": "Restarted the container using docker restart postgres-shared. Verified connections working.",
- `/home/bear/.openclaw/workspace/wiki` "files_changed": ["/home/bear/homelab/ubuntu/postgres/docker-compose.yml"]
- ZeroClaw architecture: }'
- rendered from the tracked fleet model in this repo ```
- optional runtime path overrides can be provided via `ZEROCLAW_PRIMARY_DIR` and `ZEROCLAW_CONTROL_DIR`
- Direct SSH:
- taskboard container mounts `/home/bear/.ssh` as read-only
- direct targets use `/root/.ssh/id_ed25519` by default
## Notes ## Task Schema
- The UI intentionally treats OpenClaw, ZeroClaw, and direct host targets as separate families with different runtime and channel models. ```json
- `ice` ZeroClaw remains tied to host-local secret/encryption state; the dashboard reads that runtime but does not attempt to rewrite it. {
- Direct targets are intentionally limited to safe built-in actions from `config/fleet.json`, not arbitrary shell commands from the browser. "id": "uuid",
"title": "string",
"description": "string",
"assignee": "ubuntu|pve|truenas|grizzley|ice|panda|zeroclaw|docs",
"status": "backlog|todo|in_progress|review|done",
"priority": "high|medium|low",
"tags": ["array", "of", "tags"],
"created_at": "ISO timestamp",
"updated_at": "ISO timestamp",
"completed_at": "ISO timestamp or null",
"wiki_path": "filename.md or null"
}
```
## Status Docs ## Directory Structure
- [Implementation status](./docs/IMPLEMENTATION_STATUS.md) ```
- [Roadmap](./docs/ROADMAP.md) taskboard/
├── docker-compose.yml
├── Dockerfile
├── README.md
├── package.json
├── server.js
├── client/
│ └── index.html
├── public/
│ ├── index.html
│ └── app.js
├── data/
│ └── tasks.db (SQLite)
└── wiki/
└── (auto-generated wiki pages)
```
## Deployment
The taskboard is deployed on the ubuntu host at:
- **URL**: https://agentdash.local.tophermayor.com
- **Port**: 8395
- **Container**: openclaw-taskboard
- **Traefik Route**: /home/bear/homelab/ubuntu/traefik/config/dynamic/taskboard.yml
## License
MIT

38
TASK.md Normal file
View File

@@ -0,0 +1,38 @@
# Task: Separate Routes for Each Section
## Current State
- Single page app with client-side navigation
- Navigation links: Tasks, Wiki, Agents, Usage
- All content in one index.html
## Required Changes
1. Add Express routes in server.js:
- GET /tasks - Renders tasks page with kanban board
- GET /wiki - Renders wiki page with documentation list
- GET /agents - Renders agents page with agent cards
- GET /usage - Renders usage page with provider info
- GET / - Redirect to /tasks or render tasks page
2. Create separate HTML templates or use template rendering:
- Each page should have its own route
- Navigation should use standard anchor links (href=/tasks, etc.)
- Remove client-side navigation JavaScript
3. Keep API endpoints unchanged:
- /api/tasks, /api/wiki, /api/agents, /api/usage remain as JSON APIs
- Pages can fetch data client-side from these APIs after loading
4. Remove SPA logic from app.js:
- Remove nav-link click handlers
- Remove page switching logic
- Keep only data loading for each page
## Files to Modify
- server.js - Add route handlers for each page
- public/index.html - Either split or make it a template
- public/app.js - Remove SPA navigation, keep data loading
## After Completion
git add -A
git commit -m "feat: separate routes for each section"
git push origin feat/separate-routes

View File

@@ -1,271 +0,0 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { findAgentBySlug } from "@/lib/agents";
import { formatDateTime } from "@/lib/utils";
export const dynamic = "force-dynamic";
function familyVariant(family: string) {
if (family === "zeroclaw") {
return "success";
}
if (family === "direct") {
return "warning";
}
return "default";
}
function dispatchVariant(state: string) {
return state === "failed" ? "warning" : state === "completed" ? "success" : "secondary";
}
export default async function AgentDetailPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const agent = await findAgentBySlug(slug);
if (!agent) {
notFound();
}
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<Link className="text-sm text-cyan-300/80 hover:text-cyan-200" href="/agents">
Back to agents
</Link>
<h1 className="mt-2 flex items-center gap-3 text-3xl font-semibold text-white">
<span>{agent.emoji}</span>
<span>{agent.name}</span>
</h1>
<p className="mt-2 max-w-3xl text-sm text-slate-300">{agent.role}</p>
</div>
<div className="flex flex-wrap gap-2">
<Badge variant={familyVariant(agent.family)}>{agent.family}</Badge>
<Badge variant="secondary">{agent.status}</Badge>
<Badge variant="outline">{agent.host}</Badge>
</div>
</div>
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.2fr)_380px]">
<Card>
<CardHeader>
<CardTitle>Overview</CardTitle>
<CardDescription>Runtime, routing, heartbeat, and capability details for this agent.</CardDescription>
</CardHeader>
<CardContent className="grid gap-5 md:grid-cols-2">
<dl className="grid gap-3 text-sm text-slate-300">
<div>
<dt className="text-xs uppercase tracking-[0.2em] text-slate-500">Host</dt>
<dd>{agent.host}</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-[0.2em] text-slate-500">Model</dt>
<dd>{agent.model || "Host-local/runtime-defined"}</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-[0.2em] text-slate-500">Dispatch</dt>
<dd>{agent.defaultDispatchMethod}</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-[0.2em] text-slate-500">Heartbeat</dt>
<dd>{agent.heartbeatAt ? formatDateTime(agent.heartbeatAt) : "No heartbeat"}</dd>
</div>
<div className="md:col-span-2">
<dt className="text-xs uppercase tracking-[0.2em] text-slate-500">Runtime Path</dt>
<dd className="break-all font-mono text-xs text-cyan-100">{agent.runtimePath}</dd>
</div>
{agent.configPath ? (
<div className="md:col-span-2">
<dt className="text-xs uppercase tracking-[0.2em] text-slate-500">Config Path</dt>
<dd className="break-all font-mono text-xs text-cyan-100">{agent.configPath}</dd>
</div>
) : null}
<div>
<dt className="text-xs uppercase tracking-[0.2em] text-slate-500">Current Task</dt>
<dd>{agent.currentTask || "No heartbeat task"}</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-[0.2em] text-slate-500">Failure Count</dt>
<dd>{agent.failureStreak}</dd>
</div>
</dl>
<div className="space-y-5">
<div>
<p className="mb-2 text-xs uppercase tracking-[0.2em] text-slate-500">Channels</p>
<div className="flex flex-wrap gap-2">
{agent.channels.map((channel) => (
<Badge key={`${agent.slug}-${channel.label}`} variant="outline">
{channel.label}: {channel.value}
</Badge>
))}
</div>
</div>
<div>
<p className="mb-2 text-xs uppercase tracking-[0.2em] text-slate-500">Tools</p>
<div className="flex flex-wrap gap-2">
{agent.tools.length ? (
agent.tools.map((tool) => (
<Badge key={tool} variant="secondary">
{tool}
</Badge>
))
) : (
<p className="text-sm text-slate-400">No parsed tools.</p>
)}
</div>
</div>
<div>
<p className="mb-2 text-xs uppercase tracking-[0.2em] text-slate-500">Capabilities</p>
<div className="flex flex-wrap gap-2">
{agent.capabilities.length ? (
agent.capabilities.map((capability) => (
<Badge key={capability} variant="outline">
{capability}
</Badge>
))
) : (
<p className="text-sm text-slate-400">No parsed capabilities.</p>
)}
</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Activity Snapshot</CardTitle>
<CardDescription>Recent event and assignment health for this agent.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="rounded-xl border border-white/10 bg-slate-950/40 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">Active</p>
<p className="mt-2 text-2xl font-semibold text-white">{agent.activeTasks.length}</p>
</div>
<div className="rounded-xl border border-white/10 bg-slate-950/40 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">Completed</p>
<p className="mt-2 text-2xl font-semibold text-white">{agent.completedTasks.length}</p>
</div>
</div>
<div>
<p className="mb-2 text-xs uppercase tracking-[0.2em] text-slate-500">Last Event</p>
{agent.lastEvent ? (
<div className="rounded-xl border border-white/10 bg-slate-950/40 p-4">
<div className="flex flex-wrap gap-2">
<Badge variant={agent.lastEvent.event_type === "dispatch_failed" ? "warning" : "secondary"}>
{agent.lastEvent.event_type}
</Badge>
<Badge variant={dispatchVariant(agent.lastEvent.state || "planned")}>
{agent.lastEvent.state || "n/a"}
</Badge>
</div>
<p className="mt-3 font-medium text-white">{agent.lastEvent.summary}</p>
<p className="mt-2 break-words text-sm text-slate-300">
{agent.lastEvent.detail || "No detail captured."}
</p>
<p className="mt-2 text-xs uppercase tracking-[0.2em] text-slate-500">
{formatDateTime(agent.lastEvent.created_at)}
</p>
</div>
) : (
<p className="text-sm text-slate-400">No audit events recorded yet.</p>
)}
</div>
{agent.notes.length ? (
<div>
<p className="mb-2 text-xs uppercase tracking-[0.2em] text-slate-500">Notes</p>
<div className="space-y-2">
{agent.notes.map((note) => (
<div className="rounded-xl border border-white/10 bg-slate-950/40 p-3 text-sm text-slate-300" key={note}>
{note}
</div>
))}
</div>
</div>
) : null}
</CardContent>
</Card>
</div>
<div className="grid gap-4 xl:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Assigned Tasks</CardTitle>
<CardDescription>Current active work assigned to this agent.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{agent.activeTasks.length ? (
agent.activeTasks.map((task) => (
<div className="rounded-xl border border-white/10 bg-slate-950/40 p-4" key={task.id}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="font-medium text-white">{task.title}</p>
<p className="mt-1 break-words text-sm text-slate-300">{task.description}</p>
</div>
<Badge variant={dispatchVariant(task.dispatch_state)}>{task.dispatch_state}</Badge>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Badge variant="secondary">{task.status}</Badge>
<Badge variant="outline">{task.priority}</Badge>
{task.tags.slice(0, 4).map((tag) => (
<Badge key={tag} variant="outline">
{tag}
</Badge>
))}
</div>
</div>
))
) : (
<p className="text-sm text-slate-400">No active tasks assigned.</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Recently Completed</CardTitle>
<CardDescription>Latest finished work attributed to this agent.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{agent.completedTasks.length ? (
agent.completedTasks.map((task) => (
<div className="rounded-xl border border-white/10 bg-slate-950/40 p-4" key={task.id}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="font-medium text-white">{task.title}</p>
{task.result_summary ? (
<p className="mt-1 break-words text-sm text-slate-300">{task.result_summary}</p>
) : (
<p className="mt-1 break-words text-sm text-slate-300">{task.description}</p>
)}
</div>
<Badge variant="success">done</Badge>
</div>
<p className="mt-2 text-xs uppercase tracking-[0.2em] text-slate-500">
{formatDateTime(task.completed_at || task.updated_at)}
</p>
</div>
))
) : (
<p className="text-sm text-slate-400">No recently completed tasks.</p>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

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

View File

@@ -1,29 +0,0 @@
import { NextResponse } from "next/server";
import { listFleetAgents } from "@/lib/agents";
import { findTask, updateTask } from "@/lib/tasks";
export async function POST(
request: Request,
{ params }: { params: Promise<{ slug: string }> },
) {
const { slug } = await params;
const payload = (await request.json()) as { taskId?: number };
if (!payload.taskId) {
return NextResponse.json({ error: "taskId_is_required" }, { status: 400 });
}
const agents = await listFleetAgents();
const agent = agents.find((entry) => entry.slug === slug);
if (!agent) {
return NextResponse.json({ error: "agent_not_found" }, { status: 404 });
}
const existing = await findTask(payload.taskId);
if (!existing) {
return NextResponse.json({ error: "task_not_found" }, { status: 404 });
}
const updated = await updateTask(existing.id, { assignee: agent.assignmentKey });
return NextResponse.json({ success: true, task: updated });
}

View File

@@ -1,16 +0,0 @@
import { NextResponse } from "next/server";
import { findAgentBySlug } from "@/lib/agents";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ slug: string }> },
) {
const { slug } = await params;
const agent = await findAgentBySlug(slug);
if (!agent) {
return NextResponse.json({ error: "agent_not_found" }, { status: 404 });
}
return NextResponse.json(agent);
}

View File

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

View File

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

View File

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

View File

@@ -1,19 +0,0 @@
import { NextResponse } from "next/server";
import { processAgentHeartbeat } from "@/lib/heartbeat";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ agent: string }> },
) {
const { agent } = await params;
try {
const result = await processAgentHeartbeat(agent);
return NextResponse.json(result);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const status = message === "agent_not_found" ? 404 : 500;
return NextResponse.json({ error: message }, { status });
}
}

View File

@@ -1,7 +0,0 @@
import { NextResponse } from "next/server";
import { syncOpenClawTasks } from "@/lib/openclaw-sync";
export async function POST() {
return NextResponse.json(await syncOpenClawTasks());
}

View File

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

View File

@@ -1,31 +0,0 @@
import { NextResponse } from "next/server";
import { applyTaskCallback } 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 payload = (await request.json()) as {
status?: "Backlog" | "Todo" | "In Progress" | "Review" | "Done";
dispatch_state?: "planned" | "assigned" | "dispatched" | "acknowledged" | "completed" | "failed";
summary?: string | null;
detail?: string | null;
completed_by?: string | null;
last_error?: string | null;
last_dispatch_at?: string | null;
};
const updated = await applyTaskCallback(numericId, payload);
if (!updated) {
return NextResponse.json({ error: "task_not_found" }, { status: 404 });
}
return NextResponse.json(updated);
}

View File

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

View File

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

View File

@@ -1,91 +0,0 @@
import { NextResponse } from "next/server";
import { findTask, updateTask, validateTaskPayload } 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 });
}
const task = await findTask(numericId);
if (!task) {
return NextResponse.json({ error: "task_not_found" }, { status: 404 });
}
return NextResponse.json(task);
}
export async function PATCH(
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 payload = (await request.json()) as Record<string, unknown>;
const errors = validateTaskPayload(payload as never, true);
if (errors.length > 0) {
return NextResponse.json({ error: "validation_error", details: errors }, { status: 400 });
}
const task = await updateTask(numericId, {
title: typeof payload.title === "string" ? payload.title : undefined,
description: typeof payload.description === "string" ? payload.description : undefined,
assignee: typeof payload.assignee === "string" ? payload.assignee : undefined,
family:
payload.family === null || typeof payload.family === "string"
? (payload.family as never)
: undefined,
target_host: typeof payload.target_host === "string" ? payload.target_host : undefined,
target_channel: typeof payload.target_channel === "string" ? payload.target_channel : undefined,
dispatch_method: payload.dispatch_method as never,
dispatch_state: payload.dispatch_state as never,
template_key: typeof payload.template_key === "string" ? payload.template_key : undefined,
repo_slug: typeof payload.repo_slug === "string" ? payload.repo_slug : undefined,
base_branch: typeof payload.base_branch === "string" ? payload.base_branch : undefined,
preferred_agent:
typeof payload.preferred_agent === "string" ? payload.preferred_agent : undefined,
reasoning_effort:
typeof payload.reasoning_effort === "string" ? payload.reasoning_effort : undefined,
model_hint: typeof payload.model_hint === "string" ? payload.model_hint : undefined,
result_summary:
payload.result_summary === null || typeof payload.result_summary === "string"
? (payload.result_summary as never)
: undefined,
result_detail:
payload.result_detail === null || typeof payload.result_detail === "string"
? (payload.result_detail as never)
: undefined,
completed_by:
payload.completed_by === null || typeof payload.completed_by === "string"
? (payload.completed_by as never)
: undefined,
priority: payload.priority as never,
status: payload.status as never,
last_dispatch_at:
typeof payload.last_dispatch_at === "string" ? payload.last_dispatch_at : undefined,
acknowledged_at:
payload.acknowledged_at === null || typeof payload.acknowledged_at === "string"
? (payload.acknowledged_at as never)
: undefined,
last_error:
payload.last_error === null || typeof payload.last_error === "string"
? (payload.last_error as never)
: undefined,
tags: Array.isArray(payload.tags) ? payload.tags.filter((tag) => typeof tag === "string") : undefined,
});
if (!task) {
return NextResponse.json({ error: "task_not_found" }, { status: 404 });
}
return NextResponse.json(task);
}

View File

@@ -1,64 +0,0 @@
import { NextResponse } from "next/server";
import { findAgentByAssignmentKey } from "@/lib/agents";
import { createTask, listTasks, validateTaskPayload } from "@/lib/tasks";
function extractTagValue(tags: string[], prefix: string) {
const match = tags.find((tag) => tag.startsWith(prefix));
return match ? match.slice(prefix.length) : null;
}
export async function GET() {
return NextResponse.json(await listTasks());
}
export async function POST(request: Request) {
const payload = (await request.json()) as Record<string, unknown>;
const errors = validateTaskPayload(payload as never, false);
if (errors.length > 0) {
return NextResponse.json({ error: "validation_error", details: errors }, { status: 400 });
}
const assignee = typeof payload.assignee === "string" ? payload.assignee : "";
const tags = Array.isArray(payload.tags) ? payload.tags.filter((tag) => typeof tag === "string") : [];
const assigneeAgent = assignee ? await findAgentByAssignmentKey(assignee) : null;
const derivedRepoSlug = typeof payload.repo_slug === "string" ? payload.repo_slug : extractTagValue(tags, "repo:");
const derivedPreferredAgent =
typeof payload.preferred_agent === "string" ? payload.preferred_agent : extractTagValue(tags, "agent:");
const requestedFamily = payload.family === null ? null : (payload.family as never);
const requestedDispatchMethod = payload.dispatch_method as never;
const wantsOpenClawSwarm =
requestedFamily === "openclaw" ||
requestedDispatchMethod === "openclaw-swarm" ||
tags.includes("swarm") ||
Boolean(derivedRepoSlug) ||
Boolean(derivedPreferredAgent && ["codex", "opencode", "gemini"].includes(derivedPreferredAgent));
const task = await createTask({
title: String(payload.title),
description: typeof payload.description === "string" ? payload.description : "",
assignee,
family: requestedFamily || assigneeAgent?.family || (wantsOpenClawSwarm ? "openclaw" : null),
target_host:
typeof payload.target_host === "string"
? payload.target_host
: assigneeAgent?.host || (wantsOpenClawSwarm ? "ubuntu" : ""),
target_channel:
typeof payload.target_channel === "string"
? payload.target_channel
: assigneeAgent?.channels[0]?.value || (wantsOpenClawSwarm ? "OpenClaw swarm registry" : ""),
dispatch_method:
requestedDispatchMethod || assigneeAgent?.defaultDispatchMethod || (wantsOpenClawSwarm ? "openclaw-swarm" : "manual"),
template_key: typeof payload.template_key === "string" ? payload.template_key : null,
repo_slug: derivedRepoSlug,
base_branch: typeof payload.base_branch === "string" ? payload.base_branch : null,
preferred_agent: derivedPreferredAgent,
reasoning_effort: typeof payload.reasoning_effort === "string" ? payload.reasoning_effort : null,
model_hint: typeof payload.model_hint === "string" ? payload.model_hint : null,
priority: payload.priority as never,
status: (payload.status as never) || "Backlog",
tags,
});
return NextResponse.json(task, { status: 201 });
}

View File

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

View File

@@ -1,37 +0,0 @@
import { NextResponse } from "next/server";
import { all } from "@/lib/db";
type UsageRow = {
agent: string;
provider: string;
model: string;
tokens_used: number;
cost_estimate: number;
};
export async function GET() {
const rows = await all<UsageRow>("SELECT * FROM usage_tracking ORDER BY timestamp DESC");
const result = rows.reduce(
(accumulator, row) => {
accumulator.totalRequests += 1;
accumulator.totalTokens += row.tokens_used || 0;
accumulator.totalCost += row.cost_estimate || 0;
if (!accumulator.byAgent[row.agent]) {
accumulator.byAgent[row.agent] = { requests: 0, tokens: 0, cost: 0 };
}
accumulator.byAgent[row.agent].requests += 1;
accumulator.byAgent[row.agent].tokens += row.tokens_used || 0;
accumulator.byAgent[row.agent].cost += row.cost_estimate || 0;
return accumulator;
},
{
totalRequests: 0,
totalTokens: 0,
totalCost: 0,
byAgent: {} as Record<string, { requests: number; tokens: number; cost: number }>,
},
);
return NextResponse.json(result);
}

View File

@@ -1,38 +0,0 @@
import { NextResponse } from "next/server";
import { deleteWikiPage, readWikiPage, updateWikiPage } from "@/lib/wiki";
export async function GET(
_request: Request,
{ params }: { params: Promise<{ filename: string }> },
) {
const { filename } = await params;
const page = readWikiPage(filename);
if (!page) {
return NextResponse.json({ error: "wiki_page_not_found" }, { status: 404 });
}
return NextResponse.json(page);
}
export async function PUT(
request: Request,
{ params }: { params: Promise<{ filename: string }> },
) {
const { filename } = await params;
const payload = (await request.json()) as { content?: string };
if (typeof payload.content !== "string") {
return NextResponse.json({ error: "content_is_required" }, { status: 400 });
}
updateWikiPage(filename, payload.content);
return NextResponse.json({ success: true });
}
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ filename: string }> },
) {
const { filename } = await params;
deleteWikiPage(filename);
return NextResponse.json({ success: true });
}

View File

@@ -1,17 +0,0 @@
import { NextResponse } from "next/server";
import { createWikiPage, listWikiPages } from "@/lib/wiki";
export async function GET() {
return NextResponse.json(listWikiPages());
}
export async function POST(request: Request) {
const payload = (await request.json()) as { title?: string };
if (!payload.title) {
return NextResponse.json({ error: "title_is_required" }, { status: 400 });
}
const filename = createWikiPage(payload.title);
return NextResponse.json({ filename, success: true }, { status: 201 });
}

View File

@@ -1,9 +0,0 @@
import { ArchitectureView } from "@/components/architecture-view";
import { listArchitecture } from "@/lib/agents";
export const dynamic = "force-dynamic";
export default async function ArchitecturePage() {
const architecture = await listArchitecture();
return <ArchitectureView architecture={architecture} />;
}

View File

@@ -1,5 +0,0 @@
import { redirect } from "next/navigation";
export default function DispatchPage() {
redirect("/tasks/dispatch");
}

View File

@@ -1,21 +0,0 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
export const dynamic = "force-dynamic";
export default function GiteaPage() {
return (
<Card>
<CardHeader>
<CardTitle>Gitea Integration</CardTitle>
<CardDescription>
The Next.js migration keeps the fleet UI focused on operations. Existing Gitea automation can be reattached through dedicated API routes or direct repo links.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3 text-sm text-slate-300">
<p>Primary repo: `TopherMayor/openclaw-taskboard`</p>
<p>Infra wrapper: `TopherMayor/homelabagentroot` under `homelab/ubuntu/taskboard/`</p>
<p>Use the architecture and agents pages to verify deployed fleet state before issuing repo automation from the host agents.</p>
</CardContent>
</Card>
);
}

View File

@@ -1,38 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: 220 44% 10%;
--foreground: 210 40% 96%;
--card: 222 47% 13%;
--card-foreground: 210 40% 96%;
--primary: 188 95% 48%;
--primary-foreground: 221 39% 11%;
--secondary: 221 28% 20%;
--secondary-foreground: 210 40% 96%;
--muted: 223 27% 18%;
--muted-foreground: 215 20% 74%;
--accent: 35 94% 56%;
--accent-foreground: 221 39% 11%;
--border: 218 22% 26%;
--input: 218 22% 26%;
--ring: 188 95% 48%;
--radius: 1rem;
--font-sans: "Space Grotesk", sans-serif;
--font-mono: "IBM Plex Mono", monospace;
}
* {
border-color: hsl(var(--border));
}
body {
min-height: 100vh;
background-color: hsl(var(--background));
color: hsl(var(--foreground));
}
pre {
overflow-x: auto;
}

View File

@@ -1,31 +0,0 @@
import type { Metadata } from "next";
import "@/app/globals.css";
import { AppShell } from "@/components/app-shell";
export const metadata: Metadata = {
title: "Claw Fleet Console",
description: "OpenClaw, ZeroClaw, and direct host operations dashboard",
};
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Space+Grotesk:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
</head>
<body>
<AppShell>{children}</AppShell>
</body>
</html>
);
}

View File

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

View File

@@ -1,5 +0,0 @@
import { redirect } from "next/navigation";
export default function HomePage() {
redirect("/tasks");
}

View File

@@ -1,336 +0,0 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { listFleetAgents } from "@/lib/agents";
import { findTask, listTaskEvents } from "@/lib/tasks";
import { formatDateTime } from "@/lib/utils";
import type { FleetAgent, TaskEvent, TaskRecord } from "@/lib/types";
export const dynamic = "force-dynamic";
function familyVariant(family: TaskRecord["family"]) {
if (family === "zeroclaw") {
return "success";
}
if (family === "direct") {
return "warning";
}
return "default";
}
function dispatchVariant(state: TaskRecord["dispatch_state"]) {
return state === "failed" ? "warning" : state === "completed" ? "success" : "secondary";
}
function collectDependencyRows(task: TaskRecord) {
const rows = [
{ label: "Template", value: task.template_key },
{ label: "Repository", value: task.repo_slug },
{ label: "Base Branch", value: task.base_branch },
{ label: "Preferred Agent", value: task.preferred_agent },
{ label: "Model Hint", value: task.model_hint },
{ label: "Reasoning Effort", value: task.reasoning_effort },
];
return rows.filter((row) => row.value);
}
function collectRequirementRows(task: TaskRecord) {
const rows = [
{ label: "Assignee", value: task.assignee || "Unassigned" },
{ label: "Family", value: task.family || "manual" },
{ label: "Target Host", value: task.target_host || "n/a" },
{ label: "Target Channel", value: task.target_channel || "n/a" },
{ label: "Dispatch Method", value: task.dispatch_method },
{ label: "Dispatch State", value: task.dispatch_state },
{ label: "Priority", value: task.priority },
{ label: "Status", value: task.status },
];
return rows;
}
function findAssignedAgent(task: TaskRecord, agents: FleetAgent[]) {
return agents.find(
(agent) =>
agent.assignmentKey === task.assignee ||
agent.slug === task.assignee ||
agent.aliases.includes(task.assignee),
);
}
function renderEventTone(event: TaskEvent["event_type"]) {
if (event === "dispatch_failed") {
return "border-amber-400/20 bg-amber-500/5";
}
if (event === "dispatch_succeeded" || event === "acknowledged") {
return "border-emerald-400/20 bg-emerald-500/5";
}
return "border-white/10 bg-slate-950/40";
}
export default async function TaskDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const numericId = Number(id);
if (!Number.isInteger(numericId) || numericId <= 0) {
notFound();
}
const [task, events, agents] = await Promise.all([
findTask(numericId),
listTaskEvents(numericId, 100),
listFleetAgents(),
]);
if (!task) {
notFound();
}
const requirementRows = collectRequirementRows(task);
const dependencyRows = collectDependencyRows(task);
const assignedAgent = findAssignedAgent(task, agents);
return (
<div className="space-y-6">
<Card className="border-white/10 bg-slate-950/35">
<CardHeader className="gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">Task #{task.id}</Badge>
<Badge variant={familyVariant(task.family)}>{task.family || "manual"}</Badge>
<Badge variant={dispatchVariant(task.dispatch_state)}>{task.dispatch_state}</Badge>
<Badge variant={task.priority === "Critical" ? "warning" : "outline"}>{task.priority}</Badge>
<Badge variant="secondary">{task.status}</Badge>
</div>
<div>
<CardTitle className="text-2xl text-white">{task.title}</CardTitle>
<CardDescription className="mt-2 max-w-3xl text-sm leading-6 text-slate-300">
{task.description || "No description was captured for this task."}
</CardDescription>
</div>
</div>
<Link
className="inline-flex h-10 items-center justify-center rounded-md border border-border bg-transparent px-4 py-2 text-sm font-medium transition-colors hover:bg-secondary/40"
href="/tasks"
>
Back to Board
</Link>
</CardHeader>
</Card>
<div className="grid gap-6 xl:grid-cols-[1.35fr_0.95fr]">
<div className="space-y-6">
<Card className="border-white/10 bg-slate-950/35">
<CardHeader>
<CardTitle>Work Done</CardTitle>
<CardDescription>Latest execution output, result summary, and completion metadata.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-xl border border-white/10 bg-slate-950/40 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-slate-400">Latest Result</p>
<p className="mt-2 text-sm text-slate-100">{task.result_summary || "No result has been posted yet."}</p>
</div>
<div className="rounded-xl border border-white/10 bg-slate-950/40 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-slate-400">Completion</p>
<dl className="mt-2 space-y-2 text-sm text-slate-200">
<div className="flex justify-between gap-3">
<dt className="text-slate-400">Completed By</dt>
<dd className="text-right">{task.completed_by || "Pending"}</dd>
</div>
<div className="flex justify-between gap-3">
<dt className="text-slate-400">Completed At</dt>
<dd className="text-right">{formatDateTime(task.completed_at)}</dd>
</div>
<div className="flex justify-between gap-3">
<dt className="text-slate-400">Acknowledged</dt>
<dd className="text-right">{formatDateTime(task.acknowledged_at)}</dd>
</div>
</dl>
</div>
</div>
<div className="rounded-xl border border-white/10 bg-slate-950/40 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-slate-400">Execution Detail</p>
<p className="mt-2 whitespace-pre-wrap break-words text-sm leading-6 text-slate-200">
{task.result_detail || task.last_error || "No detailed execution transcript has been recorded yet."}
</p>
</div>
{task.last_error ? (
<div className="rounded-xl border border-amber-400/20 bg-amber-500/5 p-4">
<p className="text-xs uppercase tracking-[0.2em] text-amber-300/80">Latest Failure</p>
<p className="mt-2 whitespace-pre-wrap break-words text-sm leading-6 text-slate-200">{task.last_error}</p>
</div>
) : null}
</CardContent>
</Card>
<Card className="border-white/10 bg-slate-950/35">
<CardHeader>
<CardTitle>Task History</CardTitle>
<CardDescription>Chronological events for creation, dispatch, acknowledgement, retries, and completion.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{events.length > 0 ? (
events.map((event) => (
<div className={`rounded-xl border p-4 ${renderEventTone(event.event_type)}`} key={event.id}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-sm font-medium capitalize text-white">{event.summary}</p>
<p className="mt-1 text-xs uppercase tracking-[0.18em] text-slate-400">
{event.event_type.replace(/_/g, " ")}
{event.state ? `${event.state}` : ""}
</p>
</div>
<p className="text-xs text-slate-400">{formatDateTime(event.created_at)}</p>
</div>
{event.detail ? (
<p className="mt-3 whitespace-pre-wrap break-words text-sm leading-6 text-slate-300">{event.detail}</p>
) : null}
</div>
))
) : (
<div className="rounded-xl border border-dashed border-white/10 bg-slate-950/30 p-6 text-sm text-slate-400">
No event history has been recorded for this task yet.
</div>
)}
</CardContent>
</Card>
</div>
<div className="space-y-6">
<Card className="border-white/10 bg-slate-950/35">
<CardHeader>
<CardTitle>Requirements</CardTitle>
<CardDescription>Assignment, routing, and execution constraints that define this task.</CardDescription>
</CardHeader>
<CardContent>
<dl className="space-y-3 text-sm">
{requirementRows.map((row) => (
<div className="flex items-start justify-between gap-4 border-b border-white/5 pb-3 last:border-b-0 last:pb-0" key={row.label}>
<dt className="text-slate-400">{row.label}</dt>
<dd className="max-w-[60%] break-words text-right text-slate-100">{row.value}</dd>
</div>
))}
</dl>
</CardContent>
</Card>
<Card className="border-white/10 bg-slate-950/35">
<CardHeader>
<CardTitle>Dependencies</CardTitle>
<CardDescription>Tracked repo context, templates, and execution hints tied to this task.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{dependencyRows.length > 0 ? (
<dl className="space-y-3 text-sm">
{dependencyRows.map((row) => (
<div className="flex items-start justify-between gap-4 border-b border-white/5 pb-3 last:border-b-0 last:pb-0" key={row.label}>
<dt className="text-slate-400">{row.label}</dt>
<dd className="max-w-[60%] break-words text-right text-slate-100">{row.value}</dd>
</div>
))}
</dl>
) : (
<p className="text-sm text-slate-400">No explicit dependencies or repository context were captured for this task.</p>
)}
<div>
<p className="text-xs uppercase tracking-[0.2em] text-slate-400">Tags</p>
<div className="mt-2 flex flex-wrap gap-2">
{task.tags.length > 0 ? (
task.tags.map((tag) => (
<Badge key={tag} variant="outline">
{tag}
</Badge>
))
) : (
<span className="text-sm text-slate-400">No tags</span>
)}
</div>
</div>
</CardContent>
</Card>
<Card className="border-white/10 bg-slate-950/35">
<CardHeader>
<CardTitle>Assigned Agent</CardTitle>
<CardDescription>Resolved fleet agent context for the current assignee.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{assignedAgent ? (
<>
<div className="rounded-xl border border-white/10 bg-slate-950/40 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-base font-medium text-white">{assignedAgent.name}</p>
<p className="mt-1 text-sm text-slate-400">{assignedAgent.role}</p>
</div>
<Badge variant={familyVariant(assignedAgent.family)}>{assignedAgent.family}</Badge>
</div>
<dl className="mt-4 space-y-2 text-sm text-slate-200">
<div className="flex justify-between gap-3">
<dt className="text-slate-400">Host</dt>
<dd>{assignedAgent.host}</dd>
</div>
<div className="flex justify-between gap-3">
<dt className="text-slate-400">Runtime</dt>
<dd className="max-w-[65%] break-words text-right">{assignedAgent.runtimePath}</dd>
</div>
<div className="flex justify-between gap-3">
<dt className="text-slate-400">Last Heartbeat</dt>
<dd>{formatDateTime(assignedAgent.heartbeatAt)}</dd>
</div>
</dl>
</div>
<Link
className="inline-flex h-10 w-full items-center justify-center rounded-md border border-border bg-transparent px-4 py-2 text-sm font-medium transition-colors hover:bg-secondary/40"
href={`/agents/${assignedAgent.slug}`}
>
Open Agent Details
</Link>
</>
) : (
<p className="text-sm text-slate-400">No configured fleet agent matched this task assignee.</p>
)}
</CardContent>
</Card>
<Card className="border-white/10 bg-slate-950/35">
<CardHeader>
<CardTitle>Timeline</CardTitle>
<CardDescription>Primary timestamps for audit and review.</CardDescription>
</CardHeader>
<CardContent>
<dl className="space-y-3 text-sm">
<div className="flex justify-between gap-4 border-b border-white/5 pb-3">
<dt className="text-slate-400">Created</dt>
<dd className="text-right text-slate-100">{formatDateTime(task.created_at)}</dd>
</div>
<div className="flex justify-between gap-4 border-b border-white/5 pb-3">
<dt className="text-slate-400">Updated</dt>
<dd className="text-right text-slate-100">{formatDateTime(task.updated_at)}</dd>
</div>
<div className="flex justify-between gap-4 border-b border-white/5 pb-3">
<dt className="text-slate-400">Last Dispatch</dt>
<dd className="text-right text-slate-100">{formatDateTime(task.last_dispatch_at)}</dd>
</div>
<div className="flex justify-between gap-4">
<dt className="text-slate-400">Completed</dt>
<dd className="text-right text-slate-100">{formatDateTime(task.completed_at)}</dd>
</div>
</dl>
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

@@ -1,9 +0,0 @@
import { DispatchHistory } from "@/components/dispatch-history";
import { listTaskEvents } from "@/lib/tasks";
export const dynamic = "force-dynamic";
export default async function TasksDispatchPage() {
const events = await listTaskEvents(undefined, 50);
return <DispatchHistory events={events} />;
}

View File

@@ -1,9 +0,0 @@
import { FailureQueue } from "@/components/failure-queue";
import { listFailedTasks } from "@/lib/tasks";
export const dynamic = "force-dynamic";
export default async function TasksFailuresPage() {
const failedTasks = await listFailedTasks();
return <FailureQueue failedTasks={failedTasks} />;
}

View File

@@ -1,14 +0,0 @@
import { TasksSubnav } from "@/components/tasks-subnav";
export default function TasksLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<div className="min-w-0">
<TasksSubnav />
{children}
</div>
);
}

View File

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

View File

@@ -1,39 +0,0 @@
import { all } from "@/lib/db";
import { UsageView } from "@/components/usage-view";
export const dynamic = "force-dynamic";
type UsageRow = {
agent: string;
provider: string;
model: string;
tokens_used: number;
cost_estimate: number;
};
export default async function UsagePage() {
const rows = await all<UsageRow>("SELECT * FROM usage_tracking ORDER BY timestamp DESC");
const stats = rows.reduce(
(accumulator, row) => {
accumulator.totalRequests += 1;
accumulator.totalTokens += row.tokens_used || 0;
accumulator.totalCost += row.cost_estimate || 0;
if (!accumulator.byAgent[row.agent]) {
accumulator.byAgent[row.agent] = { requests: 0, tokens: 0, cost: 0 };
}
accumulator.byAgent[row.agent].requests += 1;
accumulator.byAgent[row.agent].tokens += row.tokens_used || 0;
accumulator.byAgent[row.agent].cost += row.cost_estimate || 0;
return accumulator;
},
{
totalRequests: 0,
totalTokens: 0,
totalCost: 0,
byAgent: {} as Record<string, { requests: number; tokens: number; cost: number }>,
},
);
return <UsageView stats={stats} />;
}

View File

@@ -1,23 +0,0 @@
import { WikiView } from "@/components/wiki-view";
import { listWikiPages, readWikiPage } from "@/lib/wiki";
export const dynamic = "force-dynamic";
export default async function WikiPage() {
const pages = listWikiPages();
const firstPage = pages[0] ? readWikiPage(pages[0].filename) : null;
return (
<WikiView
pages={pages}
initialPageContent={
firstPage
? {
title: firstPage.metadata.title,
content: firstPage.content,
}
: null
}
/>
);
}

View File

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

View File

@@ -1,16 +0,0 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}

View File

@@ -1,198 +0,0 @@
"use client";
import Link from "next/link";
import { useDeferredValue, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import type { AgentFamily, FleetAgent } from "@/lib/types";
function heartbeatTone(agent: FleetAgent) {
if (agent.heartbeatAgeMinutes === null) {
return "warning";
}
if (agent.heartbeatAgeMinutes > 180) {
return "warning";
}
return "success";
}
export function AgentsClient({
agents,
defaultFamily = "",
}: {
agents: FleetAgent[];
defaultFamily?: AgentFamily | "";
}) {
const [query, setQuery] = useState("");
const [family, setFamily] = useState<AgentFamily | "">(defaultFamily);
const deferredQuery = useDeferredValue(query);
const filteredAgents = agents.filter((agent) => {
const matchesQuery =
deferredQuery.length === 0 ||
agent.name.toLowerCase().includes(deferredQuery.toLowerCase()) ||
agent.host.toLowerCase().includes(deferredQuery.toLowerCase()) ||
agent.role.toLowerCase().includes(deferredQuery.toLowerCase());
const matchesFamily = family.length === 0 || agent.family === family;
return matchesQuery && matchesFamily;
});
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Configured Agent Runtimes</CardTitle>
<CardDescription>
OpenClaw swarm members, ZeroClaw runtimes, and direct host targets from the tracked fleet model with heartbeat and dispatch overlays.
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-[1fr_220px]">
<Input
placeholder="Search by name, host, or role"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<Select value={family} onChange={(event) => setFamily(event.target.value as AgentFamily | "")}>
<option value="">All families</option>
<option value="openclaw">OpenClaw</option>
<option value="zeroclaw">ZeroClaw</option>
<option value="direct">Direct</option>
</Select>
</CardContent>
</Card>
<div className="grid gap-4 lg:grid-cols-2">
{filteredAgents.map((agent) => (
<Card key={agent.slug}>
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="flex items-center gap-2">
<span>{agent.emoji}</span>
<span>{agent.name}</span>
</CardTitle>
<CardDescription>{agent.role}</CardDescription>
</div>
<div className="flex gap-2">
<Badge variant={agent.family === "openclaw" ? "default" : agent.family === "zeroclaw" ? "success" : "warning"}>
{agent.family}
</Badge>
<Badge variant="secondary">{agent.status}</Badge>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<dl className="grid gap-2 text-sm text-slate-300 md:grid-cols-2">
<div>
<dt className="text-xs uppercase tracking-[0.2em] text-slate-500">Host</dt>
<dd>{agent.host}</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-[0.2em] text-slate-500">Model</dt>
<dd>{agent.model || "Host-local/runtime-defined"}</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-[0.2em] text-slate-500">Dispatch</dt>
<dd>{agent.defaultDispatchMethod}</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-[0.2em] text-slate-500">Workload</dt>
<dd>{agent.workload} active</dd>
</div>
<div className="md:col-span-2">
<dt className="text-xs uppercase tracking-[0.2em] text-slate-500">Runtime</dt>
<dd className="font-mono text-xs text-cyan-100">{agent.runtimePath}</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-[0.2em] text-slate-500">Current task</dt>
<dd>{agent.currentTask || "No heartbeat task"}</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-[0.2em] text-slate-500">Heartbeat</dt>
<dd>
<Badge variant={heartbeatTone(agent)}>
{agent.heartbeatAgeMinutes === null ? "No heartbeat" : `${agent.heartbeatAgeMinutes}m ago`}
</Badge>
</dd>
</div>
</dl>
<div>
<p className="mb-2 text-xs uppercase tracking-[0.2em] text-slate-500">Channels</p>
<div className="flex flex-wrap gap-2">
{agent.channels.map((channel) => (
<Badge key={`${agent.slug}-${channel.label}`} variant="outline">
{channel.label}: {channel.value}
</Badge>
))}
</div>
</div>
<div>
<p className="mb-2 text-xs uppercase tracking-[0.2em] text-slate-500">Last dispatch event</p>
{agent.lastEvent ? (
<div className="rounded-xl border border-white/10 bg-slate-950/40 p-3">
<p className="font-medium text-white">{agent.lastEvent.summary}</p>
<p className="mt-1 text-sm text-slate-300">{agent.lastEvent.detail || "No detail captured."}</p>
<div className="mt-2 flex flex-wrap gap-2">
<Badge variant={agent.lastEvent.event_type === "dispatch_failed" ? "warning" : "secondary"}>
{agent.lastEvent.event_type}
</Badge>
<Badge variant="outline">Failures: {agent.failureStreak}</Badge>
</div>
</div>
) : (
<p className="text-sm text-slate-400">No audit events recorded yet.</p>
)}
</div>
<div>
<p className="mb-2 text-xs uppercase tracking-[0.2em] text-slate-500">Assigned Tasks</p>
{agent.activeTasks.length ? (
<div className="space-y-2">
{agent.activeTasks.slice(0, 3).map((task) => (
<div className="rounded-xl border border-white/10 bg-slate-950/40 p-3" 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="secondary">{task.status}</Badge>
</div>
<p className="mt-1 text-sm text-slate-300">{task.result_summary || task.description}</p>
</div>
))}
</div>
) : (
<p className="text-sm text-slate-400">No assigned tasks.</p>
)}
</div>
<div>
<p className="mb-2 text-xs uppercase tracking-[0.2em] text-slate-500">Tools</p>
<div className="flex flex-wrap gap-2">
{agent.tools.length ? (
agent.tools.map((tool) => (
<Badge key={tool} variant="secondary">
{tool}
</Badge>
))
) : (
<span className="text-sm text-slate-400">No parsed tools.</span>
)}
</div>
</div>
<Link
className="inline-flex items-center justify-center rounded-lg border border-cyan-300/20 bg-cyan-300/10 px-4 py-2 text-sm font-medium text-cyan-100 transition hover:border-cyan-300/40 hover:bg-cyan-300/15"
href={`/agents/${agent.slug}`}
>
View Agent Details
</Link>
</CardContent>
</Card>
))}
</div>
</div>
);
}

View File

@@ -1,75 +0,0 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Network, NotebookTabs, PanelsTopLeft, ScrollText, Send, Settings2, ShieldEllipsis, UsersRound } from "lucide-react";
import { cn } from "@/lib/utils";
const navItems = [
{ href: "/tasks", label: "Tasks", icon: PanelsTopLeft },
{ href: "/agents", label: "Agents", icon: UsersRound },
{ href: "/openclaw", label: "OpenClaw", icon: ShieldEllipsis },
{ href: "/zeroclaw", label: "ZeroClaw", icon: Send },
{ href: "/architecture", label: "Architecture", icon: Network },
{ href: "/wiki", label: "Wiki", icon: NotebookTabs },
{ href: "/usage", label: "Usage", icon: ScrollText },
{ href: "/gitea", label: "Gitea", icon: Settings2 },
];
export function AppShell({
children,
}: {
children: React.ReactNode;
}) {
const pathname = usePathname();
return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(34,211,238,0.18),_transparent_30%),radial-gradient(circle_at_top_right,_rgba(245,158,11,0.14),_transparent_28%),linear-gradient(180deg,#07111f_0%,#091321_44%,#0f172a_100%)] text-foreground">
<header className="sticky top-0 z-40 border-b border-white/10 bg-slate-950/75 backdrop-blur-xl">
<div className="mx-auto flex w-full max-w-[1760px] flex-col gap-5 px-6 py-5">
<div>
<p className="font-mono text-xs uppercase tracking-[0.3em] text-cyan-300/80">
OpenClaw Taskboard
</p>
<h1 className="mt-1 text-2xl font-semibold tracking-tight text-white">
Claw Fleet Console
</h1>
<p className="mt-1 max-w-2xl text-sm text-slate-300">
Unified operations view for OpenClaw orchestration, ZeroClaw host runtimes, and deployed architecture.
</p>
</div>
<nav className="flex flex-wrap gap-2">
{navItems.map((item) => {
const Icon = item.icon;
const isActive =
pathname === item.href ||
pathname.startsWith(`${item.href}/`) ||
(item.href === "/tasks" && pathname.startsWith("/tasks"));
return (
<Link
className={cn(
"inline-flex items-center gap-2 rounded-full border px-4 py-2.5 text-sm transition",
isActive
? "border-cyan-300/30 bg-cyan-300/10 text-cyan-100 shadow-panel"
: "border-white/10 bg-slate-950/35 text-slate-300 hover:border-white/20 hover:bg-white/5 hover:text-white",
)}
href={item.href}
key={item.href}
>
<Icon className="h-4 w-4" />
<span>{item.label}</span>
</Link>
);
})}
</nav>
</div>
</header>
<div className="mx-auto w-full max-w-[1760px] px-6 py-8">
<main className="min-w-0">{children}</main>
</div>
</div>
);
}

View File

@@ -1,84 +0,0 @@
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import type { ArchitectureDocument } from "@/lib/types";
export function ArchitectureView({ architecture }: { architecture: ArchitectureDocument }) {
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>{architecture.title}</CardTitle>
<CardDescription>Generated from the deployed fleet model and tracked channel/runtime definitions.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap gap-2">
{architecture.overview.map((line) => (
<Badge key={line} variant="secondary">
{line}
</Badge>
))}
</div>
<pre className="rounded-xl border border-white/10 bg-slate-950/60 p-4 font-mono text-xs text-cyan-100">
{architecture.topologyDiagram}
</pre>
</CardContent>
</Card>
<div className="grid gap-6">
{architecture.sections.map((section) => (
<Card key={section.id}>
<CardHeader>
<CardTitle>{section.title}</CardTitle>
<CardDescription>{section.summary}</CardDescription>
</CardHeader>
<CardContent className="grid gap-6 xl:grid-cols-[minmax(0,1fr)_340px]">
<div className="space-y-4">
<pre className="rounded-xl border border-white/10 bg-slate-950/60 p-4 font-mono text-xs text-cyan-100">
{section.diagram}
</pre>
<div className="flex flex-wrap gap-2">
{section.configuredAgents.map((agentName) => (
<Badge key={agentName}>{agentName}</Badge>
))}
</div>
</div>
<div className="space-y-4">
<div>
<p className="mb-2 text-xs uppercase tracking-[0.2em] text-slate-500">Runtime</p>
<div className="space-y-2">
{section.runtime.map((entry) => (
<div className="rounded-lg border border-white/10 bg-white/5 p-3 text-sm" key={`${section.id}-${entry.label}`}>
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">{entry.label}</p>
<p>{entry.value}</p>
</div>
))}
</div>
</div>
<div>
<p className="mb-2 text-xs uppercase tracking-[0.2em] text-slate-500">Channels</p>
<div className="space-y-2">
{section.channels.map((entry) => (
<div className="rounded-lg border border-white/10 bg-white/5 p-3 text-sm" key={`${section.id}-${entry.label}`}>
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">{entry.label}</p>
<p>{entry.value}</p>
</div>
))}
</div>
</div>
<div>
<p className="mb-2 text-xs uppercase tracking-[0.2em] text-slate-500">Notes</p>
<ul className="space-y-2 text-sm text-slate-300">
{section.notes.map((note) => (
<li key={note}>- {note}</li>
))}
</ul>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}

View File

@@ -1,44 +0,0 @@
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import type { TaskEvent } from "@/lib/types";
export function DispatchHistory({
events,
}: {
events: TaskEvent[];
}) {
return (
<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 className="min-w-0">
<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" : event.family === "direct" ? "warning" : "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 break-words 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>
);
}

View File

@@ -1,49 +0,0 @@
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import type { TaskRecord } from "@/lib/types";
export function FailureQueue({
failedTasks,
}: {
failedTasks: TaskRecord[];
}) {
return (
<Card>
<CardHeader>
<CardTitle>Failure Queue</CardTitle>
<CardDescription>
Tasks with failed dispatch state that still need 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 className="min-w-0">
<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 break-words text-sm text-slate-300">
{task.last_error || "No error text captured."}
</p>
<div className="mt-3 flex flex-wrap gap-2">
{task.tags.map((tag) => (
<Badge key={tag} variant="outline">
{tag}
</Badge>
))}
</div>
</div>
))
)}
</CardContent>
</Card>
);
}

View File

@@ -1,245 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { CardDescription, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Select } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import type { FleetAgent, TaskPriority, TaskTemplate } from "@/lib/types";
const PRIORITIES: TaskPriority[] = ["Low", "Medium", "High", "Critical"];
export function TaskIntakeModal({
agents,
templates,
open,
onClose,
onCreated,
}: {
agents: FleetAgent[];
templates: TaskTemplate[];
open: boolean;
onClose: () => void;
onCreated: () => Promise<void>;
}) {
const [formState, setFormState] = useState({
templateKey: "",
title: "",
description: "",
assignee: "",
priority: "Medium" as TaskPriority,
tags: "",
repoSlug: "",
baseBranch: "main",
preferredAgent: "codex",
reasoningEffort: "high",
modelHint: "",
});
const selectedTemplate = templates.find((template) => template.key === formState.templateKey) || null;
const selectedAgent = agents.find((agent) => agent.assignmentKey === formState.assignee) || null;
useEffect(() => {
if (!open) {
return;
}
function onKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") {
onClose();
}
}
window.addEventListener("keydown", onKeyDown);
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKeyDown);
document.body.style.overflow = "";
};
}, [onClose, open]);
function applyTemplate(templateKey: string) {
const template = templates.find((entry) => entry.key === templateKey) || null;
if (!template) {
setFormState((current) => ({ ...current, templateKey }));
return;
}
setFormState((current) => ({
...current,
templateKey,
title: current.title || template.title,
priority: template.defaults.priority,
tags: template.tags.join(", "),
repoSlug: template.defaults.repoSlug || current.repoSlug,
baseBranch: template.defaults.baseBranch || current.baseBranch,
preferredAgent: template.defaults.preferredAgent || current.preferredAgent,
reasoningEffort: template.defaults.reasoningEffort || current.reasoningEffort,
}));
}
async function createTask(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const tags = formState.tags
.split(",")
.map((tag) => tag.trim())
.filter(Boolean);
await fetch("/api/tasks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: formState.title,
description: formState.description,
assignee: formState.assignee,
priority: formState.priority,
tags,
template_key: formState.templateKey || null,
repo_slug: formState.repoSlug || null,
base_branch: formState.baseBranch || null,
preferred_agent: formState.preferredAgent || null,
reasoning_effort: formState.reasoningEffort || null,
model_hint: formState.modelHint || null,
family: selectedAgent?.family || selectedTemplate?.family || null,
target_host: selectedAgent?.host || selectedTemplate?.defaults.targetHost || "",
target_channel: selectedAgent?.channels[0]?.value || selectedTemplate?.defaults.targetChannel || "",
dispatch_method: selectedAgent?.defaultDispatchMethod || selectedTemplate?.defaults.dispatchMethod || "manual",
}),
});
setFormState({
templateKey: "",
title: "",
description: "",
assignee: "",
priority: "Medium",
tags: "",
repoSlug: "",
baseBranch: "main",
preferredAgent: "codex",
reasoningEffort: "high",
modelHint: "",
});
await onCreated();
onClose();
}
if (!open) {
return null;
}
return (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-slate-950/70 px-4 py-10 backdrop-blur-sm">
<div className="absolute inset-0" onClick={onClose} />
<div className="relative w-full max-w-3xl rounded-3xl border border-white/10 bg-slate-950/95 shadow-2xl">
<div className="flex items-start justify-between gap-4 border-b border-white/10 px-6 py-5">
<div>
<CardTitle>New Task</CardTitle>
<CardDescription>
Create a typed task and route it to the right execution family without leaving the board.
</CardDescription>
</div>
<Button size="sm" variant="ghost" onClick={onClose}>
Close
</Button>
</div>
<form className="grid gap-3 p-6 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"
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 flex-wrap items-center justify-between gap-3 border-t border-white/10 pt-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>
<div className="flex gap-2">
<Button type="button" variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button type="submit">Create Task</Button>
</div>
</div>
</form>
</div>
</div>
);
}

View File

@@ -1,220 +0,0 @@
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { TaskIntakeModal } from "@/components/task-intake-modal";
import type { FleetAgent, TaskRecord, TaskStatus, TaskTemplate } from "@/lib/types";
const COLUMNS: TaskStatus[] = ["Backlog", "Todo", "In Progress", "Review", "Done"];
function familyVariant(family: string | null) {
if (family === "zeroclaw") {
return "success";
}
if (family === "direct") {
return "warning";
}
return "default";
}
function dispatchVariant(state: TaskRecord["dispatch_state"]) {
return state === "failed" ? "warning" : state === "completed" ? "success" : "secondary";
}
export function TasksClient({
initialTasks,
agents,
templates,
}: {
initialTasks: TaskRecord[];
agents: FleetAgent[];
templates: TaskTemplate[];
}) {
const [tasks, setTasks] = useState(initialTasks);
const [isModalOpen, setIsModalOpen] = useState(false);
const router = useRouter();
async function refreshData() {
const taskResponse = await fetch("/api/tasks");
setTasks((await taskResponse.json()) as TaskRecord[]);
}
async function patchTask(taskId: number, payload: Partial<TaskRecord>) {
await fetch(`/api/tasks/${taskId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
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();
}
function openTask(taskId: number) {
router.push(`/tasks/${taskId}`);
}
return (
<div className="space-y-6">
<Card className="border-white/10 bg-slate-950/35">
<CardHeader className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<CardTitle>Taskboard</CardTitle>
<CardDescription>
The board is the primary workspace. Task intake opens as a modal so the board keeps its full visual width.
</CardDescription>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">{tasks.length} total</Badge>
<Badge variant="secondary">{tasks.filter((task) => task.status === "In Progress").length} active</Badge>
<Button onClick={() => setIsModalOpen(true)}>New Task</Button>
</div>
</CardHeader>
</Card>
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-white">Task Board</h2>
<p className="text-sm text-slate-400">
Columns keep a readable width and scroll horizontally when the viewport is narrower than the full board.
</p>
</div>
<Badge variant="outline">{tasks.length} total tasks</Badge>
</div>
<div className="grid gap-4 xl:grid-cols-5">
{COLUMNS.map((column) => {
const columnTasks = tasks.filter((task) => task.status === column);
return (
<Card className="flex min-h-[560px] min-w-0 flex-col border-white/10 bg-slate-950/35" key={column}>
<CardHeader className="pb-4">
<CardTitle className="flex items-center justify-between text-base">
<span>{column}</span>
<Badge variant="secondary">{columnTasks.length}</Badge>
</CardTitle>
</CardHeader>
<CardContent className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto">
{columnTasks.map((task) => (
<div
className="min-w-0 cursor-pointer rounded-xl border border-white/10 bg-slate-950/40 p-4 transition-colors hover:border-cyan-400/30 hover:bg-slate-950/60"
key={task.id}
onClick={() => openTask(task.id)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openTask(task.id);
}
}}
role="link"
tabIndex={0}
>
<div className="flex items-start justify-between gap-3">
<h3 className="min-w-0 break-words font-medium text-white">{task.title}</h3>
<Badge variant={task.priority === "Critical" ? "warning" : "outline"}>
{task.priority}
</Badge>
</div>
<p className="mt-2 break-words text-sm leading-6 text-slate-300">
{task.description || "No description"}
</p>
<div className="mt-3 flex flex-wrap gap-2 text-xs text-slate-400">
<Badge variant={familyVariant(task.family)}>{task.family || "manual"}</Badge>
<Badge variant="secondary">{task.assignee || "Unassigned"}</Badge>
<Badge variant={dispatchVariant(task.dispatch_state)}>{task.dispatch_state}</Badge>
{task.tags.map((tag) => (
<Badge key={tag} variant="outline">
{tag}
</Badge>
))}
</div>
<dl className="mt-3 grid gap-1 text-xs text-slate-400">
<div className="flex justify-between gap-2">
<dt>Host</dt>
<dd className="break-words text-right">{task.target_host || "n/a"}</dd>
</div>
<div className="flex justify-between gap-2">
<dt>Channel</dt>
<dd className="break-all text-right">{task.target_channel || "n/a"}</dd>
</div>
</dl>
{task.result_summary ? (
<div className="mt-3 rounded-lg border border-emerald-400/20 bg-emerald-400/5 p-3">
<p className="text-xs uppercase tracking-[0.2em] text-emerald-300/80">Latest Result</p>
<p className="mt-1 text-sm text-slate-200">{task.result_summary}</p>
{task.result_detail ? (
<p className="mt-1 break-words text-xs leading-5 text-slate-400">{task.result_detail}</p>
) : null}
</div>
) : null}
<div className="mt-4 space-y-2">
{task.dispatch_state !== "dispatched" && task.dispatch_state !== "completed" ? (
<Button
className="w-full"
size="sm"
onClick={(event) => {
event.stopPropagation();
void dispatchTask(task.id);
}}
>
Dispatch
</Button>
) : null}
{task.dispatch_state === "dispatched" ? (
<Button
className="w-full"
size="sm"
variant="outline"
onClick={(event) => {
event.stopPropagation();
void acknowledgeTask(task.id);
}}
>
Mark Acknowledged
</Button>
) : null}
{task.status !== "Done" ? (
<Button
className="w-full"
size="sm"
variant="outline"
onClick={(event) => {
event.stopPropagation();
void patchTask(task.id, { status: "Done" });
}}
>
Mark Done
</Button>
) : null}
</div>
</div>
))}
</CardContent>
</Card>
);
})}
</div>
</div>
<TaskIntakeModal
agents={agents}
templates={templates}
open={isModalOpen}
onClose={() => setIsModalOpen(false)}
onCreated={refreshData}
/>
</div>
);
}

View File

@@ -1,38 +0,0 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
const items = [
{ href: "/tasks", label: "Board" },
{ href: "/tasks/dispatch", label: "Dispatch" },
{ href: "/tasks/failures", label: "Failure Queue" },
];
export function TasksSubnav() {
const pathname = usePathname();
return (
<div className="mb-6 flex flex-wrap gap-2">
{items.map((item) => {
const isActive = pathname === item.href;
return (
<Link
className={cn(
"inline-flex items-center rounded-full border px-4 py-2 text-sm transition",
isActive
? "border-cyan-300/30 bg-cyan-300/10 text-cyan-100"
: "border-white/10 bg-slate-950/35 text-slate-300 hover:border-white/20 hover:bg-white/5 hover:text-white",
)}
href={item.href}
key={item.href}
>
{item.label}
</Link>
);
})}
</div>
);
}

View File

@@ -1,29 +0,0 @@
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold tracking-wide",
{
variants: {
variant: {
default: "border-transparent bg-primary/15 text-primary",
secondary: "border-border bg-secondary/70 text-secondary-foreground",
outline: "border-border/70 text-foreground",
success: "border-emerald-400/30 bg-emerald-400/10 text-emerald-300",
warning: "border-amber-400/30 bg-amber-400/10 text-amber-300",
},
},
defaultVariants: {
variant: "default",
},
},
);
export function Badge({
className,
variant,
...props
}: React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof badgeVariants>) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}

View File

@@ -1,41 +0,0 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
outline: "border border-border bg-transparent hover:bg-secondary/40",
ghost: "hover:bg-secondary/40",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
),
);
Button.displayName = "Button";

View File

@@ -1,46 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export function Card({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"rounded-xl border border-border/70 bg-card/90 text-card-foreground shadow-panel backdrop-blur-sm",
className,
)}
{...props}
/>
);
}
export function CardHeader({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("flex flex-col gap-2 p-6", className)} {...props} />;
}
export function CardTitle({
className,
...props
}: React.HTMLAttributes<HTMLHeadingElement>) {
return <h3 className={cn("text-lg font-semibold tracking-tight", className)} {...props} />;
}
export function CardDescription({
className,
...props
}: React.HTMLAttributes<HTMLParagraphElement>) {
return <p className={cn("text-sm text-muted-foreground", className)} {...props} />;
}
export function CardContent({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("p-6 pt-0", className)} {...props} />;
}

View File

@@ -1,17 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, ...props }, ref) => (
<input
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background/70 px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
className,
)}
ref={ref}
{...props}
/>
),
);
Input.displayName = "Input";

View File

@@ -1,20 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export const Select = React.forwardRef<
HTMLSelectElement,
React.SelectHTMLAttributes<HTMLSelectElement>
>(({ className, children, ...props }, ref) => (
<select
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background/70 px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
className,
)}
ref={ref}
{...props}
>
{children}
</select>
));
Select.displayName = "Select";

View File

@@ -1,18 +0,0 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.TextareaHTMLAttributes<HTMLTextAreaElement>
>(({ className, ...props }, ref) => (
<textarea
className={cn(
"flex min-h-[100px] w-full rounded-md border border-input bg-background/70 px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
className,
)}
ref={ref}
{...props}
/>
));
Textarea.displayName = "Textarea";

View File

@@ -1,58 +0,0 @@
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
export function UsageView({
stats,
}: {
stats: {
totalRequests: number;
totalTokens: number;
totalCost: number;
byAgent: Record<string, { requests: number; tokens: number; cost: number }>;
};
}) {
return (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader>
<CardTitle>Total Requests</CardTitle>
</CardHeader>
<CardContent className="text-3xl font-semibold">{stats.totalRequests}</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Total Tokens</CardTitle>
</CardHeader>
<CardContent className="text-3xl font-semibold">{stats.totalTokens}</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Estimated Cost</CardTitle>
</CardHeader>
<CardContent className="text-3xl font-semibold">${stats.totalCost.toFixed(2)}</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>By Agent</CardTitle>
<CardDescription>Aggregated from the taskboard usage tracking table.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{Object.entries(stats.byAgent).map(([agent, value]) => (
<div className="flex items-center justify-between rounded-xl border border-white/10 bg-slate-950/40 p-4" key={agent}>
<div>
<p className="font-medium">{agent}</p>
<p className="text-sm text-slate-400">{value.tokens} tokens</p>
</div>
<div className="flex gap-2">
<Badge variant="secondary">{value.requests} req</Badge>
<Badge variant="outline">${value.cost.toFixed(2)}</Badge>
</div>
</div>
))}
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,60 +0,0 @@
"use client";
import { useState } from "react";
import ReactMarkdown from "react-markdown";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import type { WikiPageSummary } from "@/lib/types";
export function WikiView({
pages,
initialPageContent,
}: {
pages: WikiPageSummary[];
initialPageContent: { title: string; content: string } | null;
}) {
const [activeTitle, setActiveTitle] = useState(initialPageContent?.title || "Select a page");
const [activeContent, setActiveContent] = useState(initialPageContent?.content || "");
const [filter, setFilter] = useState("");
const filteredPages = pages.filter((page) =>
filter.length === 0 ? true : page.title.toLowerCase().includes(filter.toLowerCase()),
);
async function openPage(filename: string) {
const response = await fetch(`/api/wiki/${filename}`);
const page = await response.json();
setActiveTitle(page.metadata.title);
setActiveContent(page.content);
}
return (
<div className="grid gap-6 lg:grid-cols-[280px_minmax(0,1fr)]">
<Card>
<CardHeader>
<CardTitle>Wiki Pages</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<Input placeholder="Search wiki" value={filter} onChange={(event) => setFilter(event.target.value)} />
<div className="space-y-2">
{filteredPages.map((page) => (
<Button className="w-full justify-start" key={page.filename} variant="ghost" onClick={() => openPage(page.filename)}>
{page.title}
</Button>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{activeTitle}</CardTitle>
</CardHeader>
<CardContent className="prose prose-invert max-w-none prose-pre:rounded-xl prose-pre:border prose-pre:border-white/10 prose-pre:bg-slate-950/70">
{activeContent ? <ReactMarkdown>{activeContent}</ReactMarkdown> : <p className="text-slate-400">Select a wiki page to view it.</p>}
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,282 +0,0 @@
{
"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.",
"Direct SSH targets extend the taskboard to hosts that do not run an active Claw runtime.",
"The taskboard is the shared planning, dispatch, and audit surface across all host-operation 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 v\n OpenClaw agents ZeroClaw runtimes Direct SSH targets\n ubuntu-local grizzley / ice pve / truenas / panda\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."
]
},
{
"id": "direct",
"title": "Direct Host Targets",
"summary": "SSH-backed host operations for systems that do not run an active OpenClaw or ZeroClaw runtime. These flows execute safe, built-in host checks and complete through the taskboard callback pipeline.",
"runtime": [
{ "label": "Execution", "value": "taskboard container on ubuntu" },
{ "label": "Transport", "value": "SSH with mounted host key material" },
{ "label": "Key Path", "value": "/root/.ssh/id_ed25519 inside container" }
],
"channels": [
{ "label": "PVE", "value": "root@192.168.50.11:22" },
{ "label": "TrueNAS", "value": "christopher@192.168.50.12:22" },
{ "label": "Panda", "value": "bear@192.168.50.196:22" }
],
"configuredAgents": [
"pve-direct",
"truenas-admin",
"panda-direct"
],
"diagram": "taskboard direct SSH\n -> pve : built-in Proxmox overview\n -> truenas : dedicated truenas-admin audit actions\n -> panda : built-in SSH add-on overview\n\nEach direct task\n -> ssh safe built-in command or host-specific builtin audit\n -> capture stdout/stderr plus repo dependency correlation\n -> task callback -> completed/review result\n",
"notes": [
"Direct targets are for safe built-in actions, not arbitrary remote shell execution from the UI.",
"Completion state is written through the same callback pipeline used by remote agent runtimes."
]
}
],
"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."
}
}
],
"directAgents": [
{
"slug": "pve-direct",
"assignmentKey": "pve-direct",
"aliases": ["pve-direct", "PVE Direct", "pve"],
"name": "PVE Direct",
"host": "pve",
"role": "Direct Proxmox host checks over SSH",
"runtimePath": "ssh://root@192.168.50.11:22",
"configPath": null,
"emoji": "P",
"channels": [
{ "label": "SSH", "value": "root@192.168.50.11:22" },
{ "label": "Actions", "value": "proxmox-overview" }
],
"tools": ["ssh", "systemctl", "pct", "qm"],
"capabilities": [
"Verify core Proxmox services",
"Enumerate running LXC containers",
"Enumerate VM state"
],
"files": [],
"notes": [
"Uses direct SSH from the taskboard container.",
"Designed for safe built-in verification flows."
],
"dispatch": {
"method": "direct-ssh",
"hostname": "192.168.50.11",
"user": "root",
"port": 22,
"defaultAction": "proxmox-overview",
"actions": [
{
"key": "proxmox-overview",
"title": "Proxmox overview",
"description": "Verify core services and list active LXCs and VMs.",
"command": "systemctl is-active pve-cluster pvedaemon pveproxy pvestatd ssh && printf '\\nCTs:\\n' && pct list && printf '\\nVMs:\\n' && qm list",
"successSummary": "PVE services and guest inventory collected"
}
]
}
},
{
"slug": "truenas-admin",
"assignmentKey": "truenas-admin",
"aliases": ["truenas-admin", "truenas-direct", "TrueNAS Admin", "TrueNAS Direct", "truenas"],
"name": "TrueNAS Admin",
"host": "truenas",
"role": "Dedicated storage and dataset audit agent for the TrueNAS host",
"runtimePath": "ssh://christopher@192.168.50.12:22",
"configPath": null,
"emoji": "T",
"channels": [
{ "label": "SSH", "value": "christopher@192.168.50.12:22" },
{ "label": "Actions", "value": "dataset-audit, storage-overview" }
],
"tools": ["ssh", "zfs", "systemctl", "midclt"],
"capabilities": [
"Build dataset dependency matrices from live storage plus repo configuration",
"Verify storage datasets",
"Check docker service state",
"Report host identity and storage status"
],
"files": [],
"notes": [
"Runs safe read-only storage and dependency audit checks.",
"Does not delete datasets directly; cleanup stays review-gated."
],
"dispatch": {
"method": "direct-ssh",
"hostname": "192.168.50.12",
"user": "christopher",
"port": 22,
"defaultAction": "dataset-audit",
"actions": [
{
"key": "dataset-audit",
"title": "Dataset dependency audit",
"description": "Correlate live ZFS datasets with active homelab dependency signals and produce cleanup review candidates.",
"command": "builtin:truenas-dataset-audit",
"successSummary": "TrueNAS dataset dependency audit completed"
},
{
"key": "storage-overview",
"title": "Storage overview",
"description": "Report host identity, docker-app service state, and top-level ZFS datasets.",
"command": "printf 'Host: '; hostname && printf '\\nDocker apps service:\\n' && systemctl is-active truenas-docker-apps.service || true && printf '\\nDatasets:\\n' && zfs list -o name,used,avail | head -n 12",
"successSummary": "TrueNAS storage overview collected"
}
]
}
},
{
"slug": "panda-direct",
"assignmentKey": "panda-direct",
"aliases": ["panda-direct", "Panda Direct", "panda"],
"name": "Panda Direct",
"host": "panda",
"role": "Direct SSH add-on checks for the Home Assistant host",
"runtimePath": "ssh://bear@192.168.50.196:22",
"configPath": null,
"emoji": "H",
"channels": [
{ "label": "SSH", "value": "bear@192.168.50.196:22" },
{ "label": "Actions", "value": "ssh-addon-overview" }
],
"tools": ["ssh", "hostname", "cat", "ls"],
"capabilities": [
"Verify SSH add-on shell reachability",
"Report add-on OS state and mounted data files"
],
"files": [],
"notes": [
"Targets the Home Assistant SSH add-on shell, not a full host shell.",
"Uses shell-safe inspection commands that work without supervisor API auth."
],
"dispatch": {
"method": "direct-ssh",
"hostname": "192.168.50.196",
"user": "bear",
"port": 22,
"defaultAction": "ssh-addon-overview",
"actions": [
{
"key": "ssh-addon-overview",
"title": "SSH add-on overview",
"description": "Report add-on shell identity, OS information, and mounted /data files.",
"command": "printf 'Host: '; hostname && printf '\\nOS:\\n' && cat /etc/os-release && printf '\\nData dir:\\n' && ls -1 /data 2>/dev/null | head -n 10",
"successSummary": "Panda SSH add-on overview collected"
}
]
}
}
]
}

View File

@@ -1,98 +0,0 @@
[
{
"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"
}
},
{
"key": "direct-pve-check",
"title": "PVE direct verification",
"summary": "Run the built-in Proxmox overview action through the direct SSH target.",
"family": "direct",
"tags": ["host-ops", "service-check", "action:proxmox-overview"],
"defaults": {
"priority": "High",
"dispatchMethod": "direct-ssh",
"targetHost": "pve",
"targetChannel": "root@192.168.50.11:22"
}
},
{
"key": "direct-truenas-check",
"title": "TrueNAS dataset audit",
"summary": "Run the dedicated TrueNAS dataset dependency audit through the host-specific admin agent.",
"family": "direct",
"tags": ["host-ops", "storage-check", "dataset-audit", "action:dataset-audit"],
"defaults": {
"priority": "High",
"dispatchMethod": "direct-ssh",
"targetHost": "truenas",
"targetChannel": "christopher@192.168.50.12:22"
}
},
{
"key": "direct-panda-check",
"title": "Panda direct verification",
"summary": "Run the built-in SSH add-on overview action through the direct target.",
"family": "direct",
"tags": ["host-ops", "home-assistant", "action:ssh-addon-overview"],
"defaults": {
"priority": "High",
"dispatchMethod": "direct-ssh",
"targetHost": "panda",
"targetChannel": "bear@192.168.50.196:22"
}
}
]

View File

@@ -1,26 +1,18 @@
version: '3.8'
services: services:
taskboard: openclaw-taskboard:
build: . build: .
container_name: openclaw-taskboard container_name: openclaw-taskboard
restart: unless-stopped
ports: ports:
- "8395:8395" - "8395:8395"
volumes:
- ./data:/app/data
- /home/bear/.openclaw/workspace/wiki:/app/wiki
- /home/bear/.openclaw/agents:/app/agents:ro
- /home/bear/.clawdbot:/app/swarm:ro
- /home/bear/.openclaw/openclaw.json:/app/openclaw.json:ro
environment: environment:
- NODE_ENV=production
- PORT=8395 - PORT=8395
- DB_PATH=/app/data/tasks.db - DB_PATH=/app/data/tasks.db
- WIKI_DIR=/app/wiki - WIKI_DIR=/app/wiki
- AGENTS_DIR=/app/agents - AGENTS_DIR=/app/agents
- SESSIONS_DIR=/app/agents - OPENCLAW_CONFIG=/app/config/openclaw.json
- SWARM_TASKS_FILE=/app/swarm/active-tasks.json volumes:
- OPENCLAW_CONFIG=/app/openclaw.json - ./data:/app/data
- GITEA_URL=https://gitea.tophermayor.com - /home/bear/.openclaw/workspace/wiki:/app/wiki
- GITEA_TOKEN=${GITEA_TOKEN} - /home/bear/.openclaw/agents:/app/agents:ro
restart: unless-stopped - /home/bear/.openclaw/openclaw.json:/app/config/openclaw.json:ro

View File

@@ -1,55 +0,0 @@
# Taskboard Implementation Status
## Implemented
- Next.js App Router migration with React 19, Tailwind CSS, and shadcn-style UI primitives
- Typed fleet model loaded from `config/fleet.json`
- Typed task templates loaded from `config/task-templates.json`
- Unified task intake for OpenClaw, ZeroClaw, and direct SSH targets
- Dispatch lifecycle states:
- `planned`
- `assigned`
- `dispatched`
- `acknowledged`
- `completed`
- `failed`
- SQLite-backed audit log in `task_events`
- Dispatch history API and UI
- Failure queue UI
- Family-specific pages:
- `/openclaw`
- `/zeroclaw`
- Architecture page rendered from tracked fleet config
- Agent cards with:
- heartbeat age
- workload
- last dispatch event
- failure counts
- OpenClaw swarm dispatch:
- repo map lookup
- safe-directory git handling for mounted repos
- worktree creation
- queue insertion into `~/.clawdbot/active-tasks.json`
- ZeroClaw webhook dispatch:
- bearer-token support for paired gateways
- direct gateway mode for testing
- Direct SSH dispatch:
- typed direct target definitions in `config/fleet.json`
- safe built-in host actions for `pve`, `truenas`, and `panda`
- completion written through the callback pipeline
- dedicated `truenas-admin` audit action that correlates live ZFS datasets with repo dependency signals
## Verified Live
- `grizzley` ZeroClaw webhook dispatch from taskboard
- `ice` ZeroClaw webhook dispatch from taskboard
- OpenClaw swarm queue creation and host worktree creation on `ubuntu`
- direct SSH host actions can now be dispatched for `pve`, `truenas-admin`, and `panda`
## Current Limits
- Taskboard can dispatch OpenClaw swarm tasks, but it does not yet monitor tmux session progress automatically.
- ZeroClaw acknowledgements and completions are still operator-driven; remote runtimes do not push completion state back yet.
- The board records remote webhook responses, but not structured per-step execution output from the agents.
- Direct targets are intentionally restricted to configured safe actions and do not expose arbitrary shell execution in the UI.
- `truenas-admin` remains review-gated for cleanup decisions; the taskboard produces audit results and candidate datasets, not direct deletion.

View File

@@ -1,46 +0,0 @@
# Taskboard Roadmap
## Next
1. Add execution-state sync for OpenClaw swarm tasks.
- Read `~/.clawdbot/active-tasks.json`
- Detect `queued`, `running`, and `completed`
- Reflect those states back into taskboard tasks automatically
2. Add remote completion callbacks for ZeroClaw.
- Accept structured webhook acknowledgements
- Persist remote execution summaries
- Auto-transition tasks to `acknowledged` or `completed`
3. Expand direct host operations beyond the first safe action set.
- add more read-only Proxmox actions
- add richer TrueNAS storage, share, and snapshot checks beyond the dataset audit
- add more Home Assistant supervisor and add-on checks
4. Add operator controls for swarm execution.
- launch queued task
- stop task
- nudge task
- open session/log link
5. Add richer audit detail.
- store structured request payload
- store response excerpt separately from summary
- attach host/service verification artifacts
6. Add dashboard summaries.
- task counts by family
- stale heartbeat warnings
- failure trends
- dispatch latency
7. Add completion workflows.
- generate wiki summary automatically
- link completed task to artifacts, PRs, or logs
## Longer Term
- Introduce a fleet capability registry so the taskboard can validate whether a task is legal for a given host before dispatch.
- Add authentication and RBAC for multi-operator use.
- Add generated runbooks and service maps directly from live host inventory.
- Add a dedicated direct-host page or dashboard slice for SSH-backed targets.

View File

@@ -1,110 +0,0 @@
// ============ GITEA INTEGRATION ============
const GiteaIntegration = require('./gitea-integration.js');
const giteaConfig = {
baseUrl: process.env.GITEA_URL || 'https://gitea.tophermayor.com',
token: process.env.GITEA_TOKEN,
owner: 'TopherMayor',
cacheTimeout: 30000
};
const gitea = new GiteaIntegration(giteaConfig);
// Gitea page route
app.get('/gitea', (req, res) => {
res.send(renderPage('gitea', 'gitea', 'OpenClaw Agent Fleet Dashboard - Gitea'));
});
// Gitea API routes
app.get('/api/gitea/swarm', async (req, res) => {
try {
const summary = await gitea.getSwarmSummary();
res.json(summary);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.get('/api/gitea/reviews', async (req, res) => {
try {
const reviews = await gitea.getPendingReviews();
res.json(reviews);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.get('/api/gitea/activity', async (req, res) => {
try {
const activity = await gitea.getRecentActivity();
res.json(activity);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.get('/api/gitea/user', async (req, res) => {
try {
const user = await gitea.getUser();
res.json(user);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.get('/api/gitea/repos/:repo', async (req, res) => {
try {
const repo = await gitea.getRepo(req.params.repo);
res.json(repo);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.get('/api/gitea/repos/:repo/pulls', async (req, res) => {
try {
const state = req.query.state || 'open';
const prs = await gitea.getPullRequests(req.params.repo, state);
res.json(prs);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.get('/api/gitea/repos/:repo/issues', async (req, res) => {
try {
const state = req.query.state || 'open';
const issues = await gitea.getIssues(req.params.repo, state);
res.json(issues);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.get('/api/gitea/repos/:repo/commits', async (req, res) => {
try {
const branch = req.query.branch || 'main';
const limit = parseInt(req.query.limit) || 10;
const commits = await gitea.getCommits(req.params.repo, branch, limit);
res.json(commits);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.get('/api/gitea/repos/:repo/branches', async (req, res) => {
try {
const branches = await gitea.getBranches(req.params.repo);
res.json(branches);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.post('/api/gitea/cache/clear', (req, res) => {
gitea.clearCache();
res.json({ success: true, message: 'Cache cleared' });
});
console.log('✅ Gitea integration loaded');

View File

@@ -1,254 +0,0 @@
/**
* Gitea Integration for AgentDash
* Provides real-time data from Gitea API
*/
const https = require('https');
const http = require('http');
class GiteaIntegration {
constructor(config = {}) {
this.baseUrl = config.baseUrl || 'https://gitea.tophermayor.com';
this.token = config.token || process.env.GITEA_TOKEN;
this.owner = config.owner || 'TopherMayor';
this.cache = new Map();
this.cacheTimeout = config.cacheTimeout || 30000; // 30 seconds
}
/**
* Make authenticated request to Gitea API
*/
async request(endpoint) {
return new Promise((resolve, reject) => {
const url = new URL(endpoint, this.baseUrl);
const client = url.protocol === 'https:' ? https : http;
const options = {
hostname: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: url.pathname + url.search,
method: 'GET',
headers: {
'Authorization': `token ${this.token}`,
'Accept': 'application/json'
}
};
const req = client.request(options, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
resolve(JSON.parse(data));
} catch (e) {
reject(new Error(`Failed to parse response: ${e.message}`));
}
});
});
req.on('error', reject);
req.setTimeout(10000, () => {
req.destroy();
reject(new Error('Request timeout'));
});
req.end();
});
}
/**
* Get cached data or fetch fresh
*/
async getCached(key, fetcher) {
const cached = this.cache.get(key);
if (cached && Date.now() - cached.timestamp < this.cacheTimeout) {
return cached.data;
}
const data = await fetcher();
this.cache.set(key, { data, timestamp: Date.now() });
return data;
}
/**
* Get all repositories for the owner
*/
async getRepos() {
return this.getCached('repos', () =>
this.request(`/api/v1/user/repos?limit=50`)
);
}
/**
* Get repository details
*/
async getRepo(repoName) {
return this.getCached(`repo:${repoName}`, () =>
this.request(`/api/v1/repos/${this.owner}/${repoName}`)
);
}
/**
* Get open pull requests for a repository
*/
async getPullRequests(repoName, state = 'open') {
return this.getCached(`prs:${repoName}:${state}`, () =>
this.request(`/api/v1/repos/${this.owner}/${repoName}/pulls?state=${state}&limit=20`)
);
}
/**
* Get issues for a repository
*/
async getIssues(repoName, state = 'open') {
return this.getCached(`issues:${repoName}:${state}`, () =>
this.request(`/api/v1/repos/${this.owner}/${repoName}/issues?state=${state}&limit=20`)
);
}
/**
* Get recent commits for a repository
*/
async getCommits(repoName, branch = 'main', limit = 10) {
return this.getCached(`commits:${repoName}:${branch}`, () =>
this.request(`/api/v1/repos/${this.owner}/${repoName}/commits?sha=${branch}&limit=${limit}`)
);
}
/**
* Get branches for a repository
*/
async getBranches(repoName) {
return this.getCached(`branches:${repoName}`, () =>
this.request(`/api/v1/repos/${this.owner}/${repoName}/branches`)
);
}
/**
* Get repository activity feed
*/
async getActivity(repoName) {
return this.getCached(`activity:${repoName}`, () =>
this.request(`/api/v1/repos/${this.owner}/${repoName}/activities/feeds?limit=20`)
);
}
/**
* Get user info
*/
async getUser() {
return this.getCached('user', () =>
this.request('/api/v1/user')
);
}
/**
* Get organization info (if applicable)
*/
async getOrgs() {
return this.getCached('orgs', () =>
this.request('/api/v1/user/orgs')
);
}
/**
* Clear cache
*/
clearCache() {
this.cache.clear();
}
/**
* Get swarm summary - all repos with PRs and issues
*/
async getSwarmSummary() {
const repos = await this.getRepos();
const summary = [];
for (const repo of repos.slice(0, 10)) { // Limit to 10 repos
try {
const [prs, issues, branches] = await Promise.all([
this.getPullRequests(repo.name, 'open').catch(() => []),
this.getIssues(repo.name, 'open').catch(() => []),
this.getBranches(repo.name).catch(() => [])
]);
summary.push({
name: repo.name,
full_name: repo.full_name,
stars: repo.stars_count || 0,
forks: repo.forks_count || 0,
open_prs: prs.length,
open_issues: issues.length,
branches: branches.length,
updated_at: repo.updated_at,
html_url: repo.html_url
});
} catch (e) {
console.error(`Error fetching data for ${repo.name}:`, e.message);
}
}
return summary;
}
/**
* Get pending reviews (PRs needing attention)
*/
async getPendingReviews() {
const repos = await this.getRepos();
const pending = [];
for (const repo of repos.slice(0, 10)) {
try {
const prs = await this.getPullRequests(repo.name, 'open');
for (const pr of prs) {
pending.push({
repo: repo.name,
repo_url: repo.html_url,
pr_number: pr.number,
pr_title: pr.title,
pr_url: pr.html_url,
author: pr.user?.login,
created_at: pr.created_at,
mergeable: pr.mergeable,
draft: pr.draft,
labels: pr.labels || []
});
}
} catch (e) {
console.error(`Error fetching PRs for ${repo.name}:`, e.message);
}
}
return pending;
}
/**
* Get recent activity across all repos
*/
async getRecentActivity() {
const repos = await this.getRepos();
const activities = [];
for (const repo of repos.slice(0, 5)) {
try {
const activity = await this.getActivity(repo.name);
for (const act of (activity || []).slice(0, 5)) {
activities.push({
repo: repo.name,
repo_url: repo.html_url,
...act
});
}
} catch (e) {
// Skip repos without activity access
}
}
return activities
.sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
.slice(0, 20);
}
}
module.exports = GiteaIntegration;

View File

@@ -1,231 +0,0 @@
/**
* Gitea Routes Module
* Adds Gitea integration routes to Express app
*/
const GiteaIntegration = require('./gitea-integration.js');
function setupGiteaRoutes(app) {
// Initialize Gitea client
const giteaConfig = {
baseUrl: process.env.GITEA_URL || 'https://gitea.tophermayor.com',
token: process.env.GITEA_TOKEN,
owner: 'TopherMayor',
cacheTimeout: 30000
};
const gitea = new GiteaIntegration(giteaConfig);
// Gitea API routes
app.get('/api/gitea/swarm', async (req, res) => {
try {
const summary = await gitea.getSwarmSummary();
res.json(summary);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.get('/api/gitea/reviews', async (req, res) => {
try {
const reviews = await gitea.getPendingReviews();
res.json(reviews);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.get('/api/gitea/activity', async (req, res) => {
try {
const activity = await gitea.getRecentActivity();
res.json(activity);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.get('/api/gitea/user', async (req, res) => {
try {
const user = await gitea.getUser();
res.json(user);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.get('/api/gitea/repos/:repo', async (req, res) => {
try {
const repo = await gitea.getRepo(req.params.repo);
res.json(repo);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.get('/api/gitea/repos/:repo/pulls', async (req, res) => {
try {
const state = req.query.state || 'open';
const prs = await gitea.getPullRequests(req.params.repo, state);
res.json(prs);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.get('/api/gitea/repos/:repo/issues', async (req, res) => {
try {
const state = req.query.state || 'open';
const issues = await gitea.getIssues(req.params.repo, state);
res.json(issues);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.get('/api/gitea/repos/:repo/commits', async (req, res) => {
try {
const branch = req.query.branch || 'main';
const limit = parseInt(req.query.limit) || 10;
const commits = await gitea.getCommits(req.params.repo, branch, limit);
res.json(commits);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.get('/api/gitea/repos/:repo/branches', async (req, res) => {
try {
const branches = await gitea.getBranches(req.params.repo);
res.json(branches);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.post('/api/gitea/cache/clear', (req, res) => {
gitea.clearCache();
res.json({ success: true, message: 'Cache cleared' });
});
// Repository health check
app.get('/api/gitea/health', async (req, res) => {
try {
const repos = await gitea.getRepos();
const health = [];
for (const repo of repos) {
const updated = new Date(repo.updated_at);
const daysSinceUpdate = Math.floor((Date.now() - updated.getTime()) / (1000 * 60 * 60 * 24));
let status = 'healthy';
if (daysSinceUpdate > 30) status = 'stale';
else if (daysSinceUpdate > 7) status = 'inactive';
let openPRs = 0;
try {
const prs = await gitea.getPullRequests(repo.name, 'open');
openPRs = prs.length;
} catch {}
health.push({
name: repo.name,
status,
daysSinceUpdate,
updated_at: repo.updated_at,
openPRs,
html_url: repo.html_url
});
}
res.json({
repos: health,
summary: {
total: health.length,
healthy: health.filter(r => r.status === 'healthy').length,
inactive: health.filter(r => r.status === 'inactive').length,
stale: health.filter(r => r.status === 'stale').length
}
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// GitOps deployment status
app.get('/api/gitops/deployments', async (req, res) => {
try {
const repos = await gitea.getRepos();
const deployments = [];
for (const repo of repos) {
try {
// Get latest release if exists
const releases = await gitea.request(`/api/v1/repos/${giteaConfig.owner}/${repo.name}/releases?limit=1`);
const latestRelease = releases[0];
deployments.push({
repo: repo.name,
full_name: repo.full_name,
updated_at: repo.updated_at,
latest_release: latestRelease ? {
tag: latestRelease.tag_name,
created: latestRelease.created_at,
url: latestRelease.html_url
} : null,
html_url: repo.html_url
});
} catch {
deployments.push({
repo: repo.name,
full_name: repo.full_name,
updated_at: repo.updated_at,
latest_release: null,
html_url: repo.html_url
});
}
}
res.json({
deployments,
lastUpdated: new Date().toISOString()
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Get deployment status for a specific repo
app.get('/api/gitops/deployments/:repo', async (req, res) => {
const { repo } = req.params;
try {
const releases = await gitea.request(`/api/v1/repos/${giteaConfig.owner}/${repo}/releases?limit=5`);
const commits = await gitea.request(`/api/v1/repos/${giteaConfig.owner}/${repo}/commits?limit=5`);
res.json({
repo,
releases: releases.map(r => ({
tag: r.tag_name,
name: r.name,
created: r.created_at,
url: r.html_url
})),
commits: commits.map(c => ({
sha: c.sha?.substring(0, 7),
message: c.commit?.message?.split('\n')[0],
author: c.commit?.author?.name,
date: c.commit?.author?.date
}))
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
console.log('✅ Gitea integration loaded');
}
module.exports = { setupGiteaRoutes };

View File

@@ -1,413 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import {
ARCHITECTURE_DOCUMENT,
FLEET_CONFIG,
OPENCLAW_AGENTS_DIR,
OPENCLAW_CONFIG_PATH,
OPENCLAW_RUNTIME_ROOT,
ZEROCLAW_CONTROL_DIR,
ZEROCLAW_PRIMARY_DIR,
} from "@/lib/fleet-config";
import { all } from "@/lib/db";
import { normalizeTask } from "@/lib/tasks";
import type { AgentStatus, FleetAgent, TaskEvent, TaskRecord } from "@/lib/types";
type OpenClawAgentConfig = {
id: string;
name?: string;
model?: { primary?: string };
identity?: { name?: string; emoji?: string; theme?: string };
subagents?: { allowAgents?: string[] };
};
type OpenClawConfigShape = {
agents?: { list?: OpenClawAgentConfig[] };
channels?: {
telegram?: {
groups?: Record<
string,
{
topics?: Record<string, { systemPrompt?: string }>;
}
>;
};
};
};
function readTextFile(filePath: string) {
if (!fs.existsSync(filePath)) {
return "";
}
return fs.readFileSync(filePath, "utf8");
}
function parseBulletValues(content: string) {
return content
.split("\n")
.map((line) => line.trim())
.filter((line) => line.startsWith("- "))
.map((line) => line.replace(/^- /, "").replace(/`/g, "").trim());
}
function parseRoleFromAgentsMd(content: string) {
const identityMatch = content.match(/- Scope:\s*(.+)/);
if (identityMatch) {
return identityMatch[1].trim();
}
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) {
const sectionMatch = content.match(/## Responsibilities([\s\S]*?)(##|$)/);
return sectionMatch ? parseBulletValues(sectionMatch[1]) : [];
}
function readWorkspaceAgent(agentRoot: string, fallbackName: string) {
const workspaceRoot = path.join(agentRoot, "workspace");
const agentsMd = readTextFile(path.join(workspaceRoot, "AGENTS.md"));
const toolsMd = readTextFile(path.join(workspaceRoot, "TOOLS.md"));
const identityMd = readTextFile(path.join(workspaceRoot, "IDENTITY.md"));
const heartbeatPath = path.join(workspaceRoot, "HEARTBEAT.md");
const heartbeatMd = readTextFile(heartbeatPath);
const tools = parseBulletValues(toolsMd);
const capabilities = parseResponsibilities(agentsMd);
const currentTaskMatch = heartbeatMd.match(/Current Task:\s*(.+)/i);
const heartbeatAt = deriveHeartbeatTimestamp(heartbeatPath, heartbeatMd);
return {
files: ["AGENTS.md", "TOOLS.md", "IDENTITY.md", "HEARTBEAT.md"].filter((fileName) =>
fs.existsSync(path.join(workspaceRoot, fileName)),
),
tools,
capabilities,
currentTask: currentTaskMatch ? currentTaskMatch[1].trim() : null,
heartbeatAt,
role: parseRoleFromAgentsMd(agentsMd),
noteValues: parseBulletValues(identityMd),
workspaceRoot,
};
}
function readOpenClawConfig(): OpenClawConfigShape {
try {
return JSON.parse(fs.readFileSync(OPENCLAW_CONFIG_PATH, "utf8")) as OpenClawConfigShape;
} catch {
return {};
}
}
function getOpenClawChannels(agentId: string, config: OpenClawConfigShape) {
const summaries: { label: string; value: string }[] = [
{ label: "Family", value: "OpenClaw telegram + gateway" },
];
const topicGroups = config.channels?.telegram?.groups?.["-1003809447066"]?.topics;
if (!topicGroups) {
return summaries;
}
const topicEntries = Object.entries(topicGroups).filter(([, topic]) => {
const prompt = topic.systemPrompt || "";
return prompt.toLowerCase().includes(agentId.toLowerCase()) || agentId === "main";
});
if (topicEntries.length === 0 && agentId === "main") {
summaries.push({ label: "Forum", value: "Homelab HQ default route" });
return summaries;
}
topicEntries.forEach(([topicId]) => {
summaries.push({ label: "Topic", value: `Homelab HQ topic ${topicId}` });
});
return summaries;
}
async function fetchTaskBuckets(aliases: string[]) {
const placeholders = aliases.map(() => "?").join(", ");
const activeRows = await all<Omit<TaskRecord, "tags"> & { tags: string }>(
`SELECT * FROM tasks WHERE assignee IN (${placeholders}) AND status IN ('Todo', 'In Progress', 'Review')
ORDER BY priority DESC, created_at ASC`,
aliases,
);
const completedRows = await all<Omit<TaskRecord, "tags"> & { tags: string }>(
`SELECT * FROM tasks WHERE assignee IN (${placeholders}) AND status = 'Done'
ORDER BY completed_at DESC LIMIT 5`,
aliases,
);
return {
activeTasks: activeRows.map(normalizeTask),
completedTasks: completedRows.map(normalizeTask),
};
}
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() {
const config = readOpenClawConfig();
const agents = config.agents?.list || [];
const concreteAgents = await Promise.all(
agents.map(async (agentConfig) => {
const agentRoot = path.join(OPENCLAW_AGENTS_DIR, agentConfig.id);
const workspace = readWorkspaceAgent(agentRoot, agentConfig.identity?.name || agentConfig.id);
const aliases = [
agentConfig.id,
agentConfig.identity?.name || agentConfig.name || agentConfig.id,
];
const taskBuckets = await fetchTaskBuckets(aliases);
const eventSummary = await fetchAgentEventSummary(aliases);
const heartbeatAgeMinutes = deriveHeartbeatAgeMinutes(workspace.heartbeatAt);
return {
slug: agentConfig.id,
assignmentKey: agentConfig.id,
aliases,
family: "openclaw",
name: agentConfig.identity?.name || agentConfig.name || agentConfig.id,
host: "ubuntu",
role: agentConfig.identity?.theme || workspace.role,
runtimePath: workspace.workspaceRoot || OPENCLAW_AGENTS_DIR,
configPath: OPENCLAW_CONFIG_PATH,
defaultDispatchMethod: "openclaw-swarm",
model: agentConfig.model?.primary || null,
emoji: agentConfig.identity?.emoji || "🦞",
channels: getOpenClawChannels(agentConfig.id, config),
tools: workspace.tools,
capabilities: workspace.capabilities,
files: workspace.files,
status: deriveStatus(taskBuckets.activeTasks.length, workspace.heartbeatAt),
workload: taskBuckets.activeTasks.length,
activeTasks: taskBuckets.activeTasks,
completedTasks: taskBuckets.completedTasks,
currentTask: workspace.currentTask,
heartbeatAt: workspace.heartbeatAt,
heartbeatAgeMinutes,
lastEvent: eventSummary.lastEvent,
failureStreak: eventSummary.failureStreak,
notes: workspace.noteValues,
} satisfies FleetAgent;
}),
);
const swarmAliases = ["openclaw", "openclaw-swarm", "codex", "opencode", "gemini"];
const swarmTaskBuckets = await fetchTaskBuckets(swarmAliases);
const swarmEventSummary = await fetchAgentEventSummary(swarmAliases);
const runtimeWorkspace = path.join(OPENCLAW_RUNTIME_ROOT, "workspace");
const heartbeatAt = fs.existsSync(runtimeWorkspace)
? fs.statSync(runtimeWorkspace).mtime.toISOString()
: swarmEventSummary.lastEvent?.created_at || null;
const swarmAgent = {
slug: "openclaw-swarm",
assignmentKey: "openclaw",
aliases: swarmAliases,
family: "openclaw" as const,
name: "OpenClaw Swarm",
host: "ubuntu",
role: "Swarm execution queue and runner aliases for ubuntu-local OpenClaw work.",
runtimePath: OPENCLAW_RUNTIME_ROOT,
configPath: OPENCLAW_CONFIG_PATH,
defaultDispatchMethod: "openclaw-swarm" as const,
model: null,
emoji: "O",
channels: [
{ label: "Family", value: "OpenClaw swarm queue" },
{ label: "Queue", value: "OpenClaw swarm registry" },
],
tools: ["git worktree", "tmux", "taskboard callbacks", "swarm registry"],
capabilities: [
"Queue and launch swarm tasks backed by git worktrees.",
"Map runner aliases like codex, opencode, and gemini into the shared swarm executor.",
"Report acknowledgement, completion, and failure back to the taskboard.",
],
files: [],
status: deriveStatus(swarmTaskBuckets.activeTasks.length, heartbeatAt),
workload: swarmTaskBuckets.activeTasks.length,
activeTasks: swarmTaskBuckets.activeTasks,
completedTasks: swarmTaskBuckets.completedTasks,
currentTask: swarmTaskBuckets.activeTasks[0]?.title || null,
heartbeatAt,
heartbeatAgeMinutes: deriveHeartbeatAgeMinutes(heartbeatAt),
lastEvent: swarmEventSummary.lastEvent,
failureStreak: swarmEventSummary.failureStreak,
notes: [
"Synthetic fleet agent representing the OpenClaw swarm dispatcher.",
"Covers runner aliases used by Telegram and task templates.",
],
} satisfies FleetAgent;
return [...concreteAgents, swarmAgent];
}
async function buildZeroClawAgents() {
const configuredAgents = FLEET_CONFIG.zeroclawAgents.map((agent) => ({
...agent,
runtimePath:
agent.slug === "grizzley-zeroclaw"
? ZEROCLAW_PRIMARY_DIR
: agent.slug === "ice-zeroclaw"
? ZEROCLAW_CONTROL_DIR
: agent.runtimePath,
}));
return Promise.all(
configuredAgents.map(async (configuredAgent) => {
const workspace = readWorkspaceAgent(configuredAgent.runtimePath, configuredAgent.name);
const taskBuckets = await fetchTaskBuckets(configuredAgent.aliases);
const eventSummary = await fetchAgentEventSummary(configuredAgent.aliases);
const heartbeatAgeMinutes = deriveHeartbeatAgeMinutes(workspace.heartbeatAt);
return {
...configuredAgent,
family: "zeroclaw" as const,
defaultDispatchMethod: configuredAgent.dispatch.method,
tools: workspace.tools,
capabilities: workspace.capabilities,
files: workspace.files,
status: deriveStatus(taskBuckets.activeTasks.length, workspace.heartbeatAt),
workload: taskBuckets.activeTasks.length,
activeTasks: taskBuckets.activeTasks,
completedTasks: taskBuckets.completedTasks,
currentTask: workspace.currentTask,
heartbeatAt: workspace.heartbeatAt,
heartbeatAgeMinutes,
lastEvent: eventSummary.lastEvent,
failureStreak: eventSummary.failureStreak,
notes: [...configuredAgent.notes, ...workspace.noteValues],
};
}),
);
}
async function buildDirectAgents() {
return Promise.all(
FLEET_CONFIG.directAgents.map(async (configuredAgent) => {
const taskBuckets = await fetchTaskBuckets(configuredAgent.aliases);
const eventSummary = await fetchAgentEventSummary(configuredAgent.aliases);
const heartbeatAt = eventSummary.lastEvent?.created_at || null;
const heartbeatAgeMinutes = deriveHeartbeatAgeMinutes(heartbeatAt);
return {
slug: configuredAgent.slug,
assignmentKey: configuredAgent.assignmentKey,
aliases: configuredAgent.aliases,
family: "direct" as const,
name: configuredAgent.name,
host: configuredAgent.host,
role: configuredAgent.role,
runtimePath: configuredAgent.runtimePath,
configPath: configuredAgent.configPath,
defaultDispatchMethod: configuredAgent.dispatch.method,
model: null,
emoji: configuredAgent.emoji,
channels: configuredAgent.channels,
tools: configuredAgent.tools,
capabilities: configuredAgent.capabilities,
files: configuredAgent.files,
status: deriveStatus(taskBuckets.activeTasks.length, heartbeatAt),
workload: taskBuckets.activeTasks.length,
activeTasks: taskBuckets.activeTasks,
completedTasks: taskBuckets.completedTasks,
currentTask: taskBuckets.activeTasks[0]?.title || null,
heartbeatAt,
heartbeatAgeMinutes,
lastEvent: eventSummary.lastEvent,
failureStreak: eventSummary.failureStreak,
notes: configuredAgent.notes,
} satisfies FleetAgent;
}),
);
}
export async function listFleetAgents() {
const [openclawAgents, zeroclawAgents, directAgents] = await Promise.all([
buildOpenClawAgents(),
buildZeroClawAgents(),
buildDirectAgents(),
]);
return [...openclawAgents, ...zeroclawAgents, ...directAgents];
}
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 findAgentBySlug(slug: string) {
const agents = await listFleetAgents();
return agents.find((agent) => agent.slug === slug) || null;
}
export async function listArchitecture() {
const agents = await listFleetAgents();
return {
...ARCHITECTURE_DOCUMENT,
generatedAt: new Date().toISOString(),
sections: ARCHITECTURE_DOCUMENT.sections.map((section) => ({
...section,
configuredAgents: agents
.filter((agent) => agent.family === section.id)
.map((agent) => `${agent.name} (${agent.host})`),
})),
};
}

177
lib/db.ts
View File

@@ -1,177 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import sqlite3 from "sqlite3";
const DB_PATH = process.env.DB_PATH || path.join(process.cwd(), "data", "tasks.db");
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
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() {
if (database) {
return database;
}
database = new sqlite3.Database(DB_PATH);
database.serialize(() => {
database?.run(`
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT DEFAULT '',
assignee TEXT DEFAULT '',
priority TEXT NOT NULL DEFAULT 'Medium',
status TEXT NOT NULL DEFAULT 'Backlog',
tags TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
completed_at TEXT
)
`);
database?.run(`
CREATE TABLE IF NOT EXISTS usage_tracking (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent TEXT NOT NULL,
provider TEXT NOT NULL,
model TEXT NOT NULL,
request_type TEXT DEFAULT 'chat',
tokens_used INTEGER DEFAULT 0,
cost_estimate REAL DEFAULT 0,
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", "result_summary", "TEXT");
await ensureColumn(database, "tasks", "result_detail", "TEXT");
await ensureColumn(database, "tasks", "completed_by", "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;
}
async function ensureReady() {
getDatabase();
if (databaseReady) {
await databaseReady;
}
}
export async function all<T>(sql: string, params: unknown[] = []) {
await ensureReady();
const db = getDatabase();
return new Promise<T[]>((resolve, reject) => {
db.all(sql, params, (error, rows) => {
if (error) {
reject(error);
return;
}
resolve(rows as T[]);
});
});
}
export async function get<T>(sql: string, params: unknown[] = []) {
await ensureReady();
const db = getDatabase();
return new Promise<T | undefined>((resolve, reject) => {
db.get(sql, params, (error, row) => {
if (error) {
reject(error);
return;
}
resolve(row as T | undefined);
});
});
}
export async function run(sql: string, params: unknown[] = []) {
await ensureReady();
const db = getDatabase();
return new Promise<{ lastID: number; changes: number }>((resolve, reject) => {
db.run(sql, params, function onRun(error) {
if (error) {
reject(error);
return;
}
resolve({ lastID: this.lastID, changes: this.changes });
});
});
}

View File

@@ -1,724 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import { promisify } from "node:util";
import { execFile } from "node:child_process";
import {
DIRECT_SSH_KEY_PATH,
DIRECT_SSH_TIMEOUT_MS,
FLEET_CONFIG,
REPO_ACCESS_ROOTS,
SWARM_HOST_WORKTREES_DIR,
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, applyTaskCallback, findTask, updateTask } from "@/lib/tasks";
import type { DispatchState, TaskCallbackPayload } from "@/lib/types";
const execFileAsync = promisify(execFile);
type DispatchResult = {
state: DispatchState;
summary: string;
detail: string;
callback?: TaskCallbackPayload;
};
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));
}
}
function extractTagValue(tags: string[], prefix: string) {
const match = tags.find((tag) => tag.startsWith(prefix));
return match ? match.slice(prefix.length) : null;
}
function truncateOutput(output: string, maxLength = 4000) {
const trimmed = output
.split("\n")
.filter((line) => !line.startsWith("Warning: Permanently added "))
.join("\n")
.trim();
if (trimmed.length <= maxLength) {
return trimmed;
}
return `${trimmed.slice(0, maxLength - 15)}\n...[truncated]`;
}
type TrueNasDataset = {
name: string;
used: string;
avail: string;
mountpoint: string;
};
type DatasetSignal = {
source: string;
dataset: string;
signalType: "active" | "legacy";
matchedText: string;
};
const TRUENAS_SIGNAL_PATTERNS: Array<{
dataset: string;
patterns: string[];
signalType: "active" | "legacy";
}> = [
{
dataset: "TrueNAS/NetworkMediaShare",
patterns: [
"/mnt/truenas/mediadata",
"/mnt/TrueNAS/NetworkMediaShare",
"/mnt/TrueNAS/NetworkMediaShare/mediadata",
],
signalType: "active",
},
{
dataset: "RPiPool/PersonalMediaLibrary",
patterns: [
"/mnt/PersonalMediaLibrary",
"/mnt/RPiPool/PersonalMediaLibrary",
],
signalType: "active",
},
{
dataset: "TrueNAS/backups",
patterns: [
"/mnt/truenas-backup",
"/mnt/TrueNAS/backups",
],
signalType: "active",
},
{
dataset: "TrueNAS/container-config",
patterns: ["/mnt/TrueNAS/container-config"],
signalType: "active",
},
{
dataset: "TrueNAS/databases",
patterns: ["/mnt/TrueNAS/databases"],
signalType: "active",
},
{
dataset: "TrueNAS/homelab/databases",
patterns: ["/mnt/TrueNAS/homelab/databases"],
signalType: "active",
},
{
dataset: "TrueNAS/homelab/hosts/ubuntu/docker-data",
patterns: ["/mnt/TrueNAS/homelab/hosts/ubuntu/docker-data"],
signalType: "active",
},
{
dataset: "TrueNAS/homelab/hosts/grizzley/docker-data",
patterns: ["/mnt/TrueNAS/homelab/hosts/grizzley/docker-data"],
signalType: "active",
},
{
dataset: "TrueNAS/homelab/hosts/ice/docker-data",
patterns: ["/mnt/TrueNAS/homelab/hosts/ice/docker-data"],
signalType: "active",
},
{
dataset: "TrueNAS/RPiPool-backup",
patterns: ["/mnt/TrueNAS/RPiPool-backup"],
signalType: "legacy",
},
{
dataset: "TrueNAS/PersonalMediaLibraryBackup",
patterns: ["/mnt/TrueNAS/PersonalMediaLibraryBackup"],
signalType: "legacy",
},
{
dataset: "TrueNAS/TimeMachine",
patterns: ["/mnt/TrueNAS/TimeMachine", "TimeMachine"],
signalType: "legacy",
},
{
dataset: "TrueNAS/UserShares",
patterns: ["/mnt/TrueNAS/UserShares", "UserShares"],
signalType: "legacy",
},
{
dataset: "TrueNAS/UserShares/RedVelvet",
patterns: ["/mnt/TrueNAS/UserShares/RedVelvet", "TrueNAS/UserShares/RedVelvet"],
signalType: "legacy",
},
{
dataset: "TrueNAS/UserShares/Vanilla",
patterns: ["/mnt/TrueNAS/UserShares/Vanilla", "TrueNAS/UserShares/Vanilla"],
signalType: "legacy",
},
{
dataset: "TrueNAS/traefik-certs",
patterns: ["/mnt/truenas/traefik-certs", "/mnt/TrueNAS/traefik-certs"],
signalType: "active",
},
];
const ACTIVE_TRUENAS_SCAN_PATHS = [
"homelab/ubuntu",
"homelab/grizzley",
"homelab/inventory/ubuntu.json",
"homelab/inventory/grizzley.json",
"homelab/truenas/AGENTS.md",
"homelab/AGENTS.md",
"homelab/catalog",
"ansible/playbooks",
];
const LEGACY_TRUENAS_SCAN_PATHS = [
"homelab/inventory/truenas.json",
"homelab/proxmox/truenas",
"obsidian-vault/homelab",
];
function sshConnectionArgs(host: string, user: string, port: number) {
return [
"-F",
"/dev/null",
"-o",
"BatchMode=yes",
"-o",
"StrictHostKeyChecking=accept-new",
"-o",
"ConnectTimeout=15",
"-o",
"IdentitiesOnly=yes",
"-o",
"UserKnownHostsFile=/tmp/taskboard_known_hosts",
"-i",
DIRECT_SSH_KEY_PATH,
"-p",
String(port),
`${user}@${host}`,
];
}
function existingHomelabRoots() {
const candidates = [
process.env.HOMELAB_REPO_ROOT,
"/home/bear/homelabagentroot",
"/home/christopher/opencode-home",
].filter((entry): entry is string => Boolean(entry));
return [...new Set(candidates)].filter((candidate) => fs.existsSync(candidate));
}
function collectTextFiles(targetPath: string, collected: string[]) {
if (!fs.existsSync(targetPath)) {
return;
}
const stat = fs.statSync(targetPath);
if (stat.isFile()) {
if (stat.size <= 1024 * 1024) {
collected.push(targetPath);
}
return;
}
for (const entry of fs.readdirSync(targetPath, { withFileTypes: true })) {
if (entry.name.startsWith(".git") || entry.name === "node_modules" || entry.name === "code-server-ai") {
continue;
}
collectTextFiles(path.join(targetPath, entry.name), collected);
}
}
function scanTrueNasSignals(repoRoot: string, relativePaths: string[], signalType: "active" | "legacy") {
const files: string[] = [];
for (const relativePath of relativePaths) {
collectTextFiles(path.join(repoRoot, relativePath), files);
}
const signals: DatasetSignal[] = [];
for (const filePath of files) {
let content = "";
try {
content = fs.readFileSync(filePath, "utf8");
} catch {
continue;
}
for (const mapping of TRUENAS_SIGNAL_PATTERNS.filter((entry) => entry.signalType === signalType)) {
const matchedPattern = mapping.patterns.find((pattern) => content.includes(pattern));
if (matchedPattern) {
signals.push({
source: filePath,
dataset: mapping.dataset,
signalType,
matchedText: matchedPattern,
});
}
}
}
return signals;
}
function datasetHierarchyName(datasetName: string) {
return datasetName.replace(/\/+$/, "");
}
function summarizeSignals(signals: DatasetSignal[], dataset: string) {
return signals.filter((signal) => signal.dataset === dataset);
}
async function runTrueNasDatasetAudit(taskId: number, host: string, user: string, port: number): Promise<DispatchResult> {
const task = await findTask(taskId);
if (!task) {
throw new Error("task_not_found");
}
const sshArgs = sshConnectionArgs(host, user, port);
const { stdout } = await execFileAsync(
"ssh",
[...sshArgs, "zfs list -H -o name,used,avail,mountpoint"],
{
timeout: DIRECT_SSH_TIMEOUT_MS,
maxBuffer: 1024 * 1024,
},
);
const datasets = stdout
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [name, used, avail, mountpoint] = line.split("\t");
return { name, used, avail, mountpoint } satisfies TrueNasDataset;
});
const repoRoots = existingHomelabRoots();
const activeSignals = repoRoots.flatMap((repoRoot) =>
scanTrueNasSignals(repoRoot, ACTIVE_TRUENAS_SCAN_PATHS, "active"),
);
const legacySignals = repoRoots.flatMap((repoRoot) =>
scanTrueNasSignals(repoRoot, LEGACY_TRUENAS_SCAN_PATHS, "legacy"),
);
const childActiveMap = new Map<string, number>();
for (const dataset of datasets) {
const parentNames = dataset.name.split("/").map((_, index, parts) => parts.slice(0, index + 1).join("/"));
for (const parentName of parentNames.slice(0, -1)) {
childActiveMap.set(parentName, (childActiveMap.get(parentName) || 0) + 1);
}
}
const activeDatasets = datasets
.map((dataset) => ({
dataset,
activeRefs: summarizeSignals(activeSignals, dataset.name),
legacyRefs: summarizeSignals(legacySignals, dataset.name),
}))
.filter(({ activeRefs }) => activeRefs.length > 0);
const activeDatasetNames = new Set(activeDatasets.map(({ dataset }) => dataset.name));
const reviewCandidates = datasets
.map((dataset) => ({
dataset,
activeRefs: summarizeSignals(activeSignals, dataset.name),
legacyRefs: summarizeSignals(legacySignals, dataset.name),
hasActiveChild:
datasets.some(
(candidate) =>
candidate.name !== dataset.name &&
datasetHierarchyName(candidate.name).startsWith(`${datasetHierarchyName(dataset.name)}/`) &&
summarizeSignals(activeSignals, candidate.name).length > 0,
),
hasActiveAncestor: [...activeDatasetNames].some(
(activeDatasetName) =>
dataset.name !== activeDatasetName &&
datasetHierarchyName(dataset.name).startsWith(`${datasetHierarchyName(activeDatasetName)}/`),
),
}))
.filter(({ dataset, activeRefs, hasActiveChild, hasActiveAncestor }) => {
if (
dataset.name === "TrueNAS" ||
dataset.name === "RPiPool" ||
dataset.name.startsWith("boot-pool") ||
dataset.name.includes("/.system")
) {
return false;
}
return activeRefs.length === 0 && !hasActiveChild && !hasActiveAncestor;
});
const detailSections = [
`Task: #${task.id} ${task.title}`,
"",
"Active dependency signals",
...(
activeDatasets.length > 0
? activeDatasets.map(({ dataset, activeRefs }) =>
`- ${dataset.name} (${dataset.used}, mount ${dataset.mountpoint}) <- ${activeRefs
.slice(0, 4)
.map((ref) => path.relative(repoRoots[0] || process.cwd(), ref.source))
.join(", ")}`,
)
: ["- none detected"]
),
"",
"Review candidates with no active dependency signal",
...(
reviewCandidates.length > 0
? reviewCandidates.map(({ dataset, legacyRefs }) =>
`- ${dataset.name} (${dataset.used}, mount ${dataset.mountpoint})${
legacyRefs.length > 0 ? ` [legacy refs: ${legacyRefs.length}]` : ""
}`,
)
: ["- none"]
),
"",
"Legacy-only references",
...(
legacySignals.length > 0
? legacySignals.map((signal) => `- ${signal.dataset} <- ${path.relative(repoRoots[0] || process.cwd(), signal.source)}`)
: ["- none"]
),
"",
"Live datasets",
...datasets.map((dataset) => `- ${dataset.name} | used ${dataset.used} | mount ${dataset.mountpoint}`),
];
return {
state: "completed" as const,
summary: "TrueNAS dataset dependency audit completed",
detail: truncateOutput(detailSections.join("\n"), 12000),
callback: {
status: "Review",
dispatch_state: "completed",
summary: "TrueNAS dataset dependency audit completed",
detail: truncateOutput(detailSections.join("\n"), 12000),
completed_by: "direct-ssh:truenas-audit",
last_error: null,
last_dispatch_at: new Date().toISOString(),
},
};
}
async function dispatchOpenClawTask(taskId: number): Promise<DispatchResult> {
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}`);
}
await execFileAsync("git", ["config", "--global", "--add", "safe.directory", repoPath]);
const agentName = task.preferred_agent || task.assignee || "codex";
const taskKey = `taskboard-${task.id}`;
const repoName = path.basename(repoPath);
const worktree = path.join(SWARM_WORKTREES_DIR, repoName, taskKey);
const hostWorktree = path.join(SWARM_HOST_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: hostWorktree,
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,
},
});
} else {
existing.repoPath = repoPath;
existing.repoSlug = task.repo_slug;
existing.worktree = hostWorktree;
existing.branch = branch;
existing.baseBranch = baseBranch;
existing.agent = agentName;
existing.tmuxSession = `${agentName}-${taskKey}`;
}
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} -> ${hostWorktree}`,
};
}
async function dispatchZeroClawTask(taskId: number): Promise<DispatchResult> {
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`,
};
}
function findDirectAgentDefinition(assignmentKey: string) {
return (
FLEET_CONFIG.directAgents.find(
(agent) => agent.assignmentKey === assignmentKey || agent.aliases.includes(assignmentKey),
) || null
);
}
async function dispatchDirectTask(taskId: number): Promise<DispatchResult> {
const task = await findTask(taskId);
if (!task) {
throw new Error("task_not_found");
}
const directAgent = findDirectAgentDefinition(task.assignee);
if (!directAgent) {
throw new Error("direct_target_not_found");
}
const actionKey = extractTagValue(task.tags, "action:") || directAgent.dispatch.defaultAction;
const action = directAgent.dispatch.actions.find((entry) => entry.key === actionKey);
if (!action) {
throw new Error(`unsupported_direct_action:${actionKey}`);
}
if (action.command === "builtin:truenas-dataset-audit") {
return runTrueNasDatasetAudit(
taskId,
directAgent.dispatch.hostname,
directAgent.dispatch.user,
directAgent.dispatch.port,
);
}
const sshArgs = [
...sshConnectionArgs(
directAgent.dispatch.hostname,
directAgent.dispatch.user,
directAgent.dispatch.port,
),
action.command,
];
try {
const { stdout, stderr } = await execFileAsync("ssh", sshArgs, {
timeout: DIRECT_SSH_TIMEOUT_MS,
maxBuffer: 1024 * 1024,
});
const detail = truncateOutput([stdout, stderr].filter(Boolean).join("\n"));
return {
state: "completed" as const,
summary: `${action.successSummary}`,
detail,
callback: {
status: "Done",
dispatch_state: "completed",
summary: action.successSummary,
detail,
completed_by: `direct-ssh:${directAgent.host}`,
last_error: null,
last_dispatch_at: new Date().toISOString(),
},
};
} catch (error) {
const execError = error as Error & { stdout?: string; stderr?: string };
const detail = truncateOutput([execError.stdout, execError.stderr, execError.message].filter(Boolean).join("\n"));
throw new Error(`direct_ssh_failed:${directAgent.host}:${action.key}:${detail}`);
}
}
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)
: agent.family === "zeroclaw"
? await dispatchZeroClawTask(taskId)
: await dispatchDirectTask(taskId);
if (result.callback) {
const updated = await applyTaskCallback(taskId, result.callback);
if (!updated) {
throw new Error("task_not_found_after_callback");
}
return updated;
}
const updated = await updateTask(taskId, {
status: task.status === "Backlog" ? "Todo" : task.status,
dispatch_state: result.state,
last_dispatch_at: new Date().toISOString(),
last_error: null,
});
if (!updated) {
throw new Error("task_not_found_after_dispatch");
}
await appendTaskEvent({
taskId,
assignee: updated.assignee,
family: updated.family,
host: updated.target_host,
eventType: "dispatch_succeeded",
state: updated.dispatch_state,
summary: result.summary,
detail: result.detail,
});
return updated;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const updated = await updateTask(taskId, {
dispatch_state: "failed",
last_error: message,
});
await appendTaskEvent({
taskId,
assignee: task.assignee,
family: task.family,
host: task.target_host,
eventType: "dispatch_failed",
state: "failed",
summary: "Dispatch failed",
detail: message,
});
if (!updated) {
throw error;
}
return updated;
}
}

View File

@@ -1,53 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import type { FleetConfig, TaskTemplate } from "@/lib/types";
export const OPENCLAW_RUNTIME_ROOT = 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 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 SWARM_HOST_WORKTREES_DIR =
process.env.SWARM_HOST_WORKTREES_DIR || SWARM_WORKTREES_DIR;
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");
export const DIRECT_SSH_TIMEOUT_MS = Number(process.env.DIRECT_SSH_TIMEOUT_MS || "30000");
export const DIRECT_SSH_KEY_PATH = process.env.DIRECT_SSH_KEY_PATH || "/root/.ssh/id_ed25519";
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",
overview: [],
topologyDiagram: "",
sections: [],
zeroclawAgents: [],
directAgents: [],
});
export const TASK_TEMPLATES = readJsonFile<TaskTemplate[]>(TASK_TEMPLATE_PATH, []);
export const ARCHITECTURE_DOCUMENT = {
generatedAt: new Date().toISOString(),
title: FLEET_CONFIG.title,
overview: FLEET_CONFIG.overview,
sections: FLEET_CONFIG.sections,
topologyDiagram: FLEET_CONFIG.topologyDiagram,
};

View File

@@ -1,166 +0,0 @@
import { dispatchTask } from "@/lib/dispatch";
import { findAgentByAssignmentKey } from "@/lib/agents";
import { syncOpenClawTasks } from "@/lib/openclaw-sync";
import { appendTaskEvent, findTask, listTasksForAssignee } from "@/lib/tasks";
import type { FleetAgent, TaskRecord } from "@/lib/types";
type HeartbeatTaskSummary = Pick<
TaskRecord,
"id" | "title" | "status" | "dispatch_state" | "priority" | "assignee" | "target_host"
> & {
blocked: boolean;
blockedBy: number[];
};
type AgentHeartbeatResult = {
agent: {
slug: string;
assignmentKey: string;
family: FleetAgent["family"];
host: string;
heartbeatAt: string | null;
currentTask: string | null;
};
pending_tasks: number;
active_tasks: number;
blocked_tasks: number;
dispatched_task: HeartbeatTaskSummary | null;
tasks: HeartbeatTaskSummary[];
notes: string[];
};
function extractDependencyIds(task: TaskRecord) {
const values = task.tags
.filter((tag) => tag.startsWith("depends-on:") || tag.startsWith("dependency:"))
.flatMap((tag) => tag.split(":").slice(1))
.flatMap((value) => value.split(/[|,]/))
.map((value) => Number(value.trim()))
.filter((value) => Number.isInteger(value) && value > 0);
return [...new Set(values)];
}
function getTaskPriorityWeight(priority: TaskRecord["priority"]) {
switch (priority) {
case "Critical":
return 0;
case "High":
return 1;
case "Medium":
return 2;
default:
return 3;
}
}
function isActiveTask(task: Pick<TaskRecord, "dispatch_state" | "status">) {
return ["dispatched", "acknowledged"].includes(task.dispatch_state) || ["In Progress", "Review"].includes(task.status);
}
function isTaskRunnable(task: Pick<TaskRecord, "status" | "dispatch_state">) {
return ["Backlog", "Todo"].includes(task.status) && ["planned", "assigned", "failed"].includes(task.dispatch_state);
}
function sortTasks(tasks: TaskRecord[]) {
return [...tasks].sort((left, right) => {
const priorityDelta = getTaskPriorityWeight(left.priority) - getTaskPriorityWeight(right.priority);
if (priorityDelta !== 0) {
return priorityDelta;
}
return left.id - right.id;
});
}
export async function processAgentHeartbeat(assignmentKey: string): Promise<AgentHeartbeatResult> {
const agent = await findAgentByAssignmentKey(assignmentKey);
if (!agent) {
throw new Error("agent_not_found");
}
if (agent.family === "openclaw") {
await syncOpenClawTasks();
}
const assignedTasks = sortTasks(await listTasksForAssignee(agent.aliases, { includeDone: false }));
const taskSummaries = await Promise.all(
assignedTasks.map(async (task) => {
const blockedBy = (
await Promise.all(
extractDependencyIds(task).map(async (dependencyId) => {
const dependency = await findTask(dependencyId);
return dependency?.status === "Done" ? null : dependencyId;
}),
)
).filter((dependencyId): dependencyId is number => dependencyId !== null);
return {
id: task.id,
title: task.title,
status: task.status,
dispatch_state: task.dispatch_state,
priority: task.priority,
assignee: task.assignee,
target_host: task.target_host,
blocked: blockedBy.length > 0,
blockedBy,
} satisfies HeartbeatTaskSummary;
}),
);
const activeTasks = taskSummaries.filter((task) => isActiveTask(task) && !task.blocked);
const runnableTask = taskSummaries.find((task) => isTaskRunnable(task) && !task.blocked) || null;
const blockedTasks = taskSummaries.filter((task) => task.blocked);
const notes: string[] = [];
let dispatchedTask: HeartbeatTaskSummary | null = null;
if (blockedTasks.length > 0) {
notes.push(`${blockedTasks.length} blocked task(s) waiting on dependencies`);
}
if (activeTasks.length === 0 && runnableTask) {
const updatedTask = await dispatchTask(runnableTask.id);
dispatchedTask = {
id: updatedTask.id,
title: updatedTask.title,
status: updatedTask.status,
dispatch_state: updatedTask.dispatch_state,
priority: updatedTask.priority,
assignee: updatedTask.assignee,
target_host: updatedTask.target_host,
blocked: false,
blockedBy: [],
};
notes.push(`Auto-dispatched task #${updatedTask.id}`);
await appendTaskEvent({
taskId: updatedTask.id,
assignee: updatedTask.assignee,
family: updatedTask.family,
host: updatedTask.target_host,
eventType: "updated",
state: updatedTask.dispatch_state,
summary: `Heartbeat auto-dispatched ${agent.name}`,
detail: `Triggered from /api/heartbeat/${assignmentKey}`,
});
} else if (activeTasks.length > 0) {
notes.push(`${activeTasks.length} active task(s) already in progress`);
} else if (!runnableTask && assignedTasks.length > 0) {
notes.push("No runnable tasks available for dispatch");
}
return {
agent: {
slug: agent.slug,
assignmentKey: agent.assignmentKey,
family: agent.family,
host: agent.host,
heartbeatAt: agent.heartbeatAt,
currentTask: agent.currentTask,
},
pending_tasks: taskSummaries.filter((task) => !["Done"].includes(task.status)).length,
active_tasks: activeTasks.length,
blocked_tasks: blockedTasks.length,
dispatched_task: dispatchedTask,
tasks: taskSummaries,
notes,
};
}

View File

@@ -1,117 +0,0 @@
import fs from "node:fs";
import { SWARM_TASKS_FILE } from "@/lib/fleet-config";
import { appendTaskEvent, applyTaskCallback, findTask } from "@/lib/tasks";
type SwarmRegistryTask = {
id: string;
taskboardTaskId?: number | null;
status?: string;
tmuxSession?: string;
worktree?: string;
pr?: number | null;
note?: string | null;
failedAt?: number | null;
completedAt?: number | null;
startedAt?: number | null;
agent?: string | null;
};
function readRegistry() {
if (!fs.existsSync(SWARM_TASKS_FILE)) {
return [] as SwarmRegistryTask[];
}
const parsed = JSON.parse(fs.readFileSync(SWARM_TASKS_FILE, "utf8")) as { tasks?: SwarmRegistryTask[] };
return Array.isArray(parsed.tasks) ? parsed.tasks : [];
}
function statusToDispatchState(status: string | undefined) {
switch (status) {
case "running":
return "acknowledged" as const;
case "done":
return "completed" as const;
case "failed":
return "failed" as const;
case "queued":
case "retrying":
return "dispatched" as const;
default:
return null;
}
}
function statusToTaskStatus(status: string | undefined) {
switch (status) {
case "running":
return "In Progress" as const;
case "done":
return "Done" as const;
case "failed":
return "Backlog" as const;
case "queued":
case "retrying":
return "Todo" as const;
default:
return undefined;
}
}
export async function syncOpenClawTasks() {
const registryTasks = readRegistry();
const results: Array<{ taskId: number; registryStatus: string; synced: boolean }> = [];
for (const registryTask of registryTasks) {
if (!registryTask.taskboardTaskId) {
continue;
}
const task = await findTask(registryTask.taskboardTaskId);
if (!task) {
continue;
}
const dispatchState = statusToDispatchState(registryTask.status);
const taskStatus = statusToTaskStatus(registryTask.status);
if (!dispatchState) {
continue;
}
await applyTaskCallback(task.id, {
status: taskStatus,
dispatch_state: dispatchState,
summary:
registryTask.status === "done"
? `OpenClaw task ${registryTask.id} completed`
: registryTask.status === "failed"
? `OpenClaw task ${registryTask.id} failed`
: `OpenClaw task ${registryTask.id} ${registryTask.status}`,
detail:
registryTask.pr
? `PR #${registryTask.pr} from ${registryTask.id}`
: registryTask.note || registryTask.worktree || "",
completed_by: registryTask.agent || "openclaw-swarm",
last_error: registryTask.status === "failed" ? registryTask.note || "Swarm task failed" : null,
});
await appendTaskEvent({
taskId: task.id,
assignee: task.assignee,
family: task.family,
host: task.target_host,
eventType: "updated",
state: dispatchState,
summary: `OpenClaw sync: ${registryTask.status}`,
detail: registryTask.worktree || "",
});
results.push({
taskId: task.id,
registryStatus: registryTask.status || "unknown",
synced: true,
});
}
return results;
}

View File

@@ -1,472 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import { all, get, run } from "@/lib/db";
import { TASK_TEMPLATES } from "@/lib/fleet-config";
import type {
AgentFamily,
DispatchMethod,
DispatchState,
TaskEvent,
TaskEventType,
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_FAMILIES: AgentFamily[] = ["openclaw", "zeroclaw", "direct"];
const VALID_DISPATCH_METHODS: DispatchMethod[] = ["manual", "openclaw-swarm", "zeroclaw-webhook", "direct-ssh"];
const VALID_DISPATCH_STATES: DispatchState[] = [
"planned",
"assigned",
"dispatched",
"acknowledged",
"completed",
"failed",
];
const WIKI_DIR = process.env.WIKI_DIR || path.join(process.cwd(), "wiki");
fs.mkdirSync(WIKI_DIR, { recursive: true });
type DatabaseTaskRow = Omit<TaskRecord, "tags"> & { tags: string };
function parseTags(raw: string) {
try {
const parsed = JSON.parse(raw || "[]");
return Array.isArray(parsed) ? parsed.filter((tag) => typeof tag === "string") : [];
} catch {
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 {
...row,
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,
result_summary: row.result_summary || null,
result_detail: row.result_detail || null,
completed_by: row.completed_by || 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() {
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);
}
export async function findTask(id: number) {
const row = await get<DatabaseTaskRow>("SELECT * FROM tasks WHERE id = ?", [id]);
return row ? normalizeTask(row) : null;
}
export function validateTaskPayload(payload: Partial<TaskRecord> & { tags?: unknown }, partial = false) {
const errors: string[] = [];
if (!partial || payload.title !== undefined) {
if (typeof payload.title !== "string" || payload.title.trim().length === 0) {
errors.push("title is required");
}
}
if (payload.status !== undefined && !VALID_STATUSES.includes(payload.status)) {
errors.push(`status must be one of: ${VALID_STATUSES.join(", ")}`);
}
if (payload.priority !== undefined && !VALID_PRIORITIES.includes(payload.priority)) {
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)) {
errors.push("tags must be an array of strings");
}
return errors;
}
function buildWikiMarkdown(task: TaskRecord) {
const renderedTags = task.tags.length ? task.tags.join(", ") : "None";
return `# ${task.title}
- Task ID: ${task.id}
- Assignee: ${task.assignee || "Unassigned"}
- Priority: ${task.priority}
- Status: ${task.status}
- Dispatch: ${task.dispatch_method} / ${task.dispatch_state}
- Host: ${task.target_host || "n/a"}
- Channel: ${task.target_channel || "n/a"}
- Tags: ${renderedTags}
- Created: ${task.created_at}
- Completed: ${task.completed_at || new Date().toISOString()}
## Description
${task.description || "No description provided."}
`;
}
async function writeWikiForTask(task: TaskRecord) {
const safeTitle = task.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
const fileName = `${new Date().toISOString().slice(0, 10)}-task-${task.id}-${safeTitle || `task-${task.id}`}.md`;
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 listTasksForAssignee(assigneeAliases: string[], options?: {
includeDone?: boolean;
}) {
if (assigneeAliases.length === 0) {
return [] as TaskRecord[];
}
const placeholders = assigneeAliases.map(() => "?").join(", ");
const params: unknown[] = [...assigneeAliases];
const clauses = [`assignee IN (${placeholders})`];
if (!options?.includeDone) {
clauses.push("status != 'Done'");
}
const rows = await all<DatabaseTaskRow>(
`SELECT * FROM tasks
WHERE ${clauses.join(" AND ")}
ORDER BY
CASE status
WHEN 'In Progress' THEN 0
WHEN 'Review' THEN 1
WHEN 'Todo' THEN 2
WHEN 'Backlog' THEN 3
ELSE 4
END,
CASE priority
WHEN 'Critical' THEN 0
WHEN 'High' THEN 1
WHEN 'Medium' THEN 2
ELSE 3
END,
created_at ASC,
id ASC`,
params,
);
return rows.map(normalizeTask);
}
export async function listTaskTemplates(): Promise<TaskTemplate[]> {
return TASK_TEMPLATES;
}
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(
`INSERT INTO tasks (
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,
result_summary, result_detail, completed_by,
last_dispatch_at, acknowledged_at, last_error
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
input.title?.trim() || "",
input.description || "",
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.status || "Backlog",
JSON.stringify(tags),
normalizeNullableString(input.result_summary),
normalizeNullableString(input.result_detail),
normalizeNullableString(input.completed_by),
input.last_dispatch_at || null,
deriveAcknowledgedAt(dispatchState),
normalizeNullableString(input.last_error),
],
);
const task = await findTask(result.lastID);
if (!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;
}
export async function updateTask(id: number, input: Partial<TaskRecord>) {
const existing = await findTask(id);
if (!existing) {
return null;
}
const hasField = <K extends keyof TaskRecord>(field: K) =>
Object.prototype.hasOwnProperty.call(input, field);
const mergedTags = Array.isArray(input.tags) ? input.tags.filter((tag) => typeof tag === "string") : existing.tags;
const nextStatus = input.status ?? existing.status;
const nextDispatchState = deriveDispatchState({ ...existing, ...input, tags: mergedTags }, existing);
const completedAt = nextStatus === "Done" ? existing.completed_at || new Date().toISOString() : null;
const acknowledgedAt = deriveAcknowledgedAt(nextDispatchState, existing, input.acknowledged_at);
await run(
`UPDATE tasks
SET 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 = ?,
result_summary = ?, result_detail = ?, completed_by = ?,
last_dispatch_at = ?, acknowledged_at = ?, last_error = ?, completed_at = ?, updated_at = datetime('now')
WHERE id = ?`,
[
input.title?.trim() || existing.title,
input.description ?? existing.description,
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,
nextStatus,
JSON.stringify(mergedTags),
hasField("result_summary") ? input.result_summary ?? null : existing.result_summary,
hasField("result_detail") ? input.result_detail ?? null : existing.result_detail,
hasField("completed_by") ? input.completed_by ?? null : existing.completed_by,
hasField("last_dispatch_at") ? input.last_dispatch_at ?? null : existing.last_dispatch_at,
acknowledgedAt,
hasField("last_error") ? input.last_error ?? null : existing.last_error,
completedAt,
id,
],
);
const updated = await findTask(id);
if (!updated) {
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") {
await writeWikiForTask(updated);
}
return updated;
}
export async function applyTaskCallback(id: number, payload: {
status?: TaskStatus;
dispatch_state?: DispatchState;
summary?: string | null;
detail?: string | null;
completed_by?: string | null;
last_error?: string | null;
last_dispatch_at?: string | null;
}) {
const nextStatus = payload.status ?? (payload.dispatch_state === "completed" ? "Done" : undefined);
const updated = await updateTask(id, {
status: nextStatus,
dispatch_state: payload.dispatch_state,
result_summary: payload.summary ?? undefined,
result_detail: payload.detail ?? undefined,
completed_by: payload.completed_by ?? undefined,
last_error: payload.last_error ?? undefined,
last_dispatch_at: payload.last_dispatch_at ?? undefined,
});
if (!updated) {
return null;
}
const eventType =
payload.dispatch_state === "failed"
? "dispatch_failed"
: payload.dispatch_state === "completed"
? "dispatch_succeeded"
: payload.dispatch_state === "acknowledged"
? "acknowledged"
: "updated";
await appendTaskEvent({
taskId: updated.id,
assignee: updated.assignee,
family: updated.family,
host: updated.target_host,
eventType,
state: updated.dispatch_state,
summary: payload.summary || `${eventType.replace(/_/g, " ")} callback`,
detail: payload.detail || "",
});
return updated;
}

View File

@@ -1,228 +0,0 @@
export type TaskStatus = "Backlog" | "Todo" | "In Progress" | "Review" | "Done";
export type TaskPriority = "Low" | "Medium" | "High" | "Critical";
export type AgentFamily = "openclaw" | "zeroclaw" | "direct";
export type AgentStatus = "active" | "busy" | "idle";
export type DispatchMethod = "openclaw-swarm" | "zeroclaw-webhook" | "direct-ssh" | "manual";
export type DispatchState =
| "planned"
| "assigned"
| "dispatched"
| "acknowledged"
| "completed"
| "failed";
export type TaskRecord = {
id: number;
title: string;
description: 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;
result_summary: string | null;
result_detail: string | null;
completed_by: string | null;
priority: TaskPriority;
status: TaskStatus;
tags: string[];
last_dispatch_at: string | null;
acknowledged_at: string | null;
last_error: string | null;
created_at: string;
updated_at: string;
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 TaskCallbackPayload = {
status?: TaskStatus;
dispatch_state?: DispatchState;
summary?: string | null;
detail?: string | null;
completed_by?: string | null;
last_error?: string | null;
last_dispatch_at?: string | null;
};
export type WikiPageSummary = {
filename: string;
title: string;
created: string;
modified: string;
tags: string[];
};
export type WikiPage = {
filename: string;
content: string;
metadata: {
title: string;
created: string;
modified: string;
tags: string[];
};
};
export type AgentRouteSummary = {
label: string;
value: string;
};
export type FleetAgent = {
slug: string;
assignmentKey: string;
aliases: string[];
family: AgentFamily;
name: string;
host: string;
role: string;
runtimePath: string;
configPath: string | null;
defaultDispatchMethod: DispatchMethod;
model: string | null;
emoji: string;
channels: AgentRouteSummary[];
tools: string[];
capabilities: string[];
files: string[];
status: AgentStatus;
workload: number;
activeTasks: TaskRecord[];
completedTasks: TaskRecord[];
currentTask: string | null;
heartbeatAt: string | null;
heartbeatAgeMinutes: number | null;
lastEvent: TaskEvent | null;
failureStreak: number;
notes: string[];
};
export type FleetSection = {
id: AgentFamily;
title: string;
summary: string;
runtime: AgentRouteSummary[];
channels: AgentRouteSummary[];
configuredAgents: string[];
diagram: string;
notes: string[];
};
export type ArchitectureDocument = {
generatedAt: string;
title: string;
overview: string[];
sections: FleetSection[];
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 DirectAgentActionDefinition = {
key: string;
title: string;
description: string;
command: string;
successSummary: string;
};
export type DirectAgentDefinition = {
slug: string;
assignmentKey: string;
aliases: string[];
name: string;
host: string;
role: string;
runtimePath: string;
configPath: string | null;
emoji: string;
channels: AgentRouteSummary[];
tools: string[];
capabilities: string[];
files: string[];
notes: string[];
dispatch: {
method: "direct-ssh";
hostname: string;
user: string;
port: number;
defaultAction: string;
actions: DirectAgentActionDefinition[];
};
};
export type FleetConfig = {
title: string;
overview: string[];
topologyDiagram: string;
sections: FleetSection[];
zeroclawAgents: ZeroClawAgentDefinition[];
directAgents: DirectAgentDefinition[];
};

View File

@@ -1,29 +0,0 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatDateTime(value: string | null | undefined) {
if (!value) {
return "Unknown";
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return new Intl.DateTimeFormat("en-US", {
dateStyle: "medium",
timeStyle: "short",
}).format(date);
}
export function slugify(value: string) {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}

View File

@@ -1,130 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import type { WikiPage, WikiPageSummary } from "@/lib/types";
const WIKI_DIR = process.env.WIKI_DIR || path.join(process.cwd(), "wiki");
fs.mkdirSync(WIKI_DIR, { recursive: true });
function assertSafeFilename(filename: string) {
if (filename.includes("..") || filename.includes("/") || filename.includes("\\")) {
throw new Error("invalid_filename");
}
}
function extractMetadata(content: string) {
const metadata = {
title: "",
created: "",
modified: "",
tags: [] as string[],
};
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (frontmatterMatch) {
const frontmatter = frontmatterMatch[1];
const titleMatch = frontmatter.match(/title:\s*(.+)/i);
const tagsMatch = frontmatter.match(/tags:\s*\[(.+)\]/i);
if (titleMatch) {
metadata.title = titleMatch[1].trim();
}
if (tagsMatch) {
metadata.tags = tagsMatch[1].split(",").map((tag) => tag.trim()).filter(Boolean);
}
}
if (!metadata.title) {
const headingMatch = content.match(/^#\s+(.+)$/m);
if (headingMatch) {
metadata.title = headingMatch[1].trim();
}
}
return metadata;
}
export function listWikiPages(): WikiPageSummary[] {
if (!fs.existsSync(WIKI_DIR)) {
return [];
}
return fs
.readdirSync(WIKI_DIR)
.filter((fileName) => fileName.endsWith(".md"))
.map((filename) => {
const filePath = path.join(WIKI_DIR, filename);
const stats = fs.statSync(filePath);
const content = fs.readFileSync(filePath, "utf8");
const metadata = extractMetadata(content);
return {
filename,
title: metadata.title || filename.replace(".md", "").replace(/-/g, " "),
created: stats.birthtime.toISOString(),
modified: stats.mtime.toISOString(),
tags: metadata.tags,
};
})
.sort((left, right) => new Date(right.modified).getTime() - new Date(left.modified).getTime());
}
export function readWikiPage(filename: string): WikiPage | null {
assertSafeFilename(filename);
const filePath = path.join(WIKI_DIR, filename);
if (!fs.existsSync(filePath)) {
return null;
}
const content = fs.readFileSync(filePath, "utf8");
const stats = fs.statSync(filePath);
const metadata = extractMetadata(content);
return {
filename,
content,
metadata: {
title: metadata.title || filename.replace(".md", ""),
created: stats.birthtime.toISOString(),
modified: stats.mtime.toISOString(),
tags: metadata.tags,
},
};
}
export function createWikiPage(title: string) {
const safeTitle = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
const timestamp = new Date().toISOString().slice(0, 10);
let filename = `${timestamp}-${safeTitle}.md`;
let counter = 1;
while (fs.existsSync(path.join(WIKI_DIR, filename))) {
filename = `${timestamp}-${safeTitle}-${counter}.md`;
counter += 1;
}
const content = `# ${title}
## Summary
Document the architecture, deployment notes, or runbook here.
`;
fs.writeFileSync(path.join(WIKI_DIR, filename), content, "utf8");
return filename;
}
export function updateWikiPage(filename: string, content: string) {
assertSafeFilename(filename);
fs.writeFileSync(path.join(WIKI_DIR, filename), content, "utf8");
}
export function deleteWikiPage(filename: string) {
assertSafeFilename(filename);
fs.unlinkSync(path.join(WIKI_DIR, filename));
}

6
next-env.d.ts vendored
View File

@@ -1,6 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -1,9 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
typedRoutes: false,
serverExternalPackages: ["sqlite3"],
};
export default nextConfig;

3838
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,32 +1,14 @@
{ {
"name": "openclaw-taskboard", "name": "openclaw-taskboard",
"version": "2.0.0", "version": "1.0.0",
"private": true, "description": "OpenClaw agent fleet task tracking dashboard",
"description": "Next.js fleet dashboard for OpenClaw, ZeroClaw, and direct host operations", "main": "server.js",
"scripts": { "scripts": {
"dev": "next dev", "start": "node server.js"
"build": "next build",
"start": "next start",
"lint": "next lint"
}, },
"dependencies": { "dependencies": {
"class-variance-authority": "^0.7.1", "express": "^4.19.2",
"clsx": "^2.1.1",
"lucide-react": "^0.511.0",
"next": "^15.2.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"sqlite3": "^5.1.7", "sqlite3": "^5.1.7",
"tailwind-merge": "^3.3.0" "ws": "^8.18.0"
},
"devDependencies": {
"@types/node": "^22.13.10",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.5.3",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.2"
} }
} }

View File

@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

32
public/agents.html Normal file
View File

@@ -0,0 +1,32 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Agents - OpenClaw Agent Fleet Dashboard</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<nav class="navbar">
<div class="nav-brand">
<h1>🦞 OpenClaw Fleet Dashboard</h1>
</div>
<div class="nav-links">
<a href="/tasks" class="nav-link">📋 Tasks</a>
<a href="/wiki" class="nav-link">📚 Wiki</a>
<a href="/agents" class="nav-link active">🤖 Agents</a>
<a href="/usage" class="nav-link">📊 Usage</a>
</div>
</nav>
<div class="page active">
<header class="topbar">
<h2>🤖 Agents</h2>
<p>Fleet agent workspace and configuration</p>
</header>
<div class="agents-grid" id="agents-grid"></div>
</div>
<script src="/app.js"></script>
</body>
</html>

View File

@@ -1,89 +1,23 @@
// ============ THEME ============ // Task Dashboard
const THEME_STORAGE_KEY = 'agentdash-theme';
const themeToggleBtn = document.getElementById('theme-toggle');
const systemThemeMedia = window.matchMedia('(prefers-color-scheme: dark)');
function getSystemTheme() {
return systemThemeMedia.matches ? 'dark' : 'light';
}
function getSavedTheme() {
try {
const saved = localStorage.getItem(THEME_STORAGE_KEY);
return saved === 'light' || saved === 'dark' ? saved : null;
} catch {
return null;
}
}
function setSavedTheme(theme) {
try {
localStorage.setItem(THEME_STORAGE_KEY, theme);
} catch {
// Ignore localStorage errors (privacy mode, quota, etc.).
}
}
function updateThemeToggleLabel() {
if (!themeToggleBtn) return;
const isDarkTheme = document.documentElement.getAttribute('data-theme') === 'dark';
const nextTheme = isDarkTheme ? 'light' : 'dark';
themeToggleBtn.textContent = isDarkTheme ? 'Light Mode' : 'Dark Mode';
themeToggleBtn.setAttribute('aria-label', `Switch to ${nextTheme} mode`);
}
function applyTheme(theme, { persist = false } = {}) {
document.documentElement.setAttribute('data-theme', theme);
if (persist) setSavedTheme(theme);
updateThemeToggleLabel();
if (usageStats) renderUsageCharts();
}
function initTheme() {
const savedTheme = getSavedTheme();
applyTheme(savedTheme || getSystemTheme());
if (themeToggleBtn) {
themeToggleBtn.addEventListener('click', () => {
const currentTheme = document.documentElement.getAttribute('data-theme') || getSystemTheme();
const nextTheme = currentTheme === 'dark' ? 'light' : 'dark';
applyTheme(nextTheme, { persist: true });
});
}
systemThemeMedia.addEventListener('change', (event) => {
if (!getSavedTheme()) {
applyTheme(event.matches ? 'dark' : 'light');
}
});
}
// ============ STATE ============
const COLUMNS = { const COLUMNS = {
Backlog: { title: '📋 Backlog', tasks: [] }, 'Backlog': { title: '📋 Backlog', tasks: [] },
Todo: { title: '📝 Todo', tasks: [] }, 'Todo': { title: '📝 Todo', tasks: [] },
'In Progress': { title: '🔄 In Progress', tasks: [] }, 'In Progress': { title: '🔄 In Progress', tasks: [] },
Review: { title: '👀 Review', tasks: [] }, 'Review': { title: '👀 Review', tasks: [] },
Done: { title: '✅ Done', tasks: [] }, 'Done': { title: '✅ Done', tasks: [] }
}; };
let wikiPages = [];
let currentWikiPage = null;
let allAgents = [];
let usageStats = null;
let providerChart = null;
let agentChart = null;
// ============ TASK DASHBOARD ============
async function loadTasks() { async function loadTasks() {
const res = await fetch('/api/tasks'); const res = await fetch('/api/tasks');
const tasks = await res.json(); const tasks = await res.json();
Object.keys(COLUMNS).forEach((status) => { // Reset columns
Object.keys(COLUMNS).forEach(status => {
COLUMNS[status].tasks = []; COLUMNS[status].tasks = [];
}); });
tasks.forEach((task) => { // Group tasks by status
tasks.forEach(task => {
if (COLUMNS[task.status]) { if (COLUMNS[task.status]) {
COLUMNS[task.status].tasks.push(task); COLUMNS[task.status].tasks.push(task);
} }
@@ -94,8 +28,6 @@ async function loadTasks() {
function renderBoard() { function renderBoard() {
const board = document.getElementById('board'); const board = document.getElementById('board');
if (!board) return;
board.innerHTML = ''; board.innerHTML = '';
Object.entries(COLUMNS).forEach(([status, column]) => { Object.entries(COLUMNS).forEach(([status, column]) => {
@@ -112,7 +44,7 @@ function renderBoard() {
const cardsEl = columnEl.querySelector('.cards'); const cardsEl = columnEl.querySelector('.cards');
column.tasks.forEach((task) => { column.tasks.forEach(task => {
const cardEl = document.createElement('div'); const cardEl = document.createElement('div');
cardEl.className = 'card'; cardEl.className = 'card';
cardEl.innerHTML = ` cardEl.innerHTML = `
@@ -122,19 +54,20 @@ function renderBoard() {
</div> </div>
<p class="card-desc">${escapeHtml(task.description || '')}</p> <p class="card-desc">${escapeHtml(task.description || '')}</p>
<p class="meta assignee">${task.assignee || 'Unassigned'}</p> <p class="meta assignee">${task.assignee || 'Unassigned'}</p>
<p class="meta tags">${task.tags.map((t) => `<span class="tag">${escapeHtml(t)}</span>`).join(' ')}</p> <p class="meta tags">${task.tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join(' ')}</p>
<label> <label>
<input type="checkbox" class="card-check" ${task.status === 'Done' ? 'checked' : ''} /> <input type="checkbox" class="card-check" ${task.status === 'Done' ? 'checked' : ''} />
Mark Complete Mark Complete
</label> </label>
`; `;
// Checkbox handler
const checkbox = cardEl.querySelector('.card-check'); const checkbox = cardEl.querySelector('.card-check');
checkbox.addEventListener('change', async () => { checkbox.addEventListener('change', async () => {
await fetch(`/api/tasks/${task.id}`, { await fetch(`/api/tasks/${task.id}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'Done' }), body: JSON.stringify({ status: 'Done' })
}); });
loadTasks(); loadTasks();
}); });
@@ -146,284 +79,79 @@ function renderBoard() {
}); });
} }
async function populateAgentDropdown() { // Task form
try { document.getElementById('task-form').addEventListener('submit', async (e) => {
const res = await fetch('/api/agents');
const agents = await res.json();
const select = document.getElementById('assignee');
if (!select) return;
const firstOption = select.options[0];
select.innerHTML = '';
select.appendChild(firstOption);
agents.forEach((agent) => {
const option = document.createElement('option');
option.value = agent.name;
option.textContent = agent.name;
select.appendChild(option);
});
} catch (err) {
console.error('Failed to load agents for dropdown:', err);
}
}
function initTasksPage() {
const taskForm = document.getElementById('task-form');
if (!taskForm) return;
taskForm.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const formData = new FormData(e.target); const formData = new FormData(e.target);
const tagsValue = formData.get('tags');
const task = { const task = {
title: formData.get('title'), title: formData.get('title'),
description: formData.get('description'), description: formData.get('description'),
assignee: formData.get('assignee'), assignee: formData.get('assignee'),
priority: formData.get('priority'), priority: formData.get('priority'),
status: formData.get('status') || 'Backlog', status: formData.get('status'),
tags: (tagsValue || '').split(',').map((t) => t.trim()).filter((t) => t), tags: formData.get('tags').split(',').map(t => t.trim()).filter(t => t)
}; };
await fetch('/api/tasks', { await fetch('/api/tasks', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(task), body: JSON.stringify(task)
}); });
e.target.reset(); e.target.reset();
loadTasks(); loadTasks();
}); });
populateAgentDropdown(); // Wiki
loadTasks();
}
// ============ WIKI ============
async function loadWiki() { async function loadWiki() {
try {
const res = await fetch('/api/wiki'); const res = await fetch('/api/wiki');
wikiPages = await res.json(); const pages = await res.json();
renderWikiList();
} catch (err) {
console.error('Failed to load wiki:', err);
}
}
function renderWikiList(filter = '') {
const wikiList = document.getElementById('wiki-list'); const wikiList = document.getElementById('wiki-list');
if (!wikiList) return;
wikiList.innerHTML = ''; wikiList.innerHTML = '';
const filtered = filter pages.forEach(page => {
? wikiPages.filter((p) => p.title.toLowerCase().includes(filter.toLowerCase()) || p.filename.toLowerCase().includes(filter.toLowerCase()))
: wikiPages;
filtered.forEach((page) => {
const itemEl = document.createElement('div'); const itemEl = document.createElement('div');
itemEl.className = `wiki-item${currentWikiPage && currentWikiPage.filename === page.filename ? ' active' : ''}`; itemEl.className = 'wiki-item';
itemEl.innerHTML = ` itemEl.innerHTML = `
<h4 class="wiki-title">${escapeHtml(page.title)}</h4> <h4 class="wiki-title">${escapeHtml(page.filename.replace('.md', ''))}</h4>
<p class="wiki-date">${new Date(page.modified).toLocaleDateString()}</p> <p class="wiki-date">${new Date(page.created).toLocaleDateString()}</p>
`; `;
itemEl.addEventListener('click', () => selectWikiPage(page.filename)); itemEl.addEventListener('click', async () => {
// Mark active
wikiList.querySelectorAll('.wiki-item').forEach(i => i.classList.remove('active'));
itemEl.classList.add('active');
// Load content
const contentRes = await fetch(`/api/wiki/${page.filename}`);
const contentData = await contentRes.json();
const wikiContent = document.getElementById('wiki-content');
wikiContent.innerHTML = `<pre>${escapeHtml(contentData.content)}</pre>`;
});
wikiList.appendChild(itemEl); wikiList.appendChild(itemEl);
}); });
} }
async function selectWikiPage(filename) { // Agents
try {
const res = await fetch(`/api/wiki/${filename}`);
if (!res.ok) throw new Error('Page not found');
currentWikiPage = await res.json();
const titleEl = document.getElementById('wiki-page-title');
const actionsEl = document.getElementById('wiki-page-actions');
const contentEl = document.getElementById('wiki-content');
const editorEl = document.getElementById('wiki-editor');
const searchEl = document.getElementById('wiki-search');
if (!titleEl || !actionsEl || !contentEl || !editorEl || !searchEl) return;
titleEl.textContent = currentWikiPage.metadata.title || filename;
actionsEl.style.display = 'flex';
contentEl.style.display = 'block';
editorEl.style.display = 'none';
if (typeof marked !== 'undefined') {
contentEl.innerHTML = marked.parse(currentWikiPage.content);
} else {
contentEl.innerHTML = `<pre>${escapeHtml(currentWikiPage.content)}</pre>`;
}
renderWikiList(searchEl.value);
} catch (err) {
console.error('Failed to load wiki page:', err);
}
}
function initWikiPage() {
const searchInput = document.getElementById('wiki-search');
const newBtn = document.getElementById('wiki-new-btn');
const editBtn = document.getElementById('wiki-edit-btn');
const saveBtn = document.getElementById('wiki-save-btn');
const cancelBtn = document.getElementById('wiki-cancel-btn');
const deleteBtn = document.getElementById('wiki-delete-btn');
if (!searchInput || !newBtn || !editBtn || !saveBtn || !cancelBtn || !deleteBtn) return;
searchInput.addEventListener('input', (e) => {
renderWikiList(e.target.value);
});
newBtn.addEventListener('click', async () => {
const title = prompt('Enter page title:');
if (!title) return;
try {
const res = await fetch('/api/wiki', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});
if (res.ok) {
const data = await res.json();
await loadWiki();
selectWikiPage(data.filename);
}
} catch (err) {
console.error('Failed to create wiki page:', err);
}
});
editBtn.addEventListener('click', () => {
if (!currentWikiPage) return;
const contentEl = document.getElementById('wiki-content');
const editorEl = document.getElementById('wiki-editor');
const titleEl = document.getElementById('wiki-edit-title');
const editContentEl = document.getElementById('wiki-edit-content');
if (!contentEl || !editorEl || !titleEl || !editContentEl) return;
contentEl.style.display = 'none';
editorEl.style.display = 'block';
titleEl.value = currentWikiPage.metadata.title || '';
editContentEl.value = currentWikiPage.content;
});
saveBtn.addEventListener('click', async () => {
if (!currentWikiPage) return;
const editContentEl = document.getElementById('wiki-edit-content');
if (!editContentEl) return;
try {
const res = await fetch(`/api/wiki/${currentWikiPage.filename}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: editContentEl.value }),
});
if (res.ok) {
await selectWikiPage(currentWikiPage.filename);
}
} catch (err) {
console.error('Failed to save wiki page:', err);
}
});
cancelBtn.addEventListener('click', () => {
const editorEl = document.getElementById('wiki-editor');
const contentEl = document.getElementById('wiki-content');
if (!editorEl || !contentEl) return;
editorEl.style.display = 'none';
contentEl.style.display = 'block';
});
deleteBtn.addEventListener('click', async () => {
if (!currentWikiPage) return;
if (!confirm(`Delete "${currentWikiPage.metadata.title || currentWikiPage.filename}"?`)) return;
try {
const res = await fetch(`/api/wiki/${currentWikiPage.filename}`, {
method: 'DELETE',
});
if (res.ok) {
currentWikiPage = null;
const pageTitle = document.getElementById('wiki-page-title');
const pageActions = document.getElementById('wiki-page-actions');
const wikiContent = document.getElementById('wiki-content');
if (pageTitle) pageTitle.textContent = 'Select a page';
if (pageActions) pageActions.style.display = 'none';
if (wikiContent) {
wikiContent.innerHTML = '<div class="wiki-placeholder"><p>📚 Select a wiki page from the sidebar or create a new one.</p></div>';
}
await loadWiki();
}
} catch (err) {
console.error('Failed to delete wiki page:', err);
}
});
loadWiki();
}
// ============ AGENTS ============
async function loadAgents() { async function loadAgents() {
try {
const res = await fetch('/api/agents'); const res = await fetch('/api/agents');
allAgents = await res.json(); const agents = await res.json();
renderAgents();
} catch (err) {
console.error('Failed to load agents:', err);
}
}
function renderAgents(filter = '', statusFilter = '') {
const grid = document.getElementById('agents-grid'); const grid = document.getElementById('agents-grid');
if (!grid) return;
grid.innerHTML = ''; grid.innerHTML = '';
let filtered = allAgents; agents.forEach(agent => {
if (filter) {
filtered = filtered.filter((a) =>
a.name.toLowerCase().includes(filter.toLowerCase()) ||
(a.currentTask && a.currentTask.toLowerCase().includes(filter.toLowerCase()))
);
}
if (statusFilter) {
filtered = filtered.filter((a) => a.status === statusFilter);
}
filtered.forEach((agent) => {
const cardEl = document.createElement('div'); const cardEl = document.createElement('div');
cardEl.className = 'agent-card'; cardEl.className = 'agent-card';
const statusClass = `status-${agent.status}`;
cardEl.innerHTML = ` cardEl.innerHTML = `
<div class="agent-header"> <div class="agent-header">
<h3 class="agent-name">${escapeHtml(agent.name)}</h3> <h3 class="agent-name">${escapeHtml(agent.name)}</h3>
<span class="agent-status ${statusClass}">${agent.status}</span> <span class="agent-status">${agent.status}</span>
</div>
<div class="agent-workload">
<span class="workload-badge">📋 ${agent.workload} active task${agent.workload !== 1 ? 's' : ''}</span>
</div> </div>
<div class="agent-body"> <div class="agent-body">
<div class="agent-section"> <div class="agent-section">
@@ -433,389 +161,121 @@ function renderAgents(filter = '', statusFilter = '') {
<div class="agent-section"> <div class="agent-section">
<h4>🛠️ Tools</h4> <h4>🛠️ Tools</h4>
<div class="agent-tools"> <div class="agent-tools">
${agent.tools.length ? agent.tools.slice(0, 5).map((tool) => `<span class="tool-tag">${escapeHtml(tool)}</span>`).join('') : '<span class="no-data">No tools</span>'} ${agent.tools.map(tool => `<span class="tool-tag">${escapeHtml(tool)}</span>`).join('')}
${agent.tools.length > 5 ? `<span class="more-tag">+${agent.tools.length - 5} more</span>` : ''}
</div> </div>
</div> </div>
<div class="agent-section"> <div class="agent-section">
<h4>📄 Recent Files</h4> <h4>📄 Workspace Files</h4>
<div class="agent-files"> <div class="agent-files">
${agent.files.length ? agent.files.slice(0, 5).map((file) => `<span class="file-tag">${escapeHtml(file)}</span>`).join('') : '<span class="no-data">No files</span>'} ${agent.files.map(file => `<span class="file-tag">${escapeHtml(file)}</span>`).join('')}
</div> </div>
</div> </div>
</div> </div>
<div class="agent-actions">
<button class="btn-secondary agent-details-btn" data-agent="${escapeHtml(agent.name)}">Details</button>
<button class="btn-primary agent-assign-btn" data-agent="${escapeHtml(agent.name)}">Assign Task</button>
</div>
`; `;
cardEl.querySelector('.agent-details-btn').addEventListener('click', () => showAgentDetails(agent));
cardEl.querySelector('.agent-assign-btn').addEventListener('click', () => showAssignModal(agent.name));
grid.appendChild(cardEl); grid.appendChild(cardEl);
}); });
} }
function showAgentDetails(agent) { // Usage
const modal = document.getElementById('agent-modal'); async function loadUsage() {
const body = document.getElementById('modal-agent-body'); const res = await fetch('/api/usage');
const title = document.getElementById('modal-agent-name'); const usage = await res.json();
if (!modal || !body || !title) return;
title.textContent = agent.name; const container = document.getElementById('usage-container');
container.innerHTML = '';
body.innerHTML = ` if (usage.providers.length === 0) {
<div class="detail-section"> container.innerHTML = '<p style="padding: 2rem; color: var(--text-secondary);">No provider data available</p>';
<h4>Status</h4>
<p><span class="agent-status status-${agent.status}">${agent.status}</span></p>
</div>
<div class="detail-section">
<h4>Workload</h4>
<p>${agent.workload} active task${agent.workload !== 1 ? 's' : ''}</p>
</div>
<div class="detail-section">
<h4>Current Task</h4>
<p>${agent.currentTask || 'No active task'}</p>
</div>
<div class="detail-section">
<h4>Active Tasks</h4>
<ul class="task-list">
${agent.activeTasks.length
? agent.activeTasks.map((t) => `<li><strong>${escapeHtml(t.title)}</strong> <span class="badge priority-${t.priority}">${t.priority}</span></li>`).join('')
: '<li class="no-data">No active tasks</li>'}
</ul>
</div>
<div class="detail-section">
<h4>Recently Completed</h4>
<ul class="task-list">
${agent.completedTasks.length
? agent.completedTasks.map((t) => `<li>${escapeHtml(t.title)}</li>`).join('')
: '<li class="no-data">No completed tasks</li>'}
</ul>
</div>
<div class="detail-section">
<h4>Tools</h4>
<div class="tag-list">${agent.tools.length ? agent.tools.map((t) => `<span class="tool-tag">${escapeHtml(t)}</span>`).join('') : '<span class="no-data">No tools</span>'}</div>
</div>
<div class="detail-section">
<h4>Capabilities</h4>
<div class="tag-list">${agent.capabilities.length ? agent.capabilities.map((c) => `<span class="capability-tag">${escapeHtml(c)}</span>`).join('') : '<span class="no-data">No capabilities defined</span>'}</div>
</div>
`;
modal.classList.add('active');
}
async function showAssignModal(agentName) {
const modal = document.getElementById('assign-modal');
const agentNameEl = document.getElementById('assign-agent-name');
const select = document.getElementById('assign-task-select');
if (!modal || !agentNameEl || !select) return;
agentNameEl.textContent = agentName;
try {
const res = await fetch('/api/tasks');
const tasks = await res.json();
const unassignedTasks = tasks.filter((t) => t.status !== 'Done' && (!t.assignee || t.assignee === ''));
select.innerHTML = '<option value="">Select a task...</option>';
unassignedTasks.forEach((task) => {
const option = document.createElement('option');
option.value = task.id;
option.textContent = `${task.title} (${task.priority})`;
select.appendChild(option);
});
select.dataset.agent = agentName;
modal.classList.add('active');
} catch (err) {
console.error('Failed to load tasks for assignment:', err);
}
}
function initAgentsPage() {
const searchInput = document.getElementById('agent-search');
const statusFilter = document.getElementById('agent-status-filter');
const closeBtn = document.getElementById('modal-close');
const assignCloseBtn = document.getElementById('assign-modal-close');
const confirmAssignBtn = document.getElementById('confirm-assign-btn');
if (!searchInput || !statusFilter || !closeBtn || !assignCloseBtn || !confirmAssignBtn) return;
searchInput.addEventListener('input', (e) => {
renderAgents(e.target.value, statusFilter.value);
});
statusFilter.addEventListener('change', (e) => {
renderAgents(searchInput.value, e.target.value);
});
closeBtn.addEventListener('click', () => {
const modal = document.getElementById('agent-modal');
if (modal) modal.classList.remove('active');
});
assignCloseBtn.addEventListener('click', () => {
const modal = document.getElementById('assign-modal');
if (modal) modal.classList.remove('active');
});
confirmAssignBtn.addEventListener('click', async () => {
const select = document.getElementById('assign-task-select');
if (!select) return;
const taskId = select.value;
const agentName = select.dataset.agent;
if (!taskId) {
alert('Please select a task');
return; return;
} }
try { usage.providers.forEach(provider => {
const res = await fetch(`/api/agents/${agentName}/assign`, { const cardEl = document.createElement('div');
method: 'POST', cardEl.className = 'usage-card';
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ taskId: Number.parseInt(taskId, 10) }),
});
if (res.ok) { cardEl.innerHTML = `
const modal = document.getElementById('assign-modal'); <h3 class="provider-name">${escapeHtml(provider.name)}</h3>
if (modal) modal.classList.remove('active'); <div class="provider-models">
await loadAgents(); ${provider.models.map(model => `
}
} catch (err) {
console.error('Failed to assign task:', err);
}
});
loadAgents();
}
// ============ USAGE ============
async function loadUsage() {
const from = document.getElementById('usage-from')?.value;
const to = document.getElementById('usage-to')?.value;
let statsUrl = '/api/usage/stats';
const params = [];
if (from) params.push(`from=${from}`);
if (to) params.push(`to=${to}`);
if (params.length) statsUrl += `?${params.join('&')}`;
try {
const statsRes = await fetch(statsUrl);
usageStats = await statsRes.json();
const usageRes = await fetch('/api/usage');
const usageData = await usageRes.json();
renderUsageStats();
renderUsageCharts();
renderProviderDetails(usageData);
} catch (err) {
console.error('Failed to load usage:', err);
}
}
function renderUsageStats() {
const requestsEl = document.getElementById('stat-requests');
const tokensEl = document.getElementById('stat-tokens');
const costEl = document.getElementById('stat-cost');
if (!requestsEl || !tokensEl || !costEl || !usageStats) return;
requestsEl.textContent = usageStats.totalRequests.toLocaleString();
tokensEl.textContent = usageStats.totalTokens.toLocaleString();
costEl.textContent = `$${usageStats.totalCost.toFixed(2)}`;
}
function renderUsageCharts() {
if (!usageStats || typeof Chart === 'undefined') return;
const providerCanvas = document.getElementById('chart-provider');
const agentCanvas = document.getElementById('chart-agent');
if (!providerCanvas || !agentCanvas) return;
const providerCtx = providerCanvas.getContext('2d');
const agentCtx = agentCanvas.getContext('2d');
if (!providerCtx || !agentCtx) return;
if (providerChart) providerChart.destroy();
const providerLabels = Object.keys(usageStats.byProvider);
const providerData = providerLabels.map((p) => usageStats.byProvider[p].requests);
providerChart = new Chart(providerCtx, {
type: 'doughnut',
data: {
labels: providerLabels,
datasets: [{
data: providerData,
backgroundColor: ['#3498db', '#2ecc71', '#e74c3c', '#f39c12', '#9c27b0', '#00bcd4'],
}],
},
options: {
responsive: true,
plugins: {
legend: {
position: 'bottom',
labels: { color: '#e0e0e0' },
},
},
},
});
if (agentChart) agentChart.destroy();
const agentLabels = Object.keys(usageStats.byAgent);
const agentData = agentLabels.map((a) => usageStats.byAgent[a].requests);
agentChart = new Chart(agentCtx, {
type: 'bar',
data: {
labels: agentLabels,
datasets: [{
label: 'Requests',
data: agentData,
backgroundColor: '#3498db',
}],
},
options: {
responsive: true,
plugins: { legend: { display: false } },
scales: {
y: {
beginAtZero: true,
ticks: { color: '#e0e0e0' },
grid: { color: '#444' },
},
x: {
ticks: { color: '#e0e0e0' },
grid: { color: '#444' },
},
},
},
});
}
function renderProviderDetails(usageData) {
const grid = document.getElementById('provider-grid');
if (!grid || !usageData || !usageStats) return;
grid.innerHTML = '';
usageData.providers.forEach((provider) => {
const providerEl = document.createElement('div');
providerEl.className = 'provider-card';
const providerUsage = usageStats.byProvider[provider.name] || { requests: 0, tokens: 0, cost: 0 };
providerEl.innerHTML = `
<h4>${escapeHtml(provider.name)}</h4>
<div class="provider-stats">
<div class="provider-stat">
<span class="stat-label">Requests</span>
<span class="stat-value">${providerUsage.requests.toLocaleString()}</span>
</div>
<div class="provider-stat">
<span class="stat-label">Tokens</span>
<span class="stat-value">${providerUsage.tokens.toLocaleString()}</span>
</div>
<div class="provider-stat">
<span class="stat-label">Cost</span>
<span class="stat-value">$${providerUsage.cost.toFixed(2)}</span>
</div>
</div>
<div class="model-list">
<h5>Models</h5>
${provider.models.map((model) => `
<div class="model-item"> <div class="model-item">
<span class="model-name">${escapeHtml(model.name)}</span> <div class="model-name">${escapeHtml(model.name)}</div>
<span class="model-type">${escapeHtml(model.type)}</span> <div class="model-meta">Type: ${model.type} | Context: ${model.contextWindow}</div>
</div> </div>
`).join('')} `).join('')}
</div> </div>
<div class="provider-quota">
<div class="quota-title">Quota & Limits</div>
<div class="quota-item">
<span class="quota-label">Requests</span>
<span class="quota-value">${provider.quota.requests} / ${provider.quota.limit}</span>
</div>
<div class="quota-item">
<span class="quota-label">Tokens</span>
<span class="quota-value">${provider.quota.tokens}</span>
</div>
<div class="quota-bar">
<div class="quota-fill" style="width: 0%"></div>
</div>
</div>
`; `;
grid.appendChild(providerEl); container.appendChild(cardEl);
}); });
} }
function initUsagePage() { // Utility functions
const fromInput = document.getElementById('usage-from'); function escapeHtml(text) {
const toInput = document.getElementById('usage-to'); const div = document.createElement('div');
const applyBtn = document.getElementById('usage-apply-filter'); div.textContent = text;
const exportJsonBtn = document.getElementById('export-json'); return div.innerHTML;
const exportCsvBtn = document.getElementById('export-csv'); }
if (!fromInput || !toInput || !applyBtn || !exportJsonBtn || !exportCsvBtn) return; // Initialize based on current page
const path = window.location.pathname;
const to = new Date();
const from = new Date();
from.setDate(from.getDate() - 30);
fromInput.value = from.toISOString().split('T')[0];
toInput.value = to.toISOString().split('T')[0];
applyBtn.addEventListener('click', loadUsage);
exportJsonBtn.addEventListener('click', () => {
const fromValue = fromInput.value;
const toValue = toInput.value;
let url = '/api/usage/export?format=json';
if (fromValue) url += `&from=${fromValue}`;
if (toValue) url += `&to=${toValue}`;
window.open(url, '_blank');
});
exportCsvBtn.addEventListener('click', () => {
const fromValue = fromInput.value;
const toValue = toInput.value;
let url = '/api/usage/export?format=csv';
if (fromValue) url += `&from=${fromValue}`;
if (toValue) url += `&to=${toValue}`;
window.open(url, '_blank');
});
if (path === '/tasks' || path === '/') {
loadTasks();
} else if (path === '/wiki') {
loadWiki();
} else if (path === '/agents') {
loadAgents();
} else if (path === '/usage') {
loadUsage(); loadUsage();
} }
// ============ HELPERS ============ // Populate agent dropdown
function escapeHtml(text) { populateAgentDropdown();
if (typeof text !== 'string') return '';
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;',
};
return text.replace(/[&<>"']/g, (m) => map[m]);
}
function setupModalBackdropClose() { // WebSocket for real-time updates
document.querySelectorAll('.modal').forEach((modal) => { const ws = new WebSocket(`ws://${window.location.host}`);
modal.addEventListener('click', (e) => { ws.onmessage = (event) => {
if (e.target === modal) { const data = JSON.parse(event.data);
modal.classList.remove('active'); if (data.type === 'task_created' || data.type === 'task_updated') {
loadTasks();
} }
};
// Populate agent dropdown
async function populateAgentDropdown() {
try {
const res = await fetch('/api/agents');
const agents = await res.json();
const select = document.getElementById('assignee');
if (!select) return;
// Clear existing options except the first placeholder
select.innerHTML = '<option value="">Select agent...</option>';
// Add agent options
agents.forEach(agent => {
const option = document.createElement('option');
option.value = agent.name;
option.textContent = agent.name;
select.appendChild(option);
}); });
}); } catch (err) {
console.error('Failed to load agents for dropdown:', err);
}
} }
// ============ INITIALIZATION ============
document.addEventListener("DOMContentLoaded", () => {
const CURRENT_PAGE = document.body?.dataset?.page || "tasks";
initTheme();
setupModalBackdropClose();
if (CURRENT_PAGE === 'tasks') initTasksPage();
if (CURRENT_PAGE === 'wiki') initWikiPage();
if (CURRENT_PAGE === 'agents') initAgentsPage();
if (CURRENT_PAGE === 'usage') initUsagePage();
});

View File

@@ -1,279 +0,0 @@
// Navigation
const navLinks = document.querySelectorAll('.nav-link');
const pages = document.querySelectorAll('.page');
navLinks.forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const targetPage = link.dataset.page;
// Update active nav link
navLinks.forEach(l => l.classList.remove('active'));
link.classList.add('active');
// Show target page
pages.forEach(page => {
page.classList.remove('active');
if (page.id === `page-${targetPage}`) {
page.classList.add('active');
}
});
// Load page data
if (targetPage === 'wiki') loadWiki();
if (targetPage === 'agents') loadAgents();
if (targetPage === 'usage') loadUsage();
});
});
// Task Dashboard
const COLUMNS = {
'Backlog': { title: '📋 Backlog', tasks: [] },
'Todo': { title: '📝 Todo', tasks: [] },
'In Progress': { title: '🔄 In Progress', tasks: [] },
'Review': { title: '👀 Review', tasks: [] },
'Done': { title: '✅ Done', tasks: [] }
};
async function loadTasks() {
const res = await fetch('/api/tasks');
const tasks = await res.json();
// Reset columns
Object.keys(COLUMNS).forEach(status => {
COLUMNS[status].tasks = [];
});
// Group tasks by status
tasks.forEach(task => {
if (COLUMNS[task.status]) {
COLUMNS[task.status].tasks.push(task);
}
});
renderBoard();
}
function renderBoard() {
const board = document.getElementById('board');
board.innerHTML = '';
Object.entries(COLUMNS).forEach(([status, column]) => {
const columnEl = document.createElement('div');
columnEl.className = 'column';
columnEl.innerHTML = `
<div class="column-header">
<h3>${column.title}</h3>
<span class="column-count">${column.tasks.length}</span>
</div>
<div class="cards"></div>
`;
const cardsEl = columnEl.querySelector('.cards');
column.tasks.forEach(task => {
const cardEl = document.createElement('div');
cardEl.className = 'card';
cardEl.innerHTML = `
<div class="card-head">
<h3 class="card-title">${escapeHtml(task.title)}</h3>
<span class="badge priority-${task.priority}">${task.priority}</span>
</div>
<p class="card-desc">${escapeHtml(task.description || '')}</p>
<p class="meta assignee">${task.assignee || 'Unassigned'}</p>
<p class="meta tags">${task.tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join(' ')}</p>
<label>
<input type="checkbox" class="card-check" ${task.status === 'Done' ? 'checked' : ''} />
Mark Complete
</label>
`;
// Checkbox handler
const checkbox = cardEl.querySelector('.card-check');
checkbox.addEventListener('change', async () => {
await fetch(`/api/tasks/${task.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'Done' })
});
loadTasks();
});
cardsEl.appendChild(cardEl);
});
board.appendChild(columnEl);
});
}
// Task form
document.getElementById('task-form').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const task = {
title: formData.get('title'),
description: formData.get('description'),
assignee: formData.get('assignee'),
priority: formData.get('priority'),
status: formData.get('status'),
tags: formData.get('tags').split(',').map(t => t.trim()).filter(t => t)
};
await fetch('/api/tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(task)
});
e.target.reset();
loadTasks();
});
// Populate agent dropdown
async function populateAgentDropdown() {
try {
const res = await fetch('/api/agents');
const agents = await res.json();
const select = document.getElementById('assignee');
if (!select) return;
// Keep the first option ("Select agent...")
const firstOption = select.options[0];
select.innerHTML = '';
select.appendChild(firstOption);
// Add agent options
agents.forEach(agent => {
const option = document.createElement('option');
option.value = agent.name;
option.textContent = agent.name;
select.appendChild(option);
});
} catch (err) {
console.error('Failed to load agents for dropdown:', err);
}
}
// Populate dropdown on page load
document.addEventListener('DOMContentLoaded', () => {
populateAgentDropdown();
});
// Wiki
async function loadWiki() {
const res = await fetch('/api/wiki');
const pages = await res.json();
const wikiList = document.getElementById('wiki-list');
wikiList.innerHTML = '';
pages.forEach(page => {
const itemEl = document.createElement('div');
itemEl.className = 'wiki-item';
itemEl.innerHTML = `
<h4 class="wiki-title">${escapeHtml(page.filename.replace('.md', ''))}</h4>
<p class="wiki-date">${new Date(page.created).toLocaleDateString()}</p>
`;
itemEl.addEventListener('click', async () => {
// Mark active
wikiList.querySelectorAll('.wiki-item').forEach(i => i.classList.remove('active'));
itemEl.classList.add('active');
// Load content
const contentRes = await fetch(`/api/wiki/${page.filename}`);
const contentData = await contentRes.json();
const wikiContent = document.getElementById('wiki-content');
wikiContent.innerHTML = `<pre>${escapeHtml(contentData.content)}</pre>`;
});
wikiList.appendChild(itemEl);
});
}
// Agents
async function loadAgents() {
const res = await fetch('/api/agents');
const agents = await res.json();
const grid = document.getElementById('agents-grid');
grid.innerHTML = '';
agents.forEach(agent => {
const cardEl = document.createElement('div');
cardEl.className = 'agent-card';
cardEl.innerHTML = `
<div class="agent-header">
<h3 class="agent-name">${escapeHtml(agent.name)}</h3>
<span class="agent-status">${agent.status}</span>
</div>
<div class="agent-body">
<div class="agent-section">
<h4>📋 Current Task</h4>
<p class="agent-task">${agent.currentTask || 'No active task'}</p>
</div>
<div class="agent-section">
<h4>🛠️ Tools</h4>
<div class="agent-tools">
${agent.tools.map(tool => `<span class="tool-tag">${escapeHtml(tool)}</span>`).join('')}
</div>
</div>
<div class="agent-section">
<h4>📄 Workspace Files</h4>
<div class="agent-files">
${agent.files.map(file => `<span class="file-tag">${escapeHtml(file)}</span>`).join('')}
</div>
</div>
</div>
`;
grid.appendChild(cardEl);
});
}
// Usage
async function loadUsage() {
const res = await fetch('/api/usage');
const usage = await res.json();
const usageData = document.getElementById('usage-data');
usageData.innerHTML = `
<h3>📊 Provider Usage</h3>
<div class="usage-grid">
${usage.providers.map(provider => `
<div class="provider-card">
<h4>${escapeHtml(provider.name)}</h4>
<div class="model-list">
${provider.models.map(model => `
<div class="model-item">
<span class="model-name">${escapeHtml(model.name)}</span>
<span class="model-type">${escapeHtml(model.type)}</span>
<span class="model-context">${escapeHtml(model.contextWindow)}</span>
</div>
`).join('')}
</div>
</div>
`).join('')}
</div>
`;
}
// Helper function to escape HTML
function escapeHtml(text) {
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, m => map[m]);
}
// Initial load
loadTasks();

View File

@@ -1,156 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OpenClaw Agent Fleet Dashboard</title>
<!-- Distinctive Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Outfit:wght@300;400;500;600;700;800;900&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="container">
<header>
<h1>🦞 OpenClaw Agent Fleet Dashboard</h1>
<nav>
<a href="#tasks" class="nav-link active" data-page="tasks">Tasks</a>
<a href="#wiki" class="nav-link" data-page="wiki">Wiki</a>
<a href="#agents" class="nav-link" data-page="agents">Agents</a>
<a href="#usage" class="nav-link" data-page="usage">Usage</a>
<a href="#gitea" class="nav-link" data-page="gitea">Gitea</a>
</nav>
</header>
<main>
<section id="page-tasks" class="page active">
<div class="composer">
<h2>Create Task</h2>
<form id="task-form">
<input id="title" name="title" placeholder="Task title" required />
<select id="assignee" name="assignee">
<option value="">Select agent...</option>
</select>
<select id="priority" name="priority">
<option>Low</option>
<option selected>Medium</option>
<option>High</option>
<option>Critical</option>
</select>
<textarea id="description" name="description" placeholder="Task description" rows="3"></textarea>
<input id="tags" name="tags" placeholder="Tags (comma-separated)" />
<button type="submit">Create Task</button>
</form>
</div>
<div id="board">
<div class="column" data-status="Backlog">
<div class="column-header">
<h3>📋 Backlog</h3>
<span class="column-count">0</span>
</div>
<div class="cards"></div>
</div>
<div class="column" data-status="Todo">
<div class="column-header">
<h3>📝 Todo</h3>
<span class="column-count">0</span>
</div>
<div class="cards"></div>
</div>
<div class="column" data-status="In Progress">
<div class="column-header">
<h3>🔄 In Progress</h3>
<span class="column-count">0</span>
</div>
<div class="cards"></div>
</div>
<div class="column" data-status="Review">
<div class="column-header">
<h3>👀 Review</h3>
<span class="column-count">0</span>
</div>
<div class="cards"></div>
</div>
<div class="column" data-status="Done">
<div class="column-header">
<h3>✅ Done</h3>
<span class="column-count">0</span>
</div>
<div class="cards"></div>
</div>
</div>
</section>
<section id="page-wiki" class="page">
<div id="wiki-list"></div>
<div id="wiki-content"></div>
</section>
<section id="page-agents" class="page">
<div id="agents-grid"></div>
</section>
<section id="page-usage" class="page">
<div id="usage-data"></div>
</section>
<section id="page-gitea" class="page">
<h2>🔧 Gitea Integration</h2>
<div class="gitea-dashboard">
<div class="gitea-tabs">
<button class="tab-btn active" data-tab="swarm">Swarm Overview</button>
<button class="tab-btn" data-tab="reviews">Pending Reviews</button>
<button class="tab-btn" data-tab="activity">Recent Activity</button>
</div>
<div class="gitea-content">
<!-- Swarm Overview Tab -->
<div id="swarm-tab" class="tab-content active">
<div class="swarm-stats">
<div class="stat-card">
<h3>Total Repos</h3>
<div class="stat-value" id="total-repos">-</div>
</div>
<div class="stat-card">
<h3>Open PRs</h3>
<div class="stat-value" id="total-prs">-</div>
</div>
<div class="stat-card">
<h3>Open Issues</h3>
<div class="stat-value" id="total-issues">-</div>
</div>
<div class="stat-card">
<h3>Total Branches</h3>
<div class="stat-value" id="total-branches">-</div>
</div>
</div>
<div class="repo-list" id="repo-list">
<p class="loading">Loading repositories...</p>
</div>
</div>
<!-- Pending Reviews Tab -->
<div id="reviews-tab" class="tab-content">
<div class="reviews-list" id="reviews-list">
<p class="loading">Loading pending reviews...</p>
</div>
</div>
<!-- Recent Activity Tab -->
<div id="activity-tab" class="tab-content">
<div class="activity-feed" id="activity-feed">
<p class="loading">Loading recent activity...</p>
</div>
</div>
</div>
</div>
</section>
</main>
</div>
<script src="app.js"></script>
</body>
</html>

View File

@@ -1,97 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OpenClaw Agent Fleet Dashboard</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="container">
<header>
<h1>🦞 OpenClaw Agent Fleet Dashboard</h1>
<nav>
<a href="#tasks" class="nav-link active" data-page="tasks">Tasks</a>
<a href="#wiki" class="nav-link" data-page="wiki">Wiki</a>
<a href="#agents" class="nav-link" data-page="agents">Agents</a>
<a href="#usage" class="nav-link" data-page="usage">Usage</a>
</nav>
</header>
<main>
<section id="page-tasks" class="page active">
<div class="composer">
<h2>Create Task</h2>
<form id="task-form">
<input id="title" name="title" placeholder="Task title" required />
<select id="assignee" name="assignee">
<option value="">Select agent...</option>
</select>
<select id="priority" name="priority">
<option>Low</option>
<option selected>Medium</option>
<option>High</option>
<option>Critical</option>
</select>
<textarea id="description" name="description" placeholder="Task description" rows="3"></textarea>
<input id="tags" name="tags" placeholder="Tags (comma-separated)" />
<button type="submit">Create Task</button>
</form>
</div>
<div id="board">
<div class="column" data-status="Backlog">
<div class="column-header">
<h3>📋 Backlog</h3>
<span class="column-count">0</span>
</div>
<div class="cards"></div>
</div>
<div class="column" data-status="Todo">
<div class="column-header">
<h3>📝 Todo</h3>
<span class="column-count">0</span>
</div>
<div class="cards"></div>
</div>
<div class="column" data-status="In Progress">
<div class="column-header">
<h3>🔄 In Progress</h3>
<span class="column-count">0</span>
</div>
<div class="cards"></div>
</div>
<div class="column" data-status="Review">
<div class="column-header">
<h3>👀 Review</h3>
<span class="column-count">0</span>
</div>
<div class="cards"></div>
</div>
<div class="column" data-status="Done">
<div class="column-header">
<h3>✅ Done</h3>
<span class="column-count">0</span>
</div>
<div class="cards"></div>
</div>
</div>
</section>
<section id="page-wiki" class="page">
<div id="wiki-list"></div>
<div id="wiki-content"></div>
</section>
<section id="page-agents" class="page">
<div id="agents-grid"></div>
</section>
<section id="page-usage" class="page">
<div id="usage-data"></div>
</section>
</main>
</div>
<script src="app.js"></script>
</body>
</html>

146
public/index.html.old Normal file
View File

@@ -0,0 +1,146 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenClaw Agent Fleet Dashboard</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<nav class="navbar">
<div class="nav-brand">
<h1>🦞 OpenClaw Fleet Dashboard</h1>
</div>
<div class="nav-links">
<a href="#" class="nav-link active" data-page="dashboard">📋 Tasks</a>
<a href="#" class="nav-link" data-page="wiki">📚 Wiki</a>
<a href="#" class="nav-link" data-page="agents">🤖 Agents</a>
<a href="#" class="nav-link" data-page="usage">📊 Usage</a>
</div>
</nav>
<!-- Dashboard Page -->
<div id="page-dashboard" class="page active">
<header class="topbar">
<h2>Task Dashboard</h2>
<p>Real-time task coordination board</p>
</header>
<section class="composer">
<h3>Create Task</h3>
<form id="task-form">
<input id="title" name="title" placeholder="Task title" required />
<select id="assignee" name="assignee">
<option value="">Select agent...</option>
</select>
<select id="priority" name="priority">
<option>Low</option>
<option selected>Medium</option>
<option>High</option>
<option>Critical</option>
</select>
<select id="status" name="status">
<option selected>Backlog</option>
<option>Todo</option>
<option>In Progress</option>
<option>Review</option>
<option>Done</option>
</select>
<input id="tags" name="tags" placeholder="tags, comma, separated" />
<textarea id="description" name="description" placeholder="Description"></textarea>
<button type="submit">Add Task</button>
</form>
</section>
<main id="board" class="board"></main>
</div>
<!-- Wiki Page -->
<div id="page-wiki" class="page">
<header class="topbar">
<h2>📚 Wiki</h2>
<p>Task documentation and implementation details</p>
</header>
<div class="wiki-container">
<div class="wiki-list" id="wiki-list"></div>
<div class="wiki-content" id="wiki-content">
<p>Select a wiki page to view documentation</p>
</div>
</div>
</div>
<!-- Agents Page -->
<div id="page-agents" class="page">
<header class="topbar">
<h2>🤖 Agents</h2>
<p>Fleet agent workspace and configuration</p>
</header>
<div class="agents-grid" id="agents-grid"></div>
</div>
<!-- Usage Page -->
<div id="page-usage" class="page">
<header class="topbar">
<h2>📊 Usage & Quotas</h2>
<p>Provider models, quotas, and limits</p>
</header>
<div class="usage-container" id="usage-container"></div>
</div>
<template id="task-template">
<article class="card">
<div class="card-head">
<h3 class="card-title"></h3>
<span class="badge priority"></span>
</div>
<p class="card-desc"></p>
<p class="meta assignee"></p>
<p class="meta tags"></p>
<label>
<input type="checkbox" class="card-check" />
Mark Complete
</label>
</article>
</template>
<template id="wiki-item-template">
<div class="wiki-item">
<h4 class="wiki-title"></h4>
<p class="wiki-date"></p>
</div>
</template>
<template id="agent-card-template">
<div class="agent-card">
<div class="agent-header">
<h3 class="agent-name"></h3>
<span class="agent-status"></span>
</div>
<div class="agent-body">
<div class="agent-section">
<h4>📋 Current Task</h4>
<p class="agent-task"></p>
</div>
<div class="agent-section">
<h4>🛠️ Tools</h4>
<div class="agent-tools"></div>
</div>
<div class="agent-section">
<h4>📄 Workspace Files</h4>
<div class="agent-files"></div>
</div>
</div>
</div>
</template>
<template id="usage-card-template">
<div class="usage-card">
<h3 class="provider-name"></h3>
<div class="provider-models"></div>
<div class="provider-quota"></div>
</div>
</template>
<script src="/app.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

59
public/tasks.html Normal file
View File

@@ -0,0 +1,59 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tasks - OpenClaw Agent Fleet Dashboard</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<nav class="navbar">
<div class="nav-brand">
<h1>🦞 OpenClaw Fleet Dashboard</h1>
</div>
<div class="nav-links">
<a href="/tasks" class="nav-link active">📋 Tasks</a>
<a href="/wiki" class="nav-link">📚 Wiki</a>
<a href="/agents" class="nav-link">🤖 Agents</a>
<a href="/usage" class="nav-link">📊 Usage</a>
</div>
</nav>
<div class="page active">
<header class="topbar">
<h2>Task Dashboard</h2>
<p>Real-time task coordination board</p>
</header>
<section class="composer">
<h3>Create Task</h3>
<form id="task-form">
<input id="title" name="title" placeholder="Task title" required />
<select id="assignee" name="assignee">
<option value="">Select agent...</option>
</select>
<select id="priority" name="priority">
<option>Low</option>
<option selected>Medium</option>
<option>High</option>
<option>Critical</option>
</select>
<select id="status" name="status">
<option selected>Backlog</option>
<option>Todo</option>
<option>In Progress</option>
<option>Review</option>
<option>Done</option>
</select>
<input id="tags" name="tags" placeholder="tags, comma, separated" />
<textarea id="description" name="description" placeholder="Description"></textarea>
<button type="submit">Add Task</button>
</form>
</section>
<main id="board" class="board"></main>
</div>
<script src="/app.js"></script>
</body>
</html>

32
public/usage.html Normal file
View File

@@ -0,0 +1,32 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Usage - OpenClaw Agent Fleet Dashboard</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<nav class="navbar">
<div class="nav-brand">
<h1>🦞 OpenClaw Fleet Dashboard</h1>
</div>
<div class="nav-links">
<a href="/tasks" class="nav-link">📋 Tasks</a>
<a href="/wiki" class="nav-link">📚 Wiki</a>
<a href="/agents" class="nav-link">🤖 Agents</a>
<a href="/usage" class="nav-link active">📊 Usage</a>
</div>
</nav>
<div class="page active">
<header class="topbar">
<h2>📊 Usage & Quotas</h2>
<p>Provider models, quotas, and limits</p>
</header>
<div class="usage-container" id="usage-container"></div>
</div>
<script src="/app.js"></script>
</body>
</html>

37
public/wiki.html Normal file
View File

@@ -0,0 +1,37 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Wiki - OpenClaw Agent Fleet Dashboard</title>
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<nav class="navbar">
<div class="nav-brand">
<h1>🦞 OpenClaw Fleet Dashboard</h1>
</div>
<div class="nav-links">
<a href="/tasks" class="nav-link">📋 Tasks</a>
<a href="/wiki" class="nav-link active">📚 Wiki</a>
<a href="/agents" class="nav-link">🤖 Agents</a>
<a href="/usage" class="nav-link">📊 Usage</a>
</div>
</nav>
<div class="page active">
<header class="topbar">
<h2>📚 Wiki</h2>
<p>Task documentation and implementation details</p>
</header>
<div class="wiki-container">
<div class="wiki-list" id="wiki-list"></div>
<div class="wiki-content" id="wiki-content">
<p>Select a wiki page to view documentation</p>
</div>
</div>
</div>
<script src="/app.js"></script>
</body>
</html>

855
server.js
View File

@@ -4,7 +4,6 @@ const fs = require('fs');
const http = require('http'); const http = require('http');
const sqlite3 = require('sqlite3').verbose(); const sqlite3 = require('sqlite3').verbose();
const { WebSocketServer } = require('ws'); const { WebSocketServer } = require('ws');
const { setupGiteaRoutes } = require('./gitea-routes.js');
const PORT = process.env.PORT || 8395; const PORT = process.env.PORT || 8395;
const DB_PATH = process.env.DB_PATH || path.join(__dirname, 'data', 'tasks.db'); const DB_PATH = process.env.DB_PATH || path.join(__dirname, 'data', 'tasks.db');
@@ -35,85 +34,33 @@ db.serialize(() => {
completed_at TEXT completed_at TEXT
) )
`); `);
// Usage tracking table
db.run(`
CREATE TABLE IF NOT EXISTS usage_tracking (
id INTEGER PRIMARY KEY AUTOINCREMENT,
agent TEXT NOT NULL,
provider TEXT NOT NULL,
model TEXT NOT NULL,
request_type TEXT DEFAULT 'chat',
tokens_used INTEGER DEFAULT 0,
cost_estimate REAL DEFAULT 0,
timestamp TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
}); });
const app = express(); const app = express();
const server = http.createServer(app); const server = http.createServer(app);
const wss = new WebSocketServer({ server }); const wss = new WebSocketServer({ server });
const VIEWS_DIR = path.join(__dirname, 'views');
app.use(express.json()); app.use(express.json());
app.use(express.static(path.join(__dirname, 'public'), { index: false })); app.use(express.static(path.join(__dirname, 'public')));
function renderTemplate(template, vars = {}) {
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
const value = vars[key];
return value === undefined || value === null ? '' : String(value);
});
}
function renderPage(viewName, activeTab, pageTitle) {
const layoutPath = path.join(VIEWS_DIR, 'layout.html');
const viewPath = path.join(VIEWS_DIR, `${viewName}.html`);
const layout = fs.readFileSync(layoutPath, 'utf8');
const content = fs.readFileSync(viewPath, 'utf8');
return renderTemplate(layout, {
pageTitle,
pageName: viewName,
content,
tasksActive: activeTab === 'tasks' ? 'active' : '',
wikiActive: activeTab === 'wiki' ? 'active' : '',
agentsActive: activeTab === 'agents' ? 'active' : '',
usageActive: activeTab === 'usage' ? 'active' : '',
giteaActive: activeTab === 'gitea' ? 'active' : '',
markedScript: viewName === 'wiki'
? '<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>'
: '',
chartScript: viewName === 'usage'
? '<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>'
: '',
});
}
// ============ SERVER-RENDERED PAGES ============
app.get('/', (req, res) => {
res.redirect('/tasks');
});
app.get('/tasks', (req, res) => { app.get('/tasks', (req, res) => {
res.send(renderPage('tasks', 'tasks', 'OpenClaw Agent Fleet Dashboard - Tasks')); res.sendFile(path.join(__dirname, 'public', 'tasks.html'));
}); });
app.get('/wiki', (req, res) => { app.get('/wiki', (req, res) => {
res.send(renderPage('wiki', 'wiki', 'OpenClaw Agent Fleet Dashboard - Wiki')); res.sendFile(path.join(__dirname, 'public', 'wiki.html'));
}); });
app.get('/agents', (req, res) => { app.get('/agents', (req, res) => {
res.send(renderPage('agents', 'agents', 'OpenClaw Agent Fleet Dashboard - Agents')); res.sendFile(path.join(__dirname, 'public', 'agents.html'));
}); });
app.get('/usage', (req, res) => { app.get('/usage', (req, res) => {
res.send(renderPage('usage', 'usage', 'OpenClaw Agent Fleet Dashboard - Usage')); res.sendFile(path.join(__dirname, 'public', 'usage.html'));
}); });
app.get('/gitea', (req, res) => { app.get('/', (req, res) => {
res.send(renderPage('gitea', 'gitea', 'OpenClaw Agent Fleet Dashboard - Gitea')); res.redirect('/tasks');
}); });
function normalizeTask(row) { function normalizeTask(row) {
@@ -185,8 +132,6 @@ function validatePayload(body, partial = false) {
return errors; return errors;
} }
// ============ TASKS API ============
app.get('/api/tasks', (req, res) => { app.get('/api/tasks', (req, res) => {
db.all('SELECT * FROM tasks ORDER BY id DESC', (err, rows) => { db.all('SELECT * FROM tasks ORDER BY id DESC', (err, rows) => {
if (err) { if (err) {
@@ -197,6 +142,49 @@ app.get('/api/tasks', (req, res) => {
}); });
}); });
app.get('/api/wiki', (req, res) => {
try {
const files = [];
if (fs.existsSync(WIKI_DIR)) {
const wikiFiles = fs.readdirSync(WIKI_DIR).filter(f => f.endsWith('.md'));
wikiFiles.forEach(filename => {
const filePath = path.join(WIKI_DIR, filename);
const stats = fs.statSync(filePath);
files.push({
filename,
created: stats.mtime.toISOString()
});
});
files.sort((a, b) => new Date(b.created) - new Date(a.created));
}
res.json(files);
} catch (err) {
console.error('Error reading wiki:', err);
res.status(500).json({ error: 'failed_to_fetch_wiki' });
}
});
app.get('/api/wiki/:filename', (req, res) => {
try {
const filename = req.params.filename;
const filePath = path.join(WIKI_DIR, filename);
if (!fs.existsSync(filePath)) {
return res.status(404).json({ error: 'wiki_page_not_found' });
}
const content = fs.readFileSync(filePath, 'utf8');
res.json({ content });
} catch (err) {
console.error('Error reading wiki page:', err);
res.status(500).json({ error: 'failed_to_fetch_wiki_page' });
}
});
app.post('/api/tasks', (req, res) => { app.post('/api/tasks', (req, res) => {
const errors = validatePayload(req.body, false); const errors = validatePayload(req.body, false);
if (errors.length) { if (errors.length) {
@@ -313,207 +301,6 @@ app.patch('/api/tasks/:id', (req, res) => {
}); });
}); });
// ============ WIKI API ============
// Helper to extract frontmatter metadata from markdown
function extractMetadata(content) {
const metadata = {
title: '',
created: null,
modified: null,
tags: []
};
// Check for YAML-like frontmatter
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (frontmatterMatch) {
const frontmatter = frontmatterMatch[1];
const titleMatch = frontmatter.match(/title:\s*(.+)/i);
const createdMatch = frontmatter.match(/created:\s*(.+)/i);
const modifiedMatch = frontmatter.match(/modified:\s*(.+)/i);
const tagsMatch = frontmatter.match(/tags:\s*\[(.+)\]/i);
if (titleMatch) metadata.title = titleMatch[1].trim();
if (createdMatch) metadata.created = createdMatch[1].trim();
if (modifiedMatch) metadata.modified = modifiedMatch[1].trim();
if (tagsMatch) metadata.tags = tagsMatch[1].split(',').map(t => t.trim());
}
// Extract title from first heading if not in frontmatter
if (!metadata.title) {
const headingMatch = content.match(/^#\s+(.+)$/m);
if (headingMatch) {
metadata.title = headingMatch[1].trim();
}
}
return metadata;
}
// GET /api/wiki - List all wiki pages
app.get('/api/wiki', (req, res) => {
try {
if (!fs.existsSync(WIKI_DIR)) {
fs.mkdirSync(WIKI_DIR, { recursive: true });
return res.json([]);
}
const files = fs.readdirSync(WIKI_DIR)
.filter(f => f.endsWith('.md'))
.map(filename => {
const filePath = path.join(WIKI_DIR, filename);
const stats = fs.statSync(filePath);
const content = fs.readFileSync(filePath, 'utf8');
const metadata = extractMetadata(content);
return {
filename,
title: metadata.title || filename.replace('.md', '').replace(/-/g, ' '),
created: stats.birthtime.toISOString(),
modified: stats.mtime.toISOString(),
tags: metadata.tags
};
})
.sort((a, b) => new Date(b.modified) - new Date(a.modified));
res.json(files);
} catch (err) {
console.error('Error listing wiki pages:', err);
res.status(500).json({ error: 'failed_to_list_wiki_pages' });
}
});
// GET /api/wiki/:filename - Get specific wiki page content
app.get('/api/wiki/:filename', (req, res) => {
try {
const filename = req.params.filename;
// Security: prevent path traversal
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
return res.status(400).json({ error: 'invalid_filename' });
}
const filePath = path.join(WIKI_DIR, filename);
if (!fs.existsSync(filePath)) {
return res.status(404).json({ error: 'wiki_page_not_found' });
}
const content = fs.readFileSync(filePath, 'utf8');
const stats = fs.statSync(filePath);
const metadata = extractMetadata(content);
res.json({
filename,
content,
metadata: {
...metadata,
created: stats.birthtime.toISOString(),
modified: stats.mtime.toISOString()
}
});
} catch (err) {
console.error('Error reading wiki page:', err);
res.status(500).json({ error: 'failed_to_read_wiki_page' });
}
});
// POST /api/wiki - Create new wiki page
app.post('/api/wiki', (req, res) => {
try {
const { title, content } = req.body;
if (!title || typeof title !== 'string' || title.trim().length === 0) {
return res.status(400).json({ error: 'title_is_required' });
}
const safeTitle = title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80);
const timestamp = new Date().toISOString().slice(0, 10);
let filename = `${timestamp}-${safeTitle}.md`;
// Ensure unique filename
let counter = 1;
while (fs.existsSync(path.join(WIKI_DIR, filename))) {
filename = `${timestamp}-${safeTitle}-${counter}.md`;
counter++;
}
const filePath = path.join(WIKI_DIR, filename);
const pageContent = content || `# ${title}\n\n## Description\n\nEnter description here.\n\n## Implementation Status\n\n- [ ] Not started\n\n## Technical Details\n\nAdd technical notes here.\n`;
fs.writeFileSync(filePath, pageContent, 'utf8');
broadcast('wiki_created', { filename, title });
res.status(201).json({ filename, success: true, title });
} catch (err) {
console.error('Error creating wiki page:', err);
res.status(500).json({ error: 'failed_to_create_wiki_page' });
}
});
// PUT /api/wiki/:filename - Update wiki page
app.put('/api/wiki/:filename', (req, res) => {
try {
const filename = req.params.filename;
const { content } = req.body;
// Security: prevent path traversal
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
return res.status(400).json({ error: 'invalid_filename' });
}
if (typeof content !== 'string') {
return res.status(400).json({ error: 'content_is_required' });
}
const filePath = path.join(WIKI_DIR, filename);
if (!fs.existsSync(filePath)) {
return res.status(404).json({ error: 'wiki_page_not_found' });
}
fs.writeFileSync(filePath, content, 'utf8');
broadcast('wiki_updated', { filename });
res.json({ success: true });
} catch (err) {
console.error('Error updating wiki page:', err);
res.status(500).json({ error: 'failed_to_update_wiki_page' });
}
});
// DELETE /api/wiki/:filename - Delete wiki page
app.delete('/api/wiki/:filename', (req, res) => {
try {
const filename = req.params.filename;
// Security: prevent path traversal
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
return res.status(400).json({ error: 'invalid_filename' });
}
const filePath = path.join(WIKI_DIR, filename);
if (!fs.existsSync(filePath)) {
return res.status(404).json({ error: 'wiki_page_not_found' });
}
fs.unlinkSync(filePath);
broadcast('wiki_deleted', { filename });
res.json({ success: true });
} catch (err) {
console.error('Error deleting wiki page:', err);
res.status(500).json({ error: 'failed_to_delete_wiki_page' });
}
});
// ============ AGENTS API (Enhanced) ============
app.get('/api/agents', (req, res) => { app.get('/api/agents', (req, res) => {
try { try {
const agents = []; const agents = [];
@@ -523,96 +310,17 @@ app.get('/api/agents', (req, res) => {
return fs.statSync(path.join(AGENTS_DIR, d)).isDirectory(); return fs.statSync(path.join(AGENTS_DIR, d)).isDirectory();
}); });
// Get task counts per agent (workload) and completed tasks (history) agentDirs.forEach(agentName => {
const getAgentTaskData = (agentName) => {
return new Promise((resolve) => {
const result = {
workload: 0,
activeTasks: [],
completedTasks: []
};
// Get workload (tasks in Todo, In Progress, Review)
db.all(
`SELECT * FROM tasks
WHERE assignee = ? AND status IN ('Todo', 'In Progress', 'Review')
ORDER BY priority DESC, created_at ASC`,
[agentName],
(err, activeRows) => {
if (!err && activeRows) {
result.workload = activeRows.length;
result.activeTasks = activeRows.map(normalizeTask);
}
// Get last 5 completed tasks
db.all(
`SELECT * FROM tasks
WHERE assignee = ? AND status = 'Done'
ORDER BY completed_at DESC
LIMIT 5`,
[agentName],
(err2, completedRows) => {
if (!err2 && completedRows) {
result.completedTasks = completedRows.map(normalizeTask);
}
resolve(result);
}
);
}
);
});
};
const agentPromises = agentDirs.map(async (agentName) => {
const agentPath = path.join(AGENTS_DIR, agentName); const agentPath = path.join(AGENTS_DIR, agentName);
const workspacePath = path.join(agentPath, 'workspace'); const workspacePath = path.join(agentPath, 'workspace');
// Load openclaw config for identity info
let emoji = '🤖';
let model = 'unknown';
let identityName = agentName;
if (fs.existsSync(OPENCLAW_CONFIG)) {
try {
const openclawConfig = JSON.parse(fs.readFileSync(OPENCLAW_CONFIG, 'utf8'));
const agentConfig = openclawConfig.agents?.list?.find(a => a.id === agentName);
if (agentConfig) {
emoji = agentConfig.identity?.emoji || '🤖';
model = agentConfig.model?.primary || 'unknown';
identityName = agentConfig.identity?.name || agentName;
}
} catch {}
}
// Get last activity from session files
let lastActivity = null;
const sessionsPath = path.join(agentPath, 'sessions');
if (fs.existsSync(sessionsPath)) {
const sessionFiles = fs.readdirSync(sessionsPath).filter(f => f.endsWith('.jsonl'));
if (sessionFiles.length > 0) {
const latestSession = sessionFiles
.map(f => ({ file: f, mtime: fs.statSync(path.join(sessionsPath, f)).mtime }))
.sort((a, b) => b.mtime - a.mtime)[0];
if (latestSession) {
lastActivity = latestSession.mtime.toISOString();
}
}
}
const agent = { const agent = {
id: agentName, name: agentName,
emoji,
model,
lastActivity,
name: identityName,
status: 'active', status: 'active',
currentTask: null, currentTask: null,
tools: [], tools: [],
files: [], files: [],
permissions: [], permissions: []
workload: 0,
activeTasks: [],
completedTasks: [],
capabilities: []
}; };
if (fs.existsSync(workspacePath)) { if (fs.existsSync(workspacePath)) {
@@ -622,104 +330,36 @@ const agentPromises = agentDirs.map(async (agentName) => {
const memoryPath = path.join(workspacePath, 'MEMORY.md'); const memoryPath = path.join(workspacePath, 'MEMORY.md');
if (fs.existsSync(memoryPath)) { if (fs.existsSync(memoryPath)) {
const memory = fs.readFileSync(memoryPath, 'utf8'); const memory = fs.readFileSync(memoryPath, 'utf8');
const toolMatches = memory.match(/##\s+Tools([\s\S]*?)(?=##|$)/i); const toolMatches = memory.match(/##\\s+Tools([\\s\\S]*?)(?=##|$)/i);
if (toolMatches) { if (toolMatches) {
agent.tools = toolMatches[1].split('\n') agent.tools = toolMatches[1].split('\\n')
.filter(line => line.trim().startsWith('-')) .filter(line => line.trim().startsWith('-'))
.map(line => line.replace(/^-\s*/, '').trim()); .map(line => line.replace(/^-\\s*/, '').trim());
}
// Extract capabilities/skills
const skillsMatch = memory.match(/##\s+Skills([\s\S]*?)(?=##|$)/i);
if (skillsMatch) {
agent.capabilities = skillsMatch[1].split('\n')
.filter(line => line.trim().startsWith('-'))
.map(line => line.replace(/^-\s*/, '').trim());
} }
} }
const heartbeatPath = path.join(workspacePath, 'HEARTBEAT.md'); const heartbeatPath = path.join(workspacePath, 'HEARTBEAT.md');
if (fs.existsSync(heartbeatPath)) { if (fs.existsSync(heartbeatPath)) {
const heartbeat = fs.readFileSync(heartbeatPath, 'utf8'); const heartbeat = fs.readFileSync(heartbeatPath, 'utf8');
const taskMatch = heartbeat.match(/Current Task:\s*(.+)/i); const taskMatch = heartbeat.match(/Current Task:\\s*(.+)/i);
if (taskMatch) { if (taskMatch) {
agent.currentTask = taskMatch[1].trim(); agent.currentTask = taskMatch[1].trim();
} }
// Check last heartbeat time for status
const timeMatch = heartbeat.match(/Last Heartbeat:\s*(.+)/i);
if (timeMatch) {
const lastBeat = new Date(timeMatch[1]);
const now = new Date();
const minutesAgo = (now - lastBeat) / 1000 / 60;
if (minutesAgo > 30) {
agent.status = 'idle';
} else if (minutesAgo > 10) {
agent.status = 'busy';
}
}
} }
} }
// Get task data from database agents.push(agent);
const taskData = await getAgentTaskData(agentName);
agent.workload = taskData.workload;
agent.activeTasks = taskData.activeTasks;
agent.completedTasks = taskData.completedTasks;
return agent;
}); });
Promise.all(agentPromises).then(results => {
res.json(results);
});
} else {
res.json([]);
} }
res.json(agents);
} catch (err) { } catch (err) {
console.error('Error reading agents:', err); console.error('Error reading agents:', err);
res.status(500).json({ error: 'failed_to_fetch_agents' }); res.status(500).json({ error: 'failed_to_fetch_agents' });
} }
}); });
// POST /api/agents/:name/assign - Assign task to agent // Usage endpoint
app.post('/api/agents/:name/assign', (req, res) => {
const agentName = req.params.name;
const { taskId } = req.body;
if (!taskId) {
return res.status(400).json({ error: 'taskId_is_required' });
}
db.run(
'UPDATE tasks SET assignee = ?, updated_at = datetime("now") WHERE id = ?',
[agentName, taskId],
function(err) {
if (err) {
return res.status(500).json({ error: 'failed_to_assign_task' });
}
if (this.changes === 0) {
return res.status(404).json({ error: 'task_not_found' });
}
db.get('SELECT * FROM tasks WHERE id = ?', [taskId], (fetchErr, row) => {
if (fetchErr || !row) {
return res.status(500).json({ error: 'failed_to_fetch_task' });
}
const task = normalizeTask(row);
broadcast('task_assigned', { agent: agentName, task });
res.json({ success: true, task });
});
}
);
});
// ============ USAGE API (Enhanced) ============
// GET /api/usage - Basic usage info (existing)
app.get('/api/usage', (req, res) => { app.get('/api/usage', (req, res) => {
try { try {
const usage = { const usage = {
@@ -766,191 +406,7 @@ app.get('/api/usage', (req, res) => {
} }
}); });
// GET /api/usage/stats - Usage statistics with date range // Heartbeat endpoint for agents
app.get('/api/usage/stats', (req, res) => {
const { from, to } = req.query;
let query = 'SELECT * FROM usage_tracking';
const params = [];
const conditions = [];
if (from) {
conditions.push('timestamp >= ?');
params.push(from);
}
if (to) {
conditions.push('timestamp <= ?');
params.push(to);
}
if (conditions.length > 0) {
query += ' WHERE ' + conditions.join(' AND ');
}
query += ' ORDER BY timestamp DESC';
db.all(query, params, (err, rows) => {
if (err) {
console.error('Error fetching usage stats:', err);
return res.status(500).json({ error: 'failed_to_fetch_usage_stats' });
}
// Aggregate stats
const stats = {
totalRequests: rows.length,
totalTokens: rows.reduce((sum, r) => sum + (r.tokens_used || 0), 0),
totalCost: rows.reduce((sum, r) => sum + (r.cost_estimate || 0), 0),
byProvider: {},
byAgent: {},
byModel: {},
records: rows
};
rows.forEach(record => {
// By provider
if (!stats.byProvider[record.provider]) {
stats.byProvider[record.provider] = { requests: 0, tokens: 0, cost: 0 };
}
stats.byProvider[record.provider].requests++;
stats.byProvider[record.provider].tokens += record.tokens_used || 0;
stats.byProvider[record.provider].cost += record.cost_estimate || 0;
// By agent
if (!stats.byAgent[record.agent]) {
stats.byAgent[record.agent] = { requests: 0, tokens: 0, cost: 0 };
}
stats.byAgent[record.agent].requests++;
stats.byAgent[record.agent].tokens += record.tokens_used || 0;
stats.byAgent[record.agent].cost += record.cost_estimate || 0;
// By model
if (!stats.byModel[record.model]) {
stats.byModel[record.model] = { requests: 0, tokens: 0, cost: 0 };
}
stats.byModel[record.model].requests++;
stats.byModel[record.model].tokens += record.tokens_used || 0;
stats.byModel[record.model].cost += record.cost_estimate || 0;
});
res.json(stats);
});
});
// GET /api/usage/agents - Usage breakdown by agent
app.get('/api/usage/agents', (req, res) => {
const { from, to } = req.query;
let query = `
SELECT agent,
COUNT(*) as requests,
SUM(tokens_used) as tokens,
SUM(cost_estimate) as cost,
provider,
model
FROM usage_tracking
`;
const params = [];
const conditions = [];
if (from) {
conditions.push('timestamp >= ?');
params.push(from);
}
if (to) {
conditions.push('timestamp <= ?');
params.push(to);
}
if (conditions.length > 0) {
query += ' WHERE ' + conditions.join(' AND ');
}
query += ' GROUP BY agent ORDER BY requests DESC';
db.all(query, params, (err, rows) => {
if (err) {
console.error('Error fetching agent usage:', err);
return res.status(500).json({ error: 'failed_to_fetch_agent_usage' });
}
res.json(rows);
});
});
// POST /api/usage/track - Track usage (for external callers)
app.post('/api/usage/track', (req, res) => {
const { agent, provider, model, requestType, tokensUsed, costEstimate } = req.body;
if (!agent || !provider || !model) {
return res.status(400).json({ error: 'agent, provider, and model are required' });
}
db.run(
`INSERT INTO usage_tracking (agent, provider, model, request_type, tokens_used, cost_estimate)
VALUES (?, ?, ?, ?, ?, ?)`,
[agent, provider, model, requestType || 'chat', tokensUsed || 0, costEstimate || 0],
function(err) {
if (err) {
console.error('Error tracking usage:', err);
return res.status(500).json({ error: 'failed_to_track_usage' });
}
res.status(201).json({ success: true, id: this.lastID });
}
);
});
// GET /api/usage/export - Export usage data
app.get('/api/usage/export', (req, res) => {
const { format = 'json', from, to } = req.query;
let query = 'SELECT * FROM usage_tracking';
const params = [];
const conditions = [];
if (from) {
conditions.push('timestamp >= ?');
params.push(from);
}
if (to) {
conditions.push('timestamp <= ?');
params.push(to);
}
if (conditions.length > 0) {
query += ' WHERE ' + conditions.join(' AND ');
}
query += ' ORDER BY timestamp DESC';
db.all(query, params, (err, rows) => {
if (err) {
console.error('Error exporting usage:', err);
return res.status(500).json({ error: 'failed_to_export_usage' });
}
if (format === 'csv') {
const csv = [
'id,agent,provider,model,request_type,tokens_used,cost_estimate,timestamp',
...rows.map(r => `${r.id},${r.agent},${r.provider},${r.model},${r.request_type},${r.tokens_used},${r.cost_estimate},${r.timestamp}`)
].join('\n');
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', 'attachment; filename="usage-export.csv"');
res.send(csv);
} else {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Disposition', 'attachment; filename="usage-export.json"');
res.json(rows);
}
});
});
// ============ HEARTBEAT ============
app.get('/api/heartbeat/:agent', (req, res) => { app.get('/api/heartbeat/:agent', (req, res) => {
const agent = req.params.agent; const agent = req.params.agent;
@@ -972,197 +428,10 @@ app.get('/api/heartbeat/:agent', (req, res) => {
); );
}); });
// ============ WEBSOCKET ============
wss.on('connection', (socket) => { wss.on('connection', (socket) => {
socket.send(JSON.stringify({ type: 'connected', payload: { ok: true } })); socket.send(JSON.stringify({ type: 'connected', payload: { ok: true } }));
}); });
// Setup Gitea integration
setupGiteaRoutes(app, renderPage);
server.listen(PORT, '0.0.0.0', () => { server.listen(PORT, '0.0.0.0', () => {
console.log(`openclaw-taskboard listening on ${PORT}`); console.log(`openclaw-taskboard listening on ${PORT}`);
}); });
// ============ REAL USAGE TRACKING ============
const REAL_SESSIONS_DIR = process.env.SESSIONS_DIR || '/app/agents';
const SWARM_TASKS_FILE = process.env.SWARM_TASKS_FILE || '/app/swarm/active-tasks.json';
// GET /api/usage/real - Aggregate usage from session files
// GET /api/usage/real - Aggregate usage from session files with date filtering
app.get('/api/usage/real', async (req, res) => {
try {
const { from, to } = req.query;
const fromDate = from ? new Date(from) : null;
const toDate = to ? new Date(to) : null;
const usageByAgent = {};
const usageByModel = {};
let totalInput = 0, totalOutput = 0, totalCost = 0;
if (!fs.existsSync(REAL_SESSIONS_DIR)) {
return res.json({ error: 'sessions_dir_not_found', agents: {}, totals: {} });
}
const agents = fs.readdirSync(REAL_SESSIONS_DIR).filter(d => {
return fs.statSync(path.join(REAL_SESSIONS_DIR, d)).isDirectory();
});
for (const agent of agents) {
const sessionsDir = path.join(REAL_SESSIONS_DIR, agent, 'sessions');
if (!fs.existsSync(sessionsDir)) continue;
let agentInput = 0, agentOutput = 0, agentCost = 0;
const sessions = fs.readdirSync(sessionsDir).filter(f => f.endsWith('.jsonl'));
for (const sessionFile of sessions) {
const filePath = path.join(sessionsDir, sessionFile);
const lines = fs.readFileSync(filePath, 'utf8').split('\n');
for (const line of lines) {
if (!line.trim()) continue;
try {
const msg = JSON.parse(line);
// Date filtering
if (fromDate || toDate) {
const msgDate = new Date(msg.timestamp);
if (fromDate && msgDate < fromDate) continue;
if (toDate && msgDate > toDate) continue;
}
if (msg.message?.usage) {
const u = msg.message.usage;
agentInput += u.input || 0;
agentOutput += u.output || 0;
agentCost += u.cost?.total || 0;
const model = msg.message.model || 'unknown';
if (!usageByModel[model]) {
usageByModel[model] = { input: 0, output: 0, requests: 0 };
}
usageByModel[model].input += u.input || 0;
usageByModel[model].output += u.output || 0;
usageByModel[model].requests++;
}
} catch {}
}
}
usageByAgent[agent] = {
input: agentInput,
output: agentOutput,
total: agentInput + agentOutput,
cost: agentCost
};
totalInput += agentInput;
totalOutput += agentOutput;
totalCost += agentCost;
}
res.json({
agents: usageByAgent,
models: usageByModel,
totals: {
input: totalInput,
output: totalOutput,
total: totalInput + totalOutput,
cost: totalCost
},
filters: { from, to },
lastUpdated: new Date().toISOString()
});
} catch (err) {
console.error('Error calculating real usage:', err);
res.status(500).json({ error: 'failed_to_calculate_usage' });
}
});
// GET /api/usage/export/real - Export real usage data
app.get('/api/usage/export/real', (req, res) => {
const { format = 'json', from, to } = req.query;
try {
const fromDate = from ? new Date(from) : null;
const toDate = to ? new Date(to) : null;
const usageData = [];
if (!fs.existsSync(REAL_SESSIONS_DIR)) {
return res.status(404).json({ error: 'sessions_dir_not_found' });
}
const agents = fs.readdirSync(REAL_SESSIONS_DIR).filter(d => {
return fs.statSync(path.join(REAL_SESSIONS_DIR, d)).isDirectory();
});
for (const agent of agents) {
const sessionsDir = path.join(REAL_SESSIONS_DIR, agent, 'sessions');
if (!fs.existsSync(sessionsDir)) continue;
const sessions = fs.readdirSync(sessionsDir).filter(f => f.endsWith('.jsonl'));
for (const sessionFile of sessions) {
const filePath = path.join(sessionsDir, sessionFile);
const lines = fs.readFileSync(filePath, 'utf8').split('\n');
for (const line of lines) {
if (!line.trim()) continue;
try {
const msg = JSON.parse(line);
if (fromDate || toDate) {
const msgDate = new Date(msg.timestamp);
if (fromDate && msgDate < fromDate) continue;
if (toDate && msgDate > toDate) continue;
}
if (msg.message?.usage) {
usageData.push({
timestamp: msg.timestamp,
agent,
model: msg.message.model || 'unknown',
provider: msg.message.provider || 'unknown',
input: msg.message.usage.input || 0,
output: msg.message.usage.output || 0,
total: (msg.message.usage.input || 0) + (msg.message.usage.output || 0),
cost: msg.message.usage.cost?.total || 0
});
}
} catch {}
}
}
}
if (format === 'csv') {
const csv = [
'timestamp,agent,model,provider,input,output,total,cost',
...usageData.map(r => `${r.timestamp},${r.agent},${r.model},${r.provider},${r.input},${r.output},${r.total},${r.cost}`)
].join('\n');
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', 'attachment; filename="usage-export.csv"');
res.send(csv);
} else {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Disposition', 'attachment; filename="usage-export.json"');
res.json(usageData);
}
} catch (err) {
console.error('Error exporting usage:', err);
res.status(500).json({ error: 'failed_to_export_usage' });
}
});
app.get('/api/swarm/tasks', (req, res) => {
try {
if (fs.existsSync(SWARM_TASKS_FILE)) {
const data = JSON.parse(fs.readFileSync(SWARM_TASKS_FILE, 'utf8'));
res.json(data);
} else {
res.json({ tasks: [], message: 'swarm_registry_not_found' });
}
} catch (err) {
console.error('Error reading swarm tasks:', err);
res.status(500).json({ error: 'failed_to_read_swarm_tasks' });
}
});

View File

@@ -1,374 +0,0 @@
const express = require('express');
const path = require('path');
const fs = require('fs');
const http = require('http');
const sqlite3 = require('sqlite3').verbose();
const { WebSocketServer } = require('ws');
const PORT = process.env.PORT || 8395;
const DB_PATH = process.env.DB_PATH || path.join(__dirname, 'data', 'tasks.db');
const WIKI_DIR = process.env.WIKI_DIR || '/home/bear/.openclaw/workspace/wiki';
const AGENTS_DIR = process.env.AGENTS_DIR || '/home/bear/.openclaw/agents';
const OPENCLAW_CONFIG = process.env.OPENCLAW_CONFIG || '/home/bear/.openclaw/openclaw.json';
const VALID_STATUSES = ['Backlog', 'Todo', 'In Progress', 'Review', 'Done'];
const VALID_PRIORITIES = ['Low', 'Medium', 'High', 'Critical'];
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
fs.mkdirSync(WIKI_DIR, { recursive: true });
const db = new sqlite3.Database(DB_PATH);
db.serialize(() => {
db.run(`
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT DEFAULT '',
assignee TEXT DEFAULT '',
priority TEXT NOT NULL DEFAULT 'Medium',
status TEXT NOT NULL DEFAULT 'Backlog',
tags TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
completed_at TEXT
)
`);
});
const app = express();
const server = http.createServer(app);
const wss = new WebSocketServer({ server });
app.use(express.json());
app.use(express.static(path.join(__dirname, 'public')));
function normalizeTask(row) {
return {
...row,
tags: (() => {
try {
return JSON.parse(row.tags || '[]');
} catch {
return [];
}
})(),
};
}
function writeWiki(task) {
const safeTitle = task.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80) || `task-${task.id}`;
const fileName = `${new Date().toISOString().slice(0, 10)}-task-${task.id}-${safeTitle}.md`;
const filePath = path.join(WIKI_DIR, fileName);
const md = `# ${task.title}\n\n` +
`- Task ID: ${task.id}\n` +
`- Assignee: ${task.assignee || 'Unassigned'}\n` +
`- Priority: ${task.priority}\n` +
`- Status: ${task.status}\n` +
`- Tags: ${task.tags.length ? task.tags.join(', ') : 'None'}\n` +
`- Created: ${task.created_at}\n` +
`- Completed: ${task.completed_at || new Date().toISOString()}\n\n` +
`## Description\n\n${task.description || 'No description provided.'}\n`;
fs.writeFileSync(filePath, md, 'utf8');
}
function broadcast(type, payload) {
const data = JSON.stringify({ type, payload });
for (const client of wss.clients) {
if (client.readyState === 1) {
client.send(data);
}
}
}
function validatePayload(body, partial = false) {
const errors = [];
if (!partial || body.title !== undefined) {
if (typeof body.title !== 'string' || body.title.trim().length === 0) {
errors.push('title is required');
}
}
if (body.status !== undefined && !VALID_STATUSES.includes(body.status)) {
errors.push(`status must be one of: ${VALID_STATUSES.join(', ')}`);
}
if (body.priority !== undefined && !VALID_PRIORITIES.includes(body.priority)) {
errors.push(`priority must be one of: ${VALID_PRIORITIES.join(', ')}`);
}
if (body.tags !== undefined && !Array.isArray(body.tags)) {
errors.push('tags must be an array of strings');
}
return errors;
}
app.get('/api/tasks', (req, res) => {
db.all('SELECT * FROM tasks ORDER BY id DESC', (err, rows) => {
if (err) {
return res.status(500).json({ error: 'failed_to_fetch_tasks' });
}
return res.json(rows.map(normalizeTask));
});
});
app.post('/api/tasks', (req, res) => {
const errors = validatePayload(req.body, false);
if (errors.length) {
return res.status(400).json({ error: 'validation_error', details: errors });
}
const title = req.body.title.trim();
const description = typeof req.body.description === 'string' ? req.body.description : '';
const assignee = typeof req.body.assignee === 'string' ? req.body.assignee : '';
const priority = req.body.priority || 'Medium';
const status = req.body.status || 'Backlog';
const tags = Array.isArray(req.body.tags) ? req.body.tags.filter((t) => typeof t === 'string') : [];
db.run(
`INSERT INTO tasks (title, description, assignee, priority, status, tags)
VALUES (?, ?, ?, ?, ?, ?)`,
[title, description, assignee, priority, status, JSON.stringify(tags)],
function onInsert(err) {
if (err) {
return res.status(500).json({ error: 'failed_to_create_task' });
}
db.get('SELECT * FROM tasks WHERE id = ?', [this.lastID], (fetchErr, row) => {
if (fetchErr || !row) {
return res.status(500).json({ error: 'failed_to_fetch_created_task' });
}
const task = normalizeTask(row);
broadcast('task_created', task);
return res.status(201).json(task);
});
}
);
});
app.patch('/api/tasks/:id', (req, res) => {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'invalid_task_id' });
}
const errors = validatePayload(req.body, true);
if (errors.length) {
return res.status(400).json({ error: 'validation_error', details: errors });
}
db.get('SELECT * FROM tasks WHERE id = ?', [id], (err, existing) => {
if (err) {
return res.status(500).json({ error: 'failed_to_find_task' });
}
if (!existing) {
return res.status(404).json({ error: 'task_not_found' });
}
const existingTask = normalizeTask(existing);
const next = {
title: req.body.title !== undefined ? req.body.title.trim() : existingTask.title,
description: req.body.description !== undefined ? String(req.body.description) : existingTask.description,
assignee: req.body.assignee !== undefined ? String(req.body.assignee) : existingTask.assignee,
priority: req.body.priority !== undefined ? req.body.priority : existingTask.priority,
status: req.body.status !== undefined ? req.body.status : existingTask.status,
tags: req.body.tags !== undefined
? req.body.tags.filter((t) => typeof t === 'string')
: existingTask.tags,
};
const nowDone = next.status === 'Done';
const wasDone = existingTask.status === 'Done';
const completedAt = nowDone && !wasDone
? new Date().toISOString()
: nowDone
? existing.completed_at
: null;
db.run(
`UPDATE tasks
SET title = ?, description = ?, assignee = ?, priority = ?, status = ?, tags = ?,
completed_at = ?, updated_at = datetime('now')
WHERE id = ?`,
[
next.title,
next.description,
next.assignee,
next.priority,
next.status,
JSON.stringify(next.tags),
completedAt,
id,
],
(updateErr) => {
if (updateErr) {
return res.status(500).json({ error: 'failed_to_update_task' });
}
db.get('SELECT * FROM tasks WHERE id = ?', [id], (fetchErr, row) => {
if (fetchErr || !row) {
return res.status(500).json({ error: 'failed_to_fetch_updated_task' });
}
const task = normalizeTask(row);
if (nowDone && !wasDone) {
try {
writeWiki(task);
} catch (wikiErr) {
console.error('wiki_creation_error', wikiErr);
}
}
broadcast('task_updated', task);
return res.json(task);
});
});
});
});
app.get('/api/agents', (req, res) => {
try {
const agents = [];
if (fs.existsSync(AGENTS_DIR)) {
const agentDirs = fs.readdirSync(AGENTS_DIR).filter(d => {
return fs.statSync(path.join(AGENTS_DIR, d)).isDirectory();
});
agentDirs.forEach(agentName => {
const agentPath = path.join(AGENTS_DIR, agentName);
const workspacePath = path.join(agentPath, 'workspace');
const agent = {
name: agentName,
status: 'active',
currentTask: null,
tools: [],
files: [],
permissions: []
};
if (fs.existsSync(workspacePath)) {
const files = fs.readdirSync(workspacePath);
agent.files = files.filter(f => f.endsWith('.md'));
const memoryPath = path.join(workspacePath, 'MEMORY.md');
if (fs.existsSync(memoryPath)) {
const memory = fs.readFileSync(memoryPath, 'utf8');
const toolMatches = memory.match(/##\\s+Tools([\\s\\S]*?)(?=##|$)/i);
if (toolMatches) {
agent.tools = toolMatches[1].split('\\n')
.filter(line => line.trim().startsWith('-'))
.map(line => line.replace(/^-\\s*/, '').trim());
}
}
const heartbeatPath = path.join(workspacePath, 'HEARTBEAT.md');
if (fs.existsSync(heartbeatPath)) {
const heartbeat = fs.readFileSync(heartbeatPath, 'utf8');
const taskMatch = heartbeat.match(/Current Task:\\s*(.+)/i);
if (taskMatch) {
agent.currentTask = taskMatch[1].trim();
}
}
}
agents.push(agent);
});
}
res.json(agents);
} catch (err) {
console.error('Error reading agents:', err);
res.status(500).json({ error: 'failed_to_fetch_agents' });
}
});
// Usage endpoint
app.get('/api/usage', (req, res) => {
try {
const usage = {
providers: [],
lastUpdated: new Date().toISOString()
};
if (fs.existsSync(OPENCLAW_CONFIG)) {
const config = JSON.parse(fs.readFileSync(OPENCLAW_CONFIG, 'utf8'));
if (config.models) {
const providerMap = {};
Object.entries(config.models).forEach(([modelName, modelConfig]) => {
const provider = modelConfig.provider || 'unknown';
if (!providerMap[provider]) {
providerMap[provider] = {
name: provider,
models: [],
quota: {
requests: 0,
tokens: 0,
limit: 'unlimited'
}
};
}
providerMap[provider].models.push({
name: modelName,
type: modelConfig.type || 'chat',
contextWindow: modelConfig.context_window || 'unknown'
});
});
usage.providers = Object.values(providerMap);
}
}
res.json(usage);
} catch (err) {
console.error('Error reading usage:', err);
res.status(500).json({ error: 'failed_to_fetch_usage' });
}
});
// Heartbeat endpoint for agents
app.get('/api/heartbeat/:agent', (req, res) => {
const agent = req.params.agent;
db.all(
'SELECT * FROM tasks WHERE assignee = ? AND status IN (?, ?, ?) ORDER BY priority DESC, created_at ASC',
[agent, 'Todo', 'In Progress', 'Review'],
(err, rows) => {
if (err) {
return res.status(500).json({ error: 'failed_to_fetch_tasks' });
}
const tasks = rows.map(normalizeTask);
res.json({
agent,
pending_tasks: tasks.length,
tasks
});
}
);
});
wss.on('connection', (socket) => {
socket.send(JSON.stringify({ type: 'connected', payload: { ok: true } }));
});
server.listen(PORT, '0.0.0.0', () => {
console.log(`openclaw-taskboard listening on ${PORT}`);
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,56 +0,0 @@
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: ["class"],
content: [
"./app/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./lib/**/*.{ts,tsx}",
],
theme: {
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
boxShadow: {
panel: "0 24px 60px rgba(15, 23, 42, 0.28)",
},
fontFamily: {
sans: ["var(--font-sans)"],
mono: ["var(--font-mono)"],
},
},
},
plugins: [],
};
export default config;

View File

@@ -1,41 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"dom",
"dom.iterable",
"es2022"
],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"baseUrl": ".",
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
"next-env.d.ts",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}

View File

@@ -1,40 +0,0 @@
<section id="page-agents" class="page active">
<div class="agents-header">
<h2>Agent Fleet</h2>
<div class="agents-controls">
<input type="text" id="agent-search" placeholder="Search agents..." />
<select id="agent-status-filter">
<option value="">All Statuses</option>
<option value="active">Active</option>
<option value="busy">Busy</option>
<option value="idle">Idle</option>
</select>
</div>
</div>
<div id="agents-grid" class="agents-grid"></div>
<div id="agent-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 id="modal-agent-name">Agent Details</h3>
<button class="modal-close" id="modal-close">&times;</button>
</div>
<div class="modal-body" id="modal-agent-body"></div>
</div>
</div>
<div id="assign-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>Assign Task to <span id="assign-agent-name"></span></h3>
<button class="modal-close" id="assign-modal-close">&times;</button>
</div>
<div class="modal-body">
<select id="assign-task-select">
<option value="">Select a task...</option>
</select>
<button id="confirm-assign-btn" class="btn-primary">Assign Task</button>
</div>
</div>
</div>
</section>

View File

@@ -1,41 +0,0 @@
<section id="page-gitea" class="page active">
<div class="gitea-header">
<h2>🔗 Gitea Swarm Coordination</h2>
<div class="gitea-controls">
<button id="refresh-gitea" class="btn-secondary">🔄 Refresh</button>
</div>
</div>
<div class="gitea-stats">
<div class="stat-card">
<h4>Repositories</h4>
<div class="stat-value" id="stat-repos">0</div>
</div>
<div class="stat-card">
<h4>Open PRs</h4>
<div class="stat-value" id="stat-prs">0</div>
</div>
<div class="stat-card">
<h4>Pending Reviews</h4>
<div class="stat-value" id="stat-reviews">0</div>
</div>
</div>
<div class="gitea-tabs">
<button class="tab-btn active" data-tab="repos">📦 Repositories</button>
<button class="tab-btn" data-tab="prs">🔀 Pull Requests</button>
<button class="tab-btn" data-tab="activity">📊 Activity</button>
</div>
<div id="gitea-content">
<div id="tab-repos" class="tab-content active">
<div class="repos-grid" id="repos-grid"></div>
</div>
<div id="tab-prs" class="tab-content">
<div class="prs-list" id="prs-list"></div>
</div>
<div id="tab-activity" class="tab-content">
<div class="activity-list" id="activity-list"></div>
</div>
</div>
</section>

View File

@@ -1,92 +0,0 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{pageTitle}}</title>
<!-- Distinctive Google Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Outfit:wght@300;400;500;600;700;800;900&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/styles.css">
</head>
<body data-page="{{pageName}}">
<!-- Animated background particles -->
<div class="bg-particles" aria-hidden="true"></div>
<!-- Main container -->
<div class="app-container">
<!-- Header with glassmorphism effect -->
<header class="app-header">
<div class="header-content">
<div class="logo-section">
<div class="logo-icon">
<span class="logo-emoji">🦞</span>
<div class="logo-glow"></div>
</div>
<div class="logo-text">
<h1 class="logo-title">OpenClaw</h1>
<p class="logo-subtitle">Fleet Dashboard</p>
</div>
</div>
<div class="header-actions">
<button id="theme-toggle" class="theme-btn" aria-label="Toggle theme">
<span class="theme-icon">☀️</span>
<span class="theme-text">Light</span>
</button>
</div>
</div>
</header>
<!-- Navigation with hover effects -->
<nav class="app-nav" role="navigation" aria-label="Main navigation">
<div class="nav-container">
<a href="/tasks" class="nav-link {{tasksActive}}" data-tab="tasks">
<span class="nav-icon">📋</span>
<span class="nav-label">Tasks</span>
<span class="nav-indicator"></span>
</a>
<a href="/agents" class="nav-link {{agentsActive}}" data-tab="agents">
<span class="nav-icon">🤖</span>
<span class="nav-label">Agents</span>
<span class="nav-indicator"></span>
</a>
<a href="/wiki" class="nav-link {{wikiActive}}" data-tab="wiki">
<span class="nav-icon">📚</span>
<span class="nav-label">Wiki</span>
<span class="nav-indicator"></span>
</a>
<a href="/gitea" class="nav-link {{giteaActive}}" data-tab="gitea">
<span class="nav-icon">🔧</span>
<span class="nav-label">Gitea</span>
<span class="nav-indicator"></span>
</a>
<a href="/usage" class="nav-link {{usageActive}}" data-tab="usage">
<span class="nav-icon">📊</span>
<span class="nav-label">Usage</span>
<span class="nav-indicator"></span>
</a>
</div>
</nav>
<!-- Main content area -->
<main class="app-main" role="main">
<div class="content-wrapper">
{{content}}
</div>
</main>
<!-- Footer -->
<footer class="app-footer">
<p>OpenClaw Fleet Dashboard • <span class="footer-version">v2.0</span></p>
</footer>
</div>
{{markedScript}}
{{chartScript}}
<script src="/app.js"></script>
</body>
</html>

View File

@@ -1,33 +0,0 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{pageTitle}}</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<header class="top-bar">
<h1 class="logo">🦞 OpenClaw Fleet Dashboard</h1>
<div class="header-actions">
<button id="theme-toggle" class="theme-btn" aria-label="Toggle theme">Light Mode</button>
</div>
</header>
<nav class="tab-nav">
<a href="/tasks" class="tab-link {{tasksActive}}">📋 Tasks</a>
<a href="/agents" class="tab-link {{agentsActive}}">🤖 Agents</a>
<a href="/wiki" class="tab-link {{wikiActive}}">📚 Wiki</a>
<a href="/gitea" class="tab-link {{giteaActive}}">🔧 Gitea</a>
<a href="/usage" class="tab-link {{usageActive}}">📊 Usage</a>
</nav>
<main class="main-content">
{{content}}
</main>
{{markedScript}}
{{chartScript}}
<script src="/app.js"></script>
</body>
</html>

View File

@@ -1,85 +0,0 @@
<section id="page-tasks" class="page active">
<div class="task-controls">
<div class="task-search">
<input type="text" id="task-search" placeholder="🔍 Search tasks..." />
</div>
<div class="task-filters">
<select id="filter-status">
<option value="">All Statuses</option>
<option value="Backlog">Backlog</option>
<option value="Todo">Todo</option>
<option value="In Progress">In Progress</option>
<option value="Review">Review</option>
<option value="Done">Done</option>
</select>
<select id="filter-assignee">
<option value="">All Assignees</option>
</select>
<select id="filter-priority">
<option value="">All Priorities</option>
<option value="Critical">Critical</option>
<option value="High">High</option>
<option value="Medium">Medium</option>
<option value="Low">Low</option>
</select>
<button id="clear-filters" class="btn-secondary">Clear Filters</button>
</div>
</div>
<div class="composer">
<h2>Create Task</h2>
<form id="task-form">
<input id="title" name="title" placeholder="Task title" required />
<select id="assignee" name="assignee">
<option value="">Select agent...</option>
</select>
<select id="priority" name="priority">
<option>Low</option>
<option selected>Medium</option>
<option>High</option>
<option>Critical</option>
</select>
<textarea id="description" name="description" placeholder="Task description" rows="3"></textarea>
<input id="tags" name="tags" placeholder="Tags (comma-separated)" />
<button type="submit">Create Task</button>
</form>
</div>
<div id="board">
<div class="column" data-status="Backlog">
<div class="column-header">
<h3>📋 Backlog</h3>
<span class="column-count">0</span>
</div>
<div class="cards"></div>
</div>
<div class="column" data-status="Todo">
<div class="column-header">
<h3>📝 Todo</h3>
<span class="column-count">0</span>
</div>
<div class="cards"></div>
</div>
<div class="column" data-status="In Progress">
<div class="column-header">
<h3>🔄 In Progress</h3>
<span class="column-count">0</span>
</div>
<div class="cards"></div>
</div>
<div class="column" data-status="Review">
<div class="column-header">
<h3>👀 Review</h3>
<span class="column-count">0</span>
</div>
<div class="cards"></div>
</div>
<div class="column" data-status="Done">
<div class="column-header">
<h3>✅ Done</h3>
<span class="column-count">0</span>
</div>
<div class="cards"></div>
</div>
</div>
</section>

View File

@@ -1,49 +0,0 @@
<section id="page-usage" class="page active">
<div class="usage-header">
<h2>API Usage & Statistics</h2>
<div class="usage-controls">
<div class="date-range">
<label>From:</label>
<input type="date" id="usage-from" />
<label>To:</label>
<input type="date" id="usage-to" />
<button id="usage-apply-filter" class="btn-secondary">Apply</button>
</div>
<div class="export-actions">
<button id="export-json" class="btn-secondary">Export JSON</button>
<button id="export-csv" class="btn-secondary">Export CSV</button>
</div>
</div>
</div>
<div class="usage-stats">
<div class="stat-card">
<h4>Total Requests</h4>
<div class="stat-value" id="stat-requests">0</div>
</div>
<div class="stat-card">
<h4>Total Tokens</h4>
<div class="stat-value" id="stat-tokens">0</div>
</div>
<div class="stat-card">
<h4>Estimated Cost</h4>
<div class="stat-value" id="stat-cost">$0.00</div>
</div>
</div>
<div class="usage-charts">
<div class="chart-container">
<h4>Usage by Provider</h4>
<canvas id="chart-provider"></canvas>
</div>
<div class="chart-container">
<h4>Usage by Agent</h4>
<canvas id="chart-agent"></canvas>
</div>
</div>
<div id="usage-data" class="usage-details">
<h3>Provider Details</h3>
<div class="usage-grid" id="provider-grid"></div>
</div>
</section>

Some files were not shown because too many files have changed in this diff Show More