Merge branch 'master' into codex/phaser-directproject-migration
This commit is contained in:
@@ -84,6 +84,7 @@ function resolveBackendTargetsFromState(
|
||||
requireAgcBackend = false,
|
||||
expectedDatabase = backendDatabase,
|
||||
expectedSpacetimeDataDir = backendSpacetimeDataDir,
|
||||
expectedRepoRoot = repoRoot,
|
||||
fallbackApiTarget = defaultApiTarget,
|
||||
} = {},
|
||||
) {
|
||||
@@ -101,7 +102,25 @@ function resolveBackendTargetsFromState(
|
||||
const hasMatchingDataDir =
|
||||
Boolean(spacetimeDataDir) &&
|
||||
spacetimeDataDir === resolve(expectedSpacetimeDataDir);
|
||||
const hasMatchingBackend = hasMatchingDatabase && hasMatchingDataDir;
|
||||
const instanceId =
|
||||
typeof state?.instanceId === 'string' ? state.instanceId.trim() : '';
|
||||
const hasMatchingRepoRoot =
|
||||
typeof state?.repoRoot === 'string' &&
|
||||
resolve(state.repoRoot) === resolve(expectedRepoRoot);
|
||||
const hasMatchingInstance =
|
||||
Boolean(instanceId) &&
|
||||
[apiServer, spacetime, bgfilterWorker]
|
||||
.filter(Boolean)
|
||||
.every(
|
||||
(service) =>
|
||||
service.repoRoot &&
|
||||
resolve(service.repoRoot) === resolve(expectedRepoRoot) &&
|
||||
service.instanceId === instanceId,
|
||||
);
|
||||
const hasMatchingBackend =
|
||||
hasMatchingDatabase &&
|
||||
hasMatchingDataDir &&
|
||||
(!requireAgcBackend || (hasMatchingRepoRoot && hasMatchingInstance));
|
||||
const canReuseState = !requireAgcBackend || hasMatchingBackend;
|
||||
const apiUrl =
|
||||
canReuseState && isActive(apiServer) && apiServer.url
|
||||
@@ -127,6 +146,8 @@ function resolveBackendTargetsFromState(
|
||||
spacetimeDataDir,
|
||||
hasMatchingDatabase,
|
||||
hasMatchingDataDir,
|
||||
hasMatchingRepoRoot,
|
||||
hasMatchingInstance,
|
||||
hasMatchingBackend,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -860,14 +860,11 @@ pub(crate) fn acquire_project_write_lock_failure(
|
||||
}
|
||||
}
|
||||
}
|
||||
if crate::agent::autonomous_game_build_root_run_active_at(root)
|
||||
&& project_write_lock_is_owned_by_current_process(&path)
|
||||
{
|
||||
// The autonomous game-build lane intentionally permits
|
||||
// parallel specialist actions. If the durable lock belongs
|
||||
// to this very process, contention is an in-process overlap,
|
||||
// not another application editing the project. Return an
|
||||
// advisory guard and leave the real lock untouched.
|
||||
if project_write_lock_is_owned_by_current_process(&path) {
|
||||
// A project lock is the client-use lock. Nested calls in
|
||||
// the same client process must reuse that ownership instead
|
||||
// of waiting on their own durable marker. Cross-process
|
||||
// contenders still take the normal retryable path.
|
||||
return Ok(ProjectWriteLock {
|
||||
path,
|
||||
content: String::new(),
|
||||
|
||||
@@ -5611,12 +5611,14 @@ fn local_project_checkpoint_diff_restore_and_index_are_recorded() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_write_lock_rejects_parallel_writer_and_releases_on_drop() {
|
||||
fn project_write_lock_reuses_same_process_owner_and_releases_on_drop() {
|
||||
let root = unique_project_path();
|
||||
let first = acquire_project_write_lock(&root, "file.write").expect("first lock");
|
||||
|
||||
let error = acquire_project_write_lock(&root, "file.delete").expect_err("second lock fails");
|
||||
assert!(error.contains("项目正在被其他写操作占用"));
|
||||
let nested = acquire_project_write_lock(&root, "file.delete")
|
||||
.expect("same process must reuse the client project lock");
|
||||
drop(nested);
|
||||
assert!(root.join(PROJECT_WRITE_LOCK_PATH).exists());
|
||||
|
||||
drop(first);
|
||||
acquire_project_write_lock(&root, "file.delete").expect("lock released");
|
||||
|
||||
@@ -93,6 +93,7 @@ export function WorkspaceLauncherShell({
|
||||
startGameFromApprovedGdd,
|
||||
createHomeDraftAutomatically,
|
||||
openProject,
|
||||
homeCreationBusy,
|
||||
} = homeProject;
|
||||
const switchedToGameRuntime =
|
||||
gameRuntimeSwitch !== null &&
|
||||
@@ -522,7 +523,7 @@ export function WorkspaceLauncherShell({
|
||||
onStatusChange={setStatus}
|
||||
recentProjectRows={recentProjectRows}
|
||||
onCreateDraftAutomatically={createHomeDraftAutomatically}
|
||||
creationBusy={homeProject.projectAction === 'creating'}
|
||||
creationBusy={homeCreationBusy}
|
||||
onProjectsOpen={() => setLauncherView('projects')}
|
||||
onProjectOpen={(path) => {
|
||||
setProjectPath(path);
|
||||
|
||||
@@ -26,6 +26,11 @@ import type {
|
||||
TauriInvoke,
|
||||
UploadLocalAssetResult,
|
||||
} from '../../app/types';
|
||||
import {
|
||||
type ClientOperation,
|
||||
createClientOperation,
|
||||
transitionClientOperation,
|
||||
} from '../../services/clientOperation';
|
||||
import type {
|
||||
HomeAttachmentDraft,
|
||||
HomeCreationType,
|
||||
@@ -117,6 +122,21 @@ export function useHomeProjectCreation({
|
||||
(state) => state.reset,
|
||||
);
|
||||
const approvedGddStartInFlightRef = useRef(false);
|
||||
const [homeCreationOperation, setHomeCreationOperation] =
|
||||
useState<ClientOperation<
|
||||
'home-create',
|
||||
{
|
||||
draft: HomeDraft;
|
||||
startMode: ProjectStartMode;
|
||||
}
|
||||
> | null>(null);
|
||||
const homeCreationOperationRef = useRef(homeCreationOperation);
|
||||
homeCreationOperationRef.current = homeCreationOperation;
|
||||
|
||||
function homeCreationIsBusy() {
|
||||
const phase = homeCreationOperationRef.current?.phase;
|
||||
return phase === 'network' || phase === 'runner' || phase === 'project';
|
||||
}
|
||||
/**
|
||||
* 进项目流程的代次。
|
||||
*
|
||||
@@ -644,13 +664,19 @@ export function useHomeProjectCreation({
|
||||
startMode: ProjectStartMode,
|
||||
options: { suggestName: boolean },
|
||||
) {
|
||||
if (projectActionRef.current) {
|
||||
if (projectActionRef.current || homeCreationIsBusy()) {
|
||||
return '已有项目操作进行中,请稍候';
|
||||
}
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) {
|
||||
throw new Error('需要在陶泥儿客户端内运行');
|
||||
}
|
||||
const operation = createClientOperation(
|
||||
'home-create',
|
||||
{ draft, startMode },
|
||||
{ deadlineMs: null, cancellable: false },
|
||||
);
|
||||
setHomeCreationOperation(transitionClientOperation(operation, 'network'));
|
||||
// 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.
|
||||
@@ -668,6 +694,11 @@ export function useHomeProjectCreation({
|
||||
planning: startMode === 'planning',
|
||||
},
|
||||
);
|
||||
setHomeCreationOperation(
|
||||
transitionClientOperation(operation, 'project', {
|
||||
scope: { projectPath: result.projectPath },
|
||||
}),
|
||||
);
|
||||
try {
|
||||
await enterCreatedHomeProject(
|
||||
invoke,
|
||||
@@ -677,15 +708,30 @@ export function useHomeProjectCreation({
|
||||
draft.attachments,
|
||||
startMode,
|
||||
);
|
||||
setHomeCreationOperation(
|
||||
transitionClientOperation(operation, 'success', {
|
||||
scope: { projectPath: result.projectPath },
|
||||
}),
|
||||
);
|
||||
setStatus('已创建工作区,正在开始智能创作');
|
||||
return '已创建工作区并进入项目开发';
|
||||
} catch (error) {
|
||||
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'),
|
||||
);
|
||||
throw error;
|
||||
} finally {
|
||||
if (projectActionRef.current === 'creating') {
|
||||
projectActionRef.current = null;
|
||||
@@ -802,6 +848,8 @@ export function useHomeProjectCreation({
|
||||
setAgentResults,
|
||||
projectAction,
|
||||
projectBusy: projectAction !== null,
|
||||
homeCreationOperation,
|
||||
homeCreationBusy: homeCreationIsBusy(),
|
||||
pendingNonEmptyProject,
|
||||
resetLauncherHomeDraft,
|
||||
startGameFromApprovedGdd,
|
||||
|
||||
@@ -20,6 +20,11 @@ import {
|
||||
getClientServerBaseUrl,
|
||||
readClientHttpResponseText,
|
||||
} from './clientHttp';
|
||||
import {
|
||||
type ClientOperation,
|
||||
createClientOperation,
|
||||
transitionClientOperation,
|
||||
} from './clientOperation';
|
||||
|
||||
const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1';
|
||||
|
||||
@@ -55,6 +60,14 @@ export function clearStoredAuthAccessToken() {
|
||||
}
|
||||
|
||||
const clientAuthRefreshPromises = new Map<string, Promise<string>>();
|
||||
const clientAuthRefreshOperations = new Map<
|
||||
string,
|
||||
ClientOperation<'auth-refresh', { apiBaseUrl: string }>
|
||||
>();
|
||||
|
||||
export function getClientAuthRefreshOperation(apiBaseUrl: string) {
|
||||
return clientAuthRefreshOperations.get(apiBaseUrl) ?? null;
|
||||
}
|
||||
|
||||
const CLIENT_AUTH_NETWORK_ERROR_MESSAGE =
|
||||
'无法连接登录服务,请确认配套后端或 API 代理已启动后重试';
|
||||
@@ -187,6 +200,15 @@ export async function refreshClientAuthAccessToken(
|
||||
) {
|
||||
const current = clientAuthRefreshPromises.get(apiBaseUrl);
|
||||
if (current) return current;
|
||||
const operation = createClientOperation(
|
||||
'auth-refresh',
|
||||
{ apiBaseUrl },
|
||||
{ scope: { apiBaseUrl }, deadlineMs: 15_000 },
|
||||
);
|
||||
clientAuthRefreshOperations.set(
|
||||
apiBaseUrl,
|
||||
transitionClientOperation(operation, 'network'),
|
||||
);
|
||||
const refreshPromise = requestAuthJson<AuthRefreshResponse>(
|
||||
'/api/auth/refresh',
|
||||
{ method: 'POST' },
|
||||
@@ -194,9 +216,20 @@ export async function refreshClientAuthAccessToken(
|
||||
{ skipAuth: true, apiBaseUrl },
|
||||
)
|
||||
.then((response) => {
|
||||
clientAuthRefreshOperations.set(
|
||||
apiBaseUrl,
|
||||
transitionClientOperation(operation, 'success'),
|
||||
);
|
||||
setStoredAuthAccessToken(response.token);
|
||||
return response.token;
|
||||
})
|
||||
.catch((error) => {
|
||||
clientAuthRefreshOperations.set(
|
||||
apiBaseUrl,
|
||||
transitionClientOperation(operation, 'retryable-failure'),
|
||||
);
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
if (clientAuthRefreshPromises.get(apiBaseUrl) === refreshPromise) {
|
||||
clientAuthRefreshPromises.delete(apiBaseUrl);
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
export type ClientOperationPhase =
|
||||
| 'idle'
|
||||
| 'network'
|
||||
| 'runner'
|
||||
| 'project'
|
||||
| 'success'
|
||||
| 'retryable-failure'
|
||||
| 'unknown';
|
||||
|
||||
export type ClientOperationScope = {
|
||||
projectPath?: string;
|
||||
apiBaseUrl?: string;
|
||||
sessionGeneration?: number;
|
||||
};
|
||||
|
||||
export type ClientOperation<
|
||||
TKind extends string = string,
|
||||
TPayload = unknown,
|
||||
> = {
|
||||
operationId: string;
|
||||
requestId: string;
|
||||
kind: TKind;
|
||||
phase: ClientOperationPhase;
|
||||
startedAt: number;
|
||||
deadlineAt: number | null;
|
||||
scope: ClientOperationScope;
|
||||
payload: TPayload;
|
||||
cancellable: boolean;
|
||||
cancelled: boolean;
|
||||
};
|
||||
|
||||
let operationSequence = 0;
|
||||
|
||||
function randomOperationPart() {
|
||||
try {
|
||||
return crypto.randomUUID();
|
||||
} catch {
|
||||
operationSequence += 1;
|
||||
return `${Date.now().toString(36)}-${operationSequence.toString(36)}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function createClientOperation<TKind extends string, TPayload>(
|
||||
kind: TKind,
|
||||
payload: TPayload,
|
||||
options: {
|
||||
scope?: ClientOperationScope;
|
||||
deadlineMs?: number | null;
|
||||
cancellable?: boolean;
|
||||
} = {},
|
||||
): ClientOperation<TKind, TPayload> {
|
||||
const startedAt = Date.now();
|
||||
const deadlineMs = options.deadlineMs ?? null;
|
||||
return {
|
||||
operationId: `op-${randomOperationPart()}`,
|
||||
requestId: `req-${randomOperationPart()}`,
|
||||
kind,
|
||||
phase: 'idle',
|
||||
startedAt,
|
||||
deadlineAt:
|
||||
deadlineMs === null ? null : startedAt + Math.max(1, deadlineMs),
|
||||
scope: { ...options.scope },
|
||||
payload,
|
||||
cancellable: options.cancellable ?? true,
|
||||
cancelled: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function transitionClientOperation<TKind extends string, TPayload>(
|
||||
operation: ClientOperation<TKind, TPayload>,
|
||||
phase: ClientOperationPhase,
|
||||
patch: Partial<
|
||||
Pick<ClientOperation<TKind, TPayload>, 'scope' | 'payload'>
|
||||
> = {},
|
||||
) {
|
||||
return {
|
||||
...operation,
|
||||
...patch,
|
||||
scope: patch.scope
|
||||
? { ...operation.scope, ...patch.scope }
|
||||
: operation.scope,
|
||||
phase,
|
||||
};
|
||||
}
|
||||
|
||||
export function cancelClientOperation<TKind extends string, TPayload>(
|
||||
operation: ClientOperation<TKind, TPayload>,
|
||||
) {
|
||||
return { ...operation, phase: 'unknown' as const, cancelled: true };
|
||||
}
|
||||
|
||||
export function isCurrentClientOperation(
|
||||
operation: ClientOperation | null | undefined,
|
||||
operationId: string,
|
||||
) {
|
||||
return Boolean(
|
||||
operation && operation.operationId === operationId && !operation.cancelled,
|
||||
);
|
||||
}
|
||||
|
||||
export function clientOperationCanRetry(
|
||||
operation: ClientOperation | null | undefined,
|
||||
) {
|
||||
return operation?.phase === 'retryable-failure';
|
||||
}
|
||||
@@ -6,6 +6,11 @@ import {
|
||||
refreshClientAuthAccessToken,
|
||||
} from './clientAuth';
|
||||
import { getClientServerBaseUrl } from './clientHttp';
|
||||
import {
|
||||
type ClientOperation,
|
||||
createClientOperation,
|
||||
transitionClientOperation,
|
||||
} from './clientOperation';
|
||||
|
||||
const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1';
|
||||
|
||||
@@ -34,12 +39,20 @@ let committedPlatformSession: CommittedPlatformSession | null = null;
|
||||
let desiredPlatformSession: CommittedPlatformSession | null = null;
|
||||
let platformSessionRefreshPromise: Promise<PlatformSessionRefreshResult> | null =
|
||||
null;
|
||||
let platformSessionOperation: ClientOperation<
|
||||
'auth-transition',
|
||||
{ userId: string | null }
|
||||
> | null = null;
|
||||
let platformSessionNativeMutationTail: Promise<void> = Promise.resolve();
|
||||
const platformSessionRefreshListeners =
|
||||
new Set<PlatformSessionRefreshListener>();
|
||||
const platformSessionGenerationListeners =
|
||||
new Set<PlatformSessionGenerationListener>();
|
||||
|
||||
export function getPlatformSessionOperation() {
|
||||
return platformSessionOperation;
|
||||
}
|
||||
|
||||
function restoreCommittedAccessToken() {
|
||||
if (committedPlatformSession?.accessToken) {
|
||||
window.localStorage.setItem(
|
||||
@@ -259,15 +272,44 @@ export async function commitAuthenticatedPlatformSession(
|
||||
if (!accessToken) {
|
||||
throw new Error('陶泥儿登录凭据缺失,请重新登录');
|
||||
}
|
||||
const operation = createClientOperation(
|
||||
'auth-transition',
|
||||
{ userId: user.id },
|
||||
{
|
||||
scope: { apiBaseUrl, sessionGeneration: expectedGeneration },
|
||||
deadlineMs: 45_000,
|
||||
cancellable: false,
|
||||
},
|
||||
);
|
||||
platformSessionOperation = transitionClientOperation(operation, 'runner');
|
||||
return enqueuePlatformSessionNativeMutation(async () => {
|
||||
const session = await commitPlatformSession(
|
||||
user,
|
||||
accessToken,
|
||||
apiBaseUrl,
|
||||
expectedGeneration,
|
||||
);
|
||||
if (!session) return null;
|
||||
return session.generation;
|
||||
try {
|
||||
const session = await commitPlatformSession(
|
||||
user,
|
||||
accessToken,
|
||||
apiBaseUrl,
|
||||
expectedGeneration,
|
||||
);
|
||||
if (!session) {
|
||||
platformSessionOperation = transitionClientOperation(
|
||||
operation,
|
||||
'unknown',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
platformSessionOperation = transitionClientOperation(
|
||||
operation,
|
||||
'success',
|
||||
{ scope: { sessionGeneration: session.generation } },
|
||||
);
|
||||
return session.generation;
|
||||
} catch (error) {
|
||||
platformSessionOperation = transitionClientOperation(
|
||||
operation,
|
||||
'retryable-failure',
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -375,30 +417,64 @@ export function subscribePlatformSessionGeneration(
|
||||
}
|
||||
|
||||
export async function clearCommittedPlatformSession(generation: number) {
|
||||
const operation = createClientOperation(
|
||||
'auth-transition',
|
||||
{ userId: null },
|
||||
{
|
||||
scope: { sessionGeneration: generation },
|
||||
deadlineMs: 45_000,
|
||||
cancellable: false,
|
||||
},
|
||||
);
|
||||
platformSessionOperation = transitionClientOperation(operation, 'runner');
|
||||
return enqueuePlatformSessionNativeMutation(async () => {
|
||||
if (platformAuthGeneration !== generation) {
|
||||
await reconcileNativePlatformSessionToCurrentAuthority();
|
||||
return;
|
||||
}
|
||||
desiredPlatformSession = null;
|
||||
const nativeGeneration = await reserveNativePlatformSessionGeneration();
|
||||
try {
|
||||
await clearNativePlatformSession(nativeGeneration);
|
||||
} catch {
|
||||
if (platformAuthGeneration === generation) {
|
||||
desiredPlatformSession = null;
|
||||
if (platformAuthGeneration !== generation) {
|
||||
await reconcileNativePlatformSessionToCurrentAuthority();
|
||||
platformSessionOperation = transitionClientOperation(
|
||||
operation,
|
||||
'unknown',
|
||||
);
|
||||
return;
|
||||
}
|
||||
await reconcileNativePlatformSessionToCurrentAuthority();
|
||||
return;
|
||||
desiredPlatformSession = null;
|
||||
const nativeGeneration = await reserveNativePlatformSessionGeneration();
|
||||
try {
|
||||
await clearNativePlatformSession(nativeGeneration);
|
||||
} catch {
|
||||
if (platformAuthGeneration === generation) {
|
||||
desiredPlatformSession = null;
|
||||
}
|
||||
await reconcileNativePlatformSessionToCurrentAuthority();
|
||||
platformSessionOperation = transitionClientOperation(
|
||||
operation,
|
||||
'retryable-failure',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (platformAuthGeneration !== generation) {
|
||||
await reconcileNativePlatformSessionToCurrentAuthority();
|
||||
platformSessionOperation = transitionClientOperation(
|
||||
operation,
|
||||
'unknown',
|
||||
);
|
||||
return;
|
||||
}
|
||||
committedPlatformSession = null;
|
||||
desiredPlatformSession = null;
|
||||
restoreCommittedAccessToken();
|
||||
notifyPlatformSessionGeneration();
|
||||
platformSessionOperation = transitionClientOperation(
|
||||
operation,
|
||||
'success',
|
||||
);
|
||||
} catch (error) {
|
||||
platformSessionOperation = transitionClientOperation(
|
||||
operation,
|
||||
'retryable-failure',
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
if (platformAuthGeneration !== generation) {
|
||||
await reconcileNativePlatformSessionToCurrentAuthority();
|
||||
return;
|
||||
}
|
||||
committedPlatformSession = null;
|
||||
desiredPlatformSession = null;
|
||||
restoreCommittedAccessToken();
|
||||
notifyPlatformSessionGeneration();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -409,6 +485,7 @@ export function resetPlatformSessionStateForTests() {
|
||||
committedPlatformSession = null;
|
||||
desiredPlatformSession = null;
|
||||
platformSessionRefreshPromise = null;
|
||||
platformSessionOperation = null;
|
||||
platformSessionNativeMutationTail = Promise.resolve();
|
||||
platformSessionRefreshListeners.clear();
|
||||
platformSessionGenerationListeners.clear();
|
||||
|
||||
@@ -6,7 +6,10 @@ import {
|
||||
requestClientApi,
|
||||
setStoredAuthAccessToken,
|
||||
} from '../src/services/clientApi';
|
||||
import { refreshClientAuthAccessToken } from '../src/services/clientAuth';
|
||||
import {
|
||||
getClientAuthRefreshOperation,
|
||||
refreshClientAuthAccessToken,
|
||||
} from '../src/services/clientAuth';
|
||||
import {
|
||||
beginPlatformSessionTransition,
|
||||
commitAuthenticatedPlatformSession,
|
||||
@@ -169,6 +172,10 @@ it('响应体卡住超时后,下一次续期会重新发起请求', async () =
|
||||
const firstAssertion = expect(first).rejects.toThrow();
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
await firstAssertion;
|
||||
expect(getClientAuthRefreshOperation('http://localhost:3000')).toMatchObject({
|
||||
kind: 'auth-refresh',
|
||||
phase: 'retryable-failure',
|
||||
});
|
||||
|
||||
const second = refreshClientAuthAccessToken('http://localhost:3000');
|
||||
const secondAssertion = expect(second).rejects.toThrow();
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
cancelClientOperation,
|
||||
clientOperationCanRetry,
|
||||
createClientOperation,
|
||||
isCurrentClientOperation,
|
||||
transitionClientOperation,
|
||||
} from '../src/services/clientOperation';
|
||||
|
||||
describe('AGC client operation contract', () => {
|
||||
it('keeps operation/request identity and deadline across phase transitions', () => {
|
||||
const operation = createClientOperation(
|
||||
'home-create',
|
||||
{ prompt: '做一个游戏' },
|
||||
{ scope: { projectPath: 'C:\\games\\demo' }, deadlineMs: 15_000 },
|
||||
);
|
||||
const network = transitionClientOperation(operation, 'network');
|
||||
const project = transitionClientOperation(network, 'project', {
|
||||
scope: { projectPath: 'C:\\games\\created' },
|
||||
});
|
||||
|
||||
expect(project.operationId).toBe(operation.operationId);
|
||||
expect(project.requestId).toBe(operation.requestId);
|
||||
expect(project.deadlineAt).toBe(operation.startedAt + 15_000);
|
||||
expect(project.scope.projectPath).toBe('C:\\games\\created');
|
||||
expect(project.cancellable).toBe(true);
|
||||
expect(isCurrentClientOperation(project, operation.operationId)).toBe(true);
|
||||
});
|
||||
|
||||
it('marks cancellation as stale and does not make it retryable', () => {
|
||||
const operation = createClientOperation('auth-refresh', null);
|
||||
const failed = transitionClientOperation(operation, 'retryable-failure');
|
||||
expect(clientOperationCanRetry(failed)).toBe(true);
|
||||
const cancelled = cancelClientOperation(failed);
|
||||
expect(cancelled.cancelled).toBe(true);
|
||||
expect(isCurrentClientOperation(cancelled, operation.operationId)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(clientOperationCanRetry(cancelled)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -36,26 +36,30 @@ const ownedBackend = async () => ({
|
||||
});
|
||||
|
||||
function backendState(spacetimeDataDir?: string, includeBgfilterWorker = true) {
|
||||
const instanceId = 'fixture-instance';
|
||||
const service = (url: string) => ({
|
||||
status: 'running',
|
||||
url,
|
||||
repoRoot: resolve('.'),
|
||||
instanceId,
|
||||
});
|
||||
return {
|
||||
schemaVersion: spacetimeDataDir ? 2 : 1,
|
||||
repoRoot: resolve('.'),
|
||||
instanceId,
|
||||
database: expectedDatabase,
|
||||
updatedAt: '',
|
||||
...(spacetimeDataDir ? { spacetimeDataDir } : {}),
|
||||
services: {
|
||||
'api-server': {
|
||||
status: 'running',
|
||||
url: 'http://127.0.0.1:8082',
|
||||
...service('http://127.0.0.1:8082'),
|
||||
},
|
||||
spacetime: {
|
||||
status: 'running',
|
||||
url: 'http://127.0.0.1:3101',
|
||||
...service('http://127.0.0.1:3101'),
|
||||
},
|
||||
...(includeBgfilterWorker
|
||||
? {
|
||||
'bgfilter-worker': {
|
||||
status: 'running',
|
||||
url: 'http://127.0.0.1:8083',
|
||||
},
|
||||
'bgfilter-worker': service('http://127.0.0.1:8083'),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
@@ -113,6 +117,30 @@ describe('AI 游戏创作配套后端复用门禁', () => {
|
||||
expect(matching.bgfilterWorkerUrl).toBe('http://127.0.0.1:8083');
|
||||
});
|
||||
|
||||
test('旧状态缺少 repoRoot 或 instanceId 时拒绝复用', () => {
|
||||
const state = backendState(expectedDataDir);
|
||||
delete state.repoRoot;
|
||||
delete state.instanceId;
|
||||
for (const service of Object.values(state.services)) {
|
||||
if (service) {
|
||||
delete service.repoRoot;
|
||||
delete service.instanceId;
|
||||
}
|
||||
}
|
||||
const targets = resolveBackendTargetsFromState(state, {
|
||||
requireAgcBackend: true,
|
||||
expectedDatabase,
|
||||
expectedSpacetimeDataDir: expectedDataDir,
|
||||
});
|
||||
|
||||
expect(targets.hasMatchingDatabase).toBe(true);
|
||||
expect(targets.hasMatchingDataDir).toBe(true);
|
||||
expect(targets.hasMatchingRepoRoot).toBe(false);
|
||||
expect(targets.hasMatchingInstance).toBe(false);
|
||||
expect(targets.hasMatchingBackend).toBe(false);
|
||||
expect(targets.apiUrl).toBe('');
|
||||
});
|
||||
|
||||
test('worker 缺失或未 ready 时不允许复用后端', async () => {
|
||||
const isReady = vi.fn(async (_url: string) => true);
|
||||
|
||||
|
||||
@@ -63,6 +63,8 @@ export default defineConfig({
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
app: 'ai-game-creator-shell',
|
||||
repoRoot,
|
||||
processId: process.pid,
|
||||
port: server.config.server.port,
|
||||
apiTarget,
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user