From 26ea8a0740a986383ce4aa7d2b399b992c838350 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 17 Sep 2026 21:33:40 -0700 Subject: [PATCH 1/4] feat(studio): enable SDK cutover by default for every op family Master flag defaults true; unset family list now means all five families. VITE_STUDIO_SDK_CUTOVER_ENABLED=false / VITE_STUDIO_SDK_CUTOVER_FAMILIES=... remain as runtime kill switches via __HF_STUDIO_ENV__. Gate: 0 divergences on 0.8.41-0.8.46 shadow tile, 3455/0 success/failed on dev builds (30d). Co-Authored-By: Claude Fable 5.1 --- .../editor/manualEditingAvailability.test.ts | 19 +++++++++++++++++++ .../editor/manualEditingAvailability.ts | 14 +++++++++----- .../studio/src/utils/sdkCutover.gate.test.ts | 9 +++++---- .../studio/src/utils/sdkCutoverPolicy.test.ts | 13 +++++++++++-- packages/studio/src/utils/sdkCutoverPolicy.ts | 8 +++++--- 5 files changed, 49 insertions(+), 14 deletions(-) diff --git a/packages/studio/src/components/editor/manualEditingAvailability.test.ts b/packages/studio/src/components/editor/manualEditingAvailability.test.ts index b661d1f2fc..0d7f583950 100644 --- a/packages/studio/src/components/editor/manualEditingAvailability.test.ts +++ b/packages/studio/src/components/editor/manualEditingAvailability.test.ts @@ -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) { 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"])); + }); }); diff --git a/packages/studio/src/components/editor/manualEditingAvailability.ts b/packages/studio/src/components/editor/manualEditingAvailability.ts index d156bf6941..cc3c806f7a 100644 --- a/packages/studio/src/components/editor/manualEditingAvailability.ts +++ b/packages/studio/src/components/editor/manualEditingAvailability.ts @@ -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, diff --git a/packages/studio/src/utils/sdkCutover.gate.test.ts b/packages/studio/src/utils/sdkCutover.gate.test.ts index c19085955b..e6b9531e69 100644 --- a/packages/studio/src/utils/sdkCutover.gate.test.ts +++ b/packages/studio/src/utils/sdkCutover.gate.test.ts @@ -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, diff --git a/packages/studio/src/utils/sdkCutoverPolicy.test.ts b/packages/studio/src/utils/sdkCutoverPolicy.test.ts index a23cbbd1c1..5c77a1d420 100644 --- a/packages/studio/src/utils/sdkCutoverPolicy.test.ts +++ b/packages/studio/src/utils/sdkCutoverPolicy.test.ts @@ -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, ); diff --git a/packages/studio/src/utils/sdkCutoverPolicy.ts b/packages/studio/src/utils/sdkCutoverPolicy.ts index 25c7284462..da998c36af 100644 --- a/packages/studio/src/utils/sdkCutoverPolicy.ts +++ b/packages/studio/src/utils/sdkCutoverPolicy.ts @@ -54,7 +54,8 @@ export function resolveEnabledSdkFamilies( ): ReadonlySet { 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.", "", From 407cacf0aef3c498e79dc7eaa46b3f4bbfa52035 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 17 Sep 2026 21:38:38 -0700 Subject: [PATCH 2/4] feat(studio-server): default the GSAP writer to acorn HYPERFRAMES_GSAP_WRITER=recast stays as the fallback for one watch window before recast is deleted. acornDefaultBlockers -> behavioralParityOps: the 18 ops without a byte-diff oracle are now pinned in a test, not a gate. files.test.ts "stale semantic no-op" pinned to the recast writer: its interleave seam is recast's lazy import, which acorn (statically imported, fully synchronous) never performs. The 409 revalidation it covers is writer-independent. Co-Authored-By: Claude Fable 5.1 --- .../studio-server/src/routes/files.test.ts | 54 +++++++++++-------- .../routes/gsapMutationCapabilities.test.ts | 33 ++++++++++-- .../src/routes/gsapMutationCapabilities.ts | 11 ++-- 3 files changed, 66 insertions(+), 32 deletions(-) diff --git a/packages/studio-server/src/routes/files.test.ts b/packages/studio-server/src/routes/files.test.ts index b6610146c5..306efb3f44 100644 --- a/packages/studio-server/src/routes/files.test.ts +++ b/packages/studio-server/src/routes/files.test.ts @@ -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((resolve) => { - releaseImport = resolve; - }); - const parserEntered = new Promise((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((resolve) => { + releaseImport = resolve; + }); + const parserEntered = new Promise((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 () => { diff --git a/packages/studio-server/src/routes/gsapMutationCapabilities.test.ts b/packages/studio-server/src/routes/gsapMutationCapabilities.test.ts index e004fcb583..02c427acb1 100644 --- a/packages/studio-server/src/routes/gsapMutationCapabilities.test.ts +++ b/packages/studio-server/src/routes/gsapMutationCapabilities.test.ts @@ -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", diff --git a/packages/studio-server/src/routes/gsapMutationCapabilities.ts b/packages/studio-server/src/routes/gsapMutationCapabilities.ts index b59f2066ab..0980f47bd0 100644 --- a/packages/studio-server/src/routes/gsapMutationCapabilities.ts +++ b/packages/studio-server/src/routes/gsapMutationCapabilities.ts @@ -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"); } From 9ef37e5163d861248ac3c158a5d612a3143a682f Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 17 Sep 2026 21:47:14 -0700 Subject: [PATCH 3/4] feat(studio): emit sdk_cutover_declined with reason + family Declines fell back to the server silently; post-flip we could not tell 'SDK took it' from 'SDK bowed out'. Payload is reason + family only. All 23 sdkCutover.ts decline sites now pass their family; the one pre-family site (no_change) emits family:null. Co-Authored-By: Claude Fable 5.1 --- packages/studio/src/utils/sdkCutover.ts | 51 ++++++++++--------- .../utils/sdkEditTransaction.decline.test.ts | 25 +++++++++ .../studio/src/utils/sdkEditTransaction.ts | 9 +++- 3 files changed, 61 insertions(+), 24 deletions(-) create mode 100644 packages/studio/src/utils/sdkEditTransaction.decline.test.ts diff --git a/packages/studio/src/utils/sdkCutover.ts b/packages/studio/src/utils/sdkCutover.ts index 4839bc083b..37e170112d 100644 --- a/packages/studio/src/utils/sdkCutover.ts +++ b/packages/studio/src/utils/sdkCutover.ts @@ -78,15 +78,16 @@ export async function sdkCutoverPersist( options?: CutoverOptions, ): Promise { 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"); 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 +123,11 @@ 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"); + if (wrongCompositionFile(deps, targetPath)) + return declinedCutover("wrong_composition_file", "timing"); try { const serializedBefore = sdkSession.serialize(); const result = await persistSdkCandidateMutation( @@ -166,11 +168,12 @@ 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 (!sdkFamilyEnabled("timing")) return declinedCutover("feature_disabled", "timing"); + if (!sdkSession) return declinedCutover("session_unavailable", "timing"); + if (wrongCompositionFile(deps, targetPath)) + return declinedCutover("wrong_composition_file", "timing"); if (changes.some((change) => !sdkSession.getElement(change.hfId))) - return declinedCutover("target_not_found"); + return declinedCutover("target_not_found", "timing"); try { const serializedBefore = sdkSession.serialize(); const result = await persistSdkCandidateMutation( @@ -244,9 +247,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 +288,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 +563,11 @@ 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"); + if (wrongCompositionFile(deps, targetPath)) + return declinedCutover("wrong_composition_file", "lifecycle"); const result = await persistSdkCandidateMutation( sdkSession, targetPath, diff --git a/packages/studio/src/utils/sdkEditTransaction.decline.test.ts b/packages/studio/src/utils/sdkEditTransaction.decline.test.ts new file mode 100644 index 0000000000..a038b68fee --- /dev/null +++ b/packages/studio/src/utils/sdkEditTransaction.decline.test.ts @@ -0,0 +1,25 @@ +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, + }); + }); +}); diff --git a/packages/studio/src/utils/sdkEditTransaction.ts b/packages/studio/src/utils/sdkEditTransaction.ts index 2ecf9df19b..a8acb2a1fd 100644 --- a/packages/studio/src/utils/sdkEditTransaction.ts +++ b/packages/studio/src/utils/sdkEditTransaction.ts @@ -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,13 @@ 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). + */ +export function declinedCutover(reason: string, family?: StudioSdkOperationFamily): CutoverResult { + trackStudioEvent("sdk_cutover_declined", { reason, family: family ?? null }); return { status: "declined", reason }; } From 9f3a778fd11b86186636729df8e4b5d964824789 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 17 Sep 2026 23:24:09 -0700 Subject: [PATCH 4/4] feat(studio): report why a fallback happened, and when a session is missing Three silent fallback paths, three instruments: 1. resolverDisagreement on sdk_cutover_declined. Composition.getElement is canonical-only for a bare id, while dispatch resolves a bare id anywhere (resolveScoped). An element under a scoped id is therefore dispatchable yet still fails getElement -- a dispatchable edit refused, and invisible in both telemetry events (the shadow stays silent when its broader resolver hits). resolveSnapshot is now exported so the cutover path can tell the two target_not_found cases apart. 2. sdk_session_unavailable. With no session every cutover chokepoint takes the server path and emits nothing, and the shadow never runs either -- a total, silent SDK bypass. It has exactly three origins, all in useSdkSession's open effect: unreadable content, a failed ownership check, and openComposition throwing. Each now emits with its stage (and the error message for "open"). 3. isResolverDisagreement is deliberately fail-safe: a session shape without getElements reports no disagreement rather than throwing, so diagnostic context can never turn a clean decline into a failure. Co-Authored-By: Claude Fable 5.1 --- .../hooks/useSdkSession.lifecycle.test.tsx | 76 +++++++++++++++++++ packages/studio/src/hooks/useSdkSession.ts | 24 +++++- packages/studio/src/utils/sdkCutover.test.ts | 42 ++++++++++ packages/studio/src/utils/sdkCutover.ts | 28 +++++-- .../studio/src/utils/sdkCutoverEligibility.ts | 21 +++++ .../utils/sdkEditTransaction.decline.test.ts | 21 +++++ .../studio/src/utils/sdkEditTransaction.ts | 18 ++++- .../studio/src/utils/sdkResolverShadow.ts | 11 ++- 8 files changed, 229 insertions(+), 12 deletions(-) diff --git a/packages/studio/src/hooks/useSdkSession.lifecycle.test.tsx b/packages/studio/src/hooks/useSdkSession.lifecycle.test.tsx index 7719b6d883..dd52400913 100644 --- a/packages/studio/src/hooks/useSdkSession.lifecycle.test.tsx +++ b/packages/studio/src/hooks/useSdkSession.lifecycle.test.tsx @@ -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()); + 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()); + 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()); + await flushAsyncEffects(); + + expect(trackMock).not.toHaveBeenCalledWith("sdk_session_unavailable", expect.anything()); + await act(async () => root.unmount()); + }); +}); diff --git a/packages/studio/src/hooks/useSdkSession.ts b/packages/studio/src/hooks/useSdkSession.ts index 278598376a..837b4d3b46 100644 --- a/packages/studio/src/hooks/useSdkSession.ts +++ b/packages/studio/src/hooks/useSdkSession.ts @@ -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 () => { diff --git a/packages/studio/src/utils/sdkCutover.test.ts b/packages/studio/src/utils/sdkCutover.test.ts index 4815ac4bc4..d5644bc0e3 100644 --- a/packages/studio/src/utils/sdkCutover.test.ts +++ b/packages/studio/src/utils/sdkCutover.test.ts @@ -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); diff --git a/packages/studio/src/utils/sdkCutover.ts b/packages/studio/src/utils/sdkCutover.ts index 37e170112d..faf33b4b1f 100644 --- a/packages/studio/src/utils/sdkCutover.ts +++ b/packages/studio/src/utils/sdkCutover.ts @@ -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, @@ -83,7 +87,8 @@ export async function sdkCutoverPersist( const hfId = selection.hfId; if (!hfId) return declinedCutover("target_unaddressable", "dom"); const target = sdkSession.getElement(hfId); - if (!target) return declinedCutover("target_not_found", "dom"); + if (!target) + return declinedCutover("target_not_found", "dom", isResolverDisagreement(sdkSession, hfId)); if (shouldDeclineTextCutoverForTarget(target, ops)) return declinedCutover("unsupported_text_target", "dom"); if (wrongCompositionFile(deps, targetPath)) @@ -125,7 +130,8 @@ export async function sdkTimingPersist( // NOT disable it. Gate here so flag-off routes back to the legacy server path. 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"); + if (!sdkSession.getElement(hfId)) + return declinedCutover("target_not_found", "timing", isResolverDisagreement(sdkSession, hfId)); if (wrongCompositionFile(deps, targetPath)) return declinedCutover("wrong_composition_file", "timing"); try { @@ -172,8 +178,13 @@ export async function sdkTimingBatchPersist( if (!sdkSession) return declinedCutover("session_unavailable", "timing"); if (wrongCompositionFile(deps, targetPath)) return declinedCutover("wrong_composition_file", "timing"); - if (changes.some((change) => !sdkSession.getElement(change.hfId))) - return declinedCutover("target_not_found", "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( @@ -565,7 +576,12 @@ export async function sdkDeletePersist( // Dark-launch gate: flag OFF → legacy server delete path. 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"); + 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( diff --git a/packages/studio/src/utils/sdkCutoverEligibility.ts b/packages/studio/src/utils/sdkCutoverEligibility.ts index 4e779807bb..2dbc0aee63 100644 --- a/packages/studio/src/utils/sdkCutoverEligibility.ts +++ b/packages/studio/src/utils/sdkCutoverEligibility.ts @@ -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([ "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, diff --git a/packages/studio/src/utils/sdkEditTransaction.decline.test.ts b/packages/studio/src/utils/sdkEditTransaction.decline.test.ts index a038b68fee..2de21a7916 100644 --- a/packages/studio/src/utils/sdkEditTransaction.decline.test.ts +++ b/packages/studio/src/utils/sdkEditTransaction.decline.test.ts @@ -22,4 +22,25 @@ describe("declinedCutover telemetry", () => { 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", + }); + }); }); diff --git a/packages/studio/src/utils/sdkEditTransaction.ts b/packages/studio/src/utils/sdkEditTransaction.ts index a8acb2a1fd..4e8f371d05 100644 --- a/packages/studio/src/utils/sdkEditTransaction.ts +++ b/packages/studio/src/utils/sdkEditTransaction.ts @@ -71,9 +71,23 @@ interface CandidateEdit { * 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): CutoverResult { - trackStudioEvent("sdk_cutover_declined", { reason, family: family ?? null }); +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 }; } diff --git a/packages/studio/src/utils/sdkResolverShadow.ts b/packages/studio/src/utils/sdkResolverShadow.ts index 6508e683f8..e06167480c 100644 --- a/packages/studio/src/utils/sdkResolverShadow.ts +++ b/packages/studio/src/utils/sdkResolverShadow.ts @@ -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;