Files
hyperframes/packages/studio/src/utils/sdkResolverAttempts.ts
T
Vance IngallsandClaude Fable 5 405af8f8ba fix(studio): cross-file tripwire guard + stale-session disk check
Two resolver-shadow noise classes from production telemetry:

- Cross-file guard (0.7.41: 479 false element_not_found from ONE
  session): the dom-edit tripwire ran for edits targeting a different
  file than the session models. The cutover gates already decline these
  (wrongCompositionFile); the tripwire now skips the same way — no
  event, no attempt, since the op structurally cannot cut over.

- Stale-session disambiguation (0.7.48: 53 animation_not_found across
  keyframe ops): the GSAP panel derives animationIds from the CURRENT
  on-disk script every render, while the session's parsed id space
  dates from the last reload. Position edits shift every
  selector-method-position id, so panel ops landing before the reload
  target ids the session has never seen. Parser id-space parity was
  verified across legacy/acorn read/write paths (9 script shapes) —
  the ids agree; the session is just behind. On a miss with a reader
  wired, recordAnimationResolverParity now re-parses the on-disk file:
  a hit there = stale session (suppress); a miss there = genuine
  divergence, tagged diskChecked so the dashboard can trust the class.

Attempt-counter machinery moved to sdkResolverAttempts.ts (600-LOC
studio file gate); re-exported from sdkResolverShadow for API compat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 12:28:47 -07:00

95 lines
3.9 KiB
TypeScript

/**
* Attempt counter — the denominator for the resolver-shadow soak gate.
*
* The emit functions in sdkResolverShadow.ts only fire a PostHog event on
* divergence — parity is silent, by design, to avoid firing on every edit.
* That leaves no way to compute a rate (divergences / attempts): we can count
* failures but never attempts. This counter tracks attempts in memory and
* rolls them up into ONE low-frequency event instead of firing per-attempt,
* which would recreate the exact chattiness problem the divergence-only
* design avoids.
*/
import { trackStudioEvent, flushViaBeacon } from "./studioTelemetry";
const attemptCounts: Record<string, number> = {};
/**
* Record that the resolver-shadow tripwire ran for `opLabel`, regardless of
* outcome (parity or divergence). No flag check of its own — only ever called
* from inside the shadow emit functions, after their own
* STUDIO_SDK_RESOLVER_SHADOW_ENABLED guard, so it's already flag-gated.
*/
export function recordAttempt(opLabel: string): void {
attemptCounts[opLabel] = (attemptCounts[opLabel] ?? 0) + 1;
ensureAttemptFlushScheduled();
}
/**
* Return the accumulated attempt counts since the last flush (or `null` if
* nothing has been recorded — no point emitting an empty rollup), and reset
* the counter to empty.
*/
export function flushAttemptCounts(): Record<string, number> | null {
const keys = Object.keys(attemptCounts);
if (keys.length === 0) return null;
const snapshot: Record<string, number> = {};
for (const key of keys) {
snapshot[key] = attemptCounts[key];
delete attemptCounts[key];
}
return snapshot;
}
const ATTEMPT_FLUSH_INTERVAL_MS = 5 * 60_000;
let attemptFlushTimer: ReturnType<typeof setInterval> | null = null;
let attemptVisibilityHandler: (() => void) | null = null;
function flushAndEmitAttempts(): void {
const counts = flushAttemptCounts();
if (counts === null) return;
trackStudioEvent("sdk_resolver_shadow_attempt", { counts: JSON.stringify(counts) });
}
// Lazily starts the rollup timer + visibilitychange listener on the FIRST
// attempt in a session — mirrors studioTelemetry.ts's own lazy flushTimer
// start, so a session that never exercises the tripwire never runs a
// background timer.
function ensureAttemptFlushScheduled(): void {
if (!attemptFlushTimer) {
attemptFlushTimer = setInterval(flushAndEmitAttempts, ATTEMPT_FLUSH_INTERVAL_MS);
}
if (!attemptVisibilityHandler && typeof document !== "undefined") {
attemptVisibilityHandler = () => {
if (document.visibilityState !== "hidden") return;
flushAndEmitAttempts();
// studioTelemetry.ts registers its own visibilitychange listener (on
// window, at module load) that drains its queue via sendBeacon. Listener
// execution order between that handler and this one (on document,
// registered lazily) is not something to rely on — whichever runs
// first could otherwise beacon-flush before or after this rollup lands
// in the queue. Forcing a beacon flush here makes delivery of this
// rollup event correct regardless of that order.
flushViaBeacon();
};
document.addEventListener("visibilitychange", attemptVisibilityHandler);
}
}
/**
* Test-only: clears the lazy timer/listener singleton state so tests can
* verify the "starts on first attempt" behavior in isolation, without an
* earlier test's real-timer interval (or visibilitychange listener) silently
* surviving into a later test. Does NOT touch attemptCounts — only the
* scheduling state. Not part of the public module contract; only imported
* from sdkResolverShadow.test.ts.
*/
export function __resetAttemptSchedulingForTests(): void {
if (attemptFlushTimer) clearInterval(attemptFlushTimer);
attemptFlushTimer = null;
if (attemptVisibilityHandler && typeof document !== "undefined") {
document.removeEventListener("visibilitychange", attemptVisibilityHandler);
}
attemptVisibilityHandler = null;
}