188 lines
4.7 KiB
JavaScript
188 lines
4.7 KiB
JavaScript
import crypto from 'node:crypto';
|
|
import { mkdirSync } from 'node:fs';
|
|
import { DatabaseSync } from 'node:sqlite';
|
|
import path from 'node:path';
|
|
import { config } from './config.js';
|
|
|
|
const jobs = new Map();
|
|
const locks = new Map();
|
|
const maxJobs = 100;
|
|
|
|
mkdirSync(path.dirname(config.agentDatabasePath), { recursive: true, mode: 0o700 });
|
|
const db = new DatabaseSync(config.agentDatabasePath);
|
|
|
|
migrate();
|
|
loadPersistedJobs();
|
|
|
|
export function createJob(type, vmName) {
|
|
if (locks.has(vmName)) {
|
|
const activeJob = jobs.get(locks.get(vmName));
|
|
const error = new Error(`A ${activeJob?.type || 'job'} job is already active for ${vmName}.`);
|
|
error.status = 409;
|
|
throw error;
|
|
}
|
|
|
|
const id = `job_${crypto.randomBytes(8).toString('hex')}`;
|
|
const now = new Date().toISOString();
|
|
const job = {
|
|
id,
|
|
type,
|
|
vmName,
|
|
status: 'queued',
|
|
startedAt: now,
|
|
finishedAt: null,
|
|
currentStep: 'Queued',
|
|
progress: {
|
|
percent: 0,
|
|
detail: 'Queued',
|
|
currentBytes: 0,
|
|
totalBytes: null,
|
|
},
|
|
logs: [],
|
|
error: null,
|
|
};
|
|
|
|
jobs.set(id, job);
|
|
locks.set(vmName, id);
|
|
persistJob(job);
|
|
trimJobs();
|
|
return job;
|
|
}
|
|
|
|
export function getJob(id) {
|
|
return jobs.get(id) || null;
|
|
}
|
|
|
|
export function listJobs() {
|
|
return [...jobs.values()].sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
}
|
|
|
|
export function activeJobForVm(vmName) {
|
|
const id = locks.get(vmName);
|
|
return id ? getJob(id) : null;
|
|
}
|
|
|
|
export function latestJobForVm(vmName) {
|
|
return listJobs().find((job) => job.vmName === vmName) || null;
|
|
}
|
|
|
|
export function setJobRunning(job, step) {
|
|
job.status = 'running';
|
|
setJobStep(job, step);
|
|
}
|
|
|
|
export function setJobStep(job, step) {
|
|
job.currentStep = step;
|
|
job.progress = {
|
|
...job.progress,
|
|
detail: step,
|
|
};
|
|
appendJobLog(job, `==> ${step}`);
|
|
persistJob(job);
|
|
}
|
|
|
|
export function setJobProgress(job, progress = {}) {
|
|
const nextPercent = progress.percent ?? job.progress?.percent ?? 0;
|
|
job.progress = {
|
|
...job.progress,
|
|
...progress,
|
|
percent: Math.max(0, Math.min(100, Number(nextPercent) || 0)),
|
|
};
|
|
persistJob(job);
|
|
}
|
|
|
|
export function appendJobLog(job, line) {
|
|
if (!line) return;
|
|
job.logs.push(...String(line).split('\n').filter(Boolean));
|
|
if (job.logs.length > 1000) {
|
|
job.logs = job.logs.slice(-1000);
|
|
}
|
|
persistJob(job);
|
|
}
|
|
|
|
export function finishJob(job, status, error = null) {
|
|
job.status = status;
|
|
job.finishedAt = new Date().toISOString();
|
|
job.currentStep = status === 'success' ? 'Completed' : 'Failed';
|
|
job.progress = {
|
|
...job.progress,
|
|
percent: status === 'success' ? 100 : job.progress?.percent || 0,
|
|
detail: status === 'success' ? 'Completed' : 'Failed',
|
|
};
|
|
job.error = error ? String(error.message || error) : null;
|
|
locks.delete(job.vmName);
|
|
persistJob(job);
|
|
}
|
|
|
|
function trimJobs() {
|
|
const allJobs = listJobs();
|
|
for (const job of allJobs.slice(maxJobs)) {
|
|
if (job.status !== 'running' && job.status !== 'queued') {
|
|
jobs.delete(job.id);
|
|
db.prepare('DELETE FROM jobs WHERE id = ?').run(job.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
function migrate() {
|
|
db.exec(`
|
|
PRAGMA journal_mode = WAL;
|
|
CREATE TABLE IF NOT EXISTS jobs (
|
|
id TEXT PRIMARY KEY,
|
|
vm_name TEXT NOT NULL,
|
|
type TEXT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
started_at TEXT NOT NULL,
|
|
finished_at TEXT,
|
|
updated_at TEXT NOT NULL,
|
|
payload TEXT NOT NULL
|
|
);
|
|
`);
|
|
}
|
|
|
|
function loadPersistedJobs() {
|
|
const now = new Date().toISOString();
|
|
const rows = db.prepare('SELECT payload FROM jobs ORDER BY started_at DESC LIMIT ?').all(maxJobs);
|
|
for (const row of rows.reverse()) {
|
|
const job = JSON.parse(row.payload);
|
|
if (['queued', 'running'].includes(job.status)) {
|
|
job.status = 'failed';
|
|
job.finishedAt = now;
|
|
job.currentStep = 'Failed';
|
|
job.progress = {
|
|
...job.progress,
|
|
detail: 'Failed',
|
|
};
|
|
job.error = 'Agent restarted while this job was active.';
|
|
job.logs = [...(job.logs || []), 'Agent restarted while this job was active; job marked failed.'].slice(-1000);
|
|
}
|
|
jobs.set(job.id, job);
|
|
persistJob(job);
|
|
}
|
|
trimJobs();
|
|
}
|
|
|
|
function persistJob(job) {
|
|
db.prepare(`
|
|
INSERT INTO jobs (id, vm_name, type, status, started_at, finished_at, updated_at, payload)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
vm_name = excluded.vm_name,
|
|
type = excluded.type,
|
|
status = excluded.status,
|
|
started_at = excluded.started_at,
|
|
finished_at = excluded.finished_at,
|
|
updated_at = excluded.updated_at,
|
|
payload = excluded.payload
|
|
`).run(
|
|
job.id,
|
|
job.vmName,
|
|
job.type,
|
|
job.status,
|
|
job.startedAt,
|
|
job.finishedAt,
|
|
new Date().toISOString(),
|
|
JSON.stringify(job),
|
|
);
|
|
}
|