Harden workspace sync quarantine and updater recovery
Managed validation / full (pull_request) Successful in 31s
Managed validation / full (pull_request) Successful in 31s
This commit is contained in:
@@ -63,16 +63,24 @@ function Get-Sha256([string]$Path) {
|
||||
}
|
||||
}
|
||||
|
||||
function Start-ForgeFlowAndVerify([string]$Executable) {
|
||||
$process = Start-Process -FilePath $Executable -WorkingDirectory (Split-Path -Parent $Executable) -PassThru
|
||||
Start-Sleep -Milliseconds 1500
|
||||
if (-not $process -or $process.HasExited) { throw "ForgeFlow restart process exited before the application could stay running." }
|
||||
return $process
|
||||
}
|
||||
|
||||
try {
|
||||
Write-UpdateState -State "started" -Message "Binary updater owns the update request."
|
||||
Write-Log "Validating ForgeFlow $ExpectedVersion binary update."
|
||||
if ($HandshakeOnly) {
|
||||
Write-UpdateState -State "started" -Message "Binary updater owns the update request."
|
||||
Write-Log "Handshake-only verification completed successfully."
|
||||
exit 0
|
||||
}
|
||||
$actualSha256 = Get-Sha256 -Path $BinaryPath
|
||||
if ($actualSha256 -ne $ExpectedSha256.ToLowerInvariant()) { throw "Binary update SHA-256 verification failed." }
|
||||
if (-not (Test-Path -LiteralPath $CurrentExecutable -PathType Leaf)) { throw "Current ForgeFlow executable was not found." }
|
||||
Write-UpdateState -State "started" -Message "Binary preflight passed; updater owns the update request."
|
||||
if ($VerifyOnly) {
|
||||
Write-Log "Verification-only SHA-256 check completed successfully."
|
||||
exit 0
|
||||
@@ -90,9 +98,16 @@ try {
|
||||
try {
|
||||
Copy-Item -LiteralPath $BinaryPath -Destination $CurrentExecutable -Force
|
||||
} catch {
|
||||
$copyFailure = $_.Exception.Message
|
||||
try {
|
||||
Copy-Item -LiteralPath $backupPath -Destination $CurrentExecutable -Force
|
||||
Write-UpdateState -State "rolled-back" -Message $_.Exception.Message
|
||||
throw
|
||||
$rollbackRestart = Start-ForgeFlowAndVerify -Executable $CurrentExecutable
|
||||
Write-Log "Portable replacement failed; previous ForgeFlow restored and restarted as PID $($rollbackRestart.Id)."
|
||||
Write-UpdateState -State "rolled-back" -Message $copyFailure -RestartLaunched $true
|
||||
} catch {
|
||||
Write-UpdateState -State "failed" -Message "$copyFailure Rollback also failed: $($_.Exception.Message)" -RestartLaunched $false
|
||||
}
|
||||
throw $copyFailure
|
||||
}
|
||||
} else {
|
||||
Write-UpdateState -State "applying" -Message "Running the verified ForgeFlow installer."
|
||||
@@ -100,13 +115,31 @@ try {
|
||||
if ($installer.ExitCode -ne 0) { throw "ForgeFlow installer exited with code $($installer.ExitCode)." }
|
||||
}
|
||||
|
||||
$restart = Start-Process -FilePath $CurrentExecutable -WorkingDirectory (Split-Path -Parent $CurrentExecutable) -PassThru
|
||||
Write-Log "ForgeFlow $ExpectedVersion installed; restart PID $($restart.Id)."
|
||||
try {
|
||||
$restart = Start-ForgeFlowAndVerify -Executable $CurrentExecutable
|
||||
Write-Log "ForgeFlow $ExpectedVersion installed; verified restart PID $($restart.Id)."
|
||||
Write-UpdateState -State "success" -Message "ForgeFlow $ExpectedVersion installed successfully." -RestartLaunched $true
|
||||
} catch {
|
||||
$restartFailure = $_.Exception.Message
|
||||
if ($isPortable -and $backupPath -and (Test-Path -LiteralPath $backupPath -PathType Leaf)) {
|
||||
Write-Log "Updated portable executable failed its restart probe; restoring the previous executable."
|
||||
try {
|
||||
Copy-Item -LiteralPath $backupPath -Destination $CurrentExecutable -Force
|
||||
$rollbackRestart = Start-ForgeFlowAndVerify -Executable $CurrentExecutable
|
||||
Write-Log "Previous ForgeFlow restored and restarted as PID $($rollbackRestart.Id)."
|
||||
Write-UpdateState -State "rolled-back" -Message $restartFailure -RestartLaunched $true
|
||||
} catch {
|
||||
Write-UpdateState -State "failed" -Message "$restartFailure Rollback also failed: $($_.Exception.Message)" -RestartLaunched $false
|
||||
}
|
||||
exit 1
|
||||
}
|
||||
Write-Log "ForgeFlow $ExpectedVersion installed, but automatic restart failed: $restartFailure"
|
||||
Write-UpdateState -State "success" -Message "ForgeFlow $ExpectedVersion installed successfully, but must be started manually." -RestartLaunched $false
|
||||
}
|
||||
} catch {
|
||||
Write-Log $_.Exception.Message
|
||||
$current = $null
|
||||
try { $current = Get-Content -LiteralPath $StatusPath -Raw | ConvertFrom-Json } catch {}
|
||||
if ($current.state -ne "rolled-back") { Write-UpdateState -State "failed" -Message $_.Exception.Message }
|
||||
if ($current.state -notin @("rolled-back", "failed")) { Write-UpdateState -State "failed" -Message $_.Exception.Message }
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -117,13 +117,20 @@ function Start-ForgeFlow {
|
||||
|
||||
try {
|
||||
Write-UpdateLog "ForgeFlow source update helper started for version $ExpectedVersion."
|
||||
Write-UpdateState -State "started" -Message "The external update helper started successfully." -Extra @{ helperPid = $PID; startedAt = (Get-Date).ToUniversalTime().ToString("o") }
|
||||
|
||||
if ($HandshakeOnly) {
|
||||
Write-UpdateState -State "started" -Message "The external update helper started successfully." -Extra @{ helperPid = $PID; startedAt = (Get-Date).ToUniversalTime().ToString("o") }
|
||||
Write-UpdateLog "Handshake-only verification completed successfully."
|
||||
exit 0
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath (Join-Path $SourcePath ".git")) {
|
||||
throw "Integrated source update refuses to overwrite a Git working tree. Use normal Git/ForgeFlow workspace sync so local commits and dirty files remain reviewable."
|
||||
}
|
||||
$actualHash = Get-Sha256 -Path $ArchivePath
|
||||
if ($actualHash -ne $ExpectedSha256.ToLowerInvariant()) { throw "Update archive checksum mismatch." }
|
||||
Write-UpdateState -State "started" -Message "Source update preflight passed; the external helper owns the request." -Extra @{ helperPid = $PID; startedAt = (Get-Date).ToUniversalTime().ToString("o") }
|
||||
|
||||
Write-UpdateState -State "waiting-for-exit" -Message "Waiting for the running ForgeFlow process to exit."
|
||||
$deadline = (Get-Date).AddMinutes(2)
|
||||
while (Get-Process -Id $ParentPid -ErrorAction SilentlyContinue) {
|
||||
@@ -131,9 +138,6 @@ try {
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
|
||||
$actualHash = Get-Sha256 -Path $ArchivePath
|
||||
if ($actualHash -ne $ExpectedSha256.ToLowerInvariant()) { throw "Update archive checksum mismatch." }
|
||||
|
||||
$working = Join-Path ([IO.Path]::GetTempPath()) ("forgeflow-update-" + [guid]::NewGuid().ToString("N"))
|
||||
$extract = Join-Path $working "extract"
|
||||
$backup = Join-Path $working "backup"
|
||||
|
||||
@@ -167,6 +167,48 @@ class GitService {
|
||||
return { root, gitDir: path.resolve(result.stdout.trim()) };
|
||||
}
|
||||
|
||||
async writeWorkspaceReviewManifest(repoPath, plan, { backupBranch = null, stash = null } = {}) {
|
||||
const { root, gitDir } = await this.gitDirectory(repoPath);
|
||||
const reviewId = String(plan?.id || '').trim();
|
||||
if (!/^[0-9a-f]{64}$/i.test(reviewId)) throw new Error('Workspace review manifest requires a valid synchronization plan.');
|
||||
const reviewDirectory = path.join(gitDir, 'forgeflow', 'workspace-reviews');
|
||||
await fs.mkdir(reviewDirectory, { recursive: true });
|
||||
const manifestPath = path.join(reviewDirectory, `${reviewId}.json`);
|
||||
const payload = {
|
||||
schemaVersion: 1,
|
||||
kind: 'workspace-sync-quarantine',
|
||||
id: reviewId,
|
||||
status: 'pending-codex-review',
|
||||
createdAt: new Date().toISOString(),
|
||||
repositoryRoot: root,
|
||||
branch: plan.branch,
|
||||
upstream: plan.upstream,
|
||||
sourceSha: plan.currentSha,
|
||||
targetSha: plan.targetSha,
|
||||
recoveryBranch: backupBranch,
|
||||
stashRef: stash?.ref || null,
|
||||
stashSha: stash?.sha || null,
|
||||
files: (plan.localFiles || []).map((file) => ({
|
||||
path: file.path,
|
||||
originalPath: file.originalPath || null,
|
||||
status: file.status,
|
||||
staged: Boolean(file.staged),
|
||||
unstaged: Boolean(file.unstaged),
|
||||
untracked: Boolean(file.untracked)
|
||||
})),
|
||||
instructions: [
|
||||
'Review the recovery branch and quarantine stash with Codex before restoring anything.',
|
||||
'ForgeFlow recovery branches are local-only and cannot be pushed to Gitea.',
|
||||
'Restore only files that are still useful; obsolete files can be dropped after review.'
|
||||
],
|
||||
manifestPath
|
||||
};
|
||||
const temporaryPath = `${manifestPath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
||||
await fs.writeFile(temporaryPath, `${JSON.stringify(payload, null, 2)}\n`, { mode: 0o600 });
|
||||
await fs.rename(temporaryPath, manifestPath);
|
||||
return payload;
|
||||
}
|
||||
|
||||
isGitLockError(error) {
|
||||
const message = String(error?.message || error || '');
|
||||
return /(?:cannot lock ref|Unable to create .*\.lock|another git process)/i.test(message)
|
||||
@@ -447,6 +489,7 @@ class GitService {
|
||||
const stamp = new Date().toISOString().replace(/[-:]/g, '').replace(/\..+/, '').replace('T', '-');
|
||||
let backupBranch = null;
|
||||
let stash = null;
|
||||
let review = null;
|
||||
if (plan.summary.localCommitsToProtect > 0) {
|
||||
const safeBranch = plan.branch.replace(/[^A-Za-z0-9._-]/g, '-');
|
||||
backupBranch = `forgeflow/recovery-${safeBranch}-${stamp}-${plan.currentSha.slice(0, 7)}`;
|
||||
@@ -454,10 +497,13 @@ class GitService {
|
||||
await run('git', ['branch', backupBranch, 'HEAD'], { cwd: root, timeout: 30_000 });
|
||||
}
|
||||
if (plan.summary.localFilesToStash > 0) {
|
||||
const label = `ForgeFlow workspace sync ${plan.branch} ${stamp}`;
|
||||
const label = `FORGEFLOW-QUARANTINE:${plan.id} workspace sync ${plan.branch} ${stamp}`;
|
||||
await run('git', ['stash', 'push', '--include-untracked', '-m', label], { cwd: root, timeout: 120_000 });
|
||||
stash = (await this.stashList(root))[0] || null;
|
||||
}
|
||||
if (backupBranch || stash) {
|
||||
review = await this.writeWorkspaceReviewManifest(root, plan, { backupBranch, stash });
|
||||
}
|
||||
|
||||
const protectedStatus = await this.status(root);
|
||||
if (!protectedStatus.clean || protectedStatus.head !== plan.currentSha) {
|
||||
@@ -486,6 +532,7 @@ class GitService {
|
||||
status,
|
||||
backupBranch,
|
||||
stash,
|
||||
review,
|
||||
cleaned: plan.localFiles.filter((file) => file.untracked).map((file) => file.path),
|
||||
ignoredFilesPreserved: true
|
||||
};
|
||||
@@ -718,6 +765,12 @@ class GitService {
|
||||
const status = await this.status(root);
|
||||
const branch = status.branch.head;
|
||||
if (!branch || branch === '(detached)') throw new Error('Cannot push from a detached HEAD.');
|
||||
if (/^forgeflow\/recovery-/.test(branch)) {
|
||||
const error = new Error('ForgeFlow recovery branches are local quarantine references and cannot be pushed to Gitea. Review them with Codex and move only approved work onto a normal branch.');
|
||||
error.code = 'WORKSPACE_RECOVERY_BRANCH_LOCAL_ONLY';
|
||||
error.recoverable = true;
|
||||
throw error;
|
||||
}
|
||||
const args = status.branch.upstream ? ['push', '--porcelain'] : ['push', '--porcelain', '--set-upstream', 'origin', branch];
|
||||
const result = await run('git', args, { cwd: root, timeout: 180_000, maxBuffer: 16 * 1024 * 1024 });
|
||||
return { output: `${result.stdout}\n${result.stderr}`.trim(), status: await this.status(root) };
|
||||
@@ -804,7 +857,16 @@ class GitService {
|
||||
const result = await run('git', ['stash', 'list', `--format=${format}`], { cwd: root });
|
||||
return result.stdout.split('\x1e').map((record) => record.trim()).filter(Boolean).map((record) => {
|
||||
const [ref, sha, date, subject] = record.split('\x1f');
|
||||
return { ref, sha, shortSha: sha.slice(0, 7), date, subject };
|
||||
const quarantine = String(subject || '').match(/FORGEFLOW-QUARANTINE:([0-9a-f]{64})/i);
|
||||
return {
|
||||
ref,
|
||||
sha,
|
||||
shortSha: sha.slice(0, 7),
|
||||
date,
|
||||
subject,
|
||||
quarantined: Boolean(quarantine),
|
||||
reviewId: quarantine?.[1] || null
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -812,6 +874,13 @@ class GitService {
|
||||
const root = await this.ensureRepository(repoPath);
|
||||
const value = String(ref || 'stash@{0}');
|
||||
if (!/^stash@\{\d+\}$/.test(value)) throw new Error('Invalid stash reference.');
|
||||
const candidate = (await this.stashList(root)).find((item) => item.ref === value);
|
||||
if (candidate?.quarantined) {
|
||||
const error = new Error(`This stash is quarantined for Codex review (${candidate.reviewId}). ForgeFlow will not apply and drop it wholesale; restore only reviewed files manually.`);
|
||||
error.code = 'WORKSPACE_QUARANTINE_REVIEW_REQUIRED';
|
||||
error.recoverable = true;
|
||||
throw error;
|
||||
}
|
||||
const result = await run('git', ['stash', 'pop', value], { cwd: root, timeout: 120_000 });
|
||||
return { output: result.stdout.trim(), status: await this.status(root), stashes: await this.stashList(root) };
|
||||
}
|
||||
|
||||
@@ -161,12 +161,13 @@ Force repair after you have closed all Git tools for this repository?`)
|
||||
]);
|
||||
const recovery = [
|
||||
result.backupBranch ? `recovery branch ${result.backupBranch}` : null,
|
||||
result.stash ? `stash ${result.stash.ref}` : null,
|
||||
result.stash ? `quarantine stash ${result.stash.ref}` : null,
|
||||
result.review ? `Codex review manifest ${result.review.manifestPath}` : null,
|
||||
].filter(Boolean).join(" and ");
|
||||
showToast(
|
||||
"Workspace synchronized with Gitea",
|
||||
recovery
|
||||
? `Local work is preserved in ${recovery}. Ignored runtime files were retained.`
|
||||
? `Local work is quarantined in ${recovery}. Review it before restoring anything; ignored runtime files were retained.`
|
||||
: `Tracked files now match ${result.plan.upstream}; ignored runtime files were retained.`,
|
||||
"success",
|
||||
);
|
||||
|
||||
@@ -367,7 +367,7 @@ function renderGitTools(repository) {
|
||||
? ui.branches.map((branch) => `<div class="tool-row"><div><strong>${escapeHtml(branch.name)}</strong><span>${escapeHtml(branch.shortSha)}${branch.upstream ? ` · ${escapeHtml(branch.upstream)}` : " · unpublished"}</span></div>${branch.current ? '<span class="status-pill success">Current</span>' : `<button class="button" data-action="checkout-branch" data-branch="${attr(branch.name)}">Switch</button>`}</div>`).join("")
|
||||
: '<div class="empty-state compact"><p>Load branch information.</p></div>';
|
||||
const stashRows = ui.stashes.length
|
||||
? ui.stashes.map((stash) => `<div class="tool-row"><div><strong>${escapeHtml(stash.ref)}</strong><span>${escapeHtml(stash.subject)} · ${formatDate(stash.date)}</span></div><button class="button" data-action="pop-stash" data-stash-ref="${attr(stash.ref)}">Apply & drop</button></div>`).join("")
|
||||
? ui.stashes.map((stash) => `<div class="tool-row"><div><strong>${escapeHtml(stash.ref)}</strong><span>${escapeHtml(stash.subject)} · ${formatDate(stash.date)}</span></div>${stash.quarantined ? `<span class="status-pill warning" title="Workspace review ${attr(stash.reviewId || "")}">Codex review required</span>` : `<button class="button" data-action="pop-stash" data-stash-ref="${attr(stash.ref)}">Apply & drop</button>`}</div>`).join("")
|
||||
: '<div class="empty-state compact"><p>No stashes, or Git tools have not been loaded.</p></div>';
|
||||
const recoveryBody = recovery
|
||||
? `<div class="troubleshooting-summary"><span class="status-pill ${locks.length ? "warning" : "success"}">${locks.length ? `${locks.length} lock${locks.length === 1 ? "" : "s"}` : "No Git locks"}</span><span>${activeProcesses.length ? `${activeProcesses.length} active Git process(es)` : "No matching active Git process detected"}</span></div>${locks.length ? `<div class="tool-list">${locks.map((lock) => `<div class="tool-row"><div><strong>${escapeHtml(lock.name)}</strong><span>${Math.round(lock.ageMs / 1000)}s old · ${escapeHtml(lock.modifiedAt)}</span></div></div>`).join("")}</div>` : ""}${recommendations.length ? `<div class="tool-list recovery-actions">${recommendations.map((item) => `<div class="tool-row"><div><strong>${escapeHtml(item.label)}</strong><span>${item.safe ? "Safe automated action" : item.action ? "Creates a safety branch before changing history" : "Review required"}</span></div>${item.action ? `<button class="button ${item.safe ? "" : "danger"}" data-action="repair-repository-sync" data-strategy="${attr(item.action)}">Run</button>` : ""}</div>`).join("")}</div>` : ""}`
|
||||
|
||||
@@ -295,6 +295,16 @@ test('previews and safely mirrors a workspace to Gitea while preserving every cl
|
||||
assert.equal(result.status.head, reviewedPlan.targetSha);
|
||||
assert.match(result.backupBranch, /^forgeflow\/recovery-main-/);
|
||||
assert.ok(result.stash?.sha);
|
||||
assert.equal(result.stash.quarantined, true);
|
||||
assert.equal(result.review.id, reviewedPlan.id);
|
||||
assert.equal(result.review.status, 'pending-codex-review');
|
||||
const reviewManifest = JSON.parse(await fs.readFile(result.review.manifestPath, 'utf8'));
|
||||
assert.equal(reviewManifest.recoveryBranch, result.backupBranch);
|
||||
assert.equal(reviewManifest.stashSha, result.stash.sha);
|
||||
assert.deepEqual(
|
||||
new Set(reviewManifest.files.map((file) => file.path)),
|
||||
new Set(['README.md', 'local-notes.txt', 'changed-after-preview.txt'])
|
||||
);
|
||||
assert.equal((await git(['rev-parse', result.backupBranch], working)).stdout.trim(), localHead);
|
||||
assert.equal((await fs.readFile(path.join(working, 'README.md'), 'utf8')).replace(/\r\n/g, '\n'), 'changed on Gitea\n');
|
||||
assert.equal((await fs.readFile(path.join(working, 'remote-only.txt'), 'utf8')).replace(/\r\n/g, '\n'), 'new on Gitea\n');
|
||||
@@ -306,6 +316,15 @@ test('previews and safely mirrors a workspace to Gitea while preserving every cl
|
||||
assert.match(stashedPaths, /README\.md/);
|
||||
assert.match(stashedPaths, /local-notes\.txt/);
|
||||
assert.match(stashedPaths, /changed-after-preview\.txt/);
|
||||
await assert.rejects(
|
||||
service.popStash(working, result.stash.ref),
|
||||
(error) => error.code === 'WORKSPACE_QUARANTINE_REVIEW_REQUIRED'
|
||||
);
|
||||
await git(['switch', result.backupBranch], working);
|
||||
await assert.rejects(
|
||||
service.push(working),
|
||||
(error) => error.code === 'WORKSPACE_RECOVERY_BRANCH_LOCAL_ONLY'
|
||||
);
|
||||
});
|
||||
|
||||
test('repairs a diverged branch by creating a safety branch before resetting to upstream', async (t) => {
|
||||
|
||||
@@ -268,6 +268,15 @@ test("PowerShell update helper starts with param and has no BOM or stray leading
|
||||
assert.match(text, /npm ci --no-audit --no-fund/);
|
||||
assert.match(text, /package-lock\.json/);
|
||||
assert.doesNotMatch(text, /Get-Command npm\.cmd/);
|
||||
assert.match(text, /Integrated source update refuses to overwrite a Git working tree/);
|
||||
assert.ok(
|
||||
text.indexOf("Handshake-only verification completed successfully") <
|
||||
text.indexOf("Integrated source update refuses to overwrite a Git working tree"),
|
||||
);
|
||||
assert.ok(
|
||||
text.indexOf("$actualHash = Get-Sha256") <
|
||||
text.indexOf("Source update preflight passed"),
|
||||
);
|
||||
assert.ok(
|
||||
text.indexOf('Write-UpdateState -State "success"') <
|
||||
text.indexOf("Start-ForgeFlow -WorkingDirectory $SourcePath"),
|
||||
@@ -764,4 +773,15 @@ test("binary update helper verifies, waits, applies and records restart state",
|
||||
);
|
||||
}
|
||||
assert.doesNotMatch(helper, /Get-FileHash/);
|
||||
assert.match(helper, /function Start-ForgeFlowAndVerify/);
|
||||
assert.match(helper, /Start-Sleep -Milliseconds 1500/);
|
||||
assert.match(helper, /Updated portable executable failed its restart probe/);
|
||||
assert.ok(
|
||||
helper.indexOf("$actualSha256 = Get-Sha256") <
|
||||
helper.indexOf("Binary preflight passed"),
|
||||
);
|
||||
assert.ok(
|
||||
helper.indexOf("Binary preflight passed") <
|
||||
helper.indexOf('Write-UpdateState -State "waiting-for-exit"'),
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user