refactor(studio): gate SDK operation families

This commit is contained in:
James
2026-07-18 11:34:22 -04:00
parent fb362df625
commit 38c2d06933
7 changed files with 290 additions and 39 deletions
+2 -1
View File
@@ -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",
@@ -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.
@@ -109,3 +115,4 @@ export const STUDIO_FLAT_INSPECTOR_ENABLED = resolveStudioBooleanEnvFlag(
);
export const STUDIO_MANUAL_EDITING_DISABLED_TITLE = "Manual editing is temporarily disabled";
import { resolveEnabledSdkFamilies } from "../../utils/sdkCutoverPolicy";
@@ -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" });
});
});
+95 -38
View File
@@ -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";
@@ -14,6 +14,7 @@ import {
type CutoverOptions,
type CutoverResult,
} from "./sdkEditTransaction";
import { isSdkFamilyEnabled, type StudioSdkOperationFamily } from "./sdkCutoverPolicy";
export { shouldUseSdkCutover } from "./sdkCutoverEligibility";
export {
@@ -28,6 +29,27 @@ export type {
PublishSdkSession,
} 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;
@@ -55,7 +77,7 @@ export async function sdkCutoverPersist(
deps: CutoverDeps,
options?: CutoverOptions,
): Promise<CutoverResult> {
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;
@@ -75,11 +97,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;
}
@@ -104,7 +122,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");
@@ -119,11 +137,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;
@@ -152,7 +166,7 @@ export async function sdkTimingBatchPersist(
{ targetPath, compositionPath: deps.compositionPath },
);
}
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)))
@@ -229,13 +243,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);
@@ -249,6 +264,7 @@ export function sdkGsapTweenPersist(
}
async function dispatchGsapOpAndPersist(
family: "gsap-animation" | "gsap-keyframe",
targetPath: string,
sdkSession: Composition | null | undefined,
deps: CutoverDeps,
@@ -269,7 +285,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;
@@ -309,6 +325,7 @@ export function sdkGsapKeyframePersist(
options?: CutoverOptions,
): Promise<CutoverResult> {
return dispatchGsapOpAndPersist(
"gsap-keyframe",
targetPath,
sdkSession,
deps,
@@ -327,6 +344,7 @@ export function sdkGsapRemoveKeyframePersist(
options?: CutoverOptions,
): Promise<CutoverResult> {
return dispatchGsapOpAndPersist(
"gsap-keyframe",
targetPath,
sdkSession,
deps,
@@ -346,6 +364,7 @@ export function sdkGsapRemovePropertyPersist(
options?: CutoverOptions,
): Promise<CutoverResult> {
return dispatchGsapOpAndPersist(
"gsap-animation",
targetPath,
sdkSession,
deps,
@@ -362,7 +381,7 @@ export function sdkGsapDeleteAllForSelectorPersist(
deps: CutoverDeps,
options?: CutoverOptions,
): Promise<CutoverResult> {
return dispatchGsapOpAndPersist(targetPath, sdkSession, deps, options, (s) =>
return dispatchGsapOpAndPersist("gsap-animation", targetPath, sdkSession, deps, options, (s) =>
s.dispatch({ type: "deleteAllForSelector", selector }),
);
}
@@ -375,6 +394,7 @@ export function sdkGsapRemoveAllKeyframesPersist(
options?: CutoverOptions,
): Promise<CutoverResult> {
return dispatchGsapOpAndPersist(
"gsap-keyframe",
targetPath,
sdkSession,
deps,
@@ -393,6 +413,7 @@ export function sdkGsapConvertToKeyframesPersist(
options?: CutoverOptions,
): Promise<CutoverResult> {
return dispatchGsapOpAndPersist(
"gsap-keyframe",
targetPath,
sdkSession,
deps,
@@ -417,6 +438,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,
@@ -430,6 +461,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<CutoverResult> {
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,
@@ -441,16 +504,17 @@ export function sdkAddWithKeyframesPersist(
deps: CutoverDeps,
options?: CutoverOptions,
): Promise<CutoverResult> {
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(
@@ -465,21 +529,18 @@ export function sdkReplaceWithKeyframesPersist(
deps: CutoverDeps,
options?: CutoverOptions,
): Promise<CutoverResult> {
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(
@@ -498,7 +559,7 @@ export async function sdkDeletePersist(
{ targetPath, compositionPath: deps.compositionPath },
);
// 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");
@@ -510,10 +571,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;
}
@@ -0,0 +1,3 @@
import { renderStudioSdkCutoverReport } from "./sdkCutoverPolicy";
process.stdout.write(renderStudioSdkCutoverReport());
@@ -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");
});
});
@@ -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<StudioSdkOperationFamily, StudioSdkFamilyStatus>;
export function resolveEnabledSdkFamilies(
env: Record<string, boolean | string | undefined>,
masterEnabled: boolean,
): ReadonlySet<StudioSdkOperationFamily> {
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<string>(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<StudioSdkOperationFamily> | 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");
}