Add scalable deployment planning and guided releases
Security Audit / security-audit (push) Failing after 4s
Dependency Audit / dependency-audit (push) Failing after 7s
Deployment Installer / deployment-installer (push) Failing after 4s

This commit is contained in:
2026-07-31 02:49:03 +02:00
parent 3864ce28b1
commit 908090dd0f
13 changed files with 1511 additions and 228 deletions
+475 -96
View File
@@ -60,6 +60,7 @@
.toolbar,
.summary,
.release-guide,
.layout {
width: min(1600px, 100%);
margin: 0 auto 18px;
@@ -401,8 +402,146 @@
.workflow-stages {
display: grid;
gap: 10px;
grid-template-columns: repeat(7, minmax(112px, 1fr));
gap: 0;
padding: 18px 14px 14px;
overflow-x: auto;
}
.workflow-stage {
position: relative;
min-width: 112px;
min-height: 86px;
border: 0;
border-radius: 0;
background: transparent;
color: var(--muted);
padding: 0 8px;
cursor: pointer;
}
.workflow-stage::after {
content: "";
position: absolute;
z-index: 0;
top: 17px;
left: calc(50% + 21px);
width: calc(100% - 42px);
height: 2px;
background: var(--line);
}
.workflow-stage:last-child::after {
display: none;
}
.workflow-stage.is-complete::after {
background: var(--ok);
}
.workflow-stage:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.workflow-stage-marker {
position: relative;
z-index: 1;
display: grid;
place-items: center;
width: 36px;
height: 36px;
margin: 0 auto 8px;
border: 2px solid var(--line);
border-radius: 50%;
background: var(--panel);
color: var(--muted);
font-size: 12px;
font-weight: 800;
}
.workflow-stage.is-complete .workflow-stage-marker {
border-color: var(--ok);
background: var(--ok);
color: #fff;
}
.workflow-stage.is-current .workflow-stage-marker {
border-color: var(--accent);
color: var(--accent);
box-shadow: 0 0 0 3px rgba(18, 97, 166, 0.12);
}
.workflow-stage.is-blocked .workflow-stage-marker {
border-color: var(--danger);
color: var(--danger);
}
.workflow-stage.is-unavailable {
cursor: not-allowed;
opacity: 0.72;
}
.workflow-stage-label,
.workflow-stage-status {
display: block;
text-align: center;
}
.workflow-stage-label {
color: var(--text);
font-size: 12px;
font-weight: 800;
}
.workflow-stage-status {
margin-top: 3px;
color: var(--muted);
font-size: 11px;
font-weight: 600;
}
.workflow-guidance {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 18px;
align-items: center;
padding: 14px;
border-top: 1px solid var(--line);
background: var(--panel-alt);
}
.workflow-guidance h3 {
margin: 0 0 5px;
font-size: 14px;
}
.workflow-guidance p {
margin: 0;
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.workflow-guidance .action-meta {
margin-top: 5px;
}
.workflow-guidance button {
min-width: 160px;
}
.release-run-toolbar {
display: grid;
grid-template-columns: minmax(240px, 1fr) auto;
gap: 12px;
align-items: end;
padding: 14px;
border-bottom: 1px solid var(--line);
}
.release-run-toolbar .button-row {
margin: 0;
}
.action {
@@ -520,6 +659,15 @@
.insight-grid {
grid-template-columns: 1fr;
}
.workflow-stages {
grid-template-columns: repeat(7, minmax(128px, 1fr));
}
.workflow-guidance,
.release-run-toolbar {
grid-template-columns: 1fr;
}
}
</style>
</head>
@@ -547,9 +695,24 @@
<div class="summary" id="summary"></div>
<section class="release-guide" id="releaseGuide">
<div class="section-head">
<h2>Release Workflow</h2>
<small id="workflowStageStatus">Loading...</small>
</div>
<div class="workflow-stages" id="workflowStages"></div>
<div class="workflow-guidance" id="workflowGuidance">
<div>
<h3>Inspect the release workspace</h3>
<p>The console is collecting repository, contract, migration, and publication state.</p>
</div>
<button id="workflowPrimaryAction" disabled>Loading...</button>
</div>
</section>
<div class="layout">
<div class="main-stack">
<section>
<section id="releaseControlSection">
<div class="section-head">
<h2>Release Control</h2>
<small id="releaseControlStatus"></small>
@@ -558,7 +721,7 @@
<div class="issue-list" id="releaseIssues"></div>
</section>
<section>
<section id="publishedModulesSection">
<div class="section-head">
<h2>Published Modules</h2>
<small id="publishedModuleCount"></small>
@@ -581,7 +744,7 @@
</div>
</section>
<section>
<section id="compatibilitySection">
<div class="section-head">
<h2>Compatibility</h2>
<small id="compatibilityStatus"></small>
@@ -602,7 +765,7 @@
</div>
</section>
<section>
<section id="releaseTargetsSection">
<div class="section-head">
<h2>Repositories</h2>
<small id="unitCount"></small>
@@ -634,46 +797,33 @@
</table>
</div>
</section>
</div>
<div class="side">
<section>
<section id="releaseRunSection">
<div class="section-head">
<h2>Durable Run State</h2>
<h2>Durable Release Run</h2>
<small id="runStatus">no run selected</small>
</div>
<div class="details">
<p class="hint"><strong>Durable execution.</strong> A run freezes the selected inputs and plan, claims each supported executor before its effect, and keeps explicit confirmation and reconciliation boundaries. Unsupported dependency-ordering or version-metadata steps stay visible and disabled.</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 class="button-row">
<button class="secondary" id="loadOlderReleaseRuns" disabled>Load older</button>
</div>
<div class="release-run-toolbar">
<div>
<label for="releaseRun">Saved run</label>
<select id="releaseRun">
<option value="">No saved run selected</option>
</select>
</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>
<button class="secondary" id="loadOlderReleaseRuns" disabled>Load older</button>
</div>
</div>
</section>
<section>
<div class="section-head">
<h2>Release Workflow</h2>
<small id="workflowStageStatus"></small>
<div class="actions" id="runOutput">
<div class="muted"><strong>Durable execution.</strong> Durable Run State preserves the frozen plan and mutation receipts across refreshes. Select exact repository versions, validate the plan, then create or resume a run.</div>
</div>
<div class="workflow-stages" id="workflowStages"></div>
</section>
</div>
<section>
<div class="side">
<section id="gitSyncSection">
<div class="section-head">
<h2>1. Git Sync</h2>
<small id="pushStatus"></small>
@@ -700,7 +850,7 @@
</div>
</section>
<section>
<section id="prepareSection">
<div class="section-head">
<h2>2. Prepare Changes</h2>
<small id="prepareStatus"></small>
@@ -725,7 +875,7 @@
</div>
</section>
<section>
<section id="sourceReleaseSection">
<div class="section-head">
<h2>3. Source Release Tag</h2>
<small><span id="tagStatus">idle</span> · plan <span id="planStatus">idle</span></small>
@@ -752,7 +902,7 @@
<div class="actions" id="actions"></div>
</section>
<section>
<section id="catalogPublishSection">
<div class="section-head">
<h2>4. Signed Website Catalog</h2>
<small id="workflowStatus"></small>
@@ -782,7 +932,7 @@
</div>
</section>
<section>
<section id="catalogStateSection">
<div class="section-head">
<h2>Catalog State</h2>
<small id="catalogStatus"></small>
@@ -790,13 +940,13 @@
<div class="details" id="catalog"></div>
</section>
<section>
<section id="installVerificationSection">
<div class="section-head">
<h2>5. Install Catalog</h2>
<small>planned</small>
<h2>5. Installation Verification</h2>
<small>external gate</small>
</div>
<div class="details">
<p class="hint">Future work: signed module archives and an installer/update catalog for deployments.</p>
<p class="hint">The signed catalog and Python artifacts are produced by the durable candidate step. End-to-end installation verification still runs in release integration CI and is not yet a bounded local-console executor.</p>
</div>
</section>
</div>
@@ -851,6 +1001,8 @@
unitCount: document.getElementById("unitCount"),
workflowStageStatus: document.getElementById("workflowStageStatus"),
workflowStages: document.getElementById("workflowStages"),
workflowGuidance: document.getElementById("workflowGuidance"),
workflowPrimaryAction: document.getElementById("workflowPrimaryAction"),
catalog: document.getElementById("catalog"),
catalogStatus: document.getElementById("catalogStatus"),
actions: document.getElementById("actions"),
@@ -909,8 +1061,10 @@
const releaseState = {
rows: {},
dashboard: null,
currentPlan: null,
manualRepo: "",
currentRun: null,
workflowAction: null,
runs: [],
runNextCursor: null,
runStoreAvailable: true,
@@ -945,9 +1099,16 @@
elements.cancelManualTarget.addEventListener("click", closeManualTarget);
elements.applyManualTarget.addEventListener("click", applyManualTarget);
elements.manualTargetInput.addEventListener("input", validateManualTarget);
elements.workflowPrimaryAction.addEventListener("click", runWorkflowPrimaryAction);
elements.candidateDir.addEventListener("input", () => {
if (releaseState.dashboard) renderWorkflowStages(releaseState.dashboard);
});
for (const control of [elements.channel, elements.publicCatalog, elements.online, elements.migrations]) {
control.addEventListener("change", () => {
invalidateReleaseDraft();
if (releaseState.dashboard) renderWorkflowStages(releaseState.dashboard);
});
}
function api(path) {
const separator = path.indexOf("?");
@@ -1068,6 +1229,7 @@
if (selectedId && releaseState.runs.some((run) => run.run_id === selectedId)) {
elements.releaseRun.value = selectedId;
}
if (releaseState.dashboard) renderWorkflowStages(releaseState.dashboard);
}
function renderReleaseRunStoreUnavailable(error) {
@@ -1082,6 +1244,7 @@
elements.resumeReleaseRun.disabled = true;
elements.runStatus.textContent = "unavailable";
elements.runOutput.innerHTML = `<div class="action"><h3>${pill("unavailable", "block")} Durable run storage cannot be read</h3><p>${escapeHtml(error.message)}</p><p class="action-meta">Dashboard and release previews remain available. Correct the private state-directory problem, then refresh.</p></div>`;
if (releaseState.dashboard) renderWorkflowStages(releaseState.dashboard);
}
async function createReleaseRun() {
@@ -1204,6 +1367,7 @@
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>`;
renderIdlePlan();
return;
}
elements.runStatus.textContent = "loading...";
@@ -1218,6 +1382,7 @@
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>`;
if (releaseState.dashboard) renderWorkflowStages(releaseState.dashboard);
}
}
@@ -1265,7 +1430,7 @@
<div><label>Confirmation</label><input type="text" data-run-reconcile-confirm placeholder="Type RECONCILE" autocomplete="off" /></div>
<div class="button-row"><button class="secondary" data-run-reconcile-submit disabled title="Verify local and remote state independently, choose the observed outcome, and type RECONCILE.">Record Reconciliation</button></div>
</div>` : "";
return `<div class="action">
return `<div class="action" data-run-step-id="${escapeAttr(step.id)}">
<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>` : ""}
@@ -1305,6 +1470,7 @@
submit.addEventListener("click", () => executeReleaseRunStep(controls.dataset.runExecuteStep, required, submit));
if (preview) preview.addEventListener("click", () => previewReleaseRunStep(controls.dataset.runExecuteStep, preview));
}
if (releaseState.dashboard) renderWorkflowStages(releaseState.dashboard);
}
async function executeReleaseRunStep(stepId, confirmation, button) {
@@ -1572,62 +1738,245 @@
function renderWorkflowStages(data) {
const s = data.summary || {};
const selected = elements.releaseUnits ? elements.releaseUnits.querySelectorAll("[data-release-check]:checked").length : 0;
const gitBlockers = (s.missing_count || 0) + (s.safe_directory_count || 0);
const branchDrift = (s.ahead_count || 0) + (s.behind_count || 0);
const releaseBlockers = gitBlockers + (s.behind_count || 0) + (s.no_head_count || 0) + (s.dirty_count || 0) + (s.error_count || 0);
const catalog = data.catalog || {};
const hasCandidate = Boolean(elements.candidateDir?.value.trim());
const catalogPublished = catalog.catalog_matches_public === true && catalog.keyring_matches_public === true;
const gitStatus = gitBlockers ? "blocked" : branchDrift ? "sync" : "current";
const tagStatus = releaseBlockers ? "blocked" : selected ? "plan ready" : "select repos";
const signedStatus = hasCandidate ? "candidate" : "waiting";
const catalogStatus = catalogPublished ? "published" : catalog.public_checked ? "review" : "local";
const prepareStatus = s.dirty_count ? "dirty" : "clean";
elements.workflowStageStatus.textContent = data.summary?.status || "unknown";
elements.workflowStages.innerHTML = [
workflowStage(
"1. Sync local/remote Git",
gitStatus,
gitBlockers ? "block" : branchDrift ? "warn" : "ok",
`${s.ahead_count || 0} ahead / ${s.behind_count || 0} behind / ${s.dirty_count || 0} dirty repositories`
),
workflowStage(
"2. Prepare changes",
prepareStatus,
s.dirty_count ? "warn" : "ok",
s.dirty_count
? `${s.dirty_count} dirty repositories need explicit prepare commits.`
: "No dirty worktrees in the current dashboard."
),
workflowStage(
"3. Publish source release tags",
tagStatus,
releaseBlockers ? "block" : selected ? "ok" : "warn",
selected
? `${selected} selected; preview here, then create a durable run and execute its enabled tag/push steps.`
: "Select repositories, set target versions, then preview the source release tags."
),
workflowStage(
"4. Sign and publish website catalog",
signedStatus,
hasCandidate ? "ok" : "warn",
hasCandidate ? `Preview candidate: ${elements.candidateDir.value.trim()}` : "After every selected source tag is remotely published, use a durable run to generate and publish its receipt-bound candidate."
),
workflowStage(
"5. Maintain install catalog",
catalogStatus,
catalogPublished ? "ok" : "warn",
"Future: signed module archives for installer and module update workflows."
),
].join("");
const plan = releaseState.currentRun?.immutable?.plan || releaseState.currentPlan;
const run = releaseState.currentRun;
const phases = releaseWorkflowPhases({ summary: s, selected, plan, run });
const completed = phases.filter((phase) => phase.state === "complete").length;
const blocked = phases.find((phase) => phase.state === "blocked" || phase.state === "unavailable");
const active = phases.find((phase) => phase.state === "current") || blocked;
elements.workflowStageStatus.textContent = `${completed}/${phases.length} complete${run ? ` · ${run.state?.status || "unknown"}` : ""}`;
elements.workflowStages.innerHTML = phases.map(workflowStage).join("");
for (const stage of elements.workflowStages.querySelectorAll("[data-workflow-target]")) {
stage.addEventListener("click", () => scrollToReleaseTarget(stage.dataset.workflowTarget));
}
renderWorkflowGuidance({ summary: s, selected, plan, run, active });
}
function workflowStage(title, status, kind, detail) {
return `<div class="action">
<div class="stage-title"><h3>${escapeHtml(title)}</h3>${pill(status, kind)}</div>
<p>${escapeHtml(detail)}</p>
</div>`;
function releaseWorkflowPhases({ summary, selected, plan, run }) {
const inspectionNotices = (summary.missing_count || 0)
+ (summary.safe_directory_count || 0)
+ (summary.error_count || 0);
const inspectionBlockers = (summary.repository_count || 0) === 0 ? 1 : 0;
const phases = [
{
id: "inspect",
label: "Inspect",
target: "releaseControlSection",
state: inspectionBlockers ? "blocked" : "complete",
status: inspectionBlockers
? "no repositories"
: inspectionNotices
? `${inspectionNotices} notices`
: "ready",
},
{
id: "select",
label: "Targets",
target: "releaseTargetsSection",
state: selected || run ? "complete" : inspectionBlockers ? "locked" : "current",
status: selected || run ? `${selected || Object.keys(run?.immutable?.input?.repo_versions || {}).length} selected` : "select",
},
validationWorkflowPhase({ selected, plan, run, inspectionBlockers }),
runWorkflowPhase({
id: "source",
label: "Source",
target: "releaseRunSection",
run,
plan,
match: (step) => /:(preflight|tag|push)$/.test(step.id || ""),
waitingStatus: "create run",
}),
runWorkflowPhase({
id: "package",
label: "Package",
target: "releaseRunSection",
run,
plan,
match: (step) => ["catalog:initial-version-metadata", "catalog:selective-generator"].includes(step.id),
waitingStatus: "waiting",
}),
runWorkflowPhase({
id: "publish",
label: "Publish",
target: "releaseRunSection",
run,
plan,
match: (step) => step.id === "catalog:validate-sign-publish",
waitingStatus: "waiting",
}),
{
id: "verify",
label: "Verify",
target: "installVerificationSection",
state: run?.state?.status === "completed" ? "unavailable" : "locked",
status: run?.state?.status === "completed" ? "CI gate" : "waiting",
},
];
const firstOpen = phases.findIndex((phase) => ["current", "blocked", "unavailable"].includes(phase.state));
if (firstOpen >= 0) {
for (let index = firstOpen + 1; index < phases.length; index += 1) {
if (phases[index].state !== "complete") phases[index] = { ...phases[index], state: "locked" };
}
}
return phases;
}
function validationWorkflowPhase({ selected, plan, run, inspectionBlockers }) {
if (inspectionBlockers) return { id: "validate", label: "Validate", target: "sourceReleaseSection", state: "locked", status: "waiting" };
if (!selected && !run) return { id: "validate", label: "Validate", target: "sourceReleaseSection", state: "locked", status: "waiting" };
if (!plan) return { id: "validate", label: "Validate", target: "sourceReleaseSection", state: "current", status: "build plan" };
if (plan.status === "blocked") return { id: "validate", label: "Validate", target: "sourceReleaseSection", state: "blocked", status: "blocked" };
return { id: "validate", label: "Validate", target: "sourceReleaseSection", state: "complete", status: "gates pass" };
}
function runWorkflowPhase({ id, label, target, run, plan, match, waitingStatus }) {
if (!plan || plan.status === "blocked") return { id, label, target, state: "locked", status: waitingStatus };
if (!run) return {
id,
label,
target,
state: id === "source" ? "current" : "locked",
status: id === "source" ? "create run" : waitingStatus,
};
const steps = (run.state?.steps || []).filter(match);
if (!steps.length) return { id, label, target, state: "unavailable", status: "not available" };
const succeeded = steps.filter((step) => step.state === "succeeded").length;
if (succeeded === steps.length) return { id, label, target, state: "complete", status: `${succeeded}/${steps.length}` };
if (steps.some((step) => ["failed", "interrupted"].includes(step.state))) {
return { id, label, target, state: "blocked", status: `${succeeded}/${steps.length}` };
}
if (steps.some((step) => step.state === "running" || step.available)) {
return { id, label, target, state: "current", status: `${succeeded}/${steps.length}` };
}
const next = steps.find((step) => step.state === "pending");
if (next && next.executor?.available === false && next.status !== "planned") {
return { id, label, target, state: "unavailable", status: next.status || "unavailable" };
}
return { id, label, target, state: "locked", status: `${succeeded}/${steps.length}` };
}
function workflowStage(phase, index) {
const unavailable = phase.state === "unavailable";
const marker = phase.state === "complete" ? "✓" : String(index + 1);
return `<button type="button" class="workflow-stage is-${escapeAttr(phase.state)}" data-workflow-target="${escapeAttr(phase.target)}" ${unavailable ? "disabled" : ""} title="${escapeAttr(phase.status)}">
<span class="workflow-stage-marker">${escapeHtml(marker)}</span>
<span class="workflow-stage-label">${escapeHtml(phase.label)}</span>
<span class="workflow-stage-status">${escapeHtml(phase.status)}</span>
</button>`;
}
function renderWorkflowGuidance({ summary, selected, plan, run, active }) {
const recommendation = run?.recommended_next || plan?.recommended_action || null;
let guidance;
if ((summary.repository_count || 0) === 0) {
guidance = {
title: "Restore the release workspace",
detail: "No registered repositories are available for release planning.",
remediation: "Review Release Control, restore the workspace or repository registry, then refresh.",
button: "Review blockers",
action: { type: "scroll", target: "releaseControlSection" },
};
} else if (!selected && !run) {
guidance = {
title: "Select release targets",
detail: "Choose one or more repositories and confirm each exact target version.",
remediation: "The release plan will include only the selected independently versioned repositories.",
button: "Choose targets",
action: { type: "scroll", target: "releaseTargetsSection" },
};
} else if (!plan) {
guidance = {
title: "Validate the selected release",
detail: "Build a dry-run plan to check versions, contracts, migrations, Git state, and release composition.",
remediation: "Planning is read-only and does not create tags or artifacts.",
button: "Build plan",
action: { type: "build-plan" },
};
} else if (plan.status === "blocked") {
guidance = {
title: recommendation?.title || "Resolve release gates",
detail: recommendation?.detail || "The current plan contains blockers.",
remediation: recommendation?.remediation || "Review the plan findings, correct the source state, then build a fresh plan.",
button: "Review plan",
action: { type: "scroll", target: "sourceReleaseSection" },
};
} else if (!run) {
guidance = releaseState.runStoreAvailable ? {
title: "Freeze a durable release run",
detail: `${selected} repository target${selected === 1 ? "" : "s"} passed the plan-visible gates.`,
remediation: "Creating the run binds the exact plan, source commits, registered remotes, and ordered mutation boundaries.",
button: "Create durable run",
action: { type: "create-run" },
} : {
title: "Restore durable run storage",
detail: "The release plan is valid, but the console cannot persist a safe execution record.",
remediation: "Review the run-storage error and restore the private state directory before executing release mutations.",
button: "Review storage error",
action: { type: "scroll", target: "releaseRunSection" },
};
} else if (recommendation?.step_id) {
guidance = {
title: recommendation.title || "Continue the release run",
detail: recommendation.detail || "The next durable step is ready for review.",
remediation: recommendation.remediation || "Open the step, review its prerequisites, and use its narrowly scoped confirmation.",
button: ["retry_step", "reconcile_step"].includes(recommendation.id) ? "Resolve step" : "Open next step",
action: { type: "run-step", target: recommendation.step_id },
};
} else if (run.state?.status === "completed") {
guidance = {
title: "Run installation verification",
detail: "Every durable source, package, signing, and publication step has a recorded success receipt.",
remediation: "End-to-end installation verification is still an external release-integration CI gate.",
button: "Review verification gate",
action: { type: "scroll", target: "installVerificationSection" },
};
} else {
guidance = {
title: recommendation?.title || active?.label || "Review release state",
detail: recommendation?.detail || "The next release action is not currently executable.",
remediation: recommendation?.remediation || "Review the blocked or unavailable step and its prerequisites.",
button: "Review run",
action: { type: "scroll", target: "releaseRunSection" },
};
}
releaseState.workflowAction = guidance.action;
elements.workflowGuidance.innerHTML = `<div>
<h3>${escapeHtml(guidance.title)}</h3>
<p>${escapeHtml(guidance.detail)}</p>
<p class="action-meta"><strong>Next:</strong> ${escapeHtml(guidance.remediation)}</p>
</div>
<button id="workflowPrimaryAction">${escapeHtml(guidance.button)}</button>`;
elements.workflowPrimaryAction = document.getElementById("workflowPrimaryAction");
elements.workflowPrimaryAction.addEventListener("click", runWorkflowPrimaryAction);
}
function runWorkflowPrimaryAction() {
const action = releaseState.workflowAction;
if (!action) return;
if (action.type === "build-plan") {
void buildSelectedPlan();
return;
}
if (action.type === "create-run") {
void createReleaseRun();
return;
}
if (action.type === "run-step") {
const step = elements.runOutput.querySelector(`[data-run-step-id="${cssEscape(action.target)}"]`);
if (step) {
step.scrollIntoView({ behavior: "smooth", block: "center" });
step.querySelector("input:not(:disabled), select:not(:disabled), button:not(:disabled)")?.focus();
return;
}
scrollToReleaseTarget("releaseRunSection");
return;
}
scrollToReleaseTarget(action.target);
}
function scrollToReleaseTarget(targetId) {
const target = document.getElementById(targetId);
if (target) target.scrollIntoView({ behavior: "smooth", block: "start" });
}
function renderReleaseIntelligence(data) {
@@ -1723,6 +2072,7 @@
input.addEventListener("change", () => {
const repo = input.dataset.repo;
releaseState.rows[repo] = { ...releaseState.rows[repo], selected: input.checked };
invalidateReleaseDraft();
updateUnitCount();
});
}
@@ -1868,6 +2218,7 @@
}
function resetTarget(repoName) {
invalidateReleaseDraft();
const row = { ...releaseState.rows[repoName] };
delete row.target;
releaseState.rows[repoName] = row;
@@ -1880,6 +2231,7 @@
}
setRowTargetTag(repoName, target);
elements.statusLine.textContent = `${repoName} target reset.`;
if (releaseState.dashboard) renderWorkflowStages(releaseState.dashboard);
}
function currentRowTarget(repoName) {
@@ -1887,6 +2239,7 @@
}
function setRowTarget(repoName, value) {
invalidateReleaseDraft();
releaseState.rows[repoName] = { ...releaseState.rows[repoName], target: value };
const element = rowTargetElement(repoName);
if (element) {
@@ -1894,6 +2247,7 @@
element.textContent = value;
}
setRowTargetTag(repoName, value);
if (releaseState.dashboard) renderWorkflowStages(releaseState.dashboard);
}
function rowTargetElement(repoName) {
@@ -2003,6 +2357,7 @@
}
function renderPlan(plan) {
releaseState.currentPlan = plan;
elements.planStatus.textContent = plan.status;
const units = plan.units || [];
const compatibility = plan.compatibility || [];
@@ -2011,6 +2366,7 @@
const recommendation = plan.recommended_action || null;
if (!units.length && !compatibility.length && !steps.length && !recommendation) {
elements.actions.innerHTML = `<div class="muted">No selective release candidates. Select repositories above to plan a release.</div>`;
if (releaseState.dashboard) renderWorkflowStages(releaseState.dashboard);
return;
}
const recommendationKind = plan.status === "blocked" ? "block" : plan.source_preflight_ready ? "ok" : "warn";
@@ -2061,11 +2417,27 @@
${compatibilityHtml}
${stepHtml}
`;
if (releaseState.dashboard) renderWorkflowStages(releaseState.dashboard);
}
function renderIdlePlan() {
releaseState.currentPlan = null;
elements.planStatus.textContent = "idle";
elements.actions.innerHTML = `<div class="muted">Select repositories above, adjust target versions, then build a plan.</div>`;
if (releaseState.dashboard) renderWorkflowStages(releaseState.dashboard);
}
function invalidateReleaseDraft() {
releaseState.currentPlan = null;
if (releaseState.currentRun) {
releaseState.currentRun = null;
elements.releaseRun.value = "";
elements.runStatus.textContent = "no run selected";
elements.resumeReleaseRun.disabled = true;
elements.runOutput.innerHTML = `<div class="muted">The repository selection changed. Build a fresh plan before creating a durable run; saved runs remain available from the selector.</div>`;
}
elements.planStatus.textContent = "idle";
elements.actions.innerHTML = `<div class="muted">The release inputs changed. Build a fresh plan before continuing.</div>`;
}
async function generateCandidate() {
@@ -2100,16 +2472,20 @@
setBusy([elements.buildSelectedPlan], true);
try {
if (!Object.keys(selectedRepoVersions()).length) {
releaseState.currentPlan = null;
elements.planStatus.textContent = "idle";
elements.actions.innerHTML = `<div class="action"><h3>${pill("idle", "warn")} No repositories selected</h3><p>Select one or more rows in the repository table first.</p></div>`;
if (releaseState.dashboard) renderWorkflowStages(releaseState.dashboard);
return;
}
const plan = await api("/api/selective-plan");
renderPlan(plan);
elements.planStatus.textContent = plan.status;
} catch (error) {
releaseState.currentPlan = null;
elements.planStatus.textContent = "error";
elements.actions.innerHTML = `<div class="action"><h3>${pill("error", "block")} Plan failed</h3><p>${escapeHtml(error.message)}</p></div>`;
if (releaseState.dashboard) renderWorkflowStages(releaseState.dashboard);
} finally {
setBusy([elements.buildSelectedPlan], false);
}
@@ -2117,6 +2493,7 @@
function selectChangedUnits() {
if (!releaseState.dashboard) return;
invalidateReleaseDraft();
let count = 0;
for (const repo of releaseState.dashboard.repositories) {
const current = primaryVersion(repo);
@@ -2134,6 +2511,7 @@
function selectUnpushedUnits() {
if (!releaseState.dashboard) return;
invalidateReleaseDraft();
let count = 0;
for (const repo of releaseState.dashboard.repositories) {
const current = primaryVersion(repo);
@@ -2151,6 +2529,7 @@
function selectUnreleasedUnits() {
if (!releaseState.dashboard) return;
invalidateReleaseDraft();
for (const repo of releaseState.dashboard.repositories) {
if (repo.spec.name === "govoplan") continue;
const unreleased = !primaryVersion(repo);
@@ -2164,11 +2543,11 @@
}
function clearSelectedUnits() {
invalidateReleaseDraft();
for (const repo of Object.keys(releaseState.rows)) {
releaseState.rows[repo] = { ...releaseState.rows[repo], selected: false };
}
if (releaseState.dashboard) renderReleaseUnits(releaseState.dashboard);
renderIdlePlan();
}
function selectedRepoNames() {