Files
Genarrative/apps/ai-game-creator-shell/src/services/clientOperation.ts
T
kdletters c146f7f99c
Project CI / Repository checks (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
建立 AGC 稳定版生命周期基础合同 (#348)
## 变更内容

这是基于 PR #346 的稳定版生命周期基础切换,承接已完成的 HTTP body timeout、Runner blocking worker、最近项目逐项刷新和首页跨页防重:

- 新增统一 `ClientOperation` 合同,固定 operationId、requestId、phase、deadline、scope、cancellable 和 stale 判定;
- 认证 refresh 投影 `auth-refresh` operation;登录、401 refresh、退出共用平台 session generation,并由 `auth-transition` 投影 Runner phase/成功/失败/不确定状态;
- 首页自动创建记录 draft、startMode、phase 和建项后的 project scope,页面卸载不会释放 operation;
- `.app/dev-stack.json` 增加 instanceId、repoRoot、服务级 dataDir/instanceId,AGC Vite marker 增加 repoRoot/processId/port;缺身份的旧状态拒绝 AGC 后端复用;
- 新增稳定版生命周期主规范、开发运维口径和 shared pitfalls。

## 验证

- `npm --prefix apps/ai-game-creator-shell run typecheck`
- `npm --prefix apps/ai-game-creator-shell exec -- vitest run tests/clientOperation.test.ts tests/clientApi.test.ts tests/clientHttp.test.ts tests/recentProjectsModel.test.ts tests/start-dev-stack.test.ts tests/dev-port.test.ts --reporter=dot`(61 passed,2 skipped)
- `npm --prefix apps/ai-game-creator-shell exec -- vitest run tests/appSurface.test.ts --reporter=dot`(423/423 passed)
- `node --check scripts/dev.mjs`
- `node --check apps/ai-game-creator-shell/scripts/start-dev-stack.mjs`
- `npm run check:doc-index`
- `npm run check:encoding`
- `git diff --check`

`scripts/dev.test.ts` 全量仍有 1 个 Windows 文件权限相关既有失败:bootstrap secret 测试在 Windows 上无法通过 chmod 模拟 0600;本变更未触及该逻辑。

Reviewed-on: #348
2026-09-14 13:56:56 +08:00

106 lines
2.4 KiB
TypeScript

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';
}