[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": [
"pve-direct",
"truenas-direct",
"truenas-admin",
"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": [
"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."
@@ -188,37 +188,45 @@
}
},
{
"slug": "truenas-direct",
"assignmentKey": "truenas-direct",
"aliases": ["truenas-direct", "TrueNAS Direct", "truenas"],
"name": "TrueNAS Direct",
"slug": "truenas-admin",
"assignmentKey": "truenas-admin",
"aliases": ["truenas-admin", "truenas-direct", "TrueNAS Admin", "TrueNAS Direct", "truenas"],
"name": "TrueNAS Admin",
"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",
"configPath": null,
"emoji": "T",
"channels": [
{ "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"],
"capabilities": [
"Build dataset dependency matrices from live storage plus repo configuration",
"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."
"Runs safe read-only storage and dependency audit checks.",
"Does not delete datasets directly; cleanup stays review-gated."
],
"dispatch": {
"method": "direct-ssh",
"hostname": "192.168.50.12",
"user": "christopher",
"port": 22,
"defaultAction": "storage-overview",
"defaultAction": "dataset-audit",
"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",
"title": "Storage overview",

View File

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

View File

@@ -37,13 +37,14 @@
- typed direct target definitions in `config/fleet.json`
- safe built-in host actions for `pve`, `truenas`, and `panda`
- completion written through the callback pipeline
- dedicated `truenas-admin` audit action that correlates live ZFS datasets with repo dependency signals
## 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`
- direct SSH host actions can now be dispatched for `pve`, `truenas-admin`, and `panda`
## Current Limits
@@ -51,3 +52,4 @@
- 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.
- `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.
- 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
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]`;
}
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> {
const task = await findTask(taskId);
if (!task) {
@@ -236,24 +576,21 @@ async function dispatchDirectTask(taskId: number): Promise<DispatchResult> {
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 = [
"-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(directAgent.dispatch.port),
`${directAgent.dispatch.user}@${directAgent.dispatch.hostname}`,
...sshConnectionArgs(
directAgent.dispatch.hostname,
directAgent.dispatch.user,
directAgent.dispatch.port,
),
action.command,
];