feat(studio): coordinate external file changes (#2991)

This commit is contained in:
Miguel Ángel
2026-08-04 22:20:44 +00:00
committed by GitHub
parent b30a23402e
commit a99caad581
2 changed files with 650 additions and 0 deletions
@@ -0,0 +1,221 @@
// @vitest-environment happy-dom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
import { markStudioWriteToken, resetStudioWriteTokens } from "../utils/studioFileVersion";
import {
useExternalFileChangeCoordinator,
type ExternalFileChangeCoordinatorHandle,
} from "./useExternalFileChangeCoordinator";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
type HotHandler = (payload?: unknown) => void;
type CoordinatorOptions = Parameters<typeof useExternalFileChangeCoordinator>[0];
const roots: Array<ReturnType<typeof createRoot>> = [];
let handler: HotHandler | null;
async function mountCoordinator(overrides: Partial<CoordinatorOptions> = {}) {
const captured: { handle: ExternalFileChangeCoordinatorHandle | null } = { handle: null };
const defaults: CoordinatorOptions = {
projectId: "project-a",
activeCompPath: "index.html",
pendingTimelineEditPathRef: { current: new Set() },
drainPendingChanges: vi.fn(async () => ({ status: "clean" as const })),
reloadPreview: vi.fn(),
reloadSdkSession: vi.fn(),
persistConflictSnapshot: vi.fn(async () => undefined),
discardPendingChanges: vi.fn(),
overwriteConflict: vi.fn(async () => undefined),
readProjectFile: vi.fn(async () => "external"),
};
const options = { ...defaults, ...overrides };
const root = createRoot(document.createElement("div"));
roots.push(root);
function Probe() {
captured.handle = useExternalFileChangeCoordinator(options);
return null;
}
await act(async () => root.render(<Probe />));
return { captured, options };
}
describe("external file change coordinator", () => {
beforeEach(() => {
handler = null;
resetStudioWriteTokens();
vi.stubGlobal("__HF_STUDIO_HOT_TEST_ADAPTER__", {
on: (_event: string, next: HotHandler) => {
handler = next;
},
off: () => {
handler = null;
},
});
});
afterEach(async () => {
while (roots.length > 0) await act(async () => roots.pop()?.unmount());
vi.unstubAllGlobals();
});
it("drains before reloading Preview and SDK exactly once", async () => {
const order: string[] = [];
const { captured } = await mountCoordinator({
drainPendingChanges: async () => {
order.push("drain");
return { status: "clean" };
},
reloadPreview: () => order.push("preview"),
reloadSdkSession: () => order.push("sdk"),
});
await act(async () => handler?.({ path: "index.html", content: "external", version: "v2" }));
expect(order).toEqual(["drain", "preview", "sdk"]);
expect(captured.handle?.blocked).toBeNull();
});
it("suppresses an exact Studio write receipt", async () => {
const drainPendingChanges = vi.fn(async () => ({ status: "clean" as const }));
const reloadPreview = vi.fn();
const reloadSdkSession = vi.fn();
await mountCoordinator({ drainPendingChanges, reloadPreview, reloadSdkSession });
markStudioWriteToken("studio-write-1");
await act(async () =>
handler?.({ path: "index.html", content: "studio", writeToken: "studio-write-1" }),
);
expect(drainPendingChanges).not.toHaveBeenCalled();
expect(reloadPreview).not.toHaveBeenCalled();
expect(reloadSdkSession).not.toHaveBeenCalled();
});
it("does not suppress a racing external write by path alone", async () => {
const pendingTimelineEditPathRef = { current: new Set(["index.html"]) };
const drainPendingChanges = vi.fn(async () => ({ status: "clean" as const }));
const reloadPreview = vi.fn();
const reloadSdkSession = vi.fn();
await mountCoordinator({
pendingTimelineEditPathRef,
drainPendingChanges,
reloadPreview,
reloadSdkSession,
});
await act(async () => handler?.({ path: "index.html", content: "agent edit", version: "v2" }));
expect(pendingTimelineEditPathRef.current).not.toContain("index.html");
expect(drainPendingChanges).toHaveBeenCalledOnce();
expect(reloadPreview).toHaveBeenCalledOnce();
expect(reloadSdkSession).toHaveBeenCalledOnce();
});
it("blocks both reloads and retains a complete conflict", async () => {
const conflict = new StudioFileConflictError({
filePath: "index.html",
currentVersion: "v2",
currentContent: "external",
attemptedContent: "studio",
});
const persistConflictSnapshot = vi.fn(async () => undefined);
const { captured, options } = await mountCoordinator({
drainPendingChanges: async () => ({ status: "conflict", error: conflict }),
persistConflictSnapshot,
});
await act(async () => handler?.({ path: "index.html", content: "external", version: "v2" }));
expect(persistConflictSnapshot).toHaveBeenCalledWith("project-a", conflict);
expect(captured.handle?.blocked).toMatchObject({ status: "conflict", error: conflict });
expect(options.reloadPreview).not.toHaveBeenCalled();
expect(options.reloadSdkSession).not.toHaveBeenCalled();
});
it("ignores stale drain completion after a newer generation", async () => {
const drains: Array<(result: { status: "clean" }) => void> = [];
const { options } = await mountCoordinator({
drainPendingChanges: () => new Promise((resolve) => drains.push(resolve)),
});
act(() => {
handler?.({ path: "index.html", content: "first", version: "v2" });
handler?.({ path: "index.html", content: "second", version: "v3" });
});
await act(async () => drains[0]?.({ status: "clean" }));
expect(options.reloadPreview).not.toHaveBeenCalled();
await act(async () => drains[1]?.({ status: "clean" }));
expect(options.reloadPreview).toHaveBeenCalledOnce();
expect(options.reloadSdkSession).toHaveBeenCalledOnce();
});
it("restores a durable unresolved conflict after remount", async () => {
const { captured } = await mountCoordinator({
recoveryFilePath: "index.html",
loadConflictSnapshot: vi.fn(async () => ({
kind: "conflict" as const,
projectId: "project-a",
filePath: "index.html",
externalVersion: "v2",
externalContent: "external",
studioContent: "studio",
createdAt: 100,
})),
});
await vi.waitFor(() => expect(captured.handle?.blocked?.status).toBe("conflict"));
expect(captured.handle?.blocked).toMatchObject({
error: { currentContent: "external", attemptedContent: "studio" },
});
});
it("retains the final local candidate when a drain fails", async () => {
const failure = new Error("network unavailable");
const persistFailureSnapshot = vi.fn(async () => undefined);
const deleteConflictSnapshot = vi.fn(async () => undefined);
const { captured } = await mountCoordinator({
drainPendingChanges: vi
.fn()
.mockResolvedValueOnce({ status: "failed" as const, error: failure })
.mockResolvedValueOnce({ status: "clean" as const }),
getPendingCandidate: () => ({ path: "index.html", content: "final local candidate" }),
persistFailureSnapshot,
deleteConflictSnapshot,
});
await act(async () => handler?.({ path: "index.html" }));
expect(captured.handle?.blocked).toMatchObject({
status: "failed",
error: failure,
studioContent: "final local candidate",
});
expect(persistFailureSnapshot).toHaveBeenCalledWith(
"project-a",
"index.html",
"final local candidate",
null,
null,
failure,
);
await act(async () => captured.handle?.retry());
expect(deleteConflictSnapshot).toHaveBeenCalledWith("project-a", "index.html");
});
it("restores and overwrites from a durable failed draft", async () => {
const overwriteConflict = vi.fn(async () => undefined);
const { captured } = await mountCoordinator({
recoveryFilePath: "index.html",
overwriteConflict,
loadConflictSnapshot: vi.fn(async () => ({
kind: "failed" as const,
projectId: "project-a",
filePath: "index.html",
externalVersion: "v2",
externalContent: "external",
studioContent: "recover me",
failureMessage: "network unavailable",
createdAt: 100,
})),
});
await vi.waitFor(() => expect(captured.handle?.blocked?.status).toBe("failed"));
expect(captured.handle?.blocked).toMatchObject({
studioContent: "recover me",
recovered: true,
});
await act(async () => captured.handle?.keepStudioFile());
expect(overwriteConflict).toHaveBeenCalledWith(
expect.objectContaining({ attemptedContent: "recover me", currentVersion: "v2" }),
);
});
});
@@ -0,0 +1,429 @@
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react";
import { readStudioFileChangePath } from "../components/editor/manualEdits";
import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
import type { ExternalConflictSnapshot } from "../utils/externalConflictStorage";
import { isSelfWriteEcho } from "./sdkSelfWriteRegistry";
import { consumeStudioWriteToken } from "../utils/studioFileVersion";
type ExternalChangeDrainResult =
| { status: "clean" }
| { status: "conflict"; error: StudioFileConflictError }
| { status: "failed"; error: unknown };
export type ExternalFileChangeBlockedState =
| {
status: "conflict";
generation: number;
error: StudioFileConflictError;
payload: unknown;
}
| {
status: "failed";
generation: number;
path: string;
error: unknown;
payload: unknown;
studioContent: string | null;
recovered: boolean;
};
interface ExternalFileChangeCoordinatorOptions {
projectId: string | null;
activeCompPath: string | null;
recoveryFilePath?: string | null;
pendingTimelineEditPathRef: MutableRefObject<Set<string>>;
drainPendingChanges: () => Promise<ExternalChangeDrainResult>;
getPendingCandidate?: () => { path: string; content: string } | null;
discardPendingChanges: () => void;
reloadPreview: () => void;
reloadSdkSession: (path: string) => void;
persistConflictSnapshot: (projectId: string, conflict: StudioFileConflictError) => Promise<void>;
persistFailureSnapshot?: (
projectId: string,
filePath: string,
studioContent: string,
externalVersion: string | null,
externalContent: string | null,
error: unknown,
) => Promise<void>;
loadConflictSnapshot?: (
projectId: string,
filePath: string,
) => Promise<ExternalConflictSnapshot | null>;
deleteConflictSnapshot?: (projectId: string, filePath: string) => Promise<void>;
overwriteConflict: (conflict: StudioFileConflictError) => Promise<void>;
readProjectFile: (path: string) => Promise<string>;
onUseExternalFile?: (path: string, content: string) => void;
resetSaveQueues?: () => void;
}
export interface ExternalFileChangeCoordinatorHandle {
blocked: ExternalFileChangeBlockedState | null;
retry: () => Promise<void>;
useExternalFile: () => Promise<void>;
keepStudioFile: () => Promise<void>;
}
interface HotTestAdapter {
on(event: string, handler: (payload?: unknown) => void): void;
off(event: string, handler: (payload?: unknown) => void): void;
}
function testHotAdapter(): HotTestAdapter | null {
const value = (globalThis as { __HF_STUDIO_HOT_TEST_ADAPTER__?: unknown })
.__HF_STUDIO_HOT_TEST_ADAPTER__;
if (!value || typeof value !== "object") return null;
const candidate = value as Partial<HotTestAdapter>;
return typeof candidate.on === "function" && typeof candidate.off === "function"
? (candidate as HotTestAdapter)
: null;
}
function readFileChangeContent(payload: unknown): string | null {
if (!payload || typeof payload !== "object") return null;
const record = payload as Record<string, unknown>;
if (typeof record.content === "string") return record.content;
return "data" in record ? readFileChangeContent(record.data) : null;
}
function readFileChangeVersion(payload: unknown): string | null {
if (!payload || typeof payload !== "object") return null;
const record = payload as Record<string, unknown>;
if (typeof record.version === "string") return record.version;
return "data" in record ? readFileChangeVersion(record.data) : null;
}
function readFileChangeWriteToken(payload: unknown): string | null {
if (!payload || typeof payload !== "object") return null;
const record = payload as Record<string, unknown>;
if (typeof record.writeToken === "string") return record.writeToken;
return "data" in record ? readFileChangeWriteToken(record.data) : null;
}
function eventIdentity(path: string, payload: unknown): string | null {
const version = readFileChangeVersion(payload);
if (version) return `${path}\0${version}`;
const content = readFileChangeContent(payload);
return content == null ? null : `${path}\0${content.length}\0${content}`;
}
export function useExternalFileChangeCoordinator({
projectId,
activeCompPath,
recoveryFilePath = activeCompPath,
pendingTimelineEditPathRef,
drainPendingChanges,
getPendingCandidate,
discardPendingChanges,
reloadPreview,
reloadSdkSession,
persistConflictSnapshot,
persistFailureSnapshot,
loadConflictSnapshot,
deleteConflictSnapshot,
overwriteConflict,
readProjectFile,
onUseExternalFile,
resetSaveQueues,
}: ExternalFileChangeCoordinatorOptions): ExternalFileChangeCoordinatorHandle {
const [blocked, setBlocked] = useState<ExternalFileChangeBlockedState | null>(null);
const generationRef = useRef(0);
const mountedRef = useRef(true);
const lastEventIdentityRef = useRef<string | null>(null);
const blockedRef = useRef(blocked);
const snapshotWriteTailRef = useRef<Promise<void>>(Promise.resolve());
blockedRef.current = blocked;
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
generationRef.current += 1;
};
}, []);
useEffect(() => {
generationRef.current += 1;
setBlocked(null);
lastEventIdentityRef.current = null;
}, [projectId, activeCompPath]);
useEffect(() => {
if (!projectId || !recoveryFilePath || !loadConflictSnapshot) return;
const generation = ++generationRef.current;
let cancelled = false;
void loadConflictSnapshot(projectId, recoveryFilePath)
.then((snapshot) => {
if (cancelled || !snapshot || !mountedRef.current || generation !== generationRef.current) {
return;
}
const payload = {
path: snapshot.filePath,
version: snapshot.externalVersion,
content: snapshot.externalContent,
};
if (snapshot.kind === "failed") {
setBlocked({
status: "failed",
generation,
path: snapshot.filePath,
error: new Error(snapshot.failureMessage),
payload,
studioContent: snapshot.studioContent,
recovered: true,
});
} else {
const error = new StudioFileConflictError({
filePath: snapshot.filePath,
currentVersion: snapshot.externalVersion,
currentContent: snapshot.externalContent,
attemptedContent: snapshot.studioContent,
});
setBlocked({ status: "conflict", generation, error, payload });
}
})
.catch(() => {
// Storage may be unavailable in restricted browser contexts. A failed
// best-effort restore must not create an unhandled rejection or block
// a project that has no known recovery record.
});
return () => {
cancelled = true;
};
}, [loadConflictSnapshot, projectId, recoveryFilePath]);
const reloadAcceptedGeneration = useCallback(
(path: string) => {
reloadPreview();
reloadSdkSession(path);
},
[reloadPreview, reloadSdkSession],
);
const persistSnapshotInOrder = useCallback(async (write: () => Promise<void>) => {
const next = snapshotWriteTailRef.current.catch(() => undefined).then(write);
snapshotWriteTailRef.current = next.then(
() => undefined,
() => undefined,
);
await next;
}, []);
const processChange = useCallback(
// fallow-ignore-next-line complexity
async (payload: unknown, allowDuplicate = false) => {
const path = readStudioFileChangePath(payload);
if (!path || !projectId) return;
const pendingTimelinePaths = pendingTimelineEditPathRef.current;
// The old path-only suppression could drop a real agent/user write that
// raced ahead of the timeline write receipt. Clear the legacy marker but
// decide ownership only from the exact write token/content below.
pendingTimelinePaths.delete(path);
const content = readFileChangeContent(payload);
if (consumeStudioWriteToken(readFileChangeWriteToken(payload))) return;
if (content != null && isSelfWriteEcho(path, content)) return;
const identity = eventIdentity(path, payload);
if (!allowDuplicate && identity != null && identity === lastEventIdentityRef.current) return;
lastEventIdentityRef.current = identity;
const generation = ++generationRef.current;
const result = await drainPendingChanges();
if (!mountedRef.current || generation !== generationRef.current) return;
if (result.status === "clean") {
const previousBlocked = blockedRef.current;
if (previousBlocked?.status === "failed" && deleteConflictSnapshot) {
try {
await deleteConflictSnapshot(projectId, path);
} catch (error) {
if (mountedRef.current && generation === generationRef.current) {
setBlocked({ ...previousBlocked, generation, error });
}
return;
}
}
if (!mountedRef.current || generation !== generationRef.current) return;
setBlocked(null);
reloadAcceptedGeneration(path);
return;
}
if (result.status === "failed") {
const candidate = getPendingCandidate?.();
const studioContent = candidate?.path === path ? candidate.content : null;
let error = result.error;
if (studioContent != null && persistFailureSnapshot) {
try {
await persistSnapshotInOrder(() =>
persistFailureSnapshot(
projectId,
path,
studioContent,
readFileChangeVersion(payload),
content,
result.error,
),
);
} catch (snapshotError) {
error = new Error(
`Studio could not save the edit or its recovery snapshot: ${
snapshotError instanceof Error ? snapshotError.message : String(snapshotError)
}`,
{ cause: result.error },
);
}
}
if (!mountedRef.current || generation !== generationRef.current) return;
setBlocked({
status: "failed",
generation,
path,
error,
payload,
studioContent,
recovered: false,
});
return;
}
try {
await persistSnapshotInOrder(() => persistConflictSnapshot(projectId, result.error));
} catch (error) {
if (!mountedRef.current || generation !== generationRef.current) return;
setBlocked({
status: "failed",
generation,
path,
error,
payload,
studioContent: result.error.attemptedContent,
recovered: false,
});
return;
}
if (!mountedRef.current || generation !== generationRef.current) return;
setBlocked({ status: "conflict", generation, error: result.error, payload });
},
[
projectId,
pendingTimelineEditPathRef,
drainPendingChanges,
deleteConflictSnapshot,
getPendingCandidate,
persistConflictSnapshot,
persistFailureSnapshot,
persistSnapshotInOrder,
reloadAcceptedGeneration,
],
);
useEffect(() => {
const handler = (payload?: unknown) => processChange(payload);
const adapter = testHotAdapter();
if (adapter) {
adapter.on("hf:file-change", handler);
return () => adapter.off("hf:file-change", handler);
}
if (import.meta.hot) {
import.meta.hot.on("hf:file-change", handler);
return () => import.meta.hot?.off?.("hf:file-change", handler);
}
const eventSource = new EventSource("/api/events");
eventSource.addEventListener("file-change", handler);
return () => eventSource.close();
}, [processChange]);
const retry = useCallback(async () => {
const current = blockedRef.current;
if (!current || current.status === "conflict" || current.recovered) return;
resetSaveQueues?.();
lastEventIdentityRef.current = null;
await processChange(current.payload, true);
}, [processChange, resetSaveQueues]);
const useExternalFile = useCallback(
// fallow-ignore-next-line complexity
async () => {
const current = blockedRef.current;
if (!current || !projectId || current.generation !== generationRef.current) return;
const path = current.status === "conflict" ? current.error.filePath : current.path;
const external =
current.status === "conflict" && current.error.currentContent != null
? current.error.currentContent
: await readProjectFile(path);
if (current.generation !== generationRef.current) return;
discardPendingChanges();
resetSaveQueues?.();
onUseExternalFile?.(path, external);
await deleteConflictSnapshot?.(projectId, path);
setBlocked(null);
reloadAcceptedGeneration(path);
},
[
deleteConflictSnapshot,
discardPendingChanges,
onUseExternalFile,
projectId,
readProjectFile,
reloadAcceptedGeneration,
resetSaveQueues,
],
);
// fallow-ignore-next-line complexity
const keepStudioFile = useCallback(async () => {
const current = blockedRef.current;
if (!current || !projectId) return;
if (current.generation !== generationRef.current) return;
let conflict: StudioFileConflictError;
if (current.status === "conflict") {
conflict = current.error;
} else {
if (!current.recovered || current.studioContent == null) return;
try {
const currentContent =
readFileChangeContent(current.payload) ?? (await readProjectFile(current.path));
conflict = new StudioFileConflictError({
filePath: current.path,
currentVersion: readFileChangeVersion(current.payload),
currentContent,
attemptedContent: current.studioContent,
});
} catch (error) {
if (current.generation === generationRef.current) setBlocked({ ...current, error });
return;
}
}
try {
await overwriteConflict(conflict);
} catch (error) {
if (current.generation === generationRef.current) {
setBlocked({
status: "failed",
generation: current.generation,
path: conflict.filePath,
error,
payload: current.payload,
studioContent: conflict.attemptedContent,
recovered: current.status === "failed" && current.recovered,
});
}
return;
}
if (current.generation !== generationRef.current) return;
discardPendingChanges();
resetSaveQueues?.();
await deleteConflictSnapshot?.(projectId, conflict.filePath);
setBlocked(null);
reloadAcceptedGeneration(conflict.filePath);
}, [
deleteConflictSnapshot,
discardPendingChanges,
overwriteConflict,
projectId,
readProjectFile,
reloadAcceptedGeneration,
resetSaveQueues,
]);
return { blocked, retry, useExternalFile, keepStudioFile };
}