fix(preview): refresh file snapshots after completed runs (#8744)

Co-authored-by: 罗高 <luogao@192.168.5.196>
This commit is contained in:
open-design-crew[bot]
2026-10-15 07:23:05 +00:00
committed by GitHub
co-authored by 罗高
parent 535fa8eab7
commit aa3a834046
4 changed files with 163 additions and 6 deletions
+6 -5
View File
@@ -4570,7 +4570,7 @@ export function ProjectView({
}, []);
const refreshProjectFiles = useCallback(async (
options?: { fresh?: boolean },
options?: { fresh?: boolean; afterChange?: boolean },
onAcceptedGeneration?: (generation: number) => void,
): Promise<ProjectFile[]> => {
const requestSeq = ++projectFilesRequestSeqRef.current;
@@ -4581,6 +4581,7 @@ export function ProjectView({
workspaceContext: projectRunWorkspaceContextRef.current,
requireAuthoritative: true,
...(options?.fresh ? { fresh: true } : {}),
...(options?.afterChange ? { afterChange: true } : {}),
});
} catch {
// A transport/HTTP failure is not an authoritative empty directory.
@@ -7014,7 +7015,7 @@ export function ProjectView({
// A terminal run's artifact paths must resolve against a post-run
// read, not a shared file-list result cached before its last write.
let nextFiles = await refreshProjectFiles({ fresh: true });
let nextFiles = await refreshProjectFiles({ fresh: true, afterChange: true });
const beforeFileNames = new Set(
message.preTurnFileNames ?? nextFiles.map((f) => f.name),
);
@@ -7546,7 +7547,7 @@ export function ProjectView({
const preTurn = message.preTurnFileNames;
// Match live completion: the terminal artifact list can be
// newer than the GET coalescer's last successful file read.
let nextFiles = await refreshProjectFiles({ fresh: true });
let nextFiles = await refreshProjectFiles({ fresh: true, afterChange: true });
let artifactPersistenceSucceeded = false;
let artifactPersistenceError: string | undefined;
// Use the turn-start snapshot when available so reload
@@ -9848,7 +9849,7 @@ export function ProjectView({
// otherwise win the race with the file-change invalidation and
// make this turn persist an empty producedFiles list. Completion
// attribution needs a fresh post-run snapshot.
let nextFiles = await refreshProjectFiles({ fresh: true });
let nextFiles = await refreshProjectFiles({ fresh: true, afterChange: true });
let artifactPersistenceSucceeded = false;
let artifactPersistenceError: string | undefined;
const finalText = streamedText || fullText;
@@ -10305,7 +10306,7 @@ export function ProjectView({
...(authoritativeArtifactPaths ?? []),
];
void (async () => {
const nextFiles = await refreshProjectFiles({ fresh: true });
const nextFiles = await refreshProjectFiles({ fresh: true, afterChange: true });
if (authoritativeArtifactPaths === undefined) return;
const ownedFiles = attributableRunFiles(
nextFiles, manualFileWrites, authoritativeTouchedPaths, project.id, projectDetail.resolvedDir,
+10 -1
View File
@@ -2144,6 +2144,8 @@ export async function fetchProjectFiles(
signal?: AbortSignal;
workspaceContext?: WorkspaceCollabContext | null;
fresh?: boolean;
/** External writes (for example an Agent run ending) obsolete earlier reads. */
afterChange?: boolean;
requireAuthoritative?: boolean;
},
): Promise<ProjectFile[]> {
@@ -2153,6 +2155,11 @@ export async function fetchProjectFiles(
// only when no reader is left awaiting it, so a foreground project read
// can never be killed by an abandoned card scan.
try {
// A semantic write boundary must advance the domain fence even when it
// lands inside the shared reader's identity-broadcast coalescing window.
if (options?.afterChange) {
invalidateProjectFilesCache(projectId, options.workspaceContext);
}
const cacheKey = projectFilesCacheKey(projectId, options?.workspaceContext);
const cacheGeneration = projectFilesCacheGenerations.get(cacheKey) ?? 0;
const get = options?.fresh ? forceSharedCancellableGet : sharedCancellableGet;
@@ -2179,7 +2186,9 @@ export async function fetchProjectFiles(
throw new Error('Project files response was malformed');
}
if ((projectFilesCacheGenerations.get(cacheKey) ?? 0) !== cacheGeneration) {
return fetchProjectFiles(projectId, options);
// Join the newest generation without announcing the same write
// again or evicting a newer boundary's already-authoritative read.
return fetchProjectFiles(projectId, { ...options, afterChange: false, fresh: false });
}
return json.files as ProjectFile[];
},
@@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ProjectView } from '../../src/components/ProjectView';
import { streamViaDaemon, type DaemonStreamOptions } from '../../src/providers/daemon';
import { I18nProvider } from '../../src/i18n';
import { fetchProjectFiles } from '../../src/providers/registry';
import { listMessages, saveMessage } from '../../src/state/projects';
import type { AppConfig, ChatMessage, Project, ProjectFile } from '../../src/types';
@@ -27,6 +28,7 @@ vi.mock('../../src/providers/registry', async () => {
);
return {
...actual,
fetchProjectFiles: vi.fn(actual.fetchProjectFiles),
fetchDesignSystem: vi.fn().mockResolvedValue(null),
fetchProjectDesignSystemPackageAudit: vi.fn().mockResolvedValue(null),
fetchLiveArtifacts: vi.fn().mockResolvedValue([]),
@@ -265,6 +267,7 @@ afterEach(() => {
cleanup();
resolveStream?.();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('ProjectView run-produced file ownership (OPEND-2928)', () => {
@@ -303,6 +306,30 @@ describe('ProjectView run-produced file ownership (OPEND-2928)', () => {
const persistedArtifactHtml = '<!doctype html><html><head><title>Landing</title></head><body><main><h1>Landing page</h1><p>Generated design artifact with enough structure to persist.</p></main></body></html>';
describe('ProjectView post-run file-list observation', () => {
it('attributes and opens an external image write even inside a previous forced-read burst', async () => {
const options = await startRun();
// Only the coalescer's monotonic clock is fixed. React and its deferred
// work retain the real event loop; there is no sleep or timing lottery.
vi.spyOn(performance, 'now').mockReturnValue(100);
await fetchProjectFiles(project.id, { fresh: true });
vi.mocked(fetchProjectFiles).mockClear();
files = [...files, {
name: 'generated.png', path: 'generated.png', kind: 'image',
mime: 'image/png', size: 24, mtime: Date.now(),
}];
await finishRun(options, ['generated.png']);
expect(fetchProjectFiles).toHaveBeenCalledWith(project.id, expect.objectContaining({
afterChange: true, requireAuthoritative: true,
}));
await waitFor(() => expect(screen.getByRole('tab', { name: /generated\.png/i }))
.toHaveAttribute('aria-selected', 'true'));
await expectAttribution(['generated.png']);
});
});
describe('ProjectView producer evidence compatibility (OPEND-2928)', () => {
for (const tool of ['Bash', 'apply_patch'] as const) {
it(`keeps Markdown generated through ${tool} with an empty artifact whitelist`, async () => {
+120
View File
@@ -861,6 +861,126 @@ describe('fetchAppVersionInfo', () => {
});
});
describe('fetchProjectFiles after an external write', () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
const file = (name: string) => ({
name, path: name, kind: 'image', mime: 'image/png', size: 1, mtime: 1,
});
const response = (files: ReturnType<typeof file>[]) => Response.json({ files });
it.each([0, 249])('replaces a settled forced snapshot after a write %ims later', async (elapsed) => {
vi.useFakeTimers();
const before = [file('before.png')];
const after = [...before, file('generated.png')];
const fetchMock = vi.fn<typeof fetch>()
.mockResolvedValueOnce(response(before))
.mockResolvedValueOnce(response(after));
vi.stubGlobal('fetch', fetchMock);
await expect(fetchProjectFiles('post-write-settled', { fresh: true })).resolves.toEqual(before);
await vi.advanceTimersByTimeAsync(elapsed);
await expect(fetchProjectFiles('post-write-settled', {
fresh: true, afterChange: true, requireAuthoritative: true,
})).resolves.toEqual(after);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('fences an older in-flight read and shares the post-write request with ordinary readers', async () => {
vi.useFakeTimers();
const before = [file('before.png')];
const after = [...before, file('generated.png')];
const oldResponse = deferred<Response>();
const newResponse = deferred<Response>();
const fetchMock = vi.fn<typeof fetch>()
.mockImplementationOnce(() => oldResponse.promise)
.mockImplementationOnce(() => newResponse.promise);
vi.stubGlobal('fetch', fetchMock);
const earlier = fetchProjectFiles('post-write-inflight', { fresh: true });
await vi.advanceTimersByTimeAsync(95);
const completion = fetchProjectFiles('post-write-inflight', {
fresh: true, afterChange: true, requireAuthoritative: true,
});
const ordinary = fetchProjectFiles('post-write-inflight');
newResponse.resolve(response(after));
oldResponse.resolve(response(before));
await expect(Promise.all([earlier, completion, ordinary])).resolves.toEqual([after, after, after]);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('retries an overtaken write-boundary read without declaring another write', async () => {
vi.useFakeTimers();
const firstFiles = [file('first.png')];
const latestFiles = [...firstFiles, file('second.png')];
const firstResponse = deferred<Response>();
const latestResponse = deferred<Response>();
const fetchMock = vi.fn<typeof fetch>()
.mockImplementationOnce(() => firstResponse.promise)
.mockImplementationOnce(() => latestResponse.promise)
// A retry that mistakenly declares another write still settles, but its
// extra network request is rejected by the count below.
.mockImplementation(async () => response(latestFiles));
vi.stubGlobal('fetch', fetchMock);
const first = fetchProjectFiles('post-write-overtaken', { fresh: true, afterChange: true });
await vi.advanceTimersByTimeAsync(1);
const second = fetchProjectFiles('post-write-overtaken', { fresh: true, afterChange: true });
latestResponse.resolve(response(latestFiles));
await Promise.resolve();
firstResponse.resolve(response(firstFiles));
await expect(Promise.all([first, second])).resolves.toEqual([latestFiles, latestFiles]);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('preserves ordinary and broadcast-forced concurrent read coalescing', async () => {
vi.useFakeTimers();
const files = [file('shared.png')];
const gate = deferred<Response>();
const fetchMock = vi.fn<typeof fetch>(() => gate.promise);
vi.stubGlobal('fetch', fetchMock);
const readers = [
fetchProjectFiles('post-write-burst', { fresh: true }),
fetchProjectFiles('post-write-burst', { fresh: true }),
fetchProjectFiles('post-write-burst'),
];
gate.resolve(response(files));
await expect(Promise.all(readers)).resolves.toEqual([files, files, files]);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('invalidates only the written project and Workspace authority', async () => {
vi.useFakeTimers();
const workspaceA = personalWorkspaceContext();
const workspaceB = { ...workspaceA, workspaceId: 'ws-other', workspaceMemberId: 'wm-other' };
const before = [file('before.png')];
const after = [...before, file('generated.png')];
const fetchMock = vi.fn<typeof fetch>()
.mockResolvedValueOnce(response(before))
.mockResolvedValueOnce(response(before))
.mockResolvedValueOnce(response(before))
.mockResolvedValueOnce(response(after));
vi.stubGlobal('fetch', fetchMock);
await fetchProjectFiles('post-write-scoped', { workspaceContext: workspaceA, fresh: true });
await fetchProjectFiles('post-write-scoped', { workspaceContext: workspaceB, fresh: true });
await fetchProjectFiles('post-write-other-project', { workspaceContext: workspaceA, fresh: true });
await expect(fetchProjectFiles('post-write-scoped', {
workspaceContext: workspaceA, fresh: true, afterChange: true,
})).resolves.toEqual(after);
await expect(fetchProjectFiles('post-write-scoped', { workspaceContext: workspaceB })).resolves.toEqual(before);
await expect(fetchProjectFiles('post-write-other-project', { workspaceContext: workspaceA })).resolves.toEqual(before);
expect(fetchMock).toHaveBeenCalledTimes(4);
});
});
describe('fetchProjectFiles', () => {
afterEach(() => {
vi.restoreAllMocks();