feat: initial SleepGuard implementation

Wake-on-demand proxy + agent system with SvelteKit dashboard.
Monorepo: shared types, proxy (Hono + http-proxy), agent (monitors + locks), web (SvelteKit SPA).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Vadim Sobinin
2026-02-10 13:46:51 +03:00
commit 852e01df39
64 changed files with 4864 additions and 0 deletions

View File

@@ -0,0 +1,20 @@
import { requireEnv, intEnv, optionalEnv } from '@sleepguard/shared';
export const config = {
port: intEnv('AGENT_PORT', 48527),
secret: requireEnv('AGENT_SECRET'),
thresholds: {
cpuPercent: intEnv('CPU_THRESHOLD_PERCENT', 15),
gpuPercent: intEnv('GPU_THRESHOLD_PERCENT', 10),
ramPercent: intEnv('RAM_THRESHOLD_PERCENT', 50),
diskIoActive: true,
},
watchedProcesses: optionalEnv('WATCHED_PROCESSES', 'hashcat,ffmpeg,whisper,python3,ollama')
.split(',')
.map((p) => p.trim())
.filter(Boolean),
shutdownCommand: optionalEnv('SHUTDOWN_COMMAND', 'systemctl poweroff'),
} as const;

View File

@@ -0,0 +1,10 @@
import { serve } from '@hono/node-server';
import { app } from './server.js';
import { startLockCleanup } from './services/lockManager.js';
import { config } from './config.js';
startLockCleanup();
serve({ fetch: app.fetch, port: config.port }, (info) => {
console.log(`[SleepGuard Agent] Running on port ${info.port}`);
});

View File

@@ -0,0 +1,37 @@
import os from 'node:os';
import type { CpuStatus } from '@sleepguard/shared';
let prevIdle = 0;
let prevTotal = 0;
function measureCpuUsage(): number {
const cpus = os.cpus();
let idle = 0;
let total = 0;
for (const cpu of cpus) {
idle += cpu.times.idle;
total += cpu.times.user + cpu.times.nice + cpu.times.sys + cpu.times.idle + cpu.times.irq;
}
const idleDelta = idle - prevIdle;
const totalDelta = total - prevTotal;
prevIdle = idle;
prevTotal = total;
if (totalDelta === 0) return 0;
return Math.round((1 - idleDelta / totalDelta) * 100 * 10) / 10;
}
// Initialize baseline
measureCpuUsage();
export function getCpuStatus(): CpuStatus {
const loadAvg = os.loadavg() as [number, number, number];
return {
usagePercent: measureCpuUsage(),
loadAvg,
cores: os.cpus().length,
};
}

View File

@@ -0,0 +1,58 @@
import { readFile } from 'node:fs/promises';
import type { DiskIoStatus } from '@sleepguard/shared';
interface DiskStats {
readSectors: number;
writeSectors: number;
timestamp: number;
}
let prevStats: DiskStats | null = null;
const SECTOR_SIZE_KB = 0.5; // 512 bytes
async function parseDiskStats(): Promise<DiskStats> {
try {
const content = await readFile('/proc/diskstats', 'utf-8');
let readSectors = 0;
let writeSectors = 0;
for (const line of content.trim().split('\n')) {
const parts = line.trim().split(/\s+/);
if (parts.length < 14) continue;
const device = parts[2];
// Only count whole disks (sda, nvme0n1), not partitions
if (/^(sd[a-z]|nvme\d+n\d+)$/.test(device)) {
readSectors += parseInt(parts[5], 10) || 0;
writeSectors += parseInt(parts[9], 10) || 0;
}
}
return { readSectors, writeSectors, timestamp: Date.now() };
} catch {
return { readSectors: 0, writeSectors: 0, timestamp: Date.now() };
}
}
export async function getDiskIoStatus(): Promise<DiskIoStatus> {
const current = await parseDiskStats();
if (!prevStats) {
prevStats = current;
return { active: false, readKBps: 0, writeKBps: 0 };
}
const elapsed = (current.timestamp - prevStats.timestamp) / 1000;
if (elapsed <= 0) {
return { active: false, readKBps: 0, writeKBps: 0 };
}
const readKBps = Math.round(((current.readSectors - prevStats.readSectors) * SECTOR_SIZE_KB) / elapsed);
const writeKBps = Math.round(((current.writeSectors - prevStats.writeSectors) * SECTOR_SIZE_KB) / elapsed);
prevStats = current;
const active = readKBps > 100 || writeKBps > 100; // >100 KB/s considered active
return { active, readKBps, writeKBps };
}

View File

@@ -0,0 +1,37 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import type { GpuStatus } from '@sleepguard/shared';
const execFileAsync = promisify(execFile);
const EMPTY_GPU: GpuStatus = {
available: false,
usagePercent: 0,
memoryUsedMB: 0,
memoryTotalMB: 0,
temperature: 0,
name: 'N/A',
};
export async function getGpuStatus(): Promise<GpuStatus> {
try {
const { stdout } = await execFileAsync('nvidia-smi', [
'--query-gpu=utilization.gpu,memory.used,memory.total,temperature.gpu,name',
'--format=csv,noheader,nounits',
], { timeout: 5000 });
const parts = stdout.trim().split(',').map((s) => s.trim());
if (parts.length < 5) return EMPTY_GPU;
return {
available: true,
usagePercent: parseFloat(parts[0]) || 0,
memoryUsedMB: parseFloat(parts[1]) || 0,
memoryTotalMB: parseFloat(parts[2]) || 0,
temperature: parseFloat(parts[3]) || 0,
name: parts[4],
};
} catch {
return EMPTY_GPU;
}
}

View File

@@ -0,0 +1,11 @@
import os from 'node:os';
import type { MemoryStatus } from '@sleepguard/shared';
export function getMemoryStatus(): MemoryStatus {
const totalMB = Math.round(os.totalmem() / 1024 / 1024);
const freeMB = Math.round(os.freemem() / 1024 / 1024);
const usedMB = totalMB - freeMB;
const usedPercent = Math.round((usedMB / totalMB) * 100 * 10) / 10;
return { usedPercent, usedMB, totalMB };
}

View File

@@ -0,0 +1,29 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import type { ProcessInfo } from '@sleepguard/shared';
const execFileAsync = promisify(execFile);
async function findProcess(name: string): Promise<number[]> {
try {
const { stdout } = await execFileAsync('pgrep', ['-x', name], { timeout: 3000 });
return stdout
.trim()
.split('\n')
.filter(Boolean)
.map((pid) => parseInt(pid, 10));
} catch {
// pgrep exits with 1 if no processes found
return [];
}
}
export async function getProcessStatus(watchlist: string[]): Promise<ProcessInfo[]> {
const results = await Promise.all(
watchlist.map(async (name) => {
const pids = await findProcess(name);
return { name, running: pids.length > 0, pids };
})
);
return results;
}

View File

@@ -0,0 +1,66 @@
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import type { CreateLockRequest } from '@sleepguard/shared';
import { collectStatus } from './services/collector.js';
import { evaluateShutdown } from './services/shutdownPolicy.js';
import { createLock, deleteLock, getAllLocks } from './services/lockManager.js';
import { config } from './config.js';
const app = new Hono();
// CORS
app.use('*', cors());
// Auth middleware for /api/*
app.use('/api/*', async (c, next) => {
const auth = c.req.header('Authorization');
if (auth !== `Bearer ${config.secret}`) {
return c.json({ error: 'Unauthorized' }, 401);
}
await next();
});
// Health check
app.get('/api/health', (c) => {
return c.json({ ok: true, timestamp: new Date().toISOString() });
});
// Full status
app.get('/api/status', async (c) => {
const status = await collectStatus();
return c.json(status);
});
// Can shutdown?
app.get('/api/can-shutdown', async (c) => {
const status = await collectStatus();
const result = evaluateShutdown(status);
return c.json(result);
});
// List locks
app.get('/api/locks', (c) => {
return c.json(getAllLocks());
});
// Create lock
app.post('/api/locks', async (c) => {
const body = await c.req.json<CreateLockRequest>();
if (!body.name) {
return c.json({ error: 'name is required' }, 400);
}
const lock = createLock(body.name, body.ttlSeconds, body.reason);
return c.json(lock, 201);
});
// Delete lock
app.delete('/api/locks/:name', (c) => {
const name = c.req.param('name');
const deleted = deleteLock(name);
if (!deleted) {
return c.json({ error: 'Lock not found' }, 404);
}
return c.json({ ok: true });
});
export { app };

View File

@@ -0,0 +1,27 @@
import os from 'node:os';
import type { AgentStatus } from '@sleepguard/shared';
import { getCpuStatus } from '../monitors/cpu.js';
import { getGpuStatus } from '../monitors/gpu.js';
import { getMemoryStatus } from '../monitors/memory.js';
import { getDiskIoStatus } from '../monitors/disk.js';
import { getProcessStatus } from '../monitors/process.js';
import { getAllLocks } from './lockManager.js';
import { config } from '../config.js';
export async function collectStatus(): Promise<AgentStatus> {
const [gpu, diskIo, processes] = await Promise.all([
getGpuStatus(),
getDiskIoStatus(),
getProcessStatus(config.watchedProcesses),
]);
return {
cpu: getCpuStatus(),
gpu,
memory: getMemoryStatus(),
diskIo,
processes,
locks: getAllLocks(),
uptime: os.uptime(),
};
}

View File

@@ -0,0 +1,56 @@
import type { Lock } from '@sleepguard/shared';
const locks = new Map<string, Lock>();
let cleanupInterval: ReturnType<typeof setInterval> | null = null;
function cleanExpired(): void {
const now = Date.now();
for (const [name, lock] of locks) {
if (lock.expiresAt && new Date(lock.expiresAt).getTime() <= now) {
locks.delete(name);
}
}
}
export function startLockCleanup(): void {
cleanupInterval = setInterval(cleanExpired, 10_000);
}
export function stopLockCleanup(): void {
if (cleanupInterval) {
clearInterval(cleanupInterval);
cleanupInterval = null;
}
}
export function createLock(name: string, ttlSeconds?: number, reason?: string): Lock {
const now = new Date();
const lock: Lock = {
name,
reason,
createdAt: now.toISOString(),
expiresAt: ttlSeconds ? new Date(now.getTime() + ttlSeconds * 1000).toISOString() : undefined,
};
locks.set(name, lock);
return lock;
}
export function deleteLock(name: string): boolean {
return locks.delete(name);
}
export function getLock(name: string): Lock | undefined {
cleanExpired();
return locks.get(name);
}
export function getAllLocks(): Lock[] {
cleanExpired();
return Array.from(locks.values());
}
export function hasActiveLocks(): boolean {
cleanExpired();
return locks.size > 0;
}

View File

@@ -0,0 +1,41 @@
import type { AgentStatus, CanShutdownResponse } from '@sleepguard/shared';
import { config } from '../config.js';
export function evaluateShutdown(status: AgentStatus): CanShutdownResponse {
const reasons: string[] = [];
// Level 1: Locks (absolute blocker)
if (status.locks.length > 0) {
const lockNames = status.locks.map((l) => l.name).join(', ');
reasons.push(`Active locks: ${lockNames}`);
}
// Level 2: Process watchlist
const runningProcesses = status.processes.filter((p) => p.running);
if (runningProcesses.length > 0) {
const names = runningProcesses.map((p) => p.name).join(', ');
reasons.push(`Watched processes running: ${names}`);
}
// Level 3: Metric thresholds
if (status.cpu.usagePercent > config.thresholds.cpuPercent) {
reasons.push(`CPU usage ${status.cpu.usagePercent}% > ${config.thresholds.cpuPercent}%`);
}
if (status.gpu.available && status.gpu.usagePercent > config.thresholds.gpuPercent) {
reasons.push(`GPU usage ${status.gpu.usagePercent}% > ${config.thresholds.gpuPercent}%`);
}
if (status.memory.usedPercent > config.thresholds.ramPercent) {
reasons.push(`RAM usage ${status.memory.usedPercent}% > ${config.thresholds.ramPercent}%`);
}
if (config.thresholds.diskIoActive && status.diskIo.active) {
reasons.push(`Disk I/O active (R: ${status.diskIo.readKBps} KB/s, W: ${status.diskIo.writeKBps} KB/s)`);
}
return {
canShutdown: reasons.length === 0,
reasons,
};
}