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
268 lines
8.2 KiB
TypeScript
268 lines
8.2 KiB
TypeScript
/**
|
|
* Low-cost retry for progressive / external drag-drop file uploads.
|
|
*
|
|
* These rows use sourceConnectionId "external" and never have dual-pane
|
|
* endpoints, so the generic processTransfer retry path silently no-ops.
|
|
* Retry re-opens a single startStreamTransfer with the stored local path.
|
|
*/
|
|
|
|
import type { TransferTask } from "../../../domain/models";
|
|
import type { TransferConnectionLease } from "./transferConnectionPool";
|
|
import { isTransferCancelledFlag } from "./transferCancelLatch";
|
|
|
|
export function isExternalDragDropFileUpload(
|
|
task: Pick<
|
|
TransferTask,
|
|
| "origin"
|
|
| "direction"
|
|
| "sourceConnectionId"
|
|
| "isDirectory"
|
|
| "sourcePath"
|
|
| "targetPath"
|
|
| "retryable"
|
|
| "status"
|
|
>,
|
|
): boolean {
|
|
if (task.retryable === false) return false;
|
|
if (task.origin !== "drag-drop") return false;
|
|
if (task.direction !== "upload") return false;
|
|
if (task.sourceConnectionId !== "external") return false;
|
|
if (task.isDirectory) return false;
|
|
if (!task.sourcePath || task.sourcePath === "local") return false;
|
|
if (!task.targetPath) return false;
|
|
return task.status === "failed" || task.status === "cancelled" || task.status === "attention";
|
|
}
|
|
|
|
export type ExternalDragDropRetryDeps = {
|
|
getBrowseSftpId: (connectionId: string) => string | undefined;
|
|
acquireTransferSession?: (
|
|
hostId: string,
|
|
transferId: string,
|
|
) => Promise<TransferConnectionLease>;
|
|
startStreamTransfer: (options: {
|
|
transferId: string;
|
|
sourcePath: string;
|
|
targetPath: string;
|
|
sourceType: "local";
|
|
targetType: "local" | "sftp";
|
|
targetSftpId?: string;
|
|
targetHostId?: string;
|
|
totalBytes?: number;
|
|
resumable?: boolean;
|
|
checkpointBytes?: number;
|
|
}) => Promise<{ error?: string; cancelled?: boolean } | undefined>;
|
|
clearPendingCancel?: (transferId: string) => Promise<unknown>;
|
|
cleanupArtifacts?: (task: TransferTask) => Promise<void>;
|
|
onPatch: (taskId: string, updates: Partial<TransferTask>) => void;
|
|
/** Live store lookup for terminal cancel races and completion bytes. */
|
|
getTask?: (taskId: string) => TransferTask | undefined;
|
|
/** Children of a progressive parent (for rollup after child success). */
|
|
getChildTasks?: (parentTaskId: string) => TransferTask[];
|
|
};
|
|
|
|
/**
|
|
* Restart a failed/cancelled external drag-drop file upload in place (same id).
|
|
* Returns true when the stream completed successfully.
|
|
*/
|
|
export async function retryExternalDragDropFileUpload(
|
|
task: TransferTask,
|
|
deps: ExternalDragDropRetryDeps,
|
|
): Promise<{ success: boolean; error?: string }> {
|
|
if (!isExternalDragDropFileUpload(task)) {
|
|
return { success: false, error: "Not an external drag-drop file upload" };
|
|
}
|
|
|
|
try {
|
|
await deps.clearPendingCancel?.(task.id);
|
|
} catch {
|
|
// best-effort
|
|
}
|
|
try {
|
|
await deps.cleanupArtifacts?.(task);
|
|
} catch {
|
|
// best-effort
|
|
}
|
|
|
|
const targetIsLocal = task.targetConnectionId === "local" || !task.targetHostId;
|
|
let lease: TransferConnectionLease | null = null;
|
|
let targetSftpId: string | undefined;
|
|
|
|
try {
|
|
if (!targetIsLocal) {
|
|
// Prefer a dedicated pool session when hostId is known — same policy as
|
|
// progressive upload (do not pin the browse/tab session).
|
|
if (task.targetHostId && deps.acquireTransferSession) {
|
|
lease = await deps.acquireTransferSession(task.targetHostId, `${task.id}:retry`);
|
|
targetSftpId = lease.sftpId;
|
|
}
|
|
if (!targetSftpId) {
|
|
targetSftpId = deps.getBrowseSftpId(task.targetConnectionId);
|
|
}
|
|
if (!targetSftpId) {
|
|
const error = "No SFTP session available to retry this upload. Reconnect and try again.";
|
|
deps.onPatch(task.id, {
|
|
status: "failed",
|
|
error,
|
|
endTime: Date.now(),
|
|
speed: 0,
|
|
});
|
|
return { success: false, error };
|
|
}
|
|
}
|
|
|
|
deps.onPatch(task.id, {
|
|
status: "transferring",
|
|
error: undefined,
|
|
transferredBytes: 0,
|
|
checkpointBytes: 0,
|
|
speed: 0,
|
|
endTime: undefined,
|
|
phase: "transferring",
|
|
reconnectRequired: false,
|
|
pauseUnavailableReason: undefined,
|
|
startTime: Date.now(),
|
|
});
|
|
|
|
const result = await deps.startStreamTransfer({
|
|
transferId: task.id,
|
|
sourcePath: task.sourcePath,
|
|
targetPath: task.targetPath,
|
|
sourceType: "local",
|
|
targetType: targetIsLocal ? "local" : "sftp",
|
|
targetSftpId: targetIsLocal ? undefined : targetSftpId,
|
|
targetHostId: targetIsLocal ? undefined : task.targetHostId,
|
|
totalBytes: task.totalBytes > 0 ? task.totalBytes : undefined,
|
|
resumable: true,
|
|
checkpointBytes: 0,
|
|
});
|
|
|
|
// Late cancel can settle the row before this return; never resurrect it.
|
|
if (isTransferCancelledFlag(task.id) || result?.cancelled) {
|
|
deps.onPatch(task.id, {
|
|
status: "cancelled",
|
|
error: undefined,
|
|
endTime: Date.now(),
|
|
speed: 0,
|
|
phase: undefined,
|
|
});
|
|
return { success: false, error: "Transfer cancelled" };
|
|
}
|
|
if (result?.error) {
|
|
deps.onPatch(task.id, {
|
|
status: "failed",
|
|
error: result.error,
|
|
endTime: Date.now(),
|
|
speed: 0,
|
|
phase: undefined,
|
|
});
|
|
return { success: false, error: result.error };
|
|
}
|
|
|
|
const live = deps.getTask?.(task.id);
|
|
const completedBytes = Math.max(
|
|
live?.totalBytes ?? 0,
|
|
live?.transferredBytes ?? 0,
|
|
task.totalBytes,
|
|
0,
|
|
);
|
|
deps.onPatch(task.id, {
|
|
status: "completed",
|
|
error: undefined,
|
|
transferredBytes: completedBytes,
|
|
totalBytes: Math.max(live?.totalBytes ?? 0, task.totalBytes, completedBytes),
|
|
endTime: Date.now(),
|
|
speed: 0,
|
|
phase: undefined,
|
|
});
|
|
|
|
// Progressive parents finalize once with "N of M failed". After a child
|
|
// retry succeeds, re-roll the parent when no failed children remain.
|
|
if (task.parentTaskId && deps.getChildTasks) {
|
|
rollupParentAfterChildSuccess(task.parentTaskId, task.id, deps);
|
|
}
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
if (isTransferCancelledFlag(task.id) || /cancel/i.test(message)) {
|
|
deps.onPatch(task.id, {
|
|
status: "cancelled",
|
|
error: undefined,
|
|
endTime: Date.now(),
|
|
speed: 0,
|
|
phase: undefined,
|
|
});
|
|
return { success: false, error: "Transfer cancelled" };
|
|
}
|
|
deps.onPatch(task.id, {
|
|
status: "failed",
|
|
error: message,
|
|
endTime: Date.now(),
|
|
speed: 0,
|
|
phase: undefined,
|
|
});
|
|
if (lease && /session|sftp|disconnect|not found/i.test(message)) {
|
|
try { lease.discard(); } catch { /* best-effort */ }
|
|
lease = null;
|
|
}
|
|
return { success: false, error: message };
|
|
} finally {
|
|
try { lease?.release(); } catch { /* best-effort */ }
|
|
}
|
|
}
|
|
|
|
function rollupParentAfterChildSuccess(
|
|
parentTaskId: string,
|
|
completedChildId: string,
|
|
deps: ExternalDragDropRetryDeps,
|
|
): void {
|
|
// Only re-roll parents that already finished the progressive walk as failed.
|
|
// Never promote a still-scanning/transferring parent mid-walk.
|
|
const parent = deps.getTask?.(parentTaskId);
|
|
if (
|
|
parent
|
|
&& parent.status !== "failed"
|
|
&& parent.status !== "attention"
|
|
&& parent.status !== "cancelled"
|
|
) {
|
|
return;
|
|
}
|
|
const children = deps.getChildTasks?.(parentTaskId) ?? [];
|
|
if (children.length === 0) return;
|
|
const stillFailed = children.some(
|
|
(child) =>
|
|
child.id !== completedChildId
|
|
&& (child.status === "failed" || child.status === "attention"),
|
|
);
|
|
if (stillFailed) return;
|
|
const active = children.some(
|
|
(child) =>
|
|
child.id !== completedChildId
|
|
&& (
|
|
child.status === "transferring"
|
|
|| child.status === "pending"
|
|
|| child.status === "queued"
|
|
|| child.status === "pausing"
|
|
|| child.status === "paused"
|
|
),
|
|
);
|
|
if (active) return;
|
|
const completedCount = children.filter(
|
|
(child) => child.id === completedChildId || child.status === "completed",
|
|
).length;
|
|
const total = Math.max(
|
|
Number(parent?.totalBytes) || 0,
|
|
children.length,
|
|
completedCount,
|
|
);
|
|
deps.onPatch(parentTaskId, {
|
|
status: "completed",
|
|
error: undefined,
|
|
transferredBytes: completedCount,
|
|
totalBytes: total,
|
|
speed: 0,
|
|
endTime: Date.now(),
|
|
phase: undefined,
|
|
});
|
|
}
|