← back to Draft Viewer

snapshot.mjs

103 lines

#!/usr/bin/env node
// snapshot.mjs — TK-11231
// READ-ONLY. Fetches the FULL body of every draft the triage board tracks and
// writes it to disk, one JSON file per draft. Nothing is sent, edited or deleted.
//
// Why this exists: com.steve.george-drain-old-drafts calls Gmail drafts.delete,
// which has NO Trash — a deleted draft is gone. Every downstream decision on this
// ticket (send / keep / delete) was therefore irreversible. With a snapshot on
// disk it is not: the text survives even if Gmail loses the draft.
//
// Quota: the Gmail per-minute query cost is a SHARED fleet resource and was
// exhausted once during this ticket, so calls are paced and failures are recorded
// per-draft rather than aborting the run (a partial snapshot beats none).

import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';

const HERE = path.dirname(new URL(import.meta.url).pathname);
const OUT = path.join(HERE, 'data', 'bodies');
const PACE_MS = Number(process.env.SNAP_PACE_MS || 1300);

// Auth lives in ~/.claude.json mcpServers.george.env — NOT secrets-manager/.env
// (verified absent there; see memory george-draft-drain-and-http-delete).
function george() {
  const cfg = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.claude.json'), 'utf8'));
  const env = cfg?.mcpServers?.george?.env || {};
  const url = (env.GEORGE_URL || 'http://127.0.0.1:9850').replace(/\/$/, '');
  const auth = env.GEORGE_BASIC_AUTH;
  if (!auth) throw new Error('GEORGE_BASIC_AUTH missing from ~/.claude.json mcpServers.george.env');
  return { url, headers: { Authorization: 'Basic ' + auth } };
}

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

// info@ and steve-office are different Gmail accounts behind different route
// prefixes; sending an id to the wrong one returns a confusing 500, not a 404.
const routeFor = (account, id) =>
  account === 'info' ? `/api/info/messages/${id}` : `/api/messages/${id}?account=steve-office`;

async function fetchBody(g, account, id) {
  const res = await fetch(g.url + routeFor(account, id), { headers: g.headers });
  const text = await res.text();
  if (!res.ok) return { ok: false, status: res.status, error: text.slice(0, 300) };
  let msg;
  try { msg = JSON.parse(text); }
  catch { return { ok: false, status: res.status, error: 'unparseable body' }; }
  // The two account routes disagree on the field name: steve-office returns
  // `body`, info@ returns `bodyHtml`/`bodyText`. Reading only `body` gave an
  // empty string with a 200 OK — a file written, nothing in it.
  msg.body = msg.body || msg.bodyText || msg.bodyHtml || '';
  return { ok: true, msg };
}

function targets() {
  const board = JSON.parse(fs.readFileSync(path.join(HERE, 'data', 'drafts.json'), 'utf8'));
  const out = [];
  for (const r of board.ready || []) out.push({ ...r, bucket: 'ready' });
  for (const r of board.delete || []) out.push({ ...r, bucket: 'delete' });
  // The two IMMINENT info@ drafts were never on the board — they are the ones on
  // a ~4-day fuse, so they matter most and must not be missed.
  const extra = JSON.parse(fs.readFileSync(path.join(HERE, 'data', 'imminent.json'), 'utf8'));
  for (const r of extra) out.push({ ...r, bucket: 'imminent' });
  // A draft can appear twice across buckets; snapshot each id once.
  const seen = new Set();
  return out.filter((d) => (seen.has(d.id) ? false : (seen.add(d.id), true)));
}

const main = async () => {
  fs.mkdirSync(OUT, { recursive: true });
  const g = george();
  const list = targets();
  const report = { snapshot_at: new Date().toISOString(), total: list.length, saved: 0, skipped_dead: 0, failed: [] };

  for (const d of list) {
    if (d.live === false) { report.skipped_dead++; continue; }
    const file = path.join(OUT, `${d.account}__${d.id}.json`);
    if (fs.existsSync(file) && !process.env.SNAP_FORCE) { report.saved++; continue; }

    const r = await fetchBody(g, d.account, d.id);
    if (!r.ok) {
      report.failed.push({ id: d.id, account: d.account, status: r.status, error: r.error });
      console.log(`  FAIL ${d.account} ${d.id} — ${r.status} ${String(r.error).slice(0, 90)}`);
    } else if (!String(r.msg.body || '').trim()) {
      report.failed.push({ id: d.id, account: d.account, status: 200, error: 'EMPTY BODY — 200 OK but no content captured' });
      console.log(`  EMPTY ${d.account} ${d.id} — 200 but body was blank, NOT counted as saved`);
    } else {
      fs.writeFileSync(file, JSON.stringify({ board: d, message: r.msg }, null, 2));
      report.saved++;
      console.log(`  saved ${d.account} ${d.id} — ${String(r.msg.subject || '(no subject)').slice(0, 62)}`);
    }
    await sleep(PACE_MS);
  }

  fs.writeFileSync(path.join(HERE, 'data', 'snapshot-report.json'), JSON.stringify(report, null, 2));
  console.log(`\nsnapshot: ${report.saved}/${report.total} saved · ${report.skipped_dead} already gone · ${report.failed.length} failed`);
  console.log(`bodies → ${OUT}`);
  // A partial snapshot is still worth keeping, but say so loudly.
  process.exit(report.failed.length ? 2 : 0);
};

main().catch((e) => { console.error('snapshot aborted:', e.message); process.exit(1); });