← 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); });