diff --git a/Dockerfile b/Dockerfile index 5aea24b..9fe5457 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,7 +14,7 @@ WORKDIR /app ENV NODE_ENV=production ENV PORT=8395 RUN apt-get update \ - && apt-get install -y --no-install-recommends git ca-certificates \ + && apt-get install -y --no-install-recommends git ca-certificates openssh-client \ && rm -rf /var/lib/apt/lists/* COPY --from=builder /app/public ./public diff --git a/README.md b/README.md index b8de182..a6d3545 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ It tracks and visualizes: - OpenClaw swarm agents on `ubuntu` - ZeroClaw host runtimes on `grizzley` and `ice` -- shared task assignment and dispatch across both families +- direct SSH host targets for `pve`, `truenas`, and `panda` +- shared task assignment and dispatch across all families - wiki pages and architecture documentation rendered in the UI - dispatch audit history, failure queues, heartbeat overlays, and task templates @@ -21,7 +22,7 @@ It tracks and visualizes: ## Key Pages - `/tasks` - unified Kanban board -- `/agents` - configured OpenClaw and ZeroClaw runtimes +- `/agents` - configured OpenClaw, ZeroClaw, and direct host targets - `/openclaw` - focused OpenClaw swarm view - `/zeroclaw` - focused ZeroClaw host-runtime view - `/dispatch` - dispatch audit log and failure queue @@ -35,10 +36,11 @@ It tracks and visualizes: - dispatch lifecycle states and SQLite audit history - OpenClaw swarm dispatch into `~/.clawdbot/active-tasks.json` - ZeroClaw webhook dispatch for `grizzley` and `ice` +- direct SSH dispatch for `pve`, `truenas`, and `panda` - task callback API for remote completion/result sync - OpenClaw registry sync API for swarm task state reconciliation - failure queue and dispatch history views -- family-specific runtime views for OpenClaw and ZeroClaw +- family-specific runtime views for OpenClaw and ZeroClaw plus unified direct-host visibility - architecture documentation rendered directly from tracked config ## Fleet Model @@ -64,7 +66,21 @@ It tracks and visualizes: - Channels: - paired HTTP gateway access - Homelab-Ice forum topics - - remote gateway routing from `ice` +- remote gateway routing from `ice` + +### Direct SSH Targets + +- Execution host: `ubuntu` taskboard container +- Transport: `ssh` using the mounted host key +- Configured targets: + - `pve` via `root@192.168.50.11` + - `truenas` via `christopher@192.168.50.12` + - `panda` via `bear@192.168.50.196` +- Dispatch model: + - select a direct target agent + - dispatch a built-in safe action + - capture stdout/stderr + - write completion through the same callback pipeline as remote runtimes ## Important Environment Variables @@ -80,6 +96,8 @@ It tracks and visualizes: - `ZEROCLAW_GRIZZLEY_TOKEN` - `ZEROCLAW_ICE_URL` - `ZEROCLAW_ICE_TOKEN` +- `DIRECT_SSH_KEY_PATH` +- `DIRECT_SSH_TIMEOUT_MS` ## Development @@ -106,11 +124,15 @@ npm start - ZeroClaw architecture: - rendered from the tracked fleet model in this repo - optional runtime path overrides can be provided via `ZEROCLAW_PRIMARY_DIR` and `ZEROCLAW_CONTROL_DIR` +- Direct SSH: + - taskboard container mounts `/home/bear/.ssh` as read-only + - direct targets use `/root/.ssh/id_ed25519` by default ## Notes -- The UI intentionally treats OpenClaw and ZeroClaw as separate families with different runtime and channel models. +- The UI intentionally treats OpenClaw, ZeroClaw, and direct host targets as separate families with different runtime and channel models. - `ice` ZeroClaw remains tied to host-local secret/encryption state; the dashboard reads that runtime but does not attempt to rewrite it. +- Direct targets are intentionally limited to safe built-in actions from `config/fleet.json`, not arbitrary shell commands from the browser. ## Status Docs diff --git a/app/api/tasks/[id]/callback/route.ts b/app/api/tasks/[id]/callback/route.ts index 653ce95..6c01c57 100644 --- a/app/api/tasks/[id]/callback/route.ts +++ b/app/api/tasks/[id]/callback/route.ts @@ -19,6 +19,7 @@ export async function POST( detail?: string | null; completed_by?: string | null; last_error?: string | null; + last_dispatch_at?: string | null; }; const updated = await applyTaskCallback(numericId, payload); diff --git a/app/layout.tsx b/app/layout.tsx index 9afd173..e28ade7 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -6,7 +6,7 @@ import { AppShell } from "@/components/app-shell"; export const metadata: Metadata = { title: "Claw Fleet Console", - description: "OpenClaw and ZeroClaw fleet dashboard", + description: "OpenClaw, ZeroClaw, and direct host operations dashboard", }; export default async function RootLayout({ diff --git a/components/agents-client.tsx b/components/agents-client.tsx index 48dc9df..8fe6f71 100644 --- a/components/agents-client.tsx +++ b/components/agents-client.tsx @@ -45,7 +45,7 @@ export function AgentsClient({ Configured Agent Runtimes - OpenClaw swarm members and ZeroClaw host runtimes from the tracked fleet model with heartbeat and dispatch overlays. + OpenClaw swarm members, ZeroClaw runtimes, and direct host targets from the tracked fleet model with heartbeat and dispatch overlays. @@ -58,6 +58,7 @@ export function AgentsClient({ + @@ -75,7 +76,9 @@ export function AgentsClient({ {agent.role}
- {agent.family} + + {agent.family} + {agent.status}
diff --git a/components/dispatch-history.tsx b/components/dispatch-history.tsx index e3b3015..6492a7f 100644 --- a/components/dispatch-history.tsx +++ b/components/dispatch-history.tsx @@ -27,7 +27,7 @@ export function DispatchHistory({

Task #{event.task_id} • {event.assignee || "unassigned"} • {event.host || "n/a"}

- + {event.family || "manual"} diff --git a/components/tasks-client.tsx b/components/tasks-client.tsx index 8992bae..e1cc697 100644 --- a/components/tasks-client.tsx +++ b/components/tasks-client.tsx @@ -21,7 +21,13 @@ const COLUMNS: TaskStatus[] = ["Backlog", "Todo", "In Progress", "Review", "Done const PRIORITIES: TaskPriority[] = ["Low", "Medium", "High", "Critical"]; function familyVariant(family: string | null) { - return family === "zeroclaw" ? "success" : "default"; + if (family === "zeroclaw") { + return "success"; + } + if (family === "direct") { + return "warning"; + } + return "default"; } function dispatchVariant(state: TaskRecord["dispatch_state"]) { @@ -159,7 +165,7 @@ export function TasksClient({ Unified Task Intake - Create typed tasks, apply dispatch templates, and route work to OpenClaw or ZeroClaw. + Create typed tasks, apply dispatch templates, and route work to OpenClaw, ZeroClaw, or direct host targets. @@ -286,7 +292,7 @@ export function TasksClient({ Recent Dispatch Activity - Latest control-plane events across both families. + Latest control-plane events across all configured task families. diff --git a/config/fleet.json b/config/fleet.json index ab66646..c8fdf3d 100644 --- a/config/fleet.json +++ b/config/fleet.json @@ -3,9 +3,10 @@ "overview": [ "OpenClaw is the ubuntu-local orchestration layer and Telegram HQ entrypoint.", "ZeroClaw provides host-scoped remote administration on grizzley and ice.", - "The taskboard is the shared planning, dispatch, and audit surface across both families." + "Direct SSH targets extend the taskboard to hosts that do not run an active Claw runtime.", + "The taskboard is the shared planning, dispatch, and audit surface across all host-operation families." ], - "topologyDiagram": " Telegram / Forum Topics\n |\n +----------------+----------------+\n | |\n v v\n OpenClaw gateway ZeroClaw control\n ubuntu :18789 ice zeroclaw-admin\n local swarm topic router / paired gateway\n | |\n +------------+--------------------+\n |\n v\n shared taskboard UI\n |\n +-----------+-----------+\n | |\n v v\n OpenClaw agents ZeroClaw runtimes\n ubuntu-local swarm grizzley / ice\n", + "topologyDiagram": " Telegram / Forum Topics\n |\n +----------------+----------------+\n | |\n v v\n OpenClaw gateway ZeroClaw control\n ubuntu :18789 ice zeroclaw-admin\n local swarm topic router / paired gateway\n | |\n +------------+--------------------+\n |\n v\n shared taskboard UI\n |\n +-----------------+---------------------+\n | | |\n v v v\n OpenClaw agents ZeroClaw runtimes Direct SSH targets\n ubuntu-local grizzley / ice pve / truenas / panda\n", "sections": [ { "id": "openclaw", @@ -62,6 +63,31 @@ "Grizzley is host-scoped and should not proxy other hosts directly.", "Ice still uses host-local secret and encryption state under /home/bear/.zeroclaw-admin." ] + }, + { + "id": "direct", + "title": "Direct Host Targets", + "summary": "SSH-backed host operations for systems that do not run an active OpenClaw or ZeroClaw runtime. These flows execute safe, built-in host checks and complete through the taskboard callback pipeline.", + "runtime": [ + { "label": "Execution", "value": "taskboard container on ubuntu" }, + { "label": "Transport", "value": "SSH with mounted host key material" }, + { "label": "Key Path", "value": "/root/.ssh/id_ed25519 inside container" } + ], + "channels": [ + { "label": "PVE", "value": "root@192.168.50.11:22" }, + { "label": "TrueNAS", "value": "christopher@192.168.50.12:22" }, + { "label": "Panda", "value": "bear@192.168.50.196:22" } + ], + "configuredAgents": [ + "pve-direct", + "truenas-direct", + "panda-direct" + ], + "diagram": "taskboard direct SSH\n -> pve : built-in Proxmox overview\n -> truenas : built-in storage overview\n -> panda : built-in SSH add-on overview\n\nEach direct task\n -> ssh safe built-in command\n -> capture stdout/stderr\n -> task callback -> completed result\n", + "notes": [ + "Direct targets are for safe built-in actions, not arbitrary remote shell execution from the UI.", + "Completion state is written through the same callback pipeline used by remote agent runtimes." + ] } ], "zeroclawAgents": [ @@ -117,5 +143,132 @@ "description": "Posts JSON webhook payloads to the ice ZeroClaw runtime." } } + ], + "directAgents": [ + { + "slug": "pve-direct", + "assignmentKey": "pve-direct", + "aliases": ["pve-direct", "PVE Direct", "pve"], + "name": "PVE Direct", + "host": "pve", + "role": "Direct Proxmox host checks over SSH", + "runtimePath": "ssh://root@192.168.50.11:22", + "configPath": null, + "emoji": "P", + "channels": [ + { "label": "SSH", "value": "root@192.168.50.11:22" }, + { "label": "Actions", "value": "proxmox-overview" } + ], + "tools": ["ssh", "systemctl", "pct", "qm"], + "capabilities": [ + "Verify core Proxmox services", + "Enumerate running LXC containers", + "Enumerate VM state" + ], + "files": [], + "notes": [ + "Uses direct SSH from the taskboard container.", + "Designed for safe built-in verification flows." + ], + "dispatch": { + "method": "direct-ssh", + "hostname": "192.168.50.11", + "user": "root", + "port": 22, + "defaultAction": "proxmox-overview", + "actions": [ + { + "key": "proxmox-overview", + "title": "Proxmox overview", + "description": "Verify core services and list active LXCs and VMs.", + "command": "systemctl is-active pve-cluster pvedaemon pveproxy pvestatd ssh && printf '\\nCTs:\\n' && pct list && printf '\\nVMs:\\n' && qm list", + "successSummary": "PVE services and guest inventory collected" + } + ] + } + }, + { + "slug": "truenas-direct", + "assignmentKey": "truenas-direct", + "aliases": ["truenas-direct", "TrueNAS Direct", "truenas"], + "name": "TrueNAS Direct", + "host": "truenas", + "role": "Direct storage checks over SSH", + "runtimePath": "ssh://christopher@192.168.50.12:22", + "configPath": null, + "emoji": "T", + "channels": [ + { "label": "SSH", "value": "christopher@192.168.50.12:22" }, + { "label": "Actions", "value": "storage-overview" } + ], + "tools": ["ssh", "zfs", "systemctl", "midclt"], + "capabilities": [ + "Verify storage datasets", + "Check docker service state", + "Report host identity and storage status" + ], + "files": [], + "notes": [ + "Runs safe read-only storage checks.", + "Does not modify datasets or apps." + ], + "dispatch": { + "method": "direct-ssh", + "hostname": "192.168.50.12", + "user": "christopher", + "port": 22, + "defaultAction": "storage-overview", + "actions": [ + { + "key": "storage-overview", + "title": "Storage overview", + "description": "Report host identity, docker-app service state, and top-level ZFS datasets.", + "command": "printf 'Host: '; hostname && printf '\\nDocker apps service:\\n' && systemctl is-active truenas-docker-apps.service || true && printf '\\nDatasets:\\n' && zfs list -o name,used,avail | head -n 12", + "successSummary": "TrueNAS storage overview collected" + } + ] + } + }, + { + "slug": "panda-direct", + "assignmentKey": "panda-direct", + "aliases": ["panda-direct", "Panda Direct", "panda"], + "name": "Panda Direct", + "host": "panda", + "role": "Direct SSH add-on checks for the Home Assistant host", + "runtimePath": "ssh://bear@192.168.50.196:22", + "configPath": null, + "emoji": "H", + "channels": [ + { "label": "SSH", "value": "bear@192.168.50.196:22" }, + { "label": "Actions", "value": "ssh-addon-overview" } + ], + "tools": ["ssh", "hostname", "cat", "ls"], + "capabilities": [ + "Verify SSH add-on shell reachability", + "Report add-on OS state and mounted data files" + ], + "files": [], + "notes": [ + "Targets the Home Assistant SSH add-on shell, not a full host shell.", + "Uses shell-safe inspection commands that work without supervisor API auth." + ], + "dispatch": { + "method": "direct-ssh", + "hostname": "192.168.50.196", + "user": "bear", + "port": 22, + "defaultAction": "ssh-addon-overview", + "actions": [ + { + "key": "ssh-addon-overview", + "title": "SSH add-on overview", + "description": "Report add-on shell identity, OS information, and mounted /data files.", + "command": "printf 'Host: '; hostname && printf '\\nOS:\\n' && cat /etc/os-release && printf '\\nData dir:\\n' && ls -1 /data 2>/dev/null | head -n 10", + "successSummary": "Panda SSH add-on overview collected" + } + ] + } + } ] } diff --git a/config/task-templates.json b/config/task-templates.json index 2c9a6a2..d039352 100644 --- a/config/task-templates.json +++ b/config/task-templates.json @@ -55,5 +55,44 @@ "dispatchMethod": "zeroclaw-webhook", "reasoningEffort": "medium" } + }, + { + "key": "direct-pve-check", + "title": "PVE direct verification", + "summary": "Run the built-in Proxmox overview action through the direct SSH target.", + "family": "direct", + "tags": ["host-ops", "service-check", "action:proxmox-overview"], + "defaults": { + "priority": "High", + "dispatchMethod": "direct-ssh", + "targetHost": "pve", + "targetChannel": "root@192.168.50.11:22" + } + }, + { + "key": "direct-truenas-check", + "title": "TrueNAS direct verification", + "summary": "Run the built-in storage overview action through the direct SSH target.", + "family": "direct", + "tags": ["host-ops", "storage-check", "action:storage-overview"], + "defaults": { + "priority": "High", + "dispatchMethod": "direct-ssh", + "targetHost": "truenas", + "targetChannel": "christopher@192.168.50.12:22" + } + }, + { + "key": "direct-panda-check", + "title": "Panda direct verification", + "summary": "Run the built-in SSH add-on overview action through the direct target.", + "family": "direct", + "tags": ["host-ops", "home-assistant", "action:ssh-addon-overview"], + "defaults": { + "priority": "High", + "dispatchMethod": "direct-ssh", + "targetHost": "panda", + "targetChannel": "bear@192.168.50.196:22" + } } ] diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 1dc92d3..101e2a6 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -5,7 +5,7 @@ - Next.js App Router migration with React 19, Tailwind CSS, and shadcn-style UI primitives - Typed fleet model loaded from `config/fleet.json` - Typed task templates loaded from `config/task-templates.json` -- Unified task intake for OpenClaw and ZeroClaw +- Unified task intake for OpenClaw, ZeroClaw, and direct SSH targets - Dispatch lifecycle states: - `planned` - `assigned` @@ -33,15 +33,21 @@ - ZeroClaw webhook dispatch: - bearer-token support for paired gateways - direct gateway mode for testing +- Direct SSH dispatch: + - typed direct target definitions in `config/fleet.json` + - safe built-in host actions for `pve`, `truenas`, and `panda` + - completion written through the callback pipeline ## Verified Live - `grizzley` ZeroClaw webhook dispatch from taskboard - `ice` ZeroClaw webhook dispatch from taskboard - OpenClaw swarm queue creation and host worktree creation on `ubuntu` +- direct SSH host actions can now be dispatched for `pve`, `truenas`, and `panda` ## Current Limits - Taskboard can dispatch OpenClaw swarm tasks, but it does not yet monitor tmux session progress automatically. - ZeroClaw acknowledgements and completions are still operator-driven; remote runtimes do not push completion state back yet. - The board records remote webhook responses, but not structured per-step execution output from the agents. +- Direct targets are intentionally restricted to configured safe actions and do not expose arbitrary shell execution in the UI. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index f14d4ec..a854ba9 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -12,10 +12,10 @@ - Persist remote execution summaries - Auto-transition tasks to `acknowledged` or `completed` -3. Add per-host direct taskboard targets beyond the current fleet. - - `pve` - - `truenas` - - `panda` +3. Expand direct host operations beyond the first safe action set. + - add more read-only Proxmox actions + - add richer TrueNAS storage and service checks + - add more Home Assistant supervisor and add-on checks 4. Add operator controls for swarm execution. - launch queued task @@ -43,3 +43,4 @@ - Introduce a fleet capability registry so the taskboard can validate whether a task is legal for a given host before dispatch. - Add authentication and RBAC for multi-operator use. - Add generated runbooks and service maps directly from live host inventory. +- Add a dedicated direct-host page or dashboard slice for SSH-backed targets. diff --git a/lib/agents.ts b/lib/agents.ts index cd36f91..6c983de 100644 --- a/lib/agents.ts +++ b/lib/agents.ts @@ -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) { diff --git a/lib/dispatch.ts b/lib/dispatch.ts index a62b8c7..4255201 100644 --- a/lib/dispatch.ts +++ b/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 { 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 { 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 { + 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, diff --git a/lib/fleet-config.ts b/lib/fleet-config.ts index 6ff244b..3aec651 100644 --- a/lib/fleet-config.ts +++ b/lib/fleet-config.ts @@ -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(FLEET_CONFIG_PATH, { topologyDiagram: "", sections: [], zeroclawAgents: [], + directAgents: [], }); export const TASK_TEMPLATES = readJsonFile(TASK_TEMPLATE_PATH, []); diff --git a/lib/tasks.ts b/lib/tasks.ts index 6156f7e..f165208 100644 --- a/lib/tasks.ts +++ b/lib/tasks.ts @@ -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) { diff --git a/lib/types.ts b/lib/types.ts index bdecbf7..2b384ca 100644 --- a/lib/types.ts +++ b/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[]; }; diff --git a/package.json b/package.json index 453366a..8412683 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "openclaw-taskboard", "version": "2.0.0", "private": true, - "description": "Next.js fleet dashboard for OpenClaw and ZeroClaw runtimes", + "description": "Next.js fleet dashboard for OpenClaw, ZeroClaw, and direct host operations", "scripts": { "dev": "next dev", "build": "next build",