Compare commits
50 Commits
feat/dark-
...
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 | |||
| 24f077cf25 | |||
| 4bc0555974 | |||
| add7f6bf6c | |||
| 969a690732 | |||
| e05dfef5ab |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,4 +1,7 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
*.tsbuildinfo
|
||||||
data/*.db
|
data/*.db
|
||||||
data/*.db-wal
|
data/*.db-wal
|
||||||
data/*.db-shm
|
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
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
COPY package.json ./
|
FROM node:20-bookworm-slim AS builder
|
||||||
RUN npm install --omit=dev
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:20-bookworm-slim AS runner
|
||||||
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=8395
|
ENV PORT=8395
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends git ca-certificates openssh-client \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
|
||||||
EXPOSE 8395
|
EXPOSE 8395
|
||||||
|
|
||||||
CMD ["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
|
- OpenClaw swarm agents on `ubuntu`
|
||||||
- **Agent Assignment**: Assign tasks to specific OpenClaw agents
|
- ZeroClaw host runtimes on `grizzley` and `ice`
|
||||||
- **Priority Levels**: High, Medium, Low
|
- direct SSH host targets for `pve`, `truenas`, and `panda`
|
||||||
- **Tags**: Categorize tasks with tags
|
- shared task assignment and dispatch across all families
|
||||||
- **Wiki Auto-Generation**: Completed tasks generate wiki documentation
|
- wiki pages and architecture documentation rendered in the UI
|
||||||
- **Real-time Updates**: WebSocket-powered live updates
|
- dispatch audit history, failure queues, heartbeat overlays, and task templates
|
||||||
- **REST API**: For agent heartbeat integration
|
|
||||||
|
|
||||||
## 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
|
```bash
|
||||||
cd /home/bear/homelab/ubuntu/taskboard
|
curl -s http://127.0.0.1:8395/api/heartbeat/<agent>
|
||||||
docker compose up -d --build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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 |
|
## Development
|
||||||
|--------|----------|-------------|
|
|
||||||
| GET | `/api/tasks` | List all tasks (filter: `?assignee=ubuntu&status=todo`) |
|
|
||||||
| GET | `/api/tasks/:id` | Get single task |
|
|
||||||
| POST | `/api/tasks` | Create task |
|
|
||||||
| PATCH | `/api/tasks/:id` | Update task |
|
|
||||||
| POST | `/api/tasks/:id/complete` | Complete task (creates wiki) |
|
|
||||||
| DELETE | `/api/tasks/:id` | Delete task |
|
|
||||||
|
|
||||||
### Wiki
|
|
||||||
|
|
||||||
| Method | Endpoint | Description |
|
|
||||||
|--------|----------|-------------|
|
|
||||||
| GET | `/api/wiki` | List wiki pages |
|
|
||||||
| GET | `/api/wiki/:filename` | Get wiki page content |
|
|
||||||
|
|
||||||
### Agent Heartbeat
|
|
||||||
|
|
||||||
| Method | Endpoint | Description |
|
|
||||||
|--------|----------|-------------|
|
|
||||||
| GET | `/api/heartbeat/:agent` | Get pending tasks for agent |
|
|
||||||
|
|
||||||
## Agent Integration
|
|
||||||
|
|
||||||
Add to agent's HEARTBEAT.md:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Check for assigned tasks
|
npm install
|
||||||
TASKS=$(curl -s http://192.168.50.61:8395/api/heartbeat/ubuntu)
|
npm run dev
|
||||||
|
|
||||||
# If tasks pending, process them
|
|
||||||
if echo "$TASKS" | jq -e '.pending_tasks > 0' > /dev/null; then
|
|
||||||
echo "Processing assigned tasks..."
|
|
||||||
# Process tasks...
|
|
||||||
fi
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Example: Create Task via API
|
## Production Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:8395/api/tasks \
|
npm run build
|
||||||
-H "Content-Type: application/json" \
|
npm start
|
||||||
-d '{
|
|
||||||
"title": "Restart PostgreSQL container",
|
|
||||||
"description": "The postgres-shared container needs a restart for config changes",
|
|
||||||
"assignee": "ubuntu",
|
|
||||||
"priority": "high",
|
|
||||||
"tags": ["docker", "database"]
|
|
||||||
}'
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Example: Complete Task with Wiki
|
## Deployment Shape On Ubuntu
|
||||||
|
|
||||||
```bash
|
- app source checkout: `/srv/apps/openclaw-taskboard/current`
|
||||||
curl -X POST http://localhost:8395/api/tasks/TASK_ID/complete \
|
- taskboard data: `/srv/state/openclaw-taskboard/data`
|
||||||
-H "Content-Type: application/json" \
|
- OpenClaw mounts:
|
||||||
-d '{
|
- `/home/bear/.openclaw/agents`
|
||||||
"implementation_details": "Restarted the container using docker restart postgres-shared. Verified connections working.",
|
- `/home/bear/.openclaw/openclaw.json`
|
||||||
"files_changed": ["/home/bear/homelab/ubuntu/postgres/docker-compose.yml"]
|
- `/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
|
- 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.
|
||||||
"id": "uuid",
|
- Direct targets are intentionally limited to safe built-in actions from `config/fleet.json`, not arbitrary shell commands from the browser.
|
||||||
"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"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Directory Structure
|
## Status Docs
|
||||||
|
|
||||||
```
|
- [Implementation status](./docs/IMPLEMENTATION_STATUS.md)
|
||||||
taskboard/
|
- [Roadmap](./docs/ROADMAP.md)
|
||||||
├── 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
|
|
||||||
|
|||||||
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:
|
services:
|
||||||
openclaw-taskboard:
|
taskboard:
|
||||||
build: .
|
build: .
|
||||||
container_name: openclaw-taskboard
|
container_name: openclaw-taskboard
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
ports:
|
||||||
- "8395:8395"
|
- "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:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- /home/bear/.openclaw/workspace/wiki:/app/wiki
|
- /home/bear/.openclaw/workspace/wiki:/app/wiki
|
||||||
- /home/bear/.openclaw/agents:/app/agents:ro
|
- /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",
|
"name": "openclaw-taskboard",
|
||||||
"version": "1.0.0",
|
"version": "2.0.0",
|
||||||
"description": "OpenClaw agent fleet task tracking dashboard",
|
"private": true,
|
||||||
"main": "server.js",
|
"description": "Next.js fleet dashboard for OpenClaw, ZeroClaw, and direct host operations",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js"
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"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: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
955
public/app.js
955
public/app.js
File diff suppressed because it is too large
Load Diff
279
public/app.js.backup
Normal file
279
public/app.js.backup
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
// Navigation
|
||||||
|
const navLinks = document.querySelectorAll('.nav-link');
|
||||||
|
const pages = document.querySelectorAll('.page');
|
||||||
|
|
||||||
|
navLinks.forEach(link => {
|
||||||
|
link.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const targetPage = link.dataset.page;
|
||||||
|
|
||||||
|
// Update active nav link
|
||||||
|
navLinks.forEach(l => l.classList.remove('active'));
|
||||||
|
link.classList.add('active');
|
||||||
|
|
||||||
|
// Show target page
|
||||||
|
pages.forEach(page => {
|
||||||
|
page.classList.remove('active');
|
||||||
|
if (page.id === `page-${targetPage}`) {
|
||||||
|
page.classList.add('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load page data
|
||||||
|
if (targetPage === 'wiki') loadWiki();
|
||||||
|
if (targetPage === 'agents') loadAgents();
|
||||||
|
if (targetPage === 'usage') loadUsage();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Task Dashboard
|
||||||
|
const COLUMNS = {
|
||||||
|
'Backlog': { title: '📋 Backlog', tasks: [] },
|
||||||
|
'Todo': { title: '📝 Todo', tasks: [] },
|
||||||
|
'In Progress': { title: '🔄 In Progress', tasks: [] },
|
||||||
|
'Review': { title: '👀 Review', tasks: [] },
|
||||||
|
'Done': { title: '✅ Done', tasks: [] }
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadTasks() {
|
||||||
|
const res = await fetch('/api/tasks');
|
||||||
|
const tasks = await res.json();
|
||||||
|
|
||||||
|
// Reset columns
|
||||||
|
Object.keys(COLUMNS).forEach(status => {
|
||||||
|
COLUMNS[status].tasks = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Group tasks by status
|
||||||
|
tasks.forEach(task => {
|
||||||
|
if (COLUMNS[task.status]) {
|
||||||
|
COLUMNS[task.status].tasks.push(task);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
renderBoard();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBoard() {
|
||||||
|
const board = document.getElementById('board');
|
||||||
|
board.innerHTML = '';
|
||||||
|
|
||||||
|
Object.entries(COLUMNS).forEach(([status, column]) => {
|
||||||
|
const columnEl = document.createElement('div');
|
||||||
|
columnEl.className = 'column';
|
||||||
|
|
||||||
|
columnEl.innerHTML = `
|
||||||
|
<div class="column-header">
|
||||||
|
<h3>${column.title}</h3>
|
||||||
|
<span class="column-count">${column.tasks.length}</span>
|
||||||
|
</div>
|
||||||
|
<div class="cards"></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const cardsEl = columnEl.querySelector('.cards');
|
||||||
|
|
||||||
|
column.tasks.forEach(task => {
|
||||||
|
const cardEl = document.createElement('div');
|
||||||
|
cardEl.className = 'card';
|
||||||
|
cardEl.innerHTML = `
|
||||||
|
<div class="card-head">
|
||||||
|
<h3 class="card-title">${escapeHtml(task.title)}</h3>
|
||||||
|
<span class="badge priority-${task.priority}">${task.priority}</span>
|
||||||
|
</div>
|
||||||
|
<p class="card-desc">${escapeHtml(task.description || '')}</p>
|
||||||
|
<p class="meta assignee">${task.assignee || 'Unassigned'}</p>
|
||||||
|
<p class="meta tags">${task.tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join(' ')}</p>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" class="card-check" ${task.status === 'Done' ? 'checked' : ''} />
|
||||||
|
Mark Complete
|
||||||
|
</label>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Checkbox handler
|
||||||
|
const checkbox = cardEl.querySelector('.card-check');
|
||||||
|
checkbox.addEventListener('change', async () => {
|
||||||
|
await fetch(`/api/tasks/${task.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ status: 'Done' })
|
||||||
|
});
|
||||||
|
loadTasks();
|
||||||
|
});
|
||||||
|
|
||||||
|
cardsEl.appendChild(cardEl);
|
||||||
|
});
|
||||||
|
|
||||||
|
board.appendChild(columnEl);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task form
|
||||||
|
document.getElementById('task-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const formData = new FormData(e.target);
|
||||||
|
const task = {
|
||||||
|
title: formData.get('title'),
|
||||||
|
description: formData.get('description'),
|
||||||
|
assignee: formData.get('assignee'),
|
||||||
|
priority: formData.get('priority'),
|
||||||
|
status: formData.get('status'),
|
||||||
|
tags: formData.get('tags').split(',').map(t => t.trim()).filter(t => t)
|
||||||
|
};
|
||||||
|
|
||||||
|
await fetch('/api/tasks', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(task)
|
||||||
|
});
|
||||||
|
|
||||||
|
e.target.reset();
|
||||||
|
loadTasks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Populate agent dropdown
|
||||||
|
async function populateAgentDropdown() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/agents');
|
||||||
|
const agents = await res.json();
|
||||||
|
|
||||||
|
const select = document.getElementById('assignee');
|
||||||
|
if (!select) return;
|
||||||
|
|
||||||
|
// Keep the first option ("Select agent...")
|
||||||
|
const firstOption = select.options[0];
|
||||||
|
select.innerHTML = '';
|
||||||
|
select.appendChild(firstOption);
|
||||||
|
|
||||||
|
// Add agent options
|
||||||
|
agents.forEach(agent => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = agent.name;
|
||||||
|
option.textContent = agent.name;
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load agents for dropdown:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate dropdown on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
populateAgentDropdown();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wiki
|
||||||
|
async function loadWiki() {
|
||||||
|
const res = await fetch('/api/wiki');
|
||||||
|
const pages = await res.json();
|
||||||
|
|
||||||
|
const wikiList = document.getElementById('wiki-list');
|
||||||
|
wikiList.innerHTML = '';
|
||||||
|
|
||||||
|
pages.forEach(page => {
|
||||||
|
const itemEl = document.createElement('div');
|
||||||
|
itemEl.className = 'wiki-item';
|
||||||
|
itemEl.innerHTML = `
|
||||||
|
<h4 class="wiki-title">${escapeHtml(page.filename.replace('.md', ''))}</h4>
|
||||||
|
<p class="wiki-date">${new Date(page.created).toLocaleDateString()}</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
itemEl.addEventListener('click', async () => {
|
||||||
|
// Mark active
|
||||||
|
wikiList.querySelectorAll('.wiki-item').forEach(i => i.classList.remove('active'));
|
||||||
|
itemEl.classList.add('active');
|
||||||
|
|
||||||
|
// Load content
|
||||||
|
const contentRes = await fetch(`/api/wiki/${page.filename}`);
|
||||||
|
const contentData = await contentRes.json();
|
||||||
|
|
||||||
|
const wikiContent = document.getElementById('wiki-content');
|
||||||
|
wikiContent.innerHTML = `<pre>${escapeHtml(contentData.content)}</pre>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
wikiList.appendChild(itemEl);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agents
|
||||||
|
async function loadAgents() {
|
||||||
|
const res = await fetch('/api/agents');
|
||||||
|
const agents = await res.json();
|
||||||
|
|
||||||
|
const grid = document.getElementById('agents-grid');
|
||||||
|
grid.innerHTML = '';
|
||||||
|
|
||||||
|
agents.forEach(agent => {
|
||||||
|
const cardEl = document.createElement('div');
|
||||||
|
cardEl.className = 'agent-card';
|
||||||
|
|
||||||
|
cardEl.innerHTML = `
|
||||||
|
<div class="agent-header">
|
||||||
|
<h3 class="agent-name">${escapeHtml(agent.name)}</h3>
|
||||||
|
<span class="agent-status">${agent.status}</span>
|
||||||
|
</div>
|
||||||
|
<div class="agent-body">
|
||||||
|
<div class="agent-section">
|
||||||
|
<h4>📋 Current Task</h4>
|
||||||
|
<p class="agent-task">${agent.currentTask || 'No active task'}</p>
|
||||||
|
</div>
|
||||||
|
<div class="agent-section">
|
||||||
|
<h4>🛠️ Tools</h4>
|
||||||
|
<div class="agent-tools">
|
||||||
|
${agent.tools.map(tool => `<span class="tool-tag">${escapeHtml(tool)}</span>`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="agent-section">
|
||||||
|
<h4>📄 Workspace Files</h4>
|
||||||
|
<div class="agent-files">
|
||||||
|
${agent.files.map(file => `<span class="file-tag">${escapeHtml(file)}</span>`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
grid.appendChild(cardEl);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage
|
||||||
|
async function loadUsage() {
|
||||||
|
const res = await fetch('/api/usage');
|
||||||
|
const usage = await res.json();
|
||||||
|
|
||||||
|
const usageData = document.getElementById('usage-data');
|
||||||
|
usageData.innerHTML = `
|
||||||
|
<h3>📊 Provider Usage</h3>
|
||||||
|
<div class="usage-grid">
|
||||||
|
${usage.providers.map(provider => `
|
||||||
|
<div class="provider-card">
|
||||||
|
<h4>${escapeHtml(provider.name)}</h4>
|
||||||
|
<div class="model-list">
|
||||||
|
${provider.models.map(model => `
|
||||||
|
<div class="model-item">
|
||||||
|
<span class="model-name">${escapeHtml(model.name)}</span>
|
||||||
|
<span class="model-type">${escapeHtml(model.type)}</span>
|
||||||
|
<span class="model-context">${escapeHtml(model.contextWindow)}</span>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to escape HTML
|
||||||
|
function escapeHtml(text) {
|
||||||
|
const map = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": '''
|
||||||
|
};
|
||||||
|
return text.replace(/[&<>"']/g, m => map[m]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
loadTasks();
|
||||||
@@ -1,147 +1,156 @@
|
|||||||
<!doctype html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>OpenClaw Agent Fleet Dashboard</title>
|
<title>OpenClaw Agent Fleet Dashboard</title>
|
||||||
<link rel="stylesheet" href="/styles.css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<nav class="navbar">
|
|
||||||
<div class="nav-brand">
|
|
||||||
<h1>🦞 OpenClaw Fleet Dashboard</h1>
|
|
||||||
</div>
|
|
||||||
<div class="nav-links">
|
|
||||||
<a href="#" class="nav-link active" data-page="dashboard">📋 Tasks</a>
|
|
||||||
<a href="#" class="nav-link" data-page="wiki">📚 Wiki</a>
|
|
||||||
<a href="#" class="nav-link" data-page="agents">🤖 Agents</a>
|
|
||||||
<a href="#" class="nav-link" data-page="usage">📊 Usage</a>
|
|
||||||
<button id="theme-toggle" class="theme-toggle" aria-label="Toggle theme">🌙</button>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- Dashboard Page -->
|
<!-- Distinctive Google Fonts -->
|
||||||
<div id="page-dashboard" class="page active">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<header class="topbar">
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<h2>Task Dashboard</h2>
|
<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">
|
||||||
<p>Real-time task coordination board</p>
|
<link rel="stylesheet" href="styles.css">
|
||||||
</header>
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>🦞 OpenClaw Agent Fleet Dashboard</h1>
|
||||||
|
<nav>
|
||||||
|
<a href="#tasks" class="nav-link active" data-page="tasks">Tasks</a>
|
||||||
|
<a href="#wiki" class="nav-link" data-page="wiki">Wiki</a>
|
||||||
|
<a href="#agents" class="nav-link" data-page="agents">Agents</a>
|
||||||
|
<a href="#usage" class="nav-link" data-page="usage">Usage</a>
|
||||||
|
<a href="#gitea" class="nav-link" data-page="gitea">Gitea</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
<section class="composer">
|
<main>
|
||||||
<h3>Create Task</h3>
|
<section id="page-tasks" class="page active">
|
||||||
<form id="task-form">
|
<div class="composer">
|
||||||
<input id="title" name="title" placeholder="Task title" required />
|
<h2>Create Task</h2>
|
||||||
<select id="assignee" name="assignee">
|
<form id="task-form">
|
||||||
<option value="">Select agent...</option>
|
<input id="title" name="title" placeholder="Task title" required />
|
||||||
</select>
|
<select id="assignee" name="assignee">
|
||||||
<select id="priority" name="priority">
|
<option value="">Select agent...</option>
|
||||||
<option>Low</option>
|
</select>
|
||||||
<option selected>Medium</option>
|
<select id="priority" name="priority">
|
||||||
<option>High</option>
|
<option>Low</option>
|
||||||
<option>Critical</option>
|
<option selected>Medium</option>
|
||||||
</select>
|
<option>High</option>
|
||||||
<select id="status" name="status">
|
<option>Critical</option>
|
||||||
<option selected>Backlog</option>
|
</select>
|
||||||
<option>Todo</option>
|
<textarea id="description" name="description" placeholder="Task description" rows="3"></textarea>
|
||||||
<option>In Progress</option>
|
<input id="tags" name="tags" placeholder="Tags (comma-separated)" />
|
||||||
<option>Review</option>
|
<button type="submit">Create Task</button>
|
||||||
<option>Done</option>
|
</form>
|
||||||
</select>
|
</div>
|
||||||
<input id="tags" name="tags" placeholder="tags, comma, separated" />
|
|
||||||
<textarea id="description" name="description" placeholder="Description"></textarea>
|
|
||||||
<button type="submit">Add Task</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<main id="board" class="board"></main>
|
<div id="board">
|
||||||
|
<div class="column" data-status="Backlog">
|
||||||
|
<div class="column-header">
|
||||||
|
<h3>📋 Backlog</h3>
|
||||||
|
<span class="column-count">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="cards"></div>
|
||||||
|
</div>
|
||||||
|
<div class="column" data-status="Todo">
|
||||||
|
<div class="column-header">
|
||||||
|
<h3>📝 Todo</h3>
|
||||||
|
<span class="column-count">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="cards"></div>
|
||||||
|
</div>
|
||||||
|
<div class="column" data-status="In Progress">
|
||||||
|
<div class="column-header">
|
||||||
|
<h3>🔄 In Progress</h3>
|
||||||
|
<span class="column-count">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="cards"></div>
|
||||||
|
</div>
|
||||||
|
<div class="column" data-status="Review">
|
||||||
|
<div class="column-header">
|
||||||
|
<h3>👀 Review</h3>
|
||||||
|
<span class="column-count">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="cards"></div>
|
||||||
|
</div>
|
||||||
|
<div class="column" data-status="Done">
|
||||||
|
<div class="column-header">
|
||||||
|
<h3>✅ Done</h3>
|
||||||
|
<span class="column-count">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="cards"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="page-wiki" class="page">
|
||||||
|
<div id="wiki-list"></div>
|
||||||
|
<div id="wiki-content"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="page-agents" class="page">
|
||||||
|
<div id="agents-grid"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="page-usage" class="page">
|
||||||
|
<div id="usage-data"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="page-gitea" class="page">
|
||||||
|
<h2>🔧 Gitea Integration</h2>
|
||||||
|
<div class="gitea-dashboard">
|
||||||
|
<div class="gitea-tabs">
|
||||||
|
<button class="tab-btn active" data-tab="swarm">Swarm Overview</button>
|
||||||
|
<button class="tab-btn" data-tab="reviews">Pending Reviews</button>
|
||||||
|
<button class="tab-btn" data-tab="activity">Recent Activity</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="gitea-content">
|
||||||
|
<!-- Swarm Overview Tab -->
|
||||||
|
<div id="swarm-tab" class="tab-content active">
|
||||||
|
<div class="swarm-stats">
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3>Total Repos</h3>
|
||||||
|
<div class="stat-value" id="total-repos">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3>Open PRs</h3>
|
||||||
|
<div class="stat-value" id="total-prs">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3>Open Issues</h3>
|
||||||
|
<div class="stat-value" id="total-issues">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3>Total Branches</h3>
|
||||||
|
<div class="stat-value" id="total-branches">-</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="repo-list" id="repo-list">
|
||||||
|
<p class="loading">Loading repositories...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pending Reviews Tab -->
|
||||||
|
<div id="reviews-tab" class="tab-content">
|
||||||
|
<div class="reviews-list" id="reviews-list">
|
||||||
|
<p class="loading">Loading pending reviews...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Activity Tab -->
|
||||||
|
<div id="activity-tab" class="tab-content">
|
||||||
|
<div class="activity-feed" id="activity-feed">
|
||||||
|
<p class="loading">Loading recent activity...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
<script src="app.js"></script>
|
||||||
<!-- Wiki Page -->
|
</body>
|
||||||
<div id="page-wiki" class="page">
|
|
||||||
<header class="topbar">
|
|
||||||
<h2>📚 Wiki</h2>
|
|
||||||
<p>Task documentation and implementation details</p>
|
|
||||||
</header>
|
|
||||||
<div class="wiki-container">
|
|
||||||
<div class="wiki-list" id="wiki-list"></div>
|
|
||||||
<div class="wiki-content" id="wiki-content">
|
|
||||||
<p>Select a wiki page to view documentation</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Agents Page -->
|
|
||||||
<div id="page-agents" class="page">
|
|
||||||
<header class="topbar">
|
|
||||||
<h2>🤖 Agents</h2>
|
|
||||||
<p>Fleet agent workspace and configuration</p>
|
|
||||||
</header>
|
|
||||||
<div class="agents-grid" id="agents-grid"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Usage Page -->
|
|
||||||
<div id="page-usage" class="page">
|
|
||||||
<header class="topbar">
|
|
||||||
<h2>📊 Usage & Quotas</h2>
|
|
||||||
<p>Provider models, quotas, and limits</p>
|
|
||||||
</header>
|
|
||||||
<div class="usage-container" id="usage-container"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template id="task-template">
|
|
||||||
<article class="card">
|
|
||||||
<div class="card-head">
|
|
||||||
<h3 class="card-title"></h3>
|
|
||||||
<span class="badge priority"></span>
|
|
||||||
</div>
|
|
||||||
<p class="card-desc"></p>
|
|
||||||
<p class="meta assignee"></p>
|
|
||||||
<p class="meta tags"></p>
|
|
||||||
<label>
|
|
||||||
<input type="checkbox" class="card-check" />
|
|
||||||
Mark Complete
|
|
||||||
</label>
|
|
||||||
</article>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template id="wiki-item-template">
|
|
||||||
<div class="wiki-item">
|
|
||||||
<h4 class="wiki-title"></h4>
|
|
||||||
<p class="wiki-date"></p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template id="agent-card-template">
|
|
||||||
<div class="agent-card">
|
|
||||||
<div class="agent-header">
|
|
||||||
<h3 class="agent-name"></h3>
|
|
||||||
<span class="agent-status"></span>
|
|
||||||
</div>
|
|
||||||
<div class="agent-body">
|
|
||||||
<div class="agent-section">
|
|
||||||
<h4>📋 Current Task</h4>
|
|
||||||
<p class="agent-task"></p>
|
|
||||||
</div>
|
|
||||||
<div class="agent-section">
|
|
||||||
<h4>🛠️ Tools</h4>
|
|
||||||
<div class="agent-tools"></div>
|
|
||||||
</div>
|
|
||||||
<div class="agent-section">
|
|
||||||
<h4>📄 Workspace Files</h4>
|
|
||||||
<div class="agent-files"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template id="usage-card-template">
|
|
||||||
<div class="usage-card">
|
|
||||||
<h3 class="provider-name"></h3>
|
|
||||||
<div class="provider-models"></div>
|
|
||||||
<div class="provider-quota"></div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script src="/app.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
97
public/index.html.backup
Normal file
97
public/index.html.backup
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>OpenClaw Agent Fleet Dashboard</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>🦞 OpenClaw Agent Fleet Dashboard</h1>
|
||||||
|
<nav>
|
||||||
|
<a href="#tasks" class="nav-link active" data-page="tasks">Tasks</a>
|
||||||
|
<a href="#wiki" class="nav-link" data-page="wiki">Wiki</a>
|
||||||
|
<a href="#agents" class="nav-link" data-page="agents">Agents</a>
|
||||||
|
<a href="#usage" class="nav-link" data-page="usage">Usage</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section id="page-tasks" class="page active">
|
||||||
|
<div class="composer">
|
||||||
|
<h2>Create Task</h2>
|
||||||
|
<form id="task-form">
|
||||||
|
<input id="title" name="title" placeholder="Task title" required />
|
||||||
|
<select id="assignee" name="assignee">
|
||||||
|
<option value="">Select agent...</option>
|
||||||
|
</select>
|
||||||
|
<select id="priority" name="priority">
|
||||||
|
<option>Low</option>
|
||||||
|
<option selected>Medium</option>
|
||||||
|
<option>High</option>
|
||||||
|
<option>Critical</option>
|
||||||
|
</select>
|
||||||
|
<textarea id="description" name="description" placeholder="Task description" rows="3"></textarea>
|
||||||
|
<input id="tags" name="tags" placeholder="Tags (comma-separated)" />
|
||||||
|
<button type="submit">Create Task</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="board">
|
||||||
|
<div class="column" data-status="Backlog">
|
||||||
|
<div class="column-header">
|
||||||
|
<h3>📋 Backlog</h3>
|
||||||
|
<span class="column-count">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="cards"></div>
|
||||||
|
</div>
|
||||||
|
<div class="column" data-status="Todo">
|
||||||
|
<div class="column-header">
|
||||||
|
<h3>📝 Todo</h3>
|
||||||
|
<span class="column-count">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="cards"></div>
|
||||||
|
</div>
|
||||||
|
<div class="column" data-status="In Progress">
|
||||||
|
<div class="column-header">
|
||||||
|
<h3>🔄 In Progress</h3>
|
||||||
|
<span class="column-count">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="cards"></div>
|
||||||
|
</div>
|
||||||
|
<div class="column" data-status="Review">
|
||||||
|
<div class="column-header">
|
||||||
|
<h3>👀 Review</h3>
|
||||||
|
<span class="column-count">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="cards"></div>
|
||||||
|
</div>
|
||||||
|
<div class="column" data-status="Done">
|
||||||
|
<div class="column-header">
|
||||||
|
<h3>✅ Done</h3>
|
||||||
|
<span class="column-count">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="cards"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="page-wiki" class="page">
|
||||||
|
<div id="wiki-list"></div>
|
||||||
|
<div id="wiki-content"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="page-agents" class="page">
|
||||||
|
<div id="agents-grid"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="page-usage" class="page">
|
||||||
|
<div id="usage-data"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1419
public/styles.css
1419
public/styles.css
File diff suppressed because it is too large
Load Diff
1352
public/styles.css.backup
Normal file
1352
public/styles.css.backup
Normal file
File diff suppressed because it is too large
Load Diff
822
server.js
822
server.js
@@ -4,6 +4,7 @@ const fs = require('fs');
|
|||||||
const http = require('http');
|
const http = require('http');
|
||||||
const sqlite3 = require('sqlite3').verbose();
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
const { WebSocketServer } = require('ws');
|
const { WebSocketServer } = require('ws');
|
||||||
|
const { setupGiteaRoutes } = require('./gitea-routes.js');
|
||||||
|
|
||||||
const PORT = process.env.PORT || 8395;
|
const PORT = process.env.PORT || 8395;
|
||||||
const DB_PATH = process.env.DB_PATH || path.join(__dirname, 'data', 'tasks.db');
|
const DB_PATH = process.env.DB_PATH || path.join(__dirname, 'data', 'tasks.db');
|
||||||
@@ -34,14 +35,86 @@ db.serialize(() => {
|
|||||||
completed_at TEXT
|
completed_at TEXT
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Usage tracking table
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS usage_tracking (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
agent TEXT NOT NULL,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
model TEXT NOT NULL,
|
||||||
|
request_type TEXT DEFAULT 'chat',
|
||||||
|
tokens_used INTEGER DEFAULT 0,
|
||||||
|
cost_estimate REAL DEFAULT 0,
|
||||||
|
timestamp TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)
|
||||||
|
`);
|
||||||
});
|
});
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const server = http.createServer(app);
|
const server = http.createServer(app);
|
||||||
const wss = new WebSocketServer({ server });
|
const wss = new WebSocketServer({ server });
|
||||||
|
const VIEWS_DIR = path.join(__dirname, 'views');
|
||||||
|
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.static(path.join(__dirname, 'public')));
|
app.use(express.static(path.join(__dirname, 'public'), { index: false }));
|
||||||
|
|
||||||
|
function renderTemplate(template, vars = {}) {
|
||||||
|
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
|
||||||
|
const value = vars[key];
|
||||||
|
return value === undefined || value === null ? '' : String(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPage(viewName, activeTab, pageTitle) {
|
||||||
|
const layoutPath = path.join(VIEWS_DIR, 'layout.html');
|
||||||
|
const viewPath = path.join(VIEWS_DIR, `${viewName}.html`);
|
||||||
|
const layout = fs.readFileSync(layoutPath, 'utf8');
|
||||||
|
const content = fs.readFileSync(viewPath, 'utf8');
|
||||||
|
|
||||||
|
return renderTemplate(layout, {
|
||||||
|
pageTitle,
|
||||||
|
pageName: viewName,
|
||||||
|
content,
|
||||||
|
tasksActive: activeTab === 'tasks' ? 'active' : '',
|
||||||
|
wikiActive: activeTab === 'wiki' ? 'active' : '',
|
||||||
|
agentsActive: activeTab === 'agents' ? 'active' : '',
|
||||||
|
usageActive: activeTab === 'usage' ? 'active' : '',
|
||||||
|
giteaActive: activeTab === 'gitea' ? 'active' : '',
|
||||||
|
markedScript: viewName === 'wiki'
|
||||||
|
? '<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>'
|
||||||
|
: '',
|
||||||
|
chartScript: viewName === 'usage'
|
||||||
|
? '<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>'
|
||||||
|
: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ SERVER-RENDERED PAGES ============
|
||||||
|
|
||||||
|
app.get('/', (req, res) => {
|
||||||
|
res.redirect('/tasks');
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/tasks', (req, res) => {
|
||||||
|
res.send(renderPage('tasks', 'tasks', 'OpenClaw Agent Fleet Dashboard - Tasks'));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/wiki', (req, res) => {
|
||||||
|
res.send(renderPage('wiki', 'wiki', 'OpenClaw Agent Fleet Dashboard - Wiki'));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/agents', (req, res) => {
|
||||||
|
res.send(renderPage('agents', 'agents', 'OpenClaw Agent Fleet Dashboard - Agents'));
|
||||||
|
});
|
||||||
|
|
||||||
|
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) {
|
function normalizeTask(row) {
|
||||||
return {
|
return {
|
||||||
@@ -112,6 +185,8 @@ function validatePayload(body, partial = false) {
|
|||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ TASKS API ============
|
||||||
|
|
||||||
app.get('/api/tasks', (req, res) => {
|
app.get('/api/tasks', (req, res) => {
|
||||||
db.all('SELECT * FROM tasks ORDER BY id DESC', (err, rows) => {
|
db.all('SELECT * FROM tasks ORDER BY id DESC', (err, rows) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
@@ -238,6 +313,207 @@ app.patch('/api/tasks/:id', (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============ WIKI API ============
|
||||||
|
|
||||||
|
// Helper to extract frontmatter metadata from markdown
|
||||||
|
function extractMetadata(content) {
|
||||||
|
const metadata = {
|
||||||
|
title: '',
|
||||||
|
created: null,
|
||||||
|
modified: null,
|
||||||
|
tags: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check for YAML-like frontmatter
|
||||||
|
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
||||||
|
if (frontmatterMatch) {
|
||||||
|
const frontmatter = frontmatterMatch[1];
|
||||||
|
const titleMatch = frontmatter.match(/title:\s*(.+)/i);
|
||||||
|
const createdMatch = frontmatter.match(/created:\s*(.+)/i);
|
||||||
|
const modifiedMatch = frontmatter.match(/modified:\s*(.+)/i);
|
||||||
|
const tagsMatch = frontmatter.match(/tags:\s*\[(.+)\]/i);
|
||||||
|
|
||||||
|
if (titleMatch) metadata.title = titleMatch[1].trim();
|
||||||
|
if (createdMatch) metadata.created = createdMatch[1].trim();
|
||||||
|
if (modifiedMatch) metadata.modified = modifiedMatch[1].trim();
|
||||||
|
if (tagsMatch) metadata.tags = tagsMatch[1].split(',').map(t => t.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract title from first heading if not in frontmatter
|
||||||
|
if (!metadata.title) {
|
||||||
|
const headingMatch = content.match(/^#\s+(.+)$/m);
|
||||||
|
if (headingMatch) {
|
||||||
|
metadata.title = headingMatch[1].trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/wiki - List all wiki pages
|
||||||
|
app.get('/api/wiki', (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(WIKI_DIR)) {
|
||||||
|
fs.mkdirSync(WIKI_DIR, { recursive: true });
|
||||||
|
return res.json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = fs.readdirSync(WIKI_DIR)
|
||||||
|
.filter(f => f.endsWith('.md'))
|
||||||
|
.map(filename => {
|
||||||
|
const filePath = path.join(WIKI_DIR, filename);
|
||||||
|
const stats = fs.statSync(filePath);
|
||||||
|
const content = fs.readFileSync(filePath, 'utf8');
|
||||||
|
const metadata = extractMetadata(content);
|
||||||
|
|
||||||
|
return {
|
||||||
|
filename,
|
||||||
|
title: metadata.title || filename.replace('.md', '').replace(/-/g, ' '),
|
||||||
|
created: stats.birthtime.toISOString(),
|
||||||
|
modified: stats.mtime.toISOString(),
|
||||||
|
tags: metadata.tags
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => new Date(b.modified) - new Date(a.modified));
|
||||||
|
|
||||||
|
res.json(files);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error listing wiki pages:', err);
|
||||||
|
res.status(500).json({ error: 'failed_to_list_wiki_pages' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/wiki/:filename - Get specific wiki page content
|
||||||
|
app.get('/api/wiki/:filename', (req, res) => {
|
||||||
|
try {
|
||||||
|
const filename = req.params.filename;
|
||||||
|
// Security: prevent path traversal
|
||||||
|
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
||||||
|
return res.status(400).json({ error: 'invalid_filename' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(WIKI_DIR, filename);
|
||||||
|
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
return res.status(404).json({ error: 'wiki_page_not_found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = fs.readFileSync(filePath, 'utf8');
|
||||||
|
const stats = fs.statSync(filePath);
|
||||||
|
const metadata = extractMetadata(content);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
filename,
|
||||||
|
content,
|
||||||
|
metadata: {
|
||||||
|
...metadata,
|
||||||
|
created: stats.birthtime.toISOString(),
|
||||||
|
modified: stats.mtime.toISOString()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error reading wiki page:', err);
|
||||||
|
res.status(500).json({ error: 'failed_to_read_wiki_page' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/wiki - Create new wiki page
|
||||||
|
app.post('/api/wiki', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { title, content } = req.body;
|
||||||
|
|
||||||
|
if (!title || typeof title !== 'string' || title.trim().length === 0) {
|
||||||
|
return res.status(400).json({ error: 'title_is_required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeTitle = title
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.slice(0, 80);
|
||||||
|
|
||||||
|
const timestamp = new Date().toISOString().slice(0, 10);
|
||||||
|
let filename = `${timestamp}-${safeTitle}.md`;
|
||||||
|
|
||||||
|
// Ensure unique filename
|
||||||
|
let counter = 1;
|
||||||
|
while (fs.existsSync(path.join(WIKI_DIR, filename))) {
|
||||||
|
filename = `${timestamp}-${safeTitle}-${counter}.md`;
|
||||||
|
counter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(WIKI_DIR, filename);
|
||||||
|
const pageContent = content || `# ${title}\n\n## Description\n\nEnter description here.\n\n## Implementation Status\n\n- [ ] Not started\n\n## Technical Details\n\nAdd technical notes here.\n`;
|
||||||
|
|
||||||
|
fs.writeFileSync(filePath, pageContent, 'utf8');
|
||||||
|
|
||||||
|
broadcast('wiki_created', { filename, title });
|
||||||
|
res.status(201).json({ filename, success: true, title });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error creating wiki page:', err);
|
||||||
|
res.status(500).json({ error: 'failed_to_create_wiki_page' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/wiki/:filename - Update wiki page
|
||||||
|
app.put('/api/wiki/:filename', (req, res) => {
|
||||||
|
try {
|
||||||
|
const filename = req.params.filename;
|
||||||
|
const { content } = req.body;
|
||||||
|
|
||||||
|
// Security: prevent path traversal
|
||||||
|
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
||||||
|
return res.status(400).json({ error: 'invalid_filename' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof content !== 'string') {
|
||||||
|
return res.status(400).json({ error: 'content_is_required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(WIKI_DIR, filename);
|
||||||
|
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
return res.status(404).json({ error: 'wiki_page_not_found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(filePath, content, 'utf8');
|
||||||
|
|
||||||
|
broadcast('wiki_updated', { filename });
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error updating wiki page:', err);
|
||||||
|
res.status(500).json({ error: 'failed_to_update_wiki_page' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/wiki/:filename - Delete wiki page
|
||||||
|
app.delete('/api/wiki/:filename', (req, res) => {
|
||||||
|
try {
|
||||||
|
const filename = req.params.filename;
|
||||||
|
|
||||||
|
// Security: prevent path traversal
|
||||||
|
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
||||||
|
return res.status(400).json({ error: 'invalid_filename' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(WIKI_DIR, filename);
|
||||||
|
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
return res.status(404).json({ error: 'wiki_page_not_found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
|
||||||
|
broadcast('wiki_deleted', { filename });
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting wiki page:', err);
|
||||||
|
res.status(500).json({ error: 'failed_to_delete_wiki_page' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ AGENTS API (Enhanced) ============
|
||||||
|
|
||||||
app.get('/api/agents', (req, res) => {
|
app.get('/api/agents', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const agents = [];
|
const agents = [];
|
||||||
@@ -247,17 +523,96 @@ app.get('/api/agents', (req, res) => {
|
|||||||
return fs.statSync(path.join(AGENTS_DIR, d)).isDirectory();
|
return fs.statSync(path.join(AGENTS_DIR, d)).isDirectory();
|
||||||
});
|
});
|
||||||
|
|
||||||
agentDirs.forEach(agentName => {
|
// Get task counts per agent (workload) and completed tasks (history)
|
||||||
|
const getAgentTaskData = (agentName) => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const result = {
|
||||||
|
workload: 0,
|
||||||
|
activeTasks: [],
|
||||||
|
completedTasks: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get workload (tasks in Todo, In Progress, Review)
|
||||||
|
db.all(
|
||||||
|
`SELECT * FROM tasks
|
||||||
|
WHERE assignee = ? AND status IN ('Todo', 'In Progress', 'Review')
|
||||||
|
ORDER BY priority DESC, created_at ASC`,
|
||||||
|
[agentName],
|
||||||
|
(err, activeRows) => {
|
||||||
|
if (!err && activeRows) {
|
||||||
|
result.workload = activeRows.length;
|
||||||
|
result.activeTasks = activeRows.map(normalizeTask);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get last 5 completed tasks
|
||||||
|
db.all(
|
||||||
|
`SELECT * FROM tasks
|
||||||
|
WHERE assignee = ? AND status = 'Done'
|
||||||
|
ORDER BY completed_at DESC
|
||||||
|
LIMIT 5`,
|
||||||
|
[agentName],
|
||||||
|
(err2, completedRows) => {
|
||||||
|
if (!err2 && completedRows) {
|
||||||
|
result.completedTasks = completedRows.map(normalizeTask);
|
||||||
|
}
|
||||||
|
resolve(result);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const agentPromises = agentDirs.map(async (agentName) => {
|
||||||
const agentPath = path.join(AGENTS_DIR, agentName);
|
const agentPath = path.join(AGENTS_DIR, agentName);
|
||||||
const workspacePath = path.join(agentPath, 'workspace');
|
const workspacePath = path.join(agentPath, 'workspace');
|
||||||
|
|
||||||
|
// Load openclaw config for identity info
|
||||||
|
let emoji = '🤖';
|
||||||
|
let model = 'unknown';
|
||||||
|
let identityName = agentName;
|
||||||
|
|
||||||
|
if (fs.existsSync(OPENCLAW_CONFIG)) {
|
||||||
|
try {
|
||||||
|
const openclawConfig = JSON.parse(fs.readFileSync(OPENCLAW_CONFIG, 'utf8'));
|
||||||
|
const agentConfig = openclawConfig.agents?.list?.find(a => a.id === agentName);
|
||||||
|
if (agentConfig) {
|
||||||
|
emoji = agentConfig.identity?.emoji || '🤖';
|
||||||
|
model = agentConfig.model?.primary || 'unknown';
|
||||||
|
identityName = agentConfig.identity?.name || agentName;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get last activity from session files
|
||||||
|
let lastActivity = null;
|
||||||
|
const sessionsPath = path.join(agentPath, 'sessions');
|
||||||
|
if (fs.existsSync(sessionsPath)) {
|
||||||
|
const sessionFiles = fs.readdirSync(sessionsPath).filter(f => f.endsWith('.jsonl'));
|
||||||
|
if (sessionFiles.length > 0) {
|
||||||
|
const latestSession = sessionFiles
|
||||||
|
.map(f => ({ file: f, mtime: fs.statSync(path.join(sessionsPath, f)).mtime }))
|
||||||
|
.sort((a, b) => b.mtime - a.mtime)[0];
|
||||||
|
if (latestSession) {
|
||||||
|
lastActivity = latestSession.mtime.toISOString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const agent = {
|
const agent = {
|
||||||
name: agentName,
|
id: agentName,
|
||||||
|
emoji,
|
||||||
|
model,
|
||||||
|
lastActivity,
|
||||||
|
name: identityName,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
currentTask: null,
|
currentTask: null,
|
||||||
tools: [],
|
tools: [],
|
||||||
files: [],
|
files: [],
|
||||||
permissions: []
|
permissions: [],
|
||||||
|
workload: 0,
|
||||||
|
activeTasks: [],
|
||||||
|
completedTasks: [],
|
||||||
|
capabilities: []
|
||||||
};
|
};
|
||||||
|
|
||||||
if (fs.existsSync(workspacePath)) {
|
if (fs.existsSync(workspacePath)) {
|
||||||
@@ -267,36 +622,104 @@ app.get('/api/agents', (req, res) => {
|
|||||||
const memoryPath = path.join(workspacePath, 'MEMORY.md');
|
const memoryPath = path.join(workspacePath, 'MEMORY.md');
|
||||||
if (fs.existsSync(memoryPath)) {
|
if (fs.existsSync(memoryPath)) {
|
||||||
const memory = fs.readFileSync(memoryPath, 'utf8');
|
const memory = fs.readFileSync(memoryPath, 'utf8');
|
||||||
const toolMatches = memory.match(/##\\s+Tools([\\s\\S]*?)(?=##|$)/i);
|
const toolMatches = memory.match(/##\s+Tools([\s\S]*?)(?=##|$)/i);
|
||||||
if (toolMatches) {
|
if (toolMatches) {
|
||||||
agent.tools = toolMatches[1].split('\\n')
|
agent.tools = toolMatches[1].split('\n')
|
||||||
.filter(line => line.trim().startsWith('-'))
|
.filter(line => line.trim().startsWith('-'))
|
||||||
.map(line => line.replace(/^-\\s*/, '').trim());
|
.map(line => line.replace(/^-\s*/, '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract capabilities/skills
|
||||||
|
const skillsMatch = memory.match(/##\s+Skills([\s\S]*?)(?=##|$)/i);
|
||||||
|
if (skillsMatch) {
|
||||||
|
agent.capabilities = skillsMatch[1].split('\n')
|
||||||
|
.filter(line => line.trim().startsWith('-'))
|
||||||
|
.map(line => line.replace(/^-\s*/, '').trim());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const heartbeatPath = path.join(workspacePath, 'HEARTBEAT.md');
|
const heartbeatPath = path.join(workspacePath, 'HEARTBEAT.md');
|
||||||
if (fs.existsSync(heartbeatPath)) {
|
if (fs.existsSync(heartbeatPath)) {
|
||||||
const heartbeat = fs.readFileSync(heartbeatPath, 'utf8');
|
const heartbeat = fs.readFileSync(heartbeatPath, 'utf8');
|
||||||
const taskMatch = heartbeat.match(/Current Task:\\s*(.+)/i);
|
const taskMatch = heartbeat.match(/Current Task:\s*(.+)/i);
|
||||||
if (taskMatch) {
|
if (taskMatch) {
|
||||||
agent.currentTask = taskMatch[1].trim();
|
agent.currentTask = taskMatch[1].trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check last heartbeat time for status
|
||||||
|
const timeMatch = heartbeat.match(/Last Heartbeat:\s*(.+)/i);
|
||||||
|
if (timeMatch) {
|
||||||
|
const lastBeat = new Date(timeMatch[1]);
|
||||||
|
const now = new Date();
|
||||||
|
const minutesAgo = (now - lastBeat) / 1000 / 60;
|
||||||
|
|
||||||
|
if (minutesAgo > 30) {
|
||||||
|
agent.status = 'idle';
|
||||||
|
} else if (minutesAgo > 10) {
|
||||||
|
agent.status = 'busy';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
agents.push(agent);
|
// Get task data from database
|
||||||
});
|
const taskData = await getAgentTaskData(agentName);
|
||||||
}
|
agent.workload = taskData.workload;
|
||||||
|
agent.activeTasks = taskData.activeTasks;
|
||||||
|
agent.completedTasks = taskData.completedTasks;
|
||||||
|
|
||||||
res.json(agents);
|
return agent;
|
||||||
|
});
|
||||||
|
|
||||||
|
Promise.all(agentPromises).then(results => {
|
||||||
|
res.json(results);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.json([]);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error reading agents:', err);
|
console.error('Error reading agents:', err);
|
||||||
res.status(500).json({ error: 'failed_to_fetch_agents' });
|
res.status(500).json({ error: 'failed_to_fetch_agents' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Usage endpoint
|
// POST /api/agents/:name/assign - Assign task to agent
|
||||||
|
app.post('/api/agents/:name/assign', (req, res) => {
|
||||||
|
const agentName = req.params.name;
|
||||||
|
const { taskId } = req.body;
|
||||||
|
|
||||||
|
if (!taskId) {
|
||||||
|
return res.status(400).json({ error: 'taskId_is_required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
'UPDATE tasks SET assignee = ?, updated_at = datetime("now") WHERE id = ?',
|
||||||
|
[agentName, taskId],
|
||||||
|
function(err) {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ error: 'failed_to_assign_task' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.changes === 0) {
|
||||||
|
return res.status(404).json({ error: 'task_not_found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.get('SELECT * FROM tasks WHERE id = ?', [taskId], (fetchErr, row) => {
|
||||||
|
if (fetchErr || !row) {
|
||||||
|
return res.status(500).json({ error: 'failed_to_fetch_task' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = normalizeTask(row);
|
||||||
|
broadcast('task_assigned', { agent: agentName, task });
|
||||||
|
res.json({ success: true, task });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ USAGE API (Enhanced) ============
|
||||||
|
|
||||||
|
// GET /api/usage - Basic usage info (existing)
|
||||||
app.get('/api/usage', (req, res) => {
|
app.get('/api/usage', (req, res) => {
|
||||||
try {
|
try {
|
||||||
const usage = {
|
const usage = {
|
||||||
@@ -343,7 +766,191 @@ app.get('/api/usage', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Heartbeat endpoint for agents
|
// GET /api/usage/stats - Usage statistics with date range
|
||||||
|
app.get('/api/usage/stats', (req, res) => {
|
||||||
|
const { from, to } = req.query;
|
||||||
|
|
||||||
|
let query = 'SELECT * FROM usage_tracking';
|
||||||
|
const params = [];
|
||||||
|
const conditions = [];
|
||||||
|
|
||||||
|
if (from) {
|
||||||
|
conditions.push('timestamp >= ?');
|
||||||
|
params.push(from);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to) {
|
||||||
|
conditions.push('timestamp <= ?');
|
||||||
|
params.push(to);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conditions.length > 0) {
|
||||||
|
query += ' WHERE ' + conditions.join(' AND ');
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ' ORDER BY timestamp DESC';
|
||||||
|
|
||||||
|
db.all(query, params, (err, rows) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error fetching usage stats:', err);
|
||||||
|
return res.status(500).json({ error: 'failed_to_fetch_usage_stats' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate stats
|
||||||
|
const stats = {
|
||||||
|
totalRequests: rows.length,
|
||||||
|
totalTokens: rows.reduce((sum, r) => sum + (r.tokens_used || 0), 0),
|
||||||
|
totalCost: rows.reduce((sum, r) => sum + (r.cost_estimate || 0), 0),
|
||||||
|
byProvider: {},
|
||||||
|
byAgent: {},
|
||||||
|
byModel: {},
|
||||||
|
records: rows
|
||||||
|
};
|
||||||
|
|
||||||
|
rows.forEach(record => {
|
||||||
|
// By provider
|
||||||
|
if (!stats.byProvider[record.provider]) {
|
||||||
|
stats.byProvider[record.provider] = { requests: 0, tokens: 0, cost: 0 };
|
||||||
|
}
|
||||||
|
stats.byProvider[record.provider].requests++;
|
||||||
|
stats.byProvider[record.provider].tokens += record.tokens_used || 0;
|
||||||
|
stats.byProvider[record.provider].cost += record.cost_estimate || 0;
|
||||||
|
|
||||||
|
// By agent
|
||||||
|
if (!stats.byAgent[record.agent]) {
|
||||||
|
stats.byAgent[record.agent] = { requests: 0, tokens: 0, cost: 0 };
|
||||||
|
}
|
||||||
|
stats.byAgent[record.agent].requests++;
|
||||||
|
stats.byAgent[record.agent].tokens += record.tokens_used || 0;
|
||||||
|
stats.byAgent[record.agent].cost += record.cost_estimate || 0;
|
||||||
|
|
||||||
|
// By model
|
||||||
|
if (!stats.byModel[record.model]) {
|
||||||
|
stats.byModel[record.model] = { requests: 0, tokens: 0, cost: 0 };
|
||||||
|
}
|
||||||
|
stats.byModel[record.model].requests++;
|
||||||
|
stats.byModel[record.model].tokens += record.tokens_used || 0;
|
||||||
|
stats.byModel[record.model].cost += record.cost_estimate || 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(stats);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/usage/agents - Usage breakdown by agent
|
||||||
|
app.get('/api/usage/agents', (req, res) => {
|
||||||
|
const { from, to } = req.query;
|
||||||
|
|
||||||
|
let query = `
|
||||||
|
SELECT agent,
|
||||||
|
COUNT(*) as requests,
|
||||||
|
SUM(tokens_used) as tokens,
|
||||||
|
SUM(cost_estimate) as cost,
|
||||||
|
provider,
|
||||||
|
model
|
||||||
|
FROM usage_tracking
|
||||||
|
`;
|
||||||
|
const params = [];
|
||||||
|
const conditions = [];
|
||||||
|
|
||||||
|
if (from) {
|
||||||
|
conditions.push('timestamp >= ?');
|
||||||
|
params.push(from);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to) {
|
||||||
|
conditions.push('timestamp <= ?');
|
||||||
|
params.push(to);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conditions.length > 0) {
|
||||||
|
query += ' WHERE ' + conditions.join(' AND ');
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ' GROUP BY agent ORDER BY requests DESC';
|
||||||
|
|
||||||
|
db.all(query, params, (err, rows) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error fetching agent usage:', err);
|
||||||
|
return res.status(500).json({ error: 'failed_to_fetch_agent_usage' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(rows);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/usage/track - Track usage (for external callers)
|
||||||
|
app.post('/api/usage/track', (req, res) => {
|
||||||
|
const { agent, provider, model, requestType, tokensUsed, costEstimate } = req.body;
|
||||||
|
|
||||||
|
if (!agent || !provider || !model) {
|
||||||
|
return res.status(400).json({ error: 'agent, provider, and model are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO usage_tracking (agent, provider, model, request_type, tokens_used, cost_estimate)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
[agent, provider, model, requestType || 'chat', tokensUsed || 0, costEstimate || 0],
|
||||||
|
function(err) {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error tracking usage:', err);
|
||||||
|
return res.status(500).json({ error: 'failed_to_track_usage' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(201).json({ success: true, id: this.lastID });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/usage/export - Export usage data
|
||||||
|
app.get('/api/usage/export', (req, res) => {
|
||||||
|
const { format = 'json', from, to } = req.query;
|
||||||
|
|
||||||
|
let query = 'SELECT * FROM usage_tracking';
|
||||||
|
const params = [];
|
||||||
|
const conditions = [];
|
||||||
|
|
||||||
|
if (from) {
|
||||||
|
conditions.push('timestamp >= ?');
|
||||||
|
params.push(from);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to) {
|
||||||
|
conditions.push('timestamp <= ?');
|
||||||
|
params.push(to);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conditions.length > 0) {
|
||||||
|
query += ' WHERE ' + conditions.join(' AND ');
|
||||||
|
}
|
||||||
|
|
||||||
|
query += ' ORDER BY timestamp DESC';
|
||||||
|
|
||||||
|
db.all(query, params, (err, rows) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Error exporting usage:', err);
|
||||||
|
return res.status(500).json({ error: 'failed_to_export_usage' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === 'csv') {
|
||||||
|
const csv = [
|
||||||
|
'id,agent,provider,model,request_type,tokens_used,cost_estimate,timestamp',
|
||||||
|
...rows.map(r => `${r.id},${r.agent},${r.provider},${r.model},${r.request_type},${r.tokens_used},${r.cost_estimate},${r.timestamp}`)
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/csv');
|
||||||
|
res.setHeader('Content-Disposition', 'attachment; filename="usage-export.csv"');
|
||||||
|
res.send(csv);
|
||||||
|
} else {
|
||||||
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
res.setHeader('Content-Disposition', 'attachment; filename="usage-export.json"');
|
||||||
|
res.json(rows);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============ HEARTBEAT ============
|
||||||
|
|
||||||
app.get('/api/heartbeat/:agent', (req, res) => {
|
app.get('/api/heartbeat/:agent', (req, res) => {
|
||||||
const agent = req.params.agent;
|
const agent = req.params.agent;
|
||||||
|
|
||||||
@@ -365,10 +972,197 @@ app.get('/api/heartbeat/:agent', (req, res) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============ WEBSOCKET ============
|
||||||
|
|
||||||
wss.on('connection', (socket) => {
|
wss.on('connection', (socket) => {
|
||||||
socket.send(JSON.stringify({ type: 'connected', payload: { ok: true } }));
|
socket.send(JSON.stringify({ type: 'connected', payload: { ok: true } }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Setup Gitea integration
|
||||||
|
setupGiteaRoutes(app, renderPage);
|
||||||
|
|
||||||
server.listen(PORT, '0.0.0.0', () => {
|
server.listen(PORT, '0.0.0.0', () => {
|
||||||
console.log(`openclaw-taskboard listening on ${PORT}`);
|
console.log(`openclaw-taskboard listening on ${PORT}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============ REAL USAGE TRACKING ============
|
||||||
|
const REAL_SESSIONS_DIR = process.env.SESSIONS_DIR || '/app/agents';
|
||||||
|
const SWARM_TASKS_FILE = process.env.SWARM_TASKS_FILE || '/app/swarm/active-tasks.json';
|
||||||
|
|
||||||
|
// GET /api/usage/real - Aggregate usage from session files
|
||||||
|
// GET /api/usage/real - Aggregate usage from session files with date filtering
|
||||||
|
app.get('/api/usage/real', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { from, to } = req.query;
|
||||||
|
const fromDate = from ? new Date(from) : null;
|
||||||
|
const toDate = to ? new Date(to) : null;
|
||||||
|
|
||||||
|
const usageByAgent = {};
|
||||||
|
const usageByModel = {};
|
||||||
|
let totalInput = 0, totalOutput = 0, totalCost = 0;
|
||||||
|
|
||||||
|
if (!fs.existsSync(REAL_SESSIONS_DIR)) {
|
||||||
|
return res.json({ error: 'sessions_dir_not_found', agents: {}, totals: {} });
|
||||||
|
}
|
||||||
|
|
||||||
|
const agents = fs.readdirSync(REAL_SESSIONS_DIR).filter(d => {
|
||||||
|
return fs.statSync(path.join(REAL_SESSIONS_DIR, d)).isDirectory();
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const agent of agents) {
|
||||||
|
const sessionsDir = path.join(REAL_SESSIONS_DIR, agent, 'sessions');
|
||||||
|
if (!fs.existsSync(sessionsDir)) continue;
|
||||||
|
|
||||||
|
let agentInput = 0, agentOutput = 0, agentCost = 0;
|
||||||
|
|
||||||
|
const sessions = fs.readdirSync(sessionsDir).filter(f => f.endsWith('.jsonl'));
|
||||||
|
for (const sessionFile of sessions) {
|
||||||
|
const filePath = path.join(sessionsDir, sessionFile);
|
||||||
|
const lines = fs.readFileSync(filePath, 'utf8').split('\n');
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(line);
|
||||||
|
|
||||||
|
// Date filtering
|
||||||
|
if (fromDate || toDate) {
|
||||||
|
const msgDate = new Date(msg.timestamp);
|
||||||
|
if (fromDate && msgDate < fromDate) continue;
|
||||||
|
if (toDate && msgDate > toDate) continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.message?.usage) {
|
||||||
|
const u = msg.message.usage;
|
||||||
|
agentInput += u.input || 0;
|
||||||
|
agentOutput += u.output || 0;
|
||||||
|
agentCost += u.cost?.total || 0;
|
||||||
|
|
||||||
|
const model = msg.message.model || 'unknown';
|
||||||
|
if (!usageByModel[model]) {
|
||||||
|
usageByModel[model] = { input: 0, output: 0, requests: 0 };
|
||||||
|
}
|
||||||
|
usageByModel[model].input += u.input || 0;
|
||||||
|
usageByModel[model].output += u.output || 0;
|
||||||
|
usageByModel[model].requests++;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
usageByAgent[agent] = {
|
||||||
|
input: agentInput,
|
||||||
|
output: agentOutput,
|
||||||
|
total: agentInput + agentOutput,
|
||||||
|
cost: agentCost
|
||||||
|
};
|
||||||
|
|
||||||
|
totalInput += agentInput;
|
||||||
|
totalOutput += agentOutput;
|
||||||
|
totalCost += agentCost;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
agents: usageByAgent,
|
||||||
|
models: usageByModel,
|
||||||
|
totals: {
|
||||||
|
input: totalInput,
|
||||||
|
output: totalOutput,
|
||||||
|
total: totalInput + totalOutput,
|
||||||
|
cost: totalCost
|
||||||
|
},
|
||||||
|
filters: { from, to },
|
||||||
|
lastUpdated: new Date().toISOString()
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error calculating real usage:', err);
|
||||||
|
res.status(500).json({ error: 'failed_to_calculate_usage' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/usage/export/real - Export real usage data
|
||||||
|
app.get('/api/usage/export/real', (req, res) => {
|
||||||
|
const { format = 'json', from, to } = req.query;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fromDate = from ? new Date(from) : null;
|
||||||
|
const toDate = to ? new Date(to) : null;
|
||||||
|
const usageData = [];
|
||||||
|
|
||||||
|
if (!fs.existsSync(REAL_SESSIONS_DIR)) {
|
||||||
|
return res.status(404).json({ error: 'sessions_dir_not_found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const agents = fs.readdirSync(REAL_SESSIONS_DIR).filter(d => {
|
||||||
|
return fs.statSync(path.join(REAL_SESSIONS_DIR, d)).isDirectory();
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const agent of agents) {
|
||||||
|
const sessionsDir = path.join(REAL_SESSIONS_DIR, agent, 'sessions');
|
||||||
|
if (!fs.existsSync(sessionsDir)) continue;
|
||||||
|
|
||||||
|
const sessions = fs.readdirSync(sessionsDir).filter(f => f.endsWith('.jsonl'));
|
||||||
|
for (const sessionFile of sessions) {
|
||||||
|
const filePath = path.join(sessionsDir, sessionFile);
|
||||||
|
const lines = fs.readFileSync(filePath, 'utf8').split('\n');
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(line);
|
||||||
|
|
||||||
|
if (fromDate || toDate) {
|
||||||
|
const msgDate = new Date(msg.timestamp);
|
||||||
|
if (fromDate && msgDate < fromDate) continue;
|
||||||
|
if (toDate && msgDate > toDate) continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.message?.usage) {
|
||||||
|
usageData.push({
|
||||||
|
timestamp: msg.timestamp,
|
||||||
|
agent,
|
||||||
|
model: msg.message.model || 'unknown',
|
||||||
|
provider: msg.message.provider || 'unknown',
|
||||||
|
input: msg.message.usage.input || 0,
|
||||||
|
output: msg.message.usage.output || 0,
|
||||||
|
total: (msg.message.usage.input || 0) + (msg.message.usage.output || 0),
|
||||||
|
cost: msg.message.usage.cost?.total || 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === 'csv') {
|
||||||
|
const csv = [
|
||||||
|
'timestamp,agent,model,provider,input,output,total,cost',
|
||||||
|
...usageData.map(r => `${r.timestamp},${r.agent},${r.model},${r.provider},${r.input},${r.output},${r.total},${r.cost}`)
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/csv');
|
||||||
|
res.setHeader('Content-Disposition', 'attachment; filename="usage-export.csv"');
|
||||||
|
res.send(csv);
|
||||||
|
} else {
|
||||||
|
res.setHeader('Content-Type', 'application/json');
|
||||||
|
res.setHeader('Content-Disposition', 'attachment; filename="usage-export.json"');
|
||||||
|
res.json(usageData);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error exporting usage:', err);
|
||||||
|
res.status(500).json({ error: 'failed_to_export_usage' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
app.get('/api/swarm/tasks', (req, res) => {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(SWARM_TASKS_FILE)) {
|
||||||
|
const data = JSON.parse(fs.readFileSync(SWARM_TASKS_FILE, 'utf8'));
|
||||||
|
res.json(data);
|
||||||
|
} else {
|
||||||
|
res.json({ tasks: [], message: 'swarm_registry_not_found' });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error reading swarm tasks:', err);
|
||||||
|
res.status(500).json({ error: 'failed_to_read_swarm_tasks' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
374
server.js.backup
Normal file
374
server.js.backup
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const http = require('http');
|
||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const { WebSocketServer } = require('ws');
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 8395;
|
||||||
|
const DB_PATH = process.env.DB_PATH || path.join(__dirname, 'data', 'tasks.db');
|
||||||
|
const WIKI_DIR = process.env.WIKI_DIR || '/home/bear/.openclaw/workspace/wiki';
|
||||||
|
|
||||||
|
const AGENTS_DIR = process.env.AGENTS_DIR || '/home/bear/.openclaw/agents';
|
||||||
|
const OPENCLAW_CONFIG = process.env.OPENCLAW_CONFIG || '/home/bear/.openclaw/openclaw.json';
|
||||||
|
const VALID_STATUSES = ['Backlog', 'Todo', 'In Progress', 'Review', 'Done'];
|
||||||
|
const VALID_PRIORITIES = ['Low', 'Medium', 'High', 'Critical'];
|
||||||
|
|
||||||
|
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
||||||
|
fs.mkdirSync(WIKI_DIR, { recursive: true });
|
||||||
|
|
||||||
|
const db = new sqlite3.Database(DB_PATH);
|
||||||
|
|
||||||
|
db.serialize(() => {
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS tasks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT DEFAULT '',
|
||||||
|
assignee TEXT DEFAULT '',
|
||||||
|
priority TEXT NOT NULL DEFAULT 'Medium',
|
||||||
|
status TEXT NOT NULL DEFAULT 'Backlog',
|
||||||
|
tags TEXT NOT NULL DEFAULT '[]',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
completed_at TEXT
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const server = http.createServer(app);
|
||||||
|
const wss = new WebSocketServer({ server });
|
||||||
|
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
|
function normalizeTask(row) {
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
tags: (() => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(row.tags || '[]');
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeWiki(task) {
|
||||||
|
const safeTitle = task.title
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.slice(0, 80) || `task-${task.id}`;
|
||||||
|
|
||||||
|
const fileName = `${new Date().toISOString().slice(0, 10)}-task-${task.id}-${safeTitle}.md`;
|
||||||
|
const filePath = path.join(WIKI_DIR, fileName);
|
||||||
|
|
||||||
|
const md = `# ${task.title}\n\n` +
|
||||||
|
`- Task ID: ${task.id}\n` +
|
||||||
|
`- Assignee: ${task.assignee || 'Unassigned'}\n` +
|
||||||
|
`- Priority: ${task.priority}\n` +
|
||||||
|
`- Status: ${task.status}\n` +
|
||||||
|
`- Tags: ${task.tags.length ? task.tags.join(', ') : 'None'}\n` +
|
||||||
|
`- Created: ${task.created_at}\n` +
|
||||||
|
`- Completed: ${task.completed_at || new Date().toISOString()}\n\n` +
|
||||||
|
`## Description\n\n${task.description || 'No description provided.'}\n`;
|
||||||
|
|
||||||
|
fs.writeFileSync(filePath, md, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcast(type, payload) {
|
||||||
|
const data = JSON.stringify({ type, payload });
|
||||||
|
for (const client of wss.clients) {
|
||||||
|
if (client.readyState === 1) {
|
||||||
|
client.send(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validatePayload(body, partial = false) {
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
if (!partial || body.title !== undefined) {
|
||||||
|
if (typeof body.title !== 'string' || body.title.trim().length === 0) {
|
||||||
|
errors.push('title is required');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.status !== undefined && !VALID_STATUSES.includes(body.status)) {
|
||||||
|
errors.push(`status must be one of: ${VALID_STATUSES.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.priority !== undefined && !VALID_PRIORITIES.includes(body.priority)) {
|
||||||
|
errors.push(`priority must be one of: ${VALID_PRIORITIES.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.tags !== undefined && !Array.isArray(body.tags)) {
|
||||||
|
errors.push('tags must be an array of strings');
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/api/tasks', (req, res) => {
|
||||||
|
db.all('SELECT * FROM tasks ORDER BY id DESC', (err, rows) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ error: 'failed_to_fetch_tasks' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json(rows.map(normalizeTask));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/tasks', (req, res) => {
|
||||||
|
const errors = validatePayload(req.body, false);
|
||||||
|
if (errors.length) {
|
||||||
|
return res.status(400).json({ error: 'validation_error', details: errors });
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = req.body.title.trim();
|
||||||
|
const description = typeof req.body.description === 'string' ? req.body.description : '';
|
||||||
|
const assignee = typeof req.body.assignee === 'string' ? req.body.assignee : '';
|
||||||
|
const priority = req.body.priority || 'Medium';
|
||||||
|
const status = req.body.status || 'Backlog';
|
||||||
|
const tags = Array.isArray(req.body.tags) ? req.body.tags.filter((t) => typeof t === 'string') : [];
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO tasks (title, description, assignee, priority, status, tags)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
[title, description, assignee, priority, status, JSON.stringify(tags)],
|
||||||
|
function onInsert(err) {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ error: 'failed_to_create_task' });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.get('SELECT * FROM tasks WHERE id = ?', [this.lastID], (fetchErr, row) => {
|
||||||
|
if (fetchErr || !row) {
|
||||||
|
return res.status(500).json({ error: 'failed_to_fetch_created_task' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = normalizeTask(row);
|
||||||
|
broadcast('task_created', task);
|
||||||
|
return res.status(201).json(task);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.patch('/api/tasks/:id', (req, res) => {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
if (!Number.isInteger(id) || id <= 0) {
|
||||||
|
return res.status(400).json({ error: 'invalid_task_id' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors = validatePayload(req.body, true);
|
||||||
|
if (errors.length) {
|
||||||
|
return res.status(400).json({ error: 'validation_error', details: errors });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.get('SELECT * FROM tasks WHERE id = ?', [id], (err, existing) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ error: 'failed_to_find_task' });
|
||||||
|
}
|
||||||
|
if (!existing) {
|
||||||
|
return res.status(404).json({ error: 'task_not_found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingTask = normalizeTask(existing);
|
||||||
|
const next = {
|
||||||
|
title: req.body.title !== undefined ? req.body.title.trim() : existingTask.title,
|
||||||
|
description: req.body.description !== undefined ? String(req.body.description) : existingTask.description,
|
||||||
|
assignee: req.body.assignee !== undefined ? String(req.body.assignee) : existingTask.assignee,
|
||||||
|
priority: req.body.priority !== undefined ? req.body.priority : existingTask.priority,
|
||||||
|
status: req.body.status !== undefined ? req.body.status : existingTask.status,
|
||||||
|
tags: req.body.tags !== undefined
|
||||||
|
? req.body.tags.filter((t) => typeof t === 'string')
|
||||||
|
: existingTask.tags,
|
||||||
|
};
|
||||||
|
|
||||||
|
const nowDone = next.status === 'Done';
|
||||||
|
const wasDone = existingTask.status === 'Done';
|
||||||
|
const completedAt = nowDone && !wasDone
|
||||||
|
? new Date().toISOString()
|
||||||
|
: nowDone
|
||||||
|
? existing.completed_at
|
||||||
|
: null;
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
`UPDATE tasks
|
||||||
|
SET title = ?, description = ?, assignee = ?, priority = ?, status = ?, tags = ?,
|
||||||
|
completed_at = ?, updated_at = datetime('now')
|
||||||
|
WHERE id = ?`,
|
||||||
|
[
|
||||||
|
next.title,
|
||||||
|
next.description,
|
||||||
|
next.assignee,
|
||||||
|
next.priority,
|
||||||
|
next.status,
|
||||||
|
JSON.stringify(next.tags),
|
||||||
|
completedAt,
|
||||||
|
id,
|
||||||
|
],
|
||||||
|
(updateErr) => {
|
||||||
|
if (updateErr) {
|
||||||
|
return res.status(500).json({ error: 'failed_to_update_task' });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.get('SELECT * FROM tasks WHERE id = ?', [id], (fetchErr, row) => {
|
||||||
|
if (fetchErr || !row) {
|
||||||
|
return res.status(500).json({ error: 'failed_to_fetch_updated_task' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const task = normalizeTask(row);
|
||||||
|
|
||||||
|
if (nowDone && !wasDone) {
|
||||||
|
try {
|
||||||
|
writeWiki(task);
|
||||||
|
} catch (wikiErr) {
|
||||||
|
console.error('wiki_creation_error', wikiErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcast('task_updated', task);
|
||||||
|
return res.json(task);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/agents', (req, res) => {
|
||||||
|
try {
|
||||||
|
const agents = [];
|
||||||
|
|
||||||
|
if (fs.existsSync(AGENTS_DIR)) {
|
||||||
|
const agentDirs = fs.readdirSync(AGENTS_DIR).filter(d => {
|
||||||
|
return fs.statSync(path.join(AGENTS_DIR, d)).isDirectory();
|
||||||
|
});
|
||||||
|
|
||||||
|
agentDirs.forEach(agentName => {
|
||||||
|
const agentPath = path.join(AGENTS_DIR, agentName);
|
||||||
|
const workspacePath = path.join(agentPath, 'workspace');
|
||||||
|
|
||||||
|
const agent = {
|
||||||
|
name: agentName,
|
||||||
|
status: 'active',
|
||||||
|
currentTask: null,
|
||||||
|
tools: [],
|
||||||
|
files: [],
|
||||||
|
permissions: []
|
||||||
|
};
|
||||||
|
|
||||||
|
if (fs.existsSync(workspacePath)) {
|
||||||
|
const files = fs.readdirSync(workspacePath);
|
||||||
|
agent.files = files.filter(f => f.endsWith('.md'));
|
||||||
|
|
||||||
|
const memoryPath = path.join(workspacePath, 'MEMORY.md');
|
||||||
|
if (fs.existsSync(memoryPath)) {
|
||||||
|
const memory = fs.readFileSync(memoryPath, 'utf8');
|
||||||
|
const toolMatches = memory.match(/##\\s+Tools([\\s\\S]*?)(?=##|$)/i);
|
||||||
|
if (toolMatches) {
|
||||||
|
agent.tools = toolMatches[1].split('\\n')
|
||||||
|
.filter(line => line.trim().startsWith('-'))
|
||||||
|
.map(line => line.replace(/^-\\s*/, '').trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const heartbeatPath = path.join(workspacePath, 'HEARTBEAT.md');
|
||||||
|
if (fs.existsSync(heartbeatPath)) {
|
||||||
|
const heartbeat = fs.readFileSync(heartbeatPath, 'utf8');
|
||||||
|
const taskMatch = heartbeat.match(/Current Task:\\s*(.+)/i);
|
||||||
|
if (taskMatch) {
|
||||||
|
agent.currentTask = taskMatch[1].trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
agents.push(agent);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(agents);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error reading agents:', err);
|
||||||
|
res.status(500).json({ error: 'failed_to_fetch_agents' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Usage endpoint
|
||||||
|
app.get('/api/usage', (req, res) => {
|
||||||
|
try {
|
||||||
|
const usage = {
|
||||||
|
providers: [],
|
||||||
|
lastUpdated: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (fs.existsSync(OPENCLAW_CONFIG)) {
|
||||||
|
const config = JSON.parse(fs.readFileSync(OPENCLAW_CONFIG, 'utf8'));
|
||||||
|
|
||||||
|
if (config.models) {
|
||||||
|
const providerMap = {};
|
||||||
|
|
||||||
|
Object.entries(config.models).forEach(([modelName, modelConfig]) => {
|
||||||
|
const provider = modelConfig.provider || 'unknown';
|
||||||
|
|
||||||
|
if (!providerMap[provider]) {
|
||||||
|
providerMap[provider] = {
|
||||||
|
name: provider,
|
||||||
|
models: [],
|
||||||
|
quota: {
|
||||||
|
requests: 0,
|
||||||
|
tokens: 0,
|
||||||
|
limit: 'unlimited'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
providerMap[provider].models.push({
|
||||||
|
name: modelName,
|
||||||
|
type: modelConfig.type || 'chat',
|
||||||
|
contextWindow: modelConfig.context_window || 'unknown'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
usage.providers = Object.values(providerMap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(usage);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error reading usage:', err);
|
||||||
|
res.status(500).json({ error: 'failed_to_fetch_usage' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Heartbeat endpoint for agents
|
||||||
|
app.get('/api/heartbeat/:agent', (req, res) => {
|
||||||
|
const agent = req.params.agent;
|
||||||
|
|
||||||
|
db.all(
|
||||||
|
'SELECT * FROM tasks WHERE assignee = ? AND status IN (?, ?, ?) ORDER BY priority DESC, created_at ASC',
|
||||||
|
[agent, 'Todo', 'In Progress', 'Review'],
|
||||||
|
(err, rows) => {
|
||||||
|
if (err) {
|
||||||
|
return res.status(500).json({ error: 'failed_to_fetch_tasks' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks = rows.map(normalizeTask);
|
||||||
|
res.json({
|
||||||
|
agent,
|
||||||
|
pending_tasks: tasks.length,
|
||||||
|
tasks
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
wss.on('connection', (socket) => {
|
||||||
|
socket.send(JSON.stringify({ type: 'connected', payload: { ok: true } }));
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, '0.0.0.0', () => {
|
||||||
|
console.log(`openclaw-taskboard listening on ${PORT}`);
|
||||||
|
});
|
||||||
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"
|
||||||
|
]
|
||||||
|
}
|
||||||
40
views/agents.html
Normal file
40
views/agents.html
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<section id="page-agents" class="page active">
|
||||||
|
<div class="agents-header">
|
||||||
|
<h2>Agent Fleet</h2>
|
||||||
|
<div class="agents-controls">
|
||||||
|
<input type="text" id="agent-search" placeholder="Search agents..." />
|
||||||
|
<select id="agent-status-filter">
|
||||||
|
<option value="">All Statuses</option>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="busy">Busy</option>
|
||||||
|
<option value="idle">Idle</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="agents-grid" class="agents-grid"></div>
|
||||||
|
|
||||||
|
<div id="agent-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="modal-agent-name">Agent Details</h3>
|
||||||
|
<button class="modal-close" id="modal-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="modal-agent-body"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="assign-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Assign Task to <span id="assign-agent-name"></span></h3>
|
||||||
|
<button class="modal-close" id="assign-modal-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<select id="assign-task-select">
|
||||||
|
<option value="">Select a task...</option>
|
||||||
|
</select>
|
||||||
|
<button id="confirm-assign-btn" class="btn-primary">Assign Task</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
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>
|
||||||
92
views/layout.html
Normal file
92
views/layout.html
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{pageTitle}}</title>
|
||||||
|
|
||||||
|
<!-- Distinctive Google Fonts -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Outfit:wght@300;400;500;600;700;800;900&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="/styles.css">
|
||||||
|
</head>
|
||||||
|
<body data-page="{{pageName}}">
|
||||||
|
<!-- Animated background particles -->
|
||||||
|
<div class="bg-particles" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<!-- Main container -->
|
||||||
|
<div class="app-container">
|
||||||
|
<!-- Header with glassmorphism effect -->
|
||||||
|
<header class="app-header">
|
||||||
|
<div class="header-content">
|
||||||
|
<div class="logo-section">
|
||||||
|
<div class="logo-icon">
|
||||||
|
<span class="logo-emoji">🦞</span>
|
||||||
|
<div class="logo-glow"></div>
|
||||||
|
</div>
|
||||||
|
<div class="logo-text">
|
||||||
|
<h1 class="logo-title">OpenClaw</h1>
|
||||||
|
<p class="logo-subtitle">Fleet Dashboard</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="header-actions">
|
||||||
|
<button id="theme-toggle" class="theme-btn" aria-label="Toggle theme">
|
||||||
|
<span class="theme-icon">☀️</span>
|
||||||
|
<span class="theme-text">Light</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Navigation with hover effects -->
|
||||||
|
<nav class="app-nav" role="navigation" aria-label="Main navigation">
|
||||||
|
<div class="nav-container">
|
||||||
|
<a href="/tasks" class="nav-link {{tasksActive}}" data-tab="tasks">
|
||||||
|
<span class="nav-icon">📋</span>
|
||||||
|
<span class="nav-label">Tasks</span>
|
||||||
|
<span class="nav-indicator"></span>
|
||||||
|
</a>
|
||||||
|
<a href="/agents" class="nav-link {{agentsActive}}" data-tab="agents">
|
||||||
|
<span class="nav-icon">🤖</span>
|
||||||
|
<span class="nav-label">Agents</span>
|
||||||
|
<span class="nav-indicator"></span>
|
||||||
|
</a>
|
||||||
|
<a href="/wiki" class="nav-link {{wikiActive}}" data-tab="wiki">
|
||||||
|
<span class="nav-icon">📚</span>
|
||||||
|
<span class="nav-label">Wiki</span>
|
||||||
|
<span class="nav-indicator"></span>
|
||||||
|
</a>
|
||||||
|
<a href="/gitea" class="nav-link {{giteaActive}}" data-tab="gitea">
|
||||||
|
<span class="nav-icon">🔧</span>
|
||||||
|
<span class="nav-label">Gitea</span>
|
||||||
|
<span class="nav-indicator"></span>
|
||||||
|
</a>
|
||||||
|
<a href="/usage" class="nav-link {{usageActive}}" data-tab="usage">
|
||||||
|
<span class="nav-icon">📊</span>
|
||||||
|
<span class="nav-label">Usage</span>
|
||||||
|
<span class="nav-indicator"></span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Main content area -->
|
||||||
|
<main class="app-main" role="main">
|
||||||
|
<div class="content-wrapper">
|
||||||
|
{{content}}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="app-footer">
|
||||||
|
<p>OpenClaw Fleet Dashboard • <span class="footer-version">v2.0</span></p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{markedScript}}
|
||||||
|
{{chartScript}}
|
||||||
|
<script src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
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>
|
||||||
85
views/tasks.html
Normal file
85
views/tasks.html
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
<section id="page-tasks" class="page active">
|
||||||
|
<div class="task-controls">
|
||||||
|
<div class="task-search">
|
||||||
|
<input type="text" id="task-search" placeholder="🔍 Search tasks..." />
|
||||||
|
</div>
|
||||||
|
<div class="task-filters">
|
||||||
|
<select id="filter-status">
|
||||||
|
<option value="">All Statuses</option>
|
||||||
|
<option value="Backlog">Backlog</option>
|
||||||
|
<option value="Todo">Todo</option>
|
||||||
|
<option value="In Progress">In Progress</option>
|
||||||
|
<option value="Review">Review</option>
|
||||||
|
<option value="Done">Done</option>
|
||||||
|
</select>
|
||||||
|
<select id="filter-assignee">
|
||||||
|
<option value="">All Assignees</option>
|
||||||
|
</select>
|
||||||
|
<select id="filter-priority">
|
||||||
|
<option value="">All Priorities</option>
|
||||||
|
<option value="Critical">Critical</option>
|
||||||
|
<option value="High">High</option>
|
||||||
|
<option value="Medium">Medium</option>
|
||||||
|
<option value="Low">Low</option>
|
||||||
|
</select>
|
||||||
|
<button id="clear-filters" class="btn-secondary">Clear Filters</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="composer">
|
||||||
|
<h2>Create Task</h2>
|
||||||
|
<form id="task-form">
|
||||||
|
<input id="title" name="title" placeholder="Task title" required />
|
||||||
|
<select id="assignee" name="assignee">
|
||||||
|
<option value="">Select agent...</option>
|
||||||
|
</select>
|
||||||
|
<select id="priority" name="priority">
|
||||||
|
<option>Low</option>
|
||||||
|
<option selected>Medium</option>
|
||||||
|
<option>High</option>
|
||||||
|
<option>Critical</option>
|
||||||
|
</select>
|
||||||
|
<textarea id="description" name="description" placeholder="Task description" rows="3"></textarea>
|
||||||
|
<input id="tags" name="tags" placeholder="Tags (comma-separated)" />
|
||||||
|
<button type="submit">Create Task</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="board">
|
||||||
|
<div class="column" data-status="Backlog">
|
||||||
|
<div class="column-header">
|
||||||
|
<h3>📋 Backlog</h3>
|
||||||
|
<span class="column-count">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="cards"></div>
|
||||||
|
</div>
|
||||||
|
<div class="column" data-status="Todo">
|
||||||
|
<div class="column-header">
|
||||||
|
<h3>📝 Todo</h3>
|
||||||
|
<span class="column-count">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="cards"></div>
|
||||||
|
</div>
|
||||||
|
<div class="column" data-status="In Progress">
|
||||||
|
<div class="column-header">
|
||||||
|
<h3>🔄 In Progress</h3>
|
||||||
|
<span class="column-count">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="cards"></div>
|
||||||
|
</div>
|
||||||
|
<div class="column" data-status="Review">
|
||||||
|
<div class="column-header">
|
||||||
|
<h3>👀 Review</h3>
|
||||||
|
<span class="column-count">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="cards"></div>
|
||||||
|
</div>
|
||||||
|
<div class="column" data-status="Done">
|
||||||
|
<div class="column-header">
|
||||||
|
<h3>✅ Done</h3>
|
||||||
|
<span class="column-count">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="cards"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
49
views/usage.html
Normal file
49
views/usage.html
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<section id="page-usage" class="page active">
|
||||||
|
<div class="usage-header">
|
||||||
|
<h2>API Usage & Statistics</h2>
|
||||||
|
<div class="usage-controls">
|
||||||
|
<div class="date-range">
|
||||||
|
<label>From:</label>
|
||||||
|
<input type="date" id="usage-from" />
|
||||||
|
<label>To:</label>
|
||||||
|
<input type="date" id="usage-to" />
|
||||||
|
<button id="usage-apply-filter" class="btn-secondary">Apply</button>
|
||||||
|
</div>
|
||||||
|
<div class="export-actions">
|
||||||
|
<button id="export-json" class="btn-secondary">Export JSON</button>
|
||||||
|
<button id="export-csv" class="btn-secondary">Export CSV</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="usage-stats">
|
||||||
|
<div class="stat-card">
|
||||||
|
<h4>Total Requests</h4>
|
||||||
|
<div class="stat-value" id="stat-requests">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h4>Total Tokens</h4>
|
||||||
|
<div class="stat-value" id="stat-tokens">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h4>Estimated Cost</h4>
|
||||||
|
<div class="stat-value" id="stat-cost">$0.00</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="usage-charts">
|
||||||
|
<div class="chart-container">
|
||||||
|
<h4>Usage by Provider</h4>
|
||||||
|
<canvas id="chart-provider"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="chart-container">
|
||||||
|
<h4>Usage by Agent</h4>
|
||||||
|
<canvas id="chart-agent"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="usage-data" class="usage-details">
|
||||||
|
<h3>Provider Details</h3>
|
||||||
|
<div class="usage-grid" id="provider-grid"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
35
views/wiki.html
Normal file
35
views/wiki.html
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<section id="page-wiki" class="page active">
|
||||||
|
<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>
|
||||||
|
</section>
|
||||||
Reference in New Issue
Block a user