建立 AGC 稳定版生命周期基础合同

新增 ClientOperation 状态、身份和 stale-result 规则

让认证 refresh 与首页创建记录统一 operation phase 和 scope

为 dev-stack 与 AGC Vite marker 增加工作树和实例身份门禁

补充稳定版切换主规范、里程碑计划和开发运维踩坑文档
This commit is contained in:
2026-09-14 12:50:51 +08:00
parent 86c1187ddc
commit 3d5349057a
17 changed files with 460 additions and 14 deletions
@@ -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,
}),