diff --git a/packages/studio-server/src/routes/files.test.ts b/packages/studio-server/src/routes/files.test.ts index b6610146c5..306efb3f44 100644 --- a/packages/studio-server/src/routes/files.test.ts +++ b/packages/studio-server/src/routes/files.test.ts @@ -1307,30 +1307,40 @@ tl.fromTo("#box", { opacity: 0, x: -50 }, { opacity: 1, x: 0, duration: 1.5, eas }); it("rejects a stale semantic no-op after a concurrent file write", async () => { - const projectDir = createProjectDir(); - writeHtml(projectDir, "comp.html", FROMTO_COMP); - const app = new Hono(); - registerFileRoutes(app, createAdapter(projectDir)); - const successor = FROMTO_COMP.replace('data-duration="3"', 'data-duration="9"'); - let releaseImport = () => {}; - recastImportGate.wait = new Promise((resolve) => { - releaseImport = resolve; - }); - const parserEntered = new Promise((resolve) => { - recastImportGate.onEnter = resolve; - }); + // Pins the recast writer: this test's interleave seam is recast's LAZY module + // import, which the acorn default no longer performs (acorn is statically + // imported). The 409 revalidation under test is writer-independent. + const previousWriter = process.env.HYPERFRAMES_GSAP_WRITER; + process.env.HYPERFRAMES_GSAP_WRITER = "recast"; + try { + const projectDir = createProjectDir(); + writeHtml(projectDir, "comp.html", FROMTO_COMP); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + const successor = FROMTO_COMP.replace('data-duration="3"', 'data-duration="9"'); + let releaseImport = () => {}; + recastImportGate.wait = new Promise((resolve) => { + releaseImport = resolve; + }); + const parserEntered = new Promise((resolve) => { + recastImportGate.onEnter = resolve; + }); - const pending = postGsapMutationBatch(app, "comp.html", { - mutations: [{ type: "shift-positions", targetSelector: "#missing", delta: 1 }], - }); - await parserEntered; - writeHtml(projectDir, "comp.html", successor); - releaseImport(); - const response = await pending; + const pending = postGsapMutationBatch(app, "comp.html", { + mutations: [{ type: "shift-positions", targetSelector: "#missing", delta: 1 }], + }); + await parserEntered; + writeHtml(projectDir, "comp.html", successor); + releaseImport(); + const response = await pending; - expect(response.status).toBe(409); - expect(await response.json()).toMatchObject({ conflict: true }); - expect(readFileSync(join(projectDir, "comp.html"), "utf-8")).toBe(successor); + expect(response.status).toBe(409); + expect(await response.json()).toMatchObject({ conflict: true }); + expect(readFileSync(join(projectDir, "comp.html"), "utf-8")).toBe(successor); + } finally { + if (previousWriter === undefined) delete process.env.HYPERFRAMES_GSAP_WRITER; + else process.env.HYPERFRAMES_GSAP_WRITER = previousWriter; + } }); it("applies an ordered GSAP mutation batch with one before/after write result", async () => { diff --git a/packages/studio-server/src/routes/gsapMutationCapabilities.test.ts b/packages/studio-server/src/routes/gsapMutationCapabilities.test.ts index e004fcb583..02c427acb1 100644 --- a/packages/studio-server/src/routes/gsapMutationCapabilities.test.ts +++ b/packages/studio-server/src/routes/gsapMutationCapabilities.test.ts @@ -3,7 +3,7 @@ import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { GSAP_MUTATION_CAPABILITIES, - acornDefaultBlockers, + behavioralParityOps, renderGsapMutationCapabilityReport, resolveGsapWriter, } from "./gsapMutationCapabilities.js"; @@ -61,14 +61,37 @@ describe("GSAP writer capability matrix", () => { ); }); - it("renders every classified operation and keeps default blocked until parity is differential", () => { + it("renders every classified operation and names the behavioral-only ops", () => { const report = renderGsapMutationCapabilityReport(); for (const operation of operations) expect(report).toContain(`| ${operation} |`); - expect(acornDefaultBlockers().length).toBeGreaterThan(0); + expect(report).toContain("Behavioral-only parity"); + expect(behavioralParityOps().sort()).toEqual( + [ + "add-with-keyframes", + "consolidate-position-writes", + "convert-to-keyframes", + "delete-all-for-selector", + "materialize-keyframes", + "move-keyframe", + "remove-all-keyframes", + "remove-arc-path", + "replace-with-keyframes", + "resize-keyframed-tween", + "scale-positions", + "set-arc-path", + "shift-positions", + "shift-positions-batch", + "split-animations", + "split-into-property-groups", + "unroll-timeline", + "update-arc-segment", + ].sort(), + ); }); - it("defaults to Recast and requires an explicit Acorn canary selection", () => { - expect(resolveGsapWriter({})).toBe("recast"); + it("defaults to Acorn; recast is an explicit fallback", () => { + expect(resolveGsapWriter({})).toBe("acorn"); + expect(resolveGsapWriter({ HYPERFRAMES_GSAP_WRITER: "recast" })).toBe("recast"); expect(resolveGsapWriter({ HYPERFRAMES_GSAP_WRITER: "acorn" })).toBe("acorn"); expect(() => resolveGsapWriter({ HYPERFRAMES_GSAP_WRITER: "unknown" })).toThrow( "expected recast or acorn", diff --git a/packages/studio-server/src/routes/gsapMutationCapabilities.ts b/packages/studio-server/src/routes/gsapMutationCapabilities.ts index b59f2066ab..0980f47bd0 100644 --- a/packages/studio-server/src/routes/gsapMutationCapabilities.ts +++ b/packages/studio-server/src/routes/gsapMutationCapabilities.ts @@ -68,16 +68,17 @@ const GSAP_WRITER_MIGRATION = Object.freeze({ owner: "studio-foundations", deadline: "2026-09-30", graduationCriteria: - "Every operation has differential parity, the Acorn path imports no Recast runtime, and canary divergence is zero for the agreed soak window.", + "Acorn is the default writer. Recast is retained one watch window as HYPERFRAMES_GSAP_WRITER=recast, then deleted (spec 2026-09-17 §5).", }); export function resolveGsapWriter(env: { HYPERFRAMES_GSAP_WRITER?: string }): "recast" | "acorn" { - const configured = env.HYPERFRAMES_GSAP_WRITER ?? "recast"; + const configured = env.HYPERFRAMES_GSAP_WRITER ?? "acorn"; if (configured === "recast" || configured === "acorn") return configured; throw new Error(`Invalid ${GSAP_WRITER_MIGRATION.flag}=${configured}; expected recast or acorn`); } -export function acornDefaultBlockers(): GsapMutationType[] { +/** Ops whose acorn/recast parity rests on behavioral tests, not a byte-diff oracle. */ +export function behavioralParityOps(): GsapMutationType[] { return ( Object.entries(GSAP_MUTATION_CAPABILITIES) as Array<[GsapMutationType, GsapMutationCapability]> ) @@ -97,13 +98,13 @@ export function renderGsapMutationCapabilityReport(): string { "", `Owner: ${GSAP_WRITER_MIGRATION.owner}`, `Deadline: ${GSAP_WRITER_MIGRATION.deadline}`, - `Flag: \`${GSAP_WRITER_MIGRATION.flag}=recast|acorn\``, + `Flag: \`${GSAP_WRITER_MIGRATION.flag}=recast|acorn\` (default: acorn)`, "", "| Operation | Family | Acorn | Recast | Parity evidence |", "| --- | --- | --- | --- | --- |", ...rows, "", - `Default blockers: ${acornDefaultBlockers().join(", ") || "none"}`, + `Behavioral-only parity (no byte-diff oracle): ${behavioralParityOps().join(", ") || "none"}`, "", ].join("\n"); } diff --git a/packages/studio/src/components/editor/manualEditingAvailability.test.ts b/packages/studio/src/components/editor/manualEditingAvailability.test.ts index b661d1f2fc..0d7f583950 100644 --- a/packages/studio/src/components/editor/manualEditingAvailability.test.ts +++ b/packages/studio/src/components/editor/manualEditingAvailability.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { resolveStudioBooleanEnvFlag } from "./manualEditingAvailability"; +import { STUDIO_SDK_OPERATION_FAMILIES } from "../../utils/sdkCutoverPolicy"; async function loadAvailabilityWithEnv(env: Record) { vi.resetModules(); @@ -88,4 +89,22 @@ describe("manual editing availability", () => { }); expect(off.STUDIO_FLAT_INSPECTOR_ENABLED).toBe(false); }); + + it("defaults SDK cutover on for every family", async () => { + const all = await loadAvailabilityWithEnv({}); + expect(all.STUDIO_SDK_CUTOVER_ENABLED).toBe(true); + expect(all.STUDIO_SDK_CUTOVER_FAMILIES).toEqual(new Set(STUDIO_SDK_OPERATION_FAMILIES)); + }); + + it("honors the SDK cutover kill switches", async () => { + const off = await loadAvailabilityWithEnv({ VITE_STUDIO_SDK_CUTOVER_ENABLED: "false" }); + expect(off.STUDIO_SDK_CUTOVER_ENABLED).toBe(false); + expect(off.STUDIO_SDK_CUTOVER_FAMILIES.size).toBe(0); + + const subset = await loadAvailabilityWithEnv({ + VITE_STUDIO_SDK_CUTOVER_FAMILIES: "dom,timing", + }); + expect(subset.STUDIO_SDK_CUTOVER_ENABLED).toBe(true); + expect(subset.STUDIO_SDK_CUTOVER_FAMILIES).toEqual(new Set(["dom", "timing"])); + }); }); diff --git a/packages/studio/src/components/editor/manualEditingAvailability.ts b/packages/studio/src/components/editor/manualEditingAvailability.ts index d156bf6941..cc3c806f7a 100644 --- a/packages/studio/src/components/editor/manualEditingAvailability.ts +++ b/packages/studio/src/components/editor/manualEditingAvailability.ts @@ -38,16 +38,20 @@ const runtimeEnv = : {}; const env = { ...(import.meta.env ?? {}), ...runtimeEnv } as StudioFeatureFlagEnv; -// Stage 7 Step 3c: SDK cutover — routes inline-style ops through SDK dispatch -// instead of the server patch-element API. Default false; enable via -// VITE_STUDIO_SDK_CUTOVER_ENABLED=true. Requires SDK session to be open. +// Stage 7 SDK cutover — routes Studio edits through SDK dispatch instead of the +// server patch/mutation routes. Default TRUE as of the flip release (spec: +// 2026-09-17-studio-sdk-cutover-flip-design.md). Kill switch: +// VITE_STUDIO_SDK_CUTOVER_ENABLED=false (works at runtime via __HF_STUDIO_ENV__). export const STUDIO_SDK_CUTOVER_ENABLED = resolveStudioBooleanEnvFlag( env, ["VITE_STUDIO_SDK_CUTOVER_ENABLED"], - false, + true, ); -/** Explicit per-operation-family canary selection; the master flag alone enables nothing. */ +/** + * Per-family selection. Unset = every family (the master alone enables all). + * Set `VITE_STUDIO_SDK_CUTOVER_FAMILIES=dom,timing` to RESTRICT to a subset. + */ export const STUDIO_SDK_CUTOVER_FAMILIES = resolveEnabledSdkFamilies( env, STUDIO_SDK_CUTOVER_ENABLED, diff --git a/packages/studio/src/hooks/useSdkSession.lifecycle.test.tsx b/packages/studio/src/hooks/useSdkSession.lifecycle.test.tsx index 7719b6d883..dd52400913 100644 --- a/packages/studio/src/hooks/useSdkSession.lifecycle.test.tsx +++ b/packages/studio/src/hooks/useSdkSession.lifecycle.test.tsx @@ -12,6 +12,17 @@ vi.mock("@hyperframes/sdk", () => ({ import type { Composition } from "@hyperframes/sdk"; import { useSdkSession, type SdkSessionHandle } from "./useSdkSession"; +vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() })); + +import { trackStudioEvent } from "../utils/studioTelemetry"; + +const trackMock = vi.mocked(trackStudioEvent); + +function Probe({ projectId }: { projectId: string }) { + useSdkSession(projectId, "index.html"); + return null; +} + (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; function fakeSession(): Composition { @@ -135,3 +146,68 @@ describe("useSdkSession ownership", () => { expect(published.dispose).toHaveBeenCalledOnce(); }); }); + +describe("useSdkSession unavailable telemetry", () => { + beforeEach(() => { + openComposition.mockReset(); + trackMock.mockClear(); + class FakeEventSource { + addEventListener(): void {} + close(): void {} + } + vi.stubGlobal("EventSource", FakeEventSource); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + // Every cutover chokepoint silently takes the server path when there is no + // session, and the shadow never runs either — so a missing session is a total, + // otherwise-invisible SDK bypass. These three exits are its only origin. + it("reports an unreadable composition", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: false, json: async () => ({}) }) as Response), + ); + const root = createRoot(document.createElement("div")); + await act(async () => root.render()); + await flushAsyncEffects(); + + expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", { stage: "read" }); + await act(async () => root.unmount()); + }); + + it("reports a parse failure with its message", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => response("PROJECT_A")), + ); + openComposition.mockRejectedValue(new Error("unparseable composition")); + + const root = createRoot(document.createElement("div")); + await act(async () => root.render()); + await flushAsyncEffects(); + + expect(trackMock).toHaveBeenCalledWith("sdk_session_unavailable", { + stage: "open", + error: "unparseable composition", + }); + await act(async () => root.unmount()); + }); + + it("stays silent on the happy path", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => response("PROJECT_A")), + ); + openComposition.mockResolvedValue(fakeSession()); + + const root = createRoot(document.createElement("div")); + await act(async () => root.render()); + await flushAsyncEffects(); + + expect(trackMock).not.toHaveBeenCalledWith("sdk_session_unavailable", expect.anything()); + await act(async () => root.unmount()); + }); +}); diff --git a/packages/studio/src/hooks/useSdkSession.ts b/packages/studio/src/hooks/useSdkSession.ts index 278598376a..837b4d3b46 100644 --- a/packages/studio/src/hooks/useSdkSession.ts +++ b/packages/studio/src/hooks/useSdkSession.ts @@ -181,7 +181,15 @@ export function useSdkSession( readProjectFileOptional(projectId, activeCompPath) .then(async (content) => { - if (cancelled || typeof content !== "string") return; + if (cancelled) return; + if (typeof content !== "string") { + // No SDK session follows, so EVERY cutover chokepoint below takes the + // server path and emits nothing — the shadow never runs either. This + // is the only place a missing session can originate, so a broken read + // would otherwise be a silent, total SDK bypass. + trackStudioEvent("sdk_session_unavailable", { stage: "read" }); + return; + } // No persist queue: Studio's writeProjectFile (via sdkCutover's // persistSdkSerialize) is the SINGLE writer. Wiring the SDK persist // queue too would double-write the file (queue auto-writes on every @@ -206,6 +214,7 @@ export function useSdkSession( ) ) { disposeSdkSession(comp); + trackStudioEvent("sdk_session_unavailable", { stage: "ownership" }); return; } const displaced = ownedSessionRef.current; @@ -215,8 +224,17 @@ export function useSdkSession( setOwnedSession(installed); if (displaced && displaced.session !== comp) disposeSdkSession(displaced.session); }) - .catch(() => { - if (!cancelled && generationRef.current === generation) setOwnedSession(null); + .catch((error: unknown) => { + if (!cancelled && generationRef.current === generation) { + setOwnedSession(null); + // openComposition threw (unparseable composition, OOM) — same total + // bypass as the read failure above, but this one is a real defect + // rather than a missing file. Carry the message; it is the only clue. + trackStudioEvent("sdk_session_unavailable", { + stage: "open", + error: error instanceof Error ? error.message : String(error), + }); + } }); return () => { diff --git a/packages/studio/src/utils/sdkCutover.gate.test.ts b/packages/studio/src/utils/sdkCutover.gate.test.ts index c19085955b..e6b9531e69 100644 --- a/packages/studio/src/utils/sdkCutover.gate.test.ts +++ b/packages/studio/src/utils/sdkCutover.gate.test.ts @@ -1,11 +1,12 @@ import { describe, expect, it, vi } from "vitest"; -// Dark-launch contract: with STUDIO_SDK_CUTOVER_ENABLED=false, EVERY cutover +// Kill-switch contract: with VITE_STUDIO_SDK_CUTOVER_ENABLED=false, EVERY cutover // persist chokepoint must explicitly decline so the caller takes the legacy server // path — even when a valid SDK session exists (one always does, for -// shadow/selection). This is the contract the prod flag-flip rests on; a future -// refactor of the gate guards that silently re-enables cutover on flag-off -// turns these red. (sdkCutover.test.ts mocks the flag TRUE; this is its sibling.) +// shadow/selection). Default is ON (manualEditingAvailability.test.ts pins that); +// this file pins that the env override still turns it fully off. A future +// refactor of the gate guards that ignores the flag turns these red. +// (sdkCutover.test.ts mocks the flag TRUE; this is its sibling.) vi.mock("../components/editor/manualEditingAvailability", () => ({ STUDIO_SDK_CUTOVER_ENABLED: false, STUDIO_SDK_RESOLVER_SHADOW_ENABLED: false, diff --git a/packages/studio/src/utils/sdkCutover.test.ts b/packages/studio/src/utils/sdkCutover.test.ts index 4815ac4bc4..d5644bc0e3 100644 --- a/packages/studio/src/utils/sdkCutover.test.ts +++ b/packages/studio/src/utils/sdkCutover.test.ts @@ -23,6 +23,8 @@ vi.mock("./studioTelemetry", () => ({ trackStudioEvent: vi.fn(), })); +import { trackStudioEvent } from "./studioTelemetry"; + const styleOp = (property: string, value: string): PatchOperation => ({ type: "inline-style", property, @@ -202,6 +204,46 @@ describe("sdkCutoverPersist", () => { expect(result.status).toBe("declined"); }); + it("tags target_not_found with resolverDisagreement when dispatch could have resolved it", async () => { + // getElement is canonical-only for a bare id; resolveSnapshot mirrors what + // dispatch resolves (bare ids anywhere). An element present under a scoped + // id is dispatchable, so getElement refusing it is a resolver disagreement + // — and one the shadow event stays silent about. + const deps = makeDeps(); + const session = makeSession(false); + (session as unknown as { getElements: () => unknown[] }).getElements = () => [ + { id: "hf-abc", scopedId: "host/hf-abc" }, + ]; + const sel = { hfId: "hf-abc" } as never; + + await sdkCutoverPersist(sel, [styleOp("color", "red")], "before", "/path.html", session, deps); + + expect(trackStudioEvent).toHaveBeenCalledWith( + "sdk_cutover_declined", + expect.objectContaining({ reason: "target_not_found", resolverDisagreement: true }), + ); + }); + + it("does not tag resolverDisagreement when the element is genuinely absent", async () => { + const deps = makeDeps(); + const session = makeSession(false); + (session as unknown as { getElements: () => unknown[] }).getElements = () => [ + { id: "hf-other", scopedId: "hf-other" }, + ]; + const sel = { hfId: "hf-abc" } as never; + vi.mocked(trackStudioEvent).mockClear(); + + await sdkCutoverPersist(sel, [styleOp("color", "red")], "before", "/path.html", session, deps); + + expect(trackStudioEvent).toHaveBeenLastCalledWith( + "sdk_cutover_declined", + expect.objectContaining({ reason: "target_not_found" }), + ); + expect(vi.mocked(trackStudioEvent).mock.lastCall?.[1]).not.toHaveProperty( + "resolverDisagreement", + ); + }); + it("dispatches setStyle for inline-style ops", async () => { const deps = makeDeps(); const session = makeSession(true); diff --git a/packages/studio/src/utils/sdkCutover.ts b/packages/studio/src/utils/sdkCutover.ts index 4839bc083b..faf33b4b1f 100644 --- a/packages/studio/src/utils/sdkCutover.ts +++ b/packages/studio/src/utils/sdkCutover.ts @@ -5,7 +5,11 @@ import * as studioAvailability from "../components/editor/manualEditingAvailabil import { trackStudioEvent } from "./studioTelemetry"; import { patchOpsToSdkEditOps } from "./sdkOpMapping"; import { recordResolverParity, recordAnimationResolverParity } from "./sdkResolverShadow"; -import { shouldDeclineTextCutoverForTarget, shouldUseSdkCutover } from "./sdkCutoverEligibility"; +import { + isResolverDisagreement, + shouldDeclineTextCutoverForTarget, + shouldUseSdkCutover, +} from "./sdkCutoverEligibility"; import { asCutoverError, declinedCutover, @@ -78,15 +82,17 @@ export async function sdkCutoverPersist( options?: CutoverOptions, ): Promise { if (!shouldUseSdkCutover(sdkFamilyEnabled("dom"), !!sdkSession, selection.hfId, ops)) - return declinedCutover("ineligible_operation"); - if (!sdkSession) return declinedCutover("session_unavailable"); + return declinedCutover("ineligible_operation", "dom"); + if (!sdkSession) return declinedCutover("session_unavailable", "dom"); const hfId = selection.hfId; - if (!hfId) return declinedCutover("target_unaddressable"); + if (!hfId) return declinedCutover("target_unaddressable", "dom"); const target = sdkSession.getElement(hfId); - if (!target) return declinedCutover("target_not_found"); + if (!target) + return declinedCutover("target_not_found", "dom", isResolverDisagreement(sdkSession, hfId)); if (shouldDeclineTextCutoverForTarget(target, ops)) - return declinedCutover("unsupported_text_target"); - if (wrongCompositionFile(deps, targetPath)) return declinedCutover("wrong_composition_file"); + return declinedCutover("unsupported_text_target", "dom"); + if (wrongCompositionFile(deps, targetPath)) + return declinedCutover("wrong_composition_file", "dom"); const result = await persistSdkCandidateMutation( sdkSession, targetPath, @@ -122,10 +128,12 @@ export async function sdkTimingPersist( // Dark-launch gate: without this, timing cutover runs whenever an SDK session // exists (it always does, for shadow/selection) — flipping the flag OFF would // NOT disable it. Gate here so flag-off routes back to the legacy server path. - if (!sdkFamilyEnabled("timing")) return declinedCutover("feature_disabled"); - if (!sdkSession) return declinedCutover("session_unavailable"); - if (!sdkSession.getElement(hfId)) return declinedCutover("target_not_found"); - if (wrongCompositionFile(deps, targetPath)) return declinedCutover("wrong_composition_file"); + if (!sdkFamilyEnabled("timing")) return declinedCutover("feature_disabled", "timing"); + if (!sdkSession) return declinedCutover("session_unavailable", "timing"); + if (!sdkSession.getElement(hfId)) + return declinedCutover("target_not_found", "timing", isResolverDisagreement(sdkSession, hfId)); + if (wrongCompositionFile(deps, targetPath)) + return declinedCutover("wrong_composition_file", "timing"); try { const serializedBefore = sdkSession.serialize(); const result = await persistSdkCandidateMutation( @@ -166,11 +174,17 @@ export async function sdkTimingBatchPersist( { targetPath, compositionPath: deps.compositionPath }, ); } - if (!sdkFamilyEnabled("timing")) return declinedCutover("feature_disabled"); - if (!sdkSession) return declinedCutover("session_unavailable"); - if (wrongCompositionFile(deps, targetPath)) return declinedCutover("wrong_composition_file"); - if (changes.some((change) => !sdkSession.getElement(change.hfId))) - return declinedCutover("target_not_found"); + if (!sdkFamilyEnabled("timing")) return declinedCutover("feature_disabled", "timing"); + if (!sdkSession) return declinedCutover("session_unavailable", "timing"); + if (wrongCompositionFile(deps, targetPath)) + return declinedCutover("wrong_composition_file", "timing"); + const unresolved = changes.find((change) => !sdkSession.getElement(change.hfId)); + if (unresolved) + return declinedCutover( + "target_not_found", + "timing", + isResolverDisagreement(sdkSession, unresolved.hfId), + ); try { const serializedBefore = sdkSession.serialize(); const result = await persistSdkCandidateMutation( @@ -244,9 +258,9 @@ export function sdkGsapTweenPersist( // Leading dark-launch gate so flag-off does no SDK touch (getElement) at all — // matches the other three chokepoints' discipline. if (!sdkFamilyEnabled("gsap-animation")) - return Promise.resolve(declinedCutover("feature_disabled")); + return Promise.resolve(declinedCutover("feature_disabled", "gsap-animation")); if (op.kind === "add" && sdkSession && !sdkSession.getElement(op.target)) - return Promise.resolve(declinedCutover("target_not_found")); + return Promise.resolve(declinedCutover("target_not_found", "gsap-animation")); // dispatchGsapOpAndPersist declines on before===after — that catches stale // animationIds and unsupported shapes (e.g. from-prop on a plain tween), falling // back to the server path. This subsumes explicit existence guards for set/remove. @@ -285,9 +299,10 @@ async function dispatchGsapOpAndPersist( } // Dark-launch gate (shared chokepoint for every GSAP-op cutover persist): // flag OFF → explicit decline → caller falls back to the legacy server path. - if (!sdkFamilyEnabled(family)) return declinedCutover("feature_disabled"); - if (!sdkSession) return declinedCutover("session_unavailable"); - if (wrongCompositionFile(deps, targetPath)) return declinedCutover("wrong_composition_file"); + if (!sdkFamilyEnabled(family)) return declinedCutover("feature_disabled", family); + if (!sdkSession) return declinedCutover("session_unavailable", family); + if (wrongCompositionFile(deps, targetPath)) + return declinedCutover("wrong_composition_file", family); const session = sdkSession; // persistSdkCandidateMutation owns the shared per-project/file transaction // coordinator used by both SDK and legacy GSAP writes. @@ -559,10 +574,16 @@ export async function sdkDeletePersist( { targetPath, compositionPath: deps.compositionPath }, ); // Dark-launch gate: flag OFF → legacy server delete path. - if (!sdkFamilyEnabled("lifecycle")) return declinedCutover("feature_disabled"); - if (!sdkSession) return declinedCutover("session_unavailable"); - if (!sdkSession.getElement(hfId)) return declinedCutover("target_not_found"); - if (wrongCompositionFile(deps, targetPath)) return declinedCutover("wrong_composition_file"); + if (!sdkFamilyEnabled("lifecycle")) return declinedCutover("feature_disabled", "lifecycle"); + if (!sdkSession) return declinedCutover("session_unavailable", "lifecycle"); + if (!sdkSession.getElement(hfId)) + return declinedCutover( + "target_not_found", + "lifecycle", + isResolverDisagreement(sdkSession, hfId), + ); + if (wrongCompositionFile(deps, targetPath)) + return declinedCutover("wrong_composition_file", "lifecycle"); const result = await persistSdkCandidateMutation( sdkSession, targetPath, diff --git a/packages/studio/src/utils/sdkCutoverEligibility.ts b/packages/studio/src/utils/sdkCutoverEligibility.ts index 4e779807bb..2dbc0aee63 100644 --- a/packages/studio/src/utils/sdkCutoverEligibility.ts +++ b/packages/studio/src/utils/sdkCutoverEligibility.ts @@ -4,8 +4,10 @@ * sdkCutover.ts (which hit the packages/studio 600-line filesize cap) — this * block has no dependency on the persist/dispatch functions there. */ +import type { Composition } from "@hyperframes/sdk"; import type { PatchOperation } from "./sourcePatcher"; import { isAllowedHtmlAttribute, isSafeAttributeValue } from "./htmlAttrSafety"; +import { resolveSnapshot } from "./sdkResolverShadow"; const CUTOVER_OP_TYPES = new Set([ "inline-style", @@ -102,6 +104,25 @@ export function shouldDeclineTextCutoverForTarget(target: unknown, ops: PatchOpe return tag !== null && NON_HTML_CHILD_TAGS.has(tag); } +/** + * On a `target_not_found`, decide whether the miss is the known benign class + * (node genuinely absent from the session — nothing dispatch could have done) + * or a resolver disagreement: the shadow's broader resolver finds it, so + * dispatch could have made the edit and `getElement` refused it. The shadow + * event stays silent in that case, so this flag is the only signal for it. + * + * Diagnostic context only — it must never turn a clean decline into a throw, so + * a session shape without `getElements` reports "no disagreement" and the edit + * falls back exactly as it otherwise would have. + */ +export function isResolverDisagreement(session: Composition, hfId: string): boolean { + try { + return resolveSnapshot(session, hfId) !== null; + } catch { + return false; + } +} + export function shouldUseSdkCutover( flagEnabled: boolean, hasSession: boolean, diff --git a/packages/studio/src/utils/sdkCutoverPolicy.test.ts b/packages/studio/src/utils/sdkCutoverPolicy.test.ts index a23cbbd1c1..5c77a1d420 100644 --- a/packages/studio/src/utils/sdkCutoverPolicy.test.ts +++ b/packages/studio/src/utils/sdkCutoverPolicy.test.ts @@ -7,8 +7,17 @@ import { } from "./sdkCutoverPolicy"; describe("Studio SDK operation-family policy", () => { - it("enables no family from the master switch alone", () => { - expect(resolveEnabledSdkFamilies({}, true).size).toBe(0); + it("enables every family when the master switch is on and no family list is set", () => { + const all = new Set(STUDIO_SDK_OPERATION_FAMILIES); + expect(resolveEnabledSdkFamilies({}, true)).toEqual(all); + expect(resolveEnabledSdkFamilies({ VITE_STUDIO_SDK_CUTOVER_FAMILIES: "" }, true)).toEqual(all); + expect(resolveEnabledSdkFamilies({ VITE_STUDIO_SDK_CUTOVER_FAMILIES: " " }, true)).toEqual( + all, + ); + }); + + it("enables no family when the master switch is off, regardless of the family list", () => { + expect(resolveEnabledSdkFamilies({}, false).size).toBe(0); expect(resolveEnabledSdkFamilies({ VITE_STUDIO_SDK_CUTOVER_FAMILIES: "dom" }, false).size).toBe( 0, ); diff --git a/packages/studio/src/utils/sdkCutoverPolicy.ts b/packages/studio/src/utils/sdkCutoverPolicy.ts index 25c7284462..da998c36af 100644 --- a/packages/studio/src/utils/sdkCutoverPolicy.ts +++ b/packages/studio/src/utils/sdkCutoverPolicy.ts @@ -54,7 +54,8 @@ export function resolveEnabledSdkFamilies( ): ReadonlySet { if (!masterEnabled) return new Set(); const raw = env["VITE_STUDIO_SDK_CUTOVER_FAMILIES"]; - if (typeof raw !== "string" || raw.trim() === "") return new Set(); + // Unset/blank = every family. The list exists to RESTRICT (kill switch per family). + if (typeof raw !== "string" || raw.trim() === "") return new Set(STUDIO_SDK_OPERATION_FAMILIES); const requested = raw .split(",") .map((family) => family.trim()) @@ -85,8 +86,9 @@ export function renderStudioSdkCutoverReport(): string { return [ "# Studio SDK cutover report", "", - "Master flag: `VITE_STUDIO_SDK_CUTOVER_ENABLED=true`", - "Family flag: `VITE_STUDIO_SDK_CUTOVER_FAMILIES=dom,timing,...`", + "Default: SDK persist ON for every family. Kill switches:", + " `VITE_STUDIO_SDK_CUTOVER_ENABLED=false` (all families → server path)", + " `VITE_STUDIO_SDK_CUTOVER_FAMILIES=dom,timing` (only listed families → SDK; rest → server)", "", "A family graduates only after zero unexplained resolver/serialization divergences over its agreed corpus and soak window.", "", diff --git a/packages/studio/src/utils/sdkEditTransaction.decline.test.ts b/packages/studio/src/utils/sdkEditTransaction.decline.test.ts new file mode 100644 index 0000000000..2de21a7916 --- /dev/null +++ b/packages/studio/src/utils/sdkEditTransaction.decline.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("./studioTelemetry", () => ({ trackStudioEvent: vi.fn() })); + +import { trackStudioEvent } from "./studioTelemetry"; +import { declinedCutover } from "./sdkEditTransaction"; + +describe("declinedCutover telemetry", () => { + it("emits sdk_cutover_declined with reason and family, and returns the declined result", () => { + const result = declinedCutover("target_not_found", "timing"); + expect(result).toEqual({ status: "declined", reason: "target_not_found" }); + expect(trackStudioEvent).toHaveBeenCalledWith("sdk_cutover_declined", { + reason: "target_not_found", + family: "timing", + }); + }); + + it("emits family:null when the decline happens before a family is known", () => { + declinedCutover("no_change"); + expect(trackStudioEvent).toHaveBeenCalledWith("sdk_cutover_declined", { + reason: "no_change", + family: null, + }); + }); + + it("omits resolverDisagreement when it is not set, and carries it when it is", () => { + declinedCutover("target_not_found", "timing"); + expect(trackStudioEvent).toHaveBeenLastCalledWith("sdk_cutover_declined", { + reason: "target_not_found", + family: "timing", + }); + + declinedCutover("target_not_found", "timing", true); + expect(trackStudioEvent).toHaveBeenLastCalledWith("sdk_cutover_declined", { + reason: "target_not_found", + family: "timing", + resolverDisagreement: true, + }); + + declinedCutover("target_not_found", "timing", false); + expect(trackStudioEvent).toHaveBeenLastCalledWith("sdk_cutover_declined", { + reason: "target_not_found", + family: "timing", + }); + }); +}); diff --git a/packages/studio/src/utils/sdkEditTransaction.ts b/packages/studio/src/utils/sdkEditTransaction.ts index 2ecf9df19b..4e8f371d05 100644 --- a/packages/studio/src/utils/sdkEditTransaction.ts +++ b/packages/studio/src/utils/sdkEditTransaction.ts @@ -3,6 +3,7 @@ import type { EditHistoryKind } from "./editHistory"; import { hashContent, markSelfWrite } from "../hooks/sdkSelfWriteRegistry"; import { trackStudioEvent } from "./studioTelemetry"; import { serializeStudioFileMutation } from "./studioFileMutationCoordinator"; +import type { StudioSdkOperationFamily } from "./sdkCutoverPolicy"; export type CutoverResult = | { status: "declined"; reason: string } @@ -66,7 +67,27 @@ interface CandidateEdit { after: string; } -export function declinedCutover(reason: string): CutoverResult { +/** + * Explicit fall-back to the legacy server path. Emits `sdk_cutover_declined` so + * post-flip we can distinguish "SDK took the edit" from "SDK bowed out"; payload + * is reason + family only (no hfId / path / content). + * + * `resolverDisagreement` is the one extra field with diagnostic value beyond the + * reason itself: on a `target_not_found`, the shadow's `resolveSnapshot` (what + * dispatch resolves) found the element that `getElement` could not. The shadow + * event stays silent in that case, so without this flag the case is invisible — + * and it is an adoption loss (a dispatchable edit refused), not a missing node. + */ +export function declinedCutover( + reason: string, + family?: StudioSdkOperationFamily, + resolverDisagreement?: boolean, +): CutoverResult { + trackStudioEvent("sdk_cutover_declined", { + reason, + family: family ?? null, + ...(resolverDisagreement ? { resolverDisagreement: true } : {}), + }); return { status: "declined", reason }; } diff --git a/packages/studio/src/utils/sdkResolverShadow.ts b/packages/studio/src/utils/sdkResolverShadow.ts index 6508e683f8..e06167480c 100644 --- a/packages/studio/src/utils/sdkResolverShadow.ts +++ b/packages/studio/src/utils/sdkResolverShadow.ts @@ -96,7 +96,16 @@ function countHfIdInSource(source: string, id: string): number { ); } -function resolveSnapshot(session: Composition, id: string): FlatEl | null { +/** + * Resolvability the SDK DISPATCH path actually has (mirrors `resolveScoped`): + * exact scoped-path match, then canonical bare match, then first bare match. + * + * Exported so the cutover path can distinguish its two `target_not_found` cases: + * `Composition.getElement` is narrower (canonical-only for a bare id) than what + * `dispatch` resolves, so an element can be genuinely dispatchable yet still + * fail `getElement` — a resolver disagreement worth fixing, not a missing node. + */ +export function resolveSnapshot(session: Composition, id: string): FlatEl | null { const els = session.getElements(); const exact = els.find((el) => el.scopedId === id); if (exact) return exact;