Files
NetMesh/domain/sftpTransferCenter.ts
zhaolei 3c72efcb7f
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
[Init] Initial commit - NetMesh terminal manager
2026-09-13 18:24:01 +08:00

369 lines
16 KiB
TypeScript

import type { TransferTask } from "./models";
import {
compareDirectoryTraversalPaths,
createDirectoryManifestAccumulator,
createDirectoryEntryIdentity,
createEmptyDirectoryResumeCheckpoint,
isValidDirectoryResumeCheckpoint,
} from "./sftpDirectoryCheckpoint";
export const SFTP_TRANSFER_CENTER_VERSION = 1;
export const SFTP_TRANSFER_HISTORY_MAX = 200;
export const SFTP_TRANSFER_HISTORY_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
const TERMINAL_STATUSES = new Set<TransferTask["status"]>(["completed", "failed", "cancelled"]);
const RUNNING_STATUSES = new Set<TransferTask["status"]>(["transferring", "pausing"]);
// After an app restart no backend streams/sessions remain. Any non-terminal
// in-flight status must become a manual-resume "interrupted" task — including
// paused work, which also loses its live transfer handle on quit.
const RESTORED_INTERRUPTED_STATUSES = new Set<TransferTask["status"]>([
"pending",
"queued",
"transferring",
"pausing",
"paused",
]);
export interface PersistedSftpTransferCenter {
version: typeof SFTP_TRANSFER_CENTER_VERSION;
tasks: TransferTask[];
}
const SAFE_TASK_KEYS: ReadonlySet<keyof TransferTask> = new Set([
"id", "batchId", "fileName", "originalFileName", "sourcePath", "targetPath",
"sourceConnectionId", "targetConnectionId", "targetHostId", "targetConnectionKey",
"direction", "status", "totalBytes", "transferredBytes", "speed", "error",
"startTime", "endTime", "isDirectory", "progressMode", "childTasks", "parentTaskId",
"sourceLastModified", "skipConflictCheck", "replaceExistingTarget", "retryable",
"ownerId", "sourceHostId", "sourceHostLabel", "targetHostLabel", "origin", "background",
"phase", "controlKind", "resumable", "checkpointBytes", "priority", "updatedAt", "pauseUnavailableReason",
"resumeStage", "downloadCheckpointBytes", "uploadCheckpointBytes",
"conflict",
"stagedTargetPath",
"sourceFingerprint",
"reconnectRequired",
"lifecycleEpoch",
"directoryEntryIndex",
"directoryEntryIdentity",
"directoryResumeCheckpoint",
]);
export function sanitizeSftpTransferTask(value: unknown): TransferTask | null {
if (!value || typeof value !== "object") return null;
const source = value as Record<string, unknown>;
if (typeof source.id !== "string" || typeof source.fileName !== "string") return null;
if (typeof source.sourcePath !== "string" || typeof source.targetPath !== "string") return null;
const sanitized: Record<string, unknown> = {};
for (const key of SAFE_TASK_KEYS) {
if (source[key] !== undefined) sanitized[key] = source[key];
}
const task = sanitized as unknown as TransferTask;
if (!Number.isSafeInteger(task.directoryEntryIndex) || (task.directoryEntryIndex ?? -1) < 0) {
task.directoryEntryIndex = undefined;
}
if (!/^[a-f0-9]{64}$/.test(task.directoryEntryIdentity ?? "")) {
task.directoryEntryIdentity = undefined;
}
if (!isValidDirectoryResumeCheckpoint(task.directoryResumeCheckpoint)) {
task.directoryResumeCheckpoint = undefined;
} else {
const checkpoint = task.directoryResumeCheckpoint;
// Rebuild the fixed schema so unknown/legacy payload fields cannot smuggle
// an unbounded path list back into localStorage.
task.directoryResumeCheckpoint = {
version: checkpoint.version,
coveredEntries: checkpoint.coveredEntries,
completedEntries: checkpoint.completedEntries,
manifestHash: checkpoint.manifestHash,
};
}
// After force-quit / restart no backend stream remains. Every unfinished row
// (including a previously interrupted row that was already persisted) needs a
// dedicated reconnect — do not leave reconnectRequired false.
if (RESTORED_INTERRUPTED_STATUSES.has(task.status) || task.status === "interrupted") {
task.status = "interrupted";
task.speed = 0;
task.reconnectRequired = true;
// Phase labels like "transferring" would otherwise still render as "传输中"
// even though the task is dead after restart.
task.phase = undefined;
task.error = task.error || undefined;
// Stale lifecycle epochs from a previous process must not block first
// progress after dedicated reconnect.
task.lifecycleEpoch = undefined;
}
// Terminal rows must not keep conflict payloads. Older Skip paths cancelled the
// task without clearing conflict, so SFTP remount reopened the conflict dialog
// while the transfer center looked empty (no attention bucket row).
if (TERMINAL_STATUSES.has(task.status) && task.conflict) {
task.conflict = undefined;
}
return task;
}
export function serializeSftpTransferCenter(tasks: readonly TransferTask[]): string {
return JSON.stringify({
version: SFTP_TRANSFER_CENTER_VERSION,
tasks: tasks.map((task) => sanitizeSftpTransferTask(task)).filter((task): task is TransferTask => task !== null),
} satisfies PersistedSftpTransferCenter);
}
export function deserializeSftpTransferCenter(raw: string | null | undefined): PersistedSftpTransferCenter {
if (!raw) return { version: SFTP_TRANSFER_CENTER_VERSION, tasks: [] };
try {
const parsed = JSON.parse(raw) as { version?: unknown; tasks?: unknown };
if (parsed.version !== SFTP_TRANSFER_CENTER_VERSION || !Array.isArray(parsed.tasks)) {
return { version: SFTP_TRANSFER_CENTER_VERSION, tasks: [] };
}
return {
version: SFTP_TRANSFER_CENTER_VERSION,
tasks: parsed.tasks.map(sanitizeSftpTransferTask).filter((task): task is TransferTask => task !== null),
};
} catch {
return { version: SFTP_TRANSFER_CENTER_VERSION, tasks: [] };
}
}
export function pruneSftpTransferHistory(
tasks: readonly TransferTask[],
now = Date.now(),
): TransferTask[] {
// A failed terminal directory can have more retained failed children than
// the global history cap. Once those rows are evicted, a compact checkpoint
// cannot distinguish them from completed children. Drop the ambiguous skip
// state so Retry/Resume safely walks every source entry again.
let compacted = tasks.map((task) => (
task.isDirectory
&& !task.parentTaskId
&& task.status === "failed"
&& task.directoryResumeCheckpoint
? { ...task, directoryResumeCheckpoint: undefined }
: task
));
const unfinishedDirectoryParents = compacted.filter((task) => (
task.isDirectory
&& !task.parentTaskId
&& !TERMINAL_STATUSES.has(task.status)
));
const childrenByParent = new Map<string, TransferTask[]>();
for (const task of compacted) {
if (!task.parentTaskId) continue;
const children = childrenByParent.get(task.parentTaskId) ?? [];
children.push(task);
childrenByParent.set(task.parentTaskId, children);
}
const compactedChildIds = new Set<string>();
const parentUpdates = new Map<string, TransferTask>();
const normalizedChildUpdates = new Map<string, TransferTask>();
for (const parent of unfinishedDirectoryParents) {
let parentChildren = childrenByParent.get(parent.id) ?? [];
// Upgrade pre-checkpoint history safely. The sorted known set becomes a
// candidate prefix; dedicated resume validates it against a fresh walk.
// Any added/reordered/modified source entry invalidates the hash and causes
// a conservative restart instead of an incorrect skip.
if (
!isValidDirectoryResumeCheckpoint(parent.directoryResumeCheckpoint)
&& parentChildren.some((child) => (
!Number.isSafeInteger(child.directoryEntryIndex)
|| !/^[a-f0-9]{64}$/.test(child.directoryEntryIdentity ?? "")
))
) {
parentChildren = [...parentChildren]
.sort((left, right) => compareDirectoryTraversalPaths(left.sourcePath, right.sourcePath))
.map((child, directoryEntryIndex) => ({
...child,
directoryEntryIndex,
directoryEntryIdentity: createDirectoryEntryIdentity({
sourcePath: child.sourcePath,
targetPath: child.targetPath,
size: child.totalBytes,
lastModified: child.sourceLastModified,
}),
}));
for (const child of parentChildren) normalizedChildUpdates.set(child.id, child);
}
const checkpoint = isValidDirectoryResumeCheckpoint(parent.directoryResumeCheckpoint)
? { ...parent.directoryResumeCheckpoint }
: createEmptyDirectoryResumeCheckpoint();
const initialCoveredEntries = checkpoint.coveredEntries;
const childrenByIndex = new Map<number, TransferTask>();
for (const child of parentChildren) {
if (!Number.isSafeInteger(child.directoryEntryIndex) || (child.directoryEntryIndex ?? -1) < 0) continue;
if (!/^[a-f0-9]{64}$/.test(child.directoryEntryIdentity ?? "")) continue;
if (!childrenByIndex.has(child.directoryEntryIndex!)) {
childrenByIndex.set(child.directoryEntryIndex!, child);
}
}
const manifest = createDirectoryManifestAccumulator(checkpoint);
while (childrenByIndex.has(checkpoint.coveredEntries)) {
const child = childrenByIndex.get(checkpoint.coveredEntries)!;
manifest.append(child.directoryEntryIdentity!);
checkpoint.coveredEntries += 1;
}
checkpoint.manifestHash = manifest.digest();
let newlyCompacted = 0;
for (const child of parentChildren) {
if (
child.parentTaskId === parent.id
&& child.status === "completed"
&& Number.isSafeInteger(child.directoryEntryIndex)
&& (child.directoryEntryIndex ?? checkpoint.coveredEntries) < checkpoint.coveredEntries
&& /^[a-f0-9]{64}$/.test(child.directoryEntryIdentity ?? "")
) {
compactedChildIds.add(child.id);
newlyCompacted += 1;
}
}
if (checkpoint.coveredEntries !== initialCoveredEntries || newlyCompacted > 0) {
checkpoint.completedEntries = Math.min(
checkpoint.coveredEntries,
checkpoint.completedEntries + newlyCompacted,
);
parentUpdates.set(parent.id, {
...parent,
directoryResumeCheckpoint: checkpoint,
transferredBytes: Math.max(parent.transferredBytes, checkpoint.completedEntries),
});
}
}
if (compactedChildIds.size > 0 || parentUpdates.size > 0 || normalizedChildUpdates.size > 0) {
compacted = compacted
.filter((task) => !compactedChildIds.has(task.id))
.map((task) => parentUpdates.get(task.id) ?? normalizedChildUpdates.get(task.id) ?? task);
}
const unfinished = compacted.filter((task) => !TERMINAL_STATUSES.has(task.status));
const unfinishedIds = new Set(unfinished.map((task) => task.id));
// Keep terminal exceptions that cannot be compacted (failed/cancelled or a
// legacy gap). Completed indexed children normally live in the parent hash.
const checkpointChildren = compacted.filter((task) =>
TERMINAL_STATUSES.has(task.status)
&& !!task.parentTaskId
&& unfinishedIds.has(task.parentTaskId),
);
const checkpointIds = new Set(checkpointChildren.map((task) => task.id));
const terminal = compacted
.filter((task) => TERMINAL_STATUSES.has(task.status) && !checkpointIds.has(task.id))
.filter((task) => now - (task.endTime ?? task.updatedAt ?? task.startTime) <= SFTP_TRANSFER_HISTORY_MAX_AGE_MS)
.sort((a, b) => (b.endTime ?? b.updatedAt ?? b.startTime) - (a.endTime ?? a.updatedAt ?? a.startTime))
.slice(0, SFTP_TRANSFER_HISTORY_MAX);
return [...unfinished, ...checkpointChildren, ...terminal].sort((a, b) => a.startTime - b.startTime);
}
export function validateTransferResumeSource(
task: Pick<TransferTask, "totalBytes" | "sourceLastModified" | "checkpointBytes">,
source: { size: number; lastModified?: number },
options?: { allowSourceGrowth?: boolean },
): string | null {
const checkpoint = Math.max(0, task.checkpointBytes ?? 0);
if (checkpoint > source.size) return "Saved checkpoint is beyond the current source size";
const plannedSize = Number(task.totalBytes);
const hasPlannedSize = Number.isFinite(plannedSize) && plannedSize >= 0;
if (hasPlannedSize) {
if (source.size < plannedSize) {
return "Source size changed while the transfer was paused";
}
// Download snapshots of append-only files (live logs) may grow past the
// planned size — including an empty (0-byte) snapshot; the original
// [0, totalBytes) range is still resume-safe.
if (source.size > plannedSize && !options?.allowSourceGrowth) {
return "Source size changed while the transfer was paused";
}
}
// Append growth always updates mtime; only treat mtime drift as a rewrite when
// the planned size is still exact. plannedSize === 0 with source growth is
// still growth (empty log that received its first lines while paused).
const sizeGrew =
Boolean(options?.allowSourceGrowth)
&& hasPlannedSize
&& source.size > plannedSize;
if (
!sizeGrew
&& task.sourceLastModified
&& source.lastModified
&& source.lastModified !== task.sourceLastModified
) {
return "Source was modified while the transfer was paused";
}
return null;
}
export interface SftpTransferCenter {
add(ownerId: string, tasks: readonly TransferTask[]): void;
update(taskId: string, updates: Partial<TransferTask>): void;
pause(taskId: string): void;
resume(taskId: string): void;
prioritize(taskId: string): void;
complete(taskId: string): void;
takeRunnable(): TransferTask[];
getTask(taskId: string): TransferTask | undefined;
getTasks(): readonly TransferTask[];
}
export function createSftpTransferCenter({ concurrency }: { concurrency: number }): SftpTransferCenter {
let tasks: TransferTask[] = [];
let prioritySequence = 0;
const replace = (taskId: string, updates: Partial<TransferTask>) => {
tasks = tasks.map((task) => task.id === taskId
? { ...task, ...updates, updatedAt: updates.updatedAt ?? Date.now() }
: task);
};
return {
add(ownerId, incoming) {
const existingIds = new Set(tasks.map((item) => item.id));
tasks = [
...tasks,
...incoming.filter((item) => !existingIds.has(item.id)).map((item) => ({ ...item, ownerId })),
];
},
update: replace,
pause(taskId) {
const current = tasks.find((item) => item.id === taskId);
if (!current || TERMINAL_STATUSES.has(current.status)) return;
replace(taskId, { status: "paused", speed: 0 });
},
resume(taskId) {
const current = tasks.find((item) => item.id === taskId);
if (!current || !["paused", "interrupted", "failed", "attention"].includes(current.status)) return;
replace(taskId, { status: "queued", error: undefined, endTime: undefined });
},
prioritize(taskId) {
prioritySequence += 1;
replace(taskId, { priority: prioritySequence });
},
complete(taskId) {
replace(taskId, { status: "completed", endTime: Date.now(), speed: 0 });
},
takeRunnable() {
const openSlots = Math.max(0, Math.floor(concurrency) - tasks.filter((item) => RUNNING_STATUSES.has(item.status)).length);
if (openSlots === 0) return [];
const queued = tasks
.filter((item) => item.status === "queued" && !item.parentTaskId)
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0) || a.startTime - b.startTime);
const selected: TransferTask[] = [];
const owners = new Set<string>();
for (const task of queued) {
const owner = task.ownerId ?? "global";
if (owners.has(owner) && queued.some((candidate) => (
candidate.id !== task.id
&& !selected.includes(candidate)
&& !owners.has(candidate.ownerId ?? "global")
))) continue;
selected.push(task);
owners.add(owner);
if (selected.length >= openSlots) break;
}
for (const task of selected) replace(task.id, { status: "transferring" });
return selected.map((task) => tasks.find((item) => item.id === task.id) ?? task);
},
getTask(taskId) {
return tasks.find((item) => item.id === taskId);
},
getTasks() {
return tasks;
},
};
}