Files
hyperframes/packages/studio/src/hooks/useExternalFileChangeCoordinator.ts
T

430 lines
15 KiB
TypeScript

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 };
}