[taskboard] add direct host dispatch targets
This commit is contained in:
@@ -289,13 +289,54 @@ async function buildZeroClawAgents() {
|
||||
);
|
||||
}
|
||||
|
||||
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] = await Promise.all([
|
||||
const [openclawAgents, zeroclawAgents, directAgents] = await Promise.all([
|
||||
buildOpenClawAgents(),
|
||||
buildZeroClawAgents(),
|
||||
buildDirectAgents(),
|
||||
]);
|
||||
|
||||
return [...openclawAgents, ...zeroclawAgents];
|
||||
return [...openclawAgents, ...zeroclawAgents, ...directAgents];
|
||||
}
|
||||
|
||||
export async function findAgentByAssignmentKey(assignmentKey: string) {
|
||||
|
||||
107
lib/dispatch.ts
107
lib/dispatch.ts
@@ -4,6 +4,9 @@ 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,
|
||||
@@ -12,8 +15,8 @@ import {
|
||||
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";
|
||||
import { appendTaskEvent, applyTaskCallback, findTask, updateTask } from "@/lib/tasks";
|
||||
import type { DispatchState, TaskCallbackPayload } from "@/lib/types";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -21,6 +24,7 @@ type DispatchResult = {
|
||||
state: DispatchState;
|
||||
summary: string;
|
||||
detail: string;
|
||||
callback?: TaskCallbackPayload;
|
||||
};
|
||||
|
||||
function defaultModelForAgent(agent: string) {
|
||||
@@ -58,7 +62,20 @@ function ensureSwarmRegistry() {
|
||||
}
|
||||
}
|
||||
|
||||
async function dispatchOpenClawTask(taskId: number) {
|
||||
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.trim();
|
||||
if (trimmed.length <= maxLength) {
|
||||
return trimmed;
|
||||
}
|
||||
return `${trimmed.slice(0, maxLength - 15)}\n...[truncated]`;
|
||||
}
|
||||
|
||||
async function dispatchOpenClawTask(taskId: number): Promise<DispatchResult> {
|
||||
const task = await findTask(taskId);
|
||||
if (!task) {
|
||||
throw new Error("task_not_found");
|
||||
@@ -145,7 +162,7 @@ async function dispatchOpenClawTask(taskId: number) {
|
||||
};
|
||||
}
|
||||
|
||||
async function dispatchZeroClawTask(taskId: number) {
|
||||
async function dispatchZeroClawTask(taskId: number): Promise<DispatchResult> {
|
||||
const task = await findTask(taskId);
|
||||
if (!task) {
|
||||
throw new Error("task_not_found");
|
||||
@@ -190,6 +207,73 @@ async function dispatchZeroClawTask(taskId: number) {
|
||||
};
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
const sshArgs = [
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-o",
|
||||
"StrictHostKeyChecking=accept-new",
|
||||
"-o",
|
||||
"ConnectTimeout=15",
|
||||
"-i",
|
||||
DIRECT_SSH_KEY_PATH,
|
||||
"-p",
|
||||
String(directAgent.dispatch.port),
|
||||
`${directAgent.dispatch.user}@${directAgent.dispatch.hostname}`,
|
||||
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) {
|
||||
@@ -217,7 +301,20 @@ export async function dispatchTask(taskId: number) {
|
||||
|
||||
try {
|
||||
const result =
|
||||
agent.family === "openclaw" ? await dispatchOpenClawTask(taskId) : await dispatchZeroClawTask(taskId);
|
||||
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,
|
||||
|
||||
@@ -18,6 +18,8 @@ export const REPO_ACCESS_ROOTS = (process.env.REPO_ACCESS_ROOTS || "/srv/apps,/h
|
||||
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");
|
||||
@@ -37,6 +39,7 @@ export const FLEET_CONFIG = readJsonFile<FleetConfig>(FLEET_CONFIG_PATH, {
|
||||
topologyDiagram: "",
|
||||
sections: [],
|
||||
zeroclawAgents: [],
|
||||
directAgents: [],
|
||||
});
|
||||
|
||||
export const TASK_TEMPLATES = readJsonFile<TaskTemplate[]>(TASK_TEMPLATE_PATH, []);
|
||||
|
||||
@@ -17,8 +17,8 @@ import type {
|
||||
|
||||
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_FAMILIES: AgentFamily[] = ["openclaw", "zeroclaw", "direct"];
|
||||
const VALID_DISPATCH_METHODS: DispatchMethod[] = ["manual", "openclaw-swarm", "zeroclaw-webhook", "direct-ssh"];
|
||||
const VALID_DISPATCH_STATES: DispatchState[] = [
|
||||
"planned",
|
||||
"assigned",
|
||||
@@ -392,6 +392,7 @@ export async function applyTaskCallback(id: number, payload: {
|
||||
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, {
|
||||
@@ -401,6 +402,7 @@ export async function applyTaskCallback(id: number, payload: {
|
||||
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) {
|
||||
|
||||
39
lib/types.ts
39
lib/types.ts
@@ -1,8 +1,8 @@
|
||||
export type TaskStatus = "Backlog" | "Todo" | "In Progress" | "Review" | "Done";
|
||||
export type TaskPriority = "Low" | "Medium" | "High" | "Critical";
|
||||
export type AgentFamily = "openclaw" | "zeroclaw";
|
||||
export type AgentFamily = "openclaw" | "zeroclaw" | "direct";
|
||||
export type AgentStatus = "active" | "busy" | "idle";
|
||||
export type DispatchMethod = "openclaw-swarm" | "zeroclaw-webhook" | "manual";
|
||||
export type DispatchMethod = "openclaw-swarm" | "zeroclaw-webhook" | "direct-ssh" | "manual";
|
||||
export type DispatchState =
|
||||
| "planned"
|
||||
| "assigned"
|
||||
@@ -88,6 +88,7 @@ export type TaskCallbackPayload = {
|
||||
detail?: string | null;
|
||||
completed_by?: string | null;
|
||||
last_error?: string | null;
|
||||
last_dispatch_at?: string | null;
|
||||
};
|
||||
|
||||
export type WikiPageSummary = {
|
||||
@@ -184,10 +185,44 @@ export type ZeroClawAgentDefinition = {
|
||||
};
|
||||
};
|
||||
|
||||
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[];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user