Files
ForgeFlow/src/main/deployment-service.cjs
T
NuklearRabbitandClaude Opus 5 9260d35957 fix: repair broken IPC wiring and cut the cost of repository polling
Three handlers referenced a dependency they were never given, which made them
throw a ReferenceError as soon as they ran:

- deployment:preflight for Gitea Actions profiles (`preflight` was passed to
  registerOperationsIpc but not to registerDeploymentIpc)
- Unraid write-access repair (`safeRelativeRemoteFile` was missing from
  createUnraidAccessMethods)
- a dead reference of the same name in unraid-state-methods

no-undef and no-unused-vars were disabled for every file, which is why none of
these were caught. Both are now enabled for src/main and src/shared, where the
dependency graph is explicit. The renderer keeps them off because its functions
are deliberately cross-script globals.

Performance:

- git.status() spawned three processes (rev-parse, status, remote get-url) per
  call. A directory holding its own .git is by definition the work tree root, so
  rev-parse is unnecessary, and the remote URL is cached against the mtime of
  .git/config, including the failure for a repository without that remote.
- git status runs with --no-optional-locks so a read no longer rewrites the
  index. That stops it fighting a concurrent Git command for the index lock, and
  is what makes filesystem watching viable at all.
- One commit issued four `git status` reads; callers that already hold the
  status now pass it on, leaving two.
- The repository monitor is event driven. A watched repository is read on
  filesystem activity, with a 30s safety net for watchers that stop delivering
  and a 1s floor so a busy tree cannot drive a read per event. Repositories that
  cannot be watched keep using the interval. Idle cost for one repository over
  35s: 24 git processes before, 3 after.
- Resolving one repository by name no longer refreshes the whole workspace.
- Concurrent configuration saves share a single write of the latest state.
- Repository discovery follows directory junctions again. The filter that
  skipped them made the realpath cycle guard dead code, and hid any project
  folder reached through a junction.

Renderer:

- render() replaced the whole shell on every poll, discarding focus, caret and
  scroll position while the user was typing. Those are preserved now, and an
  unchanged render leaves the DOM alone entirely.
- The four sections that enhanceRenderedUi() injected after render moved into
  the views, so the rendered markup is the single source of truth.
- The monitor no longer keeps a repository paused forever when it is unlinked
  mid-mutation, scheduleAutoRefresh honours its delay argument, the demo bridges
  no longer block startup, and #app is no longer an aria-live region announcing
  the entire UI on every render.

IPC channel plumbing moved to src/main/ipc/channel.cjs, replacing a module-level
mutable diagnostics singleton with an argument.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:31:58 +02:00

428 lines
23 KiB
JavaScript

'use strict';
const crypto = require('node:crypto');
const { assertDeploymentRequest, assertFullCommitSha, assertHttpUrl } = require('../shared/validation.cjs');
const { redactSecrets } = require('./log-redaction.cjs');
const TERMINAL_STATUSES = new Set(['success', 'failed', 'cancelled', 'rolled-back']);
function applicationVerificationFailure(operation, state) {
if (!state?.statusConfigured) return { stage: 'version-verification', message: 'No server status endpoint is configured.' };
if (!state.statusReachable) return { stage: 'version-verification', message: state.error || 'The server status endpoint is not reachable.' };
if (!state.statusRepository) return { stage: 'version-verification', message: 'The server status endpoint did not identify its repository.' };
if (state.statusRepository !== operation.repository) return { stage: 'version-verification', message: `The status endpoint belongs to ${state.statusRepository}, not ${operation.repository}.` };
if (!state.statusEnvironment) return { stage: 'version-verification', message: 'The server status endpoint did not identify its environment.' };
if (state.statusEnvironment !== operation.environment) return { stage: 'version-verification', message: `The status endpoint belongs to ${state.statusEnvironment}, not ${operation.environment}.` };
if (!state.liveSha) return { stage: 'version-verification', message: 'The server status endpoint did not return a valid full commit SHA.' };
if (state.liveSha !== operation.sha) return { stage: 'version-verification', message: `Server reports ${state.liveSha.slice(0, 7)} instead of ${operation.shortSha}.` };
if (!state.requestedSha) return { stage: 'version-verification', message: 'The server status endpoint did not return the requested commit SHA.' };
if (state.requestedSha !== operation.sha) return { stage: 'version-verification', message: 'The server status document was created for a different requested commit.' };
if (!state.requestId) return { stage: 'version-verification', message: 'The server status endpoint did not return the deployment request ID.' };
if (state.requestId !== operation.id) return { stage: 'version-verification', message: 'The server status belongs to a different deployment request.' };
if (state.lastExitCode !== 0) return { stage: 'server-command', message: `The server deployment command reported exit code ${state.lastExitCode ?? 'unknown'}.` };
if (state.healthy !== true) return { stage: 'healthcheck', message: state.error || `The server did not report a healthy application state (${state.healthStatus || 'unknown'}).` };
return null;
}
function terminalRunConclusion(run) {
const value = String(run?.conclusion || run?.status || '').toLowerCase();
if (['success'].includes(value)) return 'success';
if (['failure', 'failed', 'timed_out', 'startup_failure'].includes(value)) return 'failed';
if (['cancelled', 'canceled', 'skipped'].includes(value)) return 'cancelled';
return null;
}
function isRunningStatus(value) {
return ['running', 'in_progress', 'processing'].includes(String(value || '').toLowerCase());
}
class DeploymentService {
constructor(store, giteaService, gitService, diagnostics = null) {
this.store = store;
this.gitea = giteaService;
this.git = gitService;
this.diagnostics = diagnostics;
this.refreshLocks = new Set();
}
splitRepository(fullName) {
const [owner, repo, ...unexpected] = String(fullName || '').split('/');
if (!owner || !repo || unexpected.length) throw new Error('Invalid Gitea repository identity.');
return { owner, repo };
}
makeStages() {
return [
{ id: 'requested', label: 'Requested', status: 'complete' },
{ id: 'verified', label: 'Verified', status: 'complete' },
{ id: 'queued', label: 'Workflow queued', status: 'active' },
{ id: 'runner', label: 'Runner execution', status: 'pending' },
{ id: 'healthcheck', label: 'Healthcheck', status: 'pending' },
{ id: 'complete', label: 'Complete', status: 'pending' }
];
}
setStage(operation, id, status) {
const stage = operation.stages?.find((item) => item.id === id);
if (stage) stage.status = status;
}
appendLog(operation, line) {
const clean = redactSecrets(line, [this.store.getToken()]);
operation.logs = Array.isArray(operation.logs) ? operation.logs : [];
if (operation.logs.at(-1) !== clean) operation.logs.push(clean);
operation.logs = operation.logs.slice(-1000);
}
async captureBaselineRunIds(owner, repo, branch, operation) {
try {
const result = await this.gitea.listWorkflowRuns({ owner, repo, branch, limit: 50 });
const ids = (result.runs || []).map((run) => run.id).filter((id) => id !== null && id !== undefined).map(String);
operation.baselineRunIds = [...new Set(ids)].slice(0, 100);
this.appendLog(operation, `[info] Captured ${operation.baselineRunIds.length} existing Actions run identifier(s) before dispatch.`);
} catch (error) {
operation.baselineRunIds = [];
this.appendLog(operation, `[warning] Could not capture the pre-dispatch run baseline: ${error.message}`);
}
}
async validateDeploy(repository, profile, sha) {
assertDeploymentRequest(profile, sha);
const localStatus = await this.git.status(repository.localPath);
if (localStatus.head !== sha) throw new Error('The selected commit no longer matches the local repository. Refresh before deploying.');
if (localStatus.branch.head !== profile.branch) throw new Error(`This profile only allows deployments from ${profile.branch}.`);
if (localStatus.counts.changed) throw new Error('Commit local changes before deploying.');
if (localStatus.branch.ahead) throw new Error('Push all local commits before deploying.');
if (localStatus.branch.behind) throw new Error('Synchronize with Gitea before deploying.');
if (!localStatus.branch.upstream) throw new Error('Publish this branch to Gitea before deploying.');
await this.git.verifyCommitOnRemoteBranch(repository.localPath, sha, profile.branch);
return localStatus;
}
async deploy({ repository, profileId, sha }) {
if (!repository?.fullName || !repository?.localPath) throw new Error('A linked local repository is required for deployment.');
const profile = this.store.getDeploymentProfile(repository.fullName, profileId);
const fullSha = assertFullCommitSha(sha);
await this.validateDeploy(repository, profile, fullSha);
const { owner, repo } = this.splitRepository(repository.fullName);
const operation = {
id: crypto.randomUUID(),
type: 'deployment',
action: 'deploy',
status: 'requested',
repository: repository.fullName,
profileId,
profileName: profile.name,
environment: profile.environment,
workflowFile: profile.workflowFile,
branch: profile.branch,
sha: fullSha,
shortSha: fullSha.slice(0, 7),
dispatchedAt: new Date().toISOString(),
stages: this.makeStages(),
logs: [
`[info] Verified clean ${profile.branch} at ${fullSha}`,
`[info] Dispatching ${profile.workflowFile} for ${repository.fullName}`
]
};
await this.captureBaselineRunIds(owner, repo, profile.branch, operation);
await this.store.addOperation(operation);
await this.diagnostics?.info('deployment.dispatch.requested', { operationId: operation.id, repository: operation.repository, profileId, environment: operation.environment, branch: operation.branch, sha: operation.sha, workflowFile: operation.workflowFile });
try {
await this.gitea.dispatchWorkflow({
owner,
repo,
workflowFile: profile.workflowFile,
ref: profile.branch,
inputs: { environment: profile.environment, commit_sha: fullSha, request_id: operation.id }
});
operation.status = 'queued';
this.appendLog(operation, '[ok] Gitea accepted the workflow dispatch request.');
this.appendLog(operation, '[info] Resolving the corresponding Actions run…');
const saved = await this.store.addOperation(operation);
await this.diagnostics?.info('deployment.dispatch.accepted', { operationId: operation.id, repository: operation.repository, status: operation.status });
return saved;
} catch (error) {
operation.status = 'failed';
this.setStage(operation, 'queued', 'failed');
operation.failure = { stage: 'dispatch', message: error.message };
this.appendLog(operation, `[error] ${error.message}`);
await this.store.addOperation(operation);
await this.diagnostics?.error('deployment.dispatch.failed', { operationId: operation.id, repository: operation.repository, message: error.message, code: error.code, status: error.status });
throw error;
}
}
async rollback({ repository, profileId, targetSha }) {
if (!repository?.fullName || !repository?.localPath) throw new Error('A linked local repository is required for rollback.');
const profile = this.store.getDeploymentProfile(repository.fullName, profileId);
if (!profile) throw new Error('Deployment profile not found.');
if (!profile.rollbackWorkflowFile) throw new Error('No rollback workflow is configured for this profile.');
const fullSha = assertFullCommitSha(targetSha);
assertDeploymentRequest({ ...profile, workflowFile: profile.rollbackWorkflowFile }, fullSha);
const state = await this.refreshProfileState(repository.fullName, profileId);
if (!state.statusReachable) throw new Error(state.error || 'The server status endpoint must be reachable before rollback.');
if (state.statusRepository !== repository.fullName || state.statusEnvironment !== profile.environment) throw new Error('The status endpoint does not match this repository and environment.');
if (!state.previousSha) throw new Error('The server status endpoint does not report a previous version.');
if (state.previousSha !== fullSha) throw new Error('The requested rollback SHA is no longer the previous server version. Refresh the environment state.');
if (state.liveSha === fullSha) throw new Error('The requested rollback version is already live.');
await this.git.verifyCommitOnRemoteBranch(repository.localPath, fullSha, profile.branch);
const { owner, repo } = this.splitRepository(repository.fullName);
const operation = {
id: crypto.randomUUID(),
type: 'deployment',
action: 'rollback',
status: 'requested',
repository: repository.fullName,
profileId,
profileName: profile.name,
environment: profile.environment,
workflowFile: profile.rollbackWorkflowFile,
branch: profile.branch,
sha: fullSha,
shortSha: fullSha.slice(0, 7),
dispatchedAt: new Date().toISOString(),
stages: this.makeStages(),
logs: [
`[warning] Rollback target verified on origin/${profile.branch}: ${fullSha}`,
`[info] Dispatching ${profile.rollbackWorkflowFile}`
]
};
await this.captureBaselineRunIds(owner, repo, profile.branch, operation);
await this.store.addOperation(operation);
await this.diagnostics?.info('deployment.rollback.requested', { operationId: operation.id, repository: operation.repository, profileId, environment: operation.environment, branch: operation.branch, sha: operation.sha, workflowFile: operation.workflowFile });
try {
await this.gitea.dispatchWorkflow({
owner,
repo,
workflowFile: profile.rollbackWorkflowFile,
ref: profile.branch,
inputs: { environment: profile.environment, target_sha: fullSha, request_id: operation.id }
});
operation.status = 'queued';
this.appendLog(operation, '[ok] Gitea accepted the rollback request.');
const saved = await this.store.addOperation(operation);
await this.diagnostics?.info('deployment.rollback.accepted', { operationId: operation.id, repository: operation.repository });
return saved;
} catch (error) {
operation.status = 'failed';
this.setStage(operation, 'queued', 'failed');
operation.failure = { stage: 'dispatch', message: error.message };
this.appendLog(operation, `[error] ${error.message}`);
await this.store.addOperation(operation);
await this.diagnostics?.error('deployment.rollback.failed', { operationId: operation.id, repository: operation.repository, message: error.message, code: error.code, status: error.status });
throw error;
}
}
mapJobsToStages(operation, jobs) {
operation.jobs = jobs;
if (!jobs.length) return;
const running = jobs.some((job) => isRunningStatus(job.status));
const failed = jobs.some((job) => terminalRunConclusion(job) === 'failed');
const allDone = jobs.every((job) => terminalRunConclusion(job));
this.setStage(operation, 'queued', 'complete');
this.setStage(operation, 'runner', failed ? 'failed' : allDone ? 'complete' : running ? 'active' : 'pending');
}
async refreshOperation(operationId) {
if (this.refreshLocks.has(operationId)) return this.store.getOperation(operationId);
const operation = this.store.getOperation(operationId);
if (!operation || operation.type !== 'deployment') throw new Error('Deployment operation not found.');
if (TERMINAL_STATUSES.has(operation.status)) return operation;
this.refreshLocks.add(operationId);
try {
const profile = this.store.getDeploymentProfile(operation.repository, operation.profileId);
if (!profile) throw new Error('The deployment profile used by this operation no longer exists.');
const { owner, repo } = this.splitRepository(operation.repository);
const found = await this.gitea.findWorkflowRun({
owner,
repo,
sha: operation.sha,
branch: operation.branch,
workflowFile: operation.workflowFile,
dispatchedAt: operation.dispatchedAt || operation.createdAt,
excludeRunIds: operation.baselineRunIds || []
});
if (!found.run) {
operation.status = 'queued';
this.setStage(operation, 'queued', 'active');
this.appendLog(operation, '[info] Workflow is queued or not visible through the Actions API yet.');
return await this.store.addOperation(operation);
}
operation.run = { ...found.run, source: found.source };
operation.runUrl = found.run.htmlUrl || `${this.store.data.gitea.baseUrl}/${operation.repository}/actions/runs/${found.run.runNumber}`;
this.setStage(operation, 'queued', 'complete');
const runConclusion = terminalRunConclusion(found.run);
if (!runConclusion) {
operation.status = isRunningStatus(found.run.status) ? 'running' : 'queued';
this.setStage(operation, 'runner', operation.status === 'running' ? 'active' : 'pending');
}
try {
const jobs = await this.gitea.listWorkflowJobs({ owner, repo, runNumber: found.run.runNumber });
this.mapJobsToStages(operation, jobs);
for (const job of jobs) {
const conclusion = job.conclusion || job.status;
this.appendLog(operation, `[job] ${job.name}: ${conclusion}`);
}
// Raw runner output is intentionally not ingested or persisted. Open the trusted Gitea run for full logs.
} catch (error) {
this.appendLog(operation, `[warning] Job details unavailable: ${error.message}`);
}
if (runConclusion === 'success') {
this.setStage(operation, 'runner', 'complete');
this.setStage(operation, 'healthcheck', 'active');
const state = await this.refreshProfileState(operation.repository, operation.profileId, { expectedSha: operation.sha });
operation.applicationState = state;
const verificationFailure = applicationVerificationFailure(operation, state);
if (verificationFailure) {
operation.status = 'failed';
this.setStage(operation, 'healthcheck', 'failed');
this.setStage(operation, 'complete', 'failed');
operation.failure = verificationFailure;
this.appendLog(operation, `[error] ${verificationFailure.message}`);
} else {
operation.status = operation.action === 'rollback' ? 'rolled-back' : 'success';
this.setStage(operation, 'healthcheck', 'complete');
this.setStage(operation, 'complete', 'complete');
this.appendLog(operation, `[ok] ${operation.action === 'rollback' ? 'Rollback' : 'Deployment'} completed successfully.`);
}
} else if (runConclusion === 'failed' || runConclusion === 'cancelled') {
operation.status = runConclusion;
this.setStage(operation, 'runner', runConclusion === 'failed' ? 'failed' : 'cancelled');
this.setStage(operation, 'healthcheck', 'skipped');
this.setStage(operation, 'complete', runConclusion === 'failed' ? 'failed' : 'cancelled');
operation.failure = { stage: 'runner', message: `Gitea Actions finished with ${runConclusion}.` };
this.appendLog(operation, `[error] ${operation.failure.message}`);
}
const saved = await this.store.addOperation(operation);
if (TERMINAL_STATUSES.has(operation.status)) {
await this.diagnostics?.info('deployment.operation.terminal', { operationId: operation.id, repository: operation.repository, status: operation.status, failure: operation.failure || null, applicationState: operation.applicationState || null });
} else {
await this.diagnostics?.debug('deployment.operation.refreshed', { operationId: operation.id, repository: operation.repository, status: operation.status, run: operation.run ? { id: operation.run.id, runNumber: operation.run.runNumber, status: operation.run.status, conclusion: operation.run.conclusion } : null });
}
return saved;
} catch (error) {
operation.pollError = error.message;
this.appendLog(operation, `[warning] Status refresh failed: ${error.message}`);
await this.diagnostics?.warning('deployment.operation.poll-failed', { operationId: operation.id, repository: operation.repository, message: error.message });
return await this.store.addOperation(operation);
} finally {
this.refreshLocks.delete(operationId);
}
}
async refreshActiveOperations() {
const active = this.store.data.operations.filter((item) => item.type === 'deployment' && !TERMINAL_STATUSES.has(item.status));
const queue = active.slice(0, 20);
const results = [];
const workers = Array.from({ length: Math.min(4, queue.length) }, async () => {
while (queue.length) {
const operation = queue.shift();
results.push(await this.refreshOperation(operation.id));
}
});
await Promise.all(workers);
return results;
}
async checkHealth(url) {
if (!url) return { configured: false, healthy: null };
const normalized = assertHttpUrl(url, { label: 'Healthcheck URL' });
const started = Date.now();
try {
const response = await fetch(normalized, { signal: AbortSignal.timeout(10_000), redirect: 'follow', headers: { Accept: 'application/json, text/plain, */*' } });
return { configured: true, healthy: response.ok, status: response.status, latencyMs: Date.now() - started };
} catch (error) {
return { configured: true, healthy: false, error: error.message, latencyMs: Date.now() - started };
}
}
async readStatusEndpoint(url) {
if (!url) return { configured: false };
const normalized = assertHttpUrl(url, { label: 'Application status URL' });
const started = Date.now();
try {
const response = await fetch(normalized, { signal: AbortSignal.timeout(10_000), redirect: 'follow', headers: { Accept: 'application/json' } });
if (!response.ok) return { configured: true, reachable: true, ok: false, status: response.status, latencyMs: Date.now() - started };
const payload = await response.json();
const liveSha = payload.commit_sha || payload.commitSha || payload.sha || payload.version?.commit_sha || payload.version?.sha || null;
const previousSha = payload.previous_sha || payload.previousSha || payload.previous?.sha || null;
const requestId = payload.request_id || payload.requestId || null;
const requestedSha = payload.requested_sha || payload.requestedSha || null;
const repository = payload.repository || null;
const environment = payload.environment || null;
const rawExitCode = payload.last_exit_code ?? payload.lastExitCode ?? null;
return {
configured: true,
reachable: true,
ok: true,
status: response.status,
latencyMs: Date.now() - started,
liveSha: /^[a-f0-9]{40,64}$/i.test(String(liveSha || '')) ? String(liveSha).toLowerCase() : null,
previousSha: /^[a-f0-9]{40,64}$/i.test(String(previousSha || '')) ? String(previousSha).toLowerCase() : null,
requestId: typeof requestId === 'string' ? requestId.slice(0, 100) : null,
requestedSha: /^[a-f0-9]{40,64}$/i.test(String(requestedSha || '')) ? String(requestedSha).toLowerCase() : null,
repository: typeof repository === 'string' ? repository.slice(0, 200) : null,
environment: typeof environment === 'string' ? environment.slice(0, 64).toLowerCase() : null,
lastExitCode: rawExitCode !== null && rawExitCode !== '' && Number.isInteger(Number(rawExitCode)) ? Number(rawExitCode) : null,
deployedAt: payload.deployed_at || payload.deployedAt || null,
health: payload.health || payload.status || null,
payload
};
} catch (error) {
return { configured: true, reachable: false, ok: false, error: error.message, latencyMs: Date.now() - started };
}
}
async refreshProfileState(fullName, profileId, { expectedSha = null } = {}) {
const profile = this.store.getDeploymentProfile(fullName, profileId);
if (!profile) throw new Error('Deployment profile not found.');
const [status, health] = await Promise.all([
this.readStatusEndpoint(profile.statusUrl),
this.checkHealth(profile.healthcheckUrl)
]);
const state = {
profileId,
repository: fullName,
environment: profile.environment,
liveSha: status.liveSha || null,
previousSha: status.previousSha || null,
deployedAt: status.deployedAt || null,
statusConfigured: Boolean(status.configured),
statusReachable: status.configured ? Boolean(status.reachable && status.ok) : null,
statusCode: status.status || null,
statusRepository: status.repository || null,
statusEnvironment: status.environment || null,
requestedSha: status.requestedSha || null,
lastExitCode: status.lastExitCode,
healthConfigured: Boolean(health.configured),
healthy: health.configured
? Boolean(health.healthy)
: (['healthy', 'ok', 'success', 'ready'].includes(String(status.health || '').toLowerCase())
? true
: (['unhealthy', 'failed', 'error', 'degraded'].includes(String(status.health || '').toLowerCase()) ? false : null)),
healthStatus: health.status || status.health || null,
latencyMs: health.latencyMs ?? status.latencyMs ?? null,
expectedSha: expectedSha || null,
requestId: status.requestId || null,
versionMatches: expectedSha && status.liveSha ? status.liveSha === expectedSha : null,
error: health.error || status.error || null,
checkedAt: new Date().toISOString()
};
return this.store.saveDeploymentState(profileId, state);
}
}
module.exports = { DeploymentService, TERMINAL_STATUSES, terminalRunConclusion, applicationVerificationFailure };