建立 AGC 稳定版生命周期基础合同
新增 ClientOperation 状态、身份和 stale-result 规则 让认证 refresh 与首页创建记录统一 operation phase 和 scope 为 dev-stack 与 AGC Vite marker 增加工作树和实例身份门禁 补充稳定版切换主规范、里程碑计划和开发运维踩坑文档
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,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