Files
govoplan/tools/release/webui/index.html
Albrecht Degering 9d6cdff4b8
Some checks failed
Dependency Audit / dependency-audit (push) Has been cancelled
Security Audit / security-audit (push) Has been cancelled
CSS changes for release console
2026-07-29 19:35:49 +02:00

2503 lines
113 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>GovOPlaN Release Console</title>
<style>
:root {
color-scheme: light;
--bg: #f6f7f9;
--panel: #ffffff;
--panel-alt: #eef3f8;
--line: #d7dde5;
--text: #18212f;
--muted: #637083;
--accent: #1261a6;
--accent-strong: #0a4c87;
--danger: #b42318;
--warning: #a15c07;
--ok: #23704f;
--shadow: 0 1px 2px rgba(20, 31, 48, 0.08);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
}
header {
background: #101820;
color: #fff;
padding: 18px 28px;
position: sticky;
top: 0;
z-index: 10;
}
header h1 {
margin: 0;
font-size: 20px;
font-weight: 700;
}
header p {
margin: 6px 0 0;
color: #c7d0dd;
font-size: 13px;
}
main {
padding: 22px 28px 36px;
}
.toolbar,
.summary,
.layout {
width: min(1600px, 100%);
margin: 0 auto 18px;
}
.toolbar {
display: grid;
grid-template-columns: minmax(120px, 160px) repeat(4, auto) auto;
gap: 10px;
align-items: center;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px;
box-shadow: var(--shadow);
}
.target-controls {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: end;
}
.target-controls button {
min-width: 44px;
padding: 0 10px;
}
label {
color: var(--muted);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
input[type="text"],
input[type="password"],
select {
width: 100%;
min-height: 36px;
border: 1px solid var(--line);
border-radius: 6px;
padding: 7px 9px;
color: var(--text);
font: inherit;
}
.check {
display: inline-flex;
align-items: center;
gap: 7px;
min-height: 36px;
color: var(--text);
font-size: 13px;
}
button {
min-height: 36px;
border: 1px solid var(--accent-strong);
border-radius: 6px;
background: var(--accent);
color: white;
padding: 0 14px;
font: inherit;
font-weight: 700;
cursor: pointer;
}
button.secondary {
border-color: var(--line);
background: #fff;
color: var(--text);
}
button.danger {
border-color: #8f1d14;
background: var(--danger);
}
button:disabled {
cursor: not-allowed;
opacity: 0.65;
}
.button-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.form-grid {
display: grid;
gap: 10px;
}
.unit-check {
width: 18px;
height: 18px;
}
.units-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 12px 14px;
border-bottom: 1px solid var(--line);
}
.summary {
display: grid;
grid-template-columns: repeat(6, minmax(120px, 1fr));
gap: 10px;
}
.insight-grid {
display: grid;
grid-template-columns: repeat(4, minmax(150px, 1fr));
gap: 10px;
padding: 14px;
}
.insight {
border: 1px solid var(--line);
border-radius: 8px;
padding: 10px;
background: #fff;
}
.insight span {
display: block;
color: var(--muted);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.insight strong {
display: block;
margin-top: 5px;
font-size: 18px;
}
.issue-list {
display: grid;
gap: 8px;
padding: 0 14px 14px;
}
.narrow-table {
min-width: 760px;
}
.metric {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
padding: 12px;
box-shadow: var(--shadow);
}
.metric span {
display: block;
color: var(--muted);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
.metric strong {
display: block;
margin-top: 6px;
font-size: 24px;
line-height: 1;
}
.layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 380px;
gap: 18px;
align-items: start;
}
.main-stack {
display: grid;
gap: 18px;
min-width: 0;
}
section {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
}
.section-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
padding: 13px 14px;
border-bottom: 1px solid var(--line);
}
.section-head h2 {
margin: 0;
font-size: 15px;
}
.section-head small {
color: var(--muted);
}
.table-wrap {
overflow: auto;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 960px;
}
th,
td {
border-bottom: 1px solid var(--line);
padding: 9px 10px;
text-align: left;
vertical-align: top;
font-size: 13px;
}
th {
background: var(--panel-alt);
color: var(--muted);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.target-column {
width: 330px;
min-width: 330px;
}
.target-cell {
display: flex;
gap: 6px;
align-items: center;
}
.target-value {
display: inline-flex;
align-items: center;
min-width: 58px;
min-height: 30px;
padding: 0 2px;
color: var(--text);
font-family: ui-monospace, "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 13px;
font-weight: 700;
}
.target-tag-value {
font-family: ui-monospace, "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 12px;
}
.row-target-controls {
gap: 4px;
}
.row-target-controls button {
min-height: 30px;
min-width: 34px;
padding: 0 8px;
font-size: 12px;
}
td code {
font-size: 12px;
}
.pill {
display: inline-flex;
align-items: center;
min-height: 22px;
border-radius: 999px;
padding: 2px 8px;
background: var(--panel-alt);
color: var(--muted);
font-size: 12px;
font-weight: 700;
}
.pill.ok {
background: #e8f5ef;
color: var(--ok);
}
.pill.warn {
background: #fff4df;
color: var(--warning);
}
.pill.block {
background: #fdeceb;
color: var(--danger);
}
.side {
display: grid;
gap: 18px;
}
.details {
padding: 14px;
}
.kv {
display: grid;
grid-template-columns: 140px minmax(0, 1fr);
gap: 8px;
margin: 8px 0;
font-size: 13px;
}
.kv span {
color: var(--muted);
}
.actions {
display: grid;
gap: 10px;
padding: 14px;
}
.workflow-stages {
display: grid;
gap: 10px;
padding: 14px;
}
.action {
border: 1px solid var(--line);
border-radius: 8px;
padding: 10px;
}
.action.recommended {
border-left: 4px solid var(--accent);
background: var(--panel-alt);
}
.action h3 {
margin: 0 0 5px;
font-size: 13px;
}
.stage-title {
display: flex;
justify-content: space-between;
gap: 8px;
align-items: center;
}
.stage-title h3 {
margin: 0;
}
.action p {
margin: 0;
color: var(--muted);
font-size: 12px;
}
.action .action-meta,
.hint {
margin-top: 6px;
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.action pre {
margin: 9px 0 0;
padding: 8px;
overflow: auto;
background: #101820;
color: #edf3f7;
border-radius: 6px;
font-size: 12px;
}
.muted {
color: var(--muted);
}
.error {
color: var(--danger);
}
.modal-backdrop {
position: fixed;
inset: 0;
display: grid;
place-items: center;
padding: 24px;
background: rgba(16, 24, 32, 0.42);
}
.modal-backdrop[hidden] {
display: none;
}
.modal {
width: min(460px, 100%);
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: 0 18px 52px rgba(16, 24, 32, 0.28);
}
.modal-head,
.modal-body,
.modal-actions {
padding: 14px;
}
.modal-head {
border-bottom: 1px solid var(--line);
}
.modal-head h2 {
margin: 0;
font-size: 16px;
}
.modal-body p {
margin: 0 0 10px;
color: var(--muted);
font-size: 13px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
border-top: 1px solid var(--line);
}
@media (max-width: 1100px) {
.toolbar,
.layout,
.summary,
.insight-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<header>
<h1>GovOPlaN Release Console</h1>
<p>Local release dashboard with guarded candidate generation and publication workflow actions.</p>
</header>
<main>
<div class="toolbar">
<div>
<label for="channel">Channel</label>
<input id="channel" type="text" value="stable" />
</div>
<label class="check"><input id="publicCatalog" type="checkbox" checked /> Published channel</label>
<label class="check"><input id="online" type="checkbox" /> Remote tags</label>
<label class="check"><input id="migrations" type="checkbox" /> Migrations</label>
<label class="check"><input id="website" type="checkbox" /> Website repo</label>
<button id="refresh">Refresh</button>
</div>
<div class="toolbar" style="grid-template-columns: 1fr;">
<div class="muted" id="statusLine">Loading...</div>
</div>
<div class="summary" id="summary"></div>
<div class="layout">
<div class="main-stack">
<section>
<div class="section-head">
<h2>Release Control</h2>
<small id="releaseControlStatus"></small>
</div>
<div class="insight-grid" id="releaseInsights"></div>
<div class="issue-list" id="releaseIssues"></div>
</section>
<section>
<div class="section-head">
<h2>Published Modules</h2>
<small id="publishedModuleCount"></small>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Module</th>
<th>Version</th>
<th>Repo</th>
<th>Local</th>
<th>Interfaces</th>
<th>Migration</th>
<th>Refs</th>
</tr>
</thead>
<tbody id="publishedModules"></tbody>
</table>
</div>
</section>
<section>
<div class="section-head">
<h2>Compatibility</h2>
<small id="compatibilityStatus"></small>
</div>
<div class="table-wrap">
<table class="narrow-table">
<thead>
<tr>
<th>Consumer</th>
<th>Interface</th>
<th>Range</th>
<th>Status</th>
<th>Provider</th>
</tr>
</thead>
<tbody id="compatibilityRows"></tbody>
</table>
</div>
</section>
<section>
<div class="section-head">
<h2>Repositories</h2>
<small id="unitCount"></small>
</div>
<div class="units-toolbar">
<button id="buildSelectedPlan">Build Plan</button>
<button id="generateSelectedCandidate" disabled data-release-disabled="true" title="Use the receipt-bound generator in Durable Run State.">Generate Candidate</button>
<button class="secondary" id="selectUnpushedUnits" title="Select repositories with committed branch changes to push.">Select Unpushed</button>
<button class="secondary" id="selectChangedUnits" title="Select repositories with dirty/untracked paths, missing HEAD, or unpushed commits.">Select Dirty/Changed</button>
<button class="secondary" id="selectUnreleasedUnits">Select Unreleased</button>
<button class="secondary" id="clearSelectedUnits">Clear</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Release</th>
<th>Repository</th>
<th>Type</th>
<th>Current</th>
<th class="target-column">Target</th>
<th>Target tag</th>
<th>Branch / Upstream</th>
<th>Git</th>
<th>Notes</th>
</tr>
</thead>
<tbody id="releaseUnits"></tbody>
</table>
</div>
</section>
</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>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>
<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>
<small id="workflowStageStatus"></small>
</div>
<div class="workflow-stages" id="workflowStages"></div>
</section>
<section>
<div class="section-head">
<h2>1. Git Sync</h2>
<small id="pushStatus"></small>
</div>
<div class="details">
<p class="hint">Repository sync and push are preview-only until they have a receipt-bound durable maintenance executor.</p>
<div class="form-grid">
<div>
<label for="pushRemote">Remote</label>
<input id="pushRemote" type="text" value="origin" />
</div>
<div>
<label for="pushConfirmText">Confirm</label>
<input id="pushConfirmText" type="text" placeholder="SYNC or PUSH" />
</div>
</div>
<div class="button-row">
<button class="secondary" id="previewRepoSync">Preview Sync</button>
<button id="syncRepos" disabled data-release-disabled="true" title="Durable maintenance executor required">Sync Selected</button>
<button class="secondary" id="previewRepoPush">Preview Push</button>
<button class="danger" id="pushRepos" disabled data-release-disabled="true" title="Durable maintenance executor required">Push Selected</button>
</div>
<div class="actions" id="pushOutput"></div>
</div>
</section>
<section>
<div class="section-head">
<h2>2. Prepare Changes</h2>
<small id="prepareStatus"></small>
</div>
<div class="details">
<p class="hint">Preview preparation only. Commit mutation is disabled outside a content-bound durable executor; commit and review dirty or version-changing work outside this console, then build a new run.</p>
<div class="form-grid">
<div>
<label for="prepareMessage">Commit message</label>
<input id="prepareMessage" type="text" placeholder="default Release <repo> <tag>" />
</div>
<div>
<label for="prepareConfirmText">Confirm</label>
<input id="prepareConfirmText" type="text" placeholder="durable commit executor pending" disabled />
</div>
</div>
<div class="button-row">
<button class="secondary" id="previewPrepare">Preview Prepare</button>
<button class="danger" id="commitPrepare" data-release-disabled="true" disabled title="No content-bound durable commit executor is available yet.">Commit Selected</button>
</div>
<div class="actions" id="prepareOutput"></div>
</div>
</section>
<section>
<div class="section-head">
<h2>3. Source Release Tag</h2>
<small><span id="tagStatus">idle</span> · plan <span id="planStatus">idle</span></small>
</div>
<div class="details">
<p class="hint">Preview is available here. Tag creation and publication use the matching durable run steps, bound to the frozen HEAD, branch, clean worktree, and registered origin.</p>
<div class="form-grid">
<div>
<label for="tagMessage">Tag message</label>
<input id="tagMessage" type="text" placeholder="default Release &lt;repo&gt; &lt;tag&gt;" />
</div>
<div>
<label for="tagConfirmText">Confirm</label>
<input id="tagConfirmText" type="text" placeholder="use durable run controls" disabled />
</div>
</div>
<div class="button-row">
<button class="secondary" id="previewReleaseTags">Preview Tag + Publish</button>
<button id="createReleaseTags" data-release-disabled="true" disabled title="Use the receipt-bound tag step in Durable Run State.">Create Tags</button>
<button class="danger" id="publishReleaseTags" data-release-disabled="true" disabled title="Use the receipt-bound push step in Durable Run State.">Publish Tags</button>
</div>
<div class="actions" id="tagOutput"></div>
</div>
<div class="actions" id="actions"></div>
</section>
<section>
<div class="section-head">
<h2>4. Signed Website Catalog</h2>
<small id="workflowStatus"></small>
</div>
<div class="details">
<div class="form-grid">
<div>
<label for="signingKey">Signing key</label>
<input id="signingKey" type="text" placeholder="default release-key-1" />
</div>
<div>
<label for="candidateDir">Candidate dir</label>
<input id="candidateDir" type="text" placeholder="private state/release-candidates/candidate-..." />
</div>
<div>
<label for="confirmText">Confirm</label>
<input id="confirmText" type="text" placeholder="use durable run controls" disabled />
</div>
</div>
<div class="button-row">
<button id="generateCandidate" disabled data-release-disabled="true" title="Use the receipt-bound generator in Durable Run State.">Generate</button>
<button class="secondary" id="previewPublish">Preview</button>
<button class="secondary" id="applyPublish" data-release-disabled="true" disabled title="Use the receipt-bound publication step in Durable Run State.">Apply + Website Tag</button>
<button class="danger" id="pushPublish" data-release-disabled="true" disabled title="Use the receipt-bound publication step in Durable Run State.">Push Website Release</button>
</div>
<div class="actions" id="workflowOutput"></div>
</div>
</section>
<section>
<div class="section-head">
<h2>Catalog State</h2>
<small id="catalogStatus"></small>
</div>
<div class="details" id="catalog"></div>
</section>
<section>
<div class="section-head">
<h2>5. Install Catalog</h2>
<small>planned</small>
</div>
<div class="details">
<p class="hint">Future work: signed module archives and an installer/update catalog for deployments.</p>
</div>
</section>
</div>
</div>
</main>
<div class="modal-backdrop" id="manualTargetModal" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="manualTargetTitle">
<div class="modal-head">
<h2 id="manualTargetTitle">Manual Target Version</h2>
</div>
<div class="modal-body">
<p id="manualTargetHelp"></p>
<label for="manualTargetInput">Version</label>
<input id="manualTargetInput" type="text" placeholder="0.2.0-alpha1" />
<p id="manualTargetError" class="error"></p>
</div>
<div class="modal-actions">
<button class="secondary" id="cancelManualTarget">Cancel</button>
<button id="applyManualTarget">Apply</button>
</div>
</div>
</div>
<script>
const fragmentParams = new URLSearchParams(window.location.hash.slice(1));
const fragmentToken = fragmentParams.get("token") || "";
const token = fragmentToken || sessionStorage.getItem("releaseConsoleToken") || "";
if (token) {
sessionStorage.setItem("releaseConsoleToken", token);
}
if (fragmentToken) {
history.replaceState(null, document.title, `${window.location.pathname}${window.location.search}`);
}
const elements = {
channel: document.getElementById("channel"),
publicCatalog: document.getElementById("publicCatalog"),
online: document.getElementById("online"),
migrations: document.getElementById("migrations"),
website: document.getElementById("website"),
statusLine: document.getElementById("statusLine"),
summary: document.getElementById("summary"),
releaseControlStatus: document.getElementById("releaseControlStatus"),
releaseInsights: document.getElementById("releaseInsights"),
releaseIssues: document.getElementById("releaseIssues"),
publishedModuleCount: document.getElementById("publishedModuleCount"),
publishedModules: document.getElementById("publishedModules"),
compatibilityStatus: document.getElementById("compatibilityStatus"),
compatibilityRows: document.getElementById("compatibilityRows"),
releaseUnits: document.getElementById("releaseUnits"),
unitCount: document.getElementById("unitCount"),
workflowStageStatus: document.getElementById("workflowStageStatus"),
workflowStages: document.getElementById("workflowStages"),
catalog: document.getElementById("catalog"),
catalogStatus: document.getElementById("catalogStatus"),
actions: document.getElementById("actions"),
planStatus: document.getElementById("planStatus"),
tagStatus: document.getElementById("tagStatus"),
tagMessage: document.getElementById("tagMessage"),
tagConfirmText: document.getElementById("tagConfirmText"),
tagOutput: document.getElementById("tagOutput"),
previewReleaseTags: document.getElementById("previewReleaseTags"),
createReleaseTags: document.getElementById("createReleaseTags"),
publishReleaseTags: document.getElementById("publishReleaseTags"),
signingKey: document.getElementById("signingKey"),
candidateDir: document.getElementById("candidateDir"),
confirmText: document.getElementById("confirmText"),
workflowStatus: document.getElementById("workflowStatus"),
workflowOutput: document.getElementById("workflowOutput"),
generateCandidate: document.getElementById("generateCandidate"),
generateSelectedCandidate: document.getElementById("generateSelectedCandidate"),
previewPublish: document.getElementById("previewPublish"),
applyPublish: document.getElementById("applyPublish"),
pushPublish: document.getElementById("pushPublish"),
previewRepoSync: document.getElementById("previewRepoSync"),
syncRepos: document.getElementById("syncRepos"),
previewRepoPush: document.getElementById("previewRepoPush"),
pushRepos: document.getElementById("pushRepos"),
pushRemote: document.getElementById("pushRemote"),
pushConfirmText: document.getElementById("pushConfirmText"),
pushStatus: document.getElementById("pushStatus"),
pushOutput: document.getElementById("pushOutput"),
prepareMessage: document.getElementById("prepareMessage"),
prepareConfirmText: document.getElementById("prepareConfirmText"),
prepareStatus: document.getElementById("prepareStatus"),
prepareOutput: document.getElementById("prepareOutput"),
previewPrepare: document.getElementById("previewPrepare"),
commitPrepare: document.getElementById("commitPrepare"),
buildSelectedPlan: document.getElementById("buildSelectedPlan"),
releaseRun: document.getElementById("releaseRun"),
loadOlderReleaseRuns: document.getElementById("loadOlderReleaseRuns"),
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"),
clearSelectedUnits: document.getElementById("clearSelectedUnits"),
manualTargetModal: document.getElementById("manualTargetModal"),
manualTargetHelp: document.getElementById("manualTargetHelp"),
manualTargetInput: document.getElementById("manualTargetInput"),
manualTargetError: document.getElementById("manualTargetError"),
cancelManualTarget: document.getElementById("cancelManualTarget"),
applyManualTarget: document.getElementById("applyManualTarget"),
refresh: document.getElementById("refresh"),
};
const releaseState = {
rows: {},
dashboard: null,
manualRepo: "",
currentRun: null,
runs: [],
runNextCursor: null,
runStoreAvailable: true,
};
const pendingReleaseRunKey = "govoplanReleaseConsolePendingRunCreate";
const pendingRunCommandsKey = "govoplanReleaseConsolePendingRunCommands";
elements.refresh.addEventListener("click", load);
elements.generateCandidate.addEventListener("click", generateCandidate);
elements.generateSelectedCandidate.addEventListener("click", generateCandidate);
elements.previewPublish.addEventListener("click", () => publishCandidate({}));
elements.applyPublish.addEventListener("click", () => publishCandidate({ apply: true, commit: true, tag: true }));
elements.pushPublish.addEventListener("click", () => publishCandidate({ apply: true, commit: true, tag: true, push: true }));
elements.previewRepoSync.addEventListener("click", () => syncSelectedRepos({ apply: false }));
elements.syncRepos.addEventListener("click", () => syncSelectedRepos({ apply: true }));
elements.previewRepoPush.addEventListener("click", () => pushSelectedRepos({ apply: false }));
elements.pushRepos.addEventListener("click", () => pushSelectedRepos({ apply: true }));
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.loadOlderReleaseRuns.addEventListener("click", loadOlderReleaseRuns);
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 }));
elements.selectUnpushedUnits.addEventListener("click", selectUnpushedUnits);
elements.selectChangedUnits.addEventListener("click", selectChangedUnits);
elements.selectUnreleasedUnits.addEventListener("click", selectUnreleasedUnits);
elements.clearSelectedUnits.addEventListener("click", clearSelectedUnits);
elements.cancelManualTarget.addEventListener("click", closeManualTarget);
elements.applyManualTarget.addEventListener("click", applyManualTarget);
elements.manualTargetInput.addEventListener("input", validateManualTarget);
elements.candidateDir.addEventListener("input", () => {
if (releaseState.dashboard) renderWorkflowStages(releaseState.dashboard);
});
function api(path) {
const separator = path.indexOf("?");
const basePath = separator === -1 ? path : path.slice(0, separator);
const query = new URLSearchParams(separator === -1 ? "" : path.slice(separator + 1));
if (elements.channel.value.trim()) query.set("channel", elements.channel.value.trim());
if (basePath === "/api/selective-plan") {
const selected = selectedRepoVersions();
const repoNames = Object.keys(selected);
if (repoNames.length) {
query.set("repos", repoNames.join(","));
query.set("repo_versions", Object.entries(selected).map(([repo, version]) => `${repo}:${version}`).join(","));
}
}
if (elements.publicCatalog.checked) query.set("public_catalog", "true");
else query.set("public_catalog", "false");
if (elements.online.checked) query.set("remote_tags", "true");
if (elements.migrations.checked) query.set("include_migrations", "true");
if (elements.website.checked && basePath === "/api/dashboard") query.set("include_website", "true");
const suffix = query.toString() ? `?${query}` : "";
return fetch(`${basePath}${suffix}`, {
headers: token ? { "X-Release-Console-Token": token } : {},
}).then((response) => {
if (!response.ok) {
return response.json().catch(() => ({})).then((payload) => {
const error = new Error(payload.detail || `${response.status} ${response.statusText}`);
error.status = response.status;
throw error;
});
}
return response.json();
});
}
function postJson(path, body) {
return fetch(path, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { "X-Release-Console-Token": token } : {}),
},
body: JSON.stringify(body),
}).then((response) => {
if (!response.ok) {
return response.json().catch(() => ({})).then((payload) => {
const error = new Error(payload.detail || `${response.status} ${response.statusText}`);
error.status = response.status;
throw error;
});
}
return response.json();
});
}
async function load() {
elements.statusLine.textContent = "Refreshing...";
try {
const runsRequest = api("/api/release-runs")
.then((data) => ({ data, error: null }))
.catch((error) => ({ data: null, error }));
const [dashboard, intelligence, runsResult] = await Promise.all([api("/api/dashboard"), api("/api/release-intelligence"), runsRequest]);
renderDashboard(dashboard);
renderReleaseIntelligence(intelligence);
if (runsResult.error) {
renderReleaseRunStoreUnavailable(runsResult.error);
} else {
renderReleaseRunList(runsResult.data?.runs || [], { nextCursor: runsResult.data?.next_cursor || null });
const pendingCreateResult = await recoverPendingReleaseRun();
const pendingCommandResult = await recoverPendingRunCommands();
const recoveryAttempted = pendingCreateResult !== null || pendingCommandResult !== null;
if (!recoveryAttempted && releaseState.currentRun?.run_id) {
await loadReleaseRun(releaseState.currentRun.run_id, { restoreSelection: false });
} else if (!recoveryAttempted) {
renderIdlePlan();
}
}
elements.statusLine.textContent = `Generated ${dashboard.generated_at}`;
} catch (error) {
elements.statusLine.innerHTML = `<span class="error">${escapeHtml(error.message)}</span>`;
}
}
function renderDashboard(data) {
const s = data.summary;
elements.summary.innerHTML = [
metric("Status", s.status),
metric("Repos", s.repository_count),
metric("Dirty", s.dirty_count),
metric("Ahead", s.ahead_count),
metric("Behind", s.behind_count),
metric("No HEAD", s.no_head_count),
metric("Errors", s.error_count),
metric("Contracts", data.contracts.length),
].join("");
releaseState.dashboard = data;
renderReleaseUnits(data);
renderWorkflowStages(data);
renderCatalog(data.catalog, data.target_version);
}
function renderReleaseRunList(runs, renderOptions = {}) {
releaseState.runStoreAvailable = true;
const incoming = Array.isArray(runs) ? runs : [];
releaseState.runs = renderOptions.append
? [...new Map([...releaseState.runs, ...incoming].map((run) => [run.run_id, run])).values()]
: incoming;
releaseState.runNextCursor = renderOptions.nextCursor || null;
elements.releaseRun.disabled = false;
elements.createReleaseRun.disabled = false;
elements.loadOlderReleaseRuns.disabled = !releaseState.runNextCursor;
const selectedId = releaseState.currentRun?.run_id || "";
const runOptions = 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>${runOptions}`;
if (selectedId && releaseState.runs.some((run) => run.run_id === selectedId)) {
elements.releaseRun.value = selectedId;
}
}
function renderReleaseRunStoreUnavailable(error) {
releaseState.runStoreAvailable = false;
releaseState.runs = [];
releaseState.currentRun = null;
releaseState.runNextCursor = null;
elements.releaseRun.innerHTML = `<option value="">Run storage unavailable</option>`;
elements.releaseRun.disabled = true;
elements.createReleaseRun.disabled = true;
elements.loadOlderReleaseRuns.disabled = true;
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>`;
}
async function createReleaseRun() {
if (!releaseState.runStoreAvailable) return;
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 payload = pendingReleaseRunPayload({
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,
});
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>`;
} finally {
setBusy([elements.createReleaseRun], false);
}
}
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 || [], { nextCursor: runs.next_cursor || null });
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 loadOlderReleaseRuns() {
const cursor = releaseState.runNextCursor;
if (!cursor) return;
setBusy([elements.loadOlderReleaseRuns], true);
try {
const page = await api(`/api/release-runs?cursor=${encodeURIComponent(cursor)}`);
renderReleaseRunList(page.runs || [], {
append: true,
nextCursor: page.next_cursor || null,
});
} catch (error) {
elements.runOutput.insertAdjacentHTML("afterbegin", `<div class="action"><h3>${pill("unavailable", "warn")} Older runs could not be loaded</h3><p>${escapeHtml(error.message)}</p></div>`);
} finally {
setBusy([elements.loadOlderReleaseRuns], false);
elements.loadOlderReleaseRuns.disabled = !releaseState.runNextCursor;
}
}
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" : state.status === "attention" ? "warn" : 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.");
const executor = step.executor || {};
const confirmation = executor.confirmation || "";
const executeReason = executor.available ? (step.disabled_reason || "Execute this frozen plan step.") : "No bounded executor is available for this frozen step.";
const executorHtml = executor.available ? `<div class="form-grid" data-run-execute-step="${escapeAttr(step.id)}" data-run-execute-confirmation="${escapeAttr(confirmation)}" data-run-execute-available="${step.available ? "true" : "false"}">
${confirmation ? `<div><label>Confirmation</label><input type="text" data-run-execute-confirm placeholder="Type ${escapeAttr(confirmation)}" autocomplete="off" /></div>` : ""}
<div class="button-row">${executor.kind === "catalog_publish" ? `<button class="secondary" data-run-preview-submit ${step.available ? "" : "disabled"}>Preview receipt-bound candidate</button>` : ""}<button data-run-execute-submit ${step.available && !confirmation ? "" : "disabled"} title="${escapeAttr(executeReason)}">Execute Step</button></div>
</div>` : `<p class="action-meta">No durable executor: ${escapeHtml(step.status || "unsupported")}</p>`;
const reconciliationHtml = step.reconciliation_required ? `<div class="form-grid" data-run-reconcile-step="${escapeAttr(step.id)}">
<div><label>Observed external effect</label><select data-run-reconcile-outcome><option value="">Choose verified outcome</option><option value="effect_absent">Effect is absent — retry is safe</option><option value="effect_succeeded">Effect succeeded — advance the run after server verification</option><option value="unresolved">Still unresolved — keep blocked</option></select></div>
<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">
<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>` : ""}
${releaseReceiptHtml(step.result_receipt)}
${executorHtml}
<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>
${reconciliationHtml}
</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 !== "running";
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">Every supported mutation is claimed durably before the existing narrow executor is called.</p></div>${recommendationHtml}${stepHtml}${eventHtml}`;
for (const button of elements.runOutput.querySelectorAll("[data-run-retry-step]")) {
button.addEventListener("click", () => retryReleaseRunStep(button.dataset.runRetryStep));
}
for (const controls of elements.runOutput.querySelectorAll("[data-run-reconcile-step]")) {
const outcome = controls.querySelector("[data-run-reconcile-outcome]");
const confirmation = controls.querySelector("[data-run-reconcile-confirm]");
const submit = controls.querySelector("[data-run-reconcile-submit]");
const refresh = () => {
submit.disabled = !outcome.value || confirmation.value.trim() !== "RECONCILE";
};
outcome.addEventListener("change", refresh);
confirmation.addEventListener("input", refresh);
submit.addEventListener("click", () => reconcileReleaseRunStep(controls.dataset.runReconcileStep, outcome.value, confirmation.value.trim(), submit));
}
for (const controls of elements.runOutput.querySelectorAll("[data-run-execute-step]")) {
const required = controls.dataset.runExecuteConfirmation || "";
const available = controls.dataset.runExecuteAvailable === "true";
const input = controls.querySelector("[data-run-execute-confirm]");
const submit = controls.querySelector("[data-run-execute-submit]");
const preview = controls.querySelector("[data-run-preview-submit]");
if (input) input.addEventListener("input", () => {
submit.disabled = !available || input.value.trim() !== required;
});
submit.addEventListener("click", () => executeReleaseRunStep(controls.dataset.runExecuteStep, required, submit));
if (preview) preview.addEventListener("click", () => previewReleaseRunStep(controls.dataset.runExecuteStep, preview));
}
}
async function executeReleaseRunStep(stepId, confirmation, button) {
const run = releaseState.currentRun;
if (!run || !stepId) return;
const commandKey = `execute:${run.run_id}:${stepId}:${confirmation || "NONE"}`;
const commandBody = {
confirm: confirmation,
remote: "origin",
signing_keys: elements.signingKey.value.trim() ? [elements.signingKey.value.trim()] : [],
};
const commandRequestId = inFlightRunCommandId(commandKey, "execute");
setBusy([button], true);
try {
const executed = await postJson(`/api/release-runs/${encodeURIComponent(run.run_id)}/steps/${encodeURIComponent(stepId)}/execute`, {
request_id: commandRequestId,
...commandBody,
});
clearInFlightRunCommand(commandKey, commandRequestId);
releaseState.currentRun = executed;
renderReleaseRun(executed);
const result = executed.execution_result || {};
elements.runOutput.insertAdjacentHTML("afterbegin", `<div class="action"><h3>${pill(result.status || "recorded", result.status === "failed" ? "block" : "ok")} Executor result</h3><p>${escapeHtml(result.detail || result.result_code || "The bounded result was recorded.")}</p></div>`);
} catch (error) {
if (deterministicRunCommandError(error)) clearInFlightRunCommand(commandKey, commandRequestId);
elements.runOutput.insertAdjacentHTML("afterbegin", `<div class="action"><h3>${pill("reconcile", "block")} Executor did not establish a safe result</h3><p>${escapeHtml(error.message)}</p></div>`);
} finally {
button.disabled = false;
}
}
async function previewReleaseRunStep(stepId, button) {
const run = releaseState.currentRun;
if (!run || !stepId) return;
setBusy([button], true);
try {
const result = await postJson(`/api/release-runs/${encodeURIComponent(run.run_id)}/steps/${encodeURIComponent(stepId)}/preview`, {
remote: "origin",
});
elements.runOutput.insertAdjacentHTML("afterbegin", `<div class="action"><h3>${pill(result.status || "preview", result.status === "blocked" ? "block" : "ok")} Receipt-bound publication preview</h3><p>${escapeHtml((result.notes || []).join("; ") || "Preview completed without changing run state.")}</p></div>`);
} catch (error) {
elements.runOutput.insertAdjacentHTML("afterbegin", `<div class="action"><h3>${pill("blocked", "block")} Preview failed</h3><p>${escapeHtml(error.message)}</p></div>`);
} finally {
setBusy([button], false);
}
}
function releaseReceiptHtml(receipt) {
if (!receipt || typeof receipt !== "object") return "";
if (receipt.kind === "catalog_candidate") {
return `<p class="action-meta">Candidate ${escapeHtml(receipt.candidate_id || "-")} · ${escapeHtml((receipt.catalog_sha256 || "").slice(0, 16))}…</p>`;
}
if (receipt.kind === "catalog_publication") {
return `<p class="action-meta">Published ${escapeHtml(receipt.candidate_id || "-")} · commit ${escapeHtml((receipt.publication_commit_sha || "").slice(0, 12))} · tag ${escapeHtml(receipt.tag_name || "-")} (${escapeHtml((receipt.publication_tag_object_sha || "").slice(0, 12))}) · ${escapeHtml(receipt.remote || "origin")}/${escapeHtml(receipt.branch || "-")}</p>`;
}
if (receipt.kind === "repository_state") {
const tag = receipt.tag_object ? ` · tag ${(receipt.tag_object || "").slice(0, 12)}` : "";
return `<p class="action-meta">Bound ${escapeHtml(receipt.repo || "-")} · HEAD ${escapeHtml((receipt.head || "").slice(0, 12))}${escapeHtml(tag)} · ${receipt.worktree_clean ? "clean" : "dirty"}</p>`;
}
return "";
}
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: commandRequestId });
clearInFlightRunCommand(commandKey, commandRequestId);
releaseState.currentRun = resumed;
renderReleaseRun(resumed);
} catch (error) {
if (deterministicRunCommandError(error)) clearInFlightRunCommand(commandKey, commandRequestId);
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 !== "running";
}
}
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: commandRequestId });
clearInFlightRunCommand(commandKey, commandRequestId);
releaseState.currentRun = retried;
renderReleaseRun(retried);
} catch (error) {
if (deterministicRunCommandError(error)) clearInFlightRunCommand(commandKey, commandRequestId);
elements.runOutput.insertAdjacentHTML("afterbegin", `<div class="action"><h3>${pill("error", "block")} Retry preparation failed</h3><p>${escapeHtml(error.message)}</p></div>`);
}
}
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: commandRequestId,
outcome,
confirm: confirmation,
});
clearInFlightRunCommand(commandKey, commandRequestId);
releaseState.currentRun = reconciled;
renderReleaseRun(reconciled);
} catch (error) {
if (deterministicRunCommandError(error)) clearInFlightRunCommand(commandKey, commandRequestId);
elements.runOutput.insertAdjacentHTML("afterbegin", `<div class="action"><h3>${pill("error", "block")} Reconciliation failed</h3><p>${escapeHtml(error.message)}</p></div>`);
button.disabled = false;
}
}
function readInFlightRunCommands() {
try {
const commands = JSON.parse(sessionStorage.getItem(pendingRunCommandsKey) || "{}");
if (!commands || typeof commands !== "object" || Array.isArray(commands)) return {};
const normalized = {};
for (const [commandKey, savedCommand] of Object.entries(commands)) {
const requestIdToReplay = typeof savedCommand === "string" ? savedCommand : savedCommand?.request_id;
if (typeof requestIdToReplay === "string") normalized[commandKey] = requestIdToReplay;
}
if (JSON.stringify(normalized) !== JSON.stringify(commands)) {
if (Object.keys(normalized).length) sessionStorage.setItem(pendingRunCommandsKey, JSON.stringify(normalized));
else sessionStorage.removeItem(pendingRunCommandsKey);
}
return normalized;
} catch (_error) {
sessionStorage.removeItem(pendingRunCommandsKey);
return {};
}
}
function pendingRunCommandRequest(commandKey, requestIdToReplay) {
if (typeof commandKey !== "string" || typeof requestIdToReplay !== "string") return null;
if (commandKey.startsWith("resume:")) {
const runId = commandKey.slice("resume:".length);
if (!runId || runId.includes(":")) return null;
return {
path: `/api/release-runs/${encodeURIComponent(runId)}/resume`,
body: { request_id: requestIdToReplay },
};
}
if (commandKey.startsWith("retry:")) {
const remainder = commandKey.slice("retry:".length);
const separator = remainder.indexOf(":");
const runId = remainder.slice(0, separator);
const stepId = remainder.slice(separator + 1);
if (separator < 1 || !stepId) return null;
return {
path: `/api/release-runs/${encodeURIComponent(runId)}/steps/${encodeURIComponent(stepId)}/retry`,
body: { request_id: requestIdToReplay },
};
}
if (commandKey.startsWith("reconcile:")) {
const remainder = commandKey.slice("reconcile:".length);
const runSeparator = remainder.indexOf(":");
const outcomeSeparator = remainder.lastIndexOf(":");
const runId = remainder.slice(0, runSeparator);
const stepId = remainder.slice(runSeparator + 1, outcomeSeparator);
const outcome = remainder.slice(outcomeSeparator + 1);
if (runSeparator < 1 || outcomeSeparator <= runSeparator + 1 || !["effect_absent", "effect_succeeded", "unresolved"].includes(outcome)) return null;
return {
path: `/api/release-runs/${encodeURIComponent(runId)}/steps/${encodeURIComponent(stepId)}/reconcile`,
body: { request_id: requestIdToReplay, outcome, confirm: "RECONCILE" },
};
}
if (commandKey.startsWith("execute:")) {
const remainder = commandKey.slice("execute:".length);
const runSeparator = remainder.indexOf(":");
const confirmationSeparator = remainder.lastIndexOf(":");
const runId = remainder.slice(0, runSeparator);
const stepId = remainder.slice(runSeparator + 1, confirmationSeparator);
const confirmation = remainder.slice(confirmationSeparator + 1);
if (runSeparator < 1 || confirmationSeparator <= runSeparator + 1 || !stepId) return null;
return {
path: `/api/release-runs/${encodeURIComponent(runId)}/steps/${encodeURIComponent(stepId)}/execute`,
body: { request_id: requestIdToReplay, confirm: confirmation === "NONE" ? "" : confirmation },
};
}
return null;
}
async function recoverPendingRunCommands() {
const commands = readInFlightRunCommands();
const entries = Object.entries(commands);
if (!entries.length) return null;
let attempted = false;
let recoveredRun = null;
let pendingError = null;
let rejectedError = null;
for (const [commandKey, savedCommand] of entries) {
const requestIdToReplay = typeof savedCommand === "string" ? savedCommand : savedCommand?.request_id;
if (typeof requestIdToReplay !== "string") {
delete commands[commandKey];
continue;
}
const command = pendingRunCommandRequest(commandKey, requestIdToReplay);
if (!command) {
clearInFlightRunCommand(commandKey, requestIdToReplay);
continue;
}
attempted = true;
try {
recoveredRun = await postJson(command.path, command.body);
clearInFlightRunCommand(commandKey, requestIdToReplay);
} catch (error) {
if (deterministicRunCommandError(error)) {
clearInFlightRunCommand(commandKey, requestIdToReplay);
rejectedError = error;
} else {
pendingError = error;
}
}
}
if (recoveredRun) {
releaseState.currentRun = recoveredRun;
restoreReleaseRunSelection(recoveredRun);
renderReleaseRun(recoveredRun);
elements.releaseRun.value = recoveredRun.run_id;
}
if (pendingError) {
elements.runOutput.insertAdjacentHTML("afterbegin", `<div class="action"><h3>${pill("pending", "warn")} Saved command recovery is still pending</h3><p>${escapeHtml(pendingError.message)}</p><p class="action-meta">The same private request identifier will be replayed after refresh.</p></div>`);
} else if (rejectedError) {
elements.runOutput.insertAdjacentHTML("afterbegin", `<div class="action"><h3>${pill("rejected", "block")} Saved command was not accepted</h3><p>${escapeHtml(rejectedError.message)}</p><p class="action-meta">The server returned a deterministic client conflict; the stale request identifier was cleared.</p></div>`);
}
return attempted ? Boolean(recoveredRun) : null;
}
function deterministicRunCommandError(error) {
return error?.status >= 400 && error.status < 500;
}
function inFlightRunCommandId(commandKey, prefix) {
const commands = readInFlightRunCommands();
if (typeof commands[commandKey] === "string") return commands[commandKey];
if (typeof commands[commandKey]?.request_id === "string") return commands[commandKey].request_id;
const identifier = requestId(prefix);
commands[commandKey] = identifier;
sessionStorage.setItem(pendingRunCommandsKey, JSON.stringify(commands));
return identifier;
}
function clearInFlightRunCommand(commandKey, requestIdToClear) {
const commands = readInFlightRunCommands();
const storedRequestId = typeof commands[commandKey] === "string" ? commands[commandKey] : commands[commandKey]?.request_id;
if (storedRequestId !== 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}`;
}
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("");
}
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 renderReleaseIntelligence(data) {
const summary = data.summary || {};
const catalog = data.catalog || {};
const keyring = data.keyring || {};
elements.releaseControlStatus.textContent = `${data.catalog_source || "local"} catalog / ${data.keyring_source || "local"} keyring`;
elements.releaseInsights.innerHTML = [
insight("Health", summary.status || "-"),
insight("Published Modules", summary.published_modules ?? 0),
insight("Catalog", catalog.generated_at ? `seq ${catalog.sequence || "-"}` : "-"),
insight("Core", catalog.core_version || "-"),
insight("Signatures", `${summary.trusted_signatures ?? 0}/${catalog.signature_count ?? 0} trusted`),
insight("Keys", `${keyring.active_key_count ?? 0}/${keyring.key_count ?? 0} active`),
insight("Catalog Drift", stateText(summary.catalog_matches_public)),
insight("Keyring Drift", stateText(summary.keyring_matches_public)),
].join("");
renderReleaseIssues(data.issues || []);
renderPublishedModules(data.modules || []);
renderCompatibility(data.compatibility || []);
}
function renderReleaseIssues(issues) {
if (!issues.length) {
elements.releaseIssues.innerHTML = `<div class="muted">No release health issues detected.</div>`;
return;
}
elements.releaseIssues.innerHTML = issues.map((issue) => {
const kind = issue.severity === "blocker" ? "block" : issue.severity === "warning" ? "warn" : "ok";
return `<div class="action">
<h3>${pill(issue.severity, kind)} ${escapeHtml(issue.code)}</h3>
<p>${escapeHtml(issue.message)}</p>
</div>`;
}).join("");
}
function renderPublishedModules(modules) {
elements.publishedModuleCount.textContent = `${modules.length} in channel`;
if (!modules.length) {
elements.publishedModules.innerHTML = `<tr><td colspan="7" class="muted">No published modules found in the selected channel.</td></tr>`;
return;
}
elements.publishedModules.innerHTML = modules.map((module) => {
const localState = module.local_matches_catalog === true ? pill(module.local_version, "ok") : module.local_matches_catalog === false ? pill(module.local_version || "differs", "warn") : pill("unknown", "warn");
const interfaces = `${(module.provides_interfaces || []).length} provided / ${(module.requires_interfaces || []).length} required`;
const refs = [module.python_tag ? `py ${module.python_tag}` : "", module.webui_tag ? `ui ${module.webui_tag}` : ""].filter(Boolean).join(", ") || "-";
return `<tr>
<td><strong>${escapeHtml(module.module_id || "-")}</strong><br><span class="muted">${escapeHtml(module.name || "")}</span></td>
<td>${escapeHtml(module.version || "-")}</td>
<td>${escapeHtml(module.repo || "-")}</td>
<td>${localState}</td>
<td>${escapeHtml(interfaces)}</td>
<td>${escapeHtml(module.migration_safety || "-")}</td>
<td>${escapeHtml(refs)}</td>
</tr>`;
}).join("");
}
function renderCompatibility(rows) {
const blockers = rows.filter((row) => row.severity === "blocker").length;
const warnings = rows.filter((row) => row.severity === "warning").length;
elements.compatibilityStatus.textContent = `${blockers} blockers / ${warnings} warnings / ${rows.length} requirements`;
if (!rows.length) {
elements.compatibilityRows.innerHTML = `<tr><td colspan="5" class="muted">No catalog interface requirements found.</td></tr>`;
return;
}
elements.compatibilityRows.innerHTML = rows.map((row) => {
const kind = row.severity === "blocker" ? "block" : row.severity === "warning" ? "warn" : "ok";
const providers = Array.isArray(row.satisfied_by) && row.satisfied_by.length
? row.satisfied_by.map((provider) => `${provider.module_id || provider.repo || "-"} ${provider.version || ""}`).join(", ")
: Array.isArray(row.providers) && row.providers.length
? row.providers.map((provider) => `${provider.module_id || provider.repo || "-"} ${provider.version || ""}`).join(", ")
: "-";
return `<tr>
<td>${escapeHtml(row.module_id || row.repo || "-")}</td>
<td>${escapeHtml(row.interface || "-")}</td>
<td>${escapeHtml(row.requirement || "any")}</td>
<td>${pill(row.status || "-", kind)}</td>
<td>${escapeHtml(providers)}</td>
</tr>`;
}).join("");
}
function renderReleaseUnits(data) {
const units = data.repositories
.filter((repo) => repo.spec.name !== "govoplan")
.sort((a, b) => a.spec.name.localeCompare(b.spec.name));
elements.releaseUnits.innerHTML = units.length
? units.map(releaseUnitRow).join("")
: `<tr><td colspan="9" class="muted">No repositories found in the dashboard response.</td></tr>`;
updateUnitCount();
for (const input of elements.releaseUnits.querySelectorAll("[data-release-check]")) {
input.addEventListener("change", () => {
const repo = input.dataset.repo;
releaseState.rows[repo] = { ...releaseState.rows[repo], selected: input.checked };
updateUnitCount();
});
}
for (const button of elements.releaseUnits.querySelectorAll("[data-target-action]")) {
button.addEventListener("click", () => {
const repo = button.dataset.repo;
const action = button.dataset.targetAction;
if (!repo || !action) return;
if (action === "manual") openManualTarget(repo);
else if (action === "reset") resetTarget(repo);
else bumpTarget(action, repo);
});
}
}
function releaseUnitRow(repo) {
const current = primaryVersion(repo);
const stored = releaseState.rows[repo.spec.name] || {};
const selected = stored.selected === true;
const hasStoredTarget = Object.prototype.hasOwnProperty.call(stored, "target");
const target = hasStoredTarget ? stored.target : defaultTargetFor(repo);
const git = gitStateText(repo);
const branchCell = `${escapeHtml(repo.branch || "-")}<br><span class="muted">${escapeHtml(repo.upstream || "no upstream")}</span>`;
const targetTag = targetTagFor(target);
const notes = [
current ? "" : "initial release",
repo.errors && repo.errors.length ? repo.errors.join("; ") : "",
repo.safe_directory_required ? "safe.directory required" : "",
].filter(Boolean).join("; ") || "-";
const severity = !repo.exists || !repo.is_git || repo.behind || !repo.has_head || repo.safe_directory_required ? "block" : repo.dirty_entries.length || repo.ahead ? "warn" : "ok";
releaseState.rows[repo.spec.name] = hasStoredTarget ? { selected, target } : { selected };
return `<tr>
<td><input class="unit-check" type="checkbox" data-release-check data-repo="${escapeAttr(repo.spec.name)}" ${selected ? "checked" : ""} /></td>
<td><strong>${escapeHtml(repo.spec.name)}</strong></td>
<td>${escapeHtml(repo.spec.category)} / ${escapeHtml(repo.spec.subtype)}</td>
<td>${current ? escapeHtml(current) : pill("not released", "warn")}</td>
<td>
<div class="target-cell">
<span class="target-value" data-release-target data-repo="${escapeAttr(repo.spec.name)}" data-current="${escapeAttr(current)}" data-target-value="${escapeAttr(target)}">${escapeHtml(target)}</span>
<div class="target-controls row-target-controls">
<button class="secondary" data-target-action="major" data-repo="${escapeAttr(repo.spec.name)}" title="Next major version">x..</button>
<button class="secondary" data-target-action="minor" data-repo="${escapeAttr(repo.spec.name)}" title="Next minor version">.x.</button>
<button class="secondary" data-target-action="patch" data-repo="${escapeAttr(repo.spec.name)}" title="Next subversion">..x</button>
<button class="secondary" data-target-action="manual" data-repo="${escapeAttr(repo.spec.name)}" title="Manual target version">m</button>
<button class="secondary" data-target-action="reset" data-repo="${escapeAttr(repo.spec.name)}" title="Reset row target">&#8630;</button>
</div>
</div>
</td>
<td><span class="target-tag-value" data-target-tag data-repo="${escapeAttr(repo.spec.name)}">${escapeHtml(targetTag)}</span></td>
<td>${branchCell}</td>
<td>${pill(git, severity)}</td>
<td>${escapeHtml(notes)}</td>
</tr>`;
}
function targetTagFor(target) {
return target ? `v${String(target).replace(/^v/, "")}` : "-";
}
function defaultTargetFor(repo) {
return primaryVersion(repo) || "0.1.0";
}
function primaryVersion(repo) {
const versions = repo.versions || {};
if (versions.pyproject) return versions.pyproject;
if (versions.package) return versions.package;
if (versions.webui_package) return versions.webui_package;
if (Array.isArray(versions.manifests) && versions.manifests.length) return versions.manifests[0];
return "";
}
function updateUnitCount() {
const total = elements.releaseUnits.querySelectorAll("[data-release-check]").length;
const selected = elements.releaseUnits.querySelectorAll("[data-release-check]:checked").length;
elements.unitCount.textContent = `${selected}/${total} selected`;
if (releaseState.dashboard) renderWorkflowStages(releaseState.dashboard);
}
function bumpTarget(kind, repoName) {
const base = baseVersionForTarget(repoName);
const parsed = parseVersionCore(base);
const next = parsed ? bumpParsedVersion(parsed, kind) : "0.1.0";
setRowTarget(repoName, next);
elements.statusLine.textContent = `${repoName} target set to ${next}`;
}
function bumpParsedVersion(parsed, kind) {
const next = [...parsed];
if (kind === "major") {
next[0] += 1;
next[1] = 0;
next[2] = 0;
} else if (kind === "minor") {
next[1] += 1;
next[2] = 0;
} else {
next[2] += 1;
}
return next.join(".");
}
function baseVersionForTarget(repoName) {
return currentVersionForRepo(repoName) || "0.0.0";
}
function openManualTarget(repoName) {
releaseState.manualRepo = repoName;
const base = baseVersionForTarget(repoName);
elements.manualTargetHelp.textContent = `Enter a version for ${repoName} higher than ${base} by its first three numbers. Suffixes like -alpha1 are allowed.`;
elements.manualTargetInput.value = currentRowTarget(repoName);
elements.manualTargetModal.hidden = false;
validateManualTarget();
elements.manualTargetInput.focus();
}
function closeManualTarget() {
elements.manualTargetModal.hidden = true;
releaseState.manualRepo = "";
}
function applyManualTarget() {
if (!validateManualTarget()) return;
const repoName = releaseState.manualRepo;
setRowTarget(repoName, elements.manualTargetInput.value.trim());
closeManualTarget();
elements.statusLine.textContent = `${repoName} target set to ${currentRowTarget(repoName)}`;
}
function validateManualTarget() {
const value = elements.manualTargetInput.value.trim();
const parsed = parseVersionCore(value);
const base = parseVersionCore(baseVersionForTarget(releaseState.manualRepo)) || [0, 0, 0];
let message = "";
if (!parsed) {
message = "Use a version like 0.2.0 or 0.2.0-alpha1.";
} else if (!versionCoreGreater(parsed, base)) {
message = `Version must be higher than ${base.join(".")} by the first three numbers.`;
}
elements.manualTargetError.textContent = message;
elements.applyManualTarget.disabled = Boolean(message);
return !message;
}
function resetTarget(repoName) {
const row = { ...releaseState.rows[repoName] };
delete row.target;
releaseState.rows[repoName] = row;
const repo = repoSnapshot(repoName);
const target = repo ? defaultTargetFor(repo) : "0.1.0";
const element = rowTargetElement(repoName);
if (element) {
element.dataset.targetValue = target;
element.textContent = target;
}
setRowTargetTag(repoName, target);
elements.statusLine.textContent = `${repoName} target reset.`;
}
function currentRowTarget(repoName) {
return rowTargetElement(repoName)?.dataset.targetValue?.trim() || releaseState.rows[repoName]?.target || "";
}
function setRowTarget(repoName, value) {
releaseState.rows[repoName] = { ...releaseState.rows[repoName], target: value };
const element = rowTargetElement(repoName);
if (element) {
element.dataset.targetValue = value;
element.textContent = value;
}
setRowTargetTag(repoName, value);
}
function rowTargetElement(repoName) {
return elements.releaseUnits.querySelector(`[data-release-target][data-repo="${cssEscape(repoName)}"]`);
}
function setRowTargetTag(repoName, target) {
const element = rowTargetTagElement(repoName);
if (element) element.textContent = targetTagFor(target);
}
function rowTargetTagElement(repoName) {
return elements.releaseUnits.querySelector(`[data-target-tag][data-repo="${cssEscape(repoName)}"]`);
}
function repoSnapshot(repoName) {
return releaseState.dashboard?.repositories.find((repo) => repo.spec.name === repoName) || null;
}
function currentVersionForRepo(repoName) {
const repo = repoSnapshot(repoName);
return repo ? primaryVersion(repo) : "";
}
function parseVersionCore(value) {
const match = String(value || "").match(/^v?(\d+)\.(\d+)\.(\d+)(?:[-+][0-9A-Za-z.-]+)?$/);
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
}
function versionCoreGreater(candidate, base) {
for (let index = 0; index < 3; index += 1) {
if (candidate[index] > base[index]) return true;
if (candidate[index] < base[index]) return false;
}
return false;
}
function metric(label, value) {
return `<div class="metric"><span>${escapeHtml(label)}</span><strong>${escapeHtml(String(value))}</strong></div>`;
}
function insight(label, value) {
return `<div class="insight"><span>${escapeHtml(label)}</span><strong>${escapeHtml(String(value))}</strong></div>`;
}
function stateText(value) {
if (value === true) return "matches";
if (value === false) return "differs";
return "not checked";
}
function repoRow(repo) {
const version = primaryVersion(repo) || "-";
const branch = repo.upstream ? `${repo.branch || "-"} -> ${repo.upstream}` : repo.branch || "-";
const git = gitStateText(repo);
const tag = repo.local_target_tag_exists === null
? "-"
: repo.local_target_tag_exists
? pill("local", "ok")
: pill("missing", "warn");
const notes = [
repo.errors && repo.errors.length ? repo.errors.join("; ") : "",
repo.safe_directory_required ? "safe.directory required" : "",
].filter(Boolean).join("; ") || "-";
const severity = !repo.exists || !repo.is_git || repo.behind || repo.safe_directory_required ? "block" : repo.dirty_entries.length || !repo.has_head || repo.ahead ? "warn" : "ok";
return `<tr>
<td><strong>${escapeHtml(repo.spec.name)}</strong></td>
<td>${escapeHtml(repo.spec.category)} / ${escapeHtml(repo.spec.subtype)}</td>
<td>${escapeHtml(branch)}</td>
<td>${escapeHtml(version)}</td>
<td>${pill(git, severity)}</td>
<td>${tag}</td>
<td>${escapeHtml(notes)}</td>
</tr>`;
}
function gitStateText(repo) {
if (!repo.exists || !repo.is_git) return "missing";
const parts = [];
if (repo.has_head) parts.push(repo.head || "HEAD");
else parts.push("no HEAD");
if (repo.dirty_entries.length) parts.push(`${repo.dirty_entries.length} dirty/untracked`);
if (repo.ahead) parts.push(`${repo.ahead} unpushed`);
if (repo.behind) parts.push(`${repo.behind} behind`);
if (repo.has_head && repo.branch && !repo.upstream) parts.push("no upstream");
return parts.join(", ") || "clean";
}
function renderCatalog(catalog, targetVersion) {
elements.catalogStatus.textContent = catalog.catalog_exists ? "local file found" : "missing";
elements.catalog.innerHTML = [
kv("Channel", catalog.channel),
kv("Core release", `${catalog.core_release_version || "-"}${targetVersion && catalog.core_release_version !== targetVersion ? " (not target)" : ""}`),
kv("Modules", catalog.module_count),
kv("Signatures", catalog.signature_count),
kv("Keys", catalog.key_count),
kv("Generated", catalog.generated_at || "-"),
kv("Expires", catalog.expires_at || "-"),
kv("Public catalog", catalog.public_checked ? publicState(catalog.public_catalog_exists, catalog.catalog_matches_public) : "not checked"),
kv("Public keyring", catalog.public_checked ? publicState(catalog.public_keyring_exists, catalog.keyring_matches_public) : "not checked"),
kv("Public core", catalog.public_core_release_version || "-"),
kv("Catalog", catalog.catalog_path),
kv("Keyring", catalog.keyring_path),
kv("Public URL", catalog.public_catalog_url || "-"),
kv("Keyring URL", catalog.public_keyring_url || "-"),
].join("");
}
function renderPlan(plan) {
elements.planStatus.textContent = plan.status;
const units = plan.units || [];
const compatibility = plan.compatibility || [];
const steps = plan.dry_run_steps || [];
const findings = plan.gate_findings || [];
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>`;
return;
}
const recommendationKind = plan.status === "blocked" ? "block" : plan.source_preflight_ready ? "ok" : "warn";
const recommendationHtml = recommendation ? `<div class="action recommended">
<h3>${pill("recommended next", recommendationKind)} ${escapeHtml(recommendation.title)}</h3>
<p>${escapeHtml(recommendation.detail)}</p>
<p class="action-meta"><strong>How to continue:</strong> ${escapeHtml(recommendation.remediation)}</p>
</div>` : "";
const findingHtml = findings.map((finding) => {
const kind = finding.severity === "blocker" ? "block" : finding.severity === "warning" ? "warn" : "ok";
const source = finding.source ? `${finding.source}: ${finding.actual || "-"} -> ${finding.expected || "-"}` : "";
return `<div class="action">
<h3>${pill(finding.severity, kind)} ${escapeHtml(finding.repo || "release")} · ${escapeHtml(finding.code)}</h3>
<p>${escapeHtml(finding.message)}</p>
${source ? `<p class="action-meta">${escapeHtml(source)}</p>` : ""}
<p class="action-meta"><strong>Remediation:</strong> ${escapeHtml(finding.remediation)}</p>
</div>`;
}).join("");
const unitHtml = units.map((unit) => {
const blockers = unit.blockers.length ? `<p>${escapeHtml(unit.blockers.join("; "))}</p>` : "";
const warnings = unit.warnings.length ? `<p>${escapeHtml(unit.warnings.join("; "))}</p>` : "";
const kind = unit.status === "blocked" ? "block" : unit.status === "attention" ? "warn" : "ok";
return `<div class="action">
<h3>${pill(unit.status, kind)} ${escapeHtml(unit.repo)} ${escapeHtml(unit.current_version || "-")} -> ${escapeHtml(unit.target_version)}</h3>
${blockers}${warnings}
</div>`;
}).join("");
const compatibilityHtml = compatibility.map((issue) => {
const kind = issue.severity === "blocker" ? "block" : issue.severity === "warning" ? "warn" : "ok";
return `<div class="action">
<h3>${pill(issue.severity, kind)} ${escapeHtml(issue.code)}</h3>
<p>${escapeHtml(issue.message)}</p>
</div>`;
}).join("");
const stepHtml = steps.map((step) => {
const command = step.command ? `<pre>${escapeHtml(step.command)}</pre>` : "";
const kind = step.status === "planned" ? "ok" : step.status.includes("blocked") ? "block" : "warn";
return `<div class="action">
<h3>${pill(step.status, kind)} ${escapeHtml(step.title)}</h3>
<p>${escapeHtml(step.detail)}</p>
${command}
</div>`;
}).join("");
elements.actions.innerHTML = `
${recommendationHtml}
${findingHtml}
${unitHtml}
${compatibilityHtml}
${stepHtml}
`;
}
function renderIdlePlan() {
elements.planStatus.textContent = "idle";
elements.actions.innerHTML = `<div class="muted">Select repositories above, adjust target versions, then build a plan.</div>`;
}
async function generateCandidate() {
elements.workflowStatus.textContent = "Generating...";
setBusy([elements.generateCandidate, elements.generateSelectedCandidate], true);
try {
const repoVersions = selectedRepoVersions();
if (!Object.keys(repoVersions).length) {
throw new Error("Select at least one repository first.");
}
const body = {
repo_versions: repoVersions,
channel: elements.channel.value.trim() || "stable",
signing_keys: elements.signingKey.value.trim() ? [elements.signingKey.value.trim()] : [],
check_public: elements.publicCatalog.checked,
};
const result = await postJson("/api/catalog-candidates", body);
elements.candidateDir.value = result.candidate_dir || "";
elements.workflowStatus.textContent = result.status;
renderWorkflowResult("Candidate generated", result);
await load();
} catch (error) {
elements.workflowStatus.textContent = "error";
elements.workflowOutput.innerHTML = `<div class="action"><h3>${pill("error", "block")} Candidate generation failed</h3><p>${escapeHtml(error.message)}</p></div>`;
} finally {
setBusy([elements.generateCandidate, elements.generateSelectedCandidate], false);
}
}
async function buildSelectedPlan() {
elements.planStatus.textContent = "Planning...";
setBusy([elements.buildSelectedPlan], true);
try {
if (!Object.keys(selectedRepoVersions()).length) {
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>`;
return;
}
const plan = await api("/api/selective-plan");
renderPlan(plan);
elements.planStatus.textContent = plan.status;
} catch (error) {
elements.planStatus.textContent = "error";
elements.actions.innerHTML = `<div class="action"><h3>${pill("error", "block")} Plan failed</h3><p>${escapeHtml(error.message)}</p></div>`;
} finally {
setBusy([elements.buildSelectedPlan], false);
}
}
function selectChangedUnits() {
if (!releaseState.dashboard) return;
let count = 0;
for (const repo of releaseState.dashboard.repositories) {
const current = primaryVersion(repo);
const changed = repo.dirty_entries.length || repo.ahead || !repo.has_head;
if (changed && repo.spec.name !== "govoplan") count += 1;
releaseState.rows[repo.spec.name] = {
...releaseState.rows[repo.spec.name],
selected: Boolean(changed),
target: releaseState.rows[repo.spec.name]?.target || current || "0.1.0",
};
}
renderReleaseUnits(releaseState.dashboard);
elements.statusLine.textContent = `Selected ${count} dirty/changed repositories. Dirty files still need a commit before they can be pushed.`;
}
function selectUnpushedUnits() {
if (!releaseState.dashboard) return;
let count = 0;
for (const repo of releaseState.dashboard.repositories) {
const current = primaryVersion(repo);
const unpushed = repo.has_head && repo.branch && (!repo.upstream || Number(repo.ahead || 0) > 0);
if (unpushed && repo.spec.name !== "govoplan") count += 1;
releaseState.rows[repo.spec.name] = {
...releaseState.rows[repo.spec.name],
selected: Boolean(unpushed),
target: releaseState.rows[repo.spec.name]?.target || current || "0.1.0",
};
}
renderReleaseUnits(releaseState.dashboard);
elements.statusLine.textContent = `Selected ${count} repositories with committed branch work to push.`;
}
function selectUnreleasedUnits() {
if (!releaseState.dashboard) return;
for (const repo of releaseState.dashboard.repositories) {
if (repo.spec.name === "govoplan") continue;
const unreleased = !primaryVersion(repo);
releaseState.rows[repo.spec.name] = {
...releaseState.rows[repo.spec.name],
selected: unreleased,
target: releaseState.rows[repo.spec.name]?.target || "0.1.0",
};
}
renderReleaseUnits(releaseState.dashboard);
}
function clearSelectedUnits() {
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() {
return Array.from(elements.releaseUnits.querySelectorAll("[data-release-check]:checked"))
.map((row) => row.dataset.repo)
.filter(Boolean);
}
function selectedRepoVersions() {
const result = {};
for (const row of elements.releaseUnits.querySelectorAll("[data-release-check]")) {
if (!row.checked) continue;
const repo = row.dataset.repo;
const target = elements.releaseUnits.querySelector(`[data-release-target][data-repo="${cssEscape(repo)}"]`)?.dataset.targetValue?.trim();
if (repo && target) result[repo] = target;
}
return result;
}
async function prepareSelectedRepos(options) {
const repos = selectedRepoNames();
if (!repos.length) {
elements.prepareStatus.textContent = "idle";
elements.prepareOutput.innerHTML = `<div class="action"><h3>${pill("idle", "warn")} No repositories selected</h3><p>Select one or more repositories first.</p></div>`;
return;
}
elements.prepareStatus.textContent = options.apply ? "Committing..." : "Previewing...";
setBusy([elements.previewPrepare, elements.commitPrepare], true);
try {
const result = await postJson("/api/repositories/prepare", {
repos,
repo_versions: selectedRepoVersions(),
message: elements.prepareMessage.value.trim() || null,
apply: options.apply === true,
confirm: elements.prepareConfirmText.value.trim(),
});
elements.prepareStatus.textContent = result.status;
renderPrepareResult(result);
await load();
} catch (error) {
elements.prepareStatus.textContent = "error";
elements.prepareOutput.innerHTML = `<div class="action"><h3>${pill("error", "block")} Prepare failed</h3><p>${escapeHtml(error.message)}</p></div>`;
} finally {
setBusy([elements.previewPrepare, elements.commitPrepare], false);
}
}
function renderPrepareResult(result) {
const rows = Array.isArray(result.repositories) ? result.repositories : [];
if (!rows.length) {
elements.prepareOutput.innerHTML = `<div class="muted">No prepare actions returned.</div>`;
return;
}
elements.prepareOutput.innerHTML = rows.map((row) => {
const kind = row.status === "blocked" || row.status === "failed" ? "block" : row.status === "committed" || row.status === "planned" ? "ok" : "warn";
const entries = Array.isArray(row.dirty_entries) ? row.dirty_entries : [];
const shown = entries.slice(0, 10).map((entry) => `<p>${escapeHtml(entry)}</p>`).join("");
const more = entries.length > 10 ? `<p>${escapeHtml(`... ${entries.length - 10} more`)}</p>` : "";
const after = row.after_head ? ` | new HEAD ${row.after_head} | ${row.after_ahead ?? 0} ahead | ${row.after_dirty_count ?? 0} dirty` : "";
const command = row.command && row.status !== "noop" ? `<pre>${escapeHtml(row.command)}</pre>` : "";
const output = [row.stdout, row.stderr].filter(Boolean).map((value) => `<pre>${escapeHtml(value)}</pre>`).join("");
return `<div class="action">
<h3>${pill(row.status, kind)} ${escapeHtml(row.repo)}</h3>
<p>${escapeHtml(row.detail || "")}</p>
<p class="action-meta">${escapeHtml(`${row.branch || "-"} -> ${row.upstream || "-"} | ${row.dirty_count ?? 0} dirty${after}`)}</p>
${row.message ? `<p class="action-meta">${escapeHtml(row.message)}</p>` : ""}
${shown}${more}
${command}
${output}
</div>`;
}).join("");
}
async function releaseSelectedTags(options) {
const repos = selectedRepoNames();
if (!repos.length) {
elements.tagStatus.textContent = "idle";
elements.tagOutput.innerHTML = `<div class="action"><h3>${pill("idle", "warn")} No repositories selected</h3><p>Select one or more repositories first.</p></div>`;
return;
}
elements.tagStatus.textContent = options.apply ? (options.push ? "Publishing..." : "Tagging...") : "Previewing...";
setBusy([elements.previewReleaseTags, elements.createReleaseTags, elements.publishReleaseTags], true);
try {
const result = await postJson("/api/repositories/tag", {
repos,
repo_versions: selectedRepoVersions(),
remote: elements.pushRemote.value.trim() || "origin",
message: elements.tagMessage.value.trim() || null,
apply: options.apply === true,
push: options.push === true,
confirm: elements.tagConfirmText.value.trim(),
});
elements.tagStatus.textContent = result.status;
renderTagResult(result);
await load();
} catch (error) {
elements.tagStatus.textContent = "error";
elements.tagOutput.innerHTML = `<div class="action"><h3>${pill("error", "block")} Source release failed</h3><p>${escapeHtml(error.message)}</p></div>`;
} finally {
setBusy([elements.previewReleaseTags, elements.createReleaseTags, elements.publishReleaseTags], false);
}
}
function renderTagResult(result) {
const rows = Array.isArray(result.repositories) ? result.repositories : [];
if (!rows.length) {
elements.tagOutput.innerHTML = `<div class="muted">No source release actions returned.</div>`;
return;
}
elements.tagOutput.innerHTML = rows.map((row) => {
const kind = row.status === "blocked" || row.status === "failed" ? "block" : row.status === "published" || row.status === "tagged" || row.status === "planned" ? "ok" : "warn";
const commands = [row.create_command, row.publish_command]
.filter(Boolean)
.map((command) => `<pre>${escapeHtml(command)}</pre>`)
.join("");
const output = [row.stdout, row.stderr].filter(Boolean).map((value) => `<pre>${escapeHtml(value)}</pre>`).join("");
const meta = [
row.tag,
row.branch ? `branch ${row.branch}` : "",
row.head ? `HEAD ${row.head}` : "",
row.remote ? `remote ${row.remote}` : "",
].filter(Boolean).join(" | ");
return `<div class="action">
<h3>${pill(row.status, kind)} ${escapeHtml(row.repo)}</h3>
<p>${escapeHtml(row.detail || "")}</p>
${meta ? `<p class="action-meta">${escapeHtml(meta)}</p>` : ""}
${commands}
${output}
</div>`;
}).join("");
}
async function syncSelectedRepos(options) {
const repos = selectedRepoNames();
if (!repos.length) {
elements.pushStatus.textContent = "idle";
elements.pushOutput.innerHTML = `<div class="action"><h3>${pill("idle", "warn")} No repositories selected</h3><p>Select one or more repositories first.</p></div>`;
return;
}
elements.pushStatus.textContent = options.apply ? "Syncing..." : "Previewing sync...";
setBusy([elements.previewRepoSync, elements.syncRepos, elements.previewRepoPush, elements.pushRepos], true);
try {
const result = await postJson("/api/repositories/sync", {
repos,
remote: elements.pushRemote.value.trim() || "origin",
apply: options.apply === true,
confirm: elements.pushConfirmText.value.trim(),
});
elements.pushStatus.textContent = result.status;
renderSyncResult(result);
await load();
} catch (error) {
elements.pushStatus.textContent = "error";
elements.pushOutput.innerHTML = `<div class="action"><h3>${pill("error", "block")} Sync failed</h3><p>${escapeHtml(error.message)}</p></div>`;
} finally {
setBusy([elements.previewRepoSync, elements.syncRepos, elements.previewRepoPush, elements.pushRepos], false);
}
}
async function pushSelectedRepos(options) {
const repos = selectedRepoNames();
if (!repos.length) {
elements.pushStatus.textContent = "idle";
elements.pushOutput.innerHTML = `<div class="action"><h3>${pill("idle", "warn")} No repositories selected</h3><p>Select one or more repositories first.</p></div>`;
return;
}
elements.pushStatus.textContent = options.apply ? "Pushing..." : "Previewing...";
setBusy([elements.previewRepoSync, elements.syncRepos, elements.previewRepoPush, elements.pushRepos], true);
try {
const result = await postJson("/api/repositories/push", {
repos,
remote: elements.pushRemote.value.trim() || "origin",
apply: options.apply === true,
confirm: elements.pushConfirmText.value.trim(),
});
elements.pushStatus.textContent = result.status;
renderPushResult(result);
await load();
} catch (error) {
elements.pushStatus.textContent = "error";
elements.pushOutput.innerHTML = `<div class="action"><h3>${pill("error", "block")} Push failed</h3><p>${escapeHtml(error.message)}</p></div>`;
} finally {
setBusy([elements.previewRepoSync, elements.syncRepos, elements.previewRepoPush, elements.pushRepos], false);
}
}
function renderSyncResult(result) {
const rows = Array.isArray(result.repositories) ? result.repositories : [];
if (!rows.length) {
elements.pushOutput.innerHTML = `<div class="muted">No sync actions returned.</div>`;
return;
}
elements.pushOutput.innerHTML = rows.map((row) => {
const kind = row.status === "blocked" || row.status === "failed" ? "block" : row.status === "synced" || row.status === "planned" ? "ok" : "warn";
const before = `${row.ahead ?? 0} ahead / ${row.behind ?? 0} behind / ${row.dirty_count ?? 0} dirty`;
const after = row.after_ahead !== undefined ? ` -> ${row.after_ahead} ahead / ${row.after_behind ?? 0} behind / ${row.after_dirty_count ?? 0} dirty` : "";
const command = row.command ? `<pre>${escapeHtml(row.command)}</pre>` : "";
const output = [row.stdout, row.stderr].filter(Boolean).map((value) => `<pre>${escapeHtml(value)}</pre>`).join("");
return `<div class="action">
<h3>${pill(row.status, kind)} ${escapeHtml(row.repo)}</h3>
<p>${escapeHtml(row.detail || "")}</p>
<p class="action-meta">${escapeHtml(`${row.branch || "-"} -> ${row.upstream || row.remote || "-"} | ${before}${after}`)}</p>
${command}
${output}
</div>`;
}).join("");
}
function renderPushResult(result) {
const rows = Array.isArray(result.repositories) ? result.repositories : [];
if (!rows.length) {
elements.pushOutput.innerHTML = `<div class="muted">No push actions returned.</div>`;
return;
}
const summary = result.status === "noop"
? `<div class="action"><h3>${pill("noop", "warn")} Nothing pushed</h3><p>No selected repository had committed local commits ahead of its upstream. Dirty/untracked files are not included in Git pushes.</p></div>`
: "";
elements.pushOutput.innerHTML = summary + rows.map((row) => {
const kind = row.status === "blocked" || row.status === "failed" ? "block" : row.status === "planned" || row.status === "pushed" ? "ok" : "warn";
const meta = [
row.branch ? `branch ${row.branch}` : "",
row.upstream ? `upstream ${row.upstream}` : row.push_target ? `target ${row.push_target}` : "",
row.ahead !== undefined ? `${row.ahead} ahead` : "",
row.behind ? `${row.behind} behind` : "",
row.dirty_count ? `${row.dirty_count} dirty/untracked` : "clean worktree",
].filter(Boolean).join(" | ");
const command = row.command && row.status !== "noop" ? `<pre>${escapeHtml(row.command)}</pre>` : "";
const output = [row.stdout, row.stderr].filter(Boolean).map((value) => `<pre>${escapeHtml(value)}</pre>`).join("");
return `<div class="action">
<h3>${pill(row.status, kind)} ${escapeHtml(row.repo)}</h3>
<p>${escapeHtml(row.detail || "")}</p>
${meta ? `<p class="action-meta">${escapeHtml(meta)}</p>` : ""}
${command}
${output}
</div>`;
}).join("");
}
async function publishCandidate(options) {
const candidateDir = elements.candidateDir.value.trim();
if (!candidateDir) {
elements.workflowOutput.innerHTML = `<div class="action"><h3>${pill("blocked", "block")} Candidate dir required</h3><p>Select or generate a candidate first.</p></div>`;
return;
}
elements.workflowStatus.textContent = "Running...";
setBusy([elements.previewPublish, elements.applyPublish, elements.pushPublish], true);
try {
const result = await postJson("/api/catalog-candidates/publish", {
candidate_dir: candidateDir,
channel: elements.channel.value.trim() || "stable",
confirm: elements.confirmText.value.trim(),
...options,
});
elements.workflowStatus.textContent = result.status;
renderWorkflowResult("Publish result", result);
await load();
} catch (error) {
elements.workflowStatus.textContent = "error";
elements.workflowOutput.innerHTML = `<div class="action"><h3>${pill("error", "block")} Publish failed</h3><p>${escapeHtml(error.message)}</p></div>`;
} finally {
setBusy([elements.previewPublish, elements.applyPublish, elements.pushPublish], false);
}
}
function renderWorkflowResult(title, result) {
const lines = [];
for (const key of ["status", "candidate_dir", "catalog_path", "keyring_path", "target_catalog_path", "tag_name", "validation_valid", "catalog_changed", "keyring_changed"]) {
if (result[key] !== undefined && result[key] !== null) lines.push(kv(key, result[key]));
}
const changes = Array.isArray(result.changes)
? result.changes.slice(0, 8).map((item) => `<p>${escapeHtml(`${item.repo}:${item.module_id || "-"} ${item.field} ${item.before || "-"} -> ${item.after || "-"}`)}</p>`).join("")
: "";
const steps = Array.isArray(result.steps)
? result.steps.map((step) => `<div class="action"><h3>${pill(step.status, step.status === "blocked" ? "block" : step.status === "planned" ? "warn" : "ok")} ${escapeHtml(step.title)}</h3><p>${escapeHtml(step.detail)}</p>${step.command ? `<pre>${escapeHtml(step.command)}</pre>` : ""}</div>`).join("")
: "";
const notes = Array.isArray(result.notes)
? result.notes.map((note) => `<p>${escapeHtml(note)}</p>`).join("")
: "";
const resultKind = result.status === "blocked" || result.status === "failed" || result.status === "partial"
? "block"
: result.status === "ready" || result.status === "published" || result.status === "applied"
? "ok"
: "warn";
const noteDetails = notes
? `<div class="action"><details open><summary>Validation and provenance details</summary>${notes}</details></div>`
: "";
elements.workflowOutput.innerHTML = `<div class="action"><h3>${pill(result.status || "result", resultKind)} ${escapeHtml(title)}</h3>${lines.join("")}${changes}</div>${noteDetails}${steps}`;
}
function setBusy(buttons, busy) {
for (const button of buttons) {
button.disabled = busy || button.dataset.releaseDisabled === "true";
}
}
function publicState(exists, matches) {
if (!exists) return "missing";
if (matches === true) return "matches local";
if (matches === false) return "differs from local";
return "available";
}
function kv(key, value) {
return `<div class="kv"><span>${escapeHtml(key)}</span><code>${escapeHtml(String(value))}</code></div>`;
}
function pill(text, kind) {
return `<span class="pill ${kind}">${escapeHtml(String(text))}</span>`;
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function escapeAttr(value) {
return escapeHtml(value);
}
function cssEscape(value) {
if (window.CSS && CSS.escape) return CSS.escape(value);
return String(value).replaceAll('"', '\\"');
}
load();
</script>
</body>
</html>