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:
@@ -0,0 +1,86 @@
|
||||
import { AbbruchFehler, fehlerBeschreiben, type FehlerBeschreibung } from "./fehler";
|
||||
|
||||
/**
|
||||
* Der Phasen-Runner. Er kennt weder Proxmox noch die Datenbank: Phasen sind Funktionen, Ereignisse
|
||||
* gehen an eine Senke. Der worker hängt eine Senke an, die in die Datenbank schreibt und per
|
||||
* NOTIFY meldet; Tests hängen eine an, die mitschreibt.
|
||||
*
|
||||
* Die Texte (Titel, Detail, Ursache, Hinweis) entstehen nur hier und in den Phasen – Web und CLI
|
||||
* zeigen sie unverändert an.
|
||||
*/
|
||||
export type PhasenStatusWert = "OFFEN" | "LAEUFT" | "ERLEDIGT" | "FEHLER" | "UEBERSPRUNGEN";
|
||||
|
||||
export type LogStufe = "debug" | "info" | "warn" | "error";
|
||||
|
||||
export interface EreignisSenke {
|
||||
phasenAnlegen(phasen: Array<{ schluessel: string; titel: string }>): Promise<void>;
|
||||
phaseGestartet(schluessel: string, detail?: string): Promise<void>;
|
||||
/** Zwischenstand, z. B. „Download 43 %“ oder „cloud-init läuft …“. */
|
||||
phaseDetail(schluessel: string, detail: string): Promise<void>;
|
||||
phaseBeendet(
|
||||
schluessel: string,
|
||||
status: Exclude<PhasenStatusWert, "OFFEN" | "LAEUFT">,
|
||||
angaben: { detail?: string; fehler?: FehlerBeschreibung },
|
||||
): Promise<void>;
|
||||
log(stufe: LogStufe, nachricht: string, angaben?: { phase?: string; daten?: unknown }): Promise<void>;
|
||||
}
|
||||
|
||||
export type PhasenKontext = {
|
||||
/** Detailtext der laufenden Phase aktualisieren. */
|
||||
detail(text: string): Promise<void>;
|
||||
log(nachricht: string, stufe?: LogStufe, daten?: unknown): Promise<void>;
|
||||
/** Wirft AbbruchFehler, wenn der Auftrag abgebrochen werden soll. */
|
||||
pruefeAbbruch(): Promise<void>;
|
||||
};
|
||||
|
||||
export type PhasenErgebnis = { detail?: string; uebersprungen?: boolean };
|
||||
|
||||
export type PhasenSchritt = {
|
||||
schluessel: string;
|
||||
titel: string;
|
||||
ausfuehren: (kontext: PhasenKontext) => Promise<PhasenErgebnis | void>;
|
||||
};
|
||||
|
||||
export type AblaufErgebnis =
|
||||
{ ok: true } | { ok: false; abgebrochen: boolean; phase: string; fehler: FehlerBeschreibung };
|
||||
|
||||
export async function phasenAusfuehren(
|
||||
schritte: PhasenSchritt[],
|
||||
senke: EreignisSenke,
|
||||
optionen: { abbruchGewuenscht?: () => Promise<boolean> } = {},
|
||||
): Promise<AblaufErgebnis> {
|
||||
await senke.phasenAnlegen(schritte.map(({ schluessel, titel }) => ({ schluessel, titel })));
|
||||
|
||||
for (const schritt of schritte) {
|
||||
const kontext: PhasenKontext = {
|
||||
detail: (text) => senke.phaseDetail(schritt.schluessel, text),
|
||||
log: (nachricht, stufe = "info", daten) =>
|
||||
senke.log(stufe, nachricht, { phase: schritt.schluessel, daten }),
|
||||
pruefeAbbruch: async () => {
|
||||
if (await optionen.abbruchGewuenscht?.()) throw new AbbruchFehler();
|
||||
},
|
||||
};
|
||||
try {
|
||||
await kontext.pruefeAbbruch();
|
||||
await senke.phaseGestartet(schritt.schluessel);
|
||||
const ergebnis = (await schritt.ausfuehren(kontext)) ?? {};
|
||||
await senke.phaseBeendet(schritt.schluessel, ergebnis.uebersprungen ? "UEBERSPRUNGEN" : "ERLEDIGT", {
|
||||
detail: ergebnis.detail,
|
||||
});
|
||||
} catch (fehler) {
|
||||
const beschreibung = fehlerBeschreiben(fehler);
|
||||
await senke.phaseBeendet(schritt.schluessel, "FEHLER", { fehler: beschreibung });
|
||||
await senke.log("error", `${schritt.titel}: ${beschreibung.ursache}`, {
|
||||
phase: schritt.schluessel,
|
||||
daten: fehler instanceof Error ? { name: fehler.name, stack: fehler.stack } : undefined,
|
||||
});
|
||||
return {
|
||||
ok: false,
|
||||
abgebrochen: fehler instanceof AbbruchFehler,
|
||||
phase: schritt.schluessel,
|
||||
fehler: beschreibung,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
Reference in New Issue
Block a user