// server/services/execute.ts import { randomUUID } from "node:crypto"; import { eq } from "drizzle-orm"; import { mkdirSync, writeFileSync, statSync } from "node:fs"; import { join } from "node:path"; import { db, schema } from "../db/client.js"; import { env } from "../env.js"; import { getMachineRow, getCreds } from "./machines.js"; import { renderTemplate, resolveTemplate } from "../templates/render.js"; import { reduceAptLines } from "../templates/aptReduce.js"; import { runScriptSudo } from "../ssh/client.js"; import { parseRebootRequired, buildAptExecutionResult } from "./aptParse.js"; import { parseBootIdBefore, verifyReboot } from "./rebootVerify.js"; import type { RebootResult } from "@shared/types.js"; import { extractSection, refreshMachine } from "./refresh.js"; import { buildReportMarkdown } from "./report.js"; import { outputHub } from "../ws/outputHub.js"; import { upsertMachineState, recordEvent } from "./machineState.js"; import type { ActionType, AptExecutionResult, ExecutionResult, ExecutionStatus } from "@shared/types.js"; // Actions APT/système résolues par profil OS (resolveTemplate → proxmox/raspbian si dispo, // sinon fallback apt/). La valeur est le basename d'action (sans dossier ni extension). const APT_ACTION_FILE: Partial> = { apt_full_upgrade: "full-upgrade", apt_upgrade: "upgrade", apt_autoremove: "autoremove", apt_clean: "clean", reboot: "reboot", reboot_verified: "reboot", apt_proxy_persistent: "apt-proxy-persistent", }; export interface RunActionOpts { stackId?: string; aggressive?: boolean; // docker_prune_images profileId?: string; // post_install values?: Record; } /** * Archive une exécution terminée (log brut + rapport + lignes DB + état machine + * event) et renvoie l'ExecutionResult. Mutualise le boilerplate des branches Docker. */ function archiveExecution(args: { machineId: string; machineName: string; executionId: string; action: ActionType; startedAt: string; status: ExecutionStatus; raw: string; importantLines: string[]; docker?: ExecutionResult["docker"]; postInstall?: ExecutionResult["postInstall"]; reboot?: ExecutionResult["reboot"]; errors?: ExecutionResult["errors"]; }): ExecutionResult { const { machineId, machineName, executionId, action, startedAt, status, raw, importantLines } = args; const finishedAt = new Date().toISOString(); const dir = join(env.reportsDir, machineId); mkdirSync(dir, { recursive: true }); const rawLogPath = join(dir, `${executionId}.log`); const reportPath = join(dir, `${executionId}.md`); writeFileSync(rawLogPath, raw || importantLines.join("\n") + "\n", "utf8"); const result: ExecutionResult = { executionId, machineId, startedAt, finishedAt, mode: "manual", action, status, rebootRequiredAfterRun: false, importantLogLines: importantLines, rawLogRef: rawLogPath, reportRef: reportPath, ...(args.docker ? { docker: args.docker } : {}), ...(args.postInstall ? { postInstall: args.postInstall } : {}), ...(args.reboot ? { reboot: args.reboot } : {}), ...(args.errors && args.errors.length ? { errors: args.errors } : {}), }; writeFileSync(reportPath, buildReportMarkdown(result, machineName), "utf8"); const reportId = randomUUID(); db.update(schema.executions).set({ finishedAt, status, schemaVersion: 1, resultJson: JSON.stringify(result), importantJson: JSON.stringify(importantLines), reportPath, rawLogPath, reportId, exitCode: status === "ok" ? 0 : 1, errorKind: status === "error" ? "execution_failed" : null, errorMessage: status === "error" ? (importantLines.at(-1) ?? null) : null, }).where(eq(schema.executions.id, executionId)).run(); db.update(schema.machines).set({ status: status === "error" ? "error" : "unknown" }) .where(eq(schema.machines.id, machineId)).run(); db.insert(schema.reports).values({ id: reportId, machineId, executionId, kind: "machine", title: `${machineName} — ${action}`, path: reportPath, createdAt: finishedAt, }).run(); db.insert(schema.rawArtifacts).values({ id: randomUUID(), machineId, kind: "raw_log", path: rawLogPath, bytes: statSync(rawLogPath).size, createdAt: finishedAt, retentionPolicy: status === "error" ? "failed" : "default", }).run(); upsertMachineState(machineId, { status: status === "error" ? "error" : "unknown", runningJobId: null, lastErrorKind: status === "error" ? "execution_failed" : null, lastErrorMessage: status === "error" ? (importantLines.at(-1) ?? null) : null, }); recordEvent({ machineId, eventType: `action_${action}`, severity: status === "error" ? "error" : "info", executionId, message: `Action ${action} : ${status}`, }); outputHub.publish(machineId, `\n===SU:DONE status=${status}===\n`); return result; } export async function runAction( machineId: string, action: ActionType, opts?: RunActionOpts, ): Promise { const m = getMachineRow(machineId); if (!m) throw new Error("Machine introuvable"); const executionId = `exec_${Date.now()}_${randomUUID().slice(0, 8)}`; const startedAt = new Date().toISOString(); outputHub.clear(machineId); db.update(schema.machines).set({ status: "running" }).where(eq(schema.machines.id, machineId)).run(); db.insert(schema.executions).values({ id: executionId, machineId, action, mode: "manual", startedAt, status: "running", }).run(); upsertMachineState(machineId, { status: "running", runningJobId: executionId }); // --- SJ-4 : docker_scan délégué au service dédié (évite un double rendu sans racines) --- if (action === "docker_scan") { const { scanDockerStacks } = await import("./dockerScan.js"); const startedAtDocker = startedAt; let scanStatus: ExecutionStatus = "ok"; let scanSummaryLines: string[] = []; try { const parsed = await scanDockerStacks(machineId); scanSummaryLines = [ `docker_scan: ${parsed.stacks.length} stacks trouvées (${parsed.stacks.filter((s) => s.valid).length} valides)`, ...parsed.stacks.map((s) => ` ${s.valid ? "OK" : "INVALID"} ${s.workingDir}`), ...parsed.active.map((a) => ` ACTIVE project=${a.project} dir=${a.workingDir}`), ]; outputHub.publish(machineId, `\n===SU:DONE status=ok stacks=${parsed.stacks.length}===\n`); } catch (err) { scanStatus = "error"; scanSummaryLines = [`[ERREUR] ${(err as Error).message}`]; outputHub.publish(machineId, `\n[ERREUR] ${(err as Error).message}\n`); } const finishedAtDocker = new Date().toISOString(); const rawDocker = scanSummaryLines.join("\n") + "\n"; const dirDocker = join(env.reportsDir, machineId); mkdirSync(dirDocker, { recursive: true }); const rawLogPathDocker = join(dirDocker, `${executionId}.log`); const reportPathDocker = join(dirDocker, `${executionId}.md`); writeFileSync(rawLogPathDocker, rawDocker, "utf8"); const resultDocker: ExecutionResult = { executionId, machineId, startedAt: startedAtDocker, finishedAt: finishedAtDocker, mode: "manual", action, status: scanStatus, rebootRequiredAfterRun: false, importantLogLines: scanSummaryLines, rawLogRef: rawLogPathDocker, reportRef: reportPathDocker, }; writeFileSync(reportPathDocker, buildReportMarkdown(resultDocker, m.name), "utf8"); const reportIdDocker = randomUUID(); db.update(schema.executions).set({ finishedAt: finishedAtDocker, status: scanStatus, schemaVersion: 1, resultJson: JSON.stringify(resultDocker), importantJson: JSON.stringify(scanSummaryLines), reportPath: reportPathDocker, rawLogPath: rawLogPathDocker, reportId: reportIdDocker, exitCode: scanStatus === "ok" ? 0 : 1, errorKind: scanStatus === "error" ? "execution_failed" : null, errorMessage: scanStatus === "error" ? (scanSummaryLines.at(-1) ?? null) : null, }).where(eq(schema.executions.id, executionId)).run(); db.update(schema.machines).set({ status: scanStatus === "error" ? "error" : "unknown" }) .where(eq(schema.machines.id, machineId)).run(); db.insert(schema.reports).values({ id: reportIdDocker, machineId, executionId, kind: "machine", title: `${m.name} — docker_scan`, path: reportPathDocker, createdAt: finishedAtDocker, }).run(); db.insert(schema.rawArtifacts).values({ id: randomUUID(), machineId, kind: "raw_log", path: rawLogPathDocker, bytes: statSync(rawLogPathDocker).size, createdAt: finishedAtDocker, retentionPolicy: scanStatus === "error" ? "failed" : "default", }).run(); upsertMachineState(machineId, { status: scanStatus === "error" ? "error" : "unknown", runningJobId: null, lastErrorKind: scanStatus === "error" ? "execution_failed" : null, lastErrorMessage: scanStatus === "error" ? (scanSummaryLines.at(-1) ?? null) : null, }); recordEvent({ machineId, eventType: "action_docker_scan", severity: scanStatus === "error" ? "error" : "info", executionId, message: `Action docker_scan : ${scanStatus}`, }); return resultDocker; } // --- SJ-5 : docker_pull_check délégué au service dédié (pull + comparaison + persistance) --- if (action === "docker_pull_check") { if (!opts?.stackId) throw new Error("docker_pull_check requiert un stackId"); const { pullCheckStack, dockerDedupKey } = await import("./dockerPull.js"); let rawPull = ""; let pullStatus: ExecutionStatus = "ok"; let importantPull: string[] = []; let dockerExec: ExecutionResult["docker"]; try { const outcome = await pullCheckStack(machineId, opts.stackId, (c) => { rawPull += c; }); rawPull = outcome.raw; const r = outcome.result; pullStatus = r.status === "error" ? "error" : r.status === "warning" ? "warning" : "ok"; const changes = r.changes.map((ch) => ({ ...ch, dedupKey: dockerDedupKey(ch.imageRef ?? ch.stack, ch.fromDigest ?? null, ch.toDigest ?? null, ch.fromImageId ?? null, ch.toImageId ?? null), })); dockerExec = { pull: { changes, ...(r.errors.length ? { errors: r.errors } : {}) } }; importantPull = [ `docker_pull_check ${outcome.stackName} : ${r.changes.length} image(s) mise(s) à jour (${r.services.length} service(s))`, ...r.services.map((s) => ` ${s.status} ${s.image}`), ...r.errors.map((e) => ` [${e.kind}] ${e.message}`), ]; outputHub.publish(machineId, `\n===SU:DONE status=${pullStatus} changes=${r.changes.length}===\n`); } catch (err) { pullStatus = "error"; importantPull = [`[ERREUR] ${(err as Error).message}`]; rawPull += `\n[ERREUR] ${(err as Error).message}\n`; outputHub.publish(machineId, `\n[ERREUR] ${(err as Error).message}\n`); } const finishedAtPull = new Date().toISOString(); const dirPull = join(env.reportsDir, machineId); mkdirSync(dirPull, { recursive: true }); const rawLogPathPull = join(dirPull, `${executionId}.log`); const reportPathPull = join(dirPull, `${executionId}.md`); writeFileSync(rawLogPathPull, rawPull || importantPull.join("\n") + "\n", "utf8"); const resultPull: ExecutionResult = { executionId, machineId, startedAt, finishedAt: finishedAtPull, mode: "manual", action, status: pullStatus, rebootRequiredAfterRun: false, importantLogLines: importantPull, rawLogRef: rawLogPathPull, reportRef: reportPathPull, ...(dockerExec ? { docker: dockerExec } : {}), }; writeFileSync(reportPathPull, buildReportMarkdown(resultPull, m.name), "utf8"); const reportIdPull = randomUUID(); db.update(schema.executions).set({ finishedAt: finishedAtPull, status: pullStatus, schemaVersion: 1, resultJson: JSON.stringify(resultPull), importantJson: JSON.stringify(importantPull), reportPath: reportPathPull, rawLogPath: rawLogPathPull, reportId: reportIdPull, exitCode: pullStatus === "ok" ? 0 : 1, errorKind: pullStatus === "error" ? "execution_failed" : null, errorMessage: pullStatus === "error" ? (importantPull.at(-1) ?? null) : null, }).where(eq(schema.executions.id, executionId)).run(); db.update(schema.machines).set({ status: pullStatus === "error" ? "error" : "unknown" }) .where(eq(schema.machines.id, machineId)).run(); db.insert(schema.reports).values({ id: reportIdPull, machineId, executionId, kind: "machine", title: `${m.name} — docker_pull_check`, path: reportPathPull, createdAt: finishedAtPull, }).run(); db.insert(schema.rawArtifacts).values({ id: randomUUID(), machineId, kind: "raw_log", path: rawLogPathPull, bytes: statSync(rawLogPathPull).size, createdAt: finishedAtPull, retentionPolicy: pullStatus === "error" ? "failed" : "default", }).run(); upsertMachineState(machineId, { status: pullStatus === "error" ? "error" : "unknown", runningJobId: null, lastErrorKind: pullStatus === "error" ? "execution_failed" : null, lastErrorMessage: pullStatus === "error" ? (importantPull.at(-1) ?? null) : null, }); recordEvent({ machineId, eventType: "action_docker_pull_check", severity: pullStatus === "error" ? "error" : "info", executionId, message: `Action docker_pull_check : ${pullStatus}`, }); return resultPull; } // --- SJ-6 : actions Docker destructives (apply / prune / down) --- if (action === "docker_compose_apply") { if (!opts?.stackId) throw new Error("docker_compose_apply requiert un stackId"); const { applyStack } = await import("./dockerApply.js"); try { const o = await applyStack(machineId, opts.stackId, executionId, (c) => outputHub.publish(machineId, c)); const p = o.parsed; const status: ExecutionStatus = p.errors.length || (p.exitCode !== null && p.exitCode !== 0) ? "error" : "ok"; const important = [ `docker_compose_apply ${o.stackName} : ${p.recreated.length} recréé(s), ${p.running.length} running, ${p.exited.length} exited`, ...p.recreated.map((n) => ` recreated ${n}`), ...p.exited.map((n) => ` exited ${n}`), ...p.errors.map((e) => ` [${e.kind}] ${e.message}`), ]; return archiveExecution({ machineId, machineName: m.name, executionId, action, startedAt, status, raw: o.raw, importantLines: important, docker: { up: { recreated: p.recreated, running: p.running, exited: p.exited, ...(p.errors.length ? { errors: p.errors } : {}) } }, }); } catch (err) { return archiveExecution({ machineId, machineName: m.name, executionId, action, startedAt, status: "error", raw: "", importantLines: [`[ERREUR] ${(err as Error).message}`] }); } } if (action === "docker_prune_images") { const { pruneImages } = await import("./dockerApply.js"); try { const o = await pruneImages(machineId, executionId, !!opts?.aggressive, (c) => outputHub.publish(machineId, c)); const p = o.parsed; const status: ExecutionStatus = p.errors.length || (p.exitCode !== null && p.exitCode !== 0) ? "error" : "ok"; const mb = (p.bytesReclaimed / 1e6).toFixed(1); const important = [ `docker_prune_images (${opts?.aggressive ? "agressif" : "safe"}) : ${p.imagesDeleted.length} image(s), ${mb} Mo récupérés`, ...p.errors.map((e) => ` [${e.kind}] ${e.message}`), ]; return archiveExecution({ machineId, machineName: m.name, executionId, action, startedAt, status, raw: o.raw, importantLines: important, docker: { prune: { imagesDeleted: p.imagesDeleted, bytesReclaimed: p.bytesReclaimed, ...(p.errors.length ? { errors: p.errors } : {}) } }, }); } catch (err) { return archiveExecution({ machineId, machineName: m.name, executionId, action, startedAt, status: "error", raw: "", importantLines: [`[ERREUR] ${(err as Error).message}`] }); } } if (action === "docker_compose_down") { if (!opts?.stackId) throw new Error("docker_compose_down requiert un stackId"); const { downStack } = await import("./dockerApply.js"); try { const o = await downStack(machineId, opts.stackId, (c) => outputHub.publish(machineId, c)); const p = o.parsed; const status: ExecutionStatus = p.errors.length || (p.exitCode !== null && p.exitCode !== 0) ? "error" : "ok"; const important = [ `docker_compose_down ${o.stackName} : ${p.removed.length} conteneur(s) retiré(s)`, ...p.removed.map((n) => ` removed ${n}`), ...p.errors.map((e) => ` [${e.kind}] ${e.message}`), ]; return archiveExecution({ machineId, machineName: m.name, executionId, action, startedAt, status, raw: o.raw, importantLines: important, errors: p.errors, }); } catch (err) { return archiveExecution({ machineId, machineName: m.name, executionId, action, startedAt, status: "error", raw: "", importantLines: [`[ERREUR] ${(err as Error).message}`] }); } } // --- SJ-8 : post-install (profil + champs de formulaire) --- if (action === "post_install") { if (!opts?.profileId) throw new Error("post_install requiert un profileId"); const { runPostInstall, rebootAndRebind } = await import("./postInstall.js"); try { const o = await runPostInstall(machineId, opts.profileId, opts.values ?? {}, () => {}); const r = o.result; const important = [ `post_install ${opts.profileId} : ${r.packagesInstalled.length} paquet(s), ${r.filesModified.length} fichier(s) modifié(s)${r.rebootsRequested ? " · reboot demandé" : ""}`, ...r.packagesInstalled.map((p) => ` + ${p}`), ...r.filesModified.map((f) => ` ~ ${f}`), ...(r.networkChange ? [` réseau : ${r.networkChange.oldEndpoint ?? "?"} → ${r.networkChange.newEndpoint ?? "?"} (reconnexion ${r.networkChange.reconnectHost ?? "?"})`] : []), ...(r.errors?.map((e) => ` [${e.kind}] ${e.message}`) ?? []), ]; // identity_network + reboot coché + succès : reboote, attend la nouvelle IP, corrige la BDD. let rebootResult: ExecutionResult["reboot"]; const newHost = r.networkChange?.reconnectHost ?? r.networkChange?.newEndpoint ?? null; if (o.status === "ok" && opts.profileId === "identity_network" && opts.values?.rebootAfterInstall && newHost) { const newName = opts.values?.newHostname != null ? String(opts.values.newHostname) : null; rebootResult = await rebootAndRebind(machineId, newHost, newName, () => {}); important.push( rebootResult.status === "ok" ? ` reboot vérifié → machine basculée sur ${newHost} (BDD mise à jour)` : ` reboot/reconnexion : ${rebootResult.status} (BDD inchangée)`, ); } const finalStatus: ExecutionStatus = rebootResult && rebootResult.status !== "ok" ? "error" : o.status; outputHub.publish(machineId, `\n===SU:DONE status=${finalStatus}===\n`); return archiveExecution({ machineId, machineName: m.name, executionId, action, startedAt, status: finalStatus, raw: o.raw, importantLines: important, postInstall: r, reboot: rebootResult, errors: r.errors, }); } catch (err) { return archiveExecution({ machineId, machineName: m.name, executionId, action, startedAt, status: "error", raw: "", importantLines: [`[ERREUR] ${(err as Error).message}`] }); } } // --- SJ-7 : sonde machine (lecture seule) déléguée au service dédié --- if (action === "machine_probe") { const { runProbe } = await import("./machineProbe.js"); try { const o = await runProbe(machineId, () => {}); const important = [ `machine_probe : os=${o.probe.osId ?? "?"} ${o.probe.osVersion ?? ""} arch=${o.probe.arch ?? "?"} virt=${o.probe.virt ?? "?"}`, `proposition : os_family=${o.proposal.osFamily} machine_kind=${o.proposal.machineKind} virtualization=${o.proposal.virtualization}`, ...(o.changes.length ? ["corrections proposées (non appliquées) :", ...o.changes.map((c) => ` ${c}`)] : ["aucune correction proposée"]), ]; outputHub.publish(machineId, `\n===SU:DONE status=ok===\n`); return archiveExecution({ machineId, machineName: m.name, executionId, action, startedAt, status: "ok", raw: o.raw, importantLines: important }); } catch (err) { return archiveExecution({ machineId, machineName: m.name, executionId, action, startedAt, status: "error", raw: "", importantLines: [`[ERREUR] ${(err as Error).message}`] }); } } const proxy = m.aptProxyMode === "runtime" ? m.aptProxyUrl : null; // Résolution du template : Docker inspect = chemin direct ; sinon résolution par profil OS. let rel: string; if (action === "docker_inspect_current") { rel = "docker/inspect-compose.sh.tpl"; } else { const file = APT_ACTION_FILE[action]; if (!file) throw new Error("Action sans template: " + action); rel = resolveTemplate(file, m.osFamily); } // Docker inspect par-stack : injecter stackDir ; ignoré par les templates APT. let stackDir: string | null = null; if (opts?.stackId) { const st = db.select().from(schema.dockerComposeStacks).where(eq(schema.dockerComposeStacks.id, opts.stackId)).get(); stackDir = st?.workingDir ?? null; } // Proxy persistant : l'URL est passée comme variable de template (jamais un secret). const aptProxyUrl = action === "apt_proxy_persistent" ? m.aptProxyUrl : null; const script = renderTemplate(rel, { aptProxy: proxy, stackDir, aptProxyUrl }); const inactivity = action === "reboot" ? 0 : 600000; let raw = ""; let status: ExecutionStatus = "ok"; try { const res = await runScriptSudo(getCreds(m), script, (c) => { raw += c; outputHub.publish(machineId, c); }, inactivity); raw = res.stdout; if (/===SU:EXIT=\d+===/.test(raw) && !/===SU:EXIT=0===/.test(raw)) { status = "error"; } } catch (err) { status = "error"; raw += `\n[ERREUR] ${(err as Error).message}\n`; } // Vérification réseau du reboot (nouvelle action reboot_verified, jalon SJ-3). let rebootResult: RebootResult | undefined; if (action === "reboot_verified") { const beforeBootId = parseBootIdBefore(raw); outputHub.publish(machineId, "\n[reboot] attente du redémarrage...\n"); rebootResult = await verifyReboot(getCreds(m), { beforeBootId, requestedAt: startedAt }); if (rebootResult.status !== "ok") status = "error"; if (rebootResult.status === "ok") { recordEvent({ machineId, eventType: "reboot_verified", severity: "info", executionId, message: `Reboot vérifié en ${rebootResult.waitedSeconds}s (boot_id changé)`, }); } } const finishedAt = new Date().toISOString(); const rebootRequired = parseRebootRequired(extractSection(raw, "===SU:REBOOT===", "===SU:EXIT") || raw); // Diff dpkg réel (si le template a émis DPKG_BEFORE + DPKG_AFTER). let aptResult: AptExecutionResult | undefined; if (raw.includes("===SU:DPKG_BEFORE===") && raw.includes("===SU:DPKG_AFTER===")) { const afterBeforeMarker = raw.includes("===SU:APT_FULLUPGRADE===") ? "===SU:APT_FULLUPGRADE===" : raw.includes("===SU:APT_UPGRADE===") ? "===SU:APT_UPGRADE===" : "===SU:APT_AUTOREMOVE==="; aptResult = buildAptExecutionResult( extractSection(raw, "===SU:DPKG_BEFORE===", afterBeforeMarker), extractSection(raw, "===SU:DPKG_AFTER===", "===SU:REBOOT==="), extractSection(raw, "===SU:REBOOT===", "===SU:EXIT"), ); } // Archivage log brut + rapport. const dir = join(env.reportsDir, machineId); mkdirSync(dir, { recursive: true }); const rawLogPath = join(dir, `${executionId}.log`); const reportPath = join(dir, `${executionId}.md`); writeFileSync(rawLogPath, raw, "utf8"); const result: ExecutionResult = { executionId, machineId, startedAt, finishedAt, mode: "manual", action, status, rebootRequiredAfterRun: rebootRequired, importantLogLines: reduceAptLines(raw), rawLogRef: rawLogPath, reportRef: reportPath, ...(aptResult ? { apt: aptResult } : {}), ...(rebootResult ? { reboot: rebootResult } : {}), }; writeFileSync(reportPath, buildReportMarkdown(result, m.name), "utf8"); const reportId = randomUUID(); const exitMatch = /===SU:EXIT=(\d+)===/.exec(raw); db.update(schema.executions).set({ finishedAt, status, schemaVersion: 1, resultJson: JSON.stringify(result), importantJson: JSON.stringify(result.importantLogLines), reportPath, rawLogPath, reportId, exitCode: exitMatch ? Number(exitMatch[1]) : null, errorKind: status === "error" ? "execution_failed" : null, errorMessage: status === "error" ? (result.importantLogLines.at(-1) ?? null) : null, }).where(eq(schema.executions.id, executionId)).run(); db.update(schema.machines).set({ status: status === "error" ? "error" : "unknown" }) .where(eq(schema.machines.id, machineId)).run(); db.insert(schema.reports).values({ id: reportId, machineId, executionId, kind: "machine", title: `${m.name} — ${action}`, path: reportPath, createdAt: finishedAt, }).run(); db.insert(schema.rawArtifacts).values({ id: randomUUID(), machineId, kind: "raw_log", path: rawLogPath, bytes: statSync(rawLogPath).size, createdAt: finishedAt, retentionPolicy: status === "error" ? "failed" : "default", }).run(); upsertMachineState(machineId, { status: status === "error" ? "error" : "unknown", runningJobId: null, lastErrorKind: status === "error" ? "execution_failed" : null, lastErrorMessage: status === "error" ? (result.importantLogLines.at(-1) ?? null) : null, }); const execSeverity: "info" | "warning" | "error" = status === "error" ? "error" : (status as string) === "warning" ? "warning" : "info"; recordEvent({ machineId, eventType: `action_${action}`, severity: execSeverity, executionId, message: `Action ${action} : ${status}`, }); outputHub.publish(machineId, `\n===SU:DONE status=${status}===\n`); // Après une action APT qui modifie l'état des paquets, régénérer le snapshot // pour que la webUI reflète les mises à jour restantes (retour amelioration.md #3). const REFRESH_AFTER: ActionType[] = ["apt_full_upgrade", "apt_upgrade", "apt_dist_upgrade", "apt_autoremove"]; if (status !== "error" && REFRESH_AFTER.includes(action)) { try { await refreshMachine(machineId); } catch (err) { // Refresh best-effort : ne pas faire échouer l'action si la ré-analyse échoue. recordEvent({ machineId, eventType: "post_action_refresh_failed", severity: "warning", executionId, message: `Refresh post-${action} échoué : ${(err as Error).message}` }); } } return result; } export function listExecutions(machineId: string) { return db.select().from(schema.executions).where(eq(schema.executions.machineId, machineId)).all(); } export function getExecution(executionId: string) { return db.select().from(schema.executions).where(eq(schema.executions.id, executionId)).get(); }