85453da49f
regression / regression-shards (style-16-prod style-9-prod style-17-prod iframe-render-compat variables-prod mp4-h265-sdr, shard-4) (push) Has been cancelled
regression / regression-shards (style-4-prod style-11-prod style-2-prod animejs-adapter typegpu-adapter parallel-capture-regression, shard-5) (push) Has been cancelled
regression / regression-shards (style-7-prod style-8-prod style-10-prod css-spinner-render-compat webm-transparency mp4-h264-sdr webm-vp9, shard-3) (push) Has been cancelled
regression / regression-shards (sub-composition-video style-18-prod raf-ball-render-compat font-variant-numeric sub-comp-t0 sub-comp-id-selector, shard-7) (push) Has been cancelled
Windows render verification / Detect changes (push) Has been cancelled
Windows render verification / Preflight (lint + format) (push) Has been cancelled
Windows render verification / Render on windows-latest (push) Has been cancelled
Windows render verification / Tests on windows-latest (push) Has been cancelled
CI / Detect changes (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Fallow audit (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Producer: integration tests (push) Has been cancelled
CI / Producer: unit tests (push) Has been cancelled
CI / File size check (push) Has been cancelled
CI / Test: skills (push) Has been cancelled
CI / Skills: manifest in sync (push) Has been cancelled
CI / CLI: npx shim (macos-latest) (push) Has been cancelled
CI / CLI: npx shim (ubuntu-latest) (push) Has been cancelled
CI / CLI: npx shim (windows-latest) (push) Has been cancelled
CI / SDK: unit + contract + smoke (push) Has been cancelled
CI / Test: runtime contract (push) Has been cancelled
CI / Studio: load smoke (push) Has been cancelled
CI / Smoke: global install (push) Has been cancelled
CI / CLI smoke (required) (push) Has been cancelled
CI / Semantic PR title (push) Has been cancelled
Player perf / Detect changes (push) Has been cancelled
Player perf / Preflight (lint + format) (push) Has been cancelled
Player perf / player-perf (push) Has been cancelled
Player perf / Perf: drift (push) Has been cancelled
Player perf / Perf: fps (push) Has been cancelled
Player perf / Perf: parity (push) Has been cancelled
Player perf / Perf: scrub (push) Has been cancelled
Player perf / Perf: load (push) Has been cancelled
preview-regression / Detect changes (push) Has been cancelled
preview-regression / Preflight (lint + format) (push) Has been cancelled
preview-regression / Preview parity (push) Has been cancelled
preview-regression / preview-regression (push) Has been cancelled
regression / regression (push) Has been cancelled
regression / Detect changes (push) Has been cancelled
regression / Preflight (lint + format) (push) Has been cancelled
regression / regression-shards (hdr-regression style-5-prod style-3-prod mov-prores, shard-1) (push) Has been cancelled
regression / regression-shards (overlay-montage-prod style-12-prod chat missing-host-comp-id png-sequence portrait-edge-bleed, shard-6) (push) Has been cancelled
regression / regression-shards (style-13-prod style-6-prod vignelli-stacking gsap-letters-render-compat audio-mux-parity, shard-8) (push) Has been cancelled
regression / regression-shards (style-15-prod hdr-hlg-regression style-1-prod many-cuts vfr-screen-recording render-symlinked-assets, shard-2) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Docs / Validate docs (push) Has been cancelled
Sync skills to ClawHub / Publish changed skills (push) Has been cancelled
547 lines
24 KiB
TypeScript
547 lines
24 KiB
TypeScript
/**
|
|
* SDK resolver-parity tripwire (telemetry-only).
|
|
*
|
|
* Checks whether the SDK session resolves the same element id the server
|
|
* patch path would target, then optionally verifies value parity after an
|
|
* in-memory dispatch. Emits `sdk_resolver_shadow` on any divergence.
|
|
*
|
|
* Headline signal: `element_not_found` — the resolver divergence class that
|
|
* caused the v0.6.110 regression. The writer-parity suite (#1533) cannot see
|
|
* this class; this tripwire exists specifically to catch it.
|
|
*
|
|
* Decoupled from `STUDIO_SDK_CUTOVER_ENABLED`. Gated by its own flag
|
|
* `STUDIO_SDK_RESOLVER_SHADOW_ENABLED` (default ON during the soak — collect
|
|
* wild telemetry; flip off / remove once resolver parity is proven).
|
|
* Telemetry-only — never writes to disk, never affects the user-visible edit.
|
|
*/
|
|
|
|
import type { Composition, JsonPatchOp } from "@hyperframes/sdk";
|
|
import type { PatchOperation } from "./sourcePatcher";
|
|
import { STUDIO_SDK_RESOLVER_SHADOW_ENABLED } from "../components/editor/manualEditingAvailability";
|
|
import { patchOpsToSdkEditOps } from "./sdkOpMapping";
|
|
import { trackStudioEvent, flushViaBeacon } from "./studioTelemetry";
|
|
|
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
|
|
export interface SdkResolverMismatch {
|
|
kind:
|
|
| "element_not_found"
|
|
| "value_mismatch"
|
|
| "dispatch_error"
|
|
| "animation_not_found"
|
|
| "session_empty";
|
|
hfId?: string;
|
|
animationId?: string;
|
|
property?: string;
|
|
expected?: string | null;
|
|
actual?: string | null | undefined;
|
|
error?: string;
|
|
}
|
|
|
|
// ─── Op helpers ───────────────────────────────────────────────────────────────
|
|
|
|
// Drop studio-internal data-hf-* markers the SDK model doesn't represent.
|
|
function isShadowableOp(op: PatchOperation): boolean {
|
|
const name =
|
|
op.type === "attribute"
|
|
? op.property.startsWith("data-")
|
|
? op.property
|
|
: `data-${op.property}`
|
|
: op.type === "html-attribute"
|
|
? op.property
|
|
: null;
|
|
return name === null || !name.startsWith("data-hf-");
|
|
}
|
|
|
|
const MAPPED_OP_TYPES = new Set(["inline-style", "text-content", "attribute", "html-attribute"]);
|
|
|
|
// ─── Read-back helpers ────────────────────────────────────────────────────────
|
|
|
|
function kebabToCamel(prop: string): string {
|
|
return prop.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
|
|
}
|
|
|
|
function normalizeText(v: string | null | undefined): string | null {
|
|
if (v == null) return null;
|
|
const t = v.trim();
|
|
return t === "" ? null : t;
|
|
}
|
|
|
|
type FlatEl = NonNullable<ReturnType<Composition["getElement"]>>;
|
|
type AttrMap = Record<string, string | null>;
|
|
|
|
/**
|
|
* Resolve an hf-id to its snapshot the SAME way the SDK dispatch path does
|
|
* (engine/model.ts resolveScoped), NOT via Composition.getElement.
|
|
*
|
|
* getElement is canonical-only for a bare id by design — it deliberately will
|
|
* not resolve a bare id to a non-canonical (sub-composition) element, so that
|
|
* removeElement(bareId) and getElement(bareId) agree on the same instance
|
|
* (session.subcomp.test "ambiguous bare id" suite). But the cutover persist
|
|
* path dispatches the studio's bare data-hf-id, and dispatch resolves it via
|
|
* resolveScoped, which locates the leaf anywhere (canonical preferred, else
|
|
* first match). So getElement under-resolves a bare leaf that lives inside an
|
|
* inlined sub-composition (scopedId "host/leaf") — exactly the false
|
|
* `element_not_found` this tripwire was emitting for inlined compositions.
|
|
*
|
|
* Mirror resolveScoped here: exact scoped-path match, then canonical bare
|
|
* match, then first bare match — the resolvability dispatch actually has.
|
|
*/
|
|
// Count static `data-hf-id="<id>"` occurrences (both quote styles) in source.
|
|
// Substring split, not regex — no escaping, and the id never contains a quote.
|
|
function countHfIdInSource(source: string, id: string): number {
|
|
return (
|
|
source.split(`data-hf-id="${id}"`).length - 1 + (source.split(`data-hf-id='${id}'`).length - 1)
|
|
);
|
|
}
|
|
|
|
function resolveSnapshot(session: Composition, id: string): FlatEl | null {
|
|
const els = session.getElements();
|
|
const exact = els.find((el) => el.scopedId === id);
|
|
if (exact) return exact;
|
|
const matches = els.filter((el) => el.id === id);
|
|
return matches.find((el) => el.scopedId === el.id) ?? matches[0] ?? null;
|
|
}
|
|
|
|
function checkStyleOp(
|
|
op: PatchOperation,
|
|
el: FlatEl,
|
|
): { expected: string | null; actual: string | null } {
|
|
return {
|
|
expected: op.value ?? null,
|
|
actual: el.inlineStyles[kebabToCamel(op.property)] ?? el.inlineStyles[op.property] ?? null,
|
|
};
|
|
}
|
|
|
|
function checkTextOp(
|
|
op: PatchOperation,
|
|
el: FlatEl,
|
|
): { expected: string | null; actual: string | null } {
|
|
return { expected: normalizeText(op.value), actual: normalizeText(el.text) };
|
|
}
|
|
|
|
function checkAttrOp(
|
|
op: PatchOperation,
|
|
el: FlatEl,
|
|
): { property: string; expected: string | null; actual: string | null } {
|
|
const property =
|
|
op.type === "attribute"
|
|
? op.property.startsWith("data-")
|
|
? op.property
|
|
: `data-${op.property}`
|
|
: op.property;
|
|
return {
|
|
property,
|
|
expected: op.value ?? null,
|
|
actual: (el.attributes as AttrMap)[property] ?? null,
|
|
};
|
|
}
|
|
|
|
function checkOpValue(op: PatchOperation, el: FlatEl, hfId: string): SdkResolverMismatch | null {
|
|
let property: string;
|
|
let expected: string | null;
|
|
let actual: string | null;
|
|
|
|
if (op.type === "inline-style") {
|
|
property = op.property;
|
|
({ expected, actual } = checkStyleOp(op, el));
|
|
} else if (op.type === "text-content") {
|
|
property = "text";
|
|
({ expected, actual } = checkTextOp(op, el));
|
|
} else if (op.type === "attribute" || op.type === "html-attribute") {
|
|
({ property, expected, actual } = checkAttrOp(op, el));
|
|
} else {
|
|
return null;
|
|
}
|
|
|
|
if (actual === expected) return null;
|
|
return { kind: "value_mismatch", hfId, property, expected, actual };
|
|
}
|
|
|
|
// ─── Core check (pure — testable without flag) ────────────────────────────────
|
|
|
|
/**
|
|
* Run the resolver shadow check against an already-open SDK session.
|
|
*
|
|
* Returns an array of mismatches (empty = parity). The value-parity check
|
|
* dispatches the ops into the session to read the result back, then UNDOES
|
|
* those mutations via the captured inverse patches before returning — the
|
|
* session ends exactly as it started. This is essential: the session is shared
|
|
* with the cutover path, and a residual shadow mutation would make the
|
|
* subsequent sdkCutoverPersist see before === after and silently fall back to
|
|
* the server path. Telemetry-only; the server path stays authoritative on disk.
|
|
*
|
|
* Exported for unit tests; call `runResolverShadow` at call sites.
|
|
*/
|
|
export function sdkResolverShadowCheck(
|
|
session: Composition,
|
|
hfId: string,
|
|
ops: PatchOperation[],
|
|
sourceContent?: string,
|
|
): SdkResolverMismatch[] {
|
|
if (!resolveSnapshot(session, hfId)) {
|
|
// Runtime-node filter: an hf-id absent from the on-disk source the SDK
|
|
// parsed was never in the static DOM — it belongs to an element a
|
|
// composition <script> creates at runtime (e.g. caption word/group spans),
|
|
// which the SDK session cannot model by design. That is NOT a resolver bug,
|
|
// so suppress it. An hf-id PRESENT in source but missing from the session IS
|
|
// a genuine resolver divergence (the v0.6.110 class) — keep emitting that.
|
|
// ponytail: substring match; biases toward keeping signal on a loose hit.
|
|
if (sourceContent !== undefined && !sourceContent.includes(hfId)) return [];
|
|
// Loose match here vs. countHfIdInSource's strict data-hf-id="..." match in the
|
|
// caller (runResolverShadow) means an emitted event can carry sourceHfIdCount: 0 —
|
|
// see the comment on that field in runResolverShadow for what 0 means in that case.
|
|
return [{ kind: "element_not_found", hfId }];
|
|
}
|
|
|
|
const shadowable = ops.filter(isShadowableOp);
|
|
if (shadowable.length === 0) return [];
|
|
|
|
// Silently skip op batches containing unmapped types — not a resolver bug.
|
|
if (shadowable.some((op) => !MAPPED_OP_TYPES.has(op.type))) return [];
|
|
|
|
// Capture the inverse of the shadow dispatch so we can restore the session.
|
|
// `batch` fires a single PatchEvent whose `inversePatches` are already in
|
|
// reverse-apply order (session.ts reverses inside buildPatchEvent), so
|
|
// applyPatches(inverse) undoes the dispatch with no further reordering. If a
|
|
// future SDK refactor ever coalesces batch into a composite with no per-op
|
|
// inverse, this restore breaks — keep batch emitting inverse patches.
|
|
const inverse: JsonPatchOp[] = [];
|
|
const stopCapture = session.on("patch", (e) => inverse.push(...e.inversePatches));
|
|
// restore() runs in `finally` so the patch listener is always removed and the
|
|
// session is always undone — even if checkOpValue throws between dispatch and
|
|
// return. A residual mutation or leaked listener on the shared session is the
|
|
// exact cutover-coupling failure mode this module exists to avoid.
|
|
try {
|
|
try {
|
|
const editOps = patchOpsToSdkEditOps(hfId, shadowable);
|
|
session.batch(() => {
|
|
for (const op of editOps) session.dispatch(op);
|
|
});
|
|
} catch (err) {
|
|
return [{ kind: "dispatch_error", hfId, error: String(err) }];
|
|
}
|
|
|
|
const el = resolveSnapshot(session, hfId);
|
|
if (!el) return [{ kind: "element_not_found", hfId }];
|
|
|
|
return shadowable
|
|
.map((op) => checkOpValue(op, el, hfId))
|
|
.filter((m): m is SdkResolverMismatch => m !== null);
|
|
} finally {
|
|
stopCapture();
|
|
if (inverse.length > 0) session.applyPatches(inverse);
|
|
}
|
|
}
|
|
|
|
// ─── Attempt counter (denominator for the soak gate) ──────────────────────────
|
|
//
|
|
// The three emit functions below only fire a PostHog event on divergence —
|
|
// parity is silent, by design, to avoid firing on every edit. That leaves no
|
|
// way to compute a rate (divergences / attempts): we can count failures but
|
|
// never attempts. This counter tracks attempts in memory and rolls them up
|
|
// into ONE low-frequency event instead of firing per-attempt, which would
|
|
// recreate the exact chattiness problem the divergence-only design avoids.
|
|
|
|
const attemptCounts: Record<string, number> = {};
|
|
|
|
/**
|
|
* Record that the resolver-shadow tripwire ran for `opLabel`, regardless of
|
|
* outcome (parity or divergence). No flag check of its own — only ever called
|
|
* from inside the three emit functions below, after their own
|
|
* STUDIO_SDK_RESOLVER_SHADOW_ENABLED guard, so it's already flag-gated.
|
|
*/
|
|
export function recordAttempt(opLabel: string): void {
|
|
attemptCounts[opLabel] = (attemptCounts[opLabel] ?? 0) + 1;
|
|
ensureAttemptFlushScheduled();
|
|
}
|
|
|
|
/**
|
|
* Return the accumulated attempt counts since the last flush (or `null` if
|
|
* nothing has been recorded — no point emitting an empty rollup), and reset
|
|
* the counter to empty.
|
|
*/
|
|
export function flushAttemptCounts(): Record<string, number> | null {
|
|
const keys = Object.keys(attemptCounts);
|
|
if (keys.length === 0) return null;
|
|
const snapshot: Record<string, number> = {};
|
|
for (const key of keys) {
|
|
snapshot[key] = attemptCounts[key];
|
|
delete attemptCounts[key];
|
|
}
|
|
return snapshot;
|
|
}
|
|
|
|
const ATTEMPT_FLUSH_INTERVAL_MS = 5 * 60_000;
|
|
let attemptFlushTimer: ReturnType<typeof setInterval> | null = null;
|
|
let attemptVisibilityHandler: (() => void) | null = null;
|
|
|
|
function flushAndEmitAttempts(): void {
|
|
const counts = flushAttemptCounts();
|
|
if (counts === null) return;
|
|
trackStudioEvent("sdk_resolver_shadow_attempt", { counts: JSON.stringify(counts) });
|
|
}
|
|
|
|
// Lazily starts the rollup timer + visibilitychange listener on the FIRST
|
|
// attempt in a session — mirrors studioTelemetry.ts's own lazy flushTimer
|
|
// start, so a session that never exercises the tripwire never runs a
|
|
// background timer.
|
|
function ensureAttemptFlushScheduled(): void {
|
|
if (!attemptFlushTimer) {
|
|
attemptFlushTimer = setInterval(flushAndEmitAttempts, ATTEMPT_FLUSH_INTERVAL_MS);
|
|
}
|
|
if (!attemptVisibilityHandler && typeof document !== "undefined") {
|
|
attemptVisibilityHandler = () => {
|
|
if (document.visibilityState !== "hidden") return;
|
|
flushAndEmitAttempts();
|
|
// studioTelemetry.ts registers its own visibilitychange listener (on
|
|
// window, at module load) that drains its queue via sendBeacon. Listener
|
|
// execution order between that handler and this one (on document,
|
|
// registered lazily) is not something to rely on — whichever runs
|
|
// first could otherwise beacon-flush before or after this rollup lands
|
|
// in the queue. Forcing a beacon flush here makes delivery of this
|
|
// rollup event correct regardless of that order.
|
|
flushViaBeacon();
|
|
};
|
|
document.addEventListener("visibilitychange", attemptVisibilityHandler);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Test-only: clears the lazy timer/listener singleton state so tests can
|
|
* verify the "starts on first attempt" behavior in isolation, without an
|
|
* earlier test's real-timer interval (or visibilitychange listener) silently
|
|
* surviving into a later test. Does NOT touch attemptCounts — only the
|
|
* scheduling state. Not part of the public module contract; only imported
|
|
* from sdkResolverShadow.test.ts.
|
|
*/
|
|
export function __resetAttemptSchedulingForTests(): void {
|
|
if (attemptFlushTimer) clearInterval(attemptFlushTimer);
|
|
attemptFlushTimer = null;
|
|
if (attemptVisibilityHandler && typeof document !== "undefined") {
|
|
document.removeEventListener("visibilitychange", attemptVisibilityHandler);
|
|
}
|
|
attemptVisibilityHandler = null;
|
|
}
|
|
|
|
// ─── Telemetry ────────────────────────────────────────────────────────────────
|
|
|
|
// Redact all user-content values before telemetry: style values and text both
|
|
// carry user data. Keep only the length so we can detect truncation without
|
|
// leaking the actual bytes.
|
|
function redactValue(value: string | null | undefined): string | null | undefined {
|
|
if (value == null) return value;
|
|
return `[redacted len=${value.length}]`;
|
|
}
|
|
|
|
function redactMismatches(mismatches: SdkResolverMismatch[]): SdkResolverMismatch[] {
|
|
return mismatches.map((m) => ({
|
|
...m,
|
|
expected: redactValue(m.expected),
|
|
actual: redactValue(m.actual),
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Run the resolver shadow and emit `sdk_resolver_shadow` telemetry.
|
|
* No-op when `STUDIO_SDK_RESOLVER_SHADOW_ENABLED` is false.
|
|
* Never throws — any exception inside the shadow is swallowed.
|
|
*
|
|
* Side-effect-free on the live session: sdkResolverShadowCheck dispatches into
|
|
* the session to read values back, then undoes those mutations before returning
|
|
* (see below). The session is shared with the cutover path, so it MUST end the
|
|
* call exactly as it started.
|
|
*/
|
|
// Sessions whose empty-session modeling gap has already been reported — one
|
|
// event per session instance, not one per edit (the per-edit storm is noise;
|
|
// the EXISTENCE of the gap is the signal).
|
|
const emptySessionReported = new WeakSet<Composition>();
|
|
|
|
/**
|
|
* An empty session structurally cannot resolve ANY id — a modeling gap (empty
|
|
* file, comp shape the SDK can't parse into elements), not a resolver
|
|
* divergence, and it can't cut over either, so it stays out of the attempt
|
|
* denominator. But silence would blind the tripwire to exactly the class that
|
|
* exposed the template-comp bug — so emit ONE distinguishable `session_empty`
|
|
* event per session, then skip. Returns true when the caller should skip.
|
|
*/
|
|
function reportEmptySession(session: Composition, opLabel: string): boolean {
|
|
if (session.getElements().length !== 0) return false;
|
|
if (!emptySessionReported.has(session)) {
|
|
emptySessionReported.add(session);
|
|
trackStudioEvent("sdk_resolver_shadow", {
|
|
opLabel,
|
|
sessionEmpty: true,
|
|
sessionElementCount: 0,
|
|
mismatchCount: 1,
|
|
mismatches: JSON.stringify([{ kind: "session_empty" } satisfies SdkResolverMismatch]),
|
|
});
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export function runResolverShadow(
|
|
session: Composition,
|
|
hfId: string | null | undefined,
|
|
ops: PatchOperation[],
|
|
sourceContent?: string,
|
|
): void {
|
|
if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return;
|
|
if (!hfId) return;
|
|
try {
|
|
if (reportEmptySession(session, "dom-edit")) return;
|
|
recordAttempt("dom-edit");
|
|
const mismatches = sdkResolverShadowCheck(session, hfId, ops, sourceContent);
|
|
// Emit only on divergence — parity is silent, matching recordResolverParity
|
|
// and recordAnimationResolverParity. Otherwise this fires a PostHog event on
|
|
// every style/text/attr edit (the editor's chattiest path) at default-ON.
|
|
if (mismatches.length === 0) return;
|
|
const isElementNotFound = mismatches.some((m) => m.kind === "element_not_found");
|
|
const strictCount =
|
|
isElementNotFound && sourceContent !== undefined
|
|
? countHfIdInSource(sourceContent, hfId)
|
|
: undefined;
|
|
trackStudioEvent("sdk_resolver_shadow", {
|
|
hfId,
|
|
// sessionElementCount > 0 + element_not_found = runtime-only element;
|
|
// sessionElementCount === 0 = session is empty/broken (actionable).
|
|
sessionElementCount: session.getElements().length,
|
|
// Count of data-hf-id="<id>" occurrences in source for an emitted
|
|
// element_not_found. >1 = duplicate ids → resolver picked the wrong
|
|
// instance; =1 = single static node the SDK parse dropped (foreign-content
|
|
// exclusion / sub-comp inlining gap); =0 = the runtime-node filter above
|
|
// uses a loose substring match (biased toward keeping signal) while this
|
|
// count uses a strict attribute match — see sourceLooseMatchOnly below.
|
|
...(strictCount !== undefined ? { sourceHfIdCount: strictCount } : {}),
|
|
// Loose suppression check matched (kept this event) but the strict
|
|
// attribute count came back 0 — see the sourceHfIdCount comment above.
|
|
...(strictCount === 0 ? { sourceLooseMatchOnly: true } : {}),
|
|
mismatchCount: mismatches.length,
|
|
mismatches: JSON.stringify(redactMismatches(mismatches)),
|
|
});
|
|
} catch {
|
|
// never propagate from the shadow path
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Record element-resolution parity for an element-targeted op WITHOUT
|
|
* dispatching. Read-only: emits a single `element_not_found` event when the SDK
|
|
* can't resolve a target the server path is addressing. This extends the
|
|
* tripwire beyond the DOM-edit path (runResolverShadow) to the other
|
|
* element-targeted cutover chokepoints — timing, delete, GSAP-tween add — for
|
|
* the headline resolver signal, without the cost/mutation of a value check.
|
|
*
|
|
* No-op when the shadow flag is off; never throws; never mutates the session.
|
|
*/
|
|
export async function recordResolverParity(
|
|
session: Composition | null | undefined,
|
|
hfId: string | null | undefined,
|
|
opLabel: string,
|
|
readSource?: () => Promise<string | undefined>,
|
|
): Promise<void> {
|
|
if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return;
|
|
if (!session || !hfId) return;
|
|
try {
|
|
if (reportEmptySession(session, opLabel)) return;
|
|
recordAttempt(opLabel);
|
|
if (resolveSnapshot(session, hfId)) return; // resolves — parity, nothing to record
|
|
// Capture BEFORE any await: this call is fire-and-forget (`void recordResolverParity(...)`)
|
|
// and the caller runs its own session mutation synchronously right after this call
|
|
// returns. getElements() caches and that cache is invalidated on dispatch, so reading
|
|
// the count after an await would silently reflect POST-edit state, not the pre-edit
|
|
// state this field exists to diagnose.
|
|
const sessionElementCount = session.getElements().length;
|
|
// Cheap check passed above, so the source read only runs on a real divergence.
|
|
let source: string | undefined;
|
|
let sourceReadFailed = false;
|
|
if (readSource) {
|
|
try {
|
|
source = await readSource();
|
|
} catch {
|
|
source = undefined; // fail-open: a read error must not drop a real divergence
|
|
sourceReadFailed = true;
|
|
}
|
|
}
|
|
// Runtime-generated node the static parse can't model — suppress (mirrors the dom-edit path).
|
|
if (source !== undefined && !source.includes(hfId)) return;
|
|
const strictCount = source !== undefined ? countHfIdInSource(source, hfId) : undefined;
|
|
trackStudioEvent("sdk_resolver_shadow", {
|
|
hfId,
|
|
opLabel,
|
|
sessionElementCount,
|
|
// sourceHfIdCount: strict data-hf-id="..." attribute count. Can be 0 even
|
|
// on an emitted (non-suppressed) event — the suppression check above is a
|
|
// loose substring match (biased toward keeping signal); see the longer
|
|
// comment on this field in runResolverShadow for the full explanation.
|
|
...(strictCount !== undefined ? { sourceHfIdCount: strictCount } : {}),
|
|
// Loose suppression check matched (kept this event) but the strict
|
|
// attribute count came back 0 — hfId appeared as plain text (class name,
|
|
// comment, script string) but never as a data-hf-id="..." attribute.
|
|
// Lets telemetry consumers filter this cohort without parsing the
|
|
// sourceHfIdCount comment above.
|
|
...(strictCount === 0 ? { sourceLooseMatchOnly: true } : {}),
|
|
// The reader was wired but threw — distinguishes "read failed, emitted
|
|
// fail-open without the suppression/count checks" from "no reader wired".
|
|
...(sourceReadFailed ? { sourceReadFailed: true } : {}),
|
|
mismatchCount: 1,
|
|
mismatches: JSON.stringify([
|
|
{ kind: "element_not_found", hfId } satisfies SdkResolverMismatch,
|
|
]),
|
|
});
|
|
} catch {
|
|
// never propagate from the shadow path
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Record animation-resolution parity for an animationId-targeted GSAP op WITHOUT
|
|
* dispatching. Read-only: emits `animation_not_found` when the SDK can't resolve
|
|
* the animationId the server GSAP path is addressing — the GSAP-edit-surface
|
|
* analogue of element_not_found. The SDK's resolvable animation ids are the
|
|
* located ids attached to elements (buildAnimationIdMap) OR any id parsed from
|
|
* the script regardless of DOM match (getAllAnimationIds) — a target absent
|
|
* from both is a resolver divergence.
|
|
*
|
|
* No-op when the shadow flag is off; never throws; never mutates the session.
|
|
*/
|
|
export function recordAnimationResolverParity(
|
|
session: Composition | null | undefined,
|
|
animationId: string,
|
|
opLabel: string,
|
|
): void {
|
|
if (!STUDIO_SDK_RESOLVER_SHADOW_ENABLED) return;
|
|
if (!session || !animationId) return;
|
|
try {
|
|
recordAttempt(opLabel);
|
|
const elements = session.getElements();
|
|
const resolves =
|
|
elements.some((el) => el.animationIds.includes(animationId)) ||
|
|
session.getAllAnimationIds().has(animationId);
|
|
if (resolves) return; // SDK locates the animation — parity
|
|
trackStudioEvent("sdk_resolver_shadow", {
|
|
animationId,
|
|
opLabel,
|
|
sessionElementCount: elements.length,
|
|
mismatchCount: 1,
|
|
mismatches: JSON.stringify([
|
|
{ kind: "animation_not_found", animationId } satisfies SdkResolverMismatch,
|
|
]),
|
|
});
|
|
} catch {
|
|
// never propagate from the shadow path
|
|
}
|
|
}
|
|
|
|
// ─── Soak gate ────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Evaluate the soak-gate exit criterion.
|
|
*
|
|
* A clean soak window has zero `element_not_found` divergences. When that
|
|
* condition holds, resolver parity is proven and the flag can be retired.
|
|
*/
|
|
export function evaluateSoakGate(divergenceCount: number): "parity-proven" | "divergence-detected" {
|
|
return divergenceCount === 0 ? "parity-proven" : "divergence-detected";
|
|
}
|