[taskboard] add dedicated truenas admin agent

This commit is contained in:
2026-03-07 14:48:26 -08:00
parent 0a312dc733
commit b3a6c4bafb
5 changed files with 380 additions and 33 deletions

View File

@@ -80,10 +80,10 @@
], ],
"configuredAgents": [ "configuredAgents": [
"pve-direct", "pve-direct",
"truenas-direct", "truenas-admin",
"panda-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", "diagram": "taskboard direct SSH\n -> pve : built-in Proxmox overview\n -> truenas : dedicated truenas-admin audit actions\n -> panda : built-in SSH add-on overview\n\nEach direct task\n -> ssh safe built-in command or host-specific builtin audit\n -> capture stdout/stderr plus repo dependency correlation\n -> task callback -> completed/review result\n",
"notes": [ "notes": [
"Direct targets are for safe built-in actions, not arbitrary remote shell execution from the UI.", "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." "Completion state is written through the same callback pipeline used by remote agent runtimes."
@@ -188,37 +188,45 @@
} }
}, },
{ {
"slug": "truenas-direct", "slug": "truenas-admin",
"assignmentKey": "truenas-direct", "assignmentKey": "truenas-admin",
"aliases": ["truenas-direct", "TrueNAS Direct", "truenas"], "aliases": ["truenas-admin", "truenas-direct", "TrueNAS Admin", "TrueNAS Direct", "truenas"],
"name": "TrueNAS Direct", "name": "TrueNAS Admin",
"host": "truenas", "host": "truenas",
"role": "Direct storage checks over SSH", "role": "Dedicated storage and dataset audit agent for the TrueNAS host",
"runtimePath": "ssh://christopher@192.168.50.12:22", "runtimePath": "ssh://christopher@192.168.50.12:22",
"configPath": null, "configPath": null,
"emoji": "T", "emoji": "T",
"channels": [ "channels": [
{ "label": "SSH", "value": "christopher@192.168.50.12:22" }, { "label": "SSH", "value": "christopher@192.168.50.12:22" },
{ "label": "Actions", "value": "storage-overview" } { "label": "Actions", "value": "dataset-audit, storage-overview" }
], ],
"tools": ["ssh", "zfs", "systemctl", "midclt"], "tools": ["ssh", "zfs", "systemctl", "midclt"],
"capabilities": [ "capabilities": [
"Build dataset dependency matrices from live storage plus repo configuration",
"Verify storage datasets", "Verify storage datasets",
"Check docker service state", "Check docker service state",
"Report host identity and storage status" "Report host identity and storage status"
], ],
"files": [], "files": [],
"notes": [ "notes": [
"Runs safe read-only storage checks.", "Runs safe read-only storage and dependency audit checks.",
"Does not modify datasets or apps." "Does not delete datasets directly; cleanup stays review-gated."
], ],
"dispatch": { "dispatch": {
"method": "direct-ssh", "method": "direct-ssh",
"hostname": "192.168.50.12", "hostname": "192.168.50.12",
"user": "christopher", "user": "christopher",
"port": 22, "port": 22,
"defaultAction": "storage-overview", "defaultAction": "dataset-audit",
"actions": [ "actions": [
{
"key": "dataset-audit",
"title": "Dataset dependency audit",
"description": "Correlate live ZFS datasets with active homelab dependency signals and produce cleanup review candidates.",
"command": "builtin:truenas-dataset-audit",
"successSummary": "TrueNAS dataset dependency audit completed"
},
{ {
"key": "storage-overview", "key": "storage-overview",
"title": "Storage overview", "title": "Storage overview",

View File

@@ -71,10 +71,10 @@
}, },
{ {
"key": "direct-truenas-check", "key": "direct-truenas-check",
"title": "TrueNAS direct verification", "title": "TrueNAS dataset audit",
"summary": "Run the built-in storage overview action through the direct SSH target.", "summary": "Run the dedicated TrueNAS dataset dependency audit through the host-specific admin agent.",
"family": "direct", "family": "direct",
"tags": ["host-ops", "storage-check", "action:storage-overview"], "tags": ["host-ops", "storage-check", "dataset-audit", "action:dataset-audit"],
"defaults": { "defaults": {
"priority": "High", "priority": "High",
"dispatchMethod": "direct-ssh", "dispatchMethod": "direct-ssh",

View File

@@ -37,13 +37,14 @@
- typed direct target definitions in `config/fleet.json` - typed direct target definitions in `config/fleet.json`
- safe built-in host actions for `pve`, `truenas`, and `panda` - safe built-in host actions for `pve`, `truenas`, and `panda`
- completion written through the callback pipeline - completion written through the callback pipeline
- dedicated `truenas-admin` audit action that correlates live ZFS datasets with repo dependency signals
## Verified Live ## Verified Live
- `grizzley` ZeroClaw webhook dispatch from taskboard - `grizzley` ZeroClaw webhook dispatch from taskboard
- `ice` ZeroClaw webhook dispatch from taskboard - `ice` ZeroClaw webhook dispatch from taskboard
- OpenClaw swarm queue creation and host worktree creation on `ubuntu` - OpenClaw swarm queue creation and host worktree creation on `ubuntu`
- direct SSH host actions can now be dispatched for `pve`, `truenas`, and `panda` - direct SSH host actions can now be dispatched for `pve`, `truenas-admin`, and `panda`
## Current Limits ## Current Limits
@@ -51,3 +52,4 @@
- ZeroClaw acknowledgements and completions are still operator-driven; remote runtimes do not push completion state back yet. - 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. - 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. - Direct targets are intentionally restricted to configured safe actions and do not expose arbitrary shell execution in the UI.
- `truenas-admin` remains review-gated for cleanup decisions; the taskboard produces audit results and candidate datasets, not direct deletion.

View File

@@ -14,7 +14,7 @@
3. Expand direct host operations beyond the first safe action set. 3. Expand direct host operations beyond the first safe action set.
- add more read-only Proxmox actions - add more read-only Proxmox actions
- add richer TrueNAS storage and service checks - add richer TrueNAS storage, share, and snapshot checks beyond the dataset audit
- add more Home Assistant supervisor and add-on checks - add more Home Assistant supervisor and add-on checks
4. Add operator controls for swarm execution. 4. Add operator controls for swarm execution.

View File

@@ -79,6 +79,346 @@ function truncateOutput(output: string, maxLength = 4000) {
return `${trimmed.slice(0, maxLength - 15)}\n...[truncated]`; return `${trimmed.slice(0, maxLength - 15)}\n...[truncated]`;
} }
type TrueNasDataset = {
name: string;
used: string;
avail: string;
mountpoint: string;
};
type DatasetSignal = {
source: string;
dataset: string;
signalType: "active" | "legacy";
matchedText: string;
};
const TRUENAS_SIGNAL_PATTERNS: Array<{
dataset: string;
patterns: string[];
signalType: "active" | "legacy";
}> = [
{
dataset: "TrueNAS/NetworkMediaShare",
patterns: [
"/mnt/truenas/mediadata",
"/mnt/TrueNAS/NetworkMediaShare",
"/mnt/TrueNAS/NetworkMediaShare/mediadata",
],
signalType: "active",
},
{
dataset: "RPiPool/PersonalMediaLibrary",
patterns: [
"/mnt/PersonalMediaLibrary",
"/mnt/RPiPool/PersonalMediaLibrary",
],
signalType: "active",
},
{
dataset: "TrueNAS/backups",
patterns: [
"/mnt/truenas-backup",
"/mnt/TrueNAS/backups",
],
signalType: "active",
},
{
dataset: "TrueNAS/container-config",
patterns: ["/mnt/TrueNAS/container-config"],
signalType: "active",
},
{
dataset: "TrueNAS/databases",
patterns: ["/mnt/TrueNAS/databases"],
signalType: "active",
},
{
dataset: "TrueNAS/homelab/databases",
patterns: ["/mnt/TrueNAS/homelab/databases"],
signalType: "active",
},
{
dataset: "TrueNAS/homelab/hosts/ubuntu/docker-data",
patterns: ["/mnt/TrueNAS/homelab/hosts/ubuntu/docker-data"],
signalType: "active",
},
{
dataset: "TrueNAS/homelab/hosts/grizzley/docker-data",
patterns: ["/mnt/TrueNAS/homelab/hosts/grizzley/docker-data"],
signalType: "active",
},
{
dataset: "TrueNAS/homelab/hosts/ice/docker-data",
patterns: ["/mnt/TrueNAS/homelab/hosts/ice/docker-data"],
signalType: "active",
},
{
dataset: "TrueNAS/RPiPool-backup",
patterns: ["/mnt/TrueNAS/RPiPool-backup"],
signalType: "legacy",
},
{
dataset: "TrueNAS/PersonalMediaLibraryBackup",
patterns: ["/mnt/TrueNAS/PersonalMediaLibraryBackup"],
signalType: "legacy",
},
{
dataset: "TrueNAS/TimeMachine",
patterns: ["/mnt/TrueNAS/TimeMachine", "TimeMachine"],
signalType: "legacy",
},
{
dataset: "TrueNAS/UserShares",
patterns: ["/mnt/TrueNAS/UserShares", "UserShares"],
signalType: "legacy",
},
{
dataset: "TrueNAS/UserShares/RedVelvet",
patterns: ["/mnt/TrueNAS/UserShares/RedVelvet", "TrueNAS/UserShares/RedVelvet"],
signalType: "legacy",
},
{
dataset: "TrueNAS/UserShares/Vanilla",
patterns: ["/mnt/TrueNAS/UserShares/Vanilla", "TrueNAS/UserShares/Vanilla"],
signalType: "legacy",
},
{
dataset: "TrueNAS/traefik-certs",
patterns: ["/mnt/truenas/traefik-certs", "/mnt/TrueNAS/traefik-certs"],
signalType: "active",
},
];
const ACTIVE_TRUENAS_SCAN_PATHS = [
"homelab/ubuntu",
"homelab/grizzley",
"homelab/truenas/AGENTS.md",
"homelab/AGENTS.md",
"homelab/catalog",
"ansible/playbooks",
];
const LEGACY_TRUENAS_SCAN_PATHS = [
"homelab/inventory/truenas.json",
"homelab/proxmox/truenas",
"obsidian-vault/homelab",
];
function sshConnectionArgs(host: string, user: string, port: number) {
return [
"-F",
"/dev/null",
"-o",
"BatchMode=yes",
"-o",
"StrictHostKeyChecking=accept-new",
"-o",
"ConnectTimeout=15",
"-o",
"IdentitiesOnly=yes",
"-o",
"UserKnownHostsFile=/tmp/taskboard_known_hosts",
"-i",
DIRECT_SSH_KEY_PATH,
"-p",
String(port),
`${user}@${host}`,
];
}
function existingHomelabRoots() {
const candidates = [
process.env.HOMELAB_REPO_ROOT,
"/home/bear/homelabagentroot",
"/home/christopher/opencode-home",
].filter((entry): entry is string => Boolean(entry));
return [...new Set(candidates)].filter((candidate) => fs.existsSync(candidate));
}
function collectTextFiles(targetPath: string, collected: string[]) {
if (!fs.existsSync(targetPath)) {
return;
}
const stat = fs.statSync(targetPath);
if (stat.isFile()) {
if (stat.size <= 1024 * 1024) {
collected.push(targetPath);
}
return;
}
for (const entry of fs.readdirSync(targetPath, { withFileTypes: true })) {
if (entry.name.startsWith(".git") || entry.name === "node_modules" || entry.name === "code-server-ai") {
continue;
}
collectTextFiles(path.join(targetPath, entry.name), collected);
}
}
function scanTrueNasSignals(repoRoot: string, relativePaths: string[], signalType: "active" | "legacy") {
const files: string[] = [];
for (const relativePath of relativePaths) {
collectTextFiles(path.join(repoRoot, relativePath), files);
}
const signals: DatasetSignal[] = [];
for (const filePath of files) {
let content = "";
try {
content = fs.readFileSync(filePath, "utf8");
} catch {
continue;
}
for (const mapping of TRUENAS_SIGNAL_PATTERNS.filter((entry) => entry.signalType === signalType)) {
const matchedPattern = mapping.patterns.find((pattern) => content.includes(pattern));
if (matchedPattern) {
signals.push({
source: filePath,
dataset: mapping.dataset,
signalType,
matchedText: matchedPattern,
});
}
}
}
return signals;
}
function datasetHierarchyName(datasetName: string) {
return datasetName.replace(/\/+$/, "");
}
function summarizeSignals(signals: DatasetSignal[], dataset: string) {
return signals.filter((signal) => signal.dataset === dataset);
}
async function runTrueNasDatasetAudit(taskId: number, host: string, user: string, port: number): Promise<DispatchResult> {
const task = await findTask(taskId);
if (!task) {
throw new Error("task_not_found");
}
const sshArgs = sshConnectionArgs(host, user, port);
const { stdout } = await execFileAsync(
"ssh",
[...sshArgs, "zfs list -H -o name,used,avail,mountpoint"],
{
timeout: DIRECT_SSH_TIMEOUT_MS,
maxBuffer: 1024 * 1024,
},
);
const datasets = stdout
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [name, used, avail, mountpoint] = line.split("\t");
return { name, used, avail, mountpoint } satisfies TrueNasDataset;
});
const repoRoots = existingHomelabRoots();
const activeSignals = repoRoots.flatMap((repoRoot) =>
scanTrueNasSignals(repoRoot, ACTIVE_TRUENAS_SCAN_PATHS, "active"),
);
const legacySignals = repoRoots.flatMap((repoRoot) =>
scanTrueNasSignals(repoRoot, LEGACY_TRUENAS_SCAN_PATHS, "legacy"),
);
const childActiveMap = new Map<string, number>();
for (const dataset of datasets) {
const parentNames = dataset.name.split("/").map((_, index, parts) => parts.slice(0, index + 1).join("/"));
for (const parentName of parentNames.slice(0, -1)) {
childActiveMap.set(parentName, (childActiveMap.get(parentName) || 0) + 1);
}
}
const activeDatasets = datasets
.map((dataset) => ({
dataset,
activeRefs: summarizeSignals(activeSignals, dataset.name),
legacyRefs: summarizeSignals(legacySignals, dataset.name),
}))
.filter(({ activeRefs }) => activeRefs.length > 0);
const reviewCandidates = datasets
.map((dataset) => ({
dataset,
activeRefs: summarizeSignals(activeSignals, dataset.name),
legacyRefs: summarizeSignals(legacySignals, dataset.name),
hasActiveChild:
datasets.some(
(candidate) =>
candidate.name !== dataset.name &&
datasetHierarchyName(candidate.name).startsWith(`${datasetHierarchyName(dataset.name)}/`) &&
summarizeSignals(activeSignals, candidate.name).length > 0,
),
}))
.filter(({ dataset, activeRefs, hasActiveChild }) => {
if (dataset.name === "TrueNAS" || dataset.name === "RPiPool" || dataset.name.includes("/.system")) {
return false;
}
return activeRefs.length === 0 && !hasActiveChild;
});
const detailSections = [
`Task: #${task.id} ${task.title}`,
"",
"Active dependency signals",
...(
activeDatasets.length > 0
? activeDatasets.map(({ dataset, activeRefs }) =>
`- ${dataset.name} (${dataset.used}, mount ${dataset.mountpoint}) <- ${activeRefs
.slice(0, 4)
.map((ref) => path.relative(repoRoots[0] || process.cwd(), ref.source))
.join(", ")}`,
)
: ["- none detected"]
),
"",
"Review candidates with no active dependency signal",
...(
reviewCandidates.length > 0
? reviewCandidates.map(({ dataset, legacyRefs }) =>
`- ${dataset.name} (${dataset.used}, mount ${dataset.mountpoint})${
legacyRefs.length > 0 ? ` [legacy refs: ${legacyRefs.length}]` : ""
}`,
)
: ["- none"]
),
"",
"Legacy-only references",
...(
legacySignals.length > 0
? legacySignals.map((signal) => `- ${signal.dataset} <- ${path.relative(repoRoots[0] || process.cwd(), signal.source)}`)
: ["- none"]
),
"",
"Live datasets",
...datasets.map((dataset) => `- ${dataset.name} | used ${dataset.used} | mount ${dataset.mountpoint}`),
];
return {
state: "completed" as const,
summary: "TrueNAS dataset dependency audit completed",
detail: truncateOutput(detailSections.join("\n"), 12000),
callback: {
status: "Review",
dispatch_state: "completed",
summary: "TrueNAS dataset dependency audit completed",
detail: truncateOutput(detailSections.join("\n"), 12000),
completed_by: "direct-ssh:truenas-audit",
last_error: null,
last_dispatch_at: new Date().toISOString(),
},
};
}
async function dispatchOpenClawTask(taskId: number): Promise<DispatchResult> { async function dispatchOpenClawTask(taskId: number): Promise<DispatchResult> {
const task = await findTask(taskId); const task = await findTask(taskId);
if (!task) { if (!task) {
@@ -236,24 +576,21 @@ async function dispatchDirectTask(taskId: number): Promise<DispatchResult> {
throw new Error(`unsupported_direct_action:${actionKey}`); throw new Error(`unsupported_direct_action:${actionKey}`);
} }
if (action.command === "builtin:truenas-dataset-audit") {
return runTrueNasDatasetAudit(
taskId,
directAgent.dispatch.hostname,
directAgent.dispatch.user,
directAgent.dispatch.port,
);
}
const sshArgs = [ const sshArgs = [
"-F", ...sshConnectionArgs(
"/dev/null", directAgent.dispatch.hostname,
"-o", directAgent.dispatch.user,
"BatchMode=yes", directAgent.dispatch.port,
"-o", ),
"StrictHostKeyChecking=accept-new",
"-o",
"ConnectTimeout=15",
"-o",
"IdentitiesOnly=yes",
"-o",
"UserKnownHostsFile=/tmp/taskboard_known_hosts",
"-i",
DIRECT_SSH_KEY_PATH,
"-p",
String(directAgent.dispatch.port),
`${directAgent.dispatch.user}@${directAgent.dispatch.hostname}`,
action.command, action.command,
]; ];