fix(release): make durable runs recovery safe
This commit is contained in:
@@ -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}`;
|
||||
|
||||
Reference in New Issue
Block a user