Ctrl K
ComunidadGuías…

electron/cloud-sync-engine.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// One folder's pass of Files: read both sides, plan (cloud-sync-plan.mjs), carry each action out, and record the base
// only once both sides hold the same bytes. A shared folder goes this computer → My Cloud; a bot's work My Cloud → this
// computer. A per-file failure is retried next pass; a signed-out or offline Cloud stops the whole pass.
import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { RESERVE_BYTES, clashPath, neverFromCloud, validWirePath, windowsProblem } from "./cloud-sync-rules.mjs";
import { planOutputs, planShare } from "./cloud-sync-plan.mjs";
import { scanPc, sha256File } from "./cloud-sync-scan.mjs";
import { SyncAuthError } from "./cloud-sync-client.mjs";
import { CloudAsleepError } from "./cloud-power.mjs";

const TEMP_TAG = ".omb-sync-";
const TEMP_STALE_MS = 24 * 60 * 60_000;
const CONCURRENCY = 2;

export function newReport() {
  return { folders: new Map(), outputsProblem: null };
}

/** True when the whole pass should stop (signed out, offline, asleep), not just this file. */
export function passStopper(error) {
  return error instanceof SyncAuthError || error instanceof CloudAsleepError || error?.name === "AbortError" || error?.name === "TimeoutError"
    || (error?.name === "TypeError" && /fetch/i.test(String(error.message)));
}

const defaultFree = (dir) => { const stats = fs.statfsSync(dir); return stats.bavail * stats.bsize; };

async function inPool(items, size, work) {
  let next = 0, stop = null;
  const runner = async () => {
    while (next < items.length && !stop) {
      const item = items[next++];
      try { await work(item); } catch (error) { stop ??= error; }
    }
  };
  await Promise.all(Array.from({ length: Math.min(size, items.length) }, runner));
  if (stop) throw stop;
}

/** Each action once; a failure is this file's (three in a row show as the folder's problem), unless it stops the pass. */
async function carryOut(ctx, actions, work) {
  await inPool(actions, CONCURRENCY, async (action) => {
    try {
      await work(action);
      ctx.state.cleared(ctx.folder.id, action.path);
    } catch (error) {
      if (passStopper(error)) throw error;
      const message = String(error?.message ?? error);
      if (ctx.state.failed(ctx.folder.id, action.path, message) >= 3) ctx.result.problem ??= `${action.path}: ${message}`;
    }
  });
}

/**
 * A shared folder: this computer's files to My Cloud. Not on this computer (moved, renamed, a drive unplugged): nothing
 * is touched, and the person is asked where it went. Many files gone at once wait for the person (`approveDeletes`).
 */
export async function shareFolder({ client, state, folder, local, now = Date.now, approveDeletes = null, report }) {
  const result = { status: "synced", files: 0, done: 0, waitingBytes: 0, pendingDeletes: 0, problem: null };
  report.folders.set(folder.id, result);
  if (!fs.lstatSync(local, { throwIfNoEntry: false })?.isDirectory()) { result.status = "missing"; return result; }
  const base = state.base(folder.id);
  const { entries: pc, temps, keptDirs } = await scanPc(local, base, { now });
  for (const temp of temps) if (now() - temp.mtimeMs > TEMP_STALE_MS) fs.rmSync(temp.file, { force: true });
  const cloud = new Map((await client.files(folder.id)).filter((file) => validWirePath(file.path)).map((file) => [file.path, file]));
  const plan = planShare({ base, pc, cloud, keptDirs });
  const ctx = { client, state, folder, result };
  result.files = [...pc.values()].filter((file) => !file.kept).length;
  result.done = result.files - plan.uploads.length;
  for (const record of plan.records) state.setBase(folder.id, record.path, record);
  for (const { path: wire } of plan.forgets) state.dropBase(folder.id, wire);
  // Smallest first, so one file that does not fit cannot hold the small ones back.
  await carryOut(ctx, plan.uploads.sort((a, b) => a.size - b.size), async (action) => {
    const sent = await send(ctx, action);
    if (sent === "full") { result.waitingBytes += action.size; return; }
    state.setBase(folder.id, action.path, action);
    result.done++;
  });
  if (plan.deletes.length) {
    if (plan.ask && approveDeletes == null) result.pendingDeletes = plan.deletes.length;
    else if (plan.ask && approveDeletes === false) for (const { path: wire } of plan.deletes) state.dropBase(folder.id, wire);
    else await carryOut(ctx, plan.deletes, async ({ path: wire, baseSha256 }) => {
      const [answer] = await client.ops(folder.id, [{ op: "delete", path: wire, baseSha256 }]);
      if (answer?.ok || answer?.conflict) state.dropBase(folder.id, wire);
    });
  }
  result.status = result.pendingDeletes ? "confirm-deletes" : result.waitingBytes ? "waiting" : result.done < result.files ? "copying" : result.problem ? "problem" : "synced";
  return result;
}

/** This computer's version wins: a change a bot made on My Cloud since the listing is replaced too (its copy goes to the trash there). */
async function send(ctx, action, again = true) {
  const sent = await ctx.client.upload({ folderId: ctx.folder.id, path: action.path, file: action.file, size: action.size, sha256: action.sha256, baseSha256: action.baseSha256 });
  if (sent.ok) return "ok";
  if (sent.full) return "full";
  if (sent.conflict) {
    if (sent.current?.sha256 === action.sha256) return "ok";
    if (again) return send(ctx, { ...action, baseSha256: sent.current?.sha256 ?? null }, false);
    throw new Error("It kept changing on My Cloud; it will be sent again.");
  }
  throw new Error("My Cloud gave an answer this app does not understand.");
}

/**
 * A bot's work: My Cloud's files to this computer, in `local` (Documents/MausBot/<bot>/<conversation>). Never deletes here,
 * never writes a config an agent or tool would obey, and marks each file as downloaded (`markDownloaded`).
 */
export async function fetchOutputs({ client, state, folder, local, platform = process.platform, now = Date.now, trashItem, markDownloaded = async () => {}, freeBytes = defaultFree, report }) {
  const result = { status: "synced", files: 0, done: 0, waitingBytes: 0, pendingDeletes: 0, problem: null };
  report.folders.set(folder.id, result);
  fs.mkdirSync(local, { recursive: true });
  const base = state.base(folder.id);
  const { entries: pc, temps } = await scanPc(local, base, { now });
  for (const temp of temps) if (now() - temp.mtimeMs > TEMP_STALE_MS) fs.rmSync(temp.file, { force: true });
  const listed = (await client.files(folder.id)).filter((file) => validWirePath(file.path) && !neverFromCloud(file.path) && !(platform === "win32" && windowsProblem(file.path)));
  const cloud = new Map(listed.map((file) => [file.path, file]));
  const { downloads, records } = planOutputs({ base, pc, cloud });
  const ctx = { client, state, folder, local, pc, cloud, now, trashItem, markDownloaded, freeBytes, result };
  result.files = cloud.size;
  result.done = cloud.size - downloads.length;
  for (const record of records) state.setBase(folder.id, record.path, record);
  await carryOut(ctx, downloads, async (action) => {
    if (await receive(ctx, action) === "full") { result.waitingBytes += action.size; return; }
    result.done++;
  });
  result.status = result.waitingBytes ? "waiting" : result.done < result.files ? "copying" : result.problem ? "problem" : "synced";
  return result;
}

/** Where a wire path lives in this folder: never outside it, and never through a link or a file in the way. */
function localFile(ctx, wire) {
  if (!validWirePath(wire)) throw new Error("My Cloud sent a path this app will not write.");
  const parts = wire.split("/");
  let current = ctx.local;
  for (const part of parts.slice(0, -1)) {
    current = path.join(current, part);
    const stat = fs.lstatSync(current, { throwIfNoEntry: false });
    if (stat && !stat.isDirectory()) throw new Error(`${wire} goes through a link or a file on this computer.`);
  }
  const file = path.join(ctx.local, ...parts);
  if (!file.startsWith(path.join(ctx.local, path.sep))) throw new Error("My Cloud sent a path outside this folder.");
  return file;
}

async function receive(ctx, { path: wire, sha256, size, replace, beside }) {
  const { client, folder, state } = ctx;
  if (ctx.freeBytes(ctx.local) - size < RESERVE_BYTES) return "full";
  let target = localFile(ctx, wire);
  const kept = fs.lstatSync(target, { throwIfNoEntry: false });
  // The person's own edit stays as it is: My Cloud's newer version lands beside it.
  if (beside && kept) {
    const taken = (candidate) => ctx.pc.has(candidate) || ctx.cloud.has(candidate) || fs.existsSync(localFile(ctx, candidate));
    target = localFile(ctx, clashPath(wire, ctx.now(), taken, "new from My Cloud"));
  }
  fs.mkdirSync(path.dirname(target), { recursive: true });
  const tag = createHash("sha256").update(`${folder.id}\n${wire}`).digest("hex").slice(0, 8);
  const temporary = path.join(path.dirname(target), `.${path.basename(target).slice(0, 180)}${TEMP_TAG}${tag}`);
  const sent = await client.download(folder.id, wire, temporary, size);
  const got = await sha256File(temporary);
  if (got !== (sent.sha256 ?? sha256)) { fs.rmSync(temporary, { force: true }); throw new Error("The download was damaged; it will be fetched again."); }
  await ctx.markDownloaded(temporary);
  const now = fs.lstatSync(target, { throwIfNoEntry: false });
  if (now) {
    // Only a file still exactly as the plan saw it is replaced, and the one it replaces goes to the Recycle Bin first.
    if (!replace || !now.isFile() || now.size !== replace.size || now.mtimeMs !== replace.mtimeMs) { fs.rmSync(temporary, { force: true }); return "skipped"; }
    await ctx.trashItem(target);
  }
  fs.renameSync(temporary, target);
  const stat = fs.statSync(target);
  if (target === localFile(ctx, wire)) state.setBase(folder.id, wire, { sha256: got, size: stat.size, mtimeMs: stat.mtimeMs });
  else {
    const here = ctx.pc.get(wire);
    if (here && !here.unsettled) state.setBase(folder.id, wire, { sha256, size: here.size, mtimeMs: here.mtimeMs });
  }
  return "ok";
}