fix: harden repository refresh and server pull
ForgeFlow quality gate / quality (push) Canceled after 0s
ForgeFlow quality gate / quality (push) Canceled after 0s
This commit is contained in:
@@ -72,7 +72,6 @@ function register(channel, handler) {
|
||||
stack: error?.stack,
|
||||
},
|
||||
});
|
||||
console.error(`[${channel}]`, error);
|
||||
return { ok: false, error: toErrorPayload(error) };
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
'use strict';
|
||||
|
||||
function isBrokenPipeError(error) {
|
||||
return error?.code === 'EPIPE';
|
||||
}
|
||||
|
||||
function installOutputPipeGuards({
|
||||
stdout = process.stdout,
|
||||
stderr = process.stderr,
|
||||
onBrokenPipe = () => {}
|
||||
} = {}) {
|
||||
const guardedStreams = [stdout, stderr].filter(Boolean);
|
||||
const handlers = guardedStreams.map((stream) => {
|
||||
const handler = (error) => {
|
||||
if (!isBrokenPipeError(error)) throw error;
|
||||
onBrokenPipe(error);
|
||||
};
|
||||
stream.on('error', handler);
|
||||
return { stream, handler };
|
||||
});
|
||||
return () => {
|
||||
for (const { stream, handler } of handlers) stream.off('error', handler);
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { installOutputPipeGuards, isBrokenPipeError };
|
||||
@@ -29,6 +29,8 @@ class RepositoryService {
|
||||
this.gitea = giteaService;
|
||||
this.diagnostics = diagnostics;
|
||||
this.lastKnownLocalPaths = [];
|
||||
this.lastKnownRemoteRepositories = [];
|
||||
this.lastSuccessfulRemoteRefreshAt = null;
|
||||
}
|
||||
|
||||
async discoverInRoot(root, maxDepth = 4) {
|
||||
@@ -80,11 +82,37 @@ class RepositoryService {
|
||||
return [...this.lastKnownLocalPaths];
|
||||
}
|
||||
|
||||
async getRemoteRepositories() {
|
||||
if (!this.store.data.gitea.baseUrl || !this.store.getToken()) {
|
||||
this.lastKnownRemoteRepositories = [];
|
||||
this.lastSuccessfulRemoteRefreshAt = null;
|
||||
return { repositories: [], stale: false, error: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const repositories = await this.gitea.listRepositories();
|
||||
this.lastKnownRemoteRepositories = repositories.map((repository) => ({ ...repository }));
|
||||
this.lastSuccessfulRemoteRefreshAt = new Date().toISOString();
|
||||
return { repositories, stale: false, error: null };
|
||||
} catch (error) {
|
||||
if (!this.lastSuccessfulRemoteRefreshAt) throw error;
|
||||
await this.diagnostics?.warning('repositories.remote-refresh.degraded', {
|
||||
message: error.message,
|
||||
cachedCount: this.lastKnownRemoteRepositories.length,
|
||||
lastSuccessfulAt: this.lastSuccessfulRemoteRefreshAt
|
||||
});
|
||||
return {
|
||||
repositories: this.lastKnownRemoteRepositories.map((repository) => ({ ...repository })),
|
||||
stale: true,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
const started = Date.now();
|
||||
const remoteRepositories = this.store.data.gitea.baseUrl && this.store.getToken()
|
||||
? await this.gitea.listRepositories()
|
||||
: [];
|
||||
const remoteResult = await this.getRemoteRepositories();
|
||||
const remoteRepositories = remoteResult.repositories;
|
||||
|
||||
const discoveredPaths = await this.discoverAll(this.store.data.workspaceRoots);
|
||||
const mappedPaths = Object.values(this.store.data.repositoryMappings || {});
|
||||
@@ -106,7 +134,12 @@ class RepositoryService {
|
||||
...profile,
|
||||
state: this.store.getDeploymentState(profile.id)
|
||||
}));
|
||||
repositories.push(this.decorate(remote, local, profiles));
|
||||
repositories.push({
|
||||
...this.decorate(remote, local, profiles),
|
||||
remoteStale: remoteResult.stale,
|
||||
remoteRefreshError: remoteResult.error,
|
||||
remoteLastRefreshedAt: this.lastSuccessfulRemoteRefreshAt
|
||||
});
|
||||
}
|
||||
|
||||
for (const local of localDescriptors.filter((item) => !usedLocalPaths.has(item.localPath))) {
|
||||
@@ -145,6 +178,7 @@ class RepositoryService {
|
||||
await this.diagnostics?.debug('repositories.refresh.completed', {
|
||||
durationMs: Date.now() - started,
|
||||
remoteCount: remoteRepositories.length,
|
||||
remoteStale: remoteResult.stale,
|
||||
discoveredCount: discoveredPaths.length,
|
||||
linkedCount: sorted.filter((item) => item.localPath).length,
|
||||
attentionCount: sorted.filter((item) => item.attention).length,
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
function createUnraidAccessMethods({ shellQuote, path, bash, inventoryRemoteIdentity, checksSummary, crypto, parsePermissionInspection }) {
|
||||
class UnraidAccessMethods {
|
||||
serverGitRemote(repository, profile) {
|
||||
const candidates = [repository.sshUrl, profile.cloneUrl, repository.preferredCloneUrl]
|
||||
const candidates = [
|
||||
repository.localStatus?.remoteUrl,
|
||||
repository.sshUrl,
|
||||
repository.preferredCloneUrl,
|
||||
profile.cloneUrl,
|
||||
]
|
||||
.map((value) => String(value || "").trim())
|
||||
.filter(Boolean);
|
||||
const value = candidates.find((candidate) => /^ssh:\/\//i.test(candidate) || /^[^@\s]+@[^:\s]+:.+/.test(candidate));
|
||||
|
||||
@@ -20,7 +20,7 @@ class UnraidDeployKeyHost {
|
||||
return { directory, privateKey: path.join(directory, "deploy-key"), publicKey: path.join(directory, "deploy-key.pub"), knownHosts: path.join(directory, "known_hosts"), recovery: path.join(directory, "recovery") };
|
||||
}
|
||||
remote(repository, profile) {
|
||||
const value = [repository.sshUrl, profile.cloneUrl, repository.preferredCloneUrl].map((item) => String(item || "").trim()).find((item) => /^ssh:\/\//i.test(item) || /^[^@\s]+@[^:\s]+:.+/.test(item));
|
||||
const value = [repository.localStatus?.remoteUrl, repository.sshUrl, repository.preferredCloneUrl, profile.cloneUrl].map((item) => String(item || "").trim()).find((item) => /^ssh:\/\//i.test(item) || /^[^@\s]+@[^:\s]+:.+/.test(item));
|
||||
if (!value) throw Object.assign(new Error("Server pull requires a Gitea SSH URL."), { code: "SERVER_GIT_SSH_URL_REQUIRED" });
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -418,9 +418,12 @@ function createUnraidDeploymentMethods({
|
||||
|
||||
const generated = profile.generatedCompose ? this.generatedCompose(profile, repository) : "";
|
||||
const iconReference = await this.prepareIcon(profile, repository, server);
|
||||
const deploymentRepositoryUrl = mode === "server-git"
|
||||
? this.serverGitRemote(repository, profile)
|
||||
: repository.localStatus?.remoteUrl || repository.sshUrl || repository.cloneUrl || repository.htmlUrl || repository.fullName;
|
||||
const metadata = this.metadataCompose(profile, repository, iconReference, {
|
||||
sha: targetSha,
|
||||
repositoryUrl: profile.cloneUrl || repository.sshUrl || repository.cloneUrl || repository.htmlUrl || repository.fullName,
|
||||
repositoryUrl: deploymentRepositoryUrl,
|
||||
});
|
||||
const previousState = this.store.getDeploymentState?.(profileId) || null;
|
||||
|
||||
@@ -518,9 +521,12 @@ function createUnraidDeploymentMethods({
|
||||
});
|
||||
const generated = profile.generatedCompose ? this.generatedCompose(profile, repository) : "";
|
||||
const iconReference = await this.prepareIcon(profile, repository, server);
|
||||
const rollbackRepositoryUrl = rollbackMode === "server-git"
|
||||
? this.serverGitRemote(repository, profile)
|
||||
: repository.localStatus?.remoteUrl || repository.sshUrl || repository.cloneUrl || repository.htmlUrl || repository.fullName;
|
||||
const metadata = this.metadataCompose(profile, repository, iconReference, {
|
||||
sha: target,
|
||||
repositoryUrl: profile.cloneUrl || repository.sshUrl || repository.cloneUrl || repository.htmlUrl || repository.fullName,
|
||||
repositoryUrl: rollbackRepositoryUrl,
|
||||
});
|
||||
try {
|
||||
const mode = rollbackMode;
|
||||
|
||||
@@ -176,6 +176,7 @@ const ui = {
|
||||
inputRenderTimer: null,
|
||||
isMock: false,
|
||||
refreshError: null,
|
||||
refreshWarning: null,
|
||||
autoRefreshPending: false,
|
||||
paletteQuery: "",
|
||||
updateStatus: null,
|
||||
@@ -428,6 +429,12 @@ async function refreshRepositories(withLoader = true, silent = false) {
|
||||
const selectedId = ui.selectedRepoId;
|
||||
ui.repositories = await window.forgeflow.refreshRepositories();
|
||||
ui.refreshError = null;
|
||||
const staleRepository = ui.repositories.find(
|
||||
(repository) => repository.remoteStale,
|
||||
);
|
||||
ui.refreshWarning = staleRepository
|
||||
? `Gitea could not be reached. Showing repository data last refreshed ${formatDate(staleRepository.remoteLastRefreshedAt)} while local and server state continue to refresh.`
|
||||
: null;
|
||||
if (selectedId && !selectedRepository()) ui.selectedRepoId = null;
|
||||
const repository = selectedRepository();
|
||||
if (
|
||||
@@ -459,6 +466,7 @@ async function refreshRepositories(withLoader = true, silent = false) {
|
||||
selectRepository(ui.repositories[0].id, false);
|
||||
} catch (error) {
|
||||
ui.refreshError = error.message;
|
||||
ui.refreshWarning = null;
|
||||
if (!silent) showToast("Refresh failed", error.message, "error");
|
||||
} finally {
|
||||
if (withLoader) setLoading(false);
|
||||
|
||||
@@ -5,7 +5,7 @@ function createMockRepositoryBridge(context) {
|
||||
await wait(80);
|
||||
snapshot();
|
||||
return {
|
||||
appVersion: "0.10.10-demo",
|
||||
appVersion: "0.10.11-demo",
|
||||
platform: "win32",
|
||||
state: clone(state),
|
||||
git: { available: true, version: "git version 2.47.3" },
|
||||
|
||||
@@ -157,6 +157,7 @@ function renderOverview() {
|
||||
return `<div class="page">
|
||||
<div class="page-header visual-page-header"><div><div class="eyebrow">Coding flow</div><h1>Release overview</h1><p>One decision surface for local work, Gitea synchronization and the exact version running on your server.</p></div>${projectIllustration("flow")}<button class="button" data-action="refresh">${icon("refresh")}Refresh all</button></div>
|
||||
${ui.refreshError ? `<div class="notice danger">${icon("error")} ${escapeHtml(ui.refreshError)}</div>` : ""}
|
||||
${ui.refreshWarning ? `<div class="notice warning">${icon("warning")} ${escapeHtml(ui.refreshWarning)}</div>` : ""}
|
||||
<div class="summary-grid">
|
||||
${renderSummaryCard("Local work", changed, changed === 1 ? "repository has changes" : "repositories have changes", "file", changed ? "warning" : "success")}
|
||||
${renderSummaryCard("Unpushed", unpushed, "repositories ahead of Gitea", "arrowUp", unpushed ? "warning" : "success")}
|
||||
|
||||
Reference in New Issue
Block a user