// 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:`: 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:` 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; }