mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 23:00:03 +00:00
feat(studio): coordinate external file changes (#2991)
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user