← 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
M deploy/com.steve.dw-follow-counts.plistM modules/follow-counts/index.jsM scripts/follow-counts-snapshot.js
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 →