fix(release): make durable runs recovery safe

This commit is contained in:
2026-07-22 19:30:12 +02:00
parent 19c4b63ade
commit 22f5c2ff4e
5 changed files with 881 additions and 136 deletions

View File

@@ -909,6 +909,8 @@
runs: [],
runStoreAvailable: true,
};
const pendingReleaseRunKey = "govoplanReleaseConsolePendingRunCreate";
const pendingRunCommandsKey = "govoplanReleaseConsolePendingRunCommands";
elements.refresh.addEventListener("click", load);
elements.generateCandidate.addEventListener("click", generateCandidate);
@@ -962,7 +964,9 @@
}).then((response) => {
if (!response.ok) {
return response.json().catch(() => ({})).then((payload) => {
throw new Error(payload.detail || `${response.status} ${response.statusText}`);
const error = new Error(payload.detail || `${response.status} ${response.statusText}`);
error.status = response.status;
throw error;
});
}
return response.json();
@@ -980,7 +984,9 @@
}).then((response) => {
if (!response.ok) {
return response.json().catch(() => ({})).then((payload) => {
throw new Error(payload.detail || `${response.status} ${response.statusText}`);
const error = new Error(payload.detail || `${response.status} ${response.statusText}`);
error.status = response.status;
throw error;
});
}
return response.json();
@@ -1000,9 +1006,10 @@
renderReleaseRunStoreUnavailable(runsResult.error);
} else {
renderReleaseRunList(runsResult.data?.runs || []);
if (releaseState.currentRun?.run_id) {
const pendingRecoveryResult = await recoverPendingReleaseRun();
if (pendingRecoveryResult === null && releaseState.currentRun?.run_id) {
await loadReleaseRun(releaseState.currentRun.run_id, { restoreSelection: false });
} else {
} else if (pendingRecoveryResult === null) {
renderIdlePlan();
}
}
@@ -1070,7 +1077,7 @@
setBusy([elements.createReleaseRun], true);
elements.runStatus.textContent = "freezing plan...";
try {
const run = await postJson("/api/release-runs", {
const payload = pendingReleaseRunPayload({
repo_versions: repoVersions,
channel: elements.channel.value.trim() || "stable",
online: false,
@@ -1078,12 +1085,7 @@
public_catalog: elements.publicCatalog.checked,
include_migrations: elements.migrations.checked,
});
releaseState.currentRun = run;
restoreReleaseRunSelection(run);
renderReleaseRun(run);
const runs = await api("/api/release-runs");
renderReleaseRunList(runs.runs || []);
elements.releaseRun.value = run.run_id;
await submitReleaseRunCreate(payload, { automatic: false });
} catch (error) {
elements.runStatus.textContent = "error";
elements.runOutput.innerHTML = `<div class="action"><h3>${pill("error", "block")} Run creation failed</h3><p>${escapeHtml(error.message)}</p></div>`;
@@ -1092,6 +1094,74 @@
}
}
function pendingReleaseRunPayload(input) {
const normalized = {
...input,
repo_versions: Object.fromEntries(Object.entries(input.repo_versions || {}).sort(([left], [right]) => left.localeCompare(right))),
};
const signature = JSON.stringify(normalized);
const pending = readPendingReleaseRun();
if (pending?.signature === signature) return pending.payload;
const payload = { ...normalized, request_id: requestId("create-run") };
sessionStorage.setItem(pendingReleaseRunKey, JSON.stringify({ signature, payload }));
return payload;
}
function readPendingReleaseRun() {
try {
const pending = JSON.parse(sessionStorage.getItem(pendingReleaseRunKey) || "null");
if (!pending || typeof pending.signature !== "string" || typeof pending.payload?.request_id !== "string") return null;
return pending;
} catch (_error) {
sessionStorage.removeItem(pendingReleaseRunKey);
return null;
}
}
function clearPendingReleaseRun(requestIdToClear) {
const pending = readPendingReleaseRun();
if (pending?.payload?.request_id === requestIdToClear) {
sessionStorage.removeItem(pendingReleaseRunKey);
}
}
async function recoverPendingReleaseRun() {
const pending = readPendingReleaseRun();
if (!pending) return null;
if (!releaseState.runStoreAvailable) return false;
elements.runStatus.textContent = "recovering saved create...";
return submitReleaseRunCreate(pending.payload, { automatic: true });
}
async function submitReleaseRunCreate(payload, options = {}) {
try {
const run = await postJson("/api/release-runs", payload);
clearPendingReleaseRun(payload.request_id);
releaseState.currentRun = run;
restoreReleaseRunSelection(run);
renderReleaseRun(run);
await refreshReleaseRunListAfterCreate(run);
return true;
} catch (error) {
const deterministicClientError = error.status >= 400 && error.status < 500 && error.status !== 409;
const conflictingReuse = error.status === 409 && error.message.includes("already used with different inputs");
if (deterministicClientError || conflictingReuse) clearPendingReleaseRun(payload.request_id);
elements.runStatus.textContent = options.automatic ? "recovery pending" : "error";
elements.runOutput.innerHTML = `<div class="action"><h3>${pill(options.automatic ? "pending" : "error", options.automatic ? "warn" : "block")} ${options.automatic ? "Saved run recovery is still pending" : "Run creation failed"}</h3><p>${escapeHtml(error.message)}</p><p class="action-meta">The same private request identifier will be reused after an uncertain response.</p></div>`;
return false;
}
}
async function refreshReleaseRunListAfterCreate(run) {
try {
const runs = await api("/api/release-runs");
renderReleaseRunList(runs.runs || []);
elements.releaseRun.value = run.run_id;
} catch (error) {
elements.runOutput.insertAdjacentHTML("afterbegin", `<div class="action"><h3>${pill("saved", "warn")} Run saved; list refresh unavailable</h3><p>${escapeHtml(error.message)}</p><p class="action-meta">The persisted run remains selected and can be reloaded after refresh.</p></div>`);
}
}
async function loadReleaseRun(runId, options = {}) {
if (!runId) {
releaseState.currentRun = null;
@@ -1183,9 +1253,12 @@
async function resumeReleaseRun() {
const run = releaseState.currentRun;
if (!run) return;
const commandKey = `resume:${run.run_id}`;
const commandRequestId = inFlightRunCommandId(commandKey, "resume");
setBusy([elements.resumeReleaseRun], true);
try {
const resumed = await postJson(`/api/release-runs/${encodeURIComponent(run.run_id)}/resume`, { request_id: requestId("resume") });
const resumed = await postJson(`/api/release-runs/${encodeURIComponent(run.run_id)}/resume`, { request_id: commandRequestId });
clearInFlightRunCommand(commandKey, commandRequestId);
releaseState.currentRun = resumed;
renderReleaseRun(resumed);
} catch (error) {
@@ -1199,8 +1272,11 @@
async function retryReleaseRunStep(stepId) {
const run = releaseState.currentRun;
if (!run || !stepId) return;
const commandKey = `retry:${run.run_id}:${stepId}`;
const commandRequestId = inFlightRunCommandId(commandKey, "retry");
try {
const retried = await postJson(`/api/release-runs/${encodeURIComponent(run.run_id)}/steps/${encodeURIComponent(stepId)}/retry`, { request_id: requestId("retry") });
const retried = await postJson(`/api/release-runs/${encodeURIComponent(run.run_id)}/steps/${encodeURIComponent(stepId)}/retry`, { request_id: commandRequestId });
clearInFlightRunCommand(commandKey, commandRequestId);
releaseState.currentRun = retried;
renderReleaseRun(retried);
} catch (error) {
@@ -1211,13 +1287,16 @@
async function reconcileReleaseRunStep(stepId, outcome, confirmation, button) {
const run = releaseState.currentRun;
if (!run || !stepId || !outcome || confirmation !== "RECONCILE") return;
const commandKey = `reconcile:${run.run_id}:${stepId}:${outcome}`;
const commandRequestId = inFlightRunCommandId(commandKey, "reconcile");
setBusy([button], true);
try {
const reconciled = await postJson(`/api/release-runs/${encodeURIComponent(run.run_id)}/steps/${encodeURIComponent(stepId)}/reconcile`, {
request_id: requestId("reconcile"),
request_id: commandRequestId,
outcome,
confirm: confirmation,
});
clearInFlightRunCommand(commandKey, commandRequestId);
releaseState.currentRun = reconciled;
renderReleaseRun(reconciled);
} catch (error) {
@@ -1226,6 +1305,33 @@
}
}
function readInFlightRunCommands() {
try {
const commands = JSON.parse(sessionStorage.getItem(pendingRunCommandsKey) || "{}");
return commands && typeof commands === "object" && !Array.isArray(commands) ? commands : {};
} catch (_error) {
sessionStorage.removeItem(pendingRunCommandsKey);
return {};
}
}
function inFlightRunCommandId(commandKey, prefix) {
const commands = readInFlightRunCommands();
if (typeof commands[commandKey] === "string") return commands[commandKey];
const identifier = requestId(prefix);
commands[commandKey] = identifier;
sessionStorage.setItem(pendingRunCommandsKey, JSON.stringify(commands));
return identifier;
}
function clearInFlightRunCommand(commandKey, requestIdToClear) {
const commands = readInFlightRunCommands();
if (commands[commandKey] !== requestIdToClear) return;
delete commands[commandKey];
if (Object.keys(commands).length) sessionStorage.setItem(pendingRunCommandsKey, JSON.stringify(commands));
else sessionStorage.removeItem(pendingRunCommandsKey);
}
function requestId(prefix) {
const identifier = window.crypto?.randomUUID ? window.crypto.randomUUID() : `${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `${prefix}-${identifier}`;