Was ist hier eigentlich passiert - eine Zeitleiste auf der Projektseite beantwortet das jetzt: Statuswechsel, Notizen, Aufgaben, neue Commits. Der Verlauf soll erzaehlen, nicht protokollieren. Deshalb bekommen Status und Repo-Verknuepfung einen eigenen Eintrag, waehrend alle uebrigen Felder zu einem zusammengefasst werden - sonst ertraenkt eine Feinjustage der Farbe alles Wichtige. Ein Titel, der sich aendert, kommt gar nicht vor. Beim Abgleich ist Zurueckhaltung noch wichtiger: er laeuft im Zweifel stuendlich. Vermerkt wird nur, wenn der letzte Commit ein anderer ist als beim letzten Mal, und beim ersten Abgleich gar nicht - dass ein Repo Commits hat, ist keine Neuigkeit. Fehlschlaege stehen nur beim Uebergang in den Fehlerzustand drin, sonst schlaegt ein abgelaufenes Token stuendlich neu auf. Der Text wird beim Schreiben formuliert, nicht beim Anzeigen. Ein Eintrag soll auch dann lesbar bleiben, wenn es die Notiz, um die es ging, laengst nicht mehr gibt. Zweihundert Eintraege je Projekt bleiben erhalten, der taegliche Durchgang stutzt den Rest. Der Export nimmt den Verlauf nicht mit - er beschriebe, was in dieser Instanz passiert ist, und verwiese auf Konten, die es anderswo nicht gibt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
223 lines
7.2 KiB
TypeScript
223 lines
7.2 KiB
TypeScript
import type { Prisma } from "@prisma/client";
|
||
import { pruneActivities } from "@/lib/activity";
|
||
import { getAppSettings } from "@/lib/auth/settings";
|
||
import { prisma } from "@/lib/db";
|
||
import { runStaleCheck } from "@/lib/notify/stale";
|
||
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;
|
||
/** Stillstand aendert sich langsam – einmal am Tag genuegt. */
|
||
const STALE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||
/** 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) {
|
||
const intervalMs = Math.max(5, settings.autoSyncIntervalMinutes) * 60_000;
|
||
const last = settings.lastAutoSyncAt?.getTime() ?? 0;
|
||
if (Date.now() - last >= intervalMs) {
|
||
await runAutoSyncPass("zeitplan");
|
||
}
|
||
}
|
||
|
||
// Unabhaengig vom Abgleich: beides ergibt sich aus dem gespeicherten
|
||
// Stand, dafuer muss nichts geholt werden.
|
||
await dailyChores();
|
||
} catch (error) {
|
||
console.error("[nebula] Automatischer Abgleich fehlgeschlagen:", error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Was hoechstens einmal am Tag zu tun ist: den Verlauf stutzen und nach
|
||
* stillstehenden Projekten sehen.
|
||
*/
|
||
async function dailyChores() {
|
||
const settings = await getAppSettings();
|
||
const last = settings.lastStaleCheckAt?.getTime() ?? 0;
|
||
if (Date.now() - last < STALE_CHECK_INTERVAL_MS) return;
|
||
|
||
const zuhoerer = await prisma.notificationTarget.count({
|
||
where: { isActive: true, events: { has: "PROJECT_STALE" } },
|
||
});
|
||
|
||
// Zeitstempel auch ohne Zuhoerer setzen, damit nicht jede Minute gezaehlt
|
||
// wird.
|
||
await prisma.appSettings.update({
|
||
where: { id: "singleton" },
|
||
data: { lastStaleCheckAt: new Date() },
|
||
});
|
||
|
||
// Der Verlauf waechst mit jeder Aenderung; einmal am Tag zurueckschneiden
|
||
// reicht, um ihn beschraenkt zu halten.
|
||
try {
|
||
const entfernt = await pruneActivities();
|
||
if (entfernt > 0) console.log(`[nebula] Verlauf gestutzt: ${entfernt} Eintraege entfernt.`);
|
||
} catch (error) {
|
||
console.error("[nebula] Verlauf konnte nicht gestutzt werden:", error);
|
||
}
|
||
|
||
// Die Stillstandspruefung laeuft nur, wenn jemand zuhoert – sonst waere sie
|
||
// reine Datenbanklast.
|
||
if (zuhoerer === 0) return;
|
||
|
||
const report = await runStaleCheck(Math.max(1, settings.staleAfterDays));
|
||
if (report.stale > 0) {
|
||
console.log(
|
||
`[nebula] Stillstand: ${report.stale} von ${report.checked} Projekten ` +
|
||
`ueber ${report.thresholdDays} Tage ohne Commit, ${report.notified} Meldungen.`,
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Ein Durchlauf. Laeuft nie zweimal gleichzeitig – ein zweiter Aufruf
|
||
* waehrend eines laufenden Durchlaufs bekommt null zurueck.
|
||
*/
|
||
export async function runAutoSyncPass(
|
||
trigger: AutoSyncReport["trigger"],
|
||
): Promise<AutoSyncReport | null> {
|
||
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);
|
||
}
|