[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
368
domain/sftpTransferCenter.ts
Normal file
368
domain/sftpTransferCenter.ts
Normal file
@@ -0,0 +1,368 @@
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user