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
+43
View File
@@ -0,0 +1,43 @@
import { PgBoss } from "pg-boss";
import { logger } from "@/lib/logger";
/**
* Die Auftragswarteschlange (pg-boss auf derselben PostgreSQL, Schema `pgboss`). Die app stellt
* nur ein, der worker arbeitet ab. Ein Auftrag wird nie automatisch wiederholt: halb angelegte
* Gäste soll ein Mensch ansehen, nicht eine Schleife.
*/
export const WARTESCHLANGE = "auftrag";
/** Template-Bau kann bei langsamem Download dauern; danach gilt ein Job als verwaist. */
const LAUFZEIT_SEKUNDEN = 4 * 3600;
export type AuftragsJob = { auftragId: string };
let instanz: Promise<PgBoss> | undefined;
export function boss(rolle: "app" | "worker"): Promise<PgBoss> {
instanz ??= (async () => {
const url = process.env["DATABASE_URL"];
if (!url) throw new Error("DATABASE_URL ist nicht gesetzt.");
const b = new PgBoss({
connectionString: url,
application_name: `proxdeploy-${rolle}`,
// Wartung und Zeitpläne macht nur der worker; die app stellt nur ein.
supervise: rolle === "worker",
schedule: false,
});
b.on("error", (fehler) => logger.error({ err: fehler }, "pg-boss"));
await b.start();
await b.createQueue(WARTESCHLANGE, { retryLimit: 0, expireInSeconds: LAUFZEIT_SEKUNDEN });
return b;
})();
return instanz;
}
export async function auftragEinstellen(auftragId: string): Promise<void> {
const b = await boss("app");
await b.send(WARTESCHLANGE, { auftragId } satisfies AuftragsJob, {
retryLimit: 0,
expireInSeconds: LAUFZEIT_SEKUNDEN,
});
}