[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

@@ -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,
];