[object Object]

← back to Norma

TK-10573 GATE2: browser-free Graph-API reconcile + delete tooling (57 eligible, canary-first)

75f9762dd6cf998523e4355c58a8368ae7b4023b · 2026-08-20 15:48:13 -0700 · Steve Abrams

Files touched

Diff

commit 75f9762dd6cf998523e4355c58a8368ae7b4023b
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Aug 20 15:48:13 2026 -0700

    TK-10573 GATE2: browser-free Graph-API reconcile + delete tooling (57 eligible, canary-first)
---
 agents/instagram-agent/delete-gate2-graph.js | 103 ++++++++++++++++++++++++
 agents/instagram-agent/reconcile-gate2.js    | 112 +++++++++++++++++++++++++++
 2 files changed, 215 insertions(+)

diff --git a/agents/instagram-agent/delete-gate2-graph.js b/agents/instagram-agent/delete-gate2-graph.js
new file mode 100644
index 0000000..1e727f4
--- /dev/null
+++ b/agents/instagram-agent/delete-gate2-graph.js
@@ -0,0 +1,103 @@
+#!/usr/bin/env node
+/**
+ * delete-gate2-graph.js — BROWSER-FREE Graph-API deletion of the GATE-2 eligible originals.
+ * TK-10573. Successor to delete-originals.js (openclaw browser = ban vector). batch-04
+ * (TK-10612) proved DELETE /{ig-media-id} works with the shared META token, so no browser.
+ *
+ * Source of truth = data/gate2-recon.json (written by reconcile-gate2.js). Only rows with
+ * eligible:true (old still live AND new repost verified live) are ever touched.
+ *
+ *   node delete-gate2-graph.js                 # PROBE (default): list eligible targets, delete nothing
+ *   node delete-gate2-graph.js --only <handle> # scope to one account
+ *   node delete-gate2-graph.js --canary        # delete EXACTLY ONE eligible target, verify gone, STOP
+ *   node delete-gate2-graph.js --go            # delete all eligible, paced, verify each (destructive)
+ *
+ * SAFETY: default probe-only. Paced PACE_MS between deletes (gentle; Graph, not browser, but still
+ * respectful). Every delete is VERIFIED (GET afterward must 404/return error) before tombstoning.
+ * Idempotent/resumable via data/deleted-posts.jsonl. Fail-loud: a delete that doesn't verify-gone
+ * is recorded as FAILED and does NOT count as done.
+ */
+require('dotenv').config();
+const fs = require('fs');
+const path = require('path');
+const https = require('https');
+
+const DATA = path.join(__dirname, 'data');
+const RECON = path.join(DATA, 'gate2-recon.json');
+const TOMBS = path.join(DATA, 'deleted-posts.jsonl');
+const TOKEN = process.env.IG_ACCESS_TOKEN;
+const VER = process.env.IG_GRAPH_VERSION || 'v21.0';
+const HOST = 'graph.facebook.com';
+const PACE_MS = 15000; // 15s between deletes
+const TICKET = 'TK-10573';
+
+const args = process.argv.slice(2);
+const CANARY = args.includes('--canary');
+const GO = args.includes('--go');
+const only = (() => { const i = args.indexOf('--only'); return i >= 0 ? args[i + 1] : null; })();
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const shortOf = (p) => (String(p).match(/\/p\/([^/]+)/) || [])[1] || null;
+
+function req(method, pathq) {
+  return new Promise((resolve) => {
+    const r = https.request(`https://${HOST}/${VER}/${pathq}`, { method }, (res) => {
+      let s = ''; res.on('data', (d) => (s += d));
+      res.on('end', () => { let j = null; try { j = JSON.parse(s); } catch {} resolve({ status: res.statusCode, body: j, raw: s }); });
+    });
+    r.on('error', (e) => resolve({ status: 0, body: null, raw: String(e.message) }));
+    r.end();
+  });
+}
+const get = (p) => req('GET', p);
+const del = (p) => req('DELETE', p);
+
+function alreadyDeleted() {
+  if (!fs.existsSync(TOMBS)) return new Set();
+  return new Set(fs.readFileSync(TOMBS, 'utf8').trim().split('\n').filter(Boolean)
+    .map((l) => { try { const j = JSON.parse(l); return j.media_id; } catch { return null; } }).filter(Boolean));
+}
+function tombstone(row) {
+  fs.appendFileSync(TOMBS, JSON.stringify({
+    ts: new Date().toISOString(), handle: row.handle, shortcode: shortOf(row.old_permalink),
+    media_id: row.old_media_id, permalink: row.old_permalink, method: 'graph-api', ticket: TICKET, verified: true,
+  }) + '\n');
+}
+
+(async () => {
+  const recon = JSON.parse(fs.readFileSync(RECON, 'utf8'));
+  const done = alreadyDeleted();
+  let targets = recon.report.filter((r) => r.eligible && !done.has(r.old_media_id));
+  if (only) targets = targets.filter((r) => r.handle === only);
+
+  console.log(`eligible & not-yet-deleted${only ? ` for @${only}` : ''}: ${targets.length}`);
+  if (!CANARY && !GO) {
+    const byH = {}; targets.forEach((t) => (byH[t.handle] = (byH[t.handle] || 0) + 1));
+    console.log('PROBE (no deletes). By account:', JSON.stringify(byH, null, 2));
+    console.log('Run with --canary (delete 1, verify, stop) or --go (delete all, paced+verified).');
+    return;
+  }
+  if (CANARY) targets = targets.slice(0, 1);
+
+  let ok = 0, fail = 0;
+  for (const t of targets) {
+    process.stdout.write(`DELETE @${t.handle} ${t.old_permalink} (${t.old_media_id}) ... `);
+    const d = await del(`${t.old_media_id}?access_token=${TOKEN}`);
+    await sleep(1500);
+    const chk = await get(`${t.old_media_id}?fields=id&access_token=${TOKEN}`);
+    const gone = chk.status !== 200 || (chk.body && chk.body.error);
+    if ((d.status === 200 || (d.body && d.body.success)) && gone) {
+      tombstone(t); ok++;
+      console.log('✓ deleted+verified');
+    } else if (gone) {
+      // delete response odd but object is gone -> still a success
+      tombstone(t); ok++;
+      console.log(`✓ gone (delete resp status ${d.status})`);
+    } else {
+      fail++;
+      console.log(`✗ FAILED (del status ${d.status}: ${d.body && d.body.error ? d.body.error.message : d.raw.slice(0,120)})`);
+    }
+    if (CANARY) break;
+    await sleep(PACE_MS);
+  }
+  console.log(`\nDONE: deleted+verified ${ok}, failed ${fail}, of ${targets.length} attempted.`);
+})();
diff --git a/agents/instagram-agent/reconcile-gate2.js b/agents/instagram-agent/reconcile-gate2.js
new file mode 100644
index 0000000..b77d66d
--- /dev/null
+++ b/agents/instagram-agent/reconcile-gate2.js
@@ -0,0 +1,112 @@
+#!/usr/bin/env node
+/**
+ * reconcile-gate2.js — READ-ONLY ground-truth reconciliation for TK-10573 GATE-2.
+ *
+ * The ledgers (deleted-posts.jsonl, delete-results-*.jsonl) are sparse/unreliable because the
+ * GATE-2 work happened across many sessions/architectures. The only reliable truth is the live
+ * Graph API. This script, using the ONE shared META token, for each of the 84 corrected
+ * originals:
+ *   - GET /{old_media_id}                    → is the OLD bad-caption post still live? (delete target)
+ *   - checks the owning account's live /media → is the NEW corrected repost actually live?
+ *     (batch-04 found 3 reposts had silently vanished; deleting an old whose new is gone = data loss)
+ *
+ * Output: an eligibility report. eligible = old still live AND new confirmed live.
+ * Writes NOTHING to Instagram. Pure GETs. Cost $0.
+ *
+ *   node reconcile-gate2.js            # full report -> data/gate2-recon.json + console summary
+ */
+require('dotenv').config();
+const fs = require('fs');
+const path = require('path');
+const https = require('https');
+
+const DATA = path.join(__dirname, 'data');
+const SRC = path.join(DATA, 'redo-results-20260814.jsonl');
+const ACC = require('./accounts.json').accounts;
+const TOKEN = process.env.IG_ACCESS_TOKEN;
+const VER = process.env.IG_GRAPH_VERSION || 'v21.0';
+const HOST = 'graph.facebook.com';
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const shortOf = (permalink) => (String(permalink).match(/\/p\/([^/]+)/) || [])[1] || null;
+
+function get(pathq) {
+  return new Promise((resolve) => {
+    https.get(`https://${HOST}/${VER}/${pathq}`, (r) => {
+      let s = '';
+      r.on('data', (d) => (s += d));
+      r.on('end', () => { let j = null; try { j = JSON.parse(s); } catch {} resolve({ status: r.statusCode, body: j, raw: s }); });
+    }).on('error', (e) => resolve({ status: 0, body: null, raw: String(e.message) }));
+  });
+}
+
+// paginate an account's live media into a set of live shortcodes (read-only)
+async function liveShortcodes(igId, cap = 200) {
+  const set = new Set();
+  let after = '';
+  for (let page = 0; page < 8; page++) {
+    const q = `${igId}/media?fields=id,permalink&limit=50${after ? `&after=${after}` : ''}&access_token=${TOKEN}`;
+    const r = await get(q);
+    if (r.status !== 200 || !r.body || !Array.isArray(r.body.data)) break;
+    r.body.data.forEach((m) => { const sc = shortOf(m.permalink); if (sc) set.add(sc); });
+    const next = r.body.paging && r.body.paging.cursors && r.body.paging.cursors.after;
+    if (!next || set.size >= cap) break;
+    after = next;
+    await sleep(150);
+  }
+  return set;
+}
+
+(async () => {
+  const rows = fs.readFileSync(SRC, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse)
+    .filter((r) => r.old_permalink && r.old_media_id);
+  const handles = [...new Set(rows.map((r) => r.handle))];
+
+  // per-account live shortcode sets (for NEW-repost liveness)
+  const liveByHandle = {};
+  for (const h of handles) {
+    const a = ACC[h];
+    if (!a || !a.ig_user_id) { liveByHandle[h] = null; continue; }
+    process.stderr.write(`  fetching live media: @${h}\n`);
+    liveByHandle[h] = await liveShortcodes(a.ig_user_id);
+    await sleep(150);
+  }
+
+  const report = [];
+  for (const r of rows) {
+    const oldRes = await get(`${r.old_media_id}?fields=id,permalink&access_token=${TOKEN}`);
+    const old_live = oldRes.status === 200 && oldRes.body && oldRes.body.id;
+    const old_err = oldRes.status !== 200 ? (oldRes.body && oldRes.body.error && oldRes.body.error.message) || `HTTP ${oldRes.status}` : null;
+    const newSc = shortOf(r.new_permalink);
+    const liveSet = liveByHandle[r.handle];
+    const new_live = liveSet ? liveSet.has(newSc) : null; // null = couldn't verify (no roster/id)
+    report.push({
+      handle: r.handle, product_handle: r.product_handle,
+      old_media_id: r.old_media_id, old_permalink: r.old_permalink, old_live: !!old_live, old_err,
+      new_permalink: r.new_permalink, new_shortcode: newSc, new_live,
+      eligible: !!old_live && new_live === true,
+      reason: !old_live ? 'old-already-gone' : (new_live === false ? 'NEW-REPOST-MISSING(hold)' : (new_live === null ? 'new-unverifiable(hold)' : 'eligible')),
+    });
+    await sleep(150);
+  }
+
+  const out = path.join(DATA, 'gate2-recon.json');
+  fs.writeFileSync(out, JSON.stringify({ ts: new Date().toISOString(), total: report.length, report }, null, 2));
+
+  // summary
+  const byReason = {};
+  report.forEach((x) => (byReason[x.reason] = (byReason[x.reason] || 0) + 1));
+  console.log('\n=== GATE-2 RECONCILIATION (read-only) ===');
+  console.log('total originals:', report.length);
+  console.log('by reason:', JSON.stringify(byReason, null, 2));
+  const elig = report.filter((x) => x.eligible);
+  console.log('\nELIGIBLE TO DELETE (old live + new confirmed live):', elig.length);
+  const byHandleE = {};
+  elig.forEach((x) => (byHandleE[x.handle] = (byHandleE[x.handle] || 0) + 1));
+  console.log(JSON.stringify(byHandleE, null, 2));
+  const hold = report.filter((x) => !x.eligible && x.old_live);
+  if (hold.length) {
+    console.log('\n⚠️  HOLD — old still live but new NOT confirmed (do NOT delete):');
+    hold.forEach((x) => console.log(`   @${x.handle} ${x.old_permalink} -> new ${x.new_permalink} [${x.reason}]`));
+  }
+  console.log('\nwrote', out);
+})();

← 4c96ddd auto-data-snapshot: 2026-08-20T15:43:22 (1 data files) — age  ·  back to Norma  ·  TK-10758: stop+erase sassy reposts of others' content - disa 404bd15 →