import { execFile } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { DATA_DIR } from './config.js'; import { visibility } from './visibility.js'; import { shareNamespace } from './share.js'; // Bucket backup: every 1h/3h/24h, launch one HF Job that copies this Space's // bucket to private Hub storage. Design: docs/bucket-backup.md // // The whole feature is one loop: if it is switched on and enough time has // passed, launch a Job. Nothing else. The Job builds one versioned snapshot: // // 1. list the bucket over the API — 8 s for 108,960 files, against 14m51s to // stat the same tree through a FUSE mount (§3.11). // 2. decide the scope in code, then copy just those paths server-side by Xet // hash into a private, EPHEMERAL staging bucket. Metadata only, no bytes. // Being frozen is what makes a run self-consistent: the live bucket is // written while we work. // 3. download the snapshot to the Job's local disk, scrub secrets, verify none // remain, commit to a private dataset repo, delete the staging bucket. // // There is no persistent mirror. It cost a second full copy of the bucket, never // deleted anything (so it archived every credential the Space ever held), and the // restore it offered — "the Space exactly as it was, latest only" — is what the // bucket itself already is. // // The copying happens on the Hub, not here: we launch the Job and forget it. So // a backup costs this Space one API call, never reads /data (whose cold walk runs // to minutes), and cannot wedge the event loop that pumps the terminals. // // Requires HF_TOKEN in the environment — there is no way to launch a Job or write // to the Hub without one, so with no token the feature reports itself // unavailable rather than failing every hour in the logs. const STATE_FILE = path.join(DATA_DIR, 'backup-state.json'); const SPACE_ID = process.env.SPACE_ID || ''; const spaceName = () => SPACE_ID.split('/')[1] || 'agent-manager'; export const EVERY_MS = { '1h': 3_600_000, '3h': 10_800_000, '24h': 86_400_000, }; export const INTERVALS = ['never', ...Object.keys(EVERY_MS)]; export const intervalMs = (every) => EVERY_MS[every] || 0; // How often we ask "is a backup due?". Cheap: reads a file, compares two numbers. const TICK_MS = 300_000; // 5 min // A Job that hangs must die well inside the shortest interval so runs cannot // stack up behind each other. const JOB_TIMEOUT = '3000s'; // 50 min const JOB_IMAGE = 'python:3.12'; // Pinned, not left to the default: the operator pays for this. cpu-basic is the // cheapest tier ($0.0002/min) and the work is API calls and file hashing, so // nothing here benefits from more machine. Pinning also means a change to the // Hub's default flavour cannot silently make hourly backups more expensive. export const JOB_FLAVOR = 'cpu-basic'; export const hasToken = () => !!(process.env.HF_TOKEN || process.env.HUGGING_FACE_HUB_TOKEN); // A bucket/dataset id arrives from PUT /api/config and ends up in a Job's // argument list. Anything outside this charset is refused rather than escaped — // there is no legitimate repo called `; rm -rf /`. const ID_RE = /^[A-Za-z0-9][\w.-]*\/[A-Za-z0-9][\w.-]*$/; export const validRepoId = (s) => typeof s === 'string' && s.length <= 96 && ID_RE.test(s); // Folders the operator does not want in the history: the slow, regenerable kind // (`node_modules`, `.venv`, `env`) that turn one backup into thousands of file // hashes. Measured on 805 files: 7s without, 1s with them excluded — and a real // bucket's mount is slower than a local disk, so the saving is larger there. // // Tokens are folder names, or raw globs for anything finer. No whitespace and no // shell metacharacters: these are joined into one env var and split apart again // inside the Job, so the charset is what makes that split exact. const EXCLUDE_RE = /^[A-Za-z0-9._*?/-]+$/; export const MAX_EXCLUDES = 40; /** * What a fresh install skips until the operator says otherwise. * * One criterion, and it is not size: **a command can put it back**. Everything * here is owned by a package manager, a toolchain installer, a cache or a temp * dir. Nothing is excluded for being large — a 292 MB session transcript is the * most history-shaped thing on the bucket, and an earlier draft of this list * dropped it to a per-file size cap, which was wrong. * * Measured against this Space's own bucket (110,590 files / 11.82 GB): * excluding only the names below drops 86,615 files and 8.05 GB, leaving 23,975 * files and 3.78 GB. The heavy hitters were `node_modules` (25,184 files), * `.cache` (11,319), `.tmp` (10,151), `.lake/packages` (9,606, 0.65 GB), * `.elan/toolchains` (13,904, 2.75 GB) and `.npm/_cacache` (1.03 GB). * * Deliberately NOT here: * - `.git` — appears more often than anything else and holds the work you * would most want back: unpushed commits and staged changes live nowhere * else. Cheap to keep, and a partially-copied `.git` is a corrupt repo. * - git worktrees as a class. A clone looks reproducible from its remote, but * uncommitted work in it is not. * - `dist` / `build` / `target` — genuinely ambiguous names for a source * folder. A default that silently drops work is worse than one that copies * some junk. Add them per-install. */ export const DEFAULT_EXCLUDE = Object.freeze([ // dependency trees 'node_modules', '.venv', 'venv', 'site-packages', '.lake/packages', '.pnpm-store', // caches, by manager '.cache', '.npm/_cacache', '.npm/_npx', '.yarn/cache', '.cargo/registry', 'pkg/mod', '__pycache__', '.pytest_cache', '.mypy_cache', '.ruff_cache', '.ipynb_checkpoints', '.gradle/caches', '.m2/repository', '.deno', '.bun/install/cache', '.turbo', '.next', // toolchains an installer re-fetches '.elan/toolchains', '.rustup/toolchains', '.nvm/versions', '.pyenv/versions', // scratch '.tmp', '.elan/tmp', '.lake/build', ]); /** * The stored value, or the default when the operator has never set one. * * An absent key and an empty list are NOT the same: `undefined` means "never * asked", which takes the default, while `[]` is a list the operator emptied on * purpose and must stay empty — otherwise clearing the field in Settings would * silently refill itself on the next read. */ export function excludeFromConfig(saved) { if (saved === undefined || saved === null) return [...DEFAULT_EXCLUDE]; return normalizeExclude(saved); } export function normalizeExclude(list) { if (!Array.isArray(list)) return []; const out = []; for (const raw of list) { if (typeof raw !== 'string') continue; // Leading and trailing slashes are noise: `/env/` and `env` mean the same. const t = raw.trim().replace(/^\/+/, '').replace(/\/+$/, ''); if (!t || t.length > 64 || !EXCLUDE_RE.test(t)) continue; if (!out.includes(t)) out.push(t); } return out.slice(0, MAX_EXCLUDES); } function run(args, { timeout = 120_000 } = {}) { return new Promise((resolve, reject) => { execFile('hf', args, { timeout, env: process.env, maxBuffer: 8 << 20 }, (err, stdout, stderr) => { if (err) { err.stderr = String(stderr || ''); return reject(err); } resolve(String(stdout || '')); }); }); } export function loadState() { try { return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')); } catch { return {}; } } function saveState(patch) { const next = { ...loadState(), ...patch }; try { fs.writeFileSync(STATE_FILE, JSON.stringify(next, null, 2)); } catch {} return next; } // The bucket mounted at /data, as discovered by visibility.js from // GET /api/spaces/{id} → runtime.volumes[]. AM_BACKUP_SOURCE overrides it for // local runs, where there is no Space and so no volume to discover. export function sourceBucket() { if (process.env.AM_BACKUP_SOURCE) return process.env.AM_BACKUP_SOURCE; const v = visibility(); return (v.buckets && v.buckets[0]) || null; } export async function defaultsFor() { const ns = await shareNamespace().catch(() => ''); if (!ns) return { dataset: '', staging: '' }; return { dataset: `${ns}/${spaceName()}-backup`, // Ephemeral: created private at the start of a run, deleted at the end of it. // A distinct name from the dataset so a half-finished run can never be // confused with the history, and so an operator's existing mirror bucket is // never written to by the new pipeline. staging: `${ns}/${spaceName()}-snapshot`, }; } const HF = 'https://huggingface.co'; // Every run is launched with `--name am-backup-`, which the Hub stores as // a `name=` label. So one static URL lists this Space's backup runs — past, // present and failed — and the row never has to track a job id to link to them. export const jobName = () => `am-backup-${spaceName()}`.slice(0, 40); export const jobsUrl = () => `${HF}/settings/jobs?label=${encodeURIComponent(`name=${jobName()}`)}`; // The script the Job runs. No interpolation: every value arrives as an env var // (`-e`), so a config string can never become shell syntax. export const JOB_SCRIPT = `set -euo pipefail # Plain package, no [cli] extra: 1.26.0 dropped it and ships the CLI in the base # install. hf_xet speeds up the one leg that actually moves bytes. pip install -q "huggingface_hub[hf_xet]" python - <<'EOPY' import os, re, sys, time, collections from huggingface_hub import HfApi api = HfApi() SRC = os.environ["AM_SOURCE"] DATASET = os.environ["AM_DATASET"] STAGING = os.environ["AM_STAGING"] # Folder-name tokens, space-joined. Matched here rather than handed to an # uploader: an uploader enumerates the folder first and filters second, which # pays exactly the walk this pipeline exists to avoid. EXCLUDE = [t for t in os.environ.get("AM_EXCLUDE", "").split(" ") if t] WORK = "/work" def say(m): print(m, flush=True) # ---------------------------------------------------------------- privacy gate # A backup carries every saved login on the bucket (docs/bucket-backup.md §4), so # a public destination is a stop, not a warning. Re-checked HERE on every run, # because a repo can be flipped public long after it was created. # # Never inferred from a default: create_bucket() with no private= yields a PUBLIC # bucket, and hf upload into a repo that does not exist creates it PUBLIC. Both # verified against the Hub (§3.12). So every destination is created explicitly # private and then read back before a single byte is written. def must_be_private(kind, rid): info = api.bucket_info(rid) if kind == "bucket" else api.dataset_info(rid) if getattr(info, "private", None) is not True: sys.exit("refusing to back up: " + kind + " " + rid + " is not private") must_be_private("bucket", SRC) api.create_repo(DATASET, repo_type="dataset", private=True, exist_ok=True) must_be_private("dataset", DATASET) api.create_bucket(STAGING, private=True, exist_ok=True) must_be_private("bucket", STAGING) say("destinations verified private: " + DATASET + ", " + STAGING) def cleanup(): # Left behind, staging is a second full copy of the bucket, so failing to # remove it is loud rather than silent. try: api.delete_bucket(STAGING) say("staging bucket deleted: " + STAGING) except Exception as e: say("WARNING could not delete staging bucket " + STAGING + ": " + str(e)) try: # ------------------------------------------------------------------ 1. list # Over the API, never a mount. The mount costs ~8 ms per file: a stat-only # walk of this bucket took 14m51s for 108,960 files; the same tree lists in # 8 s here. That gap is the whole reason for this rewrite. t = time.time() entries = [f for f in api.list_bucket_tree(SRC, recursive=True) if hasattr(f, "size")] say("listed " + str(len(entries)) + " files in " + format(time.time() - t, ".1f") + "s") # ------------------------------------------------------------- 2. the scope def excluded(path): q = "/" + path + "/" return any(("/" + t.strip("/") + "/") in q for t in EXCLUDE) # Files that exist to hold credentials, matched on the exact final path # segment rather than as a substring: a substring test for "/credentials" # sails straight past "/.credentials.json", which is how one got into a probe # commit before the scrub existed. CRED_NAMES = { ".credentials.json", ".claude.json", "credentials.json", "auth.json", "hosts.yml", "token", "stored_tokens", ".netrc", ".env", ".env.local", "id_rsa", "id_ed25519", "credentials", } CRED_DIRS = ("/shell_snapshots/",) def is_credential(path): if any(d in "/" + path for d in CRED_DIRS): return True return path.rsplit("/", 1)[-1] in CRED_NAMES keep = [] n_excl = n_cred = n_nohash = 0 for f in entries: if excluded(f.path): n_excl += 1 elif is_credential(f.path): n_cred += 1 elif not f.xet_hash: n_nohash += 1 else: keep.append(f) kb = sum((f.size or 0) for f in keep) say("scope: keeping " + str(len(keep)) + " files (" + format(kb / 1e9, ".2f") + " GB); skipped " + str(n_excl) + " reproducible, " + str(n_cred) + " credential-bearing, " + str(n_nohash) + " without a hash") if not keep: sys.exit("refusing to commit: the scope matched no files") # -------------------------------------------------- 3. frozen snapshot copy # Server-side, by xet hash: metadata only, no bytes, ~880 files/s measured. # The snapshot is also what makes a run self-consistent. The live bucket is # written while we work, and reading it directly used to die with "not a file # on the local file system" when the Space rotated a file away mid-run. t = time.time() for i in range(0, len(keep), 2000): api.batch_bucket_files( STAGING, copy=[("bucket", SRC, f.xet_hash, f.path) for f in keep[i:i + 2000]], ) say("snapshot: " + str(len(keep)) + " paths copied server-side in " + format(time.time() - t, ".0f") + "s") # ------------------------------------------------------------ 4. sync local # Local disk, so the walk before the commit is free: 0.14 s for 39,873 files # against 14m51s for the same work on the mount. os.makedirs(WORK, exist_ok=True) t = time.time() api.sync_bucket("hf://buckets/" + STAGING, WORK) got = sum(len(fs) for _, _, fs in os.walk(WORK)) say("downloaded " + str(got) + " files in " + format(time.time() - t, ".0f") + "s") # ---------------------------------------------------------------- 5. scrub # Tight patterns on purpose: a looser sk-[A-Za-z0-9_-]{24,} matched # "sk-abstraction-and-chart-selection" in ordinary prose, and a scrub that # rewrites real content is worse than one that over-reports. BEF = "(? 5 else "")) say("verified: no secret patterns remain") # --------------------------------------------------------------- 7. commit # delete_patterns makes the newest commit match the scope, while every # earlier commit keeps since-removed files recoverable. t = time.time() api.upload_folder( folder_path=WORK, repo_id=DATASET, repo_type="dataset", delete_patterns="*", commit_message="Bucket snapshot " + time.strftime("%Y-%m-%dT%H:%MZ", time.gmtime()), ) say("committed to " + DATASET + " in " + format(time.time() - t, ".0f") + "s") finally: cleanup() EOPY `; /** * The Job id out of `hf jobs run --detach` output, whatever shape it comes in. * * This is deliberately format-agnostic. The image installs huggingface_hub * unpinned, so the CLI here is not the CLI a dev machine has, and a version that * printed the id differently silently left us with `jobId: null` — which killed * overlap protection and made the status row report a stage it had invented. * Accepts `id=`, a bare id on its own line, or one embedded in a job URL. */ export function parseJobId(out) { const text = String(out || ''); const looksLikeId = (s) => /^[a-f0-9]{16,32}$/i.test(s || ''); const tagged = text.match(/\bid=([A-Za-z0-9]+)/); if (tagged && looksLikeId(tagged[1])) return tagged[1]; const fromUrl = text.match(/\/jobs\/[^\s/]+\/([A-Za-z0-9]+)/); if (fromUrl && looksLikeId(fromUrl[1])) return fromUrl[1]; for (const line of text.split('\n').map((l) => l.trim()).reverse()) { const last = line.split(/\s+/).pop(); if (looksLikeId(last)) return last; } return null; } // Job arguments, as an array (never a shell string). Exported for the tests: // asserting on this is how we know a config value cannot reach a shell. export function jobArgs({ source, dataset, staging, exclude = [] }) { return [ 'jobs', 'run', '--detach', '--name', jobName(), '--secrets', 'HF_TOKEN', '-e', `AM_SOURCE=${source}`, '-e', `AM_DATASET=${dataset}`, '-e', `AM_STAGING=${staging}`, // Folder-name tokens, space-joined. They are validated to contain no // whitespace, so the Job splits them back apart exactly. Tokens, not globs: // the Job matches them against the API listing itself, so the two-pattern // glob dance `hf upload --exclude` needed is gone. '-e', `AM_EXCLUDE=${normalizeExclude(exclude).join(' ')}`, // No `-v`: nothing is mounted. The pipeline reads the bucket through the API // and downloads to local disk, which is ~6,000x faster to walk (§3.11). '--flavor', JOB_FLAVOR, '--timeout', JOB_TIMEOUT, JOB_IMAGE, 'bash', '-c', JOB_SCRIPT, ]; } /** * Why an on-demand backup cannot run, or null if it can. Deliberately does NOT * consider the interval: "back up now" is exactly what you want before a risky * change, without committing to a schedule. */ export function runNowBlockedBy() { if (!hasToken()) return 'needs a write-scoped HF_TOKEN secret on the Space'; if (!sourceBucket()) return 'no bucket is mounted on this Space'; return null; } /** * Why the scheduled backup is not running, or null if it is live. Same reasons * as on-demand plus the interval, so the timer and the settings row can never * disagree about it. */ export function unavailableReason(cfg) { return runNowBlockedBy() || ((cfg?.backup?.every || 'never') === 'never' ? 'switched off' : null); } /** Resolve the destinations, falling back to /-backup. */ async function targets(cfg) { const d = await defaultsFor(); const dataset = (cfg?.backup?.dataset || d.dataset || '').trim(); const staging = (cfg?.backup?.staging || d.staging || '').trim(); return { dataset, staging, defaults: d, exclude: excludeFromConfig(cfg?.backup?.exclude) }; } /** Launch one backup Job now. Returns { job } — the Hub does the rest. */ export async function runBackupNow(cfg) { const blocked = runNowBlockedBy(); if (blocked) throw new Error(blocked); // Two runs at once would have two Jobs uploading to the same dataset, which // race. The timer skips for this reason too; on demand it is worth saying out // loud rather than silently doing nothing. if (await isRunning()) throw new Error('a backup is already running'); const source = sourceBucket(); const { dataset, staging, exclude } = await targets(cfg); for (const [label, id] of [['source', source], ['dataset', dataset], ['staging', staging]]) { if (!validRepoId(id)) throw new Error(`${label} "${id}" is not a valid repo id`); } // Nothing is pre-created here. Both destinations are created explicitly // private INSIDE the Job and then read back before anything is written — // creating them from two places is how you end up trusting a default. const out = await run(jobArgs({ source, dataset, staging, exclude }), { timeout: 180_000 }); const job = parseJobId(out); // A launch we cannot identify still happened — record it, but say so, because // without an id there is nothing to poll and no overlap to detect. if (!job) console.warn('[backup] launched a Job but could not parse its id from:', out.slice(0, 200)); saveState({ jobId: job, startedAt: Date.now(), source, dataset, staging, error: null, outcomeFor: null }); return { job }; } /** Stage and the platform's own message for a Job — "Job timeout" lives in the * message, and it is the difference between "it broke" and "it never finished". */ async function jobStatus(jobId) { if (!jobId) return { stage: null, message: null }; try { const j = JSON.parse(await run(['jobs', 'inspect', jobId, '--json'], { timeout: 30_000 })); const s = Array.isArray(j) ? j[0] : j; return { stage: (s && s.status && s.status.stage) || null, message: (s && s.status && s.status.message) || null }; } catch { return { stage: null, message: null }; } } /** Terminal state of the last launched Job, or null while it is still going. */ async function jobStage(jobId) { return (await jobStatus(jobId)).stage; } /** * Why a run failed, in one line, from its own logs. * * The platform message says "Job timeout"; the logs say the bucket has a * `.cache/` path the Hub will not commit. The second is the one that tells an * operator what to do, so both are kept and this is the one shown first. */ async function failureReason(jobId) { try { const out = await run(['jobs', 'logs', jobId], { timeout: 90_000 }); const lines = out.split('\n').map((l) => l.trim()) .filter((l) => l && !/^(WARNING|Hint:|\[notice\])/.test(l)); const telling = [...lines].reverse() .find((l) => /error|refus|denied|invalid|cannot|failed|timeout/i.test(l)); return (telling || lines[lines.length - 1] || '').slice(0, 300) || null; } catch { return null; } } /** * Record how the last run ended, once, so the answer survives in state instead * of needing a Hub call to discover. * * This is the whole point of the feature: a backup that fails quietly is worse * than no backup at all, because the operator believes they are covered. Until * now nothing wrote a failure down — the settings row only learned of one if * somebody happened to open it while the evidence was still fresh. */ async function recordOutcome() { const st = loadState(); if (!st.jobId || st.outcomeFor === st.jobId) return; const { stage, message } = await jobStatus(st.jobId); if (!stage || isActiveStage(stage)) return; // still running, or the Hub did not say if (stage === 'COMPLETED') { saveState({ outcomeFor: st.jobId, failures: 0, lastFailure: null, lastSuccessAt: Date.now() }); return; } saveState({ outcomeFor: st.jobId, failures: (st.failures || 0) + 1, lastFailure: { at: Date.now(), jobId: st.jobId, stage, message: message || null, reason: await failureReason(st.jobId) }, }); console.warn(`[backup] run ${st.jobId} ended ${stage}${message ? ` (${message})` : ''}`); } // Stages that mean a run is genuinely in flight. Observed from the Hub: // SCHEDULING and RUNNING while live, COMPLETED or ERROR once finished. // // Deliberately a list of ACTIVE stages rather than terminal ones, so anything // unrecognised — a new stage name, or `inspect` failing and returning nothing — // counts as NOT running. Enumerating terminal stages had it the wrong way round: // an unknown answer read as "running", which is the worse mistake twice over. It // showed a run in progress that had long finished, and it would have blocked // every future backup behind a job that no longer exists. const ACTIVE = new Set(['SCHEDULING', 'RUNNING']); export const isActiveStage = (stage) => !!stage && ACTIVE.has(stage); /** Is the last launched Job still going? Unknown counts as finished, so a Hub * hiccup can never wedge backups off forever. */ async function isRunning() { const { jobId } = loadState(); if (!jobId) return false; return isActiveStage(await jobStage(jobId)); } /** * One tick: launch a backup if one is due. Deliberately dumb — no cron, no * catch-up queue. "Due" is just "the last one started more than an interval * ago", which behaves correctly across restarts because it is derived from the * timestamp on disk rather than from an in-memory schedule. */ export async function tick(cfg) { // How the last run ended is recorded even when this tick does nothing else — // otherwise a failure is only noticed by someone opening the settings page. if (hasToken()) await recordOutcome().catch(() => {}); if (unavailableReason(cfg)) return { skipped: unavailableReason(cfg) }; const state = loadState(); const due = Date.now() - (state.startedAt || 0) >= intervalMs(cfg.backup.every); if (!due) return { skipped: 'not due' }; // Never let two runs overlap: a big first backup can outlast an interval. if (await isRunning()) return { skipped: 'previous backup still running' }; try { const r = await runBackupNow(cfg); console.log(`[backup] launched job ${r.job} (every ${cfg.backup.every})`); return r; } catch (e) { const error = e.stderr || e.message; saveState({ error, erroredAt: Date.now() }); console.warn('[backup] launch failed:', error); return { error }; } } /** * Start the loop. One unref'd timer that asks every 5 minutes whether a backup * is due; the config is re-read each time, so switching the interval takes * effect without restarting anything. */ /** Note when the schedule was first switched on, so "no success yet" can go * stale for a backup that has never once worked — which is this Space today. */ export function armStaleClock(cfg) { if (!intervalMs(cfg?.backup?.every)) return; const st = loadState(); if (!st.firstArmedAt) saveState({ firstArmedAt: Date.now() }); } export function startBackupTimer(getConfig) { armStaleClock(getConfig()); const t = setInterval(() => { armStaleClock(getConfig()); tick(getConfig()).catch(() => {}); }, TICK_MS); if (t.unref) t.unref(); // First check shortly after boot, once bucket discovery has landed. const first = setTimeout(() => { tick(getConfig()).catch(() => {}); }, 30_000); if (first.unref) first.unref(); return () => { clearInterval(t); clearTimeout(first); }; } /** * Backup health, from state alone — no Hub calls, because this rides on * /api/info which every open tab polls every 15 seconds. * * Returns null when there is nothing wrong, so the dashboard shows nothing at * all in the normal case. Two ways to be unwell: * - `failing`: the last run ended in ERROR (or was killed). * - `stale`: no successful run in three intervals. A silent stall — the timer * never firing, the token going bad — leaves an operator just as wrongly * confident as an outright failure, and looks fine without this. */ export function backupHealth(cfg) { const every = cfg?.backup?.every || 'never'; const ms = intervalMs(every); if (!ms) return null; // switched off: silence is correct const st = loadState(); const f = st.lastFailure || null; const lastSuccessAt = st.lastSuccessAt || null; const since = lastSuccessAt || st.firstArmedAt || null; const stale = !!since && Date.now() - since > ms * 3; if (!f && !stale) return null; return { failing: !!f, stale, failures: st.failures || 0, at: f ? f.at : null, jobId: f ? f.jobId : null, stage: f ? f.stage : null, message: f ? f.message || null : null, reason: f ? f.reason || null : null, lastSuccessAt, jobsUrl: jobsUrl(), }; } /** * Status for the settings row. A backup nobody can see the state of is a backup * nobody trusts — and the destination's privacy is the one thing that must be * visible, so it is re-read here rather than remembered. */ export async function backupStatus(cfg) { const state = loadState(); const every = cfg?.backup?.every || 'never'; const source = sourceBucket(); const { dataset, staging, defaults, exclude } = await targets(cfg); // Not gated on the interval: an on-demand backup is a real backup, and its // result has to be visible even with the schedule off. const [stage, priv] = await Promise.all([ state.jobId ? jobStage(state.jobId) : null, dataset && hasToken() ? datasetPrivate(dataset) : null, ]); return { every, source, dataset, staging, defaults, hasToken: hasToken(), canRunNow: !runNowBlockedBy(), running: isActiveStage(stage), unavailable: unavailableReason(cfg), last: state.startedAt ? { at: state.startedAt, jobId: state.jobId || null, stage: stage || null } : null, jobName: jobName(), jobsUrl: jobsUrl(), // The tokens as stored, plus the shipped defaults, so Settings can offer // "restore defaults" and show whether the current list differs from them. exclude, excludeDefaults: [...DEFAULT_EXCLUDE], excludeIsDefault: exclude.length === DEFAULT_EXCLUDE.length && exclude.every((t, i) => t === DEFAULT_EXCLUDE[i]), health: backupHealth(cfg), failures: state.failures || 0, lastFailure: state.lastFailure || null, lastSuccessAt: state.lastSuccessAt || null, nextDue: state.startedAt && intervalMs(every) ? state.startedAt + intervalMs(every) : null, datasetPrivate: priv, // null = unknown or not created yet error: state.error || null, }; } // One small authed read: the settings row needs the CURRENT answer, because a // repo holding credentials can be flipped public at any time. async function datasetPrivate(dataset) { if (!validRepoId(dataset)) return null; try { const j = JSON.parse(await run(['datasets', 'info', dataset, '--json'], { timeout: 30_000 })); return j.private === true; } catch { return null; } }