feat(sdk): ws-3 — reorderElements op (batch z-index update) (#1502)

* feat(sdk): ws-3 — reorderElements op (batch z-index update)

Adds the reorderElements EditOp: each entry sets inline zIndex on one element.
Last-write-wins per target so a duplicated target collapses to a single zIndex
patch. Positioning is unchanged — z-index only takes effect on non-static
elements, so the caller must ensure the target is positioned.

Also fixes single-dispatch undo to reverse the inverse patch list (parity with
batch()): an op emitting multiple patches whose undo order matters — a duplicated
reorderElements target, an aliased multi-target, or a nested parent+child
removeElement — must undo in reverse application order, or undo lands on an
intermediate value / drops a subtree.

validateOp resolves every entry target (E_TARGET_NOT_FOUND for unknown ids;
empty entries is a clean no-op). Tests cover set/inverse/validate/duplicate-target.

Rebuilt standalone on main (reorderElements only depends on handleSetStyle).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(sdk): remove unused HTTP persist adapter

The HTTP PersistAdapter (createHttpAdapter) was dead weight after the studio
cutover went single-writer (ws-4): Studio's writeProjectFile is the sole writer
and useSdkSession opens with no persist queue, so the adapter's write/flush/
listVersions/loadFrom were never used — only read() was, to fetch the
composition source. Replace those two read() calls with a direct optional fetch
(GET /files/<path>?optional=1) and drop the adapter + its export-map entries.

Saved for later re-introduction (when a non-Studio SDK host needs server-backed
persist) at docs/hyperframes/plans/sdk-http-adapter/ (outside the repo).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(studio): resolver shadow for z-index reorder targets

The z-index reorder commit takes the server path (no SDK persist), but the
resolver-shadow tripwire is decoupled from cutover — so it should still record
whether the SDK resolves each reordered element (reorderElements' targets), the
same as timing/delete already do before their cutover gate. This gives wild
resolver-parity telemetry on z-index targets before z-index reorder is cut over.

Threads an onReorderShadow callback (sdkSession-bound, mirrors onTrySdkDelete)
from useDomEditSession → useDomEditCommits → useElementLifecycleOps, called with
the reordered elements' hf-ids in handleDomZIndexReorderCommit. Read-only,
divergence-only, never throws — same contract as recordResolverParity elsewhere.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(studio): guard project-file read path against traversal (CodeQL CSRF)

readProjectFileOptional interpolated a user-influenced composition path into the
fetch URL, which CodeQL flagged as client-side request forgery. Reject NUL/`..`
up front (mirrors the existing guard in timelineEditingHelpers) and
encodeURIComponent the projectId too, so both values stay confined to single
segments of the same-origin URL. Unsafe path → undefined (graceful for the
optional read).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-06-17 23:51:47 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent de87f3932e
commit 28bfe09f21
11 changed files with 154 additions and 452 deletions
+29 -16
View File
@@ -1,11 +1,32 @@
import { useState, useEffect, useCallback } from "react";
import type { MutableRefObject } from "react";
import { openComposition } from "@hyperframes/sdk";
import { createHttpAdapter } from "@hyperframes/sdk/adapters/http";
import type { Composition } from "@hyperframes/sdk";
import { readStudioFileChangePath } from "../components/editor/manualEdits";
import { isSelfWriteEcho } from "./sdkSelfWriteRegistry";
/**
* Read a project file's content, or undefined on a non-2xx (optional read).
* Replaces the removed SDK http adapter's `read()` — the only thing Studio used
* it for (Studio is the sole writer, so the adapter's write path was dead).
*/
async function readProjectFileOptional(
projectId: string,
path: string,
): Promise<string | undefined> {
// Reject traversal / NUL before building the request URL — `path` is a
// user-influenced composition path (mirrors the guard in timelineEditingHelpers,
// and closes the CodeQL client-side-request-forgery flag). encodeURIComponent
// already confines both values to single segments of this same-origin URL.
if (path.includes("\0") || path.includes("..")) return undefined;
const res = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/files/${encodeURIComponent(path)}?optional=1`,
);
if (!res.ok) return undefined;
const data = (await res.json()) as { content?: string };
return typeof data.content === "string" ? data.content : undefined;
}
/**
* True when an external file-change payload targets the active composition and
* the SDK session must be re-opened to pick up the new content.
@@ -18,8 +39,8 @@ export function shouldReloadSdkSession(payload: unknown, activeCompPath: string
/**
* Stage 7 Step 3a — SDK session wired to the active composition.
*
* Creates an SDK Composition backed by createHttpAdapter on every
* (projectId, activeCompPath) change, disposes the old one on cleanup, and
* Creates an SDK Composition (reading the file via the project files API) on
* every (projectId, activeCompPath) change, disposes the old one on cleanup, and
* re-opens it when the active composition file changes on disk (code editor,
* agent, or server-side patch) so the in-memory linkedom document never goes
* stale. The session has NO persist queue — Studio is the sole file writer; see
@@ -81,10 +102,7 @@ export function useSdkSession(
useEffect(() => {
if (!activeCompPath) return;
const compPath = activeCompPath;
const readAdapter =
projectId != null
? createHttpAdapter({ projectFilesUrl: `/api/projects/${projectId}` })
: null;
const readProjectId = projectId ?? null;
const handler = (payload?: unknown) => {
if (!shouldReloadSdkSession(payload, compPath)) return;
const withinWindow =
@@ -96,13 +114,12 @@ export function useSdkSession(
const payloadContent = readFileChangeContent(payload);
// Prefer payload content; otherwise re-read so the decision is by IDENTITY
// (an undo's reverted bytes won't match a registered self-write → reload).
if (payloadContent != null || !readAdapter) {
if (payloadContent != null || readProjectId == null) {
decide(payloadContent);
return;
}
readAdapter
.read(compPath)
.then((c) => decide(typeof c === "string" ? c : null))
readProjectFileOptional(readProjectId, compPath)
.then((c) => decide(c ?? null))
.catch(() => decide(null));
};
if (import.meta.hot) {
@@ -126,11 +143,7 @@ export function useSdkSession(
let cancelled = false;
const compRef = { current: null as Composition | null };
const adapter = createHttpAdapter({
projectFilesUrl: `/api/projects/${projectId}`,
});
adapter
.read(activeCompPath)
readProjectFileOptional(projectId, activeCompPath)
.then(async (content) => {
if (cancelled || typeof content !== "string") return;
// No persist queue: Studio's writeProjectFile (via sdkCutover's