← back to Marketing Command Center

modules/engine/scheduler.js

192 lines

// Engine scheduler (agent 3). Fires APPROVED, due suggestions through the same
// live rails as /publish (channels.postLive). Safe-by-construction:
//   • ENGINE_SCHEDULER=off → never starts the interval (dev / test / staging).
//   • Every state change goes through store.transition() — the compare-and-set
//     that makes double-posting impossible (two ticks racing the same item: one
//     wins the approved→posting claim, the loser's transition returns null).
//   • Items are processed SEQUENTIALLY (never in parallel) to avoid platform
//     rate spikes.
//   • attempts is capped at 1: a failed post is NEVER auto-refired — Steve must
//     re-approve. This is deliberate. A post that threw/failed may still have
//     LANDED on the platform (the adapter succeeded but the response was lost),
//     so silently retrying risks a duplicate live post.
//   • CRASH SWEEP on start: any item stuck in 'posting' (the process died
//     mid-post) is moved to 'failed' with a verify-first note — NEVER
//     auto-refired, for the same land-before-crash reason.
const store = require('./store');

let _timer = null;
let _started = false;
let _ticking = false;
let _lastTick = null;

// ── logging ──────────────────────────────────────────────────────────────────
// Matches the repo's bracketed-prefix console style ([mcc]/[engine]).
const log = (...a) => console.log('[engine]', ...a);

// summarize(detail) — best-effort one-line error from an adapter result array
// when postLive returned no top-level `error` string (it doesn't on the happy
// path, but a per-adapter failure lives inside detail[].error).
function summarize(detail) {
  try {
    if (Array.isArray(detail)) {
      const errs = detail.map(d => d && d.error).filter(Boolean);
      if (errs.length) return errs.join('; ');
    }
    if (detail && typeof detail === 'object' && detail.error) return String(detail.error);
  } catch { /* fall through */ }
  return 'post failed';
}

// ── crash sweep ────────────────────────────────────────────────────────────────
// Any item left in 'posting' means the process died between the approved→posting
// claim and the posting→posted|failed resolve. The post MAY have landed, so we
// move it to 'failed' (never auto-refire) and flag it for manual verification.
function _sweep() {
  let swept = 0;
  for (const it of store.load()) {
    if (it.status === 'posting') {
      const done = store.transition(it.id, 'posting', 'failed', {
        lastError: 'interrupted mid-post — verify on platform before re-approving',
      });
      if (done) { swept++; log('crash-sweep: posting →', it.id, 'moved to failed (verify before re-approve)'); }
    }
  }
  if (swept) log(`crash-sweep swept ${swept} interrupted item(s)`);
  return swept;
}

// ── content mapping ────────────────────────────────────────────────────────────
// Build the channels.postLive `content` object from a queue item.
// Caption = the caption text, then (only if hashtags exist) a blank line + the
// hashtags joined by a single space. The product link is the conversion path:
// link-friendly channels carry it in the caption (Bluesky auto-facets it into a
// tappable link); YouTube carries it in the description. IG/TikTok captions don't
// render clickable links, so it's omitted there rather than shipped dead.
const LINK_CHANNELS = new Set(['bluesky', 'facebook', 'threads', 'linkedin']);
function buildContent(item) {
  const tags = Array.isArray(item.hashtags) ? item.hashtags.filter(Boolean) : [];
  const url = item.product && item.product.productUrl;
  // Bluesky hard-slices at 300 chars — budget the caption so the link never gets chopped.
  let cap = item.caption || '';
  if (item.channel === 'bluesky' && url) {
    const budget = 300 - (url.length + 2 + (tags.length ? tags.join(' ').length + 2 : 0));
    if (cap.length > budget) cap = cap.slice(0, Math.max(0, budget - 1)).replace(/\s+\S*$/, '') + '…';
  }
  const parts = [cap];
  if (url && LINK_CHANNELS.has(item.channel)) parts.push(url);
  if (tags.length) parts.push(tags.join(' '));
  const caption = parts.filter(Boolean).join('\n\n');
  let description = item.description || undefined;
  if (item.channel === 'youtube' && url) description = [description, url].filter(Boolean).join('\n\n');
  return {
    caption,
    mediaUrl: item.mediaUrl,
    videoUrl: item.videoUrl,
    privacy: item.privacy || undefined,
    title: item.channel === 'youtube' ? item.caption : undefined,
    description,
  };
}

// dueApproved() — items ready to fire this tick: approved, scheduled in the past,
// and under the attempts cap (never re-fire a failed post).
function dueApproved(items) {
  const now = Date.now();
  return items.filter(it =>
    it.status === 'approved' &&
    it.scheduledAt &&
    Number.isFinite(Date.parse(it.scheduledAt)) &&
    Date.parse(it.scheduledAt) <= now &&
    (it.attempts || 0) < 1
  );
}

// ── tick ────────────────────────────────────────────────────────────────────────
async function _tickOnce() {
  _lastTick = new Date().toISOString();
  const items = store.load();
  const due = dueApproved(items);
  if (!due.length) return;

  // require channels lazily + freshly so a test can monkey-patch postLive.
  const channels = require('../channels');

  for (const item of due) {
    // (1) CLAIM: compare-and-set approved→posting. If null, another tick or a
    //     manual action already took it — skip.
    const claimed = store.transition(item.id, 'approved', 'posting', {
      attempts: (item.attempts || 0) + 1,
    });
    if (!claimed) continue;

    // (2) POST — wrapped so one item's failure never kills the tick.
    try {
      const content = buildContent(claimed);
      const res = await channels.postLive(claimed.channel, content, {});
      const ok = !!(res && res.ok);
      store.transition(claimed.id, 'posting', ok ? 'posted' : 'failed', {
        detail: (res && res.detail) || null,
        outboxId: (res && res.outboxId) || null,
        lastError: ok ? null : ((res && res.error) || summarize(res && res.detail)),
        postedAt: ok ? new Date().toISOString() : null,
      });
      if (ok) log('posted', claimed.id, '→ outbox', (res && res.outboxId) || '(none)');
      else log('FAILED', claimed.id + ':', (res && res.error) || summarize(res && res.detail));
    } catch (err) {
      const msg = (err && err.message) || String(err);
      store.transition(claimed.id, 'posting', 'failed', { lastError: msg, postedAt: null });
      log('FAILED', claimed.id + ':', msg);
    }
  }
}

// tick() — the interval body: re-entrancy guard so a slow tick never overlaps.
async function tick() {
  if (_ticking) return;
  _ticking = true;
  try {
    await _tickOnce();
  } catch (e) {
    log('tick error:', (e && e.message) || String(e));
  } finally {
    _ticking = false;
  }
}

// ── lifecycle ────────────────────────────────────────────────────────────────
function start() {
  if (process.env.ENGINE_SCHEDULER === 'off') {
    log('scheduler disabled (ENGINE_SCHEDULER=off) — not starting');
    return;
  }
  if (_started) return;
  _started = true;

  // Crash sweep BEFORE the interval so interrupted items are resolved up front.
  _sweep();

  // Steady 60s cadence, plus one prompt tick 5s after boot. Both unref()'d so
  // they never keep the process alive on their own (mirrors social/index.js).
  _timer = setInterval(() => { tick().catch(() => {}); }, 60 * 1000);
  if (_timer.unref) _timer.unref();

  const kick = setTimeout(() => { tick().catch(() => {}); }, 5 * 1000);
  if (kick.unref) kick.unref();

  log('scheduler started (60s cadence)');
}

function getState() {
  return { started: _started, lastTick: _lastTick, ticking: _ticking };
}

module.exports = {
  start,
  getState,
  lastTick: () => _lastTick,
  // exported for tests only:
  _tickOnce,
  _sweep,
};