fix: reconcile server deployments across repository views
ForgeFlow quality gate / quality (push) Canceled after 0s

This commit is contained in:
NuklearRabbit
2026-08-01 18:57:55 +02:00
parent 958d5b84d3
commit f7d6bc374f
11 changed files with 86 additions and 32 deletions
+31 -4
View File
@@ -384,15 +384,41 @@ function createUnraidInventoryMethods({
};
}
async scanServerInventory(serverId, repositories) {
async scanServerInventory(serverId, repositories, { autoLink = false } = {}) {
const { server, inventory, workloads } = await this.collectServerInventory(serverId, repositories);
const response = this.inventoryResponse(server, inventory, workloads);
let adopted = 0;
const adoptedLinks = [];
if (autoLink) {
const plan = this.reconciliationPlan(server, workloads, repositories, { autoLink: true });
if (plan.additions.length) await this.store.createRecoverySnapshot?.(`automatic-server-links-${serverId}`);
const linkedRepositories = new Set(workloads
.filter((workload) => workload.link?.repositoryFullName)
.map((workload) => String(workload.link.repositoryFullName).toLowerCase()));
for (const addition of plan.additions) {
const workload = workloads.find((item) => item.workloadId === addition.workloadId);
const repository = (repositories || []).find((item) => String(item.fullName).toLowerCase() === String(addition.repositoryFullName).toLowerCase());
const key = String(repository?.fullName || "").toLowerCase();
if (!workload || !repository || linkedRepositories.has(key)) continue;
const linkSource = addition.evidence === "exact-provenance" ? "automatic" : "automatic-runtime-identity";
const profile = this.profileFromWorkload(repository, server, workload, { linkSource, deploymentMode: "server-git" });
const saved = await this.store.saveDeploymentProfile(repository.fullName, profile);
await this.saveWorkloadState(saved, workload, server);
workload.status = "linked";
workload.link = { status: "linked", profileId: saved.id, repositoryFullName: repository.fullName, source: linkSource };
linkedRepositories.add(key);
adopted += 1;
adoptedLinks.push({ repositoryFullName: repository.fullName, profileId: saved.id, workloadId: workload.workloadId });
}
}
const response = this.inventoryResponse(server, inventory, workloads, { adopted });
await this.diagnostics?.info("unraid.workloads.scanned", {
serverId,
detected: response.detected,
linked: response.linked,
needsReview: response.needsReview,
readOnly: true,
adopted,
adoptedLinks,
readOnly: !autoLink,
});
return response;
}
@@ -428,6 +454,7 @@ function createUnraidInventoryMethods({
evidence: candidate.exact ? "exact-provenance" : "exact-runtime-identity",
impact: "Create a server-pull deployment profile; no container changes",
});
linkedRepositories.add(String(candidate.repositoryFullName).toLowerCase());
} else if (["suggested", "ambiguous"].includes(workload.status) || (workload.runtime?.running && workload.candidates?.length)) {
conflicts.push({
workloadId: workload.workloadId,
@@ -559,7 +586,7 @@ function createUnraidInventoryMethods({
}
async discoverServerWorkloads(serverId, repositories) {
return this.scanServerInventory(serverId, repositories);
return this.scanServerInventory(serverId, repositories, { autoLink: true });
}
async linkServerWorkload({ repository, serverId, workloadId, deploymentMode = "server-git", remoteFolder = "" }) {
+1 -1
View File
@@ -5,7 +5,7 @@ function createMockRepositoryBridge(context) {
await wait(80);
snapshot();
return {
appVersion: "0.10.6-demo",
appVersion: "0.10.7-demo",
platform: "win32",
state: clone(state),
git: { available: true, version: "git version 2.47.3" },
+12 -2
View File
@@ -31,6 +31,9 @@ function renderTitlebar() {
function renderRepositoryRow(repository) {
const status = repository.localStatus;
const profiles = repository.deploymentProfiles || [];
const workloads = linkedWorkloadsForRepository(repository);
const runningWorkloads = workloads.filter((workload) => workload.runtime?.running);
const badges = [];
if (status?.counts.conflicts)
badges.push('<span class="mini-badge danger" title="Conflicts">!</span>');
@@ -50,10 +53,14 @@ function renderRepositoryRow(repository) {
badges.push(
'<span class="mini-badge success" title="Ready to deploy">↗</span>',
);
if (profiles.length)
badges.push(
`<span class="mini-badge ${runningWorkloads.length ? "success" : "warning"} deployment-badge" title="${attr(`${profiles.length} server deployment${profiles.length === 1 ? "" : "s"} linked${runningWorkloads.length ? ` · ${runningWorkloads.length} running` : ""}`)}">S${profiles.length}</span>`,
);
if (!repository.localPath)
badges.push('<span class="mini-badge" title="No local folder">—</span>');
const branch = status?.branch.head || repository.defaultBranch || "remote";
return `<button class="repo-row ${String(repository.id) === String(ui.selectedRepoId) ? "active" : ""} ${repository.attention ? "attention" : ""}" data-action="select-repo" data-id="${attr(repository.id)}">
return `<button class="repo-row ${String(repository.id) === String(ui.selectedRepoId) ? "active" : ""} ${repository.attention ? "attention" : ""}" data-action="select-repo" data-id="${attr(repository.id)}" data-deployment-count="${profiles.length}">
<span class="repo-icon">${repository.favorite ? icon("star") : icon(repository.localPath ? "git" : "cloud")}</span>
<span class="repo-main"><span class="repo-name">${escapeHtml(repository.name)}</span><span class="repo-sub"><span>${escapeHtml(branch)}</span>${status?.shortHead ? `<span>• ${escapeHtml(status.shortHead)}</span>` : ""}</span></span>
<span class="repo-badges">${badges.join("")}</span>
@@ -432,6 +439,7 @@ function renderRepositoryWorkspace(repository) {
const profiles = repository.deploymentProfiles || [];
const linkedWorkloads = linkedWorkloadsForRepository(repository);
const profile = selectedProfile(repository);
const profileWorkload = linkedWorkloads.find((workload) => workload.link?.profileId === profile?.id);
const serverState = profile?.state || {};
const localTone = status?.counts.conflicts
? "danger"
@@ -452,6 +460,8 @@ function renderRepositoryWorkspace(repository) {
? "danger"
: serverState.healthy === true
? "success"
: profileWorkload?.runtime?.running
? "success"
: "";
const content = (
{
@@ -474,7 +484,7 @@ function renderRepositoryWorkspace(repository) {
: "";
return `<div class="repo-workspace"><header class="repo-header illustrated-repo-header"><div class="repo-heading"><h1><button class="favorite-button ${repository.favorite ? "active" : ""}" data-action="toggle-favorite" title="Toggle favorite">${icon("star")}</button>${escapeHtml(repository.fullName)}</h1><p>${escapeHtml(repository.localPath || "No local working tree linked")}</p></div>${projectIllustration("repo")}<div class="repo-header-actions"><button class="button" data-action="fetch" ${!repository.localPath ? "disabled" : ""}>${icon("refresh")}Fetch</button><button class="button" data-action="open-path" ${!repository.localPath ? "disabled" : ""}>${icon("folder")}Folder</button><button class="button" data-action="open-gitea" ${!repository.htmlUrl ? "disabled" : ""}>${icon("external")}Gitea</button></div></header>
${repository.localPath ? `<div class="repo-quick-actions"><button class="button" data-action="open-editor">${icon("external")}Open in editor</button><button class="button" data-action="open-terminal">${icon("terminal")}Open terminal</button><button class="button" data-action="check-branch-protection">${icon("shield")}Check branch protection</button><button class="button primary" data-action="open-pull-request">${icon("git")}Create pull request</button>${ui.branchProtection ? `<span class="status-pill ${ui.branchProtection.protected ? "warning" : "success"}">${ui.branchProtection.protected ? `Protected · ${ui.branchProtection.requiredApprovals || 0} approval(s)` : "Direct pushes allowed"}</span>` : ""}</div>` : ""}
<div class="release-rail">${releaseNode("Local", status?.shortHead || "Not linked", status ? `${status.counts.changed} changes · ${status.branch.head}` : "No working tree", localTone)}${releaseNode("Gitea", status?.shortHead || "Unknown", status?.branch.upstream ? `${status.branch.ahead} ahead · ${status.branch.behind} behind` : "Branch not published", remoteTone)}${releaseNode(`Server${profile ? ` · ${profile.environment}` : ""}`, serverState.liveSha ? shortSha(serverState.liveSha) : "Unknown", profile ? (serverState.checkedAt ? `checked ${formatDate(serverState.checkedAt)}` : "not checked") : "No deployment profile", serverTone)}</div>
<div class="release-rail">${releaseNode("Local", status?.shortHead || "Not linked", status ? `${status.counts.changed} changes · ${status.branch.head}` : "No working tree", localTone)}${releaseNode("Gitea", status?.shortHead || "Unknown", status?.branch.upstream ? `${status.branch.ahead} ahead · ${status.branch.behind} behind` : "Branch not published", remoteTone)}${releaseNode(`Server${profile ? ` · ${profile.environment}` : ""}`, serverState.liveSha ? shortSha(serverState.liveSha) : profile ? "Linked" : "Unknown", profileWorkload ? `${profileWorkload.displayName || profile.containerName || "Container"} · ${profileWorkload.runtime?.running ? "running" : "stopped"} on ${profileWorkload.serverName}` : profile ? (serverState.checkedAt ? `checked ${formatDate(serverState.checkedAt)}` : "profile linked · awaiting live scan") : "No deployment profile", serverTone)}</div>
${deploymentLinks}
<nav class="tabs">${[
["changes", "Changes"],