Repository navigation
fix(studio): deleting a picked element can no longer wipe the whole video file - #5973
miguel-heygen wants to merge 6 commits into
Conversation
…ideo file The preview picker named a nested element by a selector that was unique only among its siblings (a bare `div`), so anything re-finding the pick with querySelector got the first div in the document, the root composition, and a delete emptied index.html. Pick selectors now match only the picked element. Studio's file writers also refuse any save that removes a root composition the file had (422, file untouched), so no wrong target can empty a video again.
A renamed root composition and non-HTML files now save as before, and an element patch that would strip the last composition answers 422 instead of a bare 500. Pick selectors stay valid for prefixed tags such as o:p.
Pre-review pass (independent reviewer), and what changed at 13b387a
|
Edit accuracy: accurate 271 (base branch 271), smooth 120 of thoseThe gate passes. Quarantined, measured but not gated (0) Unstable (1)
|
… runner The film bridge and runtime contract tests ran on vitest's 5 s default. On main the slowest peaks at 1.77 s (5 runs); a slow runner stretched tests up to 5.1x and one hit 5.05 s. They now get an explicit 15 s timeout.
terencecho
left a comment
There was a problem hiding this comment.
Reviewed 6250014 against effective merge base 87c6e50 (not the newer API base tip).
What is working well
- The picker checks document-wide uniqueness and element identity before using a short selector, otherwise assembling an escaped, anchored child path (picker.ts). The repeated-node/prefixed-tag fixture checks that selectors resolve to the original same-document elements. This is not a claim that runtime selectors universally preserve source-file ownership.
- The guard deliberately protects composition existence, not a particular root ID or all original descendants. HTML-only checking, template traversal, root-rename/non-HTML controls, and typed 422 handling keep that contract narrow. The inspected ordinary PUT and one-file mutation paths check before their own snapshot.
- The successor adds a named 15,000 timeout option to the two browser-test suites, leaving explicit 30,000 test/hook annotations in place. It changes test configuration, not production deletion code. I did not execute Vitest or establish its installed implementation's exact timeout inheritance, default, or flake resolution.
[P2] Preserve earlier files' recovery history when a later batch file is refused
The new guard runs inside the sequential applyOneMutation loop, rather than in the existing all-files preflight (applyFileMutations.ts:38–59, 105–107). Consequently, rejecting a later file can already have changed an earlier file's retained recovery history.
Static source-trace scenario, not an executed reproduction: submit the supported POST /projects/:id/file-mutations/patch-element-batches with two matched files in order. A.html gets a harmless title="old" → title="new" attribute edit that preserves its composition. B.html receives a schema-valid html-attribute operation setting its sole data-composition-id to null; the inspected mutation fold removes that attribute. This passes existing operation/target/version checks but is then rejected by the new composition guard. No concurrent writer is needed. A starts with ten distinct, chronologically older retained backups, including an oldest recovery point with distinct content. The journal unconditionally writes a new snapshot at a non-colliding timestamp/counter path and immediately prunes; choose ordinary increasing timestamps so the distinct oldest entry is the prune victim. The route accepts/folds these operations and preserves request order (files.ts:448–509, 3449–3472).
A passes its guard, snapshots, and writes. snapshotBeforeWrite immediately prunes to the default ten backups, deleting A's oldest retained snapshot (backupJournal.ts:6, 31–49, 82–100). B then throws the new root-refusal error, which the route maps to 422. Rollback restores A's current source bytes and clears its write receipt, but does not restore the pruned older backup or remove the newly created snapshot (applyFileMutations.ts:71–83). B is refused before its own snapshot.
Thus this rejected operation leaves both source files unchanged/restored but loses an older retained recovery point. This is not current document-byte loss, a whole-video wipe, or proof of irrecoverability from every other backup. Generic journal/rollback nontransactionality predates this PR; the introduced interaction is the new deterministic semantic refusal after earlier-file side effects. On the reviewed BASE batch helper, this otherwise-valid request proceeds through the existing write path without the new root-existence refusal. The PR description's “Fix,” item 2 promises “422 with a plain message, file and backups untouched” and explicitly includes element patch batches; this case violates that stated refusal behavior.
Please ensure a root-refused batch preserves source bytes, receipts, and retained backup inventory/content—for example, preflight every prepared root-loss outcome before the first snapshot/write while preserving per-write freshness checks. Add a two-file refusal regression with a full A journal asserting that invariant. The new one-file refusal fixture does not exercise this interaction.
Scope, verification, and limits
The primary and fresh unprimed adversarial passes read all seven existing changed BASE bodies before all nine changed HEAD bodies, including tests. Source identity/ownership, actual picker consumers, separate ordinary canvas-delete targeting, save/error handling, and recorded-history/GSAP carveouts were traced within bounded supporting source. The ten public author concerns were reconciled individually. No application-wide proof for every SDK/external consumer or terminal restore implementation is claimed. A possible attribute-casing mismatch remains unpromoted because complete application recognition support for uppercase-only composition attributes has not been established.
No application tests, browser/native execution, installs/builds, CI queries/triggers, live/provider calls, merge, release, deployment, or production mutation were performed. Author tests, mutants, screenshots, timing and historical automation comments remain attributed claims, not my runtime results. CI color is not the reason for this verdict.
Workflow review found no approval-event trigger in the 17 inspected repository workflows, which does not exclude the stated external approval→merge loop. A later main push can invoke catalog publication/branch/PR/dispatch automation; release/npm/plugin effects have additional conditions. External consumers, generated-artifact closure, and successful publication/deployment were not verified. This review does not authorize direct merge or triggering those actions.
Process disclosures: current primary source accounting is 54 blobs / 1,070,339 bytes including charged API text; two supporting files remain explicitly partial. Its retained-public-text Reads lacked fresh per-read reservations. The fresh adversary reports 42 blobs / 929,014 API-inclusive bytes; resumed/batched reservations, earlier omitted API charges, retrospective coverage receipts, and an EOF-overshooting final range are disclosed rather than treated as contemporaneously perfect records. The effects inventory comprises 74 acquired Git blobs (42 FULL, three PARTIAL, 29 UNREAD) and six FULL API-text objects, totaling 519,460 acquired bytes; nested instructions were discovered after cache materialization but before semantic effect reads, initial per-call plans were late, and some cap records were stale. The applicable current-pass limits are 100 objects, 4 MiB aggregate, and 400 KiB per object. The figures above report current acquisitions, not procedural clearance; they do not cure the procedural gaps or bound the unknown historical acquisition. An initially primed adversarial attempt was discarded and replaced. The canceled, unposted 13b387a0 attempt's unknown transient acquisition bytes and rejected original read method remain historical caveats, not retroactively repaired by this new review.
— tai
Verdict: REQUEST CHANGES
Reasoning: The picker and single-file refusal handling are targeted improvements, but a later-file root refusal can occur after an earlier file's retained recovery history has been rotated away. Rejected batches need to preserve that recovery history.
|
[P2] later batch file refused after an earlier file's backup rotated: fixed at bb46618.
Regression: The two single-file writers ( |
There was a problem hiding this comment.
Verdict: APPROVE · 0 blocker · 0 important · 1 nit · head 66a0673b
@terencecho's P2 (a refused later file rotating an earlier file's backups) is fixed. The root check now runs in the all-files preflight before the first snapshotBeforeWrite, and the two-file test pins the full journal, the receipt and both files' bytes. The PUT and writeMutationResult guards also run before their snapshots. CI is green.
- nit: the PR body still lists the 15 s
coreRuntimeBrowser.test.tstimeout fold-in. That change from62500149isn't in the diff after the main merges, and main still has the defaults, so please update the body or re-add it.
What broke
Picking a nested element inside a sub-scene (for example a label with no id inside a b-roll scene) and deleting it could empty
index.html: the root composition and every clip gone, with no error.Why
The preview picker (
packages/core/src/runtime/picker.ts,buildElementSelector) named an element with a selector that was unique only among its siblings. A nesteddivwith no id got the bare selectordiv. Anything that re-found the pick withdocument.querySelectorgot the firstdivin the preview, which isindex.html's root composition, so deleting "the pick" removed the root. The server then wrote what it was asked to.Fix
#id,[data-composition-id], ...) are kept when they are already unique; otherwise the selector is a child path from the nearest ancestor whose short form is unique, e.g.[data-composition-id="broll"] > [data-hf-inner-root] > div > div > div.rootCompositionRefusal(helpers/rootCompositionGuard.ts), refuses a save that leaves an HTML file which had a composition with none at all: 422 with a plain message, file and backups untouched. Renaming a root composition and non-HTML files save as before. It runs in the three writers that can drop elements: the mutation routes (writeMutationResult), the filePUTevery client save goes through, andapplyFileMutations(element patch batches answer 422 too). Not wired: the undo restore and GSAP rollback routes (they write back content the client recorded) and the path-rename rewrite (attribute values only); freeze-frame and split only add or clone.Tests
picker.test.ts: every element's pick selector matches exactly that element (including prefixed tags likeo:p), and the nested label's no longer resolves to the root. Fails on main (the label's selector resolved to the root).files.rootComposition.test.ts: deleting the root ofindex.html, deleting a sub-scene file's own root inside its<template>, aPUTwith an emptied body, and an element patch that strips the last composition are each refused with 422 and the file left byte-identical (the first three fail on main with 200). Deleting a nested sub-scene child, deleting a sub-scene host, renaming the root in the code editor and editing a non-HTML file that mentions a composition all still save.applyFileMutations.test.ts: a mutation that drops the last composition throws and leaves the file untouched. In a two-file batch where only the second file loses its composition, the check runs for every file before the first backup is taken, so both files, the first file's write receipt and its full ten-entry backup journal are unchanged (fails on the previous head: the first file's oldest backup was pruned).Mutations: guard off fails exactly the refusal tests; HTML-only check removed fails only the non-HTML test; the 422 mapping removed fails only the patch test (500); tag escaping removed or uniqueness forced true fails only the picker test.
Also folds in a flake fix met on this PR's CI:
coreRuntimeBrowser.test.tsbrowser tests ran on vitest's 5 s default and one timed out at 5048 ms on a slow runner (main peaks at 1.77 s over 5 runs; that runner stretched tests up to 5.1x), so both browserdescribeblocks now get an explicit 15 s timeout.Before / After
Fixture project:
index.html(rootmain, a title clip, a b-roll sub-scene host) andcompositions/broll.html(a nested label with no id). Headless Studio: ask the preview picker for the label's selector, re-find it withquerySelector, delete what that resolves to through Studio.div, it resolves to#root, the delete returns 200, the stage goes black and the timeline is empty.compositions/broll.html;index.htmlis unchanged. Sending the old request (delete#root) now returns 422 and nothing is written.