[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
132
application/state/sftp/directoryReplacePromotion.ts
Normal file
132
application/state/sftp/directoryReplacePromotion.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
export const DIRECTORY_REPLACE_BACKUP_DELETE_ATTEMPTS = 3;
|
||||
|
||||
export function isMissingDirectoryReplacePathError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return /\bENOENT\b|no such file|not found|does not exist/i.test(message);
|
||||
}
|
||||
|
||||
export async function deleteDirectoryReplaceBackup(
|
||||
deleteBackup: () => Promise<unknown>,
|
||||
options: {
|
||||
attempts?: number;
|
||||
delay?: (attempt: number) => Promise<void>;
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
const attempts = Math.max(1, options.attempts ?? DIRECTORY_REPLACE_BACKUP_DELETE_ATTEMPTS);
|
||||
const delay = options.delay ?? ((attempt: number) => new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, attempt * 25);
|
||||
}));
|
||||
let lastError: unknown;
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
try {
|
||||
await deleteBackup();
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt < attempts) await delay(attempt);
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
export interface DirectoryReplacePromotionOptions {
|
||||
targetPath: string;
|
||||
stagedPath: string;
|
||||
backupPath: string;
|
||||
statPath: (path: string) => Promise<unknown>;
|
||||
renamePath: (source: string, target: string) => Promise<unknown>;
|
||||
deletePath: (path: string) => Promise<unknown>;
|
||||
}
|
||||
|
||||
async function pathExists(
|
||||
statPath: DirectoryReplacePromotionOptions["statPath"],
|
||||
candidate: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
return Boolean(await statPath(candidate));
|
||||
} catch (error) {
|
||||
if (isMissingDirectoryReplacePathError(error)) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function createDirectoryReplaceRecoveryError(
|
||||
promotionError: unknown,
|
||||
restoreError: unknown,
|
||||
options: Pick<DirectoryReplacePromotionOptions, "targetPath" | "stagedPath" | "backupPath">,
|
||||
): Error {
|
||||
const error = new Error(
|
||||
`Directory replacement failed and the original could not be restored. `
|
||||
+ `Target: ${options.targetPath}; backup: ${options.backupPath}; stage: ${options.stagedPath}. `
|
||||
+ `Promotion error: ${promotionError instanceof Error ? promotionError.message : String(promotionError)}. `
|
||||
+ `Restore error: ${restoreError instanceof Error ? restoreError.message : String(restoreError)}`,
|
||||
);
|
||||
(error as Error & { cause?: unknown }).cause = promotionError;
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a fully-written replacement directory with one shared recovery rule.
|
||||
*
|
||||
* Both live transfers and restart resume use this transaction:
|
||||
* - restore the only known-good backup before touching anything else;
|
||||
* - remove only a stale backup that accompanies a live final target;
|
||||
* - ignore only a genuinely missing final target;
|
||||
* - restore the original if stage promotion fails;
|
||||
* - keep a committed target and its backup recoverable if cleanup keeps failing.
|
||||
*/
|
||||
export async function promoteDirectoryReplaceStage(
|
||||
options: DirectoryReplacePromotionOptions,
|
||||
): Promise<void> {
|
||||
const {
|
||||
targetPath,
|
||||
stagedPath,
|
||||
backupPath,
|
||||
statPath,
|
||||
renamePath,
|
||||
deletePath,
|
||||
} = options;
|
||||
if (!targetPath || !stagedPath || !backupPath || stagedPath === targetPath) {
|
||||
throw new Error("Invalid directory replacement paths");
|
||||
}
|
||||
|
||||
const backupExists = await pathExists(statPath, backupPath);
|
||||
if (backupExists) {
|
||||
if (await pathExists(statPath, targetPath)) {
|
||||
await deleteDirectoryReplaceBackup(() => deletePath(backupPath));
|
||||
} else {
|
||||
// An interrupted prior commit left the backup as the only known-good tree.
|
||||
// Restore it before creating a new backup or publishing a rebuilt stage.
|
||||
await renamePath(backupPath, targetPath);
|
||||
}
|
||||
}
|
||||
|
||||
let backedUp = false;
|
||||
try {
|
||||
await renamePath(targetPath, backupPath);
|
||||
backedUp = true;
|
||||
} catch (error) {
|
||||
// Permission, conflict, and transport failures must stop publication. Only
|
||||
// a missing target is safe to treat as a new-directory replacement.
|
||||
if (!isMissingDirectoryReplacePathError(error)) throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
await renamePath(stagedPath, targetPath);
|
||||
} catch (promotionError) {
|
||||
if (backedUp) {
|
||||
try {
|
||||
await renamePath(backupPath, targetPath);
|
||||
} catch (restoreError) {
|
||||
throw createDirectoryReplaceRecoveryError(promotionError, restoreError, options);
|
||||
}
|
||||
}
|
||||
throw promotionError;
|
||||
}
|
||||
|
||||
if (backedUp) {
|
||||
// Publication is already committed. Persistent cleanup failure deliberately
|
||||
// leaves both final and backup present and reports a retryable failure.
|
||||
await deleteDirectoryReplaceBackup(() => deletePath(backupPath));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user