[taskboard] add dispatch control plane

This commit is contained in:
2026-03-06 15:21:19 -08:00
parent 1699f0f2b7
commit be1cf8ca8d
25 changed files with 1594 additions and 292 deletions

View File

@@ -3,6 +3,7 @@ import path from "node:path";
import {
ARCHITECTURE_DOCUMENT,
FLEET_CONFIG,
OPENCLAW_AGENTS_DIR,
OPENCLAW_CONFIG_PATH,
ZEROCLAW_CONTROL_DIR,
@@ -10,7 +11,7 @@ import {
} from "@/lib/fleet-config";
import { all } from "@/lib/db";
import { normalizeTask } from "@/lib/tasks";
import type { AgentStatus, FleetAgent, TaskRecord } from "@/lib/types";
import type { AgentStatus, FleetAgent, TaskEvent, TaskRecord } from "@/lib/types";
type OpenClawAgentConfig = {
id: string;
@@ -58,6 +59,24 @@ function parseRoleFromAgentsMd(content: string) {
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]) : [];
@@ -68,11 +87,13 @@ function readWorkspaceAgent(agentRoot: string, fallbackName: string) {
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 heartbeatMd = readTextFile(path.join(workspaceRoot, "HEARTBEAT.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"].filter((fileName) =>
@@ -81,6 +102,7 @@ function readWorkspaceAgent(agentRoot: string, fallbackName: string) {
tools,
capabilities,
currentTask: currentTaskMatch ? currentTaskMatch[1].trim() : null,
heartbeatAt,
role: parseRoleFromAgentsMd(agentsMd),
noteValues: parseBulletValues(identityMd),
workspaceRoot,
@@ -141,6 +163,45 @@ async function fetchTaskBuckets(aliases: string[]) {
};
}
async function fetchAgentEventSummary(aliases: string[]) {
const placeholders = aliases.map(() => "?").join(", ");
const latestEvent = await all<TaskEvent>(
`SELECT * FROM task_events WHERE assignee IN (${placeholders}) ORDER BY created_at DESC LIMIT 1`,
aliases,
);
const failureRows = await all<{ count: number }>(
`SELECT COUNT(*) as count FROM task_events
WHERE assignee IN (${placeholders}) AND event_type = 'dispatch_failed'`,
aliases,
);
return {
lastEvent: latestEvent[0] || null,
failureStreak: failureRows[0]?.count || 0,
};
}
function deriveHeartbeatAgeMinutes(heartbeatAt: string | null) {
if (!heartbeatAt) {
return null;
}
const diffMs = Date.now() - new Date(heartbeatAt).getTime();
return Math.max(0, Math.round(diffMs / 60000));
}
function deriveStatus(activeTaskCount: number, heartbeatAt: string | null): AgentStatus {
if (activeTaskCount > 0) {
return "busy";
}
const heartbeatAge = deriveHeartbeatAgeMinutes(heartbeatAt);
if (heartbeatAge !== null && heartbeatAge > 180) {
return "idle";
}
return "active";
}
async function buildOpenClawAgents() {
const config = readOpenClawConfig();
const agents = config.agents?.list || [];
@@ -154,6 +215,8 @@ async function buildOpenClawAgents() {
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,
@@ -165,17 +228,22 @@ async function buildOpenClawAgents() {
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),
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;
}),
@@ -183,58 +251,38 @@ async function buildOpenClawAgents() {
}
async function buildZeroClawAgents() {
const configuredAgents = [
{
slug: "grizzley-zeroclaw",
assignmentKey: "grizzley-zeroclaw",
aliases: ["grizzley-zeroclaw", "ZeroClaw Grizzley", "grizzley"],
name: "ZeroClaw Grizzley",
host: "grizzley",
role: "Edge host operator for grizzley",
runtimePath: ZEROCLAW_PRIMARY_DIR,
configPath: path.join(ZEROCLAW_PRIMARY_DIR, "config.toml"),
model: "glm-4.7",
emoji: "🛰️",
channels: [
{ label: "Gateway", value: "HTTP gateway :3000" },
{ label: "Access", value: "paired remote gateway via ice" },
],
notes: ["Host-scoped runtime for Traefik, OpenCode, and local services."],
},
{
slug: "ice-zeroclaw",
assignmentKey: "ice-zeroclaw",
aliases: ["ice-zeroclaw", "ZeroClaw Ice", "ZeroClaw Admin", "ice"],
name: "ZeroClaw Ice",
host: "ice",
role: "Control-plane operator for ice",
runtimePath: ZEROCLAW_CONTROL_DIR,
configPath: path.join(ZEROCLAW_CONTROL_DIR, "config.toml"),
model: "glm-5",
emoji: "🧊",
channels: [
{ label: "Telegram", value: "Homelab-Ice topics 11-15" },
{ label: "Gateway", value: "paired webhook + status routing" },
],
notes: ["Control-plane runtime and topic router for remote host delegation."],
},
];
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),
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],
};
}),
@@ -250,6 +298,11 @@ export async function listFleetAgents() {
return [...openclawAgents, ...zeroclawAgents];
}
export async function findAgentByAssignmentKey(assignmentKey: string) {
const agents = await listFleetAgents();
return agents.find((agent) => agent.assignmentKey === assignmentKey || agent.aliases.includes(assignmentKey)) || null;
}
export async function listArchitecture() {
const agents = await listFleetAgents();
return {
@@ -263,6 +316,3 @@ export async function listArchitecture() {
})),
};
}
function deriveStatus(activeTaskCount: number): AgentStatus {
return activeTaskCount > 0 ? "busy" : "active";
}

View File

@@ -7,6 +7,41 @@ const DB_PATH = process.env.DB_PATH || path.join(process.cwd(), "data", "tasks.d
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
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) {
@@ -41,12 +76,63 @@ function getDatabase() {
timestamp TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
database?.run(`
CREATE TABLE IF NOT EXISTS task_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL,
assignee TEXT NOT NULL DEFAULT '',
family TEXT,
host TEXT NOT NULL DEFAULT '',
event_type TEXT NOT NULL,
state TEXT,
summary TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
`);
database?.run(`
CREATE INDEX IF NOT EXISTS idx_task_events_task_time
ON task_events(task_id, created_at DESC)
`);
database?.run(`
CREATE INDEX IF NOT EXISTS idx_task_events_assignee_time
ON task_events(assignee, created_at DESC)
`);
});
databaseReady = (async () => {
if (!database) {
return;
}
await ensureColumn(database, "tasks", "family", "TEXT");
await ensureColumn(database, "tasks", "target_host", "TEXT NOT NULL DEFAULT ''");
await ensureColumn(database, "tasks", "target_channel", "TEXT NOT NULL DEFAULT ''");
await ensureColumn(database, "tasks", "dispatch_method", "TEXT NOT NULL DEFAULT 'manual'");
await ensureColumn(database, "tasks", "dispatch_state", "TEXT NOT NULL DEFAULT 'planned'");
await ensureColumn(database, "tasks", "template_key", "TEXT");
await ensureColumn(database, "tasks", "repo_slug", "TEXT");
await ensureColumn(database, "tasks", "base_branch", "TEXT");
await ensureColumn(database, "tasks", "preferred_agent", "TEXT");
await ensureColumn(database, "tasks", "reasoning_effort", "TEXT");
await ensureColumn(database, "tasks", "model_hint", "TEXT");
await ensureColumn(database, "tasks", "last_dispatch_at", "TEXT");
await ensureColumn(database, "tasks", "acknowledged_at", "TEXT");
await ensureColumn(database, "tasks", "last_error", "TEXT");
})();
return database;
}
export function all<T>(sql: string, params: unknown[] = []) {
async function ensureReady() {
getDatabase();
if (databaseReady) {
await databaseReady;
}
}
export async function all<T>(sql: string, params: unknown[] = []) {
await ensureReady();
const db = getDatabase();
return new Promise<T[]>((resolve, reject) => {
db.all(sql, params, (error, rows) => {
@@ -59,7 +145,8 @@ export function all<T>(sql: string, params: unknown[] = []) {
});
}
export function get<T>(sql: string, params: unknown[] = []) {
export async function get<T>(sql: string, params: unknown[] = []) {
await ensureReady();
const db = getDatabase();
return new Promise<T | undefined>((resolve, reject) => {
db.get(sql, params, (error, row) => {
@@ -72,7 +159,8 @@ export function get<T>(sql: string, params: unknown[] = []) {
});
}
export function run(sql: string, params: unknown[] = []) {
export async function run(sql: string, params: unknown[] = []) {
await ensureReady();
const db = getDatabase();
return new Promise<{ lastID: number; changes: number }>((resolve, reject) => {
db.run(sql, params, function onRun(error) {

254
lib/dispatch.ts Normal file
View File

@@ -0,0 +1,254 @@
import fs from "node:fs";
import path from "node:path";
import { promisify } from "node:util";
import { execFile } from "node:child_process";
import {
REPO_ACCESS_ROOTS,
SWARM_REPO_MAP_FILE,
SWARM_TASKS_FILE,
SWARM_WORKTREES_DIR,
ZEROCLAW_WEBHOOK_TIMEOUT_MS,
} from "@/lib/fleet-config";
import { findAgentByAssignmentKey } from "@/lib/agents";
import { appendTaskEvent, findTask, updateTask } from "@/lib/tasks";
import type { DispatchState } from "@/lib/types";
const execFileAsync = promisify(execFile);
type DispatchResult = {
state: DispatchState;
summary: string;
detail: string;
};
function defaultModelForAgent(agent: string) {
switch (agent) {
case "opencode":
return "zai-coding-plan/glm-4.7";
case "gemini":
return "gemini-3.1-pro";
default:
return "gpt-5.3-codex";
}
}
function readRepoMap() {
if (!fs.existsSync(SWARM_REPO_MAP_FILE)) {
throw new Error(`missing_repo_map:${SWARM_REPO_MAP_FILE}`);
}
return JSON.parse(fs.readFileSync(SWARM_REPO_MAP_FILE, "utf8")) as Record<string, string>;
}
function ensureAllowedRepoPath(repoPath: string) {
const resolved = path.resolve(repoPath);
const allowed = REPO_ACCESS_ROOTS.some((root) => resolved.startsWith(path.resolve(root)));
if (!allowed) {
throw new Error(`repo_path_not_allowed:${resolved}`);
}
return resolved;
}
function ensureSwarmRegistry() {
fs.mkdirSync(path.dirname(SWARM_TASKS_FILE), { recursive: true });
if (!fs.existsSync(SWARM_TASKS_FILE)) {
fs.writeFileSync(SWARM_TASKS_FILE, JSON.stringify({ tasks: [] }, null, 2));
}
}
async function dispatchOpenClawTask(taskId: number) {
const task = await findTask(taskId);
if (!task) {
throw new Error("task_not_found");
}
if (!task.repo_slug) {
throw new Error("repo_slug_required_for_openclaw_dispatch");
}
const repoMap = readRepoMap();
const repoPath = ensureAllowedRepoPath(repoMap[task.repo_slug] || "");
if (!repoPath || !fs.existsSync(path.join(repoPath, ".git"))) {
throw new Error(`repo_not_available:${task.repo_slug}`);
}
const agentName = task.preferred_agent || "codex";
const taskKey = `taskboard-${task.id}`;
const repoName = path.basename(repoPath);
const worktree = path.join(SWARM_WORKTREES_DIR, repoName, taskKey);
const branch = `feat/taskboard-${task.id}`;
const baseBranch = task.base_branch || "main";
fs.mkdirSync(path.dirname(worktree), { recursive: true });
if (!fs.existsSync(worktree)) {
try {
await execFileAsync("git", ["-C", repoPath, "fetch", "origin", baseBranch]);
} catch {
// Keep going. Many local repos already have the base branch available.
}
await execFileAsync("git", ["-C", repoPath, "worktree", "add", worktree, "-b", branch, `origin/${baseBranch}`]);
}
ensureSwarmRegistry();
const registry = JSON.parse(fs.readFileSync(SWARM_TASKS_FILE, "utf8")) as { tasks?: Array<Record<string, unknown>> };
const tasks = Array.isArray(registry.tasks) ? registry.tasks : [];
const existing = tasks.find((entry) => entry.taskboardTaskId === task.id);
if (!existing) {
tasks.push({
id: taskKey,
agent: agentName,
repo: repoName,
repoPath,
repoSlug: task.repo_slug,
worktree,
branch,
baseBranch,
tmuxSession: `${agentName}-${taskKey}`,
description: task.description,
prompt: `Taskboard task #${task.id}: ${task.title}\n\n${task.description}`,
status: "queued",
notifyOnComplete: true,
attempts: 0,
maxAttempts: 3,
model: task.model_hint || defaultModelForAgent(agentName),
reasoning: task.reasoning_effort || "high",
taskboardTaskId: task.id,
startedAt: null,
completedAt: null,
createdAt: Date.now(),
checks: {
prCreated: false,
ciPassed: false,
reviewPassed: false,
},
});
}
fs.writeFileSync(SWARM_TASKS_FILE, JSON.stringify({ tasks }, null, 2));
return {
state: "dispatched" as const,
summary: `Queued in OpenClaw swarm for ${agentName}`,
detail: `${task.repo_slug} -> ${worktree}`,
};
}
async function dispatchZeroClawTask(taskId: number) {
const task = await findTask(taskId);
if (!task) {
throw new Error("task_not_found");
}
const agent = await findAgentByAssignmentKey(task.assignee);
if (!agent) {
throw new Error("assignee_not_found");
}
const urlEnv = agent.slug === "grizzley-zeroclaw" ? "ZEROCLAW_GRIZZLEY_URL" : "ZEROCLAW_ICE_URL";
const tokenEnv = agent.slug === "grizzley-zeroclaw" ? "ZEROCLAW_GRIZZLEY_TOKEN" : "ZEROCLAW_ICE_TOKEN";
const baseUrl = process.env[urlEnv];
if (!baseUrl) {
throw new Error(`missing_gateway_url:${urlEnv}`);
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), ZEROCLAW_WEBHOOK_TIMEOUT_MS);
const response = await fetch(`${baseUrl.replace(/\/$/, "")}/webhook`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(process.env[tokenEnv] ? { Authorization: `Bearer ${process.env[tokenEnv]}` } : {}),
},
body: JSON.stringify({
message: `Taskboard task #${task.id}: ${task.title}\nHost: ${task.target_host || agent.host}\nChannel: ${task.target_channel || "n/a"}\n\n${task.description}`,
}),
signal: controller.signal,
});
clearTimeout(timeout);
if (!response.ok) {
const responseText = await response.text();
throw new Error(`webhook_failed:${response.status}:${responseText}`);
}
const responseText = await response.text();
return {
state: "dispatched" as const,
summary: `Posted to ${agent.name} webhook`,
detail: responseText || `${agent.host} webhook accepted`,
};
}
export async function dispatchTask(taskId: number) {
const task = await findTask(taskId);
if (!task) {
throw new Error("task_not_found");
}
if (!task.assignee) {
throw new Error("assignee_required");
}
const agent = await findAgentByAssignmentKey(task.assignee);
if (!agent) {
throw new Error("assignee_not_found");
}
await appendTaskEvent({
taskId,
assignee: task.assignee,
family: task.family,
host: task.target_host,
eventType: "dispatch_requested",
state: task.dispatch_state,
summary: `Dispatch requested for ${agent.name}`,
detail: task.description,
});
try {
const result =
agent.family === "openclaw" ? await dispatchOpenClawTask(taskId) : await dispatchZeroClawTask(taskId);
const updated = await updateTask(taskId, {
status: task.status === "Backlog" ? "Todo" : task.status,
dispatch_state: result.state,
last_dispatch_at: new Date().toISOString(),
last_error: null,
});
if (!updated) {
throw new Error("task_not_found_after_dispatch");
}
await appendTaskEvent({
taskId,
assignee: updated.assignee,
family: updated.family,
host: updated.target_host,
eventType: "dispatch_succeeded",
state: updated.dispatch_state,
summary: result.summary,
detail: result.detail,
});
return updated;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const updated = await updateTask(taskId, {
dispatch_state: "failed",
last_error: message,
});
await appendTaskEvent({
taskId,
assignee: task.assignee,
family: task.family,
host: task.target_host,
eventType: "dispatch_failed",
state: "failed",
summary: "Dispatch failed",
detail: message,
});
if (!updated) {
throw error;
}
return updated;
}
}

View File

@@ -1,124 +1,48 @@
import type { ArchitectureDocument } from "@/lib/types";
import fs from "node:fs";
import path from "node:path";
export const OPENCLAW_RUNTIME_ROOT =
process.env.OPENCLAW_RUNTIME_ROOT || "/home/bear/.openclaw";
export const OPENCLAW_AGENTS_DIR =
process.env.AGENTS_DIR || "/home/bear/.openclaw/agents";
export const OPENCLAW_CONFIG_PATH =
process.env.OPENCLAW_CONFIG || "/home/bear/.openclaw/openclaw.json";
export const ZEROCLAW_PRIMARY_DIR =
process.env.ZEROCLAW_PRIMARY_DIR || "/app/zeroclaw/grizzley";
export const ZEROCLAW_CONTROL_DIR =
process.env.ZEROCLAW_CONTROL_DIR || "/app/zeroclaw/ice";
import type { FleetConfig, TaskTemplate } from "@/lib/types";
export const ARCHITECTURE_DOCUMENT: ArchitectureDocument = {
generatedAt: new Date().toISOString(),
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 REPO_ACCESS_ROOTS = (process.env.REPO_ACCESS_ROOTS || "/srv/apps,/home/bear")
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
export const ZEROCLAW_PRIMARY_DIR = process.env.ZEROCLAW_PRIMARY_DIR || "/app/zeroclaw/grizzley";
export const ZEROCLAW_CONTROL_DIR = process.env.ZEROCLAW_CONTROL_DIR || "/app/zeroclaw/ice";
export const ZEROCLAW_WEBHOOK_TIMEOUT_MS = Number(process.env.ZEROCLAW_WEBHOOK_TIMEOUT_MS || "15000");
const CONFIG_DIR = path.join(process.cwd(), "config");
const FLEET_CONFIG_PATH = path.join(CONFIG_DIR, "fleet.json");
const TASK_TEMPLATE_PATH = path.join(CONFIG_DIR, "task-templates.json");
function readJsonFile<T>(filePath: string, fallback: T): T {
try {
return JSON.parse(fs.readFileSync(filePath, "utf8")) as T;
} catch {
return fallback;
}
}
export const FLEET_CONFIG = readJsonFile<FleetConfig>(FLEET_CONFIG_PATH, {
title: "Claw Fleet Architecture",
overview: [
"OpenClaw is the ubuntu-local orchestration layer and Telegram HQ entrypoint.",
"ZeroClaw provides host-scoped remote administration on grizzley and ice.",
"Task assignment is shared across both families in a single board.",
],
topologyDiagram: String.raw`
Telegram / Forum Topics
|
+----------------+----------------+
| |
v v
OpenClaw gateway ZeroClaw control
ubuntu :18789 ice zeroclaw-admin
local swarm topic router / paired gateway
| |
+------------+--------------------+
|
v
shared taskboard UI
|
+-----------+-----------+
| |
v v
OpenClaw agents ZeroClaw runtimes
ubuntu-local swarm grizzley / ice
`,
sections: [
{
id: "openclaw",
title: "OpenClaw",
summary:
"Primary orchestration family on ubuntu. Owns local swarm execution, HQ Telegram bindings, and ubuntu-host workflows.",
runtime: [
{ label: "Host", value: "ubuntu (192.168.50.61)" },
{ label: "Service", value: "openclaw.service" },
{ label: "Runtime", value: "/srv/state/openclaw/current" },
{ label: "Config", value: "/home/bear/.openclaw/openclaw.json" },
],
channels: [
{ label: "Telegram DM", value: "allowlist: tg:5512934365" },
{ label: "Forum Group", value: "Homelab HQ (-1003809447066)" },
{ label: "Gateway", value: "LAN bind :18789 with token auth" },
],
configuredAgents: [
"main",
"ubuntu",
"docs",
"gitea-admin",
"planner",
"builder",
"reviewer",
],
diagram: String.raw`
OpenClaw HQ topics
topic 2 -> ubuntu
topic 3 -> docs
topic 4 -> gitea-admin
topics 5-9 -> main, then delegate to host-scoped ZeroClaw paths
overview: [],
topologyDiagram: "",
sections: [],
zeroclawAgents: [],
});
main
|- ubuntu
|- docs
|- gitea-admin
|- planner
|- builder
\- reviewer
`,
notes: [
"Remote host personas were removed from OpenClaw.",
"OpenClaw remains gateway-only on ubuntu.",
],
},
{
id: "zeroclaw",
title: "ZeroClaw",
summary:
"Host-scoped runtime family for remote administration. Grizzley is the primary active gateway. Ice is the control-plane runtime and topic router.",
runtime: [
{ label: "Primary", value: "/srv/state/zeroclaw/current on grizzley" },
{ label: "Control", value: "/home/bear/.zeroclaw-admin on ice" },
{ label: "Primary Service", value: "zeroclaw.service" },
{ label: "Control Service", value: "zeroclaw-admin.service" },
],
channels: [
{ label: "Grizzley Gateway", value: "HTTP gateway :3000, pairing required" },
{ label: "Ice Telegram", value: "Homelab-Ice (-1003728617160)" },
{ label: "Remote Routing", value: "paired status/webhook to grizzley and pve" },
],
configuredAgents: ["grizzley-zeroclaw", "ice-zeroclaw"],
diagram: String.raw`
Homelab-Ice topics
11 -> local ice operations
12 -> grizzley paired gateway
13 -> pve paired gateway
14 -> truenas blocker message
15 -> panda rollout pending
export const TASK_TEMPLATES = readJsonFile<TaskTemplate[]>(TASK_TEMPLATE_PATH, []);
ice zeroclaw-admin
-> zeroclaw-remote-gateway.sh status grizzley|pve
-> zeroclaw-remote-gateway.sh webhook grizzley|pve "<message>"
`,
notes: [
"Grizzley is host-scoped and should not proxy other hosts directly.",
"Ice still uses host-local secret/encryption state under /home/bear/.zeroclaw-admin.",
],
},
],
export const ARCHITECTURE_DOCUMENT = {
generatedAt: new Date().toISOString(),
title: FLEET_CONFIG.title,
overview: FLEET_CONFIG.overview,
sections: FLEET_CONFIG.sections,
topologyDiagram: FLEET_CONFIG.topologyDiagram,
};

View File

@@ -2,39 +2,133 @@ import fs from "node:fs";
import path from "node:path";
import { all, get, run } from "@/lib/db";
import type { TaskPriority, TaskRecord, TaskStatus } from "@/lib/types";
const VALID_STATUSES: TaskStatus[] = [
"Backlog",
"Todo",
"In Progress",
"Review",
"Done",
];
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"];
const VALID_DISPATCH_METHODS: DispatchMethod[] = ["manual", "openclaw-swarm", "zeroclaw-webhook"];
const VALID_DISPATCH_STATES: DispatchState[] = [
"planned",
"assigned",
"dispatched",
"acknowledged",
"completed",
"failed",
];
const WIKI_DIR = process.env.WIKI_DIR || path.join(process.cwd(), "wiki");
fs.mkdirSync(WIKI_DIR, { recursive: true });
type DatabaseTaskRow = Omit<TaskRecord, "tags"> & { tags: string };
export function normalizeTask(row: DatabaseTaskRow): TaskRecord {
let tags: string[] = [];
function parseTags(raw: string) {
try {
tags = JSON.parse(row.tags || "[]");
const parsed = JSON.parse(raw || "[]");
return Array.isArray(parsed) ? parsed.filter((tag) => typeof tag === "string") : [];
} catch {
tags = [];
return [];
}
}
function extractTagValue(tags: string[], prefix: string) {
const match = tags.find((tag) => tag.startsWith(prefix));
return match ? match.slice(prefix.length) : null;
}
function deriveDispatchState(task: Partial<TaskRecord>, existing?: TaskRecord): DispatchState {
if (task.dispatch_state && VALID_DISPATCH_STATES.includes(task.dispatch_state)) {
return task.dispatch_state;
}
if (task.status === "Done") {
return "completed";
}
const status = task.status ?? existing?.status;
const priorState = existing?.dispatch_state ?? "planned";
if (status === "In Progress" || status === "Review") {
return priorState === "failed" ? priorState : "acknowledged";
}
if (status === "Todo" && (priorState === "planned" || priorState === "assigned")) {
return existing?.assignee || task.assignee ? "assigned" : "planned";
}
return existing?.assignee || task.assignee ? priorState === "planned" ? "assigned" : priorState : "planned";
}
function deriveAcknowledgedAt(
nextState: DispatchState,
existing?: TaskRecord,
explicitValue?: string | null,
) {
if (explicitValue !== undefined) {
return explicitValue;
}
if (nextState === "acknowledged" || nextState === "completed") {
return existing?.acknowledged_at || new Date().toISOString();
}
return null;
}
function normalizeNullableString(value: unknown) {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
export function normalizeTask(row: DatabaseTaskRow): TaskRecord {
return {
...row,
tags,
tags: parseTags(row.tags),
family: row.family || null,
target_host: row.target_host || "",
target_channel: row.target_channel || "",
dispatch_method: row.dispatch_method || "manual",
dispatch_state: row.dispatch_state || "planned",
template_key: row.template_key || null,
repo_slug: row.repo_slug || null,
base_branch: row.base_branch || null,
preferred_agent: row.preferred_agent || null,
reasoning_effort: row.reasoning_effort || null,
model_hint: row.model_hint || null,
last_dispatch_at: row.last_dispatch_at || null,
acknowledged_at: row.acknowledged_at || null,
last_error: row.last_error || null,
};
}
export async function listTasks() {
const rows = await all<DatabaseTaskRow>("SELECT * FROM tasks ORDER BY id DESC");
const rows = await all<DatabaseTaskRow>(
`SELECT * FROM tasks
ORDER BY
CASE dispatch_state WHEN 'failed' THEN 0 ELSE 1 END,
CASE status
WHEN 'In Progress' THEN 0
WHEN 'Review' THEN 1
WHEN 'Todo' THEN 2
WHEN 'Backlog' THEN 3
ELSE 4
END,
id DESC`,
);
return rows.map(normalizeTask);
}
export async function listFailedTasks() {
const rows = await all<DatabaseTaskRow>(
"SELECT * FROM tasks WHERE dispatch_state = 'failed' ORDER BY updated_at DESC",
);
return rows.map(normalizeTask);
}
@@ -43,10 +137,7 @@ export async function findTask(id: number) {
return row ? normalizeTask(row) : null;
}
export function validateTaskPayload(
payload: Partial<TaskRecord> & { tags?: unknown },
partial = false,
) {
export function validateTaskPayload(payload: Partial<TaskRecord> & { tags?: unknown }, partial = false) {
const errors: string[] = [];
if (!partial || payload.title !== undefined) {
@@ -63,6 +154,18 @@ export function validateTaskPayload(
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");
}
@@ -78,6 +181,9 @@ function buildWikiMarkdown(task: TaskRecord) {
- 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()}
@@ -98,17 +204,77 @@ async function writeWikiForTask(task: TaskRecord) {
fs.writeFileSync(path.join(WIKI_DIR, fileName), buildWikiMarkdown(task), "utf8");
}
export async function appendTaskEvent(input: {
taskId: number;
assignee?: string;
family?: AgentFamily | null;
host?: string;
eventType: TaskEventType;
state?: DispatchState | null;
summary: string;
detail?: string;
}) {
await run(
`INSERT INTO task_events (task_id, assignee, family, host, event_type, state, summary, detail)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[
input.taskId,
input.assignee || "",
input.family || null,
input.host || "",
input.eventType,
input.state || null,
input.summary,
input.detail || "",
],
);
}
export async function listTaskEvents(taskId?: number, limit = 50) {
const rows = taskId
? await all<TaskEvent>(
"SELECT * FROM task_events WHERE task_id = ? ORDER BY created_at DESC LIMIT ?",
[taskId, limit],
)
: await all<TaskEvent>("SELECT * FROM task_events ORDER BY created_at DESC LIMIT ?", [limit]);
return rows;
}
export async function listTaskTemplates(): Promise<TaskTemplate[]> {
return TASK_TEMPLATES;
}
export async function createTask(input: Partial<TaskRecord>) {
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, priority, status, tags)
VALUES (?, ?, ?, ?, ?, ?)`,
`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,
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(Array.isArray(input.tags) ? input.tags.filter((tag) => typeof tag === "string") : []),
JSON.stringify(tags),
input.last_dispatch_at || null,
deriveAcknowledgedAt(dispatchState),
normalizeNullableString(input.last_error),
],
);
@@ -117,6 +283,17 @@ export async function createTask(input: Partial<TaskRecord>) {
throw new Error("failed_to_fetch_created_task");
}
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;
}
@@ -126,24 +303,40 @@ export async function updateTask(id: number, input: Partial<TaskRecord>) {
return null;
}
const mergedTags = Array.isArray(input.tags) ? input.tags.filter((tag) => typeof tag === "string") : existing.tags;
const nextStatus = input.status ?? existing.status;
const completedAt =
nextStatus === "Done"
? existing.completed_at || new Date().toISOString()
: null;
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 = ?, priority = ?, status = ?, tags = ?,
completed_at = ?, updated_at = datetime('now')
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 = ?,
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(input.tags ?? existing.tags),
JSON.stringify(mergedTags),
input.last_dispatch_at ?? existing.last_dispatch_at,
acknowledgedAt,
input.last_error ?? existing.last_error,
completedAt,
id,
],
@@ -154,6 +347,23 @@ export async function updateTask(id: number, input: Partial<TaskRecord>) {
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);
}

View File

@@ -2,20 +2,82 @@ export type TaskStatus = "Backlog" | "Todo" | "In Progress" | "Review" | "Done";
export type TaskPriority = "Low" | "Medium" | "High" | "Critical";
export type AgentFamily = "openclaw" | "zeroclaw";
export type AgentStatus = "active" | "busy" | "idle";
export type DispatchMethod = "openclaw-swarm" | "zeroclaw-webhook" | "manual";
export type DispatchState =
| "planned"
| "assigned"
| "dispatched"
| "acknowledged"
| "completed"
| "failed";
export type TaskRecord = {
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;
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 WikiPageSummary = {
filename: string;
title: string;
@@ -50,6 +112,7 @@ export type FleetAgent = {
role: string;
runtimePath: string;
configPath: string | null;
defaultDispatchMethod: DispatchMethod;
model: string | null;
emoji: string;
channels: AgentRouteSummary[];
@@ -61,6 +124,10 @@ export type FleetAgent = {
activeTasks: TaskRecord[];
completedTasks: TaskRecord[];
currentTask: string | null;
heartbeatAt: string | null;
heartbeatAgeMinutes: number | null;
lastEvent: TaskEvent | null;
failureStreak: number;
notes: string[];
};
@@ -82,3 +149,33 @@ export type ArchitectureDocument = {
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 FleetConfig = {
title: string;
overview: string[];
topologyDiagram: string;
sections: FleetSection[];
zeroclawAgents: ZeroClawAgentDefinition[];
};