[object Object]

← back to Norma

IG fleet: add read-only link-drift checker (TK-10740)

dd6830391d75db09bf0f9d3d5f7fcce69f3d1944 · 2026-09-10 07:35:25 -0700 · Steve Abrams

build-registry.js is only "self-healing" when something runs it — and nothing
does. No launchd plist and no cron entry calls it, so a newly created or
restored IG<->Page link never enrolls and never signals. That is how
@grassclothwallpaper sat linked-but-unenrolled for ~2 weeks.

check-link-drift.js is the DISCOVER half of a discover/activate split: it
diffs the live Meta token's postable accounts against accounts.json and
reports both directions (linked-not-enrolled, enrolled-but-link-lost). It
never writes accounts.json — activation stays a gated `build-registry.js`,
because accounts.json is the control plane for daily-cadence.js, which
com.steve.dw-ig-cadence runs without --dry (live public posts).

A failed token/API read exits 2 (UNKNOWN) rather than reporting in-sync, so
it cannot emit a false PASS. Emits PASS/WARN in the fleet-health-rollup
vocabulary. No schedule installed — that is a gated action.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q2weureFg2J91hfDAipxTM

Files touched

Diff

commit dd6830391d75db09bf0f9d3d5f7fcce69f3d1944
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Sep 10 07:35:25 2026 -0700

    IG fleet: add read-only link-drift checker (TK-10740)
    
    build-registry.js is only "self-healing" when something runs it — and nothing
    does. No launchd plist and no cron entry calls it, so a newly created or
    restored IG<->Page link never enrolls and never signals. That is how
    @grassclothwallpaper sat linked-but-unenrolled for ~2 weeks.
    
    check-link-drift.js is the DISCOVER half of a discover/activate split: it
    diffs the live Meta token's postable accounts against accounts.json and
    reports both directions (linked-not-enrolled, enrolled-but-link-lost). It
    never writes accounts.json — activation stays a gated `build-registry.js`,
    because accounts.json is the control plane for daily-cadence.js, which
    com.steve.dw-ig-cadence runs without --dry (live public posts).
    
    A failed token/API read exits 2 (UNKNOWN) rather than reporting in-sync, so
    it cannot emit a false PASS. Emits PASS/WARN in the fleet-health-rollup
    vocabulary. No schedule installed — that is a gated action.
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01Q2weureFg2J91hfDAipxTM
---
 agents/instagram-agent/check-link-drift.js | 111 +++++++++++++++++++++++++++++
 1 file changed, 111 insertions(+)

diff --git a/agents/instagram-agent/check-link-drift.js b/agents/instagram-agent/check-link-drift.js
new file mode 100755
index 0000000..42e7cbe
--- /dev/null
+++ b/agents/instagram-agent/check-link-drift.js
@@ -0,0 +1,111 @@
+#!/usr/bin/env node
+/**
+ * check-link-drift.js — READ-ONLY. Report drift between the LIVE Meta token's
+ * postable IG accounts and the enrolled registry in accounts.json.
+ *
+ * Why this exists (TK-10740): build-registry.js is "self-healing" only in the
+ * sense that re-running it picks up new links — but NOTHING runs it. There is
+ * no launchd job and no cron entry that calls it. So when an IG<->Page link is
+ * created (or restored), the fleet silently stays stale and no signal fires.
+ * That is how @grassclothwallpaper sat linked-but-unenrolled for ~2 weeks.
+ *
+ * This is the DISCOVER half of a discover/activate split. It NEVER writes
+ * accounts.json — activation stays a human-gated `node build-registry.js`,
+ * because accounts.json is the control plane for daily-cadence.js, which
+ * com.steve.dw-ig-cadence runs WITHOUT --dry (i.e. it posts publicly).
+ *
+ * Usage:  node check-link-drift.js          # human-readable report
+ *         node check-link-drift.js --json   # machine-readable
+ * Exit:   0 = in sync · 1 = drift found · 2 = could not determine (token/API)
+ */
+
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+
+const GRAPH = process.env.IG_GRAPH_HOST || 'https://graph.facebook.com';
+const VERSION = process.env.IG_GRAPH_VERSION || 'v21.0';
+const HERE = __dirname;
+const JSON_OUT = process.argv.includes('--json');
+
+function token() {
+  if (process.env.META_ACCESS_TOKEN) return process.env.META_ACCESS_TOKEN;
+  try {
+    const line = fs.readFileSync(path.join(os.homedir(), 'Projects/secrets-manager/.env'), 'utf8')
+      .split('\n').find((l) => l.startsWith('META_ACCESS_TOKEN='));
+    if (line) return line.slice('META_ACCESS_TOKEN='.length).trim();
+  } catch { /* ignore */ }
+  return '';
+}
+
+(async () => {
+  const t = token();
+  if (!t) { console.error('UNKNOWN: no META_ACCESS_TOKEN (env or secrets-manager/.env)'); process.exit(2); }
+
+  let url = `${GRAPH}/${VERSION}/me/accounts`
+    + `?fields=name,instagram_business_account{id,username}&limit=100&access_token=${encodeURIComponent(t)}`;
+  const live = new Map();
+  let pagesSeen = 0;
+  try {
+    while (url) {
+      const r = await fetch(url);
+      const j = await r.json();
+      if (j.error) throw new Error(j.error.message);
+      for (const p of j.data || []) {
+        pagesSeen += 1;
+        const ig = p.instagram_business_account;
+        if (ig && ig.id && ig.username) {
+          live.set(ig.username, { ig_user_id: ig.id, page_id: p.id, page_name: p.name });
+        }
+      }
+      url = j.paging && j.paging.next ? j.paging.next : null;
+    }
+  } catch (e) {
+    // Never report "in sync" off a failed read — that would be a false PASS.
+    console.error(`UNKNOWN: live read failed — ${e.message}`);
+    process.exit(2);
+  }
+
+  let disk;
+  try {
+    disk = JSON.parse(fs.readFileSync(path.join(HERE, 'accounts.json'), 'utf8')).accounts || {};
+  } catch (e) {
+    console.error(`UNKNOWN: could not read accounts.json — ${e.message}`);
+    process.exit(2);
+  }
+
+  const liveHandles = [...live.keys()].sort();
+  const diskHandles = Object.keys(disk).sort();
+  const newlyLinked = liveHandles.filter((h) => !diskHandles.includes(h));   // linked, not enrolled
+  const lostLink = diskHandles.filter((h) => !liveHandles.includes(h));      // enrolled, link gone
+  const drift = newlyLinked.length > 0 || lostLink.length > 0;
+
+  const report = {
+    checked_at: new Date().toISOString(),
+    pages_on_token: pagesSeen,
+    live_postable: liveHandles.length,
+    enrolled_on_disk: diskHandles.length,
+    newly_linked_not_enrolled: newlyLinked.map((h) => ({ handle: h, ...live.get(h) })),
+    enrolled_but_link_lost: lostLink,
+    verdict: drift ? 'DRIFT' : 'IN_SYNC',
+    status: drift ? 'WARN' : 'PASS',   // fleet-health-rollup vocabulary
+    activate_command: drift ? 'node build-registry.js   # GATED: enrolling widens live public posting scope' : null,
+  };
+
+  if (JSON_OUT) { console.log(JSON.stringify(report, null, 2)); }
+  else {
+    console.log(`IG link drift check — ${report.verdict}`);
+    console.log(`  pages on token: ${pagesSeen} · live postable: ${liveHandles.length} · enrolled: ${diskHandles.length}`);
+    if (newlyLinked.length) {
+      console.log(`  LINKED BUT NOT ENROLLED (${newlyLinked.length}):`);
+      for (const h of newlyLinked) console.log(`    + @${h}  (Page "${live.get(h).page_name}", ig ${live.get(h).ig_user_id})`);
+    }
+    if (lostLink.length) {
+      console.log(`  ENROLLED BUT LINK LOST (${lostLink.length}):`);
+      for (const h of lostLink) console.log(`    - @${h}`);
+    }
+    if (drift) console.log(`  → activation is GATED (it widens live public posting): ${report.activate_command}`);
+    else console.log('  registry matches the live token; nothing to do.');
+  }
+  process.exit(drift ? 1 : 0);
+})();

← 58215ca auto-data-snapshot: 2026-09-09T20:06:23 (1 data files) — age  ·  back to Norma  ·  TK-10758: ID-pinned eraser for the 2 residual sassy reposts 690a70b →