electron/cloud-send-check.mjs
Rama main · réplica de lectura del repositorio original.
// What a move to the person's own Cloud checks once it has run, from both servers' own answers and never a secret:
// the two manifests (GET /api/cloud-send/manifest), each conversation's rows, its owner's lines and its visible path
// (this computer's /api/cloud-send/chats/ids against My Cloud's /chats/verify), the attachments the rows name, and each
// credential's fingerprint under a fresh nonce (this computer's private port against My Cloud's /verify-secrets).
import { randomBytes } from "node:crypto";
import { EMPTY } from "./cloud-send-parts.mjs";
import { fileArrived } from "./cloud-send-kinds.mjs";
const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
const list = (value) => (Array.isArray(value) ? value : []);
/** As many of a conversation's row ids, and of its path, as My Cloud checks at once. */
const IDS = 5_000;
/** My Cloud checks at most this many conversations, and row and path ids together, a request. */
const VERIFY_CONVERSATIONS = 200;
const VERIFY_IDS = 2 * IDS;
const ATTACHMENT_NAMES = 2_000;
const THREAD = /^[\w-]{1,200}$/u;
/** The waits between tries of a request nothing answered, and how long a check goes on while none of its requests gets an
* answer: a slow Cloud that keeps answering is waited for however long its work takes. */
export const PATIENCE_MS = Object.freeze([15_000, 30_000, 60_000, 120_000, 300_000]);
export const STALL_MS = 10 * 60_000;
/** How often a check that waits for My Cloud looks whether the person stopped the move. */
const STOP_LOOK_MS = 5_000;
/** The waits before a move's pass gives up on a request it can safely ask again: as long as a Cloud takes to start again. */
export const BLIP_MS = Object.freeze([5_000, 15_000, 30_000, 60_000]);
/** Nothing answered on the way to My Cloud (no network, a timeout, a gateway error, the Admin's or My Cloud's), as
* against a refusal: trying again soon may well work. */
export function unanswered(error, depth = 0) {
if (!error || depth > 4) return false;
if ((error.name === "TypeError" && /fetch/iu.test(error.message ?? "")) || error.name === "TimeoutError" || error.name === "AbortError") return true;
if (Number.isInteger(error.status) && error.status >= 500) return true;
return unanswered(error.cause, depth + 1);
}
/** An answer of My Cloud's, or the error it is: a gateway error (5xx) stays one that trying again may get past, and any
* other is what `refuse(answer, message)` makes of it. */
export function answered(answer, message, refuse = null) {
if (answer.ok) return answer;
if (refuse && !(answer.status >= 500)) throw refuse(answer, message);
throw Object.assign(new Error(message), { status: answer.status });
}
const nap = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); });
/**
* A check's patience with a slow Cloud: a request nothing answered is asked again after PATIENCE_MS, for as long as some
* request got an answer in the last STALL_MS (`moved`). Then it gives up with an error that still reads as unanswered and
* says whether any of the work got done (`progressed`), which the next check does not do again; an answer that only
* opens the work (`run(work, { counts: false })`) keeps it waiting without counting as work done. `onWait(until)` while it
* waits, `onWait(null)` once it asks again. `stopped()`: the person stopped the move, so it ends at the next answer or look.
*/
export function createPatience({ now = Date.now, sleep = nap, onWait = () => {}, stallMs = STALL_MS, waits = PATIENCE_MS, stopped = () => false } = {}) {
let movedAt = now(), progressed = false, tries = 0;
const stop = () => { if (stopped()) throw Object.assign(new Error("The move stopped."), { code: "stopped" }); };
const patience = {
moved({ counts = true } = {}) { movedAt = now(); tries = 0; if (counts) progressed = true; stop(); },
get progressed() { return progressed; },
/** Waits before asking again after `error`, or throws once the check has stood still too long. */
async wait(error) {
if (!unanswered(error)) throw error;
const ms = waits[Math.min(tries++, waits.length - 1)];
if (now() + ms - movedAt > stallMs) {
throw Object.assign(new Error(`My Cloud did not answer for ${Math.round((now() - movedAt) / 60_000)} min (${error?.message ?? "no answer"}).`), { cause: error, code: "slow", progressed });
}
onWait(now() + ms);
for (let left = ms; left > 0; left -= STOP_LOOK_MS) { stop(); await sleep(Math.min(left, STOP_LOOK_MS)); }
stop();
onWait(null);
},
/** `work()` until it is answered. */
async run(work, { counts = true } = {}) {
for (;;) {
try { const value = await work(); patience.moved({ counts }); return value; }
catch (error) { await patience.wait(error); }
}
},
};
return patience;
}
const GATEWAY = new Set([502, 503, 504]);
const REPEATABLE = /^\/api\/cloud-send\/chats(?:[/?]|$)/u;
/**
* A move's session for its passes: a read, or a chats request My Cloud adds by id, is asked again through a blip (BLIP_MS)
* instead of ending the pass. What still has no answer after that throws as it did, or comes back as the gateway answered,
* and says whether anything was answered before it (`progressed`).
*/
export function patientSession(session, { sleep = nap, blips = BLIP_MS } = {}) {
let answeredOnce = false;
return {
...session,
async call(method, route, body, options) {
if (method !== "GET" && !REPEATABLE.test(route)) return session.call(method, route, body, options);
for (let waited = 0; ; waited++) {
try {
const answer = await session.call(method, route, body, options);
if (!GATEWAY.has(answer.status) || waited >= blips.length) {
if (!GATEWAY.has(answer.status)) answeredOnce = true;
return answer;
}
} catch (error) {
if (!unanswered(error) || waited >= blips.length) throw Object.assign(error, { progressed: answeredOnce });
}
await sleep(blips[waited]);
}
},
};
}
/** Asks once: what a check without patience does. */
const IMPATIENT = { moved() {}, progressed: false, wait: async (error) => { throw error; }, run: (work) => work() };
/** My Cloud's journal shows at most this many of a bot's latest changes. */
const JOURNAL_ROWS = 500;
/** The rooms of `here` that a move carries: those with a bot that goes (`bots`). My Cloud makes a room only once one of
* its bots is there (server/cloud-chat-merge.ts), so one whose bots were all deleted here, or removed on My Cloud, stays. */
export function roomsThatGo(here, bots) {
return new Set(list(here?.rooms).filter((room) => record(room) && list(room.members).some((id) => bots.has(id))).map((room) => room.id));
}
/** The rooms of `here` My Cloud had once and lacks now, as `room:<id>`: a conversation of the room went (`chats`, the
* cursors cloud-send.mjs keeps, "removed" included) and every one of its conversations with rows here did, so nothing
* here makes it again; My Cloud answers that each was removed there. */
export function roomsRemovedThere(here, there, chats) {
const held = new Set(list(there?.rooms).map((room) => room.id));
const went = (threadId) => record(chats) && chats[threadId] !== undefined;
return list(here?.rooms).filter((room) => record(room) && !held.has(room.id) && list(room.threads).some(went)
&& list(here?.conversations).filter((head) => head.room === room.id).every((head) => went(head.threadId))).map((room) => `room:${room.id}`);
}
/**
* What of this computer's manifest My Cloud's lacks: items, memory, rooms, connections and conversations it does not
* have, and parts of items it holds otherwise than it took them; the pieces of a memory note, a team's context or memory,
* a learned fact, a journal row, a bot file, a routine's finished run or a conversation's own setting it lacks. A part
* counts as carried when it is equal on both sides, or when, since it last went (`sent`: what each side held then, as
* cloud-send.mjs keeps it), this computer did not change it or My Cloud did (then My Cloud's is kept).
* Left out: what was removed on My Cloud (`removed`, by key, `room:<id>` or conversation id), what this computer never
* sends (`skip`), and a room no bot of which goes, with its conversations (roomsThatGo).
*/
export function manifestGaps(here, there, { sent = {}, removed = new Set(), skip = new Set() } = {}) {
const gaps = { items: [], parts: [], memory: [], rooms: [], connections: [], conversations: [], files: [], runs: [], journal: [], learned: [], teams: [], settings: [] };
if (!record(here) || !record(there)) return null;
const theirs = new Map(list(there.items).map((item) => [item.key, item]));
const bots = new Set();
const carried = new Set();
for (const item of list(here.items)) {
if (removed.has(item.key) || skip.has(item.key) || (item.bot && (removed.has(`bot:${item.bot}`) || skip.has(`bot:${item.bot}`)))) continue;
// A Cloud too old to take saved secrets by id is sent none.
if (item.kind === "secret" && there.vault !== 1) continue;
carried.add(item.key);
if (item.kind === "bot") bots.add(item.id);
const on = theirs.get(item.key);
if (!on) { gaps.items.push({ key: item.key, kind: item.kind, label: item.label }); continue; }
// Logins and 1Password are compared by their secrets' fingerprints, never by when they changed.
if (item.kind === "login" || item.kind === "1password" || item.kind === "secret") continue;
const differs = Object.entries(item.parts ?? {}).filter(([part, hash]) => {
const cloud = on.parts?.[part] ?? EMPTY;
const pair = sent[item.key]?.[part];
// Missing only when this computer changed it since it last went, and My Cloud did not: that change should have gone.
return hash !== cloud && !(Array.isArray(pair) && (pair[0] === hash || pair[1] !== cloud));
}).map(([part]) => part);
if (differs.length) gaps.parts.push({ key: item.key, label: item.label, parts: differs });
}
const notes = new Map(list(there.memory).map((note) => [`${note.bot}/${note.path}`, note]));
for (const note of list(here.memory)) {
if (!bots.has(note.bot)) continue;
const held = notes.get(`${note.bot}/${note.path}`);
const lines = new Set(list(held?.lines));
if (!held || (Array.isArray(note.lines) ? note.lines.some((line) => !lines.has(line)) : held.hash !== note.hash)) gaps.memory.push({ bot: note.bot, path: note.path });
}
const journals = new Map(list(there.journal).map((entry) => [entry.bot, list(entry.ids)]));
for (const entry of list(here.journal)) {
const held = journals.get(entry.bot) ?? [];
const ids = new Set(held);
if (bots.has(entry.bot) && held.length < JOURNAL_ROWS && list(entry.ids).some((id) => !ids.has(id))) gaps.journal.push(entry.bot);
}
const known = new Set([...list(there.learned), ...list(there.forgotten)]);
for (const fact of list(here.learned)) if (!known.has(fact)) gaps.learned.push(fact);
const teams = new Map(list(there.teams).map((team) => [team.section, team]));
for (const team of list(here.teams)) {
const held = teams.get(team.section);
const context = new Set(list(held?.context)), memory = new Set(list(held?.memory));
if (list(team.context).some((line) => !context.has(line)) || list(team.memory).some((key) => !memory.has(key))) gaps.teams.push(team.section);
}
const going = roomsThatGo(here, bots);
const rooms = new Set(list(there.rooms).map((room) => room.id));
for (const room of list(here.rooms)) if (going.has(room.id) && !removed.has(`room:${room.id}`) && !rooms.has(room.id)) gaps.rooms.push({ id: room.id, name: room.name });
const connections = new Set(list(there.connections));
for (const id of list(here.connections)) if (!skip.has(id) && !connections.has(id)) gaps.connections.push(id);
const conversations = new Map(list(there.conversations).map((head) => [head.threadId, head]));
for (const head of list(here.conversations)) {
if (removed.has(head.threadId) || (head.bot && !bots.has(head.bot)) || (head.room && (!going.has(head.room) || removed.has(`room:${head.room}`)))) continue;
const held = conversations.get(head.threadId);
if (!held) { gaps.conversations.push(head.threadId); continue; }
if (Object.keys(record(head.settings) ? head.settings : {}).some((key) => !record(held.settings) || held.settings[key] === undefined)) gaps.settings.push(head.threadId);
}
const files = new Map(list(there.files).map((file) => [file.path, file]));
for (const file of list(here.files)) {
const bot = /^(?:workspaces|task-workspaces)\/([\w-]{1,128})\//u.exec(file.path)?.[1];
if (bots.has(bot) && !fileArrived(file, files)) gaps.files.push(file.path);
}
const runs = new Set(list(there.runs).map((run) => run.id));
for (const run of list(here.runs)) if (carried.has(`routine:${run.routine}`) && !runs.has(run.id)) gaps.runs.push(run.id);
return gaps;
}
export const noGaps = (gaps) => Boolean(gaps) && Object.values(gaps).every((entries) => entries.length === 0);
/** A conversation's row ids on this computer, and its visible path's, a chunk at a time. */
export async function* rowIdChunks(localJson, threadId) {
for (let from = 0, more = true; more; from += IDS) {
const page = await localJson(`/api/cloud-send/chats/ids?thread=${threadId}&from=${from}`);
if (!record(page)) throw new Error("OpenMausBot on this computer did not list a conversation's rows.");
more = page.more === true;
yield { ids: list(page.ids), path: list(page.path), leaf: page.leaf ?? null, more };
}
}
/**
* Each conversation's rows and visible path checked on My Cloud, ids only: `missing`, the rows My Cloud lacks;
* `unstamped`, the lines of its owner's it holds that do not carry the owner yet; `path`: "equal" when My Cloud shows
* exactly this computer's path, "inside" when this computer's path appears in order inside a longer one of My Cloud's
* (both sides added to it), "broken" otherwise. Conversations of up to IDS rows go many to a request; a longer one goes a
* chunk at a time, each after the one before it, as its path is checked in order. A request nothing answered is split in
* two, and the rest of the check asks for less at a time, until one conversation's chunk waits for My Cloud (`patience`).
* `skip(threadId)`: checked before and unchanged since, left out; `checked(threadId)`: nothing missing; `onProgress(done,
* total)` as conversations are checked.
*/
export async function checkChats({ localJson, session, threads, skip = () => false, checked = () => {}, onProgress = () => {}, patience = IMPATIENT, timeoutMs }) {
const out = [];
const todo = threads.filter((id) => THREAD.test(id));
const total = todo.length;
let done = 0, most = VERIFY_CONVERSATIONS, budget = VERIFY_IDS, batch = [], ids = 0;
const verify = async (conversations) => {
const answer = answered(await session.call("POST", "/api/cloud-send/chats/verify", { v: 1, conversations }, timeoutMs ? { timeoutMs } : undefined), "My Cloud did not check the chats.");
const byThread = new Map(list(answer.body?.conversations).filter(record).map((entry) => [entry.threadId, entry]));
if (conversations.some((entry) => !byThread.has(entry.threadId))) throw Object.assign(new Error("My Cloud did not check the chats."), { status: answer.status });
return byThread;
};
const finish = (result) => {
out.push(result);
if (!result.missing && !result.unstamped && result.path !== "broken") checked(result.threadId);
onProgress(++done, total);
};
const flush = async () => {
const queue = batch.length ? [batch] : [];
batch = []; ids = 0;
while (queue.length) {
const part = queue.shift();
let answers;
try { answers = await verify(part.map(({ asked }) => asked)); }
catch (error) {
if (!unanswered(error)) throw error;
if (part.length > 1) {
const half = Math.ceil(part.length / 2);
most = Math.max(1, Math.min(most, half));
budget = Math.max(IDS, Math.floor(budget / 2));
queue.unshift(part.slice(0, half), part.slice(half));
} else {
await patience.wait(error);
queue.unshift(part);
}
continue;
}
patience.moved();
for (const { asked, leaf } of part) {
const answer = answers.get(asked.threadId);
const holds = answer.path?.holds === true;
finish({ threadId: asked.threadId, missing: list(answer.missing).length, unstamped: Number.isSafeInteger(answer.unstamped) ? answer.unstamped : 0,
path: !holds ? "broken" : answer.path.leaf === leaf && answer.path.length === asked.path.ids.length ? "equal" : "inside" });
}
}
};
for (const threadId of todo) {
if (skip(threadId)) { onProgress(++done, total); continue; }
const first = await localJson(`/api/cloud-send/chats/ids?thread=${threadId}&from=0`);
if (!record(first)) throw new Error("OpenMausBot on this computer did not list a conversation's rows.");
if (first.more === true) { finish(await checkLong(threadId)); continue; }
const asked = { threadId, ids: list(first.ids), path: { after: null, ids: list(first.path), last: true } };
const size = asked.ids.length + asked.path.ids.length;
if (batch.length && (batch.length >= most || ids + size > budget)) await flush();
batch.push({ asked, leaf: first.leaf ?? null });
ids += size;
}
await flush();
return out;
async function checkLong(threadId) {
let missing = 0, unstamped = 0, after = null, holds = true, cloud = null, localLeaf = null, length = 0;
for await (const chunk of rowIdChunks(localJson, threadId)) {
localLeaf = chunk.leaf ?? localLeaf;
length += chunk.path.length;
const asked = { threadId, ids: chunk.ids, ...(holds ? { path: { after, ids: chunk.path, last: !chunk.more } } : {}) };
const answer = await patience.run(() => verify([asked])).then((answers) => answers.get(threadId));
missing += list(answer.missing).length;
unstamped += Number.isSafeInteger(answer.unstamped) ? answer.unstamped : 0;
if (asked.path) {
holds = answer.path?.holds === true;
after = answer.path?.at ?? null;
if (!chunk.more) cloud = answer.path;
}
}
return { threadId, missing, unstamped, path: !holds ? "broken" : cloud && cloud.leaf === localLeaf && cloud.length === length ? "equal" : "inside" };
}
}
/** A move's check passes when nothing is missing: no gap, every conversation's rows there on a path that holds with
* each line of the owner's carrying them, and every attachment and credential there as it is here. */
export const checkPassed = ({ gaps, chats, attachments, secrets }) => noGaps(gaps)
&& chats.every((thread) => !thread.missing && !thread.unstamped && thread.path !== "broken")
&& !attachments.length && !secrets.differs.length && !secrets.missing.length;
/** The attachments of `names` My Cloud lacks; `onProgress(done, total)` as they are checked. */
export async function missingAttachments(session, names, { patience = IMPATIENT, onProgress = () => {} } = {}) {
const missing = [];
for (let i = 0; i < names.length; i += ATTACHMENT_NAMES) {
const asked = names.slice(i, i + ATTACHMENT_NAMES);
const answer = await patience.run(async () => answered(await session.call("POST", "/api/cloud-send/chats/attachments", { names: asked }), "My Cloud did not check the attachments."));
if (!Array.isArray(answer.body?.missing)) throw new Error("My Cloud did not check the attachments.");
const named = new Set(asked);
missing.push(...answer.body.missing.filter((name) => named.has(name)));
onProgress(Math.min(names.length, i + ATTACHMENT_NAMES), names.length);
}
return missing;
}
/** Each of this computer's credentials, by id, as `equal`, `differs` (My Cloud holds another value) or `missing` there:
* their fingerprints under one fresh nonce on both sides, so nothing secret travels. */
export async function checkSecrets({ handoff, session, skip = new Set(), nonce = randomBytes(32).toString("base64url") }) {
const here = (await handoff({ kind: "verify-secrets", nonce }))?.secrets;
const answer = await session.call("POST", "/api/cloud-send/verify-secrets", { nonce });
const there = answer.ok ? answer.body?.secrets : null;
if (!record(here) || !record(there)) throw Object.assign(new Error("The credentials could not be checked."), { status: answer.status });
const out = { equal: [], differs: [], missing: [] };
for (const [id, value] of Object.entries(here)) {
if (skip.has(id) || (id.startsWith("secret:") && answer.body?.vault !== 1)) continue;
out[typeof there[id] !== "string" ? "missing" : there[id] === value ? "equal" : "differs"].push(id);
}
return out;
}
/** How long a request that lists much may take: My Cloud's, a little longer for each conversation it heads or byte of a
* bot's files it may hash; this computer's own, frozen while the check reads it. */
const LISTING_MS = 2 * 60_000;
const LISTING_MAX_MS = 30 * 60_000;
export const LOCAL_LISTING_MS = 10 * 60_000;
const listingMs = (work) => Math.min(LISTING_MAX_MS, LISTING_MS + Math.round(work));
const BOT_FILE = /^(?:workspaces|task-workspaces)\/([\w-]{1,128})\//u;
/**
* What did not arrive of what a move carried, from both sides' own answers: `gaps`, the manifests compared; `chats`, each
* conversation's rows, its owner's lines and its visible path; `attachments`, the stored ones My Cloud lacks; `secrets`,
* each credential by its fingerprint (none without `handoff`, as for a bot moving into a team). Left out: what was removed
* on My Cloud, and a 1Password token 1Password itself refused. `ok` when nothing is missing.
*
* Every request is a bounded piece of the work, so a Cloud with thousands of conversations answers each in time: My
* Cloud's manifest without the attachments and files it never needs to list for this, its files a bot at a time, the
* chats many conversations to a request. A conversation found whole before (`kept.verified`), with its rows and the path
* it shows unchanged on both sides since, is not asked about again, so a check cut short goes on where it was. A slow Cloud is waited for while it keeps
* answering (createPatience). `onStep({ step, done, total, bots, until? })`: which step it is at, how far, and until when
* it waits for My Cloud. `refuse(answer, message)`: the error a refusal of My Cloud's is.
*/
export async function moveCheck({ session, kept, persist, localJson, handoff, scoped = (route) => route, now = Date.now, sleep, patience: pace = {}, stopped, takesMove, refuse = null, onStep = () => {} }) {
let bots = 0, at = null;
const step = (name, done = 0, total = 0) => { at = { step: name, done, total, bots }; onStep(at); };
const patience = createPatience({ now, ...(sleep ? { sleep } : {}), ...(pace.stallMs !== undefined ? { stallMs: pace.stallMs } : {}), ...(pace.waits ? { waits: pace.waits } : {}), ...(stopped ? { stopped } : {}), onWait: (until) => {
if (!at) return;
const { until: _, ...rest } = at;
at = until ? { ...rest, until } : rest;
onStep(at);
} });
const ask = (method, route, timeoutMs, message = "My Cloud did not answer. Try again.", counts = true) =>
patience.run(async () => answered(await session.call(method, route, undefined, { timeoutMs }), message, refuse), { counts });
step("manifest");
await patience.run(takesMove, { counts: false });
const here = await localJson(scoped("/api/cloud-send/manifest"), { timeoutMs: LOCAL_LISTING_MS });
const heads = new Map(list(here?.conversations).filter(record).map((head) => [head.threadId, head]));
const there = (await ask("GET", scoped("/api/cloud-send/manifest?attachments=0&files=0"), listingMs(heads.size * 20 + list(here?.items).length * 300), undefined, false)).body;
if (!record(here) || !record(there)) throw new Error("My Cloud did not say what it has. Try again.");
const removed = new Set([...Object.entries(kept.off).filter(([, why]) => why === "removed").map(([key]) => key),
...Object.entries(kept.chats).filter(([, cursor]) => cursor === "removed").map(([threadId]) => threadId),
...roomsRemovedThere(here, there, kept.chats)]);
const refused = new Set(list(kept.whole?.result.failed).filter((failure) => failure.final === true).map((failure) => failure.key));
const going = new Set(list(here.items).filter((item) => item.kind === "bot" && !removed.has(item.key) && !refused.has(item.key)).map((item) => item.id));
bots = going.size;
if (!Array.isArray(there.files)) there.files = await cloudFiles(list(here.files), going, ask, step, scoped);
const gaps = manifestGaps(here, there, { sent: kept.sent, removed, skip: refused });
const rooms = roomsThatGo(here, going);
const threads = list(here.conversations).filter((head) => !removed.has(head.threadId)
&& (head.room ? rooms.has(head.room) && !removed.has(`room:${head.room}`) : going.has(head.bot))).map((head) => head.threadId);
const theirs = new Map(list(there.conversations).filter(record).map((head) => [head.threadId, head]));
const theirRows = (threadId) => (Number.isSafeInteger(theirs.get(threadId)?.rows) ? theirs.get(threadId).rows : 0);
const whole = (threadId) => {
const seen = kept.verified?.[threadId], head = heads.get(threadId);
return Boolean(seen && head && seen.newest === head.newest && seen.rows === head.rows && seen.leaf === head.leaf && theirs.has(threadId) && theirRows(threadId) >= seen.there && seen.theirLeaf === theirs.get(threadId).leaf);
};
step("chats", 0, threads.length);
const chats = await checkChats({ localJson, session, threads, patience, timeoutMs: LISTING_MS, skip: whole,
checked: (threadId) => {
const head = heads.get(threadId);
if (kept.verified && head && typeof head.newest === "string" && Number.isSafeInteger(head.rows)) {
kept.verified[threadId] = { newest: head.newest, rows: head.rows, there: theirRows(threadId), ...(head.leaf ? { leaf: head.leaf } : {}), ...(theirs.get(threadId)?.leaf ? { theirLeaf: theirs.get(threadId).leaf } : {}) };
}
},
onProgress: (done, total) => { if (done % 50 === 0) persist(); step("chats", done, total); } });
for (const entry of chats) if (entry.unstamped && kept.stamped) delete kept.stamped[entry.threadId];
persist();
const names = list(here.attachments);
step("attachments", 0, names.length);
const attachments = await missingAttachments(session, names, { patience, onProgress: (done, total) => step("attachments", done, total) });
step("credentials");
const secrets = handoff ? await patience.run(() => checkSecrets({ handoff, session, skip: new Set([...removed, ...refused]) }), { counts: false }) : { equal: [], differs: [], missing: [] };
const ok = checkPassed({ gaps, chats, attachments, secrets });
// What the person knows things by, for what stayed behind (move-gaps.mjs): bots and rooms by name, and the bot or room
// each conversation the check found short belongs to.
const short = new Set([...gaps.conversations, ...gaps.settings, ...chats.filter((entry) => entry.missing || entry.unstamped || entry.path === "broken").map((entry) => entry.threadId)]);
const named = ok ? {} : Object.fromEntries([...list(here.items).filter((item) => item.kind === "bot" && item.id).map((item) => [item.id, item.label]),
...list(here.rooms).map((room) => [`room:${room.id}`, room.name])]);
const owners = ok ? {} : Object.fromEntries(list(here.conversations).filter((head) => short.has(head.threadId)).map((head) => [head.threadId, head.bot ?? `room:${head.room}`]));
return { ok, gaps, chats, attachments, secrets, names: named, owners };
}
/** My Cloud's files of the bots that go, a bot at a time, each given as long as hashing that bot's files here could take
* there; only bots with files here are asked about. */
async function cloudFiles(here, going, ask, step, scoped) {
const bytes = new Map();
for (const file of here) {
const bot = BOT_FILE.exec(typeof file?.path === "string" ? file.path : "")?.[1];
if (bot && going.has(bot)) bytes.set(bot, (bytes.get(bot) ?? 0) + (Number.isSafeInteger(file.size) ? file.size : 0));
}
const out = [];
let done = 0;
for (const [bot, size] of bytes) {
step("files", done, bytes.size);
const route = scoped("/api/cloud-send/files").includes("bot=") ? scoped("/api/cloud-send/files") : `/api/cloud-send/files?bot=${bot}`;
const listed = await ask("GET", route, listingMs(size / 20_000), "My Cloud did not list its files.");
out.push(...list(listed.body?.files));
step("files", ++done, bytes.size);
}
return out;
}