From f9a0051907608d2f1cac066f661224eda8c30b32 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 10 Jul 2026 13:19:35 -0700 Subject: [PATCH] refactor(studio): gate SDK operation families --- packages/studio/package.json | 3 +- .../editor/manualEditingAvailability.ts | 7 + .../src/utils/sdkCutover.familyGate.test.ts | 47 +++++++ packages/studio/src/utils/sdkCutover.ts | 133 +++++++++++++----- .../src/utils/sdkCutoverPolicy.report.ts | 3 + .../studio/src/utils/sdkCutoverPolicy.test.ts | 38 +++++ packages/studio/src/utils/sdkCutoverPolicy.ts | 98 +++++++++++++ 7 files changed, 290 insertions(+), 39 deletions(-) create mode 100644 packages/studio/src/utils/sdkCutover.familyGate.test.ts create mode 100644 packages/studio/src/utils/sdkCutoverPolicy.report.ts create mode 100644 packages/studio/src/utils/sdkCutoverPolicy.test.ts create mode 100644 packages/studio/src/utils/sdkCutoverPolicy.ts diff --git a/packages/studio/package.json b/packages/studio/package.json index 5de9beee08..e99e245a40 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -40,7 +40,8 @@ "build": "vite build && tsup", "typecheck": "tsc --noEmit", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "report:sdk-cutover": "bun src/utils/sdkCutoverPolicy.report.ts" }, "dependencies": { "@codemirror/autocomplete": "^6.20.1", diff --git a/packages/studio/src/components/editor/manualEditingAvailability.ts b/packages/studio/src/components/editor/manualEditingAvailability.ts index b49a0523ca..ce0b9088ba 100644 --- a/packages/studio/src/components/editor/manualEditingAvailability.ts +++ b/packages/studio/src/components/editor/manualEditingAvailability.ts @@ -87,6 +87,12 @@ export const STUDIO_SDK_CUTOVER_ENABLED = resolveStudioBooleanEnvFlag( false, ); +/** Explicit per-operation-family canary selection; the master flag alone enables nothing. */ +export const STUDIO_SDK_CUTOVER_FAMILIES = resolveEnabledSdkFamilies( + env, + STUDIO_SDK_CUTOVER_ENABLED, +); + // Resolver-parity tripwire (telemetry-only, decoupled from cutover). // Runs the SDK resolver alongside any edit and emits sdk_resolver_shadow on // divergence. Default true; disable via VITE_STUDIO_SDK_RESOLVER_SHADOW_ENABLED=false. @@ -98,3 +104,4 @@ export const STUDIO_SDK_RESOLVER_SHADOW_ENABLED = resolveStudioBooleanEnvFlag( ); export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled"; +import { resolveEnabledSdkFamilies } from "../../utils/sdkCutoverPolicy"; diff --git a/packages/studio/src/utils/sdkCutover.familyGate.test.ts b/packages/studio/src/utils/sdkCutover.familyGate.test.ts new file mode 100644 index 0000000000..049dcb415e --- /dev/null +++ b/packages/studio/src/utils/sdkCutover.familyGate.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("../components/editor/manualEditingAvailability", () => ({ + STUDIO_SDK_CUTOVER_ENABLED: true, + STUDIO_SDK_CUTOVER_FAMILIES: new Set(["timing"]), + STUDIO_SDK_RESOLVER_SHADOW_ENABLED: false, +})); +vi.mock("./studioTelemetry", () => ({ trackStudioEvent: vi.fn() })); + +import { + sdkCutoverPersist, + sdkDeletePersist, + sdkGsapTweenPersist, + sdkTimingPersist, +} from "./sdkCutover"; + +const deps = { + editHistory: { recordEdit: vi.fn() }, + writeProjectFile: vi.fn(), + reloadPreview: vi.fn(), + domEditSaveTimestampRef: { current: 0 }, +} as never; + +describe("SDK family gate mapping", () => { + it("enables timing independently while declining other operation families", async () => { + await expect( + sdkTimingPersist("hf", "/c.html", { start: 1 }, null, deps), + ).resolves.toMatchObject({ status: "declined", reason: "session_unavailable" }); + await expect( + sdkGsapTweenPersist("/c.html", { kind: "remove", animationId: "a" }, null, deps), + ).resolves.toMatchObject({ status: "declined", reason: "feature_disabled" }); + await expect(sdkDeletePersist("hf", "before", "/c.html", null, deps)).resolves.toMatchObject({ + status: "declined", + reason: "feature_disabled", + }); + await expect( + sdkCutoverPersist( + { hfId: "hf" } as never, + [{ type: "inline-style", property: "color", value: "red" }], + "before", + "/c.html", + {} as never, + deps, + ), + ).resolves.toMatchObject({ status: "declined", reason: "ineligible_operation" }); + }); +}); diff --git a/packages/studio/src/utils/sdkCutover.ts b/packages/studio/src/utils/sdkCutover.ts index 639c3559a3..f6a0287b96 100644 --- a/packages/studio/src/utils/sdkCutover.ts +++ b/packages/studio/src/utils/sdkCutover.ts @@ -1,7 +1,7 @@ import type { Composition, GsapTweenSpec } from "@hyperframes/sdk"; import type { DomEditSelection } from "../components/editor/domEditing"; import type { PatchOperation } from "./sourcePatcher"; -import { STUDIO_SDK_CUTOVER_ENABLED } from "../components/editor/manualEditingAvailability"; +import * as studioAvailability from "../components/editor/manualEditingAvailability"; import { trackStudioEvent } from "./studioTelemetry"; import { patchOpsToSdkEditOps } from "./sdkOpMapping"; import { recordResolverParity, recordAnimationResolverParity } from "./sdkResolverShadow"; @@ -15,6 +15,7 @@ import { type CutoverOptions, type CutoverResult, } from "./sdkEditTransaction"; +import { isSdkFamilyEnabled, type StudioSdkOperationFamily } from "./sdkCutoverPolicy"; export { shouldUseSdkCutover } from "./sdkCutoverEligibility"; export { @@ -24,6 +25,27 @@ export { } from "./sdkEditTransaction"; export type { CutoverDeps, CutoverOptions, CutoverResult } from "./sdkEditTransaction"; +function sdkFamilyEnabled(family: StudioSdkOperationFamily): boolean { + const configured = Object.prototype.hasOwnProperty.call( + studioAvailability, + "STUDIO_SDK_CUTOVER_FAMILIES", + ) + ? studioAvailability.STUDIO_SDK_CUTOVER_FAMILIES + : undefined; + return isSdkFamilyEnabled(studioAvailability.STUDIO_SDK_CUTOVER_ENABLED, configured, family); +} + +function trackCutoverResult( + result: CutoverResult, + context: { hfId?: string | null; opCount: number }, +): void { + if (result.status === "committed") { + trackStudioEvent("sdk_cutover_success", context); + } else if (result.status === "failed") { + trackStudioEvent("sdk_cutover_failed", { ...context, error: result.error.message }); + } +} + /** True when targetPath isn't the composition the SDK session models. */ function wrongCompositionFile(deps: CutoverDeps, targetPath: string): boolean { return deps.compositionPath != null && targetPath !== deps.compositionPath; @@ -38,7 +60,7 @@ export async function sdkCutoverPersist( deps: CutoverDeps, options?: CutoverOptions, ): Promise { - if (!shouldUseSdkCutover(STUDIO_SDK_CUTOVER_ENABLED, !!sdkSession, selection.hfId, ops)) + if (!shouldUseSdkCutover(sdkFamilyEnabled("dom"), !!sdkSession, selection.hfId, ops)) return declinedCutover("ineligible_operation"); if (!sdkSession) return declinedCutover("session_unavailable"); const hfId = selection.hfId; @@ -58,11 +80,7 @@ export async function sdkCutoverPersist( }, options, ); - if (result.status === "committed") { - trackStudioEvent("sdk_cutover_success", { hfId, opCount: ops.length }); - } else if (result.status === "failed") { - trackStudioEvent("sdk_cutover_failed", { hfId, error: result.error.message }); - } + trackCutoverResult(result, { hfId, opCount: ops.length }); return result; } @@ -86,7 +104,7 @@ 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 (!STUDIO_SDK_CUTOVER_ENABLED) return declinedCutover("feature_disabled"); + 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"); @@ -102,11 +120,7 @@ export async function sdkTimingPersist( options, serializedBefore, ); - if (result.status === "committed") { - trackStudioEvent("sdk_cutover_success", { hfId, opCount: 1 }); - } else if (result.status === "failed") { - trackStudioEvent("sdk_cutover_failed", { hfId, error: result.error.message }); - } + trackCutoverResult(result, { hfId, opCount: 1 }); return result; } catch (error) { const failed = { status: "failed", error: asCutoverError(error) } as const; @@ -134,7 +148,7 @@ export async function sdkTimingBatchPersist( timingSrc ? () => timingSrc(targetPath) : undefined, ); } - if (!STUDIO_SDK_CUTOVER_ENABLED) return declinedCutover("feature_disabled"); + 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))) @@ -209,13 +223,14 @@ export function sdkGsapTweenPersist( } // Leading dark-launch gate so flag-off does no SDK touch (getElement) at all — // matches the other three chokepoints' discipline. - if (!STUDIO_SDK_CUTOVER_ENABLED) return Promise.resolve(declinedCutover("feature_disabled")); + if (!sdkFamilyEnabled("gsap-animation")) + return Promise.resolve(declinedCutover("feature_disabled")); if (op.kind === "add" && sdkSession && !sdkSession.getElement(op.target)) return Promise.resolve(declinedCutover("target_not_found")); // 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. - return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) => { + return dispatchGsapOpAndPersist("gsap-animation", targetPath, sdkSession, deps, options, (s) => { s.batch(() => { if (op.kind === "add") { s.addGsapTween(op.target, op.spec); @@ -229,6 +244,7 @@ export function sdkGsapTweenPersist( } async function dispatchGsapOpAndPersist( + family: "gsap-animation" | "gsap-keyframe", targetPath: string, sdkSession: Composition | null | undefined, deps: CutoverDeps, @@ -243,7 +259,7 @@ 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 (!STUDIO_SDK_CUTOVER_ENABLED) return declinedCutover("feature_disabled"); + if (!sdkFamilyEnabled(family)) return declinedCutover("feature_disabled"); if (!sdkSession) return declinedCutover("session_unavailable"); if (wrongCompositionFile(deps, targetPath)) return declinedCutover("wrong_composition_file"); const session = sdkSession; @@ -291,6 +307,7 @@ export function sdkGsapKeyframePersist( options?: CutoverOptions, ): Promise { return dispatchGsapOpAndPersist( + "gsap-keyframe", targetPath, sdkSession, deps, @@ -309,6 +326,7 @@ export function sdkGsapRemoveKeyframePersist( options?: CutoverOptions, ): Promise { return dispatchGsapOpAndPersist( + "gsap-keyframe", targetPath, sdkSession, deps, @@ -328,6 +346,7 @@ export function sdkGsapRemovePropertyPersist( options?: CutoverOptions, ): Promise { return dispatchGsapOpAndPersist( + "gsap-animation", targetPath, sdkSession, deps, @@ -344,7 +363,7 @@ export function sdkGsapDeleteAllForSelectorPersist( deps: CutoverDeps, options?: CutoverOptions, ): Promise { - return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) => + return dispatchGsapOpAndPersist("gsap-animation", targetPath, sdkSession, deps, options, (s) => s.dispatch({ type: "deleteAllForSelector", selector }), ); } @@ -357,6 +376,7 @@ export function sdkGsapRemoveAllKeyframesPersist( options?: CutoverOptions, ): Promise { return dispatchGsapOpAndPersist( + "gsap-keyframe", targetPath, sdkSession, deps, @@ -375,6 +395,7 @@ export function sdkGsapConvertToKeyframesPersist( options?: CutoverOptions, ): Promise { return dispatchGsapOpAndPersist( + "gsap-keyframe", targetPath, sdkSession, deps, @@ -399,6 +420,16 @@ type KeyframesPayload = { ease?: string; }; +function keyframesPayload( + targetSelector: string, + position: number, + duration: number, + keyframes: KeyframeSpec[], + ease: string | undefined, +): KeyframesPayload { + return { targetSelector, position, duration, keyframes, ...(ease ? { ease } : {}) }; +} + /** Shared inner dispatch for addWithKeyframes / replaceWithKeyframes ops. */ function dispatchWithKeyframes( s: Composition, @@ -412,6 +443,38 @@ function dispatchWithKeyframes( } } +function persistKeyframesOperation(input: { + targetPath: string; + targetSelector: string; + position: number; + duration: number; + keyframes: KeyframeSpec[]; + ease: string | undefined; + sdkSession: Composition | null | undefined; + deps: CutoverDeps; + options?: CutoverOptions; + animationId?: string; +}): Promise { + const payload = keyframesPayload( + input.targetSelector, + input.position, + input.duration, + input.keyframes, + input.ease, + ); + return dispatchGsapOpAndPersist( + "gsap-keyframe", + input.targetPath, + input.sdkSession, + input.deps, + input.options, + (session) => dispatchWithKeyframes(session, payload, input.animationId), + input.animationId + ? { animationId: input.animationId, opLabel: "replaceWithKeyframes" } + : undefined, + ); +} + export function sdkAddWithKeyframesPersist( targetPath: string, targetSelector: string, @@ -423,16 +486,17 @@ export function sdkAddWithKeyframesPersist( deps: CutoverDeps, options?: CutoverOptions, ): Promise { - const payload: KeyframesPayload = { + return persistKeyframesOperation({ + targetPath, targetSelector, position, duration, keyframes, - ...(ease ? { ease } : {}), - }; - return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) => - dispatchWithKeyframes(s, payload), - ); + ease, + sdkSession, + deps, + options, + }); } export function sdkReplaceWithKeyframesPersist( @@ -447,21 +511,18 @@ export function sdkReplaceWithKeyframesPersist( deps: CutoverDeps, options?: CutoverOptions, ): Promise { - const payload: KeyframesPayload = { + return persistKeyframesOperation({ + targetPath, + animationId, targetSelector, position, duration, keyframes, - ...(ease ? { ease } : {}), - }; - return dispatchGsapOpAndPersist( - targetPath, + ease, sdkSession, deps, options, - (s) => dispatchWithKeyframes(s, payload, animationId), - { animationId, opLabel: "replaceWithKeyframes" }, - ); + }); } export async function sdkDeletePersist( @@ -476,7 +537,7 @@ export async function sdkDeletePersist( Promise.resolve(originalContent), ); // Dark-launch gate: flag OFF → legacy server delete path. - if (!STUDIO_SDK_CUTOVER_ENABLED) return declinedCutover("feature_disabled"); + 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"); @@ -488,10 +549,6 @@ export async function sdkDeletePersist( (session) => session.removeElement(hfId), { label: "Delete element" }, ); - if (result.status === "committed") { - trackStudioEvent("sdk_cutover_success", { hfId, opCount: 1 }); - } else if (result.status === "failed") { - trackStudioEvent("sdk_cutover_failed", { hfId, error: result.error.message }); - } + trackCutoverResult(result, { hfId, opCount: 1 }); return result; } diff --git a/packages/studio/src/utils/sdkCutoverPolicy.report.ts b/packages/studio/src/utils/sdkCutoverPolicy.report.ts new file mode 100644 index 0000000000..ecd10af577 --- /dev/null +++ b/packages/studio/src/utils/sdkCutoverPolicy.report.ts @@ -0,0 +1,3 @@ +import { renderStudioSdkCutoverReport } from "./sdkCutoverPolicy"; + +process.stdout.write(renderStudioSdkCutoverReport()); diff --git a/packages/studio/src/utils/sdkCutoverPolicy.test.ts b/packages/studio/src/utils/sdkCutoverPolicy.test.ts new file mode 100644 index 0000000000..a23cbbd1c1 --- /dev/null +++ b/packages/studio/src/utils/sdkCutoverPolicy.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { + STUDIO_SDK_OPERATION_FAMILIES, + isSdkFamilyEnabled, + renderStudioSdkCutoverReport, + resolveEnabledSdkFamilies, +} from "./sdkCutoverPolicy"; + +describe("Studio SDK operation-family policy", () => { + it("enables no family from the master switch alone", () => { + expect(resolveEnabledSdkFamilies({}, true).size).toBe(0); + expect(resolveEnabledSdkFamilies({ VITE_STUDIO_SDK_CUTOVER_FAMILIES: "dom" }, false).size).toBe( + 0, + ); + }); + + it("enables only explicitly selected families", () => { + const enabled = resolveEnabledSdkFamilies( + { VITE_STUDIO_SDK_CUTOVER_FAMILIES: "dom, gsap-keyframe" }, + true, + ); + expect(isSdkFamilyEnabled(true, enabled, "dom")).toBe(true); + expect(isSdkFamilyEnabled(true, enabled, "gsap-keyframe")).toBe(true); + expect(isSdkFamilyEnabled(true, enabled, "timing")).toBe(false); + }); + + it("rejects misspelled family configuration", () => { + expect(() => + resolveEnabledSdkFamilies({ VITE_STUDIO_SDK_CUTOVER_FAMILIES: "dom,gsap-twen" }, true), + ).toThrow("Unknown Studio SDK cutover families: gsap-twen"); + }); + + it("reports every family with owner, deadline, evidence, and graduation state", () => { + const report = renderStudioSdkCutoverReport(); + for (const family of STUDIO_SDK_OPERATION_FAMILIES) expect(report).toContain(`| ${family} |`); + expect(report).toContain("Graduated"); + }); +}); diff --git a/packages/studio/src/utils/sdkCutoverPolicy.ts b/packages/studio/src/utils/sdkCutoverPolicy.ts new file mode 100644 index 0000000000..25c7284462 --- /dev/null +++ b/packages/studio/src/utils/sdkCutoverPolicy.ts @@ -0,0 +1,98 @@ +export const STUDIO_SDK_OPERATION_FAMILIES = [ + "dom", + "timing", + "gsap-animation", + "gsap-keyframe", + "lifecycle", +] as const; + +export type StudioSdkOperationFamily = (typeof STUDIO_SDK_OPERATION_FAMILIES)[number]; + +interface StudioSdkFamilyStatus { + owner: string; + deadline: string; + parityEvidence: string; + graduated: boolean; +} + +const STUDIO_SDK_FAMILY_STATUS = { + dom: { + owner: "studio-foundations", + deadline: "2026-09-30", + parityEvidence: "sdkCutoverParity.test.ts", + graduated: false, + }, + timing: { + owner: "studio-foundations", + deadline: "2026-10-15", + parityEvidence: "sdkCutover.test.ts timing corpus", + graduated: false, + }, + "gsap-animation": { + owner: "studio-foundations", + deadline: "2026-10-31", + parityEvidence: "sdkCutover.test.ts GSAP animation corpus", + graduated: false, + }, + "gsap-keyframe": { + owner: "studio-foundations", + deadline: "2026-11-15", + parityEvidence: "sdkCutover.test.ts GSAP keyframe corpus", + graduated: false, + }, + lifecycle: { + owner: "studio-foundations", + deadline: "2026-11-30", + parityEvidence: "sdkCutover.test.ts lifecycle corpus", + graduated: false, + }, +} as const satisfies Record; + +export function resolveEnabledSdkFamilies( + env: Record, + masterEnabled: boolean, +): ReadonlySet { + if (!masterEnabled) return new Set(); + const raw = env["VITE_STUDIO_SDK_CUTOVER_FAMILIES"]; + if (typeof raw !== "string" || raw.trim() === "") return new Set(); + const requested = raw + .split(",") + .map((family) => family.trim()) + .filter(Boolean); + const known = new Set(STUDIO_SDK_OPERATION_FAMILIES); + const unknown = requested.filter((family) => !known.has(family)); + if (unknown.length > 0) { + throw new Error(`Unknown Studio SDK cutover families: ${unknown.join(", ")}`); + } + return new Set(requested as StudioSdkOperationFamily[]); +} + +export function isSdkFamilyEnabled( + masterEnabled: boolean, + configured: ReadonlySet | undefined, + family: StudioSdkOperationFamily, +): boolean { + // `undefined` preserves compatibility for isolated tests/embedders that mock + // only the historical master flag. Production always supplies an explicit set. + return masterEnabled && (configured?.has(family) ?? true); +} + +export function renderStudioSdkCutoverReport(): string { + const rows = STUDIO_SDK_OPERATION_FAMILIES.map((family) => { + const status = STUDIO_SDK_FAMILY_STATUS[family]; + return `| ${family} | ${status.owner} | ${status.deadline} | ${status.parityEvidence} | ${status.graduated ? "yes" : "no"} |`; + }); + return [ + "# Studio SDK cutover report", + "", + "Master flag: `VITE_STUDIO_SDK_CUTOVER_ENABLED=true`", + "Family flag: `VITE_STUDIO_SDK_CUTOVER_FAMILIES=dom,timing,...`", + "", + "A family graduates only after zero unexplained resolver/serialization divergences over its agreed corpus and soak window.", + "", + "| Family | Owner | Deadline | Parity evidence | Graduated |", + "| --- | --- | --- | --- | --- |", + ...rows, + "", + ].join("\n"); +}