feat(release): persist resumable run state
This commit is contained in:
1154
tools/release/govoplan_release/release_run.py
Normal file
1154
tools/release/govoplan_release/release_run.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
@@ -21,6 +22,12 @@ from govoplan_release import (
|
||||
tag_repositories,
|
||||
)
|
||||
from govoplan_release.model import to_jsonable
|
||||
from govoplan_release.release_run import (
|
||||
ReleaseRunConflict,
|
||||
ReleaseRunCorrupt,
|
||||
ReleaseRunNotFound,
|
||||
ReleaseRunStore,
|
||||
)
|
||||
from govoplan_release.workspace import DEFAULT_WORKSPACE_ROOT, META_ROOT
|
||||
|
||||
|
||||
@@ -90,10 +97,35 @@ class RepositoryTagRequest(BaseModel):
|
||||
confirm: str = ""
|
||||
|
||||
|
||||
def create_app(*, workspace_root: Path = DEFAULT_WORKSPACE_ROOT, token: str | None = None) -> FastAPI:
|
||||
class ReleaseRunCreateRequest(BaseModel):
|
||||
repo_versions: dict[str, str] = Field(default_factory=dict)
|
||||
channel: str = Field(default="stable", min_length=1, max_length=64)
|
||||
online: bool = False
|
||||
remote_tags: bool = False
|
||||
public_catalog: bool = True
|
||||
include_migrations: bool = False
|
||||
|
||||
|
||||
class ReleaseRunCommandRequest(BaseModel):
|
||||
request_id: str = Field(min_length=8, max_length=128)
|
||||
reconciled: bool = False
|
||||
|
||||
|
||||
def create_app(
|
||||
*,
|
||||
workspace_root: Path = DEFAULT_WORKSPACE_ROOT,
|
||||
token: str | None = None,
|
||||
run_state_root: Path | None = None,
|
||||
) -> FastAPI:
|
||||
app = FastAPI(title="GovOPlaN Release Console", version="0.1.0")
|
||||
app.state.workspace_root = workspace_root
|
||||
app.state.token = token
|
||||
app.state.release_runs = ReleaseRunStore(
|
||||
run_state_root
|
||||
if run_state_root is not None
|
||||
else default_release_run_root(workspace_root)
|
||||
)
|
||||
app.state.workspace_fingerprint = release_workspace_fingerprint(workspace_root)
|
||||
|
||||
@app.middleware("http")
|
||||
async def require_token(request: Request, call_next): # type: ignore[no-untyped-def]
|
||||
@@ -202,6 +234,105 @@ def create_app(*, workspace_root: Path = DEFAULT_WORKSPACE_ROOT, token: str | No
|
||||
)
|
||||
return to_jsonable(release_plan)
|
||||
|
||||
@app.get("/api/release-runs")
|
||||
def release_runs(limit: int = 20) -> dict[str, object]:
|
||||
return {"runs": app.state.release_runs.list(limit=limit)}
|
||||
|
||||
@app.post("/api/release-runs")
|
||||
def create_release_run(request: ReleaseRunCreateRequest) -> dict[str, object]:
|
||||
if not request.repo_versions:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="At least one repository version is required"
|
||||
)
|
||||
snapshot = build_dashboard(
|
||||
workspace_root=app.state.workspace_root,
|
||||
online=request.online,
|
||||
check_remote_tags=request.online or request.remote_tags,
|
||||
check_public_catalog=request.public_catalog,
|
||||
include_migrations=request.include_migrations,
|
||||
channel=request.channel,
|
||||
)
|
||||
plan = build_selective_release_plan(
|
||||
snapshot,
|
||||
selected_repos=tuple(request.repo_versions),
|
||||
repo_versions=dict(request.repo_versions),
|
||||
channel=request.channel,
|
||||
)
|
||||
plan_payload = to_jsonable(plan)
|
||||
planned_units = plan_payload.get("units")
|
||||
planned_versions = (
|
||||
{
|
||||
unit.get("repo"): unit.get("target_version")
|
||||
for unit in planned_units
|
||||
if isinstance(unit, dict)
|
||||
and isinstance(unit.get("repo"), str)
|
||||
and isinstance(unit.get("target_version"), str)
|
||||
}
|
||||
if isinstance(planned_units, list)
|
||||
else {}
|
||||
)
|
||||
if planned_versions != dict(request.repo_versions) or len(
|
||||
planned_versions
|
||||
) != len(planned_units or []):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=(
|
||||
"The release plan did not resolve exactly the requested repository "
|
||||
"versions; refresh the dashboard and select known release units."
|
||||
),
|
||||
)
|
||||
try:
|
||||
return app.state.release_runs.create(
|
||||
input_snapshot={
|
||||
"channel": request.channel,
|
||||
"repo_versions": dict(request.repo_versions),
|
||||
"online": request.online,
|
||||
"remote_tags": request.remote_tags,
|
||||
"public_catalog": request.public_catalog,
|
||||
"include_migrations": request.include_migrations,
|
||||
"workspace_fingerprint": app.state.workspace_fingerprint,
|
||||
},
|
||||
plan_snapshot=plan_payload,
|
||||
)
|
||||
except (ReleaseRunConflict, ReleaseRunCorrupt) as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
|
||||
@app.get("/api/release-runs/{run_id}")
|
||||
def release_run(run_id: str) -> dict[str, object]:
|
||||
try:
|
||||
return app.state.release_runs.get(run_id)
|
||||
except ReleaseRunNotFound as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except ReleaseRunCorrupt as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
|
||||
@app.post("/api/release-runs/{run_id}/resume")
|
||||
def resume_release_run(
|
||||
run_id: str, request: ReleaseRunCommandRequest
|
||||
) -> dict[str, object]:
|
||||
try:
|
||||
return app.state.release_runs.resume(run_id, request_id=request.request_id)
|
||||
except ReleaseRunNotFound as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except (ReleaseRunConflict, ReleaseRunCorrupt) as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
|
||||
@app.post("/api/release-runs/{run_id}/steps/{step_id}/retry")
|
||||
def retry_release_run_step(
|
||||
run_id: str, step_id: str, request: ReleaseRunCommandRequest
|
||||
) -> dict[str, object]:
|
||||
try:
|
||||
return app.state.release_runs.retry_step(
|
||||
run_id,
|
||||
step_id,
|
||||
request_id=request.request_id,
|
||||
reconciled=request.reconciled,
|
||||
)
|
||||
except ReleaseRunNotFound as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
except (ReleaseRunConflict, ReleaseRunCorrupt) as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
|
||||
@app.get("/api/catalog-candidates")
|
||||
def catalog_candidates() -> dict[str, object]:
|
||||
return {"candidates": list_catalog_candidates()}
|
||||
@@ -319,6 +450,22 @@ def create_app(*, workspace_root: Path = DEFAULT_WORKSPACE_ROOT, token: str | No
|
||||
return app
|
||||
|
||||
|
||||
def default_release_run_root(workspace_root: Path) -> Path:
|
||||
workspace = workspace_root.expanduser().resolve()
|
||||
if (workspace / "repositories.json").is_file():
|
||||
return workspace / "runtime" / "release-runs"
|
||||
workspace_meta = workspace / "govoplan"
|
||||
if (workspace_meta / "repositories.json").is_file():
|
||||
return workspace_meta / "runtime" / "release-runs"
|
||||
fingerprint = release_workspace_fingerprint(workspace)
|
||||
return META_ROOT / "runtime" / "release-runs" / f"workspace-{fingerprint[:16]}"
|
||||
|
||||
|
||||
def release_workspace_fingerprint(workspace_root: Path) -> str:
|
||||
canonical = str(workspace_root.expanduser().resolve()).encode("utf-8")
|
||||
return hashlib.sha256(canonical).hexdigest()
|
||||
|
||||
|
||||
def parse_csv(value: str | None) -> tuple[str, ...]:
|
||||
if not value:
|
||||
return ()
|
||||
|
||||
@@ -96,7 +96,8 @@
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"] {
|
||||
input[type="password"],
|
||||
select {
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
border: 1px solid var(--line);
|
||||
@@ -139,7 +140,7 @@
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: wait;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
@@ -635,6 +636,31 @@
|
||||
</div>
|
||||
|
||||
<div class="side">
|
||||
<section>
|
||||
<div class="section-head">
|
||||
<h2>Durable Run State</h2>
|
||||
<small id="runStatus">no run selected</small>
|
||||
</div>
|
||||
<div class="details">
|
||||
<p class="hint"><strong>State tracking only.</strong> A run freezes the selected inputs and plan, guides recovery, and records bounded state events. It does not execute or confirm tags, signing, publication, or catalog changes.</p>
|
||||
<div class="form-grid">
|
||||
<div>
|
||||
<label for="releaseRun">Saved run</label>
|
||||
<select id="releaseRun">
|
||||
<option value="">No saved run selected</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button id="createReleaseRun">Create Run from Selection</button>
|
||||
<button class="secondary" id="resumeReleaseRun" disabled>Resume Run</button>
|
||||
</div>
|
||||
<div class="actions" id="runOutput">
|
||||
<div class="muted">Create a run after selecting exact repository versions, or choose a saved run.</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="section-head">
|
||||
<h2>Release Workflow</h2>
|
||||
@@ -857,6 +883,11 @@
|
||||
previewPrepare: document.getElementById("previewPrepare"),
|
||||
commitPrepare: document.getElementById("commitPrepare"),
|
||||
buildSelectedPlan: document.getElementById("buildSelectedPlan"),
|
||||
releaseRun: document.getElementById("releaseRun"),
|
||||
runStatus: document.getElementById("runStatus"),
|
||||
runOutput: document.getElementById("runOutput"),
|
||||
createReleaseRun: document.getElementById("createReleaseRun"),
|
||||
resumeReleaseRun: document.getElementById("resumeReleaseRun"),
|
||||
selectUnpushedUnits: document.getElementById("selectUnpushedUnits"),
|
||||
selectChangedUnits: document.getElementById("selectChangedUnits"),
|
||||
selectUnreleasedUnits: document.getElementById("selectUnreleasedUnits"),
|
||||
@@ -874,6 +905,8 @@
|
||||
rows: {},
|
||||
dashboard: null,
|
||||
manualRepo: "",
|
||||
currentRun: null,
|
||||
runs: [],
|
||||
};
|
||||
|
||||
elements.refresh.addEventListener("click", load);
|
||||
@@ -889,6 +922,9 @@
|
||||
elements.previewPrepare.addEventListener("click", () => prepareSelectedRepos({ apply: false }));
|
||||
elements.commitPrepare.addEventListener("click", () => prepareSelectedRepos({ apply: true }));
|
||||
elements.buildSelectedPlan.addEventListener("click", buildSelectedPlan);
|
||||
elements.createReleaseRun.addEventListener("click", createReleaseRun);
|
||||
elements.resumeReleaseRun.addEventListener("click", resumeReleaseRun);
|
||||
elements.releaseRun.addEventListener("change", () => loadReleaseRun(elements.releaseRun.value));
|
||||
elements.previewReleaseTags.addEventListener("click", () => releaseSelectedTags({ apply: false, push: true }));
|
||||
elements.createReleaseTags.addEventListener("click", () => releaseSelectedTags({ apply: true, push: false }));
|
||||
elements.publishReleaseTags.addEventListener("click", () => releaseSelectedTags({ apply: true, push: true }));
|
||||
@@ -949,10 +985,15 @@
|
||||
async function load() {
|
||||
elements.statusLine.textContent = "Refreshing...";
|
||||
try {
|
||||
const [dashboard, intelligence] = await Promise.all([api("/api/dashboard"), api("/api/release-intelligence")]);
|
||||
const [dashboard, intelligence, runs] = await Promise.all([api("/api/dashboard"), api("/api/release-intelligence"), api("/api/release-runs")]);
|
||||
renderDashboard(dashboard);
|
||||
renderReleaseIntelligence(intelligence);
|
||||
renderIdlePlan();
|
||||
renderReleaseRunList(runs.runs || []);
|
||||
if (releaseState.currentRun?.run_id) {
|
||||
await loadReleaseRun(releaseState.currentRun.run_id, { restoreSelection: false });
|
||||
} else {
|
||||
renderIdlePlan();
|
||||
}
|
||||
elements.statusLine.textContent = `Generated ${dashboard.generated_at}`;
|
||||
} catch (error) {
|
||||
elements.statusLine.innerHTML = `<span class="error">${escapeHtml(error.message)}</span>`;
|
||||
@@ -977,6 +1018,156 @@
|
||||
renderCatalog(data.catalog, data.target_version);
|
||||
}
|
||||
|
||||
function renderReleaseRunList(runs) {
|
||||
releaseState.runs = Array.isArray(runs) ? runs : [];
|
||||
const selectedId = releaseState.currentRun?.run_id || "";
|
||||
const options = releaseState.runs.map((run) => {
|
||||
const repositories = run.repo_versions ? Object.keys(run.repo_versions).length : 0;
|
||||
const label = `${run.run_id} · ${run.status || "unknown"} · ${repositories} repos`;
|
||||
return `<option value="${escapeAttr(run.run_id)}" ${run.run_id === selectedId ? "selected" : ""}>${escapeHtml(label)}</option>`;
|
||||
}).join("");
|
||||
elements.releaseRun.innerHTML = `<option value="">No saved run selected</option>${options}`;
|
||||
if (selectedId && releaseState.runs.some((run) => run.run_id === selectedId)) {
|
||||
elements.releaseRun.value = selectedId;
|
||||
}
|
||||
}
|
||||
|
||||
async function createReleaseRun() {
|
||||
const repoVersions = selectedRepoVersions();
|
||||
if (!Object.keys(repoVersions).length) {
|
||||
elements.runStatus.textContent = "selection required";
|
||||
elements.runOutput.innerHTML = `<div class="action"><h3>${pill("unavailable", "warn")} Select release units</h3><p>Select one or more repositories and exact target versions before creating a durable run.</p></div>`;
|
||||
return;
|
||||
}
|
||||
setBusy([elements.createReleaseRun], true);
|
||||
elements.runStatus.textContent = "freezing plan...";
|
||||
try {
|
||||
const run = await postJson("/api/release-runs", {
|
||||
repo_versions: repoVersions,
|
||||
channel: elements.channel.value.trim() || "stable",
|
||||
online: false,
|
||||
remote_tags: elements.online.checked,
|
||||
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;
|
||||
} 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>`;
|
||||
} finally {
|
||||
setBusy([elements.createReleaseRun], false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadReleaseRun(runId, options = {}) {
|
||||
if (!runId) {
|
||||
releaseState.currentRun = null;
|
||||
elements.runStatus.textContent = "no run selected";
|
||||
elements.resumeReleaseRun.disabled = true;
|
||||
elements.runOutput.innerHTML = `<div class="muted">Create a run after selecting exact repository versions, or choose a saved run.</div>`;
|
||||
return;
|
||||
}
|
||||
elements.runStatus.textContent = "loading...";
|
||||
try {
|
||||
const run = await api(`/api/release-runs/${encodeURIComponent(runId)}`);
|
||||
releaseState.currentRun = run;
|
||||
if (options.restoreSelection !== false) restoreReleaseRunSelection(run);
|
||||
renderReleaseRun(run);
|
||||
elements.releaseRun.value = run.run_id;
|
||||
} catch (error) {
|
||||
releaseState.currentRun = null;
|
||||
elements.runStatus.textContent = "unavailable";
|
||||
elements.resumeReleaseRun.disabled = true;
|
||||
elements.runOutput.innerHTML = `<div class="action"><h3>${pill("unavailable", "block")} Run cannot be read</h3><p>${escapeHtml(error.message)}</p></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function restoreReleaseRunSelection(run) {
|
||||
const input = run.immutable?.input || {};
|
||||
const repoVersions = input.repo_versions || {};
|
||||
elements.channel.value = input.channel || "stable";
|
||||
elements.publicCatalog.checked = input.public_catalog === true;
|
||||
elements.online.checked = input.remote_tags === true || input.online === true;
|
||||
elements.migrations.checked = input.include_migrations === true;
|
||||
for (const repo of Object.keys(releaseState.rows)) {
|
||||
releaseState.rows[repo] = {
|
||||
...releaseState.rows[repo],
|
||||
selected: Object.prototype.hasOwnProperty.call(repoVersions, repo),
|
||||
...(Object.prototype.hasOwnProperty.call(repoVersions, repo) ? { target: repoVersions[repo] } : {}),
|
||||
};
|
||||
}
|
||||
if (releaseState.dashboard) renderReleaseUnits(releaseState.dashboard);
|
||||
if (run.immutable?.plan) renderPlan(run.immutable.plan);
|
||||
}
|
||||
|
||||
function renderReleaseRun(run) {
|
||||
const state = run.state || {};
|
||||
const recommendation = run.recommended_next || {};
|
||||
const steps = Array.isArray(state.steps) ? state.steps : [];
|
||||
const events = Array.isArray(state.events) ? state.events.slice(-5).reverse() : [];
|
||||
const recommendationKind = state.status === "blocked" ? "block" : recommendation.available ? "ok" : "warn";
|
||||
const recommendationHtml = recommendation.id ? `<div class="action recommended">
|
||||
<h3>${pill("recommended next", recommendationKind)} ${escapeHtml(recommendation.title || recommendation.id)}</h3>
|
||||
<p>${escapeHtml(recommendation.detail || "")}</p>
|
||||
<p class="action-meta"><strong>How to continue:</strong> ${escapeHtml(recommendation.remediation || "")}</p>
|
||||
</div>` : "";
|
||||
const stepHtml = steps.map((step) => {
|
||||
const kind = step.state === "succeeded" ? "ok" : ["failed", "interrupted"].includes(step.state) ? "block" : step.available ? "ok" : "warn";
|
||||
const retryReason = step.retry_available ? "Prepare this known failed or read-only interrupted step for an explicit retry." : (step.disabled_reason || "Retry is unavailable for this state.");
|
||||
return `<div class="action">
|
||||
<div class="stage-title"><h3>${escapeHtml(`${step.order}. ${step.title || step.id}`)}</h3>${pill(step.state, kind)}</div>
|
||||
<p>${escapeHtml(step.detail || "")}</p>
|
||||
${step.result_code ? `<p class="action-meta">Result code: ${escapeHtml(step.result_code)}</p>` : ""}
|
||||
<div class="button-row"><button class="secondary" data-run-retry-step="${escapeAttr(step.id)}" ${step.retry_available ? "" : "disabled"} title="${escapeAttr(retryReason)}">Prepare Retry</button></div>
|
||||
</div>`;
|
||||
}).join("");
|
||||
const eventHtml = events.length ? `<div class="action"><h3>Recent bounded state events</h3>${events.map((event) => `<p class="action-meta">${escapeHtml(`${event.at} · ${event.type}${event.step_id ? ` · ${event.step_id}` : ""}${event.result_code ? ` · ${event.result_code}` : ""}`)}</p>`).join("")}</div>` : "";
|
||||
elements.runStatus.textContent = state.status || "unknown";
|
||||
elements.resumeReleaseRun.disabled = state.status === "completed";
|
||||
elements.runOutput.innerHTML = `<div class="action"><h3>${pill(state.status || "unknown", state.status === "blocked" ? "block" : state.status === "completed" ? "ok" : "warn")} ${escapeHtml(run.run_id)}</h3><p>Frozen plan ${escapeHtml((run.immutable?.digest || "").slice(0, 16))}… · updated ${escapeHtml(run.updated_at || "-")}</p><p class="action-meta">Executor controls remain separate and keep their existing confirmations.</p></div>${recommendationHtml}${stepHtml}${eventHtml}`;
|
||||
for (const button of elements.runOutput.querySelectorAll("[data-run-retry-step]")) {
|
||||
button.addEventListener("click", () => retryReleaseRunStep(button.dataset.runRetryStep));
|
||||
}
|
||||
}
|
||||
|
||||
async function resumeReleaseRun() {
|
||||
const run = releaseState.currentRun;
|
||||
if (!run) return;
|
||||
setBusy([elements.resumeReleaseRun], true);
|
||||
try {
|
||||
const resumed = await postJson(`/api/release-runs/${encodeURIComponent(run.run_id)}/resume`, { request_id: requestId("resume") });
|
||||
releaseState.currentRun = resumed;
|
||||
renderReleaseRun(resumed);
|
||||
} catch (error) {
|
||||
elements.runStatus.textContent = "error";
|
||||
elements.runOutput.insertAdjacentHTML("afterbegin", `<div class="action"><h3>${pill("error", "block")} Resume failed</h3><p>${escapeHtml(error.message)}</p></div>`);
|
||||
} finally {
|
||||
elements.resumeReleaseRun.disabled = releaseState.currentRun?.state?.status === "completed";
|
||||
}
|
||||
}
|
||||
|
||||
async function retryReleaseRunStep(stepId) {
|
||||
const run = releaseState.currentRun;
|
||||
if (!run || !stepId) return;
|
||||
try {
|
||||
const retried = await postJson(`/api/release-runs/${encodeURIComponent(run.run_id)}/steps/${encodeURIComponent(stepId)}/retry`, { request_id: requestId("retry"), reconciled: false });
|
||||
releaseState.currentRun = retried;
|
||||
renderReleaseRun(retried);
|
||||
} catch (error) {
|
||||
elements.runOutput.insertAdjacentHTML("afterbegin", `<div class="action"><h3>${pill("error", "block")} Retry preparation failed</h3><p>${escapeHtml(error.message)}</p></div>`);
|
||||
}
|
||||
}
|
||||
|
||||
function requestId(prefix) {
|
||||
const identifier = window.crypto?.randomUUID ? window.crypto.randomUUID() : `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
return `${prefix}-${identifier}`;
|
||||
}
|
||||
|
||||
function renderWorkflowStages(data) {
|
||||
const s = data.summary || {};
|
||||
const selected = elements.releaseUnits ? elements.releaseUnits.querySelectorAll("[data-release-check]:checked").length : 0;
|
||||
|
||||
Reference in New Issue
Block a user