feat(auftraege): worker, Phasen und Live-Fortschritt per SSE

Aufträge laufen als pg-boss-Jobs im worker. Der Phasen-Runner der Engine
kennt weder Proxmox noch Datenbank; eine Senke schreibt Phasen und Log
in die DB und meldet per NOTIFY, die app streamt per SSE an Web und CLI
(Last-Event-ID, Herzschlag, Polling-Fallback im Browser).

VMIDs vergibt eine Advisory-Sperre je Server zusammen mit einer
Reservierungstabelle; zehn parallele Reservierungen im Test bekommen
zehn verschiedene IDs. Der Proxmox-Client hat einen Trockenmodus für
den Dry-Run: Lesendes echt, Schreibendes nur aufgezeichnet.

Live-Ansicht im Aufbau der CLI-Ausgabe. Im echten Betrieb aufgefallen:
ssh2 und pg sind CommonJS und brauchen im worker Default-Importe.

Closes #006

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 10:54:41 +02:00
co-authored by Claude Opus 5.5
parent 01d22aac3a
commit 76bc0e4269
38 changed files with 2274 additions and 22 deletions
+127
View File
@@ -0,0 +1,127 @@
import type { Auftrag, Prisma } from "@/generated/prisma/client";
import type { AuftragsArt } from "@/generated/prisma/enums";
import type { AblaufErgebnis, EreignisSenke } from "@/engine/ablauf/ablauf";
import { prisma } from "@/lib/db";
import { logger } from "@/lib/logger";
import { DatenbankSenke, melden } from "@/server/ereignisse/senke";
import { reservierungenFreigeben } from "@/server/services/vmid";
/**
* Führt einen Auftrag aus: Status setzen, den passenden Ablauf starten, Ergebnis festhalten.
* Die Abläufe selbst (Template bauen, VM/LXC ausrollen, aufräumen) melden sich hier an.
*/
export type AblaufKontext = {
auftrag: Auftrag;
senke: EreignisSenke;
abbruchGewuenscht: () => Promise<boolean>;
/** Merkt sich, was angelegt wurde – fürs Aufräumen, auch wenn später etwas scheitert. */
angelegt: (eintrag: Record<string, unknown>) => Promise<void>;
};
export type AblaufAusgang = AblaufErgebnis & { ergebnis?: Record<string, unknown> };
export type AblaufFunktion = (kontext: AblaufKontext) => Promise<AblaufAusgang>;
const ABLAEUFE = new Map<AuftragsArt, AblaufFunktion>();
export function ablaufRegistrieren(art: AuftragsArt, funktion: AblaufFunktion): void {
ABLAEUFE.set(art, funktion);
}
export async function auftragAusfuehren(
auftragId: string,
senke: EreignisSenke = new DatenbankSenke(auftragId),
) {
// Nur einmal starten, auch wenn pg-boss einen Job doppelt liefern sollte.
const gestartet = await prisma.auftrag.updateMany({
where: { id: auftragId, status: "WARTEND" },
data: { status: "LAEUFT", gestartetAm: new Date() },
});
if (gestartet.count === 0) {
logger.warn({ auftragId }, "Auftrag ist nicht (mehr) wartend – übersprungen");
return;
}
const auftrag = await prisma.auftrag.findUniqueOrThrow({ where: { id: auftragId } });
await melden(auftragId);
let ausgang: AblaufAusgang;
const ablauf = ABLAEUFE.get(auftrag.art);
try {
if (!ablauf) throw new Error(`Für ${auftrag.art} ist kein Ablauf registriert.`);
ausgang = await ablauf({
auftrag,
senke,
abbruchGewuenscht: async () =>
(await prisma.auftrag.findUnique({ where: { id: auftragId }, select: { abbrechen: true } }))
?.abbrechen ?? false,
angelegt: async (eintrag) => {
const aktuell = (
await prisma.auftrag.findUnique({ where: { id: auftragId }, select: { angelegt: true } })
)?.angelegt as Record<string, unknown> | null;
await prisma.auftrag.update({
where: { id: auftragId },
data: { angelegt: { ...(aktuell ?? {}), ...eintrag } as Prisma.InputJsonValue },
});
},
});
} catch (fehler) {
// Fehler außerhalb einer Phase (Programmfehler) – trotzdem sauber beenden.
logger.error({ err: fehler, auftragId }, "Ablauf ist abgestürzt");
const text = fehler instanceof Error ? fehler.message : String(fehler);
ausgang = {
ok: false,
abgebrochen: false,
phase: "",
fehler: { ursache: `Unerwarteter Fehler: ${text}`, hinweis: "Detail-Log prüfen." },
};
await senke.log("error", `Unerwarteter Fehler: ${text}`);
}
await prisma.auftrag.update({
where: { id: auftragId },
data: {
status: ausgang.ok ? "ERFOLGREICH" : ausgang.abgebrochen ? "ABGEBROCHEN" : "FEHLGESCHLAGEN",
beendetAm: new Date(),
...(ausgang.ok
? { ergebnis: (ausgang.ergebnis ?? {}) as Prisma.InputJsonValue }
: { fehler: { phase: ausgang.phase, ...ausgang.fehler } }),
},
});
await reservierungenFreigeben(auftragId);
await melden(auftragId);
}
/**
* Nach einem Absturz oder Neustart des workers hängen Aufträge auf LAEUFT. Sie werden ehrlich als
* fehlgeschlagen markiert – fortsetzen wäre riskanter als neu starten.
*/
export async function verwaisteAuftraegeBeenden(): Promise<number> {
const verwaist = await prisma.auftrag.findMany({ where: { status: "LAEUFT" }, select: { id: true } });
for (const { id } of verwaist) {
await prisma.$transaction([
prisma.phase.updateMany({
where: { auftragId: id, status: "LAEUFT" },
data: {
status: "FEHLER",
beendetAm: new Date(),
ursache: "Der worker wurde neu gestartet.",
hinweis: "Auftrag wiederholen; Reste ggf. aufräumen.",
},
}),
prisma.auftrag.update({
where: { id },
data: {
status: "FEHLGESCHLAGEN",
beendetAm: new Date(),
fehler: {
ursache: "Der worker wurde während des Auftrags neu gestartet.",
hinweis: "Auftrag wiederholen; Reste ggf. aufräumen.",
},
},
}),
]);
await reservierungenFreigeben(id);
await melden(id);
}
return verwaist.length;
}