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