Merge pull request #4055 from heygen-com/worktree-glowing-giggling-toucan

feat(studio): flip the SDK cutover and the GSAP writer to default-on
This commit is contained in:
Vance Ingalls
2026-09-17 23:47:37 -07:00
committed by GitHub
16 changed files with 399 additions and 76 deletions
+32 -22
View File
@@ -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<void>((resolve) => {
releaseImport = resolve;
});
const parserEntered = new Promise<void>((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<void>((resolve) => {
releaseImport = resolve;
});
const parserEntered = new Promise<void>((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 () => {
@@ -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",
@@ -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");
}
@@ -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<string, string | undefined>) {
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"]));
});
});
@@ -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,
@@ -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(<Probe projectId="project-a" />));
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(<Probe projectId="project-a" />));
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(<Probe projectId="project-a" />));
await flushAsyncEffects();
expect(trackMock).not.toHaveBeenCalledWith("sdk_session_unavailable", expect.anything());
await act(async () => root.unmount());
});
});
+21 -3
View File
@@ -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 () => {
@@ -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,
@@ -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);
+46 -25
View File
@@ -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<CutoverResult> {
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,
@@ -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<PatchOperation["type"]>([
"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,
@@ -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,
);
@@ -54,7 +54,8 @@ export function resolveEnabledSdkFamilies(
): ReadonlySet<StudioSdkOperationFamily> {
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.",
"",
@@ -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",
});
});
});
@@ -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 };
}
+10 -1
View File
@@ -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;