Files
NetMesh/application/state/sftp/dedicatedTransferResume.ts

1370 lines
54 KiB
TypeScript
Raw Permalink Normal View History

import { runTransferAndWaitForOwner } from "./waitForTransferOwner";
import type { Host, Identity, KnownHost, SSHKey, TerminalSettings, TransferTask } from "../../../domain/models";
import { validateTransferResumeSource } from "../../../domain/sftpTransferCenter";
import { STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY } from "../../../infrastructure/config/storageKeys";
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { buildSftpHostCredentials } from "./useSftpHostCredentials";
import {
getSftpTransferResourceKeys,
globalSftpTransferScheduler,
unlimitedSftpSchedulerAdmission,
} from "./globalTransferScheduler";
import { runWithTransferRetry } from "./transferRetry";
import { runSftpTransferWorkers } from "./transferConcurrency";
import { getParentPath, joinPath, joinTransferTargetPath } from "./utils";
import {
accountSftpDirectoryEntries,
claimSftpDirectoryVisit,
compareDirectoryTraversalPaths,
createDirectoryManifestAccumulator,
createSftpDirectoryTraversalBudget,
createDirectoryEntryIdentity,
createEmptyDirectoryResumeCheckpoint,
isValidDirectoryResumeCheckpoint,
releaseSftpDirectoryVisit,
shouldFollowSftpSymlinkDirectory,
type SftpDirectoryTraversalBudget,
} from "../../../domain/sftpDirectoryCheckpoint";
import {
isMissingDirectoryReplacePathError,
promoteDirectoryReplaceStage as promoteDirectoryReplacePaths,
} from "./directoryReplacePromotion";
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
export interface DedicatedResumeDeps {
hosts: readonly Host[];
keys: readonly SSHKey[];
identities: readonly Identity[];
knownHosts?: readonly KnownHost[];
terminalSettings?: Pick<TerminalSettings, "verifyHostKeys" | "keepaliveInterval" | "keepaliveCountMax">;
}
export interface DedicatedResumeProgress {
transferred: number;
total: number;
speed: number;
checkpointBytes?: number;
resumeStage?: TransferTask["resumeStage"];
downloadCheckpointBytes?: number;
uploadCheckpointBytes?: number;
sourceFingerprint?: string;
}
export type DedicatedResumeResult = {
success: boolean;
error?: string;
/** Leave the task in attention (e.g. source changed) instead of plain failed. */
needsAttention?: boolean;
/** Clear checkpoint so the next resume starts this file from zero. */
resetCheckpoint?: boolean;
};
export type DedicatedResumeOptions = {
children?: readonly TransferTask[];
onChildUpdate?: (child: TransferTask) => void;
onDirectoryCheckpointUpdate?: (checkpoint: TransferTask["directoryResumeCheckpoint"]) => void;
shouldAbort?: () => boolean;
};
/** Cap concurrent vault SSH opens across Resume All stampede. */
export const MAX_CONCURRENT_DEDICATED_SESSION_OPENS = 2;
let dedicatedOpenSlots = 0;
const dedicatedOpenWaiters: Array<() => void> = [];
export async function withDedicatedSessionOpenSlot<T>(work: () => Promise<T>): Promise<T> {
while (dedicatedOpenSlots >= MAX_CONCURRENT_DEDICATED_SESSION_OPENS) {
await new Promise<void>((resolve) => {
dedicatedOpenWaiters.push(resolve);
});
}
dedicatedOpenSlots += 1;
try {
return await work();
} finally {
dedicatedOpenSlots -= 1;
const next = dedicatedOpenWaiters.shift();
next?.();
}
}
/** Test helper — reset open-slot state between unit tests. */
export function resetDedicatedSessionOpenGateForTests(): void {
dedicatedOpenSlots = 0;
dedicatedOpenWaiters.length = 0;
}
/**
* Resolve the recorded endpoint, never a same-named replacement. Legacy tasks
* without an id may use label/hostname only when exactly one host matches.
*/
export function resolveHostForTransferEndpoint(
hosts: readonly Host[],
hostId?: string,
hostLabel?: string,
): Host | null {
if (hostId) {
return hosts.find((host) => host.id === hostId) ?? null;
}
const needle = (hostLabel || "").trim().toLowerCase();
if (!needle) return null;
const matches = hosts.filter((host) => {
const label = (host.label || "").trim().toLowerCase();
const hostname = (host.hostname || "").trim().toLowerCase();
return label === needle || hostname === needle;
});
return matches.length === 1 ? matches[0] : null;
}
export type OpenTransferSftpSessionOptions = {
/**
* Prefer a live terminal SSH transport (borrow a lease + open SFTP channel).
* Safe for bulk transfers when the main-process transport registry holds a
* transfer/sftp lease: closing the terminal tab returns only the shell lease
* and does not tear down the shared transport until this SFTP handle closes.
*/
sourceSessionId?: string;
/**
* When true (default), open an independent SSH/SFTP session from vault
* credentials. When false, try sourceSessionId first (shared transport).
*/
dedicated?: boolean;
};
/**
* Open a transfer-owned SFTP session.
* Default is a dedicated vault connection for restart/resume. With
* dedicated:false it uses the unified transport registry, preferring the
* specified terminal transport when available and otherwise reusing/dialing by
* endpoint without forcing a second physical SSH connection.
*/
export async function openTransferSftpSession(
host: Host,
deps: DedicatedResumeDeps,
options?: OpenTransferSftpSessionOptions,
): Promise<string> {
return withDedicatedSessionOpenSlot(async () => {
const bridge = netcattyBridge.get();
if (!bridge?.openSftp) throw new Error("SFTP bridge unavailable");
const wantDedicated = options?.dedicated !== false;
const credentials = buildSftpHostCredentials({
host,
hosts: [...deps.hosts],
keys: [...deps.keys],
identities: [...deps.identities],
knownHosts: deps.knownHosts ? [...deps.knownHosts] : undefined,
terminalSettings: deps.terminalSettings,
});
// Shared transport path: open an SFTP channel on the already-authenticated
// terminal connection. The transport registry lease keeps the conn alive
// after the shell tab closes until this sftpId is closed.
if (
!wantDedicated
&& options?.sourceSessionId
&& bridge.openSftpForSession
) {
try {
const sftpId = await bridge.openSftpForSession(options.sourceSessionId, credentials);
if (sftpId) return sftpId;
} catch {
// Fall through to vault credentials.
}
}
// Restart/resume owns an independent SSH dial. Normal pooled transfer opens
// leave reuseTransport enabled so the main-process registry can reuse an
// authenticated endpoint even when no terminal tab exists.
const openOpts = wantDedicated
? { ...credentials, reuseTransport: false as const }
: credentials;
// The main-process auth driver owns method ordering, fallback, MFA and
// retry. A renderer-side key-then-password retry performs a second full SSH
// dial and can repeat interactive authentication.
return bridge.openSftp(openOpts);
});
}
async function closeDedicatedSftpSession(sftpId: string | undefined): Promise<void> {
if (!sftpId) return;
try {
await netcattyBridge.get()?.closeSftp?.(sftpId);
} catch {
// Best-effort cleanup of transfer-owned sessions.
}
}
export type TransferEndpointKind = {
isDownload: boolean;
isUpload: boolean;
isRemoteToRemote: boolean;
};
/** Classify transfer endpoints for dedicated resume (local↔remote and SFTP↔SFTP). */
export function classifyDedicatedResumeEndpoints(
task: Pick<TransferTask, "direction" | "sourceHostId" | "targetHostId" | "sourceConnectionId" | "targetConnectionId">,
): TransferEndpointKind {
const isRemoteToRemote = task.direction === "remote-to-remote"
|| (!!task.sourceHostId && !!task.targetHostId
&& task.sourceConnectionId !== "local"
&& task.targetConnectionId !== "local");
if (isRemoteToRemote) {
return { isDownload: false, isUpload: false, isRemoteToRemote: true };
}
const isDownload = task.direction === "download"
|| (!!task.sourceHostId && !task.targetHostId)
|| (task.targetConnectionId === "local" && !!task.sourceHostId);
const isUpload = task.direction === "upload"
|| (!!task.targetHostId && !task.sourceHostId)
|| (task.sourceConnectionId === "local" && !!task.targetHostId);
return { isDownload, isUpload, isRemoteToRemote: false };
}
export type DirectoryResumeFilePlan = {
relativePath: string;
sourcePath: string;
targetPath: string;
size: number;
lastModified?: number;
directoryEntryIndex?: number;
directoryEntryIdentity?: string;
};
const DIRECTORY_RESUME_SYNC_BUDGET_MS = 8;
const DIRECTORY_RESUME_YIELD_CHECK_INTERVAL = 128;
function directoryResumeNow(): number {
return globalThis.performance?.now?.() ?? Date.now();
}
async function yieldDirectoryResumeWork(): Promise<void> {
const scheduler = (globalThis as typeof globalThis & {
scheduler?: { yield?: () => Promise<void> };
}).scheduler;
if (typeof scheduler?.yield === "function") {
await scheduler.yield();
return;
}
await new Promise<void>((resolve) => setTimeout(resolve, 0));
}
async function indexDirectoryResumeFiles(files: DirectoryResumeFilePlan[]): Promise<DirectoryResumeFilePlan[]> {
const sorted = [...files]
.sort((left, right) => compareDirectoryTraversalPaths(left.relativePath, right.relativePath));
if (sorted.length >= DIRECTORY_RESUME_YIELD_CHECK_INTERVAL) {
await yieldDirectoryResumeWork();
}
const indexed = new Array<DirectoryResumeFilePlan>(sorted.length);
let sliceStartedAt = directoryResumeNow();
for (let directoryEntryIndex = 0; directoryEntryIndex < sorted.length; directoryEntryIndex += 1) {
const file = sorted[directoryEntryIndex]!;
indexed[directoryEntryIndex] = {
...file,
directoryEntryIndex,
directoryEntryIdentity: createDirectoryEntryIdentity({
sourcePath: file.sourcePath,
targetPath: file.targetPath,
size: file.size,
lastModified: file.lastModified,
}),
};
if (
directoryEntryIndex > 0
&& directoryEntryIndex % DIRECTORY_RESUME_YIELD_CHECK_INTERVAL === 0
&& directoryResumeNow() - sliceStartedAt >= DIRECTORY_RESUME_SYNC_BUDGET_MS
) {
await yieldDirectoryResumeWork();
sliceStartedAt = directoryResumeNow();
}
}
return indexed;
}
export async function validateDirectoryResumeCheckpoint(
parent: Pick<TransferTask, "directoryResumeCheckpoint">,
planned: readonly DirectoryResumeFilePlan[],
): Promise<boolean> {
const checkpoint = parent.directoryResumeCheckpoint;
if (!isValidDirectoryResumeCheckpoint(checkpoint)) return false;
if (checkpoint.coveredEntries > planned.length) return false;
const rebuilt = checkpoint.version === 1
? {
version: 1 as const,
coveredEntries: 0,
completedEntries: 0,
manifestHash: "0".repeat(64),
}
: createEmptyDirectoryResumeCheckpoint();
const manifest = createDirectoryManifestAccumulator(rebuilt);
let sliceStartedAt = directoryResumeNow();
for (let index = 0; index < checkpoint.coveredEntries; index += 1) {
const identity = planned[index]?.directoryEntryIdentity;
if (!identity) return false;
manifest.append(identity);
rebuilt.coveredEntries += 1;
if (
index > 0
&& index % DIRECTORY_RESUME_YIELD_CHECK_INTERVAL === 0
&& directoryResumeNow() - sliceStartedAt >= DIRECTORY_RESUME_SYNC_BUDGET_MS
) {
await yieldDirectoryResumeWork();
sliceStartedAt = directoryResumeNow();
}
}
rebuilt.manifestHash = manifest.digest();
return rebuilt.manifestHash === checkpoint.manifestHash;
}
async function migrateLegacyDirectoryResumeCheckpoint(
checkpoint: NonNullable<TransferTask["directoryResumeCheckpoint"]>,
planned: readonly DirectoryResumeFilePlan[],
): Promise<NonNullable<TransferTask["directoryResumeCheckpoint"]>> {
if (checkpoint.version !== 1) return checkpoint;
const migrated = createEmptyDirectoryResumeCheckpoint();
const manifest = createDirectoryManifestAccumulator(migrated);
let sliceStartedAt = directoryResumeNow();
for (let index = 0; index < checkpoint.coveredEntries; index += 1) {
const identity = planned[index]?.directoryEntryIdentity;
if (!identity) throw new Error("Directory resume manifest is incomplete");
manifest.append(identity);
migrated.coveredEntries += 1;
if (
index > 0
&& index % DIRECTORY_RESUME_YIELD_CHECK_INTERVAL === 0
&& directoryResumeNow() - sliceStartedAt >= DIRECTORY_RESUME_SYNC_BUDGET_MS
) {
await yieldDirectoryResumeWork();
sliceStartedAt = directoryResumeNow();
}
}
migrated.manifestHash = manifest.digest();
migrated.completedEntries = checkpoint.completedEntries;
return migrated;
}
/**
* Destination root for directory resume. Replace-mode parents write under
* `stagedTargetPath` until the full tree is ready to promote.
*/
export function resolveDirectoryResumeTargetRoot(
parent: Pick<TransferTask, "targetPath" | "stagedTargetPath" | "replaceExistingTarget">,
): string {
if (parent.stagedTargetPath) return parent.stagedTargetPath;
return parent.targetPath;
}
/** Match a planned file to a persisted child by exact source+target paths. */
export function findPersistedChildForResumeFile(
children: readonly Pick<TransferTask, "id" | "status" | "sourcePath" | "targetPath" | "checkpointBytes" | "transferredBytes" | "resumeStage" | "downloadCheckpointBytes" | "uploadCheckpointBytes" | "sourceFingerprint" | "totalBytes" | "sourceLastModified">[],
file: Pick<DirectoryResumeFilePlan, "sourcePath" | "targetPath">,
) {
const exact = children.find((child) =>
child.sourcePath === file.sourcePath && child.targetPath === file.targetPath
);
return exact ?? null;
}
type PersistedResumeChild = Pick<
TransferTask,
"id" | "status" | "sourcePath" | "targetPath" | "checkpointBytes" | "transferredBytes"
| "resumeStage" | "downloadCheckpointBytes" | "uploadCheckpointBytes" | "sourceFingerprint"
| "totalBytes" | "sourceLastModified"
>;
/** Build once per directory resume; every planned-file match is then O(1). */
export function createPersistedResumeChildLookup(
children: readonly PersistedResumeChild[],
): (file: Pick<DirectoryResumeFilePlan, "sourcePath" | "targetPath">) => PersistedResumeChild | null {
const exactBySource = new Map<string, Map<string, PersistedResumeChild>>();
for (const child of children) {
let targets = exactBySource.get(child.sourcePath);
if (!targets) {
targets = new Map();
exactBySource.set(child.sourcePath, targets);
}
if (!targets.has(child.targetPath)) targets.set(child.targetPath, child);
}
return (file) => exactBySource.get(file.sourcePath)?.get(file.targetPath)
?? null;
}
export function shouldSkipCompletedResumeChild(
child: Pick<TransferTask, "status"> | null | undefined,
): boolean {
return child?.status === "completed";
}
/** Classify validateTransferResumeSource failures for UX (retry from 0 vs hard fail). */
export function classifyResumeSourceValidationError(message: string | null | undefined): {
kind: "ok" | "restart" | "modified" | "fatal";
message: string | null;
} {
if (!message) return { kind: "ok", message: null };
if (/beyond the current source size|size changed/i.test(message)) {
return { kind: "restart", message };
}
if (/modified while the transfer was paused/i.test(message)) {
return { kind: "modified", message };
}
return { kind: "fatal", message };
}
/**
* Resume a transfer by opening dedicated SFTP session(s) (not tied to any UI
* panel). Used after app restart or when the original browse connection is gone.
*
* - Single files: stream resume from checkpoint
* - Directories: re-walk tree, skip completed children, concurrent file resume
* - SFTPSFTP: open source + target dedicated sessions
*/
export async function resumeTransferWithDedicatedSession(
task: TransferTask,
deps: DedicatedResumeDeps,
onProgress?: (progress: DedicatedResumeProgress) => void,
options?: DedicatedResumeOptions,
): Promise<DedicatedResumeResult> {
if (task.isDirectory) {
return resumeDirectoryWithDedicatedSession(task, deps, onProgress, options);
}
return resumeSingleFileWithDedicatedSession(task, deps, onProgress, options?.shouldAbort);
}
type ResolvedEndpoints = {
isDownload: boolean;
isUpload: boolean;
isRemoteToRemote: boolean;
sourceHost: Host | null;
targetHost: Host | null;
/** Live terminal/SFTP session to borrow when vault/ephemeral host is missing. */
sourceSessionId?: string;
targetSessionId?: string;
resourceKeys: string[];
};
/**
* True for live terminal/SSH session ids that openSftpForSession can resolve.
* SFTP pane connection ids are `left-<uuid>` / `right-<uuid>` (createSftpConnectionId)
* and must not be passed to openSftpForSession.
*/
export function isUsableTransferSessionId(sessionId: string | undefined): sessionId is string {
if (!sessionId || sessionId === "local" || sessionId === "agent") return false;
if (/^(left|right)-/i.test(sessionId)) return false;
return true;
}
function missingHostError(label: string): string {
return `Cannot find host "${label}" in your vault. Re-add the host or start a new transfer.`;
}
/**
* Resolve remote endpoints for hard reconnect.
*
* Prefer vault/ephemeral host credentials **and** always attach any still-live
* terminal/SFTP connection ids so open can borrow the shared SSH transport
* instead of forcing a second full dial. When the host is missing entirely
* (quick-connect without save), session id alone is enough to reopen a channel.
*/
export function resolveResumeHosts(
task: TransferTask,
deps: DedicatedResumeDeps,
): { ok: true; endpoints: ResolvedEndpoints } | { ok: false; error: string } {
const kind = classifyDedicatedResumeEndpoints(task);
// Prefer live sessions even when the host is known — hard resume should
// reuse the open terminal transport (openSftpForSession / parked registry).
const liveSourceSessionId = isUsableTransferSessionId(task.sourceConnectionId)
? task.sourceConnectionId
: undefined;
const liveTargetSessionId = isUsableTransferSessionId(task.targetConnectionId)
? task.targetConnectionId
: undefined;
if (kind.isRemoteToRemote) {
const sourceHost = resolveHostForTransferEndpoint(deps.hosts, task.sourceHostId, task.sourceHostLabel);
const targetHost = resolveHostForTransferEndpoint(deps.hosts, task.targetHostId, task.targetHostLabel);
if ((!sourceHost && !liveSourceSessionId) || (!targetHost && !liveTargetSessionId)) {
const missing = !sourceHost && !liveSourceSessionId
? (task.sourceHostLabel || task.sourceHostId || "source")
: (task.targetHostLabel || task.targetHostId || "target");
return { ok: false, error: missingHostError(missing) };
}
return {
ok: true,
endpoints: {
...kind,
sourceHost,
targetHost,
sourceSessionId: liveSourceSessionId,
targetSessionId: liveTargetSessionId,
resourceKeys: getSftpTransferResourceKeys({
sourceHostId: sourceHost?.id ?? task.sourceHostId,
targetHostId: targetHost?.id ?? task.targetHostId,
}),
},
};
}
if (!kind.isDownload && !kind.isUpload) {
return {
ok: false,
error: "Unsupported transfer endpoints for dedicated resume.",
};
}
const remoteHost = kind.isDownload
? resolveHostForTransferEndpoint(deps.hosts, task.sourceHostId, task.sourceHostLabel)
: resolveHostForTransferEndpoint(deps.hosts, task.targetHostId, task.targetHostLabel);
const remoteSessionId = kind.isDownload ? liveSourceSessionId : liveTargetSessionId;
if (!remoteHost && !remoteSessionId) {
const label = kind.isDownload
? (task.sourceHostLabel || task.sourceHostId || "source")
: (task.targetHostLabel || task.targetHostId || "target");
return { ok: false, error: missingHostError(label) };
}
return {
ok: true,
endpoints: {
...kind,
sourceHost: kind.isDownload ? remoteHost : null,
targetHost: kind.isUpload ? remoteHost : null,
sourceSessionId: kind.isDownload ? remoteSessionId : undefined,
targetSessionId: kind.isUpload ? remoteSessionId : undefined,
resourceKeys: getSftpTransferResourceKeys({
sourceHostId: kind.isDownload ? (remoteHost?.id ?? task.sourceHostId) : undefined,
targetHostId: kind.isUpload ? (remoteHost?.id ?? task.targetHostId) : undefined,
}),
},
};
}
async function openSessionBackedSftp(sessionId: string): Promise<string> {
const bridge = netcattyBridge.get();
if (!bridge?.openSftpForSession) {
throw new Error("Session-backed SFTP open is unavailable");
}
const sftpId = await bridge.openSftpForSession(sessionId);
if (!sftpId) throw new Error("Could not open SFTP on the existing server session");
return sftpId;
}
/**
* Open a hard-reconnect SFTP channel the same way the transfer pool does:
* prefer a live terminal session, otherwise open with transport reuse so we
* attach to a parked/shared SSH conn instead of a cold dedicated dial.
*/
async function openRemoteEndpoint(
host: Host | null,
sessionId: string | undefined,
deps: DedicatedResumeDeps,
): Promise<string> {
if (host) {
return openTransferSftpSession(host, deps, {
dedicated: false,
sourceSessionId: sessionId,
});
}
if (sessionId) return openSessionBackedSftp(sessionId);
throw new Error("No remote host or session for dedicated resume");
}
async function openEndpointSessions(
endpoints: ResolvedEndpoints,
deps: DedicatedResumeDeps,
): Promise<{ sourceSftpId?: string; targetSftpId?: string }> {
if (endpoints.isRemoteToRemote) {
// Open sequentially under the open-slot gate so we never hold 2×N dials.
const sourceSftpId = await openRemoteEndpoint(
endpoints.sourceHost,
endpoints.sourceSessionId,
deps,
);
try {
const targetSftpId = await openRemoteEndpoint(
endpoints.targetHost,
endpoints.targetSessionId,
deps,
);
return { sourceSftpId, targetSftpId };
} catch (error) {
await closeDedicatedSftpSession(sourceSftpId);
throw error;
}
}
if (endpoints.isDownload) {
return {
sourceSftpId: await openRemoteEndpoint(
endpoints.sourceHost,
endpoints.sourceSessionId,
deps,
),
};
}
if (endpoints.isUpload) {
return {
targetSftpId: await openRemoteEndpoint(
endpoints.targetHost,
endpoints.targetSessionId,
deps,
),
};
}
throw new Error("No remote host for dedicated resume");
}
async function resumeSingleFileWithDedicatedSession(
task: TransferTask,
deps: DedicatedResumeDeps,
onProgress?: (progress: DedicatedResumeProgress) => void,
shouldAbort?: () => boolean,
): Promise<DedicatedResumeResult> {
const bridge = netcattyBridge.get();
if (!bridge?.startStreamTransfer) {
return { success: false, error: "Transfer bridge unavailable" };
}
const resolved = resolveResumeHosts(task, deps);
if (!resolved.ok) return { success: false, error: resolved.error };
const { endpoints } = resolved;
let sourceSftpId: string | undefined;
let targetSftpId: string | undefined;
try {
const result = await globalSftpTransferScheduler.run(
"dedicated-resume",
task.id,
endpoints.resourceKeys,
// Folder concurrency is for in-folder fan-out only. Dedicated reconnect of
// a single top-level task must not wait behind that host admission cap.
unlimitedSftpSchedulerAdmission,
async () => {
if (shouldAbort?.()) throw new Error("Transfer cancelled");
await runWithTransferRetry(async (attempt) => {
if (shouldAbort?.()) throw new Error("Transfer cancelled");
if (attempt > 0) {
await closeDedicatedSftpSession(sourceSftpId);
await closeDedicatedSftpSession(targetSftpId);
sourceSftpId = undefined;
targetSftpId = undefined;
}
if (!sourceSftpId && !targetSftpId) {
const opened = await openEndpointSessions(endpoints, deps);
sourceSftpId = opened.sourceSftpId;
targetSftpId = opened.targetSftpId;
}
const sourceType = endpoints.isUpload ? "local" as const : "sftp" as const;
const targetType = endpoints.isDownload ? "local" as const : "sftp" as const;
const sourceStat = sourceType === "sftp" && sourceSftpId
? await bridge.statSftp?.(sourceSftpId, task.sourcePath, "auto")
: sourceType === "local"
? await bridge.statLocal?.(task.sourcePath)
: null;
if (!sourceStat) throw new Error("Source is unavailable");
{
// Remote download sources may grow append-only (live logs); keep the
// planned snapshot size and existing checkpoint instead of restarting.
const allowSourceGrowth = sourceType === "sftp";
const validationError = validateTransferResumeSource(task, {
size: sourceStat.size,
lastModified: sourceStat.lastModified,
}, { allowSourceGrowth });
const classified = classifyResumeSourceValidationError(validationError);
if (classified.kind === "modified") {
const err = new Error(classified.message || validationError || "Source modified");
(err as Error & { dedicatedAttention?: boolean; resetCheckpoint?: boolean }).dedicatedAttention = true;
(err as Error & { resetCheckpoint?: boolean }).resetCheckpoint = true;
throw err;
}
if (classified.kind === "restart") {
// Source shrunk/grew — restart this file from byte 0.
task = { ...task, checkpointBytes: 0, transferredBytes: 0, totalBytes: sourceStat.size };
} else if (classified.kind === "fatal") {
throw new Error(classified.message || validationError || "Resume validation failed");
}
}
const streamResult = await bridge.startStreamTransfer!({
transferId: task.id,
sourcePath: task.sourcePath,
targetPath: task.targetPath,
sourceType,
targetType,
sourceSftpId,
targetSftpId,
sourceHostId: endpoints.sourceHost?.id,
targetHostId: endpoints.targetHost?.id,
totalBytes: Number.isFinite(task.totalBytes)
? task.totalBytes
: undefined,
resumable: task.resumable !== false,
checkpointBytes: task.checkpointBytes ?? task.transferredBytes ?? 0,
resumeStage: task.resumeStage,
downloadCheckpointBytes: task.downloadCheckpointBytes,
uploadCheckpointBytes: task.uploadCheckpointBytes,
sourceFingerprint: task.sourceFingerprint,
skipAdmission: true,
});
if (streamResult?.superseded === true) {
// Live same-id owner still running; wait for terminal events only.
for (;;) {
if (shouldAbort?.()) throw new Error("Transfer cancelled");
const latest = sftpTransferCenterStore.getTask(task.id);
const status = latest?.status;
if (status === "completed") break;
if (status === "failed") throw new Error(latest?.error || "Transfer failed");
if (status === "cancelled") throw new Error("Transfer cancelled");
await new Promise((resolve) => setTimeout(resolve, 200));
}
} else if (streamResult?.error || streamResult?.cancelled) {
throw new Error(streamResult.error || "Transfer cancelled");
}
}, { retries: 1, delayMs: 600 });
return { transferId: task.id };
},
);
if (result?.error) {
throw new Error(result.error);
}
return { success: true };
} catch (error) {
const err = error as Error & { dedicatedAttention?: boolean; resetCheckpoint?: boolean };
return {
success: false,
error: err instanceof Error ? err.message : String(error),
needsAttention: !!err.dedicatedAttention,
resetCheckpoint: !!err.resetCheckpoint,
};
} finally {
await closeDedicatedSftpSession(sourceSftpId);
await closeDedicatedSftpSession(targetSftpId);
}
}
type DirectoryResumeTraversal = {
files: DirectoryResumeFilePlan[];
directoryRelativePaths: string[];
};
async function listRemoteFilesRecursive(
sftpId: string,
rootPath: string,
relativePrefix = "",
followSymlinkDirectories = false,
symlinkDepth = 0,
shouldAbort?: () => boolean,
traversalBudget?: SftpDirectoryTraversalBudget,
): Promise<DirectoryResumeTraversal> {
if (shouldAbort?.()) throw new Error("Transfer cancelled");
const bridge = netcattyBridge.get();
if (!bridge?.listSftp) throw new Error("SFTP list unavailable");
const traversal = traversalBudget ?? createSftpDirectoryTraversalBudget();
const canonicalPath = await bridge.realpathSftp?.(sftpId, rootPath, "auto")
.catch(() => rootPath) ?? rootPath;
const claimedCanonicalPath = claimSftpDirectoryVisit(traversal, canonicalPath);
if (!claimedCanonicalPath) {
return { files: [], directoryRelativePaths: [] };
}
try {
const entries = (await bridge.listSftp(sftpId, rootPath, "auto"))
.filter((entry) => entry?.name && entry.name !== "." && entry.name !== "..");
accountSftpDirectoryEntries(traversal, entries.length);
if (shouldAbort?.()) throw new Error("Transfer cancelled");
const files: DirectoryResumeFilePlan[] = [];
const directoryRelativePaths: string[] = [];
for (const entry of entries) {
if (shouldAbort?.()) throw new Error("Transfer cancelled");
const relativePath = relativePrefix ? `${relativePrefix}/${entry.name}` : entry.name;
const fullPath = joinPath(rootPath, entry.name);
const isDirectory = entry.type === "directory";
const isFollowedSymlinkDirectory = followSymlinkDirectories
&& entry.type === "symlink"
&& entry.linkTarget === "directory"
&& shouldFollowSftpSymlinkDirectory(symlinkDepth);
if (isDirectory || isFollowedSymlinkDirectory) {
directoryRelativePaths.push(relativePath);
const nested = await listRemoteFilesRecursive(
sftpId,
fullPath,
relativePath,
followSymlinkDirectories,
isFollowedSymlinkDirectory ? symlinkDepth + 1 : symlinkDepth,
shouldAbort,
traversal,
);
for (const nestedFile of nested.files) files.push(nestedFile);
for (const nestedDirectory of nested.directoryRelativePaths) {
directoryRelativePaths.push(nestedDirectory);
}
continue;
}
if (entry.type === "directory") continue;
const sizeRaw = entry.size as unknown;
const size = typeof sizeRaw === "number"
? sizeRaw
: Number.parseInt(String(sizeRaw ?? "0"), 10) || 0;
const mtimeRaw = entry.lastModified as unknown;
const lastModified = typeof mtimeRaw === "number"
? mtimeRaw
: (Number.parseInt(String(mtimeRaw ?? ""), 10) || undefined);
files.push({
relativePath,
sourcePath: fullPath,
targetPath: "",
size,
lastModified,
});
}
return { files, directoryRelativePaths };
} finally {
releaseSftpDirectoryVisit(traversal, claimedCanonicalPath);
}
}
function normalizeLocalTreeRelativePath(sourceRoot: string, relativePath: string): string {
const normalized = relativePath.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
const rootName = sourceRoot.replace(/\\/g, "/").replace(/\/+$/g, "").split("/").pop() ?? "";
if (!rootName) return normalized;
if (normalized === rootName) return "";
return normalized.startsWith(`${rootName}/`) ? normalized.slice(rootName.length + 1) : normalized;
}
async function collectDirectoryResumeFiles(
parent: TransferTask,
endpoints: ResolvedEndpoints,
sourceSftpId: string | undefined,
shouldAbort?: () => boolean,
): Promise<{ files: DirectoryResumeFilePlan[]; directoryTargetPaths: string[] }> {
const bridge = netcattyBridge.get();
const destRoot = resolveDirectoryResumeTargetRoot(parent);
if (shouldAbort?.()) throw new Error("Transfer cancelled");
// Upload: local source tree.
if (endpoints.isUpload) {
if (!bridge?.listLocalTree) {
throw new Error("Local folder listing is unavailable for upload resume");
}
const localEntries = await bridge.listLocalTree(parent.sourcePath);
if (shouldAbort?.()) throw new Error("Transfer cancelled");
const normalizedEntries = localEntries.map((entry) => ({
...entry,
normalizedRelativePath: normalizeLocalTreeRelativePath(parent.sourcePath, entry.relativePath),
}));
const files = await indexDirectoryResumeFiles(normalizedEntries
.filter((entry) => entry.type === "file")
.map((entry) => ({
relativePath: entry.normalizedRelativePath,
sourcePath: entry.localPath,
targetPath: joinTransferTargetPath(destRoot, entry.normalizedRelativePath),
size: entry.size,
lastModified: entry.lastModified,
})));
return {
files,
directoryTargetPaths: normalizedEntries
.filter((entry) => entry.type === "directory" && !!entry.normalizedRelativePath)
.map((entry) => joinTransferTargetPath(destRoot, entry.normalizedRelativePath)),
};
}
// Download or remote-to-remote: list remote source.
if (!sourceSftpId) throw new Error("Source SFTP session missing for directory resume");
const remote = await listRemoteFilesRecursive(
sourceSftpId,
parent.sourcePath,
"",
endpoints.isDownload,
0,
shouldAbort,
);
const files = await indexDirectoryResumeFiles(remote.files.map((file) => ({
...file,
targetPath: joinTransferTargetPath(destRoot, file.relativePath),
})));
return {
files,
directoryTargetPaths: remote.directoryRelativePaths.map((relativePath) => (
joinTransferTargetPath(destRoot, relativePath)
)),
};
}
/** Atomically promote a replace-mode staged directory to the final target path. */
function expectedDirectoryReplaceStage(parent: Pick<TransferTask, "id" | "targetPath">): string {
const safeId = String(parent.id).replace(/[^A-Za-z0-9_-]/g, "_");
return `${parent.targetPath}.netcatty-${safeId}.part`;
}
function assertSafeDirectoryReplaceStage(parent: TransferTask): void {
if (
parent.stagedTargetPath
&& parent.stagedTargetPath !== expectedDirectoryReplaceStage(parent)
) {
throw new Error("Unsafe replacement stage path in saved transfer history");
}
}
async function promoteDirectoryReplaceStage(
parent: TransferTask,
endpoints: ResolvedEndpoints,
targetSftpId: string | undefined,
): Promise<void> {
const staged = parent.stagedTargetPath;
if (!staged || staged === parent.targetPath) return;
assertSafeDirectoryReplaceStage(parent);
const bridge = netcattyBridge.get();
if (!bridge) throw new Error("Transfer bridge unavailable");
const safeId = String(parent.id).replace(/[^A-Za-z0-9_-]/g, "_");
const backupPath = `${parent.targetPath}.netcatty-${safeId}.backup`;
if (endpoints.isDownload) {
if (!bridge.statLocal || !bridge.renameLocalFile || !bridge.deleteLocalFile) {
throw new Error("Local directory replacement is unavailable");
}
await promoteDirectoryReplacePaths({
targetPath: parent.targetPath,
stagedPath: staged,
backupPath,
statPath: bridge.statLocal,
renamePath: bridge.renameLocalFile,
deletePath: bridge.deleteLocalFile,
});
return;
}
if (!targetSftpId) throw new Error("Target SFTP session missing for directory promote");
if (!bridge.statSftp || !bridge.renameSftp || !bridge.deleteSftp) {
throw new Error("Remote directory replacement is unavailable");
}
await promoteDirectoryReplacePaths({
targetPath: parent.targetPath,
stagedPath: staged,
backupPath,
statPath: (candidate) => bridge.statSftp!(targetSftpId, candidate, "auto"),
renamePath: (source, target) => bridge.renameSftp!(targetSftpId, source, target, "auto"),
deletePath: (candidate) => bridge.deleteSftp!(targetSftpId, candidate, "auto"),
});
}
async function ensureLocalDir(dirPath: string): Promise<void> {
if (!dirPath) return;
const mkdirLocal = netcattyBridge.get()?.mkdirLocal;
if (!mkdirLocal) throw new Error("Local directory creation is unavailable");
await mkdirLocal(dirPath);
}
async function ensureRemoteDir(sftpId: string, dirPath: string): Promise<void> {
if (!dirPath || dirPath === "/") return;
const mkdirSftp = netcattyBridge.get()?.mkdirSftp;
if (!mkdirSftp) throw new Error("Remote directory creation is unavailable");
await mkdirSftp(sftpId, dirPath, "auto");
}
function isMissingTransferPathError(error: unknown): boolean {
return isMissingDirectoryReplacePathError(error);
}
async function resetDirectoryReplaceStage(
parent: TransferTask,
endpoints: ResolvedEndpoints,
targetSftpId: string | undefined,
): Promise<void> {
const staged = parent.stagedTargetPath;
if (!staged || staged === parent.targetPath) return;
assertSafeDirectoryReplaceStage(parent);
const bridge = netcattyBridge.get();
if (!bridge) throw new Error("Transfer bridge unavailable");
try {
if (endpoints.isDownload) {
if (!bridge.deleteLocalFile) throw new Error("Local directory replacement is unavailable");
if (bridge.statLocal && !await bridge.statLocal(staged)) return;
await bridge.deleteLocalFile(staged);
return;
}
if (!targetSftpId || !bridge.deleteSftp) {
throw new Error("Remote directory replacement is unavailable");
}
if (bridge.statSftp && !await bridge.statSftp(targetSftpId, staged, "auto")) return;
await bridge.deleteSftp(targetSftpId, staged, "auto");
} catch (error) {
if (!isMissingTransferPathError(error)) throw error;
}
}
async function resumeDirectoryWithDedicatedSession(
parent: TransferTask,
deps: DedicatedResumeDeps,
onProgress?: (progress: DedicatedResumeProgress) => void,
options?: DedicatedResumeOptions,
): Promise<DedicatedResumeResult> {
// Fresh recovery authorizes only pauses already present at its entry. Keep
// row references so a newer cross-window pause invalidates that permission.
const pausedAtResume = new Map(sftpTransferCenterStore.getSnapshot().tasks
.filter((child) => child.parentTaskId === parent.id && child.status === "paused")
.map((child) => [child.id, child]));
const bridge = netcattyBridge.get();
if (!bridge?.startStreamTransfer) {
return { success: false, error: "Transfer bridge unavailable" };
}
const resolved = resolveResumeHosts(parent, deps);
if (!resolved.ok) return { success: false, error: resolved.error };
const { endpoints } = resolved;
const existingChildren = options?.children ?? [];
let sourceSftpId: string | undefined;
let targetSftpId: string | undefined;
let completedCount = existingChildren.filter((child) => child.status === "completed").length;
let failedCount = 0;
let attentionCount = 0;
let totalFiles = Math.max(parent.totalBytes, existingChildren.length, 0);
const bumpParentProgress = (speed = 0) => {
onProgress?.({
transferred: completedCount,
total: totalFiles,
speed,
checkpointBytes: completedCount,
});
};
try {
const result = await globalSftpTransferScheduler.run(
"dedicated-resume",
parent.id,
endpoints.resourceKeys,
// Parent walk admits freely; per-file fan-out still uses folder concurrency.
unlimitedSftpSchedulerAdmission,
async () => {
if (options?.shouldAbort?.()) throw new Error("Transfer cancelled");
const opened = await openEndpointSessions(endpoints, deps);
sourceSftpId = opened.sourceSftpId;
targetSftpId = opened.targetSftpId;
const traversal = await collectDirectoryResumeFiles(
parent,
endpoints,
sourceSftpId,
options?.shouldAbort,
);
const planned = traversal.files;
totalFiles = planned.length;
const hasCompactCheckpoint = isValidDirectoryResumeCheckpoint(parent.directoryResumeCheckpoint);
// Compact identities keep size+mtime so a rewrite/truncate fails closed.
// Append-only growth of an already-covered remote file invalidates the
// prefix and retransfers those entries — safer than path-only proofs.
const compactCheckpointValid = hasCompactCheckpoint
&& await validateDirectoryResumeCheckpoint(parent, planned);
if (
compactCheckpointValid
&& parent.directoryResumeCheckpoint?.version === 1
&& !parent.stagedTargetPath
) {
const migrated = await migrateLegacyDirectoryResumeCheckpoint(
parent.directoryResumeCheckpoint,
planned,
);
parent = { ...parent, directoryResumeCheckpoint: migrated };
options?.onDirectoryCheckpointUpdate?.(migrated);
}
// File checkpoints intentionally do not persist an unbounded directory
// path list. Consequently they cannot prove that an empty directory was
// not deleted at the source. Rebuild every interrupted replace stage so
// promotion is an exact snapshot; ordinary copy/resume still reuses its
// compact file checkpoint.
const resetReplaceStage = !!parent.stagedTargetPath;
const reusableCompactCheckpoint = compactCheckpointValid && !resetReplaceStage;
if ((hasCompactCheckpoint && !compactCheckpointValid) || resetReplaceStage) {
// Fail closed: a changed/reordered source invalidates the whole compact
// prefix. Persist the reset before retransferring so another restart
// cannot reuse stale completion state.
options?.onDirectoryCheckpointUpdate?.(undefined);
}
if (resetReplaceStage) {
// A stage is an exact replacement snapshot. Without a valid manifest,
// retained files in it cannot be distinguished from files deleted at
// the source since the interrupted attempt, so rebuild it from empty.
await resetDirectoryReplaceStage(parent, endpoints, targetSftpId);
}
const destRoot = resolveDirectoryResumeTargetRoot(parent);
if (endpoints.isDownload) await ensureLocalDir(destRoot);
if (targetSftpId) await ensureRemoteDir(targetSftpId, destRoot);
for (const directoryPath of traversal.directoryTargetPaths) {
if (options?.shouldAbort?.()) throw new Error("Transfer cancelled");
if (endpoints.isDownload) await ensureLocalDir(directoryPath);
else if (targetSftpId) await ensureRemoteDir(targetSftpId, directoryPath);
}
const compactedCompleted = reusableCompactCheckpoint
? parent.directoryResumeCheckpoint!.completedEntries
: 0;
const retainedCompleted = !resetReplaceStage && (!hasCompactCheckpoint || compactCheckpointValid)
? existingChildren.filter((child) => child.status === "completed").length
: 0;
completedCount = compactedCompleted + retainedCompleted;
bumpParentProgress(0);
const findPersistedChild = createPersistedResumeChildLookup(existingChildren);
const pending = planned.filter((file) => {
const persisted = findPersistedChild(file);
if (
shouldSkipCompletedResumeChild(persisted)
&& !resetReplaceStage
&& (!hasCompactCheckpoint || compactCheckpointValid)
) return false;
return !(
reusableCompactCheckpoint
&& (file.directoryEntryIndex ?? Number.MAX_SAFE_INTEGER)
< parent.directoryResumeCheckpoint!.coveredEntries
&& !persisted
);
});
await runSftpTransferWorkers(
pending,
() => localStorageAdapter.readNumber(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY),
async (file) => {
if (options?.shouldAbort?.()) throw new Error("Transfer cancelled");
const persisted = findPersistedChild(file);
if (options?.shouldAbort?.()) throw new Error("Transfer cancelled");
const childId = persisted?.id ?? crypto.randomUUID();
const resetPersistedCheckpoint = resetReplaceStage
|| (hasCompactCheckpoint && !compactCheckpointValid);
// Prefer the original planned snapshot size for interrupted download
// children. Fresh traversal `file.size` may already include appends
// (live logs); re-planning to the grown size breaks growth-aware
// resume validation and restarts the file incorrectly.
const plannedTotalBytes = !resetPersistedCheckpoint
&& endpoints.isDownload
&& Number.isFinite(persisted?.totalBytes)
? Math.max(0, Number(persisted?.totalBytes) || 0)
: (file.size || persisted?.totalBytes || 0);
let childBase: TransferTask = {
...parent,
...persisted,
id: childId,
fileName: file.relativePath.split("/").pop() || file.relativePath,
originalFileName: file.relativePath.split("/").pop() || file.relativePath,
sourcePath: file.sourcePath,
targetPath: file.targetPath,
parentTaskId: parent.id,
isDirectory: false,
progressMode: "bytes",
ownerId: "dedicated-resume",
status: "transferring",
totalBytes: plannedTotalBytes,
transferredBytes: resetPersistedCheckpoint
? 0
: (persisted?.checkpointBytes ?? persisted?.transferredBytes ?? 0),
speed: 0,
startTime: persisted?.startTime ?? Date.now(),
endTime: undefined,
error: undefined,
reconnectRequired: false,
phase: "transferring",
resumable: parent.resumable !== false,
checkpointBytes: resetPersistedCheckpoint
? 0
: (persisted?.checkpointBytes ?? persisted?.transferredBytes ?? 0),
resumeStage: resetPersistedCheckpoint ? undefined : persisted?.resumeStage,
downloadCheckpointBytes: resetPersistedCheckpoint ? undefined : persisted?.downloadCheckpointBytes,
uploadCheckpointBytes: resetPersistedCheckpoint ? undefined : persisted?.uploadCheckpointBytes,
sourceFingerprint: resetPersistedCheckpoint ? undefined : persisted?.sourceFingerprint,
sourceLastModified: resetPersistedCheckpoint
? file.lastModified
: (persisted?.sourceLastModified ?? file.lastModified),
directoryEntryIndex: file.directoryEntryIndex,
directoryEntryIdentity: file.directoryEntryIdentity,
conflict: undefined,
};
if (endpoints.isDownload) {
await ensureLocalDir(getParentPath(file.targetPath));
}
if (targetSftpId) await ensureRemoteDir(targetSftpId, getParentPath(file.targetPath));
try {
if (options?.shouldAbort?.()) throw new Error("Transfer cancelled");
const sourceType = endpoints.isUpload ? "local" as const : "sftp" as const;
const targetType = endpoints.isDownload ? "local" as const : "sftp" as const;
const sourceStat = sourceType === "sftp" && sourceSftpId
? await bridge.statSftp?.(sourceSftpId, file.sourcePath, "auto")
: sourceType === "local"
? await bridge.statLocal?.(file.sourcePath)
: null;
if (!sourceStat) {
throw new Error("Source is unavailable");
}
const allowSourceGrowth = sourceType === "sftp";
const validationError = validateTransferResumeSource(childBase, {
size: sourceStat.size,
lastModified: sourceStat.lastModified,
}, { allowSourceGrowth });
const classified = classifyResumeSourceValidationError(validationError);
if (classified.kind === "restart") {
childBase = {
...childBase,
checkpointBytes: 0,
transferredBytes: 0,
totalBytes: sourceStat.size,
sourceLastModified: sourceStat.lastModified,
};
} else if (classified.kind === "modified") {
attentionCount += 1;
options?.onChildUpdate?.({
...childBase,
status: "attention",
error: classified.message || validationError || "Source was modified",
speed: 0,
reconnectRequired: false,
phase: undefined,
retryable: true,
});
return;
} else if (classified.kind === "fatal") {
throw new Error(classified.message || validationError || "Resume validation failed");
} else {
// Keep the planned snapshot size for growing remote downloads so
// we do not re-plan the whole file mid-resume. Preserve explicit
// zero-byte plans (`||` would incorrectly promote to grown size).
const plannedBytes = Number(childBase.totalBytes);
const hasPlannedBytes = Number.isFinite(plannedBytes) && plannedBytes >= 0;
childBase = {
...childBase,
totalBytes: allowSourceGrowth
? (hasPlannedBytes ? plannedBytes : sourceStat.size)
: (sourceStat.size || childBase.totalBytes),
sourceLastModified: allowSourceGrowth
&& hasPlannedBytes
&& sourceStat.size > plannedBytes
? (childBase.sourceLastModified ?? sourceStat.lastModified)
: (sourceStat.lastModified ?? childBase.sourceLastModified),
};
}
// Re-check abort after async stat before inserting a transferring child.
if (options?.shouldAbort?.()) throw new Error("Transfer cancelled");
options?.onChildUpdate?.(childBase);
const streamResult = await runTransferAndWaitForOwner(childBase, () => bridge.startStreamTransfer!({
transferId: childId,
// Lifecycle events must carry the current identity even while
// large-history renderer child updates remain batched.
parentTaskId: childBase.parentTaskId,
directoryEntryIndex: childBase.directoryEntryIndex,
directoryEntryIdentity: childBase.directoryEntryIdentity,
sourcePath: file.sourcePath,
targetPath: file.targetPath,
sourceType,
targetType,
sourceSftpId,
targetSftpId,
sourceHostId: endpoints.sourceHost?.id,
targetHostId: endpoints.targetHost?.id,
totalBytes: Number.isFinite(childBase.totalBytes)
? childBase.totalBytes
: (file.size || undefined),
resumable: parent.resumable !== false,
checkpointBytes: childBase.checkpointBytes ?? 0,
resumeStage: childBase.resumeStage,
downloadCheckpointBytes: childBase.downloadCheckpointBytes,
uploadCheckpointBytes: childBase.uploadCheckpointBytes,
sourceFingerprint: childBase.sourceFingerprint,
skipAdmission: true,
}), () => options?.shouldAbort?.() === true, pausedAtResume.get(childId));
if (streamResult?.error || streamResult?.cancelled) {
throw new Error(streamResult.error || "Transfer cancelled");
}
completedCount += 1;
options?.onChildUpdate?.({
...childBase,
status: "completed",
transferredBytes: Number.isFinite(childBase.totalBytes)
? childBase.totalBytes
: (file.size || childBase.transferredBytes),
speed: 0,
endTime: Date.now(),
error: undefined,
reconnectRequired: false,
phase: undefined,
});
bumpParentProgress(0);
} catch (error) {
if (options?.shouldAbort?.() || /cancelled|canceled/i.test(error instanceof Error ? error.message : String(error))) {
throw error instanceof Error ? error : new Error(String(error));
}
failedCount += 1;
options?.onChildUpdate?.({
...childBase,
status: "failed",
error: error instanceof Error ? error.message : String(error),
speed: 0,
endTime: Date.now(),
reconnectRequired: false,
phase: undefined,
});
}
},
);
return { transferId: parent.id };
},
);
if (result?.error) throw new Error(result.error);
if (attentionCount > 0 && failedCount === 0 && completedCount + attentionCount >= totalFiles) {
return {
success: false,
needsAttention: true,
error: attentionCount === 1
? "Source was modified for 1 file — review and retry"
: `Source was modified for ${attentionCount} files — review and retry`,
};
}
if (failedCount > 0 || attentionCount > 0) {
return {
success: false,
needsAttention: attentionCount > 0 && failedCount === 0,
error: [
failedCount > 0
? (failedCount === totalFiles
? `All ${failedCount} files failed to resume`
: `${failedCount} of ${totalFiles} files failed to resume`)
: null,
attentionCount > 0 ? `${attentionCount} file(s) need attention (source changed)` : null,
].filter(Boolean).join("; "),
};
}
// Full success — promote replace-mode stage onto the final target path.
if (parent.stagedTargetPath) {
await promoteDirectoryReplaceStage(parent, endpoints, targetSftpId);
// Partial upsert only — do not re-publish a stale full parent snapshot
// (would clobber live transferredBytes / totalBytes from patchTask).
options?.onChildUpdate?.({
id: parent.id,
stagedTargetPath: undefined,
replaceExistingTarget: undefined,
} as TransferTask);
}
return { success: true };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error),
};
} finally {
await closeDedicatedSftpSession(sourceSftpId);
await closeDedicatedSftpSession(targetSftpId);
}
}