[object Object]

← back to Marketing Command Center

follow-counts: write no row on failure + exit 2 (no SIMULATED nulls, TK-11431 class); timestamp every attempt; job 06:05->07:05 clear of 5am fleet restart (TK-12008)

8377f045f97d9ae20d6aff64a0851bad69a1fe6e · 2026-09-26 08:40:03 -0700 · Steve Abrams

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C29dMTKDo1Ff3goeJgr2XY

Files touched

Diff

commit 8377f045f97d9ae20d6aff64a0851bad69a1fe6e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Sep 26 08:40:03 2026 -0700

    follow-counts: write no row on failure + exit 2 (no SIMULATED nulls, TK-11431 class); timestamp every attempt; job 06:05->07:05 clear of 5am fleet restart (TK-12008)
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01C29dMTKDo1Ff3goeJgr2XY
---
 deploy/com.steve.dw-follow-counts.plist | 57 +++++++++++++--------------------
 modules/follow-counts/index.js          | 34 +++++++++++---------
 scripts/follow-counts-snapshot.js       |  6 ++--
 3 files changed, 44 insertions(+), 53 deletions(-)

diff --git a/deploy/com.steve.dw-follow-counts.plist b/deploy/com.steve.dw-follow-counts.plist
index 1cbd073..865d403 100644
--- a/deploy/com.steve.dw-follow-counts.plist
+++ b/deploy/com.steve.dw-follow-counts.plist
@@ -1,39 +1,28 @@
 <?xml version="1.0" encoding="UTF-8"?>
-<!--
-  STAGED — NOT loaded. Once-daily Followers/Following snapshot for the DW Marketing
-  Command Center. Captures each DW IG account's followers_count + follows_count via
-  Norma's instagram-agent (:9810) and persists one row/day so growth charts.
-
-  Local install (Mac2), when Steve approves:
-    cp deploy/com.steve.dw-follow-counts.plist ~/Library/LaunchAgents/
-    launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.steve.dw-follow-counts.plist
-  Verify:  launchctl list | grep dw-follow-counts
-  Run now: launchctl kickstart gui/$(id -u)/com.steve.dw-follow-counts
-
-  Runs daily at 06:05 local (RunAtLoad so a missed forced-sleep day still fires on
-  next login — see MEMORY: keepawake does not block power-button sleep).
--->
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
 <plist version="1.0">
 <dict>
-  <key>Label</key>
-  <string>com.steve.dw-follow-counts</string>
-  <key>ProgramArguments</key>
-  <array>
-    <string>/opt/homebrew/bin/node</string>
-    <string>/Users/macstudio3/Projects/marketing-command-center/scripts/follow-counts-snapshot.js</string>
-  </array>
-  <key>WorkingDirectory</key>
-  <string>/Users/macstudio3/Projects/marketing-command-center</string>
-  <key>RunAtLoad</key>
-  <true/>
-  <key>StartCalendarInterval</key>
-  <dict>
-    <key>Hour</key><integer>6</integer>
-    <key>Minute</key><integer>5</integer>
-  </dict>
-  <key>StandardOutPath</key>
-  <string>/Users/macstudio3/Projects/marketing-command-center/data/follow-counts-snapshot.out</string>
-  <key>StandardErrorPath</key>
-  <string>/Users/macstudio3/Projects/marketing-command-center/data/follow-counts-snapshot.err</string>
+	<key>Label</key>
+	<string>com.steve.dw-follow-counts</string>
+	<key>ProgramArguments</key>
+	<array>
+		<string>/opt/homebrew/bin/node</string>
+		<string>/Users/macstudio3/Projects/marketing-command-center/scripts/follow-counts-snapshot.js</string>
+	</array>
+	<key>RunAtLoad</key>
+	<true/>
+	<key>StandardErrorPath</key>
+	<string>/Users/macstudio3/Projects/marketing-command-center/data/follow-counts-snapshot.err</string>
+	<key>StandardOutPath</key>
+	<string>/Users/macstudio3/Projects/marketing-command-center/data/follow-counts-snapshot.out</string>
+	<key>StartCalendarInterval</key>
+	<dict>
+		<key>Hour</key>
+		<integer>7</integer>
+		<key>Minute</key>
+		<integer>5</integer>
+	</dict>
+	<key>WorkingDirectory</key>
+	<string>/Users/macstudio3/Projects/marketing-command-center</string>
 </dict>
 </plist>
diff --git a/modules/follow-counts/index.js b/modules/follow-counts/index.js
index 701fd3e..3d73f95 100644
--- a/modules/follow-counts/index.js
+++ b/modules/follow-counts/index.js
@@ -82,7 +82,9 @@ async function fetchCountsFromNorma(attempts = 4) {
   let last;
   for (let i = 0; i < attempts; i++) {
     last = await fetchCountsFromNormaOnce();
-    if (last.ok || !/^Norma unreachable/.test(last.error || '')) return last;
+    if (last.ok) return last;
+    console.error(`[follow-counts] ${new Date().toISOString()} attempt ${i + 1}/${attempts}: ${last.error}`);
+    if (!/^Norma unreachable/.test(last.error || '')) return last;
     if (i < attempts - 1) await new Promise(r => setTimeout(r, 15000));
   }
   return last;
@@ -126,24 +128,24 @@ async function captureSnapshot() {
   // primary; if multiple accounts are configured the same reading applies to the
   // primary only and others record a no-data point until Norma is multi-account.
   const counts = await fetchCountsFromNorma();
+  // On failure write NO row (Steve, TK-12008): a null "SIMULATED" row makes the history
+  // look complete when it isn't (TK-11431 false-green class) and the per-day upsert let a
+  // later failed run overwrite a good LIVE reading. The failure belongs in the error log only.
+  if (!counts.ok) {
+    return { ok: false, simulated: true, error: counts.error, date, captured: [] };
+  }
   for (const a of accounts) {
     const h = a.handle;
+    if (a.primary === false) continue; // Norma is single-account: no reading for non-primary
     hist[h] = hist[h] || [];
-    let row;
-    if (a.primary !== false && counts.ok) {
-      row = {
-        date, ts,
-        followers: counts.followers,
-        following: counts.following,
-        media: counts.media,
-        simulated: counts.simulated,
-        source: 'norma:monitor',
-      };
-    } else {
-      row = { date, ts, followers: null, following: null, media: null, simulated: true,
-              source: counts.ok ? 'no-account-binding' : 'norma-error',
-              error: counts.ok ? undefined : counts.error };
-    }
+    const row = {
+      date, ts,
+      followers: counts.followers,
+      following: counts.following,
+      media: counts.media,
+      simulated: counts.simulated,
+      source: 'norma:monitor',
+    };
     // upsert by date
     const idx = hist[h].findIndex(r => r.date === date);
     if (idx >= 0) hist[h][idx] = row; else hist[h].push(row);
diff --git a/scripts/follow-counts-snapshot.js b/scripts/follow-counts-snapshot.js
index c7010f9..53f781b 100644
--- a/scripts/follow-counts-snapshot.js
+++ b/scripts/follow-counts-snapshot.js
@@ -24,14 +24,14 @@ const mod = require('../modules/follow-counts');
 (async () => {
   try {
     const r = await mod.captureSnapshot();
-    const tag = r.simulated ? 'SIMULATED (awaiting live IG creds)' : 'LIVE';
+    const tag = !r.ok ? 'NO DATA' : r.simulated ? 'SIMULATED (awaiting live IG creds)' : 'LIVE';
     console.log(`[follow-counts] ${new Date().toISOString()} snapshot ${r.ok ? 'ok' : 'FAILED'} · ${tag} · date=${r.date}`);
     for (const c of (r.captured || [])) {
       console.log(`  @${c.handle}: followers=${c.followers} following=${c.following} media=${c.media} (${c.source})`);
     }
-    if (!r.ok) { console.error(`  error: ${r.error}`); process.exit(2); }
+    if (!r.ok) { console.error(`[follow-counts] ${new Date().toISOString()} FAILED (no row written): ${r.error}`); process.exit(2); }
   } catch (e) {
-    console.error(`[follow-counts] snapshot crashed: ${e.message}`);
+    console.error(`[follow-counts] ${new Date().toISOString()} snapshot crashed: ${e.message}`);
     process.exit(1);
   }
 })();

← 14cd75b auto-data-snapshot: 2026-09-26T08:19:11 (1 data files) — pub  ·  back to Marketing Command Center  ·  auto-data-snapshot: 2026-09-26T08:50:16 (3 data files) — dat c82763c →