Merge remote-tracking branch 'origin/rm/design-v2' into rm/design-v2
This commit is contained in:
@@ -145,6 +145,11 @@ export function AuthenticatedClient({
|
||||
const [authCheckError, setAuthCheckError] = useState('');
|
||||
const [authCheckRetryKey, setAuthCheckRetryKey] = useState(0);
|
||||
const authCheckRunRef = useRef(0);
|
||||
/**
|
||||
* 登录尝试代次。UI 的 45s 围栏只约束"等待":底层 native 提交仍在队列里跑,所以围栏超时后
|
||||
* 仍要有人接手这次提交的结果。代次确保只有最近一次登录尝试的迟到结果能改变界面。
|
||||
*/
|
||||
const loginAttemptRef = useRef(0);
|
||||
const [loginMode, setLoginMode] = useState<'code' | 'password'>('code');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
@@ -477,6 +482,7 @@ export function AuthenticatedClient({
|
||||
return;
|
||||
}
|
||||
const loginApiBaseUrl = getClientServerBaseUrl(persistedSelection);
|
||||
const loginAttempt = (loginAttemptRef.current += 1);
|
||||
setLoginBusy(true);
|
||||
setLoginStatus('正在登录');
|
||||
const loginGeneration = beginPlatformSessionTransition();
|
||||
@@ -493,15 +499,43 @@ export function AuthenticatedClient({
|
||||
password,
|
||||
loginApiBaseUrl,
|
||||
);
|
||||
const committedGeneration = await withAuthCheckTimeout(
|
||||
commitAuthenticatedPlatformSession(
|
||||
user,
|
||||
loginGeneration,
|
||||
loginApiBaseUrl,
|
||||
),
|
||||
AUTH_CHECK_RUNNER_TIMEOUT_MS,
|
||||
'连接本地运行时超时,请重试或重启客户端',
|
||||
const commitRequest = commitAuthenticatedPlatformSession(
|
||||
user,
|
||||
loginGeneration,
|
||||
loginApiBaseUrl,
|
||||
);
|
||||
let commitFenceExpired = false;
|
||||
// 围栏只放弃等待,不放弃结果:本地运行时确实装好会话时,界面必须跟着进工作区,
|
||||
// 否则用户停在登录页、而后端已经认为登录成功(重试也会被已装的会话挡住)。
|
||||
void commitRequest
|
||||
.then((committedGeneration) => {
|
||||
if (
|
||||
committedGeneration === null ||
|
||||
!commitFenceExpired ||
|
||||
loginAttemptRef.current !== loginAttempt ||
|
||||
currentPlatformSessionGeneration() !== committedGeneration
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setAuthUser(user);
|
||||
setAuthCheckError('');
|
||||
setAuthStatus('authenticated');
|
||||
setCode('');
|
||||
setPassword('');
|
||||
setLoginStatus('本地运行时登录态已确认');
|
||||
})
|
||||
.catch(() => undefined);
|
||||
let committedGeneration: number | null;
|
||||
try {
|
||||
committedGeneration = await withAuthCheckTimeout(
|
||||
commitRequest,
|
||||
AUTH_CHECK_RUNNER_TIMEOUT_MS,
|
||||
'连接本地运行时超时,请重试或重启客户端',
|
||||
);
|
||||
} catch (error) {
|
||||
commitFenceExpired = true;
|
||||
throw error;
|
||||
}
|
||||
if (committedGeneration === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@ export function WorkspaceLauncherShell({
|
||||
createHomeDraftAutomatically,
|
||||
openProject,
|
||||
homeCreationBusy,
|
||||
homeCreationRecoverableProjectPath,
|
||||
} = homeProject;
|
||||
const switchedToGameRuntime =
|
||||
gameRuntimeSwitch !== null &&
|
||||
@@ -524,6 +525,7 @@ export function WorkspaceLauncherShell({
|
||||
recentProjectRows={recentProjectRows}
|
||||
onCreateDraftAutomatically={createHomeDraftAutomatically}
|
||||
creationBusy={homeCreationBusy}
|
||||
recoverableCreatedProjectPath={homeCreationRecoverableProjectPath}
|
||||
onProjectsOpen={() => setLauncherView('projects')}
|
||||
onProjectOpen={(path) => {
|
||||
setProjectPath(path);
|
||||
|
||||
@@ -92,6 +92,37 @@ async function suggestAutomaticProjectName(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动建项的兜底期限。
|
||||
*
|
||||
* 建项要跑脚手架和依赖安装,慢是正常的,所以这不是"失败期限"而是"解围期限":到点后不再让首页
|
||||
* 入口无限占用工作区闸门(底层创建不会被取消,迟到成功仍会照常进入项目)。没有这个期限时,
|
||||
* 一次卡死的 `create_automatic_local_game_project` 会让首页之后的打开/新建全部无法进行。
|
||||
*/
|
||||
const HOME_CREATION_DEADLINE_MS = 10 * 60_000;
|
||||
const HOME_CREATION_WATCHDOG_MESSAGE =
|
||||
'工作区创建超过 10 分钟仍未返回;可先打开其它项目,或在项目列表查看已创建的工作区';
|
||||
|
||||
/**
|
||||
* 建项已经落盘、但没能进入项目时,给首页一个恢复入口(打开已创建的工作区)。
|
||||
* 只有"已经知道项目路径且当前没有在跑"的阶段才提示,避免和进行中的建项打架。
|
||||
*/
|
||||
function resolveRecoverableHomeProjectPath(
|
||||
operation: ClientOperation<
|
||||
'home-create',
|
||||
{ draft: HomeDraft; startMode: ProjectStartMode }
|
||||
> | null,
|
||||
) {
|
||||
if (!operation) return '';
|
||||
if (
|
||||
operation.phase !== 'retryable-failure' &&
|
||||
operation.phase !== 'unknown'
|
||||
) {
|
||||
return '';
|
||||
}
|
||||
return operation.scope.projectPath ?? '';
|
||||
}
|
||||
|
||||
export function useHomeProjectCreation({
|
||||
setStatus,
|
||||
setLauncherView,
|
||||
@@ -146,6 +177,14 @@ export function useHomeProjectCreation({
|
||||
* 一次代次,await 回来时已经不是最新代次的结果整体丢弃。
|
||||
*/
|
||||
const projectEntryTokenRef = useRef(0);
|
||||
/**
|
||||
* 首页自动建项的尝试代次与"底层创建仍未返回"标记。
|
||||
*
|
||||
* 看门狗到点后会放开工位闸门(让用户还能打开其它项目),但底层创建仍在跑。此时既不允许
|
||||
* 并发再建一个项目(会产生重复工作区),也不能让迟到的创建结果强行劫持用户已经打开的别的项目。
|
||||
*/
|
||||
const homeCreationAttemptRef = useRef(0);
|
||||
const homeCreationUnresolvedRef = useRef(false);
|
||||
|
||||
function validateProjectPath(nextProjectPath: string) {
|
||||
const trimmedProjectPath = nextProjectPath.trim();
|
||||
@@ -667,14 +706,21 @@ export function useHomeProjectCreation({
|
||||
if (projectActionRef.current || homeCreationIsBusy()) {
|
||||
return '已有项目操作进行中,请稍候';
|
||||
}
|
||||
if (homeCreationUnresolvedRef.current) {
|
||||
// 上一次建项的底层调用还没返回(可能已经超过看门狗期限)。此时放行会真的建出第二个
|
||||
// 工作区,所以只挡"再建一个",不挡打开/查看已有项目。
|
||||
return '上一次工作区创建仍未返回,请稍候,或重启客户端后再试';
|
||||
}
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) {
|
||||
throw new Error('需要在陶泥儿客户端内运行');
|
||||
}
|
||||
const attempt = (homeCreationAttemptRef.current += 1);
|
||||
const isCurrentAttempt = () => homeCreationAttemptRef.current === attempt;
|
||||
const operation = createClientOperation(
|
||||
'home-create',
|
||||
{ draft, startMode },
|
||||
{ deadlineMs: null, cancellable: false },
|
||||
{ deadlineMs: HOME_CREATION_DEADLINE_MS, cancellable: false },
|
||||
);
|
||||
setHomeCreationOperation(transitionClientOperation(operation, 'network'));
|
||||
// This action is owned by WorkspaceLauncher rather than HomeView. The
|
||||
@@ -682,62 +728,123 @@ export function useHomeProjectCreation({
|
||||
// the guard while project creation or first-turn import is still running.
|
||||
projectActionRef.current = 'creating';
|
||||
setProjectAction('creating');
|
||||
homeCreationUnresolvedRef.current = true;
|
||||
setStatus('正在创建工作区');
|
||||
try {
|
||||
const suggestedName = options.suggestName
|
||||
? await suggestAutomaticProjectName(invoke, draft)
|
||||
: null;
|
||||
const result = await invoke<InitLocalProjectResult>(
|
||||
'create_automatic_local_game_project',
|
||||
{
|
||||
name: suggestedName,
|
||||
planning: startMode === 'planning',
|
||||
},
|
||||
);
|
||||
setHomeCreationOperation(
|
||||
transitionClientOperation(operation, 'project', {
|
||||
scope: { projectPath: result.projectPath },
|
||||
}),
|
||||
);
|
||||
try {
|
||||
await enterCreatedHomeProject(
|
||||
invoke,
|
||||
result,
|
||||
draft.creationType,
|
||||
draft.prompt,
|
||||
draft.attachments,
|
||||
startMode,
|
||||
);
|
||||
let createdProjectPath: string | undefined;
|
||||
let entryTokenAtWatchdog: number | null = null;
|
||||
const operationScope = () =>
|
||||
createdProjectPath ? { scope: { projectPath: createdProjectPath } } : {};
|
||||
let watchdogId: number | undefined;
|
||||
/**
|
||||
* 看门狗到点后**只解围不取消**:首页入口立刻拿回控制权(否则 `await` 不结束,
|
||||
* 首页按钮会一直禁用),而底层建项继续在后台跑;它真的成功时会照常进入项目。
|
||||
* 这就是为什么这里用 `return string` 而不是抛错——首页的 catch 会把错误统一压成
|
||||
* 「创建未完成,请重试」,反而丢掉"只是慢"这个信息。
|
||||
*/
|
||||
const watchdog = new Promise<string>((resolve) => {
|
||||
watchdogId = window.setTimeout(() => {
|
||||
if (!isCurrentAttempt() || !homeCreationUnresolvedRef.current) return;
|
||||
entryTokenAtWatchdog = projectEntryTokenRef.current;
|
||||
setHomeCreationOperation(
|
||||
transitionClientOperation(operation, 'success', {
|
||||
transitionClientOperation(operation, 'unknown', operationScope()),
|
||||
);
|
||||
setStatus(HOME_CREATION_WATCHDOG_MESSAGE);
|
||||
if (projectActionRef.current === 'creating') {
|
||||
projectActionRef.current = null;
|
||||
setProjectAction(null);
|
||||
}
|
||||
resolve(HOME_CREATION_WATCHDOG_MESSAGE);
|
||||
}, HOME_CREATION_DEADLINE_MS);
|
||||
});
|
||||
/** 后台继续跑的建项主体:用户可见的等待由 `watchdog` 兜底,这里只负责最终落定。 */
|
||||
const creation = (async () => {
|
||||
try {
|
||||
const suggestedName = options.suggestName
|
||||
? await suggestAutomaticProjectName(invoke, draft)
|
||||
: null;
|
||||
const result = await invoke<InitLocalProjectResult>(
|
||||
'create_automatic_local_game_project',
|
||||
{
|
||||
name: suggestedName,
|
||||
planning: startMode === 'planning',
|
||||
},
|
||||
);
|
||||
createdProjectPath = result.projectPath;
|
||||
setHomeCreationOperation(
|
||||
transitionClientOperation(operation, 'project', {
|
||||
scope: { projectPath: result.projectPath },
|
||||
}),
|
||||
);
|
||||
setStatus('已创建工作区,正在开始智能创作');
|
||||
return '已创建工作区并进入项目开发';
|
||||
if (
|
||||
entryTokenAtWatchdog !== null &&
|
||||
projectEntryTokenRef.current !== entryTokenAtWatchdog
|
||||
) {
|
||||
// 看门狗之后用户已经进了别的项目:工作区确实建好了,但不能在此时把工作区切过去。
|
||||
rememberRecentWorkspace(result.projectPath);
|
||||
setHomeCreationOperation(
|
||||
transitionClientOperation(operation, 'retryable-failure', {
|
||||
scope: { projectPath: result.projectPath },
|
||||
}),
|
||||
);
|
||||
setStatus('工作区已创建;可在项目列表打开');
|
||||
return '工作区已创建,可从项目列表打开';
|
||||
}
|
||||
try {
|
||||
await enterCreatedHomeProject(
|
||||
invoke,
|
||||
result,
|
||||
draft.creationType,
|
||||
draft.prompt,
|
||||
draft.attachments,
|
||||
startMode,
|
||||
);
|
||||
setHomeCreationOperation(
|
||||
transitionClientOperation(operation, 'success', {
|
||||
scope: { projectPath: result.projectPath },
|
||||
}),
|
||||
);
|
||||
setStatus('已创建工作区,正在开始智能创作');
|
||||
return '已创建工作区并进入项目开发';
|
||||
} catch (error) {
|
||||
// 项目目录已经建好了:把它登记进最近项目,用户可以直接打开,不必重新建一遍。
|
||||
rememberRecentWorkspace(result.projectPath);
|
||||
setHomeCreationOperation(
|
||||
transitionClientOperation(operation, 'retryable-failure', {
|
||||
scope: { projectPath: result.projectPath },
|
||||
}),
|
||||
);
|
||||
const message = `工作区已创建;首条需求投递失败:${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`;
|
||||
setStatus(message);
|
||||
throw new Error(message);
|
||||
}
|
||||
} catch (error) {
|
||||
setHomeCreationOperation(
|
||||
transitionClientOperation(operation, 'retryable-failure', {
|
||||
scope: { projectPath: result.projectPath },
|
||||
}),
|
||||
transitionClientOperation(
|
||||
operation,
|
||||
'retryable-failure',
|
||||
operationScope(),
|
||||
),
|
||||
);
|
||||
const message = `工作区已创建;首条需求投递失败:${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`;
|
||||
setStatus(message);
|
||||
throw new Error(message);
|
||||
if (createdProjectPath) {
|
||||
rememberRecentWorkspace(createdProjectPath);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (watchdogId !== undefined) {
|
||||
window.clearTimeout(watchdogId);
|
||||
}
|
||||
homeCreationUnresolvedRef.current = false;
|
||||
if (isCurrentAttempt() && projectActionRef.current === 'creating') {
|
||||
projectActionRef.current = null;
|
||||
setProjectAction(null);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
setHomeCreationOperation(
|
||||
transitionClientOperation(operation, 'retryable-failure'),
|
||||
);
|
||||
throw error;
|
||||
} finally {
|
||||
if (projectActionRef.current === 'creating') {
|
||||
projectActionRef.current = null;
|
||||
setProjectAction(null);
|
||||
}
|
||||
}
|
||||
})();
|
||||
// 后台主体不会因为竞速落定而停止:这里只防止它变成未处理的拒绝。
|
||||
void creation.catch(() => undefined);
|
||||
return await Promise.race([creation, watchdog]);
|
||||
}
|
||||
|
||||
async function pickAndOpenProject() {
|
||||
@@ -850,6 +957,9 @@ export function useHomeProjectCreation({
|
||||
projectBusy: projectAction !== null,
|
||||
homeCreationOperation,
|
||||
homeCreationBusy: homeCreationIsBusy(),
|
||||
homeCreationRecoverableProjectPath: resolveRecoverableHomeProjectPath(
|
||||
homeCreationOperation,
|
||||
),
|
||||
pendingNonEmptyProject,
|
||||
resetLauncherHomeDraft,
|
||||
startGameFromApprovedGdd,
|
||||
|
||||
@@ -43,7 +43,16 @@ let platformSessionOperation: ClientOperation<
|
||||
'auth-transition',
|
||||
{ userId: string | null }
|
||||
> | null = null;
|
||||
let platformSessionNativeMutationTail: Promise<void> = Promise.resolve();
|
||||
/**
|
||||
* 本地会话写入的排队闸门。
|
||||
*
|
||||
* Rust 侧 `install_platform_session_in` / `clear_platform_session_in` 按 generation 单调校验:
|
||||
* 更旧的 install 与更旧的 clear 都会被拒绝。因此渲染层必须保证的只有"新 generation 不被旧调用
|
||||
* 无限挡住",而不需要让队列永远等下去。这里给闸门加一个上限:底层 invoke 迟迟不返回(例如
|
||||
* Runner 卡住、IPC 不回调)时,后续登录/退出仍能继续推进,迟到的旧写入由 Rust 按代次拒绝。
|
||||
*/
|
||||
const PLATFORM_SESSION_NATIVE_MUTATION_ABANDONMENT_MS = 60_000;
|
||||
let platformSessionNativeMutationGate: Promise<void> = Promise.resolve();
|
||||
const platformSessionRefreshListeners =
|
||||
new Set<PlatformSessionRefreshListener>();
|
||||
const platformSessionGenerationListeners =
|
||||
@@ -104,15 +113,34 @@ async function clearNativePlatformSession(generation: number) {
|
||||
await invoke('clear_platform_account_session', { generation });
|
||||
}
|
||||
|
||||
function waitForNativeMutationAbandonment(
|
||||
settled: Promise<void>,
|
||||
timeoutMs: number,
|
||||
) {
|
||||
let timerId: number | undefined;
|
||||
const abandoned = new Promise<void>((resolve) => {
|
||||
timerId = window.setTimeout(resolve, timeoutMs);
|
||||
});
|
||||
return Promise.race([settled, abandoned]).finally(() => {
|
||||
if (timerId !== undefined) {
|
||||
window.clearTimeout(timerId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function enqueuePlatformSessionNativeMutation<T>(
|
||||
operation: () => Promise<T>,
|
||||
abandonmentMs = PLATFORM_SESSION_NATIVE_MUTATION_ABANDONMENT_MS,
|
||||
): Promise<T> {
|
||||
const pending = platformSessionNativeMutationTail
|
||||
const pending = platformSessionNativeMutationGate
|
||||
.catch(() => undefined)
|
||||
.then(operation);
|
||||
platformSessionNativeMutationTail = pending.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
platformSessionNativeMutationGate = waitForNativeMutationAbandonment(
|
||||
pending.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
),
|
||||
abandonmentMs,
|
||||
);
|
||||
return pending;
|
||||
}
|
||||
@@ -135,7 +163,15 @@ async function readNativePlatformSessionGenerationFloor() {
|
||||
async function reserveNativePlatformSessionGeneration() {
|
||||
platformNativeGenerationFloorPromise ??=
|
||||
readNativePlatformSessionGenerationFloor();
|
||||
const nativeGenerationFloor = await platformNativeGenerationFloorPromise;
|
||||
let nativeGenerationFloor: number;
|
||||
try {
|
||||
nativeGenerationFloor = await platformNativeGenerationFloorPromise;
|
||||
} catch (error) {
|
||||
// 一次瞬时失败(IPC 抖动、Runner 刚重启)不能被缓存成"永久失败":否则本次渲染进程
|
||||
// 内的后续登录/退出都会在同一个已 reject 的 promise 上失败,用户重试也不会重新读取。
|
||||
platformNativeGenerationFloorPromise = null;
|
||||
throw error;
|
||||
}
|
||||
platformNativeGeneration = Math.max(
|
||||
platformNativeGeneration + 1,
|
||||
platformAuthGeneration,
|
||||
@@ -486,7 +522,7 @@ export function resetPlatformSessionStateForTests() {
|
||||
desiredPlatformSession = null;
|
||||
platformSessionRefreshPromise = null;
|
||||
platformSessionOperation = null;
|
||||
platformSessionNativeMutationTail = Promise.resolve();
|
||||
platformSessionNativeMutationGate = Promise.resolve();
|
||||
platformSessionRefreshListeners.clear();
|
||||
platformSessionGenerationListeners.clear();
|
||||
}
|
||||
|
||||
@@ -113,6 +113,10 @@ type HomeViewProps = {
|
||||
startMode: ProjectStartMode,
|
||||
) => Promise<string>;
|
||||
creationBusy?: boolean;
|
||||
/**
|
||||
* 建项已经落盘、但当前没能进入项目时给出该项目路径,让用户直接打开而不是重建一遍。
|
||||
*/
|
||||
recoverableCreatedProjectPath?: string;
|
||||
onProjectsOpen: () => void;
|
||||
onProjectOpen: (path: string) => void;
|
||||
onProjectPick: () => void;
|
||||
@@ -125,6 +129,7 @@ export default function HomeView({
|
||||
recentProjectRows,
|
||||
onCreateDraftAutomatically,
|
||||
creationBusy = false,
|
||||
recoverableCreatedProjectPath = '',
|
||||
onProjectsOpen,
|
||||
onProjectOpen,
|
||||
onProjectPick,
|
||||
@@ -295,6 +300,21 @@ export default function HomeView({
|
||||
{status}
|
||||
</p>
|
||||
) : null}
|
||||
{recoverableCreatedProjectPath && !creationBusy ? (
|
||||
<p
|
||||
className="m-0 flex w-[min(814px,calc(100vw-122px))] flex-wrap items-center gap-2 text-sm text-(--platform-warm-text) max-[760px]:w-[min(100%,calc(100vw-76px))]"
|
||||
role="status"
|
||||
>
|
||||
<span>已创建的工作区:{recoverableCreatedProjectPath}</span>
|
||||
<button
|
||||
className="cursor-pointer rounded-md border border-(--platform-surface-border) bg-(--platform-input-fill) px-2 py-1 text-[12px] text-(--platform-text-strong)"
|
||||
type="button"
|
||||
onClick={() => onProjectOpen(recoverableCreatedProjectPath)}
|
||||
>
|
||||
打开已创建的工作区
|
||||
</button>
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section
|
||||
|
||||
@@ -270,6 +270,185 @@ export function registerAuthTests() {
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('retries the native session generation floor read after a transient failure', async () => {
|
||||
let floorReads = 0;
|
||||
const invoke = vi.fn(async (command: string, payload?: unknown) => {
|
||||
if (command === 'read_platform_account_session_generation') {
|
||||
floorReads += 1;
|
||||
if (floorReads === 1) {
|
||||
throw new Error('runner not ready');
|
||||
}
|
||||
return 12;
|
||||
}
|
||||
if (
|
||||
command === 'install_platform_account_session' ||
|
||||
command === 'clear_platform_account_session'
|
||||
) {
|
||||
expect(payload).toEqual(
|
||||
expect.objectContaining({ generation: expect.any(Number) }),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
const firstGeneration = beginPlatformSessionTransition();
|
||||
window.localStorage.setItem(
|
||||
'genarrative.auth.access-token.v1',
|
||||
'retry-floor-token',
|
||||
);
|
||||
|
||||
await expect(
|
||||
commitAuthenticatedPlatformSession(testAuthUser, firstGeneration),
|
||||
).rejects.toThrow('runner not ready');
|
||||
|
||||
// 瞬时读取失败不能被缓存成永久失败:第二次登录必须重新读取并成功。
|
||||
const secondGeneration = beginPlatformSessionTransition();
|
||||
window.localStorage.setItem(
|
||||
'genarrative.auth.access-token.v1',
|
||||
'retry-floor-token',
|
||||
);
|
||||
await expect(
|
||||
commitAuthenticatedPlatformSession(testAuthUser, secondGeneration),
|
||||
).resolves.toEqual(expect.any(Number));
|
||||
expect(floorReads).toBeGreaterThan(1);
|
||||
expect(invoke).toHaveBeenLastCalledWith(
|
||||
'install_platform_account_session',
|
||||
expect.objectContaining({
|
||||
userId: testAuthUser.id,
|
||||
accessToken: 'retry-floor-token',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not let a stalled native install wedge the next login', async () => {
|
||||
vi.useFakeTimers();
|
||||
const installedTokens: string[] = [];
|
||||
let releaseStalledInstall: (() => void) | null = null;
|
||||
const invoke = vi.fn(async (command: string, payload?: unknown) => {
|
||||
if (command === 'install_platform_account_session') {
|
||||
installedTokens.push(
|
||||
String((payload as { accessToken?: string })?.accessToken),
|
||||
);
|
||||
if (installedTokens.length === 1) {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseStalledInstall = resolve;
|
||||
});
|
||||
}
|
||||
}
|
||||
return null;
|
||||
});
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
|
||||
const stalledGeneration = beginPlatformSessionTransition();
|
||||
window.localStorage.setItem(
|
||||
'genarrative.auth.access-token.v1',
|
||||
'stalled-token',
|
||||
);
|
||||
const stalled = commitAuthenticatedPlatformSession(
|
||||
testAuthUser,
|
||||
stalledGeneration,
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(installedTokens).toEqual(['stalled-token']);
|
||||
|
||||
const retryGeneration = beginPlatformSessionTransition();
|
||||
window.localStorage.setItem(
|
||||
'genarrative.auth.access-token.v1',
|
||||
'retry-token',
|
||||
);
|
||||
const retry = commitAuthenticatedPlatformSession(
|
||||
testAuthUser,
|
||||
retryGeneration,
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
|
||||
expect(installedTokens).toEqual(['stalled-token', 'retry-token']);
|
||||
await expect(retry).resolves.toEqual(expect.any(Number));
|
||||
expect(
|
||||
window.localStorage.getItem('genarrative.auth.access-token.v1'),
|
||||
).toBe('retry-token');
|
||||
|
||||
// 迟到的旧 install 只影响它自己:Rust 按 generation 拒绝过期写入,
|
||||
// 渲染层保持新会话为准。
|
||||
releaseStalledInstall?.();
|
||||
await expect(stalled).resolves.toBeNull();
|
||||
expect(
|
||||
window.localStorage.getItem('genarrative.auth.access-token.v1'),
|
||||
).toBe('retry-token');
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('adopts a login whose native session install finishes after the UI fence', async () => {
|
||||
vi.useFakeTimers();
|
||||
let releaseInstall: (() => void) | null = null;
|
||||
const invoke = vi.fn(async (command: string) => {
|
||||
if (command === 'install_platform_account_session') {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseInstall = resolve;
|
||||
});
|
||||
}
|
||||
return null;
|
||||
});
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(
|
||||
async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === '/api/auth/refresh') {
|
||||
return new Response('', { status: 401 });
|
||||
}
|
||||
if (url === '/api/auth/phone/login') {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
token: 'late-install-token',
|
||||
user: { ...testAuthUser, loginMethod: 'phone' },
|
||||
created: false,
|
||||
referral: null,
|
||||
}),
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
throw new Error(`unexpected fetch ${url}`);
|
||||
},
|
||||
);
|
||||
|
||||
render(
|
||||
React.createElement(AuthenticatedClient, null, ({ user }) =>
|
||||
React.createElement('main', { 'aria-label': '已登录' }, user.id),
|
||||
),
|
||||
);
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
}
|
||||
fireEvent.change(screen.getByLabelText('手机号'), {
|
||||
target: { value: '13800000000' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('验证码'), {
|
||||
target: { value: '123456' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '登录' }));
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(45_000);
|
||||
});
|
||||
expect(
|
||||
screen.getByText('连接本地运行时超时,请重试或重启客户端'),
|
||||
).not.toBeNull();
|
||||
expect(screen.queryByLabelText('已登录')).toBeNull();
|
||||
|
||||
// 围栏只放弃等待:本地运行时确实装好会话后,界面必须跟着进工作区。
|
||||
releaseInstall?.();
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(screen.queryByLabelText('已登录')).not.toBeNull();
|
||||
expect(
|
||||
window.localStorage.getItem('genarrative.auth.access-token.v1'),
|
||||
).toBe('late-install-token');
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('keeps the previous renderer session authoritative when replacement install is rejected', async () => {
|
||||
const invoke = vi.fn(async (_command: string, payload?: unknown) => {
|
||||
const userId = (payload as { userId?: string } | undefined)?.userId;
|
||||
|
||||
@@ -1835,6 +1835,146 @@ export function registerHomeProjectCreationTests() {
|
||||
expect(screen.queryByText('自动创建测试结束')).toBeNull();
|
||||
});
|
||||
|
||||
it('unblocks the home launcher when automatic creation never returns', async () => {
|
||||
vi.useFakeTimers();
|
||||
const automaticProjectPath = '/tmp/home-stalled-project';
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'home-stalled-project',
|
||||
'卡住的自动建项',
|
||||
);
|
||||
let resolveAutomaticProject:
|
||||
| ((result: Record<string, unknown>) => void)
|
||||
| null = null;
|
||||
const invoke = vi.fn(async (command: string) => {
|
||||
if (command === 'create_automatic_local_game_project') {
|
||||
return await new Promise((resolve) => {
|
||||
resolveAutomaticProject = resolve;
|
||||
});
|
||||
}
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
});
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
renderLauncherAt('/?launcher', 'home', true);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
|
||||
const promptInput = screen.getByLabelText('创作想法');
|
||||
nativeClipboardMock.text = '做一个挂机游戏';
|
||||
fireEvent.paste(promptInput);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '开启创作' }));
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(
|
||||
(screen.getByRole('button', { name: '开启创作' }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
|
||||
// 超过兜底期限:首页入口必须解围,不能永远卡在"正在创建工作区"。
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(10 * 60_000);
|
||||
});
|
||||
expect(screen.getByText(/工作区创建超过 10 分钟/)).not.toBeNull();
|
||||
expect(
|
||||
(screen.getByRole('button', { name: '开启创作' }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(false);
|
||||
|
||||
// 解围不等于放弃底层创建,也不等于允许再建第二个工作区。
|
||||
fireEvent.click(screen.getByRole('button', { name: '开启创作' }));
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(screen.getByText(/上一次工作区创建仍未返回/)).not.toBeNull();
|
||||
expect(
|
||||
invoke.mock.calls.filter(
|
||||
([command]) => command === 'create_automatic_local_game_project',
|
||||
),
|
||||
).toHaveLength(1);
|
||||
|
||||
// 迟到的成功仍然要进项目:用户不该因为慢就丢掉这次创建。
|
||||
await act(async () => {
|
||||
resolveAutomaticProject?.({
|
||||
projectPath: automaticProjectPath,
|
||||
manifestPath: `${automaticProjectPath}/.agent/manifest.json`,
|
||||
manifest,
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(screen.getByLabelText('项目开发工作台')).not.toBeNull();
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('keeps the created workspace recoverable when design runtime setup fails', async () => {
|
||||
const projectPath = '/tmp/home-design-mode-failure';
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'home-design-mode-failure',
|
||||
'策划初始化失败项目',
|
||||
);
|
||||
const supervisorHarness = createProjectSupervisorRuntimeHarness({
|
||||
projectPath,
|
||||
initialSessionExists: false,
|
||||
});
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'create_automatic_local_game_project') {
|
||||
return {
|
||||
projectPath,
|
||||
manifestPath: `${projectPath}/.agent/manifest.json`,
|
||||
manifest,
|
||||
};
|
||||
}
|
||||
if (command === 'set_design_agent_runtime_mode') {
|
||||
throw new Error('design runtime unavailable');
|
||||
}
|
||||
if (command === 'inspect_local_project_directory') {
|
||||
return {
|
||||
exists: true,
|
||||
isDirectory: true,
|
||||
isGameCreatorProject: true,
|
||||
isCocosProject: false,
|
||||
godotProjectRoot: null,
|
||||
};
|
||||
}
|
||||
if (command === 'get_local_game_manifest') {
|
||||
return manifest;
|
||||
}
|
||||
return supervisorHarness.invoke(command, args);
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = {
|
||||
core: { invoke },
|
||||
event: { listen: supervisorHarness.listen },
|
||||
};
|
||||
renderLauncherAt('/?launcher', 'home', true);
|
||||
|
||||
// 做方案走立项链路:建项之后还要写策划运行时,这一步失败时项目目录已经存在。
|
||||
fireEvent.click(screen.getByRole('button', { name: '做方案' }));
|
||||
const promptInput = screen.getByLabelText('创作想法');
|
||||
nativeClipboardMock.text = '做一个塔防游戏';
|
||||
fireEvent.paste(promptInput);
|
||||
await waitFor(() => {
|
||||
expect(promptInput.textContent).toContain('做一个塔防游戏');
|
||||
});
|
||||
fireEvent.keyDown(promptInput, { key: 'Enter', code: 'Enter' });
|
||||
|
||||
expect(await screen.findByText('创建未完成,请重试')).not.toBeNull();
|
||||
expect(screen.getByText(`已创建的工作区:${projectPath}`)).not.toBeNull();
|
||||
await waitFor(() => {
|
||||
expect(invoke).toHaveBeenCalledWith('inspect_local_project_directory', {
|
||||
projectPath,
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开已创建的工作区' }));
|
||||
expect(await screen.findByLabelText('项目开发工作台')).not.toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['做方案', false],
|
||||
['做方案', true],
|
||||
|
||||
@@ -1893,7 +1893,7 @@ export function registerProjectCommandTests() {
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'当前仅支持确认 project.index、project.status、project.checkpoint、project.diff、project.restore、project.export_package、project.export_list、file.list、file.read、memory.read、memory.write、memory.delete、asset.register、asset.list、task.list、task.create、task.update、agent.run_status、agent.kill、agent.retry、agent.resume、agent.delegate、agent.spawn_isolated、agent.schedule_ready、agent.audit、agent.trace_read、preview.status、preview.start、preview.validate、preview.open、preview.stop、canvas.project_sync、canvas.asset_import、canvas.asset_generate、canvas.export_import、conversation.read 和 conversation.write。',
|
||||
'当前仅支持确认 project.index、project.status、project.bootstrap、project.checkpoint、project.diff、project.restore、project.export_package、project.export_list、file.list、file.read、memory.read、memory.write、memory.delete、asset.register、asset.list、task.list、task.create、task.update、agent.run_status、agent.kill、agent.retry、agent.resume、agent.delegate、agent.spawn_isolated、agent.schedule_ready、agent.audit、agent.trace_read、preview.status、preview.start、preview.validate、preview.open、preview.stop、canvas.project_sync、canvas.asset_import、canvas.asset_generate、canvas.export_import、conversation.read 和 conversation.write。',
|
||||
),
|
||||
).not.toBeNull();
|
||||
expect(screen.queryByText('project.policy_write')).toBeNull();
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# 踩坑与排障记录
|
||||
|
||||
## 2026-09-14 UI 超时围栏只能放弃等待,不能放弃结果;排队闸门不能无限等
|
||||
|
||||
- **现象**:登录/建项在 UI 上"超时"后报错,用户重试仍然无效;界面停在原页面,而后端/Runner 其实已经接受了这次操作(登录后本机登录态已装好、项目目录已建好)。
|
||||
- **原因**:两个独立缺陷叠加。(1) `withAuthCheckTimeout` 一类围栏用 `Promise.race` 只让界面提前失败,底层 native mutation 仍在队列里跑;而队列尾是"无限等上一次完成"的串接,一次卡住的 invoke 会让之后每次登录/退出都排在它后面(故障注入:连续两次登录只产生 1 次 install 调用)。(2) `platformNativeGenerationFloorPromise` 用 `??=` 缓存 promise,一次瞬时读取失败被缓存成永久失败。
|
||||
- **处理**:(1) 围栏超时后仍要有人接手结果——`AuthenticatedClient` 用尝试代次 + `currentPlatformSessionGeneration()` 判定,迟到成功才写回界面,绝不覆盖更新的尝试;(2) native 写入队列改成带解围期限的闸门(`PLATFORM_SESSION_NATIVE_MUTATION_ABANDONMENT_MS`)。普通队列"串行"看起来更安全,但本地会话写入的真正不变量在 Rust:`install_platform_session_in` / `clear_platform_session_in` 按 generation 单调拒绝更旧写入,所以渲染层只要保证新 generation 不被旧调用永久挡住即可;(3) 首页 `home-create` 从 `deadlineMs: null` 改为有兜底期限,到点用 `Promise.race` 返回提示字符串解围(**不要抛错**:首页 catch 会把错误统一压成「创建未完成,请重试」,反而丢掉"只是慢")而底层创建继续跑,迟到成功照常进项目;已建好的工作区要登记最近项目并留「打开已创建的工作区」入口。
|
||||
- **易错点**:失败分支里不要用**初始** operation 去覆盖已经带上 `scope.projectPath` 的状态——`transitionClientOperation(初始 operation, ...)` 会把内层写好的路径丢掉,导致"项目已建好但用户拿不到"。看门狗解围后仍要保留"底层创建未返回"标记:放行会真的建出第二个工作区;但这条只挡"再建一个",不能挡打开已有项目。
|
||||
- **验证**:`apps/ai-game-creator-shell/tests/appSurface.test.ts`(`auth.suite.ts` 的 stalled install / floor 瞬时失败 / 围栏后迟到安装;`home.suite.ts` 的建项看门狗与设计运行时初始化失败后的恢复入口)。
|
||||
- **关联**:`apps/ai-game-creator-shell/src/services/platformSession.ts`、`src/app/AuthenticatedClient.tsx`、`src/features/app-shell/useHomeProjectCreation.ts`、`src-tauri/src/platform_session.rs`、`docs/【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md`
|
||||
|
||||
## 2026-09-13 Cocos 操作必须核对实际回执与引擎就绪状态
|
||||
|
||||
- named pipe 使用真实换行分帧;测试客户端若写入字面量反斜杠 n,服务端不会执行请求。不能仅凭这类超时推断 Scene WebView 卡死,更不能重放不确定写操作。
|
||||
|
||||
@@ -59,6 +59,15 @@
|
||||
- 稳定版基础切换里程碑已完成;后续入口只允许复用该合同,不再新增 component-level busy/ref 状态机。
|
||||
- Planning/DirectProject/资源生成/预览已有各自 durable operation 或 request scope;本轮只补统一投影与身份校验,不重写其持久化账本。
|
||||
|
||||
### 生命周期残留收口(本轮)
|
||||
|
||||
上一轮在 master(`0f829cd25`)上做故障注入复现出四类"每走一步都卡住"的残留,本轮按"超时后真正隔离并核对底层操作"收口,不再新增 operation 字段:
|
||||
|
||||
1. **本地会话写入队列不再被卡死**:`platformSession.ts` 的 native mutation 队列改为带"解围期限"的闸门(60s)。Rust `install_platform_session_in` / `clear_platform_session_in` 本来就按 generation 单调校验(更旧的 install/clear 一律拒绝),所以渲染层只需保证新 generation 不被旧调用无限挡住;迟到的旧写入由 Rust 拒绝。
|
||||
2. **generation floor 读取失败可重试**:`reserveNativePlatformSessionGeneration` 不再把一次瞬时失败缓存成永久失败(原先 `??=` 缓存了已 reject 的 promise,导致同一次渲染进程内后续登录/退出全部失败)。
|
||||
3. **登录围栏超时后的迟到结果必须落地**:UI 的 45s 围栏只放弃等待,不再放弃结果。本地运行时确实装好会话时,界面跟着进入工作区,避免"后端已登录、前端停在登录页"。
|
||||
4. **首页自动建项有兜底期限与恢复入口**:`home-create` 从 `deadlineMs: null` 改为 10 分钟兜底期限;到点后首页入口解围(底层创建继续在后台跑,迟到成功照常进项目),并把已建好的工作区登记进最近项目、在首页给出「打开已创建的工作区」入口。外层 catch 不再用初始 operation 覆盖内层已写入的 `scope.projectPath`;底层创建未返回期间只挡"再建一个",不挡打开已有项目。
|
||||
|
||||
## 现役边界审计
|
||||
|
||||
以下能力在本次切换前已经具备 durable 恢复或失败关闭合同,因此本轮按现有实现接入统一投影,不重复重写:
|
||||
@@ -82,4 +91,17 @@
|
||||
| 最近项目逐行刷新 | `recentProjectsModel.test.ts`、home appSurface |
|
||||
| dev-stack 身份 | `scripts/dev.test.ts`、`start-dev-stack.test.ts`、端口 marker 检查 |
|
||||
| 本地恢复边界 | 现有 manifest/runtime/resource recovery tests;未确认外部副作用不自动重放 |
|
||||
| 超时隔离与迟到结果 | `auth.suite.ts`(stalled native install 不阻塞后续登录、floor 瞬时失败可重试、围栏超时后迟到安装落地)、`home.suite.ts`(建项看门狗解围并保留迟到成功、设计运行时初始化失败后保留工作区并可打开) |
|
||||
| 完整流程 | AGC appSurface、开发栈 smoke;真实 Provider/安装包另行记录 |
|
||||
|
||||
### 本轮门禁执行记录
|
||||
|
||||
- `npm --prefix apps/ai-game-creator-shell run typecheck`(含 skill-pack、check-config)通过。
|
||||
- `apps/ai-game-creator-shell/tests/appSurface.test.ts` 428/428 通过(含本轮新增 5 个故障注入回归)。
|
||||
- 定向:`clientHttp`、`clientApi`、`clientOperation`、`recentProjectsModel`、`clientRuntimeErrorBoundary`、`sessionPreview`、`start-dev-stack`、`dev-port`、`start-tauri-dev` 全部通过。
|
||||
|
||||
### 待验证(本轮未确认,不作为结论)
|
||||
|
||||
- 原生(真实 Runner/IPC)与真实 Provider 下的同一批时序未执行:本轮结论来自 deterministic surface 与 mock 故障注入。
|
||||
- `src-tauri/src/project/bootstrap.rs` 在 `npm install` 之前读取 `package-lock.json` 计算 `lockSha256`,安装后仍使用旧字节;疑似只影响审计准确性,未复现、未修改。
|
||||
- 同 PID 下的写入 advisory guard(`write_lock.rs` 的 `bypassed_same_process`)是否会放过并行写,尚未排除误报。
|
||||
|
||||
Reference in New Issue
Block a user