fix(studio): save retries, mutation queue circuit breaker, save_failure diagnostics (#1366)

* fix(studio): save retries, mutation queue circuit breaker, save_failure diagnostics

Save failures could silently drop user work: code-editor saves fired a single
PUT with no retry, DOM-edit failures drained the whole queue against a failing
server, and several failure paths only logged to the console.

- Retry code-editor saves with exponential backoff instead of dropping the
  edit on the first failed PUT.
- Circuit breaker on the DOM-edit save queue: a failing server pauses the
  queue with a user-visible error state instead of burning every queued
  mutation against it.
- save_failure events now carry error_message, status_code, and source on
  every emission path; style/attribute DOM-edit failures that previously only
  logged to the console now emit telemetry too.
- Route unawaited commitMutation call sites (GSAP drag, property scrubbing,
  undo/redo, text fields) through a safe wrapper that reports failures via
  telemetry instead of unhandledrejection.

Follow-ups (deferred): version/ETag conflict guard on file PUTs, offline
save queue.

* fix(studio): narrow save retry changes for fallow
This commit is contained in:
Miguel Ángel
2026-06-12 22:02:17 -04:00
committed by GitHub
parent 84a56986c6
commit 5f12e692d5
19 changed files with 1122 additions and 285 deletions
@@ -22,10 +22,15 @@ describe("walkDir", () => {
it("hides internal HyperFrames backup files from project listings", () => {
const projectDir = createProjectDir();
mkdirSync(join(projectDir, ".hyperframes", "backup"), { recursive: true });
mkdirSync(join(projectDir, ".hyperframes", "examples"), { recursive: true });
mkdirSync(join(projectDir, "compositions"), { recursive: true });
writeFileSync(join(projectDir, ".hyperframes", "backup", "snapshot.html"), "backup");
writeFileSync(join(projectDir, ".hyperframes", "examples", "preset.html"), "preset");
writeFileSync(join(projectDir, "compositions", "scene.html"), "scene");
expect(walkDir(projectDir)).toEqual(["compositions/scene.html"]);
expect(walkDir(projectDir)).toEqual([
".hyperframes/examples/preset.html",
"compositions/scene.html",
]);
});
});
@@ -7,7 +7,11 @@ export function isSafePath(base: string, resolved: string): boolean {
return resolved.startsWith(norm) || resolved === resolve(base);
}
const IGNORE_DIRS = new Set([".hyperframes", ".thumbnails", "node_modules", ".git"]);
const IGNORE_DIRS = new Set([".thumbnails", "node_modules", ".git"]);
function shouldIgnoreDir(rel: string): boolean {
return rel === ".hyperframes/backup";
}
/**
* True when any directory segment of a relative path is a dot-directory or
@@ -25,8 +29,8 @@ export function isInHiddenOrVendorDir(relPath: string): boolean {
export function walkDir(dir: string, prefix = ""): string[] {
const files: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (IGNORE_DIRS.has(entry.name)) continue;
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
if (IGNORE_DIRS.has(entry.name) || shouldIgnoreDir(rel)) continue;
if (entry.isDirectory()) {
files.push(...walkDir(join(dir, entry.name), rel));
} else {