建立 AGC 稳定版生命周期基础合同 #348
@@ -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,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,
|
||||
}),
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
|
||||
- [AI 游戏创作智能体 App 实施计划](./technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md):当前 DirectProject、受控语义工具、UI workflow、资源和运行时合同。
|
||||
- [AGC 异步操作可恢复闭环](./【技术方案】AGC异步操作可恢复闭环-2026-09-14.md):认证响应体、最近项目检查和首页自动创建的超时、逐项恢复与跨页防重合同。
|
||||
- [AGC 客户端稳定版生命周期大切换](./【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md):统一 operation、认证/Runner、项目入口、本地恢复和 dev-stack 身份边界。
|
||||
- [策划会话 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 拆分、命名、启用和启动时注入边界。
|
||||
|
||||
@@ -5495,3 +5495,10 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
|
||||
- **真机判据**:点进任一栏目(或展开「所有资源」)时,**每一张**卡片都从它那一摞的位置/尺寸位移并缩放到自己的位置,而不只是总览里那 3 张;同一栏目里不同类型的两摞各自飞各自的卡。`prefers-reduced-motion: reduce` 下仍不播放动画(口径未改)。
|
||||
- **已知未覆盖**:① 子画布内直接切到另一个栏目(分页画布换栏目)时目标栏目的卡片在该次 `begin` 时未渲染、拿不到任何 First,整段转场仍按旧口径跳过(`play` 的 `!captured.entries.size` 早退),本次未改;② 真机动画观感由浏览器渲染,jsdom 只覆盖几何与调用契约,需要按上面那条判据人工确认一次。
|
||||
- **关联**:`apps/ai-game-creator-shell/src/view/project-development/resourceBookController.ts`(`begin` 的堆锚点 / `syncNodes` 的合成 First)、`resourceBookLayout.ts`(`allOpen` 列号)、`index.tsx`(宿主 `data-resource-book-stack-*`)、`apps/ai-game-creator-shell/tests/{resourceBookController,resourceBookLayout}.test.ts`、`tests/appSurface/project-development.suite.ts`、`docs/technical/【技术方案】GameAgent资源自由画板与快速编辑-2026-08-20.md`。
|
||||
|
||||
## 稳定版 AGC 复用开发栈必须核对 instance identity
|
||||
|
||||
- 现象:端口和 `/healthz` 都正常,但 AGC 连接了另一个 worktree 的 API、SpacetimeDB 或旧 Vite,表现为登录、项目列表、Runner 状态与当前代码不一致。
|
||||
- 原因:健康检查只能证明“有服务响应”,不能证明服务属于当前工作树;旧 `.app/dev-stack.json` 可能没有当前 `repoRoot`、`instanceId` 和服务级 dataDir 身份。
|
||||
- 处理:先读取 `.app/dev-stack.json`,核对顶层 `repoRoot + instanceId`,再核对服务 `repoRoot + instanceId + dataDir + pid + port`;AGC Vite marker 还必须带 `repoRoot + processId + port`。任何字段缺失或不匹配都拒绝静默复用,改为启动当前工作树自己的服务或明确提示清理。
|
||||
- 验证:`scripts/dev.test.ts`、`apps/ai-game-creator-shell/tests/start-dev-stack.test.ts` 覆盖 snapshot identity 和旧状态拒绝复用;运行时记录实际端口、进程命令行和 dataDir,不要只记录 HTTP 200。
|
||||
|
||||
@@ -36,7 +36,7 @@ npm run dev
|
||||
- 主站 Vite。
|
||||
- 后台 Vite。
|
||||
|
||||
`npm run dev` 和单模块 `npm run dev:web`、`npm run dev:api-server`、`npm run dev:bgfilter-worker`、`npm run dev:spacetime`、`npm run dev:admin-web` 启动后都会更新根目录 `.app/dev-stack.json`。该文件记录本次命令、数据库、更新时间,以及 `spacetime`、`api-server`、`bgfilter-worker`、`web`、`admin-web` 的 `pid`、监听 host / port、可访问 URL、启动状态和当前命令。`.app/` 是本地运行态目录,不提交 Git;端口漂移、服务重启或子进程退出后以该文件里的实际状态为准。
|
||||
`npm run dev` 和单模块 `npm run dev:web`、`npm run dev:api-server`、`npm run dev:bgfilter-worker`、`npm run dev:spacetime`、`npm run dev:admin-web` 启动后都会更新根目录 `.app/dev-stack.json`。该文件记录本次命令、数据库、更新时间,以及 `spacetime`、`api-server`、`bgfilter-worker`、`web`、`admin-web` 的 `pid`、监听 host / port、可访问 URL、启动状态和当前命令;稳定版状态还记录顶层 `repoRoot + instanceId`,每个服务记录 `repoRoot + instanceId + dataDir`,与端口组成复用身份。`.app/` 是本地运行态目录,不提交 Git;端口漂移、服务重启或子进程退出后以该文件里的实际状态为准。缺少身份字段或身份不匹配的旧状态不得被 AGC 静默复用。
|
||||
|
||||
通过 `nohup` 在仓库根目录启动 dev 栈且未显式重定向 stdout / stderr 时,默认 `nohup.out` 会持续收集 SpacetimeDB、api-server、bgfilter-worker、主站 Vite 和后台 Vite 的整套 dev 栈输出;该文件已被主站 Vite watcher 和 Git 忽略,避免日志追加触发页面刷新循环,重启主站 Vite 后生效。若把输出显式重定向到其它仓库内文件(例如 `> dev.out`),该自定义文件不会自动获得同样的 watcher 保护,应改为写到 Vite root 之外,或同步配置精确的忽略规则。
|
||||
|
||||
@@ -62,7 +62,7 @@ Linux 本机多用户并发开发时,`npm run dev`、`npm run dev:*` 单模块
|
||||
|
||||
后端日志默认写入 `logs/api-server/`,独立 BgFilter worker 日志默认写入 `logs/bgfilter-worker/`。后端 API smoke 使用 `npm run dev:api-server`,先检查 BgFilter worker `/readyz`,再检查 API `/healthz`;需要确认 API 实例可接生产流量时检查 API `/readyz`。不要使用旧 `api-server:maincloud` 或任何 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 口径。
|
||||
|
||||
AI 游戏创作客户端使用 `npm run agc`。该入口由 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 解析 AGC Vite 实际端口:Linux 默认取当前用户端口段的 `start + 5`,占用时只在本用户段内漂移;Windows / macOS 保留 `3080` 为兼容首选并允许统一漂移。最终端口通过 `GENARRATIVE_AGC_VITE_PORT` 传给 `beforeDevCommand` 和配套后端端口解析器,通过 Tauri CLI 动态 `build.devUrl` 配置传给 WebView,并通过 Vite CLI `--port` 启动严格监听;Vite 继续使用 `strictPort`,任何一层都不得自行改到另一个端口。AGC 配套后端的 `backend` 模式启动 SpacetimeDB、独立 `bgfilter-worker` 和 `api-server`,并在复用现有后端前同时检查三者状态及 `/v1/ping`、`/readyz`、`/healthz`;worker 缺失时不得把不完整的 API/数据库组合误判为 ready。任一配套服务在启动阶段进入 `failed` 时,外层启动器必须立即报告具体服务和退出原因,不能继续等待前端地址超时。端口健康不等于归属正确:复用前还必须证明端口上的监听进程属于当前工作树(Windows 按 `server-rs/target/debug/api-server.exe` 绝对路径与 SpacetimeDB `--data-dir` 校验,探测不可用时退化为旧行为),无法证明归属时一律不复用,改为启动本工作树自己的后端并在需要时端口漂移;否则上个工作树 Ctrl+C 残留的后端会被当成自己的后端复用,改了数据库的工作树会连到旧库。启动器在创建原生窗口前预检最终地址;若竞态中该地址被 AGC Vite、无响应监听器或其它服务占用,一律失败关闭,不复用、也不擅自终止无法证明归属的进程。
|
||||
AI 游戏创作客户端使用 `npm run agc`。该入口由 `apps/ai-game-creator-shell/scripts/start-tauri-dev.mjs` 解析 AGC Vite 实际端口:Linux 默认取当前用户端口段的 `start + 5`,占用时只在本用户段内漂移;Windows / macOS 保留 `3080` 为兼容首选并允许统一漂移。最终端口通过 `GENARRATIVE_AGC_VITE_PORT` 传给 `beforeDevCommand` 和配套后端端口解析器,通过 Tauri CLI 动态 `build.devUrl` 配置传给 WebView,并通过 Vite CLI `--port` 启动严格监听;Vite 继续使用 `strictPort`,任何一层都不得自行改到另一个端口。AGC 配套后端的 `backend` 模式启动 SpacetimeDB、独立 `bgfilter-worker` 和 `api-server`,并在复用现有后端前同时检查三者状态及 `/v1/ping`、`/readyz`、`/healthz`;worker 缺失时不得把不完整的 API/数据库组合误判为 ready。任一配套服务在启动阶段进入 `failed` 时,外层启动器必须立即报告具体服务和退出原因,不能继续等待前端地址超时。端口健康不等于归属正确:复用前还必须证明端口上的监听进程属于当前工作树(Windows 按 `server-rs/target/debug/api-server.exe` 绝对路径与 SpacetimeDB `--data-dir` 校验,探测不可用时退化为旧行为),无法证明归属时一律不复用,改为启动本工作树自己的后端并在需要时端口漂移;否则上个工作树 Ctrl+C 残留的后端会被当成自己的后端复用,改了数据库的工作树会连到旧库。启动器在创建原生窗口前预检最终地址;AGC Vite marker 同时提供 `repoRoot + processId + port`,与 `.app/dev-stack.json` 的 `instanceId` 和 API target 交叉核对;若竞态中该地址被 AGC Vite、无响应监听器或其它服务占用,一律失败关闭,不复用、也不擅自终止无法证明归属的进程。
|
||||
|
||||
Tauri `beforeDevCommand` 默认与客户端构建并行,不能把上述检查只放在 `beforeDevCommand` 内:选定地址上若已有旧 Vite,Tauri 可能先创建加载旧前端的窗口,随后配套后端才因代理不匹配退出。外层启动器会把 Tauri CLI 放入受控进程树;CLI 正常退出、启动失败或收到终止信号后,POSIX 先向保留的 PGID 发送 `SIGTERM`、有界等待后升级 `SIGKILL`,Windows 使用 `taskkill /PID <pid> /T /F`。Windows 下每个长驻服务都经 `cmd.exe /d /s /c` 包装层启动,Ctrl+C 会先杀掉包装层(退出码 `0xC000013A`),因此清理不能只看直接子进程是否存活:`taskkill` 对已退出的 PID 只会失败,必须继续按记录下来的根 PID 遍历,并在退出时按本工作树 `api-server.exe` 绝对路径(以及本次自己拉起的 SpacetimeDB `--data-dir`)做一次身份兜底清扫;`scripts/dev-windows-process.mjs` 是这套判定的唯一实现。Linux 容器中的孤儿后代退出后可能暂时保留为 zombie,`kill(-PGID, 0)` 仍会返回成功;启动器必须结合 `/proc/<pid>/stat` 判断同组是否还存在非 zombie 成员,不能把等待 PID 1 回收误报为清理失败。配套后端和 Vite 仍由 `start-dev-stack.mjs` 各自持有,退出时同样有界收束,避免只剩客户端、Runner、Cargo 或旧订阅进程。排障时同时核对控制台输出的 AGC Vite 实际地址及其 marker、`.app/dev-stack.json` 的实际 API URL 和进程 cwd;不要把“终端已返回”当成客户端及其 Runner 已退出的证据。
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# 【技术方案】AGC 客户端稳定版生命周期大切换
|
||||
|
||||
更新时间:`2026-09-14`
|
||||
|
||||
## 目标
|
||||
|
||||
在 AGC 尚未对外发布的前提下,统一现役客户端入口的异步操作生命周期,并删除会继续制造重复状态机的旧局部防重路径。现有本地项目、`.agent` 文件、Runner 账本和公开后端契约继续作为迁移边界。
|
||||
|
||||
## 非目标
|
||||
|
||||
- 不删除或重排本地 manifest、会话、资源生成账本和 Runtime journal。
|
||||
- 不删除 `/api/external/v1`、账号、编辑器素材、生成接口、SpacetimeDB schema 或共享 DTO。
|
||||
- 不把新玩法、插件、支付、编辑器大功能和主站架构调整并入稳定版切换。
|
||||
- 不自动重放无法确认已经受理的 pending 外部操作。
|
||||
|
||||
## 新的内部合同
|
||||
|
||||
### ClientOperation
|
||||
|
||||
所有 renderer 异步入口必须能投影以下字段:`operationId`、`requestId`、`kind`、`phase`、`startedAt`、`deadlineAt`、`scope`、`cancelled`。phase 使用 `idle / network / runner / project / success / retryable-failure / unknown`;迟到结果只能在 operationId、scope 和 session generation 都仍匹配时写回。
|
||||
|
||||
### AuthTransition / RunnerTransition
|
||||
|
||||
启动恢复、手工登录、发送验证码、退出和 401 refresh 共享认证 operation 身份与 session generation。Runner 安装/清除在 blocking worker 执行;前端只展示 network/runner/success/retryable-failure/unknown,并提供重试或重新登录。
|
||||
|
||||
### HomeCreationOperation
|
||||
|
||||
首页创建由 Launcher controller 持有,payload 保留 draft 快照和 startMode,scope 在建项后绑定 projectPath;创建、附件导入、首轮投递分别推进 phase。页面卸载不取消 operation;成功、可重试失败和不确定状态均可被重新投影。
|
||||
|
||||
### RecentProjectInspection
|
||||
|
||||
每个最近项目拥有独立 operation 和结果。检查中、可打开、失败、超时、不存在、非目录、未初始化和可导入不能通过一个全局刷新 gate 互相覆盖。
|
||||
|
||||
### DevStackIdentity
|
||||
|
||||
`.app/dev-stack.json` 和服务 marker 必须包含 `repoRoot + processId + port + dataDir + instanceId` 可验证的身份信息。客户端发现状态时,数据库名、dataDir、repoRoot 或进程归属不匹配就拒绝复用并启动/提示当前工作树自己的服务。
|
||||
|
||||
## 本地数据迁移
|
||||
|
||||
- 旧 manifest、项目资源、Planning/GDD、会话和 Runtime journal 继续按现有读取与恢复规则打开。
|
||||
- 缺少 operationId 的旧 pending 记录只能转换为可重试或 `unknown`,不自动重放外部副作用。
|
||||
- 旧 Runner endpoint 只有在当前 dataDir、repoRoot、进程归属和协议/可用性都匹配时才能复用。
|
||||
- 迁移失败保留原文件并给出可操作错误,不以新状态覆盖旧数据。
|
||||
|
||||
## 任务列表与验收顺序
|
||||
|
||||
1. **操作合同**:建立共享 `ClientOperation` 类型、状态转移和 stale-result 规则;认证 refresh 与首页创建接入。
|
||||
2. **认证/Runner**:统一登录恢复、手工登录、退出、401 refresh 的 operation 投影,保留 session generation 和 blocking worker。
|
||||
3. **入口迁移**:首页创建、手动打开、附件导入、Planning V2、DirectProject、资源生成和预览入口复用 operation scope,不再新增 component-level busy ref。
|
||||
4. **开发栈身份**:dev-stack snapshot、Vite marker、AGC 配套后端复用门禁统一使用 repoRoot/processId/port/dataDir/instanceId。
|
||||
5. **本地恢复**:对旧 pending operation、旧 endpoint 和正在写入的项目执行失败关闭、可重试或人工核对迁移。
|
||||
6. **旧路径清理**:仅删除无现役调用方、无持久化合同、无公开契约的旧分支;每次删除前补调用方和持久化证据。
|
||||
7. **完整验收**:启动、登录、进入首页、打开项目、对话、确认、资源、预览、切项目、恢复对话/运行态、失败重试、退出登录,并验证坏项目、迟到结果、Runner 重复启动和旧 worktree 串用门禁。
|
||||
|
||||
## 当前实现状态
|
||||
|
||||
- HTTP body-aware timeout、refresh singleflight 清理、Runner blocking worker、最近项目逐项检查和首页创建跨页防重已完成并在 PR #346 中提交。
|
||||
- `ClientOperation` 基础合同、认证 refresh 与 Runner auth-transition operation 投影、首页创建 operation 投影和 dev-stack `instanceId` 已在本轮切换中落地。
|
||||
- 稳定版基础切换里程碑已完成;后续入口只允许复用该合同,不再新增 component-level busy/ref 状态机。
|
||||
- Planning/DirectProject/资源生成/预览已有各自 durable operation 或 request scope;本轮只补统一投影与身份校验,不重写其持久化账本。
|
||||
|
||||
## 现役边界审计
|
||||
|
||||
以下能力在本次切换前已经具备 durable 恢复或失败关闭合同,因此本轮按现有实现接入统一投影,不重复重写:
|
||||
|
||||
| 边界 | 当前证据 | 切换结论 |
|
||||
| --- | --- | --- |
|
||||
| 本地 manifest、Godot/Cocos 导入和项目 revision | `src-tauri/src/project/manifest/`、`import_tests.rs`、`recovery_tests.rs` | 保留旧文件格式,失败关闭,不复制平行项目根 |
|
||||
| 资源生成与 pending operation | `src-tauri/src/project/resource_editor.rs`、`generation/*` 测试 | 已有 operation/幂等/needs-reconciliation,禁止未知结果自动重放 |
|
||||
| Agent Runtime、Planning V2、DirectProject | `runtime_driver/recovery_scan.rs`、`runtime_protocol/`、`direct_runtime.rs` | 继续使用 durable task/session/run,统一 renderer operation 投影 |
|
||||
| 公开后端与共享契约 | `server-rs`、`packages/shared`、`docs/openapi` | 不删除、不改公开契约 |
|
||||
| 已退役客户端入口 | 当前 `WorkspaceLauncher`、`App.tsx` 和路由调用方审计 | 无现役调用方的旧分支才允许后续删除,暂不以猜测删除 |
|
||||
|
||||
完整真实 Provider、原生安装包和跨重启端到端时序仍需在具备登录/Provider 的环境中执行;本次代码门禁已覆盖 deterministic surface、operation、认证和 dev-stack identity。
|
||||
|
||||
## 验收证据
|
||||
|
||||
| 条款 | 证据 |
|
||||
| --- | --- |
|
||||
| operation identity 与 stale-result | `clientOperation.test.ts`、认证/home 定向测试 |
|
||||
| HTTP/auth/Runner | client HTTP/API 测试、Rust cargo check、认证 appSurface |
|
||||
| 最近项目逐行刷新 | `recentProjectsModel.test.ts`、home appSurface |
|
||||
| dev-stack 身份 | `scripts/dev.test.ts`、`start-dev-stack.test.ts`、端口 marker 检查 |
|
||||
| 本地恢复边界 | 现有 manifest/runtime/resource recovery tests;未确认外部副作用不自动重放 |
|
||||
| 完整流程 | AGC appSurface、开发栈 smoke;真实 Provider/安装包另行记录 |
|
||||
@@ -425,6 +425,7 @@ function buildDevStackSnapshot(runner, updatedAt = new Date().toISOString()) {
|
||||
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
instanceId: runner.instanceId,
|
||||
command: runner.command ?? 'all',
|
||||
repoRoot,
|
||||
database: runner.options.database,
|
||||
@@ -450,6 +451,12 @@ function buildDevStackServiceSnapshot(runner, serviceName, updatedAt) {
|
||||
: null;
|
||||
|
||||
return {
|
||||
repoRoot,
|
||||
instanceId: runner.instanceId,
|
||||
dataDir:
|
||||
serviceName === 'spacetime'
|
||||
? resolve(runner.options.spacetimeDataDir)
|
||||
: null,
|
||||
status,
|
||||
pid:
|
||||
childPid ??
|
||||
@@ -1198,6 +1205,9 @@ class DevRunner {
|
||||
constructor(options, baseEnv = process.env, explicitOptions = new Set()) {
|
||||
this.options = options;
|
||||
this.baseEnv = { ...baseEnv };
|
||||
this.instanceId = `dev-${process.pid}-${randomBytes(12).toString('hex')}`;
|
||||
this.baseEnv.GENARRATIVE_DEV_STACK_INSTANCE_ID = this.instanceId;
|
||||
this.baseEnv.GENARRATIVE_DEV_STACK_REPO_ROOT = repoRoot;
|
||||
this.spacetimeApiToken = String(
|
||||
this.baseEnv.GENARRATIVE_SPACETIME_TOKEN ?? '',
|
||||
).trim();
|
||||
|
||||
@@ -723,6 +723,7 @@ describe('dev scheduler stack state file', () => {
|
||||
test('状态快照记录服务 pid、端口、URL 和当前命令', () => {
|
||||
const updatedAt = '2026-05-29T00:00:00.000Z';
|
||||
const runner = {
|
||||
instanceId: 'test-instance',
|
||||
command: 'web',
|
||||
options: {
|
||||
apiHost: '127.0.0.1',
|
||||
@@ -770,12 +771,16 @@ describe('dev scheduler stack state file', () => {
|
||||
const snapshot = buildDevStackSnapshot(runner, updatedAt);
|
||||
|
||||
expect(snapshot.schemaVersion).toBe(2);
|
||||
expect(snapshot.instanceId).toBe('test-instance');
|
||||
expect(snapshot.command).toBe('web');
|
||||
expect(snapshot.database).toBe('genarrative-test');
|
||||
expect(snapshot.spacetimeDataDir).toBe(
|
||||
resolve('server-rs/.spacetimedb/local/data'),
|
||||
);
|
||||
expect(snapshot.services.web).toMatchObject({
|
||||
repoRoot: resolve('.'),
|
||||
instanceId: 'test-instance',
|
||||
dataDir: null,
|
||||
status: 'running',
|
||||
pid: 4321,
|
||||
host: '0.0.0.0',
|
||||
|
||||
Reference in New Issue
Block a user