diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 4d94ddd35..fa1b8a9ce 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -260,8 +260,13 @@ export function useDomEditSession({ ? (selection, operations, originalContent, targetPath, options) => { // Resolver shadow runs regardless of the cutover flag — decoupled tripwire. // Pass originalContent so the runtime-node filter can suppress hf-ids - // absent from source (script-created nodes the SDK can't model). - runResolverShadow(sdkSession, selection.hfId, operations, originalContent); + // absent from source (script-created nodes the SDK can't model), and + // the paths so cross-file edits (session models only the active comp) + // skip instead of emitting structural element_not_found noise. + runResolverShadow(sdkSession, selection.hfId, operations, originalContent, { + targetPath, + compositionPath: activeCompPath, + }); return sdkCutoverPersist( selection, operations, diff --git a/packages/studio/src/utils/sdkCutover.ts b/packages/studio/src/utils/sdkCutover.ts index 6f23880fa..e76283aa0 100644 --- a/packages/studio/src/utils/sdkCutover.ts +++ b/packages/studio/src/utils/sdkCutover.ts @@ -82,6 +82,20 @@ function wrongCompositionFile(deps: CutoverDeps, targetPath: string): boolean { return deps.compositionPath != null && targetPath !== deps.compositionPath; } +/** + * Reader for the animation-resolver tripwire's disk-truth check: on an + * animationId miss it re-parses the CURRENT file to distinguish a stale + * session (panel ids re-derive from disk every render; session ids date from + * the last reload) from a genuine resolver divergence. + */ +function gsapReadSource( + deps: CutoverDeps, + targetPath: string, +): (() => Promise) | undefined { + const read = deps.readProjectFile; + return read ? () => read(targetPath) : undefined; +} + interface CutoverOptions { label?: string; coalesceKey?: string; @@ -271,10 +285,11 @@ export function sdkGsapTweenPersist( gsapSrc ? () => gsapSrc(targetPath) : undefined, ); } else { - recordAnimationResolverParity( + void recordAnimationResolverParity( sdkSession, op.animationId, op.kind === "set" ? "setGsapTween" : "removeGsapTween", + gsapReadSource(deps, targetPath), ); } // Leading dark-launch gate so flag-off does no SDK touch (getElement) at all — @@ -309,7 +324,12 @@ async function dispatchGsapOpAndPersist( // Resolver tripwire — runs BEFORE the cutover gate (decoupled): records when // the SDK can't resolve the animationId the server GSAP path is addressing. if (resolverTarget) { - recordAnimationResolverParity(sdkSession, resolverTarget.animationId, resolverTarget.opLabel); + void recordAnimationResolverParity( + sdkSession, + resolverTarget.animationId, + resolverTarget.opLabel, + gsapReadSource(deps, targetPath), + ); } // Dark-launch gate (shared chokepoint for every GSAP-op cutover persist): // flag OFF → return false → caller falls back to the legacy server path. diff --git a/packages/studio/src/utils/sdkResolverAttempts.ts b/packages/studio/src/utils/sdkResolverAttempts.ts new file mode 100644 index 000000000..be203b081 --- /dev/null +++ b/packages/studio/src/utils/sdkResolverAttempts.ts @@ -0,0 +1,94 @@ +/** + * 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 = {}; + +/** + * 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 | null { + const keys = Object.keys(attemptCounts); + if (keys.length === 0) return null; + const snapshot: Record = {}; + 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 | 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; +} diff --git a/packages/studio/src/utils/sdkResolverShadow.test.ts b/packages/studio/src/utils/sdkResolverShadow.test.ts index 2485867cb..5401afd9d 100644 --- a/packages/studio/src/utils/sdkResolverShadow.test.ts +++ b/packages/studio/src/utils/sdkResolverShadow.test.ts @@ -562,6 +562,100 @@ describe("G. recordAnimationResolverParity", () => { recordAnimationResolverParity(session, unmatchedId, "removeAllKeyframes"); expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0); }); + + // ── Stale-session disambiguation (the 0.7.48 keyframe-op class) ───────────── + // The GSAP panel computes animationIds from the CURRENT on-disk script (the + // server read path re-reads per render), but the session's parsed id space + // reflects the last session reload. An edit that shifts tween positions + // shifts every `selector-method-position` id, so a panel op landing between + // the write and the session reload targets an id the session has never seen. + // That is a sync gap, not a resolver bug — disambiguate against disk truth. + + // Same tween, position moved 0→3: every id in the NEW script differs from + // the ids the OLD (session) script parses to. + const GSAP_DISK_MOVED_HTML = /* html */ ` + +
Hello
+ +`; + + it("suppresses the emit when the animationId resolves against the on-disk script (stale session)", async () => { + mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true; + const session = await openComposition(GSAP_HTML); // session parsed the OLD script + const disk = await openComposition(GSAP_DISK_MOVED_HTML); + const diskId = [...disk.getAllAnimationIds()][0] ?? ""; + disk.dispose(); + expect(diskId).not.toBe(""); + expect(session.getAllAnimationIds().has(diskId)).toBe(false); // session can't see it + await recordAnimationResolverParity(session, diskId, "removeGsapKeyframe", () => + Promise.resolve(GSAP_DISK_MOVED_HTML), + ); + expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0); + }); + + it("emits with diskChecked when the animationId resolves in NEITHER session nor disk", async () => { + mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true; + const session = await openComposition(GSAP_HTML); + await recordAnimationResolverParity(session, "no-such-anim", "removeGsapKeyframe", () => + Promise.resolve(GSAP_DISK_MOVED_HTML), + ); + const ev = lastShadow(); + expect(ev?.mismatchCount).toBe(1); + expect(ev?.diskChecked).toBe(true); + expect(JSON.stringify(ev?.mismatches)).toContain("animation_not_found"); + }); + + it("fails open with sourceReadFailed when the reader throws", async () => { + mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true; + const session = await openComposition(GSAP_HTML); + await recordAnimationResolverParity(session, "no-such-anim", "removeGsapKeyframe", () => + Promise.reject(new Error("read failed")), + ); + const ev = lastShadow(); + expect(ev?.mismatchCount).toBe(1); + expect(ev?.sourceReadFailed).toBe(true); + expect(ev?.diskChecked).toBeUndefined(); + }); +}); + +// ─── G2. runResolverShadow cross-file guard ─────────────────────────────────── +// PostHog 0.7.41: one session emitted 479 element_not_found because the user +// edited elements whose sourceFile differed from the active composition — the +// session models ONLY the active comp, so cross-file targets are structurally +// unresolvable. The cutover gates already decline via wrongCompositionFile; +// the tripwire must skip the same way (no event, no attempt — the op can't +// cut over, so it belongs in neither side of the soak rate). +describe("G2. runResolverShadow cross-file guard", () => { + const ops: PatchOperation[] = [{ type: "inline-style", property: "color", value: "blue" }]; + + it("skips entirely when targetPath differs from the session's composition", async () => { + mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true; + flushAttemptCounts(); + const session = await openComposition(BASE_HTML); + runResolverShadow(session, "hf-cross", ops, undefined, { + targetPath: "compositions/sample-vote-count.html", + compositionPath: "templates/document-card.html", + }); + expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(0); + expect(flushAttemptCounts()).toBeNull(); + }); + + it("still emits for a same-file divergence", async () => { + mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true; + const session = await openComposition(BASE_HTML); + runResolverShadow(session, "hf-missing", ops, undefined, { + targetPath: "index.html", + compositionPath: "index.html", + }); + expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(1); + }); + + it("runs normally when paths are not supplied (status quo)", async () => { + mockFlags.STUDIO_SDK_RESOLVER_SHADOW_ENABLED = true; + const session = await openComposition(BASE_HTML); + runResolverShadow(session, "hf-missing", ops); + expect(trackedEvents.filter((e) => e.event === "sdk_resolver_shadow")).toHaveLength(1); + }); }); // ─── H. Inlined sub-composition: bare leaf id resolves (regression) ─────────── diff --git a/packages/studio/src/utils/sdkResolverShadow.ts b/packages/studio/src/utils/sdkResolverShadow.ts index 33300f2bd..f26db5817 100644 --- a/packages/studio/src/utils/sdkResolverShadow.ts +++ b/packages/studio/src/utils/sdkResolverShadow.ts @@ -15,11 +15,12 @@ * Telemetry-only — never writes to disk, never affects the user-visible edit. */ +import { openComposition } from "@hyperframes/sdk"; import type { Composition, JsonPatchOp } from "@hyperframes/sdk"; import type { PatchOperation } from "./sourcePatcher"; import { STUDIO_SDK_RESOLVER_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability"; import { patchOpsToSdkEditOps } from "./sdkOpMapping"; -import { trackStudioEvent, flushViaBeacon } from "./studioTelemetry"; +import { trackStudioEvent } from "./studioTelemetry"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -234,95 +235,14 @@ export function sdkResolverShadowCheck( } } -// ─── Attempt counter (denominator for the soak gate) ────────────────────────── -// -// The three emit functions below 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. - -const attemptCounts: Record = {}; - -/** - * 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 three emit functions below, 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 | null { - const keys = Object.keys(attemptCounts); - if (keys.length === 0) return null; - const snapshot: Record = {}; - 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 | 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; -} +// Attempt counting (the soak-gate denominator) lives in sdkResolverAttempts.ts; +// re-exported here so existing consumers/tests keep one import surface. +export { + recordAttempt, + flushAttemptCounts, + __resetAttemptSchedulingForTests, +} from "./sdkResolverAttempts"; +import { recordAttempt } from "./sdkResolverAttempts"; // ─── Telemetry ──────────────────────────────────────────────────────────────── @@ -385,10 +305,24 @@ export function runResolverShadow( hfId: string | null | undefined, ops: PatchOperation[], sourceContent?: string, + paths?: { targetPath?: string; compositionPath?: string | null }, ): void { if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return; if (!hfId) return; try { + // Cross-file edit: the session models ONLY the active composition, so a + // target living in another file is structurally unresolvable — the cutover + // gates decline it (wrongCompositionFile) and it can never cut over. Skip + // entirely (no event, no attempt), mirroring the empty-session rule below. + // PostHog 0.7.41: one cross-file editing session emitted 479 false + // element_not_found events through this path. + if ( + paths?.targetPath !== undefined && + paths.compositionPath != null && + paths.targetPath !== paths.compositionPath + ) { + return; + } if (reportEmptySession(session, "dom-edit")) return; recordAttempt("dom-edit"); const mismatches = sdkResolverShadowCheck(session, hfId, ops, sourceContent); @@ -505,11 +439,48 @@ export async function recordResolverParity( * * No-op when the shadow flag is off; never throws; never mutates the session. */ -export function recordAnimationResolverParity( +/** + * Disk-truth disambiguation for a missed animationId: the GSAP panel computes + * animationIds from the CURRENT on-disk script (the server read path re-reads + * per render), while the session's parsed id space reflects the last session + * reload. An edit that shifts tween positions shifts every + * `selector-method-position` id, so a panel op landing before the reload + * targets an id the session has never seen — a sync gap, not a resolver bug + * (the 0.7.48 keyframe-op class). `staleSession` = a fresh parse of the file + * resolves the id (suppress); `diskChecked` = the fresh parse ran and ALSO + * missed (a trustworthy real divergence); read errors fail open. + */ +async function checkAnimationIdOnDisk( + animationId: string, + readSource: () => Promise, +): Promise<{ staleSession: boolean; diskChecked: boolean; sourceReadFailed: boolean }> { + let source: string | undefined; + try { + source = await readSource(); + } catch { + return { staleSession: false, diskChecked: false, sourceReadFailed: true }; + } + if (source === undefined) { + return { staleSession: false, diskChecked: false, sourceReadFailed: false }; + } + const disk = await openComposition(source, { history: false }); + try { + return { + staleSession: disk.getAllAnimationIds().has(animationId), + diskChecked: true, + sourceReadFailed: false, + }; + } finally { + disk.dispose(); + } +} + +export async function recordAnimationResolverParity( session: Composition | null | undefined, animationId: string, opLabel: string, -): void { + readSource?: () => Promise, +): Promise { if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return; if (!session || !animationId) return; try { @@ -519,10 +490,25 @@ export function recordAnimationResolverParity( elements.some((el) => el.animationIds.includes(animationId)) || session.getAllAnimationIds().has(animationId); if (resolves) return; // SDK locates the animation — parity + // Capture BEFORE any await (fire-and-forget caller mutates right after). + const sessionElementCount = elements.length; + let diskChecked = false; + let sourceReadFailed = false; + if (readSource) { + const verdict = await checkAnimationIdOnDisk(animationId, readSource); + if (verdict.staleSession) return; // sync gap, not a resolver bug — suppress + diskChecked = verdict.diskChecked; + sourceReadFailed = verdict.sourceReadFailed; + } trackStudioEvent("sdk_resolver_shadow", { animationId, opLabel, - sessionElementCount: elements.length, + sessionElementCount, + // The id missed a fresh parse of the on-disk file too — this is a real + // resolver divergence, not session staleness. Absent when no reader was + // wired or the read failed (see sourceReadFailed). + ...(diskChecked ? { diskChecked: true } : {}), + ...(sourceReadFailed ? { sourceReadFailed: true } : {}), mismatchCount: 1, mismatches: JSON.stringify([ { kind: "animation_not_found", animationId } satisfies SdkResolverMismatch,