修复确认生成后的任务列表对账
问题:客户端确认 Quote 后只刷新一次任务,遇到短暂投影延迟时任务列表为空且流程静默成功。 修复:按 quoteId 有界重试任务列表,合并 REST 与实时推送快照,缺任务时显示明确错误;补充 Store 与页面回归测试。
This commit is contained in:
@@ -580,6 +580,9 @@ function asErrorDetail(payload: unknown): ServerErrorDetail {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function userFacingErrorMessage(code: string, fallback: string): string {
|
function userFacingErrorMessage(code: string, fallback: string): string {
|
||||||
|
if (code === 'generation_task_not_created') {
|
||||||
|
return '生成方案已确认,但任务创建失败,请刷新后重试';
|
||||||
|
}
|
||||||
const messages: Record<string, string> = {
|
const messages: Record<string, string> = {
|
||||||
workspace_not_found: '设计项目不存在或无权访问',
|
workspace_not_found: '设计项目不存在或无权访问',
|
||||||
workspace_revision_conflict: '设计项目已更新,请刷新后重试',
|
workspace_revision_conflict: '设计项目已更新,请刷新后重试',
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ type ImageWorkspaceState = {
|
|||||||
|
|
||||||
let inFlightLoad: Promise<DesignWorkspaceBootstrap | null> | null = null;
|
let inFlightLoad: Promise<DesignWorkspaceBootstrap | null> | null = null;
|
||||||
const TASK_FALLBACK_POLL_INTERVAL_MS = 15_000;
|
const TASK_FALLBACK_POLL_INTERVAL_MS = 15_000;
|
||||||
|
const CONFIRMED_TASK_RECONCILE_ATTEMPTS = 5;
|
||||||
|
const CONFIRMED_TASK_RECONCILE_INTERVAL_MS = 250;
|
||||||
let activeTaskEventSource: EventSource | null = null;
|
let activeTaskEventSource: EventSource | null = null;
|
||||||
let activeTaskEventWorkspaceId: string | null = null;
|
let activeTaskEventWorkspaceId: string | null = null;
|
||||||
let taskStreamGeneration = 0;
|
let taskStreamGeneration = 0;
|
||||||
@@ -170,6 +172,18 @@ function sortTasks(tasks: DesignGenerationTask[]): DesignGenerationTask[] {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeTasks(
|
||||||
|
current: DesignGenerationTask[],
|
||||||
|
incoming: DesignGenerationTask[],
|
||||||
|
): DesignGenerationTask[] {
|
||||||
|
const tasksById = new Map(current.map((task) => [task.taskId, task]));
|
||||||
|
for (const task of incoming) {
|
||||||
|
const existing = tasksById.get(task.taskId);
|
||||||
|
if (!existing || existing.updatedAt <= task.updatedAt) tasksById.set(task.taskId, task);
|
||||||
|
}
|
||||||
|
return sortTasks([...tasksById.values()]);
|
||||||
|
}
|
||||||
|
|
||||||
function unavailable(error: unknown): boolean {
|
function unavailable(error: unknown): boolean {
|
||||||
return error instanceof ImageWorkspaceApiError
|
return error instanceof ImageWorkspaceApiError
|
||||||
&& (error.status === 501 || error.code === IMAGE_WORKSPACE_UNAVAILABLE_CODE);
|
&& (error.status === 501 || error.code === IMAGE_WORKSPACE_UNAVAILABLE_CODE);
|
||||||
@@ -598,7 +612,7 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
|||||||
if (get().activeWorkspaceId === workspaceId
|
if (get().activeWorkspaceId === workspaceId
|
||||||
&& currentWorkspace?.workspaceId === workspaceId
|
&& currentWorkspace?.workspaceId === workspaceId
|
||||||
&& currentWorkspace.viewRevision === requestedViewRevision) {
|
&& currentWorkspace.viewRevision === requestedViewRevision) {
|
||||||
set({ tasks: sortTasks(tasks) });
|
set((state) => ({ tasks: mergeTasks(state.tasks, tasks) }));
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
const key = taskRevisionKey(workspaceId, task.taskId);
|
const key = taskRevisionKey(workspaceId, task.taskId);
|
||||||
taskEventRevisions.set(
|
taskEventRevisions.set(
|
||||||
@@ -669,7 +683,30 @@ export const useImageWorkspaceStore = create<ImageWorkspaceState>((set, get) =>
|
|||||||
);
|
);
|
||||||
if (get().activeWorkspaceId === workspace.workspaceId) {
|
if (get().activeWorkspaceId === workspace.workspaceId) {
|
||||||
applyWorkspace(updated);
|
applyWorkspace(updated);
|
||||||
await get().refreshTasks().catch(() => []);
|
let confirmedTaskVisible = get().tasks.some((task) => task.quoteId === quoteId);
|
||||||
|
for (
|
||||||
|
let attempt = 0;
|
||||||
|
!confirmedTaskVisible && attempt < CONFIRMED_TASK_RECONCILE_ATTEMPTS;
|
||||||
|
attempt += 1
|
||||||
|
) {
|
||||||
|
if (get().activeWorkspaceId !== workspace.workspaceId) return updated;
|
||||||
|
const refreshed = await get().refreshTasks();
|
||||||
|
if (get().activeWorkspaceId !== workspace.workspaceId) return updated;
|
||||||
|
confirmedTaskVisible = refreshed.some((task) => task.quoteId === quoteId)
|
||||||
|
|| get().tasks.some((task) => task.quoteId === quoteId);
|
||||||
|
if (!confirmedTaskVisible && attempt + 1 < CONFIRMED_TASK_RECONCILE_ATTEMPTS) {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
setTimeout(resolve, CONFIRMED_TASK_RECONCILE_INTERVAL_MS);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!confirmedTaskVisible) {
|
||||||
|
throw new ImageWorkspaceApiError(
|
||||||
|
502,
|
||||||
|
'generation_task_not_visible',
|
||||||
|
'生成已确认,但任务列表同步超时。请刷新项目查看,不要重复确认。',
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return updated;
|
return updated;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -405,6 +405,7 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
|||||||
.mockResolvedValueOnce([{
|
.mockResolvedValueOnce([{
|
||||||
...taskFixture,
|
...taskFixture,
|
||||||
taskId: 'task-two',
|
taskId: 'task-two',
|
||||||
|
quoteId: 'quote-one',
|
||||||
status: 'queued',
|
status: 'queued',
|
||||||
resultAssets: [],
|
resultAssets: [],
|
||||||
}]);
|
}]);
|
||||||
@@ -421,6 +422,7 @@ describe('ImageCanvas Workspace-first design experience', () => {
|
|||||||
expect.stringMatching(/^turn-/),
|
expect.stringMatching(/^turn-/),
|
||||||
));
|
));
|
||||||
await waitFor(() => expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(2));
|
await waitFor(() => expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(2));
|
||||||
|
expect(await screen.findByTestId('design-task-task-two')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders a new task pushed by the design event stream without repeated polling', async () => {
|
it('renders a new task pushed by the design event stream without repeated polling', async () => {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const fetchImageWorkspaceProjectMock = vi.hoisted(() => vi.fn());
|
|||||||
const fetchImageWorkspaceTasksMock = vi.hoisted(() => vi.fn());
|
const fetchImageWorkspaceTasksMock = vi.hoisted(() => vi.fn());
|
||||||
const openImageWorkspaceTaskEventsMock = vi.hoisted(() => vi.fn());
|
const openImageWorkspaceTaskEventsMock = vi.hoisted(() => vi.fn());
|
||||||
const sendImageWorkspaceMessageMock = vi.hoisted(() => vi.fn());
|
const sendImageWorkspaceMessageMock = vi.hoisted(() => vi.fn());
|
||||||
|
const confirmImageWorkspaceGenerationMock = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
vi.mock('@/lib/image-workspace', async (importOriginal) => {
|
vi.mock('@/lib/image-workspace', async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import('@/lib/image-workspace')>();
|
const actual = await importOriginal<typeof import('@/lib/image-workspace')>();
|
||||||
@@ -24,6 +25,9 @@ vi.mock('@/lib/image-workspace', async (importOriginal) => {
|
|||||||
fetchImageWorkspaceTasks: (...args: unknown[]) => fetchImageWorkspaceTasksMock(...args),
|
fetchImageWorkspaceTasks: (...args: unknown[]) => fetchImageWorkspaceTasksMock(...args),
|
||||||
openImageWorkspaceTaskEvents: (...args: unknown[]) => openImageWorkspaceTaskEventsMock(...args),
|
openImageWorkspaceTaskEvents: (...args: unknown[]) => openImageWorkspaceTaskEventsMock(...args),
|
||||||
sendImageWorkspaceMessage: (...args: unknown[]) => sendImageWorkspaceMessageMock(...args),
|
sendImageWorkspaceMessage: (...args: unknown[]) => sendImageWorkspaceMessageMock(...args),
|
||||||
|
confirmImageWorkspaceGeneration: (...args: unknown[]) => (
|
||||||
|
confirmImageWorkspaceGenerationMock(...args)
|
||||||
|
),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -144,6 +148,10 @@ describe('AI design task event store', () => {
|
|||||||
fetchImageWorkspaceProjectMock.mockResolvedValue(workspace());
|
fetchImageWorkspaceProjectMock.mockResolvedValue(workspace());
|
||||||
fetchImageWorkspaceTasksMock.mockResolvedValue([task]);
|
fetchImageWorkspaceTasksMock.mockResolvedValue([task]);
|
||||||
sendImageWorkspaceMessageMock.mockResolvedValue(workspace());
|
sendImageWorkspaceMessageMock.mockResolvedValue(workspace());
|
||||||
|
confirmImageWorkspaceGenerationMock.mockResolvedValue({
|
||||||
|
...workspace('workspace-one', 2),
|
||||||
|
turnRevision: 2,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -236,6 +244,46 @@ describe('AI design task event store', () => {
|
|||||||
await sending;
|
await sending;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reconciles the confirmed Quote until its generation task is visible', async () => {
|
||||||
|
const source = new MockEventSource();
|
||||||
|
openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource);
|
||||||
|
fetchImageWorkspaceTasksMock
|
||||||
|
.mockResolvedValueOnce([])
|
||||||
|
.mockResolvedValueOnce([])
|
||||||
|
.mockResolvedValueOnce([task]);
|
||||||
|
|
||||||
|
await useImageWorkspaceStore.getState().load();
|
||||||
|
await useImageWorkspaceStore.getState().confirmGeneration('quote-one');
|
||||||
|
|
||||||
|
expect(confirmImageWorkspaceGenerationMock).toHaveBeenCalledWith(
|
||||||
|
'workspace-one',
|
||||||
|
1,
|
||||||
|
'quote-one',
|
||||||
|
expect.stringMatching(/^turn-/),
|
||||||
|
);
|
||||||
|
expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(3);
|
||||||
|
expect(useImageWorkspaceStore.getState().tasks).toEqual([task]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not silently complete confirmation when the quoted task stays missing', async () => {
|
||||||
|
const source = new MockEventSource();
|
||||||
|
openImageWorkspaceTaskEventsMock.mockResolvedValue(source as unknown as EventSource);
|
||||||
|
fetchImageWorkspaceTasksMock.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await useImageWorkspaceStore.getState().load();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const outcome = useImageWorkspaceStore.getState().confirmGeneration('quote-one').then(
|
||||||
|
() => null,
|
||||||
|
(error: unknown) => error,
|
||||||
|
);
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
await expect(outcome).resolves.toMatchObject({ code: 'generation_task_not_visible' });
|
||||||
|
expect(fetchImageWorkspaceTasksMock).toHaveBeenCalledTimes(6);
|
||||||
|
expect(useImageWorkspaceStore.getState().error)
|
||||||
|
.toContain('任务列表同步超时');
|
||||||
|
});
|
||||||
|
|
||||||
it('closes the previous stream when switching workspaces and on reset', async () => {
|
it('closes the previous stream when switching workspaces and on reset', async () => {
|
||||||
const first = new MockEventSource();
|
const first = new MockEventSource();
|
||||||
const second = new MockEventSource();
|
const second = new MockEventSource();
|
||||||
|
|||||||
Reference in New Issue
Block a user