Compare commits
45 Commits
24f077cf25
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 328a3e945d | |||
| 07c42e92a9 | |||
| 53259f6b37 | |||
| b3a6c4bafb | |||
| 0a312dc733 | |||
| 01c9a206ab | |||
| 2ec17712c9 | |||
| 195ef5b2ca | |||
| 2bf137a437 | |||
| 430dcd209b | |||
| 77e7fbd860 | |||
| b70472f617 | |||
| aaab6b95a0 | |||
| 85c5ab10b0 | |||
| 73da5ae6d2 | |||
| e8e79c7b4c | |||
| e20b0ba55c | |||
| 85841a67da | |||
| be1cf8ca8d | |||
| 1699f0f2b7 | |||
| a765b3d22f | |||
| 94e54dc144 | |||
| 229cfba270 | |||
| 9bbfd97c86 | |||
| bc53380d4a | |||
| e20ebdf871 | |||
| 6fe05ba756 | |||
| cd53a4e612 | |||
| c310b22e8e | |||
| fa5e1887f4 | |||
| 1f17d44ee2 | |||
| 756187f8d5 | |||
| f4ec3ebf95 | |||
| 60e2dad05d | |||
| 7992cb3c7e | |||
| 0e5d31aefd | |||
| ac98c3e242 | |||
| 9cce26b08a | |||
| 0e56f33539 | |||
| 65b1b42a79 | |||
| 2a0788ee04 | |||
| 76415bdcc7 | |||
| 6d3bac8c19 | |||
| 4c65c520fd | |||
| 318a54425b |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,4 +1,7 @@
|
||||
node_modules/
|
||||
.next/
|
||||
out/
|
||||
*.tsbuildinfo
|
||||
data/*.db
|
||||
data/*.db-wal
|
||||
data/*.db-shm
|
||||
|
||||
23
Dockerfile
23
Dockerfile
@@ -1,15 +1,26 @@
|
||||
FROM node:20-bookworm-slim
|
||||
|
||||
FROM node:20-bookworm-slim AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install --omit=dev
|
||||
|
||||
FROM node:20-bookworm-slim AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-bookworm-slim AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=8395
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git ca-certificates 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
|
||||
|
||||
CMD ["npm", "start"]
|
||||
CMD ["node", "server.js"]
|
||||
|
||||
249
README.md
249
README.md
@@ -1,138 +1,159 @@
|
||||
# OpenClaw Agent Fleet Dashboard
|
||||
# Claw Fleet Console
|
||||
|
||||
A real-time task coordination board for the OpenClaw agent fleet.
|
||||
`openclaw-taskboard` is now a `Next.js + React + Tailwind + shadcn-style` dashboard for the deployed Claw fleet.
|
||||
|
||||
## Features
|
||||
It tracks and visualizes:
|
||||
|
||||
- **Kanban Board**: Backlog → Todo → In Progress → Review → Done
|
||||
- **Agent Assignment**: Assign tasks to specific OpenClaw agents
|
||||
- **Priority Levels**: High, Medium, Low
|
||||
- **Tags**: Categorize tasks with tags
|
||||
- **Wiki Auto-Generation**: Completed tasks generate wiki documentation
|
||||
- **Real-time Updates**: WebSocket-powered live updates
|
||||
- **REST API**: For agent heartbeat integration
|
||||
- OpenClaw swarm agents on `ubuntu`
|
||||
- ZeroClaw host runtimes on `grizzley` and `ice`
|
||||
- direct SSH host targets for `pve`, `truenas`, and `panda`
|
||||
- shared task assignment and dispatch across all families
|
||||
- wiki pages and architecture documentation rendered in the UI
|
||||
- dispatch audit history, failure queues, heartbeat overlays, and task templates
|
||||
|
||||
## Quick Start
|
||||
## Stack
|
||||
|
||||
- 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
|
||||
cd /home/bear/homelab/ubuntu/taskboard
|
||||
docker compose up -d --build
|
||||
curl -s http://127.0.0.1:8395/api/heartbeat/<agent>
|
||||
```
|
||||
|
||||
Access at: https://agentdash.local.tophermayor.com
|
||||
The heartbeat endpoint will:
|
||||
|
||||
## API Endpoints
|
||||
- sync OpenClaw swarm state before scheduling
|
||||
- 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
|
||||
|
||||
### Tasks
|
||||
This is the canonical path for agent-driven task pickup. Assignment alone does not start work; heartbeat pickup or an explicit dispatch does.
|
||||
|
||||
| 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:
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Check for assigned tasks
|
||||
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
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Example: Create Task via API
|
||||
## Production Build
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8395/api/tasks \
|
||||
-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"]
|
||||
}'
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
## Example: Complete Task with Wiki
|
||||
## Deployment Shape On Ubuntu
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8395/api/tasks/TASK_ID/complete \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"implementation_details": "Restarted the container using docker restart postgres-shared. Verified connections working.",
|
||||
"files_changed": ["/home/bear/homelab/ubuntu/postgres/docker-compose.yml"]
|
||||
}'
|
||||
```
|
||||
- app source checkout: `/srv/apps/openclaw-taskboard/current`
|
||||
- taskboard data: `/srv/state/openclaw-taskboard/data`
|
||||
- OpenClaw mounts:
|
||||
- `/home/bear/.openclaw/agents`
|
||||
- `/home/bear/.openclaw/openclaw.json`
|
||||
- `/home/bear/.openclaw/workspace/wiki`
|
||||
- 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
|
||||
|
||||
## Task Schema
|
||||
## Notes
|
||||
|
||||
```json
|
||||
{
|
||||
"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"
|
||||
}
|
||||
```
|
||||
- The UI intentionally treats OpenClaw, ZeroClaw, and direct host targets as separate families with different runtime and channel models.
|
||||
- `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.
|
||||
|
||||
## Directory Structure
|
||||
## Status Docs
|
||||
|
||||
```
|
||||
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
|
||||
- [Implementation status](./docs/IMPLEMENTATION_STATUS.md)
|
||||
- [Roadmap](./docs/ROADMAP.md)
|
||||
|
||||
271
app/agents/[slug]/page.tsx
Normal file
271
app/agents/[slug]/page.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
9
app/agents/page.tsx
Normal file
9
app/agents/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { AgentsClient } from "@/components/agents-client";
|
||||
import { listFleetAgents } from "@/lib/agents";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AgentsPage() {
|
||||
const agents = await listFleetAgents();
|
||||
return <AgentsClient agents={agents} />;
|
||||
}
|
||||
29
app/api/agents/[slug]/assign/route.ts
Normal file
29
app/api/agents/[slug]/assign/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
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 });
|
||||
}
|
||||
16
app/api/agents/[slug]/route.ts
Normal file
16
app/api/agents/[slug]/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
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);
|
||||
}
|
||||
7
app/api/agents/route.ts
Normal file
7
app/api/agents/route.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { listFleetAgents } from "@/lib/agents";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(await listFleetAgents());
|
||||
}
|
||||
7
app/api/architecture/route.ts
Normal file
7
app/api/architecture/route.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { listArchitecture } from "@/lib/agents";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(await listArchitecture());
|
||||
}
|
||||
7
app/api/dispatch-history/route.ts
Normal file
7
app/api/dispatch-history/route.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { listTaskEvents } from "@/lib/tasks";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(await listTaskEvents(undefined, 100));
|
||||
}
|
||||
19
app/api/heartbeat/[agent]/route.ts
Normal file
19
app/api/heartbeat/[agent]/route.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
7
app/api/sync/openclaw/route.ts
Normal file
7
app/api/sync/openclaw/route.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { syncOpenClawTasks } from "@/lib/openclaw-sync";
|
||||
|
||||
export async function POST() {
|
||||
return NextResponse.json(await syncOpenClawTasks());
|
||||
}
|
||||
26
app/api/tasks/[id]/ack/route.ts
Normal file
26
app/api/tasks/[id]/ack/route.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { updateTask } from "@/lib/tasks";
|
||||
|
||||
export async function POST(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
const numericId = Number(id);
|
||||
if (!Number.isInteger(numericId) || numericId <= 0) {
|
||||
return NextResponse.json({ error: "invalid_task_id" }, { status: 400 });
|
||||
}
|
||||
|
||||
const task = await updateTask(numericId, {
|
||||
dispatch_state: "acknowledged",
|
||||
acknowledged_at: new Date().toISOString(),
|
||||
status: "In Progress",
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
return NextResponse.json({ error: "task_not_found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json(task);
|
||||
}
|
||||
31
app/api/tasks/[id]/callback/route.ts
Normal file
31
app/api/tasks/[id]/callback/route.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
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);
|
||||
}
|
||||
21
app/api/tasks/[id]/dispatch/route.ts
Normal file
21
app/api/tasks/[id]/dispatch/route.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { dispatchTask } from "@/lib/dispatch";
|
||||
|
||||
export async function POST(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
const numericId = Number(id);
|
||||
if (!Number.isInteger(numericId) || numericId <= 0) {
|
||||
return NextResponse.json({ error: "invalid_task_id" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
return NextResponse.json(await dispatchTask(numericId));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return NextResponse.json({ error: "dispatch_failed", detail: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
16
app/api/tasks/[id]/events/route.ts
Normal file
16
app/api/tasks/[id]/events/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { listTaskEvents } from "@/lib/tasks";
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params;
|
||||
const numericId = Number(id);
|
||||
if (!Number.isInteger(numericId) || numericId <= 0) {
|
||||
return NextResponse.json({ error: "invalid_task_id" }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json(await listTaskEvents(numericId, 50));
|
||||
}
|
||||
91
app/api/tasks/[id]/route.ts
Normal file
91
app/api/tasks/[id]/route.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
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);
|
||||
}
|
||||
64
app/api/tasks/route.ts
Normal file
64
app/api/tasks/route.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
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 });
|
||||
}
|
||||
7
app/api/tasks/templates/route.ts
Normal file
7
app/api/tasks/templates/route.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { listTaskTemplates } from "@/lib/tasks";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(await listTaskTemplates());
|
||||
}
|
||||
37
app/api/usage/stats/route.ts
Normal file
37
app/api/usage/stats/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
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);
|
||||
}
|
||||
38
app/api/wiki/[filename]/route.ts
Normal file
38
app/api/wiki/[filename]/route.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
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 });
|
||||
}
|
||||
17
app/api/wiki/route.ts
Normal file
17
app/api/wiki/route.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
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 });
|
||||
}
|
||||
9
app/architecture/page.tsx
Normal file
9
app/architecture/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
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} />;
|
||||
}
|
||||
5
app/dispatch/page.tsx
Normal file
5
app/dispatch/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function DispatchPage() {
|
||||
redirect("/tasks/dispatch");
|
||||
}
|
||||
21
app/gitea/page.tsx
Normal file
21
app/gitea/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
38
app/globals.css
Normal file
38
app/globals.css
Normal file
@@ -0,0 +1,38 @@
|
||||
@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;
|
||||
}
|
||||
31
app/layout.tsx
Normal file
31
app/layout.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
9
app/openclaw/page.tsx
Normal file
9
app/openclaw/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { AgentsClient } from "@/components/agents-client";
|
||||
import { listFleetAgents } from "@/lib/agents";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function OpenClawPage() {
|
||||
const agents = await listFleetAgents();
|
||||
return <AgentsClient agents={agents} defaultFamily="openclaw" />;
|
||||
}
|
||||
5
app/page.tsx
Normal file
5
app/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function HomePage() {
|
||||
redirect("/tasks");
|
||||
}
|
||||
336
app/tasks/[id]/page.tsx
Normal file
336
app/tasks/[id]/page.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
9
app/tasks/dispatch/page.tsx
Normal file
9
app/tasks/dispatch/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
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} />;
|
||||
}
|
||||
9
app/tasks/failures/page.tsx
Normal file
9
app/tasks/failures/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
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} />;
|
||||
}
|
||||
14
app/tasks/layout.tsx
Normal file
14
app/tasks/layout.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { TasksSubnav } from "@/components/tasks-subnav";
|
||||
|
||||
export default function TasksLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<TasksSubnav />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
app/tasks/page.tsx
Normal file
14
app/tasks/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
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} />;
|
||||
}
|
||||
39
app/usage/page.tsx
Normal file
39
app/usage/page.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
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} />;
|
||||
}
|
||||
23
app/wiki/page.tsx
Normal file
23
app/wiki/page.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
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
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
9
app/zeroclaw/page.tsx
Normal file
9
app/zeroclaw/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { AgentsClient } from "@/components/agents-client";
|
||||
import { listFleetAgents } from "@/lib/agents";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ZeroClawPage() {
|
||||
const agents = await listFleetAgents();
|
||||
return <AgentsClient agents={agents} defaultFamily="zeroclaw" />;
|
||||
}
|
||||
16
components.json
Normal file
16
components.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$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"
|
||||
}
|
||||
}
|
||||
198
components/agents-client.tsx
Normal file
198
components/agents-client.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
75
components/app-shell.tsx
Normal file
75
components/app-shell.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
84
components/architecture-view.tsx
Normal file
84
components/architecture-view.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
44
components/dispatch-history.tsx
Normal file
44
components/dispatch-history.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
49
components/failure-queue.tsx
Normal file
49
components/failure-queue.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
245
components/task-intake-modal.tsx
Normal file
245
components/task-intake-modal.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
220
components/tasks-client.tsx
Normal file
220
components/tasks-client.tsx
Normal file
@@ -0,0 +1,220 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
38
components/tasks-subnav.tsx
Normal file
38
components/tasks-subnav.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
29
components/ui/badge.tsx
Normal file
29
components/ui/badge.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
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} />;
|
||||
}
|
||||
41
components/ui/button.tsx
Normal file
41
components/ui/button.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
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";
|
||||
46
components/ui/card.tsx
Normal file
46
components/ui/card.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
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} />;
|
||||
}
|
||||
17
components/ui/input.tsx
Normal file
17
components/ui/input.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
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";
|
||||
20
components/ui/select.tsx
Normal file
20
components/ui/select.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
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";
|
||||
18
components/ui/textarea.tsx
Normal file
18
components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
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";
|
||||
58
components/usage-view.tsx
Normal file
58
components/usage-view.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
60
components/wiki-view.tsx
Normal file
60
components/wiki-view.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
282
config/fleet.json
Normal file
282
config/fleet.json
Normal file
@@ -0,0 +1,282 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
98
config/task-templates.json
Normal file
98
config/task-templates.json
Normal file
@@ -0,0 +1,98 @@
|
||||
[
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,18 +1,26 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
openclaw-taskboard:
|
||||
taskboard:
|
||||
build: .
|
||||
container_name: openclaw-taskboard
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8395:8395"
|
||||
environment:
|
||||
- PORT=8395
|
||||
- DB_PATH=/app/data/tasks.db
|
||||
- WIKI_DIR=/app/wiki
|
||||
- AGENTS_DIR=/app/agents
|
||||
- OPENCLAW_CONFIG=/app/config/openclaw.json
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- /home/bear/.openclaw/workspace/wiki:/app/wiki
|
||||
- /home/bear/.openclaw/agents:/app/agents:ro
|
||||
- /home/bear/.openclaw/openclaw.json:/app/config/openclaw.json:ro
|
||||
- /home/bear/.clawdbot:/app/swarm:ro
|
||||
- /home/bear/.openclaw/openclaw.json:/app/openclaw.json:ro
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=8395
|
||||
- DB_PATH=/app/data/tasks.db
|
||||
- WIKI_DIR=/app/wiki
|
||||
- AGENTS_DIR=/app/agents
|
||||
- SESSIONS_DIR=/app/agents
|
||||
- SWARM_TASKS_FILE=/app/swarm/active-tasks.json
|
||||
- OPENCLAW_CONFIG=/app/openclaw.json
|
||||
- GITEA_URL=https://gitea.tophermayor.com
|
||||
- GITEA_TOKEN=${GITEA_TOKEN}
|
||||
restart: unless-stopped
|
||||
|
||||
55
docs/IMPLEMENTATION_STATUS.md
Normal file
55
docs/IMPLEMENTATION_STATUS.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# 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.
|
||||
46
docs/ROADMAP.md
Normal file
46
docs/ROADMAP.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# 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.
|
||||
110
gitea-append.js
Normal file
110
gitea-append.js
Normal file
@@ -0,0 +1,110 @@
|
||||
|
||||
// ============ 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');
|
||||
254
gitea-integration.js
Normal file
254
gitea-integration.js
Normal file
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* 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;
|
||||
231
gitea-routes.js
Normal file
231
gitea-routes.js
Normal file
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* 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 };
|
||||
413
lib/agents.ts
Normal file
413
lib/agents.ts
Normal file
@@ -0,0 +1,413 @@
|
||||
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
Normal file
177
lib/db.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
}
|
||||
724
lib/dispatch.ts
Normal file
724
lib/dispatch.ts
Normal file
@@ -0,0 +1,724 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
53
lib/fleet-config.ts
Normal file
53
lib/fleet-config.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
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,
|
||||
};
|
||||
166
lib/heartbeat.ts
Normal file
166
lib/heartbeat.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
117
lib/openclaw-sync.ts
Normal file
117
lib/openclaw-sync.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
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;
|
||||
}
|
||||
472
lib/tasks.ts
Normal file
472
lib/tasks.ts
Normal file
@@ -0,0 +1,472 @@
|
||||
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;
|
||||
}
|
||||
228
lib/types.ts
Normal file
228
lib/types.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
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[];
|
||||
};
|
||||
29
lib/utils.ts
Normal file
29
lib/utils.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
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, "");
|
||||
}
|
||||
130
lib/wiki.ts
Normal file
130
lib/wiki.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
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
Normal file
6
next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <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.
|
||||
9
next.config.ts
Normal file
9
next.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
typedRoutes: false,
|
||||
serverExternalPackages: ["sqlite3"],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
4884
package-lock.json
generated
Normal file
4884
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
package.json
30
package.json
@@ -1,14 +1,32 @@
|
||||
{
|
||||
"name": "openclaw-taskboard",
|
||||
"version": "1.0.0",
|
||||
"description": "OpenClaw agent fleet task tracking dashboard",
|
||||
"main": "server.js",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"description": "Next.js fleet dashboard for OpenClaw, ZeroClaw, and direct host operations",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.19.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"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",
|
||||
"ws": "^8.18.0"
|
||||
"tailwind-merge": "^3.3.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"
|
||||
}
|
||||
}
|
||||
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -809,7 +809,8 @@ function setupModalBackdropClose() {
|
||||
}
|
||||
|
||||
// ============ INITIALIZATION ============
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const CURRENT_PAGE = document.body?.dataset?.page || "tasks";
|
||||
initTheme();
|
||||
setupModalBackdropClose();
|
||||
|
||||
|
||||
@@ -4,11 +4,12 @@
|
||||
<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">
|
||||
<!-- Marked.js for markdown rendering -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<!-- Chart.js for usage charts -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
@@ -19,12 +20,11 @@
|
||||
<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>
|
||||
<button id="theme-toggle" class="btn-secondary" type="button" aria-label="Toggle theme">Dark Mode</button>
|
||||
<a href="#gitea" class="nav-link" data-page="gitea">Gitea</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- TASKS PAGE -->
|
||||
<section id="page-tasks" class="page active">
|
||||
<div class="composer">
|
||||
<h2>Create Task</h2>
|
||||
@@ -84,137 +84,71 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- WIKI PAGE -->
|
||||
<section id="page-wiki" class="page">
|
||||
<div class="wiki-container">
|
||||
<div class="wiki-sidebar">
|
||||
<div class="wiki-actions">
|
||||
<button id="wiki-new-btn" class="btn-primary">+ New Page</button>
|
||||
</div>
|
||||
<div class="wiki-search">
|
||||
<input type="text" id="wiki-search" placeholder="Search wiki...">
|
||||
</div>
|
||||
<div id="wiki-list" class="wiki-list"></div>
|
||||
</div>
|
||||
<div class="wiki-main">
|
||||
<div class="wiki-toolbar">
|
||||
<div class="wiki-page-title" id="wiki-page-title">Select a page</div>
|
||||
<div class="wiki-page-actions" id="wiki-page-actions" style="display: none;">
|
||||
<button id="wiki-edit-btn" class="btn-secondary">Edit</button>
|
||||
<button id="wiki-delete-btn" class="btn-danger">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="wiki-content" class="wiki-content">
|
||||
<div class="wiki-placeholder">
|
||||
<p>📚 Select a wiki page from the sidebar or create a new one.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="wiki-editor" class="wiki-editor" style="display: none;">
|
||||
<input type="text" id="wiki-edit-title" placeholder="Page title">
|
||||
<textarea id="wiki-edit-content" placeholder="Markdown content..."></textarea>
|
||||
<div class="editor-actions">
|
||||
<button id="wiki-save-btn" class="btn-primary">Save</button>
|
||||
<button id="wiki-cancel-btn" class="btn-secondary">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="wiki-list"></div>
|
||||
<div id="wiki-content"></div>
|
||||
</section>
|
||||
|
||||
<!-- AGENTS PAGE -->
|
||||
<section id="page-agents" class="page">
|
||||
<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>
|
||||
|
||||
<!-- Agent Details Modal -->
|
||||
<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">×</button>
|
||||
</div>
|
||||
<div class="modal-body" id="modal-agent-body"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assign Task Modal -->
|
||||
<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">×</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>
|
||||
<div id="agents-grid"></div>
|
||||
</section>
|
||||
|
||||
<!-- USAGE PAGE -->
|
||||
<section id="page-usage" class="page">
|
||||
<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 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="export-actions">
|
||||
<button id="export-json" class="btn-secondary">Export JSON</button>
|
||||
<button id="export-csv" class="btn-secondary">Export CSV</button>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<!-- 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>
|
||||
|
||||
<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>
|
||||
<!-- 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 id="usage-data" class="usage-details">
|
||||
<h3>Provider Details</h3>
|
||||
<div class="usage-grid" id="provider-grid"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
<script src="app.js"></script>
|
||||
|
||||
1908
public/styles.css
1908
public/styles.css
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
230
server.js
230
server.js
@@ -4,6 +4,7 @@ const fs = require('fs');
|
||||
const http = require('http');
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const { WebSocketServer } = require('ws');
|
||||
const { setupGiteaRoutes } = require('./gitea-routes.js');
|
||||
|
||||
const PORT = process.env.PORT || 8395;
|
||||
const DB_PATH = process.env.DB_PATH || path.join(__dirname, 'data', 'tasks.db');
|
||||
@@ -79,6 +80,7 @@ function renderPage(viewName, activeTab, pageTitle) {
|
||||
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>'
|
||||
: '',
|
||||
@@ -110,6 +112,10 @@ app.get('/usage', (req, res) => {
|
||||
res.send(renderPage('usage', 'usage', 'OpenClaw Agent Fleet Dashboard - Usage'));
|
||||
});
|
||||
|
||||
app.get('/gitea', (req, res) => {
|
||||
res.send(renderPage('gitea', 'gitea', 'OpenClaw Agent Fleet Dashboard - Gitea'));
|
||||
});
|
||||
|
||||
function normalizeTask(row) {
|
||||
return {
|
||||
...row,
|
||||
@@ -556,13 +562,48 @@ app.get('/api/agents', (req, res) => {
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const agentPromises = agentDirs.map(async (agentName) => {
|
||||
const agentPath = path.join(AGENTS_DIR, agentName);
|
||||
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 = {
|
||||
name: agentName,
|
||||
id: agentName,
|
||||
emoji,
|
||||
model,
|
||||
lastActivity,
|
||||
name: identityName,
|
||||
status: 'active',
|
||||
currentTask: null,
|
||||
tools: [],
|
||||
@@ -937,6 +978,191 @@ wss.on('connection', (socket) => {
|
||||
socket.send(JSON.stringify({ type: 'connected', payload: { ok: true } }));
|
||||
});
|
||||
|
||||
// Setup Gitea integration
|
||||
setupGiteaRoutes(app, renderPage);
|
||||
|
||||
server.listen(PORT, '0.0.0.0', () => {
|
||||
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' });
|
||||
}
|
||||
});
|
||||
|
||||
1165
server.js.broken
Normal file
1165
server.js.broken
Normal file
File diff suppressed because it is too large
Load Diff
56
tailwind.config.ts
Normal file
56
tailwind.config.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
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;
|
||||
41
tsconfig.json
Normal file
41
tsconfig.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
41
views/gitea.html
Normal file
41
views/gitea.html
Normal file
@@ -0,0 +1,41 @@
|
||||
<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>
|
||||
@@ -1,30 +1,92 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{pageTitle}}</title>
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
{{markedScript}}
|
||||
{{chartScript}}
|
||||
|
||||
<!-- 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}}">
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>🦞 OpenClaw Agent Fleet Dashboard</h1>
|
||||
<nav>
|
||||
<a href="/tasks" class="nav-link {{tasksActive}}">Tasks</a>
|
||||
<a href="/wiki" class="nav-link {{wikiActive}}">Wiki</a>
|
||||
<a href="/agents" class="nav-link {{agentsActive}}">Agents</a>
|
||||
<a href="/usage" class="nav-link {{usageActive}}">Usage</a>
|
||||
</nav>
|
||||
</header>
|
||||
<!-- Animated background particles -->
|
||||
<div class="bg-particles" aria-hidden="true"></div>
|
||||
|
||||
<main>
|
||||
{{content}}
|
||||
</main>
|
||||
<!-- 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>
|
||||
|
||||
33
views/layout.html.backup
Normal file
33
views/layout.html.backup
Normal file
@@ -0,0 +1,33 @@
|
||||
<!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>
|
||||
@@ -1,4 +1,31 @@
|
||||
<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">
|
||||
|
||||
Reference in New Issue
Block a user