fix(cicd): 自动恢复 GitHub webhook 失败投递

This commit is contained in:
Codex
2026-07-12 08:51:06 +02:00
parent c7663f8df7
commit 4629a6fb94
16 changed files with 1458 additions and 19 deletions
@@ -420,6 +420,7 @@ export function createDurableSyncController(options) {
journal: storageReady ? store.journalMetadata() : null,
counts,
deliveries: statusRecords(records).map(publicInboxRecord),
failedDeliveryRecovery: await readFailedDeliveryRecoveryStatus(options.failedDeliveryRecoveryStatePath),
};
}
@@ -946,6 +947,7 @@ export function loadRuntimeConfig() {
committedRetentionMs: positiveIntegerEnv("UNIDESK_GITEA_WEBHOOK_COMMITTED_RETENTION_SECONDS") * 1000,
cleanupIntervalMs: positiveIntegerEnv("UNIDESK_GITEA_WEBHOOK_CLEANUP_INTERVAL_MS"),
},
failedDeliveryRecoveryStatePath: process.env.UNIDESK_GITEA_HOOK_RECOVERY_STATE_PATH || null,
workerRetry: {
maxAttempts: positiveIntegerEnv("UNIDESK_GITEA_WEBHOOK_RETRY_MAX_ATTEMPTS"),
initialDelayMs: positiveIntegerEnv("UNIDESK_GITEA_WEBHOOK_RETRY_INITIAL_DELAY_MS"),
@@ -964,6 +966,51 @@ export function loadRuntimeConfig() {
};
}
export async function readFailedDeliveryRecoveryStatus(statePath) {
if (!statePath) return { enabled: false, ready: true, state: "disabled", counts: {}, entries: [] };
let ledger;
try {
ledger = JSON.parse(await readFile(statePath, "utf8"));
} catch (error) {
if (error?.code === "ENOENT") return { enabled: true, ready: false, state: "not-initialized", counts: {}, entries: [] };
return { enabled: true, ready: false, state: "invalid-or-unreadable", errorType: "hook-recovery-ledger-unreadable", counts: {}, entries: [] };
}
if (ledger?.version !== 1 || ledger?.kind !== "GiteaGithubHookRecoveryLedger" || !ledger.records || typeof ledger.records !== "object" || Array.isArray(ledger.records)) {
return { enabled: true, ready: false, state: "invalid-or-unreadable", errorType: "hook-recovery-ledger-invalid", counts: {}, entries: [] };
}
const allowedStates = new Set(["pending", "attempted", "delivery-succeeded-awaiting-source", "source-committed", "terminal-exhausted"]);
const records = Object.values(ledger.records);
const validRecord = (item) => item && typeof item === "object"
&& allowedStates.has(item.state)
&& typeof item.repository === "string" && item.repository.length > 0
&& (typeof item.hookId === "number" || typeof item.hookId === "string")
&& typeof item.deliveryId === "string" && item.deliveryId.length > 0
&& Number.isInteger(item.attempts) && item.attempts >= 0
&& Number.isInteger(item.cooldownMs) && item.cooldownMs >= 0
&& typeof item.updatedAt === "string" && item.updatedAt.length > 0;
if (records.some((item) => !validRecord(item))) {
return { enabled: true, ready: false, state: "invalid-or-unreadable", errorType: "hook-recovery-ledger-record-invalid", counts: {}, entries: [] };
}
const counts = { pending: 0, attempted: 0, "delivery-succeeded-awaiting-source": 0, "source-committed": 0, "terminal-exhausted": 0 };
for (const item of records) counts[item.state] += 1;
const entries = records
.sort((left, right) => String(right.updatedAt || "").localeCompare(String(left.updatedAt || "")))
.slice(0, 50)
.map((item) => ({
repository: item.repository,
hookId: item.hookId,
deliveryId: item.deliveryId,
state: item.state,
attempts: item.attempts,
cooldownMs: item.cooldownMs,
nextAt: item.nextAt ?? null,
updatedAt: item.updatedAt,
lastStatusCode: item.lastStatusCode ?? null,
completionReason: item.completionReason ?? null,
}));
return { enabled: true, ready: true, state: "ready", counts, entries };
}
export function startServer(config = loadRuntimeConfig()) {
const controller = config.controller ?? createDurableSyncController(config);
const server = createServer(createGithubSyncHandler({ ...config, controller }));