report-tasks.sh's new hardware-collection code ran under set -euo pipefail, so a single failing command inside it aborted the whole script before the report was ever sent — with no error message, since nothing in that path had explicit error handling. On real hardware this hit immediately: `grep -m1 "model name" /proc/cpuinfo` exits 1 when there's no match, and many ARM boards (e.g. Raspberry Pi) have no such line at all. Confirmed via journalctl showing the systemd service failing every 15 minutes with exit 1 and zero output, and via a minimal repro of the exact bash control flow. Wrap the hardware/network collection call so any failure inside it is non-fatal: task reporting (the actual core function) must never be taken down by a quirk in the best-effort hardware-gathering code, on this host or any other. Also fixed a related gap found while testing the fallback path: the server only accepted the system field being absent, not explicitly null (what the script now sends if collection fails outright), which would have turned graceful degradation into a rejected report. Verified end-to-end against the real dev server: system:null, system omitted, and a normal populated report all now return 202 and persist correctly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
118 lines
4.0 KiB
TypeScript
118 lines
4.0 KiB
TypeScript
import { and, eq, notInArray } from "drizzle-orm";
|
|
import { db } from "../db/client.js";
|
|
import { scheduledTasks, servers } from "../db/schema.js";
|
|
|
|
export interface IncomingTask {
|
|
scheduleType: "cron" | "systemd_timer";
|
|
name: string;
|
|
command?: string;
|
|
scheduleExpression?: string;
|
|
source?: string;
|
|
enabled?: boolean;
|
|
nextRunAt?: string;
|
|
metadata?: unknown;
|
|
}
|
|
|
|
export interface IncomingSystemInfo {
|
|
ip_addresses?: string[];
|
|
cpu?: { model?: string; cores?: number; load_percent?: number | null };
|
|
memory?: { total_bytes?: number; used_bytes?: number };
|
|
disks?: { mount: string; size_bytes: number; used_bytes: number }[];
|
|
}
|
|
|
|
export interface AgentReport {
|
|
hostname?: string;
|
|
system?: IncomingSystemInfo | null;
|
|
tasks: IncomingTask[];
|
|
}
|
|
|
|
export async function syncServerTasks(serverId: number, report: AgentReport) {
|
|
const now = new Date().toISOString();
|
|
|
|
const existing = await db.query.scheduledTasks.findMany({
|
|
where: and(eq(scheduledTasks.serverId, serverId), eq(scheduledTasks.origin, "agent")),
|
|
});
|
|
|
|
const seenIds: number[] = [];
|
|
|
|
for (const task of report.tasks) {
|
|
const match = existing.find(
|
|
(t) =>
|
|
t.scheduleType === task.scheduleType &&
|
|
t.name === task.name &&
|
|
(t.source ?? "") === (task.source ?? ""),
|
|
);
|
|
|
|
if (match) {
|
|
const [updated] = await db
|
|
.update(scheduledTasks)
|
|
.set({
|
|
command: task.command,
|
|
scheduleExpression: task.scheduleExpression,
|
|
enabled: task.enabled ?? true,
|
|
nextRunAt: task.nextRunAt,
|
|
rawMetadata: task.metadata ? JSON.stringify(task.metadata) : null,
|
|
isStale: false,
|
|
lastSeenAt: now,
|
|
})
|
|
.where(eq(scheduledTasks.id, match.id))
|
|
.returning({ id: scheduledTasks.id });
|
|
seenIds.push(updated.id);
|
|
} else {
|
|
const [created] = await db
|
|
.insert(scheduledTasks)
|
|
.values({
|
|
serverId,
|
|
scheduleType: task.scheduleType,
|
|
origin: "agent",
|
|
name: task.name,
|
|
command: task.command,
|
|
scheduleExpression: task.scheduleExpression,
|
|
source: task.source,
|
|
enabled: task.enabled ?? true,
|
|
nextRunAt: task.nextRunAt,
|
|
rawMetadata: task.metadata ? JSON.stringify(task.metadata) : null,
|
|
firstSeenAt: now,
|
|
lastSeenAt: now,
|
|
})
|
|
.returning({ id: scheduledTasks.id });
|
|
seenIds.push(created.id);
|
|
}
|
|
}
|
|
|
|
// Anything agent-sourced for this server that wasn't in this report is now stale,
|
|
// rather than deleted, so a bad/partial agent run doesn't wipe history. Manually
|
|
// entered tasks (origin = 'manual') are never touched by agent sync.
|
|
const agentScope = and(eq(scheduledTasks.serverId, serverId), eq(scheduledTasks.origin, "agent"));
|
|
if (seenIds.length > 0) {
|
|
await db
|
|
.update(scheduledTasks)
|
|
.set({ isStale: true })
|
|
.where(and(agentScope, notInArray(scheduledTasks.id, seenIds)));
|
|
} else {
|
|
await db.update(scheduledTasks).set({ isStale: true }).where(agentScope);
|
|
}
|
|
|
|
const system = report.system;
|
|
await db
|
|
.update(servers)
|
|
.set({
|
|
lastSeenAt: now,
|
|
...(report.hostname ? { hostname: report.hostname } : {}),
|
|
...(system?.ip_addresses ? { ipAddresses: JSON.stringify(system.ip_addresses) } : {}),
|
|
...(system?.cpu?.model !== undefined ? { cpuModel: system.cpu.model } : {}),
|
|
...(system?.cpu?.cores !== undefined ? { cpuCores: system.cpu.cores } : {}),
|
|
...(system?.cpu?.load_percent !== undefined ? { cpuLoadPercent: system.cpu.load_percent } : {}),
|
|
...(system?.memory?.total_bytes !== undefined ? { memTotalBytes: system.memory.total_bytes } : {}),
|
|
...(system?.memory?.used_bytes !== undefined ? { memUsedBytes: system.memory.used_bytes } : {}),
|
|
...(system?.disks
|
|
? {
|
|
disks: JSON.stringify(
|
|
system.disks.map((d) => ({ mount: d.mount, sizeBytes: d.size_bytes, usedBytes: d.used_bytes })),
|
|
),
|
|
}
|
|
: {}),
|
|
})
|
|
.where(eq(servers.id, serverId));
|
|
}
|