mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
fix(preview): refresh file snapshots after completed runs (#8744)
Co-authored-by: 罗高 <luogao@192.168.5.196>
This commit is contained in:
co-authored by
罗高
parent
535fa8eab7
commit
aa3a834046
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user