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
1370 lines
54 KiB
TypeScript
1370 lines
54 KiB
TypeScript
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
|
||
* - SFTP↔SFTP: 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);
|
||
}
|
||
}
|