← back to Kickbacks Ai Upstream

src/sessionState.ts

66 lines

import { dlog } from "./log";

/** Single source of truth for the cross-component session state that
 *  several UI surfaces used to derive independently — and frequently
 *  disagreed on (status bar reading auth.signedIn() while the menu
 *  read K_ON, etc.). Every state-changing call site `set()`s into here;
 *  every UI subscriber gets the new snapshot in one place. Each change
 *  is also logged so `debug.log` shows a state-transition timeline. */
export interface SessionSnapshot {
  /** True iff we hold an access token the extension considers usable.
   *  Driven by the AuthClient's in-memory `at`; goes false when refresh
   *  fails or signOut runs. Does NOT prove the backend still accepts
   *  the token — `authHealthy` reflects the last real call. */
  signedIn: boolean;
  /** Backend response status for the most recent authenticated call.
   *  Tracking this separately from `signedIn` lets the UI distinguish
   *  "we have a token but every call 401s" (the bug class the user
   *  asked about) from a clean signed-out / signed-in state. */
  authHealthy: "ok" | "401" | "unknown";
  /** K_ON — the debug-injection master toggle. */
  injectionOn: boolean;
  /** True iff a kill switch (server or override) is engaged. */
  killed: boolean;
  /** True iff the most recent portfolio fetch returned a winning ad. */
  hasAd: boolean;
  /** Claude Code version (for the status bar version label). */
  ccVersion: string;
}

const INITIAL: SessionSnapshot = {
  signedIn: false, authHealthy: "unknown", injectionOn: false,
  killed: false, hasAd: false, ccVersion: "unknown",
};

export class SessionState {
  private snap: SessionSnapshot = { ...INITIAL };
  private subs: Array<(s: SessionSnapshot) => void> = [];

  get(): Readonly<SessionSnapshot> { return this.snap; }

  set(patch: Partial<SessionSnapshot>): void {
    // Cheap structural-equality check: avoid spamming debug.log when a
    // no-op set comes through (showActive ticks, periodic kill polls).
    const before = JSON.stringify(this.snap);
    this.snap = { ...this.snap, ...patch };
    const after = JSON.stringify(this.snap);
    if (before === after) return;
    dlog("ext", "session.state", this.snap as unknown as Record<string, unknown>);
    for (const fn of this.subs) {
      try { fn(this.snap); } catch { /* observers are best-effort */ }
    }
  }

  onChange(fn: (s: SessionSnapshot) => void): void { this.subs.push(fn); }

  /** Operator-readable single-line summary used in diagnostic dumps and
   *  the debug menu's "Built …" info row context. */
  describe(): string {
    const s = this.snap;
    const auth = s.signedIn ? (s.authHealthy === "401" ? "in (broken)" :
      s.authHealthy === "ok" ? "in" : "in (unknown)") : "out";
    return `auth: ${auth}  injection: ${s.injectionOn ? "on" : "off"}  ad: ${
      s.hasAd ? "yes" : "no"}  killed: ${s.killed ? "yes" : "no"}  cc: ${s.ccVersion}`;
  }
}