diff --git a/prisma/migrations/20260725210000_auto_sync/migration.sql b/prisma/migrations/20260725210000_auto_sync/migration.sql new file mode 100644 index 0000000..2062765 --- /dev/null +++ b/prisma/migrations/20260725210000_auto_sync/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable +ALTER TABLE "AppSettings" ADD COLUMN "autoSyncActiveOnly" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "autoSyncBatchSize" INTEGER NOT NULL DEFAULT 25, +ADD COLUMN "autoSyncEnabled" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "autoSyncIntervalMinutes" INTEGER NOT NULL DEFAULT 60, +ADD COLUMN "lastAutoSyncAt" TIMESTAMP(3), +ADD COLUMN "lastAutoSyncReport" JSONB; + +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "autoSync" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "syncFailureCount" INTEGER NOT NULL DEFAULT 0; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5a97205..c06a565 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -137,7 +137,25 @@ model AppSettings { allowRegistration Boolean @default(false) /// Anzeigename gegenueber dem Authenticator beim Anlegen eines Passkeys. rpName String @default("Nebula") - updatedAt DateTime @updatedAt + + // ── Automatischer Abgleich mit den Git-Systemen ──────────── + /// Standardmaessig aus: der Abgleich verbraucht fremde API-Kontingente, + /// das soll eine bewusste Entscheidung sein. + autoSyncEnabled Boolean @default(false) + /// Abstand zwischen zwei Durchlaeufen in Minuten. + autoSyncIntervalMinutes Int @default(60) + /// Fertige und archivierte Projekte auslassen – dort aendert sich selten + /// noch etwas. + autoSyncActiveOnly Boolean @default(true) + /// Hoechstzahl Projekte je Durchlauf, damit ein grosses Board nicht auf + /// einen Schlag in ein Rate-Limit laeuft. + autoSyncBatchSize Int @default(25) + + lastAutoSyncAt DateTime? + /// Kurzbericht des letzten Durchlaufs: { synced, failed, skipped, ms } + lastAutoSyncReport Json? + + updatedAt DateTime @updatedAt } /// Eine Verbindung zu einem Git-System inkl. verschluesseltem Token. @@ -207,6 +225,12 @@ model Project { lastSyncedAt DateTime? syncError String? + /// Am automatischen Abgleich teilnehmen. Einzeln abschaltbar, etwa bei + /// einem Repo, dessen Token staendig Fehler wirft. + autoSync Boolean @default(true) + /// Aufeinanderfolgende Fehlversuche. Steuert das Zurueckhalten: nach + /// jedem Fehlschlag wird der naechste Versuch weiter hinausgezoegert. + syncFailureCount Int @default(0) /// Besitzer. Nullable wie bei GitConnection, damit Bestandsdaten beim /// Einrichten des ersten Kontos uebernommen werden koennen. diff --git a/src/instrumentation.ts b/src/instrumentation.ts new file mode 100644 index 0000000..7287b67 --- /dev/null +++ b/src/instrumentation.ts @@ -0,0 +1,22 @@ +/** + * Wird von Next einmal beim Start des Servers ausgefuehrt. + * + * Hier haengt der Zeitgeber fuer den automatischen Abgleich. + * + * Wichtig ist die Form der Abfrage: Next uebersetzt diese Datei auch fuer den + * Edge-Runtime (weil es eine Middleware gibt). Dort gibt es weder node:crypto + * noch node:net, die der Abgleich mitbringt. Der Import muss deshalb *in* der + * positiven Bedingung stehen – dann ersetzt der Bundler NEXT_RUNTIME beim + * Bauen und entfernt den ganzen Block aus dem Edge-Bundle. Mit einem frueh + * abbrechenden "if (… !== 'nodejs') return" bliebe der Import stehen und der + * Build schluege fehl. + */ +export async function register() { + if (process.env.NEXT_RUNTIME === "nodejs") { + const { describeConfig } = await import("@/lib/config"); + console.log(`[nebula] Konfiguration: ${describeConfig()}`); + + const { startAutoSyncScheduler } = await import("@/lib/sync/scheduler"); + startAutoSyncScheduler(); + } +} diff --git a/src/lib/sync.ts b/src/lib/sync/index.ts similarity index 95% rename from src/lib/sync.ts rename to src/lib/sync/index.ts index 0ac69ae..b853669 100644 --- a/src/lib/sync.ts +++ b/src/lib/sync/index.ts @@ -63,7 +63,12 @@ export async function syncProject(projectId: string): Promise { const message = error instanceof Error ? error.message : String(error); await prisma.project.update({ where: { id: project.id }, - data: { syncError: message, lastSyncedAt: new Date() }, + data: { + syncError: message, + lastSyncedAt: new Date(), + // Zaehler fuers Zurueckhalten beim automatischen Abgleich. + syncFailureCount: { increment: 1 }, + }, }); return { ok: false, message }; } @@ -127,6 +132,7 @@ async function persistSnapshot(projectId: string, data: RepoSnapshotData) { data: { lastSyncedAt: new Date(), syncError: null, + syncFailureCount: 0, defaultBranch: data.defaultBranch, repoUrl: data.htmlUrl ?? undefined, }, diff --git a/src/lib/sync/scheduler.ts b/src/lib/sync/scheduler.ts new file mode 100644 index 0000000..fada231 --- /dev/null +++ b/src/lib/sync/scheduler.ts @@ -0,0 +1,171 @@ +import type { Prisma } from "@prisma/client"; +import { getAppSettings } from "@/lib/auth/settings"; +import { prisma } from "@/lib/db"; +import { syncProject } from "@/lib/sync"; + +/** + * Automatischer Abgleich mit den Git-Systemen. + * + * Laeuft im Serverprozess selbst, angestossen von instrumentation.ts. Fuer + * eine Einzelinstanz ist das die einfachste tragfaehige Loesung: kein + * zusaetzlicher Container, kein externer Cron, keine zweite Konfiguration. + * Wer lieber von aussen taktet, ruft stattdessen POST /api/admin/sync/run + * auf und laesst den Automatismus aus. + * + * Ruecksicht auf fremde Kontingente: + * - je Durchlauf nur eine begrenzte Zahl Projekte + * - die am laengsten nicht abgeglichenen zuerst + * - nach Fehlschlaegen wird zurueckgehalten, mit wachsendem Abstand + * - kurze Pause zwischen den Projekten + */ + +const TICK_MS = 60_000; +/** Kleine Verschnaufpause zwischen zwei Projekten. */ +const PAUSE_MS = 400; +/** Hoechstens so lange wird nach Fehlschlaegen zurueckgehalten. */ +const MAX_BACKOFF_STEPS = 4; + +export interface AutoSyncReport { + at: string; + trigger: "zeitplan" | "manuell"; + candidates: number; + synced: number; + failed: number; + /** Wegen Zurueckhaltung nach Fehlschlaegen uebersprungen. */ + skipped: number; + durationMs: number; +} + +// Ueber globalThis, damit der Hot-Reload im Entwicklungsbetrieb nicht bei +// jedem Neuladen einen weiteren Zeitgeber startet. +const globalForScheduler = globalThis as unknown as { + nebulaAutoSyncTimer?: NodeJS.Timeout; + nebulaAutoSyncRunning?: boolean; +}; + +export function startAutoSyncScheduler() { + if (globalForScheduler.nebulaAutoSyncTimer) return; + + const timer = setInterval(() => { + void tick(); + }, TICK_MS); + // Der Zeitgeber darf den Prozess nicht am Beenden hindern. + timer.unref?.(); + globalForScheduler.nebulaAutoSyncTimer = timer; + + // Kurz nach dem Start einmal nachsehen, statt eine volle Minute zu warten. + const initial = setTimeout(() => void tick(), 20_000); + initial.unref?.(); + + console.log("[nebula] Zeitgeber fuer den automatischen Abgleich laeuft."); +} + +/** Prueft, ob ein Durchlauf faellig ist, und startet ihn gegebenenfalls. */ +async function tick() { + try { + const settings = await getAppSettings(); + if (!settings.autoSyncEnabled) return; + + const intervalMs = Math.max(5, settings.autoSyncIntervalMinutes) * 60_000; + const last = settings.lastAutoSyncAt?.getTime() ?? 0; + if (Date.now() - last < intervalMs) return; + + await runAutoSyncPass("zeitplan"); + } catch (error) { + console.error("[nebula] Automatischer Abgleich fehlgeschlagen:", error); + } +} + +/** + * Ein Durchlauf. Laeuft nie zweimal gleichzeitig – ein zweiter Aufruf + * waehrend eines laufenden Durchlaufs bekommt null zurueck. + */ +export async function runAutoSyncPass( + trigger: AutoSyncReport["trigger"], +): Promise { + if (globalForScheduler.nebulaAutoSyncRunning) return null; + globalForScheduler.nebulaAutoSyncRunning = true; + + const startedAt = Date.now(); + let synced = 0; + let failed = 0; + let skipped = 0; + + try { + const settings = await getAppSettings(); + const batchSize = Math.max(1, Math.min(200, settings.autoSyncBatchSize)); + const intervalMs = Math.max(5, settings.autoSyncIntervalMinutes) * 60_000; + + const where: Prisma.ProjectWhereInput = { + autoSync: true, + // Ohne Repository gibt es nichts abzugleichen. + OR: [ + { AND: [{ repoOwner: { not: null } }, { repoName: { not: null } }] }, + { repoUrl: { not: null } }, + ], + ...(settings.autoSyncActiveOnly + ? { status: { notIn: ["DONE", "ARCHIVED"] } } + : {}), + }; + + // Grosszuegig holen: ein Teil faellt gleich der Zurueckhaltung zum Opfer. + const candidates = await prisma.project.findMany({ + where, + orderBy: [{ lastSyncedAt: { sort: "asc", nulls: "first" } }], + take: batchSize * 4, + select: { id: true, name: true, lastSyncedAt: true, syncFailureCount: true }, + }); + + const due = candidates.filter((project) => { + if (project.syncFailureCount === 0) return true; + if (!project.lastSyncedAt) return true; + + // Nach jedem Fehlschlag doppelt so lange warten, gedeckelt. + const steps = Math.min(project.syncFailureCount, MAX_BACKOFF_STEPS); + const wait = intervalMs * 2 ** steps; + const ready = Date.now() - project.lastSyncedAt.getTime() >= wait; + if (!ready) skipped += 1; + return ready; + }); + + const batch = due.slice(0, batchSize); + + for (const project of batch) { + const result = await syncProject(project.id); + if (result.ok) synced += 1; + else failed += 1; + if (PAUSE_MS > 0) await new Promise((resolve) => setTimeout(resolve, PAUSE_MS)); + } + + const report: AutoSyncReport = { + at: new Date().toISOString(), + trigger, + candidates: candidates.length, + synced, + failed, + skipped, + durationMs: Date.now() - startedAt, + }; + + await prisma.appSettings.update({ + where: { id: "singleton" }, + data: { + lastAutoSyncAt: new Date(), + lastAutoSyncReport: report as unknown as Prisma.InputJsonValue, + }, + }); + + console.log( + `[nebula] Abgleich (${trigger}): ${synced} aktualisiert, ${failed} fehlgeschlagen, ` + + `${skipped} zurueckgestellt, ${report.durationMs} ms`, + ); + + return report; + } finally { + globalForScheduler.nebulaAutoSyncRunning = false; + } +} + +export function isAutoSyncRunning(): boolean { + return Boolean(globalForScheduler.nebulaAutoSyncRunning); +}