← back to Kickbacks Ai Upstream

src/fleetSignals.ts

153 lines

import type { KillState } from "./killswitch/client";
import type { EarningCap } from "./earnings/client";
import { parseCap } from "./earnings/client";

/** Fleet-chattiness fix (2026-06-12): the backend piggybacks the killswitch
 *  verdict (`kill`) and the status-bar earnings (`balances`, now incl. `cap`)
 *  on the portfolio + metrics responses the extension already fetches. This
 *  store receives those signals from the wire clients and lets the standalone
 *  GET /v1/killswitch and GET /v1/earnings pollers stand down while the
 *  piggybacked data is fresh — together those polls were ~60% of backend
 *  traffic at fleet scale.
 *
 *  Trust rules (the load-bearing part):
 *    - Only a FRESH 2xx with a parseable field may produce a signal. Carrier
 *      failures (401/5xx/network/missing field/warm-cache fallback) produce
 *      NOTHING here — in particular they must never set the kill `offline`
 *      posture; only the standalone fallback poller may do that. An auth
 *      failure is not evidence about the kill table.
 *    - A parsed kill verdict gets `confirmed: killed` (it came from a 200),
 *      matching KillSwitchClient's 200-path semantics exactly.
 *    - `capCapable` records whether the carrier had the `cap` KEY at all:
 *      key-present (even when null) = piggyback-capable backend; key-absent =
 *      old backend, so the earnings poller must keep running. */

/** How long a piggybacked kill verdict suppresses the standalone
 *  /v1/killswitch poll. 90s = one missed 60s portfolio refresh plus slack;
 *  an idle-but-open editor's worst-case kill latency becomes ~65s (60s
 *  portfolio + 5s server cache) with the fallback poll resuming inside 90s.
 *  Tuning knob — drop to 45_000 if that idle window ever matters more than
 *  the request savings. */
export const KILL_STALE_MS = 90_000;

/** How long piggybacked balances satisfy the status bar before showActive
 *  falls back to a real GET /v1/earnings. Same 90s logic as the kill gate. */
export const EARNINGS_STALE_MS = 90_000;

export interface EarningsSnapshot {
  lifetimeUsd: string;
  todayUsd: string;
  /** Defensive-parsed cap; undefined = under both ceilings or unparseable. */
  cap?: EarningCap;
  /** True when the carrier response had the `cap` key — the new-backend
   *  marker. While false the standalone /v1/earnings poll must continue
   *  (its response is the only source of the cap pill on old backends). */
  capCapable: boolean;
  updatedAt: number;
}

/** Defensive parse of a piggybacked `kill` field. Returns undefined for
 *  null / missing / malformed input — never a fail-safe verdict: an absent
 *  field only means "this carrier doesn't speak the protocol", which the
 *  staleness-gated fallback poller covers. Exported for unit tests. */
export function parseKillField(raw: unknown): KillState | undefined {
  if (!raw || typeof raw !== "object") return undefined;
  const k = raw as { killed?: unknown; scope?: unknown; reason?: unknown };
  if (typeof k.killed !== "boolean") return undefined;
  return {
    killed: k.killed,
    confirmed: k.killed, // fresh 200 ⇒ same trust as KillSwitchClient's 200
    scope: typeof k.scope === "string" ? k.scope : undefined,
    reason: typeof k.reason === "string" ? k.reason : undefined,
    offline: false,
  };
}

/** Defensive parse of a piggybacked `balances` block. Returns undefined on
 *  anything malformed — a bad block must never poison the status bar. */
export function parseBalancesField(raw: unknown): EarningsSnapshot | undefined {
  if (!raw || typeof raw !== "object") return undefined;
  const b = raw as { lifetime_usd?: unknown; today_usd?: unknown };
  if (typeof b.lifetime_usd !== "string" || typeof b.today_usd !== "string")
    return undefined;
  return {
    lifetimeUsd: b.lifetime_usd,
    todayUsd: b.today_usd,
    cap: parseCap((raw as { cap?: unknown }).cap),
    capCapable: "cap" in (raw as Record<string, unknown>),
    updatedAt: Date.now(),
  };
}

// Sign-out hook, module-scoped to match noteMetricsSignOut /
// clearAdRotationOnSignOut: exactly one FleetSignals serves an extension
// host, and cmdSignOut must drop the old identity's balances so the next
// paint can't show the previous user's money.
let liveSignOutClear: (() => void) | null = null;
export function noteFleetSignalsSignOut(): void {
  liveSignOutClear?.();
}

export class FleetSignals {
  private lastKillVerdictAt = 0;
  private lastKillVerdict: KillState | null = null;
  private earnings: EarningsSnapshot | null = null;
  private killSink: ((ks: KillState) => void) | null = null;
  private earningsSink: (() => void) | null = null;

  constructor() {
    liveSignOutClear = () => this.clearEarnings();
  }

  /** Wire-client entry point: raw `kill` field off a fresh 2xx body. */
  noteKill(raw: unknown): void {
    const ks = parseKillField(raw);
    if (!ks) return;
    this.lastKillVerdictAt = Date.now();
    this.lastKillVerdict = ks;
    try { this.killSink?.(ks); } catch { /* sink must never break a fetch */ }
  }

  /** Wire-client entry point: raw `balances` field off a fresh AUTHED 2xx. */
  noteBalances(raw: unknown): void {
    const snap = parseBalancesField(raw);
    if (!snap) return;
    this.earnings = snap;
    try { this.earningsSink?.(); } catch { /* never break a fetch */ }
  }

  /** True while the standalone killswitch poll may stand down. */
  killFreshWithin(ms: number): boolean {
    return this.lastKillVerdictAt > 0
      && Date.now() - this.lastKillVerdictAt < ms;
  }

  earningsSnapshot(): EarningsSnapshot | null {
    return this.earnings;
  }

  earningsFreshWithin(ms: number): boolean {
    return !!this.earnings && Date.now() - this.earnings.updatedAt < ms;
  }

  /** Sign-out teardown: piggybacked balances belong to the old identity. */
  clearEarnings(): void {
    this.earnings = null;
  }

  /** Registers the verdict sink and immediately replays a buffered fresh
   *  verdict: activation fetches the first portfolio BEFORE the kill
   *  machinery exists, so without the replay that boot verdict would both
   *  suppress the standalone poll (fresh timestamp) AND never be applied. */
  onKillVerdict(sink: (ks: KillState) => void): void {
    this.killSink = sink;
    if (this.lastKillVerdict && this.killFreshWithin(KILL_STALE_MS)) {
      try { sink(this.lastKillVerdict); } catch { /* never break wiring */ }
    }
  }

  onEarningsUpdated(sink: () => void): void {
    this.earningsSink = sink;
  }
}