← back to Kickbacks Ai Upstream

src/servingGate.ts

108 lines

/** Central serving gate — the ONE predicate every ad-patch writer consults
 *  before touching user files or handing an ad to a webview (wave 2, audit
 *  2026-06-09 findings #3/#4/#6/#9/#14/#19).
 *
 *  Pre-fix, several independent timers (the 60s debug reassert, the rotation
 *  tick, the 60s cliSync loop, boot-canary patching, the webview reasserts,
 *  the live loopback /ad route) each re-applied the ad patch without
 *  consulting the global gates, so a killed or user-disabled install
 *  oscillated restore→re-patch forever. The gate owns the three inputs and
 *  every writer asks it first:
 *
 *    1. kill posture — "clear" (healthy), "confirmed" (a 200 from
 *       /v1/killswitch said killed:true), or "offline" (the endpoint was
 *       unreachable / non-200 — we genuinely don't know). The split is the
 *       kill HYSTERESIS: confirmed ⇒ restore everything; offline ⇒ FREEZE
 *       (no restore, no new writes) so a wifi blip never churns the user's
 *       Claude Code install while staying fail-closed for new writes.
 *    2. the user master toggle — wired by extension.ts to the
 *       DebugController's persisted intent. A deliberate "Disable Kickbacks"
 *       / Restore command reads disabled here; the sign-out pause (K_ON
 *       forced false but K_PRESIGNOUT remembered) stays ENABLED so the
 *       signed-out demo flow keeps working (the Wave-1 sign-out contract).
 *    3. crash-canary suspension — "skip automatic patch this run". Session-
 *       scoped; lifted only by an explicit user re-enable (setOn(true)),
 *       matching the canary toast's wording.
 *
 *  Module-scoped singleton (same idiom as adRotation's sign-out hook):
 *  exactly one serving posture exists per extension host, and the writers
 *  live in modules that don't all share an ActivationContext. activate()
 *  resets it; tests reset via resetServingGate(). Every read is guarded —
 *  the gate itself can never throw into a writer (prime directive). */

export type KillPosture = "clear" | "confirmed" | "offline";

/** What a periodic writer should do this tick:
 *    "write"   — healthy + enabled + not suspended ⇒ normal apply path.
 *    "freeze"  — offline-unsure or canary-suspended ⇒ neither write NOR
 *                restore (keep the current on-disk state, keep checking).
 *    "restore" — confirmed kill or user-disabled ⇒ tear the patch down. */
export type ServingVerdict = "write" | "freeze" | "restore";

const state = {
  kill: "clear" as KillPosture,
  enabled: (() => true) as () => boolean,
  suspended: false,
};

function safeEnabled(): boolean {
  try { return state.enabled(); } catch { return false; }
}

/** Back to the boot defaults (healthy, enabled, not suspended). Called at
 *  the top of activate() so a reloaded host never inherits stale posture. */
export function resetServingGate(): void {
  state.kill = "clear";
  state.enabled = () => true;
  state.suspended = false;
}

/** Wire the user-master-toggle input (extension.ts: the DebugController's
 *  persisted intent). Defaults to "enabled" so unit tests that exercise a
 *  single subsystem in isolation see today's behavior unless they opt in. */
export function wireServingGateEnabled(fn: () => boolean): void {
  state.enabled = fn;
}

export function setKillPosture(p: KillPosture): void { state.kill = p; }
export function killPosture(): KillPosture { return state.kill; }

/** Crash-canary: no automatic patch writes for the rest of this session. */
export function suspendServing(): void { state.suspended = true; }
/** Explicit user re-enable (DebugController.setOn(true)) lifts it. */
export function clearServingSuspension(): void { state.suspended = false; }
export function servingSuspended(): boolean { return state.suspended; }

/** Single source of truth for the per-tick writer decision. Precedence:
 *  a CONFIRMED kill and a deliberate disable always win (restore); the
 *  offline freeze and the canary suspension hold the line (no churn);
 *  otherwise write. */
export function servingVerdict(): ServingVerdict {
  if (state.kill === "confirmed") return "restore";
  if (!safeEnabled()) return "restore";
  if (state.kill === "offline") return "freeze";
  if (state.suspended) return "freeze";
  return "write";
}

/** May a writer WRITE an ad patch (or start serving a new ad) right now? */
export function canPatch(): boolean { return servingVerdict() === "write"; }

/** May the live loopback keep handing /ad payloads to running webviews?
 *  Stricter than "freeze": only a CONFIRMED kill or a deliberate disable
 *  stops serving (the webview drops its overlay within one 10s poll).
 *  Offline-unsure keeps serving the current ad — that's the freeze
 *  contract — and the canary suspension only blocks PATCH writes (a stale
 *  webview from a prior session may still legitimately poll). */
export function canServeAds(): boolean {
  return state.kill !== "confirmed" && safeEnabled();
}

/** Snapshot for UI painters that need to know WHY serving is off
 *  (killed / offline / disabled-or-suspended). */
export function servingGateSnapshot():
  { kill: KillPosture; enabled: boolean; suspended: boolean } {
  return { kill: state.kill, enabled: safeEnabled(),
           suspended: state.suspended };
}