mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 23:03:09 +00:00
refactor(studio): gate SDK operation families
This commit is contained in:
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user