mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user