修复 AGC 异步操作恢复闭环
认证响应体读取加入可取消超时并释放 refresh singleflight 将 Runner 会话安装与清除移到 blocking worker 并增加 UI fence 让最近项目逐项检查且单目录超时不阻塞其它项目 将首页自动创建锁提升到 WorkspaceLauncher 生命周期 补充定向测试、跨页创建测试和异步闭环规范
This commit is contained in:
@@ -1945,28 +1945,36 @@ pub(crate) fn read_platform_account_session_generation() -> u64 {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn install_platform_account_session(
|
||||
pub(crate) async fn install_platform_account_session(
|
||||
user_id: String,
|
||||
access_token: String,
|
||||
api_base_url: String,
|
||||
generation: u64,
|
||||
) -> Result<(), String> {
|
||||
validate_platform_session_input(&user_id, &access_token, &api_base_url, generation)?;
|
||||
install_external_agent_runner_platform_session(
|
||||
&user_id,
|
||||
&access_token,
|
||||
&api_base_url,
|
||||
generation,
|
||||
)?;
|
||||
install_platform_session(&user_id, &access_token, &api_base_url, generation)
|
||||
tokio::task::spawn_blocking(move || {
|
||||
validate_platform_session_input(&user_id, &access_token, &api_base_url, generation)?;
|
||||
install_external_agent_runner_platform_session(
|
||||
&user_id,
|
||||
&access_token,
|
||||
&api_base_url,
|
||||
generation,
|
||||
)?;
|
||||
install_platform_session(&user_id, &access_token, &api_base_url, generation)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("安装本地运行时会话任务意外终止:{error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn clear_platform_account_session(generation: u64) -> Result<(), String> {
|
||||
shutdown_game_creator_codex_app_servers()?;
|
||||
clear_external_agent_runner_platform_session(generation)?;
|
||||
clear_platform_session(generation);
|
||||
Ok(())
|
||||
pub(crate) async fn clear_platform_account_session(generation: u64) -> Result<(), String> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
shutdown_game_creator_codex_app_servers()?;
|
||||
clear_external_agent_runner_platform_session(generation)?;
|
||||
clear_platform_session(generation);
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("清除本地运行时会话任务意外终止:{error}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -75,6 +75,7 @@ function withAuthCheckTimeout<T>(
|
||||
timeoutMs: number,
|
||||
message: string,
|
||||
) {
|
||||
void promise.catch(() => undefined);
|
||||
let timeoutId: number | undefined;
|
||||
const timeout = new Promise<T>((_, reject) => {
|
||||
timeoutId = window.setTimeout(() => reject(new Error(message)), timeoutMs);
|
||||
@@ -492,10 +493,14 @@ export function AuthenticatedClient({
|
||||
password,
|
||||
loginApiBaseUrl,
|
||||
);
|
||||
const committedGeneration = await commitAuthenticatedPlatformSession(
|
||||
user,
|
||||
loginGeneration,
|
||||
loginApiBaseUrl,
|
||||
const committedGeneration = await withAuthCheckTimeout(
|
||||
commitAuthenticatedPlatformSession(
|
||||
user,
|
||||
loginGeneration,
|
||||
loginApiBaseUrl,
|
||||
),
|
||||
AUTH_CHECK_RUNNER_TIMEOUT_MS,
|
||||
'连接本地运行时超时,请重试或重启客户端',
|
||||
);
|
||||
if (committedGeneration === null) {
|
||||
return;
|
||||
@@ -524,7 +529,11 @@ export function AuthenticatedClient({
|
||||
clearStoredAuthAccessToken();
|
||||
}
|
||||
try {
|
||||
await clearCommittedPlatformSession(logoutGeneration);
|
||||
await withAuthCheckTimeout(
|
||||
clearCommittedPlatformSession(logoutGeneration),
|
||||
AUTH_CHECK_RUNNER_TIMEOUT_MS,
|
||||
'清理本地运行时超时,请重启客户端后再登录',
|
||||
);
|
||||
} catch (error) {
|
||||
nativeClearError = error;
|
||||
}
|
||||
|
||||
@@ -522,6 +522,7 @@ export function WorkspaceLauncherShell({
|
||||
onStatusChange={setStatus}
|
||||
recentProjectRows={recentProjectRows}
|
||||
onCreateDraftAutomatically={createHomeDraftAutomatically}
|
||||
creationBusy={homeProject.projectAction === 'creating'}
|
||||
onProjectsOpen={() => setLauncherView('projects')}
|
||||
onProjectOpen={(path) => {
|
||||
setProjectPath(path);
|
||||
|
||||
@@ -230,6 +230,9 @@ export function buildRecentProjectRows(
|
||||
>,
|
||||
recentWorkspaceRefreshing: boolean,
|
||||
): RecentProjectRow[] {
|
||||
// The refresh flag is kept for the page-level indicator. Each row owns its
|
||||
// pending state so a slow directory cannot disable already inspected rows.
|
||||
void recentWorkspaceRefreshing;
|
||||
return recentWorkspaces.map((workspace) => {
|
||||
const directoryStatus = recentWorkspaceStatuses[workspace];
|
||||
const isPendingStatus = directoryStatus === undefined;
|
||||
@@ -237,34 +240,31 @@ export function buildRecentProjectRows(
|
||||
directoryStatus?.projectName ||
|
||||
workspace.split(/[\\/]/).filter(Boolean).pop() ||
|
||||
workspace;
|
||||
const status = recentWorkspaceRefreshing
|
||||
const status = isPendingStatus
|
||||
? '检查中'
|
||||
: isPendingStatus
|
||||
? '检查中'
|
||||
: directoryStatus === null
|
||||
? '检查失败'
|
||||
: directoryStatus?.exists === false
|
||||
? '未找到'
|
||||
: directoryStatus?.isDirectory === false
|
||||
? '不是文件夹'
|
||||
: directoryStatus?.manifestError
|
||||
? '无法读取'
|
||||
: (directoryStatus?.isGodotProject === true ||
|
||||
directoryStatus?.isCocosProject === true) &&
|
||||
directoryStatus?.isGameCreatorProject === false
|
||||
? '可导入'
|
||||
: directoryStatus?.isGameCreatorProject === false
|
||||
? '未初始化'
|
||||
: directoryStatus?.recentRunStatus
|
||||
? formatRecentProjectRunStatus(
|
||||
directoryStatus.recentRunStatus,
|
||||
directoryStatus.recentRunStopReason,
|
||||
)
|
||||
: directoryStatus?.isGodotProject
|
||||
? '可打开'
|
||||
: '本地项目';
|
||||
: directoryStatus === null
|
||||
? '检查失败'
|
||||
: directoryStatus?.exists === false
|
||||
? '未找到'
|
||||
: directoryStatus?.isDirectory === false
|
||||
? '不是文件夹'
|
||||
: directoryStatus?.manifestError
|
||||
? '无法读取'
|
||||
: (directoryStatus?.isGodotProject === true ||
|
||||
directoryStatus?.isCocosProject === true) &&
|
||||
directoryStatus?.isGameCreatorProject === false
|
||||
? '可导入'
|
||||
: directoryStatus?.isGameCreatorProject === false
|
||||
? '未初始化'
|
||||
: directoryStatus?.recentRunStatus
|
||||
? formatRecentProjectRunStatus(
|
||||
directoryStatus.recentRunStatus,
|
||||
directoryStatus.recentRunStopReason,
|
||||
)
|
||||
: directoryStatus?.isGodotProject
|
||||
? '可打开'
|
||||
: '本地项目';
|
||||
const canReveal =
|
||||
!recentWorkspaceRefreshing &&
|
||||
Boolean(directoryStatus) &&
|
||||
directoryStatus?.exists !== false &&
|
||||
directoryStatus?.isDirectory !== false;
|
||||
@@ -286,7 +286,6 @@ export function buildRecentProjectRows(
|
||||
recentRunStopReason: directoryStatus?.recentRunStopReason ?? null,
|
||||
canReveal,
|
||||
canOpen:
|
||||
!recentWorkspaceRefreshing &&
|
||||
Boolean(directoryStatus) &&
|
||||
directoryStatus?.exists !== false &&
|
||||
directoryStatus?.isDirectory !== false &&
|
||||
|
||||
@@ -644,37 +644,53 @@ export function useHomeProjectCreation({
|
||||
startMode: ProjectStartMode,
|
||||
options: { suggestName: boolean },
|
||||
) {
|
||||
if (projectActionRef.current) {
|
||||
return '已有项目操作进行中,请稍候';
|
||||
}
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) {
|
||||
throw new Error('需要在陶泥儿客户端内运行');
|
||||
}
|
||||
const suggestedName = options.suggestName
|
||||
? await suggestAutomaticProjectName(invoke, draft)
|
||||
: null;
|
||||
const result = await invoke<InitLocalProjectResult>(
|
||||
'create_automatic_local_game_project',
|
||||
{
|
||||
name: suggestedName,
|
||||
planning: startMode === 'planning',
|
||||
},
|
||||
);
|
||||
// This action is owned by WorkspaceLauncher rather than HomeView. The
|
||||
// launcher survives navigation, so unmounting the home page cannot release
|
||||
// the guard while project creation or first-turn import is still running.
|
||||
projectActionRef.current = 'creating';
|
||||
setProjectAction('creating');
|
||||
setStatus('正在创建工作区');
|
||||
try {
|
||||
await enterCreatedHomeProject(
|
||||
invoke,
|
||||
result,
|
||||
draft.creationType,
|
||||
draft.prompt,
|
||||
draft.attachments,
|
||||
startMode,
|
||||
const suggestedName = options.suggestName
|
||||
? await suggestAutomaticProjectName(invoke, draft)
|
||||
: null;
|
||||
const result = await invoke<InitLocalProjectResult>(
|
||||
'create_automatic_local_game_project',
|
||||
{
|
||||
name: suggestedName,
|
||||
planning: startMode === 'planning',
|
||||
},
|
||||
);
|
||||
setStatus('已创建工作区,正在开始智能创作');
|
||||
return '已创建工作区并进入项目开发';
|
||||
} catch (error) {
|
||||
const message = `工作区已创建;首条需求投递失败:${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`;
|
||||
setStatus(message);
|
||||
throw new Error(message);
|
||||
try {
|
||||
await enterCreatedHomeProject(
|
||||
invoke,
|
||||
result,
|
||||
draft.creationType,
|
||||
draft.prompt,
|
||||
draft.attachments,
|
||||
startMode,
|
||||
);
|
||||
setStatus('已创建工作区,正在开始智能创作');
|
||||
return '已创建工作区并进入项目开发';
|
||||
} catch (error) {
|
||||
const message = `工作区已创建;首条需求投递失败:${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`;
|
||||
setStatus(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
} finally {
|
||||
if (projectActionRef.current === 'creating') {
|
||||
projectActionRef.current = null;
|
||||
setProjectAction(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
writeRecentWorkspace,
|
||||
} from './model';
|
||||
|
||||
const RECENT_WORKSPACE_CHECK_TIMEOUT_MS = 5_000;
|
||||
|
||||
export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
|
||||
const [recentWorkspaces, setRecentWorkspaces] =
|
||||
useState<string[]>(readRecentWorkspaces);
|
||||
@@ -34,14 +36,26 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
|
||||
invoke: NonNullable<ReturnType<typeof resolveTauriInvoke>>,
|
||||
workspace: string,
|
||||
): Promise<[string, LocalProjectDirectoryStatus | null]> {
|
||||
let timeoutHandle: number | undefined;
|
||||
try {
|
||||
const result = await invoke<LocalProjectDirectoryStatus>(
|
||||
'inspect_local_project_directory',
|
||||
{ projectPath: workspace },
|
||||
);
|
||||
const result = await Promise.race([
|
||||
invoke<LocalProjectDirectoryStatus>('inspect_local_project_directory', {
|
||||
projectPath: workspace,
|
||||
}),
|
||||
new Promise<never>((_, reject) => {
|
||||
timeoutHandle = window.setTimeout(
|
||||
() => reject(new Error('项目目录检查超时')),
|
||||
RECENT_WORKSPACE_CHECK_TIMEOUT_MS,
|
||||
);
|
||||
}),
|
||||
]);
|
||||
return [workspace, result];
|
||||
} catch {
|
||||
return [workspace, null];
|
||||
} finally {
|
||||
if (timeoutHandle !== undefined) {
|
||||
window.clearTimeout(timeoutHandle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,18 +67,27 @@ export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
|
||||
return;
|
||||
}
|
||||
let disposed = false;
|
||||
let pendingCount = recentWorkspaces.length;
|
||||
setRecentWorkspaceStatuses({});
|
||||
setRecentWorkspaceRefreshing(true);
|
||||
void Promise.all(
|
||||
recentWorkspaces.map((workspace) =>
|
||||
inspectRecentWorkspace(invoke, workspace),
|
||||
),
|
||||
).then((entries) => {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
setRecentWorkspaceStatuses(Object.fromEntries(entries));
|
||||
setRecentWorkspaceRefreshing(false);
|
||||
});
|
||||
|
||||
for (const workspace of recentWorkspaces) {
|
||||
void inspectRecentWorkspace(invoke, workspace).then(
|
||||
([projectPath, status]) => {
|
||||
if (disposed) {
|
||||
return;
|
||||
}
|
||||
setRecentWorkspaceStatuses((current) => ({
|
||||
...current,
|
||||
[projectPath]: status,
|
||||
}));
|
||||
pendingCount -= 1;
|
||||
if (pendingCount === 0) {
|
||||
setRecentWorkspaceRefreshing(false);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
return () => {
|
||||
disposed = true;
|
||||
};
|
||||
|
||||
@@ -15,7 +15,11 @@ import {
|
||||
API_RESPONSE_ENVELOPE_VERSION,
|
||||
unwrapApiResponse,
|
||||
} from '../../../../packages/shared/src/http';
|
||||
import { fetchClientHttp, getClientServerBaseUrl } from './clientHttp';
|
||||
import {
|
||||
fetchClientHttp,
|
||||
getClientServerBaseUrl,
|
||||
readClientHttpResponseText,
|
||||
} from './clientHttp';
|
||||
|
||||
const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1';
|
||||
|
||||
@@ -104,7 +108,9 @@ export function getClientAuthErrorMessage(error: unknown, fallback: string) {
|
||||
}
|
||||
|
||||
async function readAuthErrorMessage(response: Response, fallback: string) {
|
||||
const text = await response.text();
|
||||
const text = await readClientHttpResponseText(response, {
|
||||
url: 'auth error response',
|
||||
});
|
||||
if (!text.trim()) {
|
||||
return fallback;
|
||||
}
|
||||
@@ -158,7 +164,9 @@ async function requestAuthJson<T>(
|
||||
{ status: response.status },
|
||||
);
|
||||
}
|
||||
const text = await response.text();
|
||||
const text = await readClientHttpResponseText(response, {
|
||||
url,
|
||||
});
|
||||
return text ? unwrapApiResponse<T>(JSON.parse(text) as T) : (null as T);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,60 @@ export function isClientHttpTimeoutError(
|
||||
return error instanceof ClientHttpTimeoutError;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a response body with the same bounded lifetime as the request that
|
||||
* produced it. Some transports resolve fetch() after headers arrive while
|
||||
* leaving body consumption pending indefinitely.
|
||||
*/
|
||||
export async function readClientHttpResponseText(
|
||||
response: Response,
|
||||
options: { timeoutMs?: number | null; url?: string } = {},
|
||||
) {
|
||||
const timeoutMs =
|
||||
options.timeoutMs === undefined
|
||||
? CLIENT_HTTP_DEFAULT_TIMEOUT_MS
|
||||
: options.timeoutMs;
|
||||
if (timeoutMs === null) {
|
||||
return response.text();
|
||||
}
|
||||
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
||||
throw new RangeError('响应体超时时间必须是大于 0 的有限数值');
|
||||
}
|
||||
|
||||
let timedOut = false;
|
||||
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
||||
const bodyPromise = response.text();
|
||||
// A transport may reject after cancel() unblocks the stream. The race owns
|
||||
// the observable result, so keep the late rejection out of the global queue.
|
||||
void bodyPromise.catch(() => undefined);
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timeoutHandle = setTimeout(() => {
|
||||
timedOut = true;
|
||||
try {
|
||||
void response.body?.cancel().catch(() => undefined);
|
||||
} catch {
|
||||
// Response doubles and older WebViews may not expose cancel().
|
||||
}
|
||||
reject(
|
||||
new ClientHttpTimeoutError(options.url ?? 'response body', timeoutMs),
|
||||
);
|
||||
}, timeoutMs);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([bodyPromise, timeout]);
|
||||
} catch (error) {
|
||||
if (timedOut) {
|
||||
throw new ClientHttpTimeoutError(
|
||||
options.url ?? 'response body',
|
||||
timeoutMs,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (timeoutHandle !== undefined) clearTimeout(timeoutHandle);
|
||||
}
|
||||
}
|
||||
|
||||
export type ClientServerPreset = 'release' | 'dev' | 'custom';
|
||||
|
||||
export type ClientServerSelection = {
|
||||
|
||||
@@ -112,6 +112,7 @@ type HomeViewProps = {
|
||||
draft: HomeDraft,
|
||||
startMode: ProjectStartMode,
|
||||
) => Promise<string>;
|
||||
creationBusy?: boolean;
|
||||
onProjectsOpen: () => void;
|
||||
onProjectOpen: (path: string) => void;
|
||||
onProjectPick: () => void;
|
||||
@@ -123,6 +124,7 @@ export default function HomeView({
|
||||
onStatusChange,
|
||||
recentProjectRows,
|
||||
onCreateDraftAutomatically,
|
||||
creationBusy = false,
|
||||
onProjectsOpen,
|
||||
onProjectOpen,
|
||||
onProjectPick,
|
||||
@@ -148,7 +150,7 @@ export default function HomeView({
|
||||
homeCreationType === 'doc' ? 'planning' : 'direct-build';
|
||||
|
||||
async function createFromHome() {
|
||||
if (homeCreationBusyRef.current) {
|
||||
if (homeCreationBusyRef.current || creationBusy) {
|
||||
return;
|
||||
}
|
||||
const referencedAttachments = richTextToAttachments(homeRichText);
|
||||
@@ -261,7 +263,7 @@ export default function HomeView({
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<ConversationModelSelect
|
||||
className="home-input-model-select"
|
||||
disabled={homeCreationBusy}
|
||||
disabled={homeCreationBusy || creationBusy}
|
||||
/>
|
||||
<button
|
||||
className="grid size-7 cursor-pointer place-items-center rounded-full border-0 bg-(image:--platform-button-primary-fill) p-0 text-(--platform-button-primary-text) shadow-(--platform-profile-action-shadow) transition-transform hover:scale-105 disabled:cursor-not-allowed disabled:opacity-55"
|
||||
@@ -269,7 +271,7 @@ export default function HomeView({
|
||||
aria-label={
|
||||
startMode === 'planning' ? '进入立项策划' : '开启创作'
|
||||
}
|
||||
disabled={homeCreationBusy}
|
||||
disabled={homeCreationBusy || creationBusy}
|
||||
>
|
||||
{homeCreationBusy ? (
|
||||
<Loader2
|
||||
|
||||
@@ -1807,17 +1807,30 @@ export function registerHomeProjectCreationTests() {
|
||||
expect(screen.getByLabelText('最近项目').textContent).not.toContain(
|
||||
'选择一个项目继续创作',
|
||||
);
|
||||
expect(screen.getByLabelText('最近项目').textContent).not.toContain(
|
||||
'正在创建工作区',
|
||||
);
|
||||
expect(screen.queryByText('正在创建工作区')).toBeNull();
|
||||
expect(screen.getByText('正在创建工作区')).not.toBeNull();
|
||||
|
||||
// The launcher owns the operation, so navigating away and back must not
|
||||
// release the duplicate-create guard or lose the in-progress status.
|
||||
fireEvent.click(screen.getByRole('button', { name: '项目组' }));
|
||||
expect(await screen.findByLabelText('项目列表')).not.toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: '首页' }));
|
||||
const returnedCreateButton = await screen.findByRole('button', {
|
||||
name: '开启创作',
|
||||
});
|
||||
expect((returnedCreateButton as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(screen.getByText('正在创建工作区')).not.toBeNull();
|
||||
expect(
|
||||
invoke.mock.calls.filter(
|
||||
([command]) => command === 'create_automatic_local_game_project',
|
||||
),
|
||||
).toHaveLength(1);
|
||||
|
||||
await act(async () => {
|
||||
rejectAutomaticProject?.(new Error('自动创建测试结束'));
|
||||
await automaticProject.catch(() => undefined);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect((createButton as HTMLButtonElement).disabled).toBe(false);
|
||||
expect((returnedCreateButton as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
expect(screen.queryByText('自动创建测试结束')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
requestClientApi,
|
||||
setStoredAuthAccessToken,
|
||||
} from '../src/services/clientApi';
|
||||
import { refreshClientAuthAccessToken } from '../src/services/clientAuth';
|
||||
import {
|
||||
beginPlatformSessionTransition,
|
||||
commitAuthenticatedPlatformSession,
|
||||
@@ -144,3 +145,34 @@ it('请求期间账号切换后,不替新账号续期或重发旧请求', asyn
|
||||
await rejection;
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('响应体卡住超时后,下一次续期会重新发起请求', async () => {
|
||||
vi.useFakeTimers();
|
||||
let refreshCalls = 0;
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation((input) => {
|
||||
if (input === '/api/auth/refresh') {
|
||||
refreshCalls += 1;
|
||||
}
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start() {
|
||||
// Simulate headers returned while the body remains open.
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const first = refreshClientAuthAccessToken('http://localhost:3000');
|
||||
const firstAssertion = expect(first).rejects.toThrow();
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
await firstAssertion;
|
||||
|
||||
const second = refreshClientAuthAccessToken('http://localhost:3000');
|
||||
const secondAssertion = expect(second).rejects.toThrow();
|
||||
expect(refreshCalls).toBe(2);
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
await secondAssertion;
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
getClientServerBaseUrl,
|
||||
getClientServerSelection,
|
||||
normalizeClientServerBaseUrl,
|
||||
readClientHttpResponseText,
|
||||
resetClientServerSelectionForTests,
|
||||
resolveClientHttpTarget,
|
||||
setClientServerSelection,
|
||||
@@ -273,6 +274,29 @@ describe('AGC client HTTP transport', () => {
|
||||
expect((forwardedInit.signal as AbortSignal).aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('times out after response headers when the response body never completes', async () => {
|
||||
vi.useFakeTimers();
|
||||
const response = new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start() {
|
||||
// Keep the stream open forever: headers exist, body does not finish.
|
||||
},
|
||||
}),
|
||||
);
|
||||
const read = readClientHttpResponseText(response, {
|
||||
timeoutMs: 25,
|
||||
url: '/api/auth/refresh',
|
||||
});
|
||||
const assertion = expect(read).rejects.toMatchObject({
|
||||
name: 'ClientHttpTimeoutError',
|
||||
code: 'CLIENT_HTTP_TIMEOUT',
|
||||
timeoutMs: 25,
|
||||
url: '/api/auth/refresh',
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(25);
|
||||
await assertion;
|
||||
});
|
||||
|
||||
it('preserves caller AbortError and does not report it as a timeout', async () => {
|
||||
const fetchMock = vi.fn(
|
||||
(_url: string, init: RequestInit) =>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildRecentProjectRows } from '../src/features/app-shell/model';
|
||||
|
||||
describe('最近项目行状态', () => {
|
||||
it('已完成的项目不受其它慢目录的全局刷新状态阻塞', () => {
|
||||
const rows = buildRecentProjectRows(
|
||||
['C:\\projects\\ready', 'C:\\projects\\slow'],
|
||||
{
|
||||
'C:\\projects\\ready': {
|
||||
projectPath: 'C:\\projects\\ready',
|
||||
exists: true,
|
||||
isDirectory: true,
|
||||
isGameCreatorProject: true,
|
||||
isGodotProject: false,
|
||||
isCocosProject: false,
|
||||
godotProjectRoot: null,
|
||||
cocosProjectRoot: null,
|
||||
projectName: '可打开项目',
|
||||
recentRunStatus: null,
|
||||
recentRunStopReason: null,
|
||||
},
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
expect(rows[0]).toMatchObject({
|
||||
name: '可打开项目',
|
||||
status: '本地项目',
|
||||
canOpen: true,
|
||||
canReveal: true,
|
||||
});
|
||||
expect(rows[1]).toMatchObject({
|
||||
status: '检查中',
|
||||
canOpen: false,
|
||||
canReveal: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,7 @@
|
||||
- [LLM 累计额度结算](./technical/【技术方案】LLM累计额度结算-2026-09-05.md):Router 累计额度、首次基线与原子钱包结算。
|
||||
|
||||
- [AI 游戏创作智能体 App 实施计划](./technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md):当前 DirectProject、受控语义工具、UI workflow、资源和运行时合同。
|
||||
- [AGC 异步操作可恢复闭环](./【技术方案】AGC异步操作可恢复闭环-2026-09-14.md):认证响应体、最近项目检查和首页自动创建的超时、逐项恢复与跨页防重合同。
|
||||
- [策划会话 Runtime V2 接入与旧链路退役方案](./technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md):新单 Agent 策划会话、GDD 策略、未来 MCP/Skill 兼容插槽、阶段任务与退役验收合同。
|
||||
- [DirectProject Codex 原始历史与异常恢复](<./technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md>):原始 Responses item 持久化、线程注入与异常回合收尾。
|
||||
- [DirectProject 客户端 Skill 与 MCP 扩展导入方案](./technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md):客户端扩展导入、按独立 Skill/MCP 拆分、命名、启用和启动时注入边界。
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# 【技术方案】AGC 异步操作可恢复闭环
|
||||
|
||||
更新时间:`2026-09-14`
|
||||
|
||||
## 目标
|
||||
|
||||
让 AGC 的认证、最近项目检查和首页自动创建在响应体卡住、单目录慢、页面切换或操作迟到时仍然可观察、可重试且不会重复创建或覆盖当前项目。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 本轮不改变认证接口、Runner 协议、SpacetimeDB schema 或 External API。
|
||||
- 不处理环境中其它 worktree 的进程;运行环境清理需单独按进程归属执行。
|
||||
- 不把 UI 测试警告全部清零,除非它们阻碍本轮新增行为验证。
|
||||
|
||||
## 入口与边界
|
||||
|
||||
- 用户/系统入口:AGC 登录恢复、登录/验证码/退出、首页最近项目、首页做游戏/做素材/做方案。
|
||||
- 涉及模块:`clientHttp`、`clientAuth`、`AuthenticatedClient`、最近项目 controller/model、`useHomeProjectCreation`、`HomeView`。
|
||||
- 正式状态来源:认证 token 与 platform session、Tauri 项目 manifest;前端操作状态仅用于防重和恢复提示。
|
||||
|
||||
## 必须成立的行为
|
||||
|
||||
1. HTTP 响应头已返回但响应体未结束时,认证请求在有界时间内失败并释放 refresh singleflight;下一次重试必须发起新请求。
|
||||
2. 最近项目逐项独立检查;单项超时/失败只影响该行,已完成且可打开的项目立即可操作。
|
||||
3. 首页自动创建状态由 `WorkspaceLauncher` 生命周期持有;切页期间仍防重,迟到结果不能覆盖用户已打开的其它项目。
|
||||
4. 认证恢复和 Runner 连接继续有明确超时、错误和重试入口;本地 Runner 会话安装/清除的阻塞工作不得占用 Tauri 窗口线程。
|
||||
|
||||
## 契约与迁移
|
||||
|
||||
不新增公开 API、DTO、schema 或持久化字段。Runner command 协议保持不变。
|
||||
|
||||
## 验收标准与证据
|
||||
|
||||
| 条款 | 验收方式 | 证据 |
|
||||
| --- | --- | --- |
|
||||
| 响应体超时 | client auth/http 定向测试 | body 卡住抛出稳定超时,第二次 refresh 请求计数为 2 |
|
||||
| 最近项目独立完成 | model/controller 定向测试或 appSurface 场景 | A 完成时可打开,B 继续检查 |
|
||||
| 首页创建跨页防重 | appSurface 场景 | 切页返回后按钮仍禁用,迟到创建不覆盖已有项目 |
|
||||
| Runner 会话不阻塞窗口 | Rust 编译检查与登录/退出 UI fence | command 使用 blocking worker,前端使用 45 秒可恢复超时 |
|
||||
| 现有行为不回归 | typecheck、AGC 定向测试、编码和 diff 检查 | 命令输出 |
|
||||
|
||||
## 未决问题与决策
|
||||
|
||||
Runner 同步 Tauri command 的窗口线程影响需要通过当前 Rust command 注册与调用链复核;若要改为异步 command,应单独补 Rust 线程/取消语义测试,不在未验证前引入表面异步包装。
|
||||
Reference in New Issue
Block a user