← 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
A agents/instagram-agent/check-link-drift.js
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 →