合并 origin/master:保留 3D 契约与 provider checkpoint,旧玩法表随主线退役

- server-rs/crates/shared-contracts/src/lib.rs:采用主线的模块裁剪(删掉 cfg(any()) 遗留模块、补上 agc_analytics 与 game_distribution),同时保留本分支的 editor_canvas、model3d 模块与 EDITOR_GENERATION_OPERATION_KINDS 导出
- server-rs/crates/spacetime-module/src/migration.rs:主线删除的 410 行旧玩法表 normalize 段保持删除,保留本分支的 provider_kind / provider_task_id 兼容段与对应用例
- docs/【开发运维】本地开发验证与生产运维-2026-05-15.md:校验清单同时保留 Tripo 3D 生成任务与 editor_background_music_generation / model3d_text_to_model / model3d_image_to_model
- .gitignore:补回本分支新增的 3D 模型文件忽略规则(*.glb / *.gltf 等 12 行),压测数据段随主线一并删除
- 共享记忆:本分支的 3D 决策与踩坑条目保留在主线重排后的 decision-log.md 与 pitfalls.md 中
This commit is contained in:
2026-09-23 19:39:30 +08:00
997 changed files with 56264 additions and 80302 deletions
@@ -7,9 +7,13 @@ import {
getAdminFeatureGateConfig,
getAdminUserDetail,
importAdminAgcTemplates,
listAdminAgcTrackingEvents,
listAdminGameDistributionReviews,
listAdminRechargeOrders,
reconcileAdminUserConsumption,
resolveAdminRechargeRefundManualReview,
reviewAdminGameDistributionVersion,
suspendAdminGameDistributionGame,
updateAdminAccount,
updateAdminAgcTemplate,
uploadAdminEditorShowcaseCampaignImage,
@@ -21,6 +25,30 @@ afterEach(() => {
vi.unstubAllGlobals();
});
test('客户端埋点查询传递筛选和游标并复用后台认证', async () => {
const payload = { entries: [], nextCursor: null };
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ ok: true, data: payload }), {
status: 200,
}),
);
vi.stubGlobal('fetch', fetchMock);
expect(
await listAdminAgcTrackingEvents('token', {
userId: 'user+1',
projectId: 'project-1',
cursor: 'page/2',
limit: 50,
}),
).toEqual(payload);
expect(fetchMock).toHaveBeenCalledWith(
'/admin/api/agc/tracking-events?userId=user%2B1&projectId=project-1&cursor=page%2F2&limit=50',
expect.objectContaining({
headers: expect.objectContaining({ Authorization: 'Bearer token' }),
}),
);
});
test('模板管理读取和更新复用认证封装,提交 revision 和封面但不提交 ZIP 或版本', async () => {
const library = { revision: 'revision-new', writable: true, templates: [] };
const fetchMock = vi.fn().mockImplementation(
@@ -454,3 +482,134 @@ test('退款人工复核使用独立 resolve 管理员路由', async () => {
}),
);
});
test('游戏审核列表与审核动作使用约定的 URL、方法和幂等键', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
new Response(JSON.stringify({ entries: [], nextCursor: null }), {
status: 200,
}),
),
);
vi.stubGlobal('fetch', fetchMock);
await listAdminGameDistributionReviews('admin-token');
await reviewAdminGameDistributionVersion(
'admin-token',
'gamever/1',
'game-review-key-1',
{
decision: 'approve',
expectedPublicationRevision: 3,
entryUrl: 'https://games.example.test/releases/game_1/index.html',
},
);
expect(fetchMock.mock.calls[0]?.[0]).toBe(
'/admin/api/game-distribution/reviews?limit=48',
);
expect(fetchMock.mock.calls[1]?.[0]).toBe(
'/admin/api/game-distribution/versions/gamever%2F1/review',
);
expect(fetchMock.mock.calls[1]?.[1]).toEqual(
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: 'Bearer admin-token',
'Idempotency-Key': 'game-review-key-1',
}),
body: JSON.stringify({
decision: 'approve',
expectedPublicationRevision: 3,
entryUrl: 'https://games.example.test/releases/game_1/index.html',
}),
}),
);
});
test('安全下架请求携带公开修订号、原因与幂等键', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
new Response(JSON.stringify({ game: {}, replayed: false }), {
status: 200,
}),
),
);
vi.stubGlobal('fetch', fetchMock);
await suspendAdminGameDistributionGame(
'admin-token',
'game/1',
'game-suspend-key-1',
{ expectedPublicationRevision: 7, reason: '版权投诉' },
);
expect(fetchMock.mock.calls[0]?.[0]).toBe(
'/admin/api/game-distribution/games/game%2F1/suspend',
);
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
Authorization: 'Bearer admin-token',
'Idempotency-Key': 'game-suspend-key-1',
}),
body: JSON.stringify({
expectedPublicationRevision: 7,
reason: '版权投诉',
}),
}),
);
expect(() =>
suspendAdminGameDistributionGame('admin-token', ' ', 'key', {
expectedPublicationRevision: 1,
}),
).toThrow('缺少游戏 ID');
expect(() =>
suspendAdminGameDistributionGame('admin-token', 'game-1', ' ', {
expectedPublicationRevision: 1,
}),
).toThrow('下架幂等键必须是 1 到 128 个字符');
expect(fetchMock).toHaveBeenCalledTimes(1);
});
test('游戏审核拒绝请求携带理由,空幂等键在本地失败关闭', async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
new Response(JSON.stringify({ version: {}, replayed: false }), {
status: 200,
}),
),
);
vi.stubGlobal('fetch', fetchMock);
await reviewAdminGameDistributionVersion(
'admin-token',
'version-1',
'game-review-key-2',
{
decision: 'reject',
expectedPublicationRevision: 0,
reviewReason: '运行时报错',
},
);
expect(fetchMock.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
body: JSON.stringify({
decision: 'reject',
expectedPublicationRevision: 0,
reviewReason: '运行时报错',
}),
}),
);
expect(() =>
reviewAdminGameDistributionVersion('admin-token', 'version-1', ' ', {
decision: 'reject',
expectedPublicationRevision: 0,
reviewReason: 'x',
}),
).toThrow('审核幂等键必须是 1 到 128 个字符');
expect(fetchMock).toHaveBeenCalledTimes(1);
});
+99
View File
@@ -1,6 +1,8 @@
import type {
AdminAccountListResponse,
AdminAgcTemplateLibraryResponse,
AdminAgcTrackingEventListResponse,
AdminAgcTrackingEventQuery,
AdminConfirmEditorShowcaseCampaignImageUploadRequest,
AdminCreateAccountRequest,
AdminCreateAccountResponse,
@@ -30,6 +32,9 @@ import type {
AdminExternalApiKeyListQuery,
AdminExternalApiKeyListResponse,
AdminFeatureGateConfigResponse,
AdminGameDistributionReviewListResponse,
AdminGameDistributionReviewRequest,
AdminGameDistributionReviewResponse,
AdminImportAgcTemplatesResponse,
AdminLoginResponse,
AdminMeResponse,
@@ -407,6 +412,31 @@ export function listAdminTrackingEventKeys(token: string) {
);
}
export function listAdminAgcTrackingEvents(
token: string,
query: AdminAgcTrackingEventQuery = {},
) {
return request<AdminAgcTrackingEventListResponse>(
`/admin/api/agc/tracking-events${buildQueryString((params) => {
for (const key of [
'userId',
'projectId',
'creativeTaskId',
'agentRunId',
'eventName',
'clientVersion',
'startTime',
'endTime',
'cursor',
] as const) {
appendQueryParam(params, key, query[key]);
}
appendNumericQueryParam(params, 'limit', query.limit);
})}`,
{ token },
);
}
export function listAdminErrorReports(
token: string,
query: {
@@ -1201,6 +1231,75 @@ export function saveAgcModelCatalog(
);
}
export function listAdminGameDistributionReviews(token: string, limit = 48) {
const normalizedLimit = Number.isFinite(limit)
? Math.min(Math.max(Math.trunc(limit), 1), 48)
: 48;
return request<AdminGameDistributionReviewListResponse>(
`/admin/api/game-distribution/reviews?limit=${normalizedLimit}`,
{ token },
);
}
/**
* 审核游戏发行版本。幂等键由调用方生成并在同一次提交内复用,避免重复点击产生
* 两条审核结论。
*/
/**
* 安全下架整个游戏。管理员下架同样要求 CAS 修订号与幂等键,避免并发审核互相覆盖。
*/
export function suspendAdminGameDistributionGame(
token: string,
gameId: string,
idempotencyKey: string,
payload: import('./adminApiTypes').AdminGameDistributionSuspendRequest,
) {
const normalizedGameId = gameId.trim();
const normalizedKey = idempotencyKey.trim();
if (!normalizedGameId) {
throw new Error('缺少游戏 ID');
}
if (!normalizedKey || normalizedKey.length > 128) {
throw new Error('下架幂等键必须是 1 到 128 个字符');
}
return request<
import('./adminApiTypes').AdminGameDistributionSuspendResponse
>(
`/admin/api/game-distribution/games/${encodeURIComponent(normalizedGameId)}/suspend`,
{
method: 'POST',
token,
headers: { 'Idempotency-Key': normalizedKey },
body: payload,
},
);
}
export function reviewAdminGameDistributionVersion(
token: string,
versionId: string,
idempotencyKey: string,
payload: AdminGameDistributionReviewRequest,
) {
const normalizedVersionId = versionId.trim();
const normalizedKey = idempotencyKey.trim();
if (!normalizedVersionId) {
throw new Error('缺少发行版本 ID');
}
if (!normalizedKey || normalizedKey.length > 128) {
throw new Error('审核幂等键必须是 1 到 128 个字符');
}
return request<AdminGameDistributionReviewResponse>(
`/admin/api/game-distribution/versions/${encodeURIComponent(normalizedVersionId)}/review`,
{
method: 'POST',
token,
headers: { 'Idempotency-Key': normalizedKey },
body: payload,
},
);
}
export function getAdminAgcTemplates(token: string, signal?: AbortSignal) {
return request<AdminAgcTemplateLibraryResponse>('/admin/api/agc-templates', {
token,
+83
View File
@@ -854,6 +854,44 @@ export interface AdminTrackingEventListResponse {
entries: AdminTrackingEventEntryPayload[];
}
export interface AdminAgcTrackingEventQuery {
userId?: string;
projectId?: string;
creativeTaskId?: string;
agentRunId?: string;
eventName?: string;
clientVersion?: string;
startTime?: string;
endTime?: string;
cursor?: string;
limit?: number;
}
export interface AdminAgcTrackingEventEntry {
eventId: string;
schemaVersion: number;
eventName: string;
eventTime: string;
userId: string;
editorSessionId: string;
projectId: string | null;
creativeTaskId: string | null;
agentRunId: string | null;
agentTurnId: string | null;
status: string | null;
errorCode: string | null;
source: string;
clientVersion: string;
properties: Record<string, unknown>;
batchId: string;
receivedAt: string;
}
export interface AdminAgcTrackingEventListResponse {
entries: AdminAgcTrackingEventEntry[];
nextCursor: string | null;
}
export interface AdminTrackingEventKeyPayload {
eventKey: string;
eventTitle: string;
@@ -1077,6 +1115,51 @@ export interface AdminAgcModelCatalog {
models: AdminAgcModel[];
}
export interface AdminGameDistributionReviewEntry {
versionId: string;
gameId: string;
versionNumber: number;
packageSha256: string;
packageBytes: number;
status: string;
publicationRevision: number;
reviewReason: string | null;
createdAt: string;
updatedAt: string;
}
export interface AdminGameDistributionReviewListResponse {
entries: AdminGameDistributionReviewEntry[];
nextCursor: string | null;
}
export interface AdminGameDistributionReviewRequest {
decision: 'approve' | 'reject';
expectedPublicationRevision: number;
reviewReason?: string;
entryUrl?: string;
}
export interface AdminGameDistributionReviewResponse {
version: AdminGameDistributionReviewEntry;
replayed: boolean;
}
export interface AdminGameDistributionSuspendRequest {
expectedPublicationRevision: number;
reason?: string;
}
export interface AdminGameDistributionSuspendResponse {
game: {
id: string;
title: string;
status: string;
publicationRevision: number;
};
replayed: boolean;
}
export interface AdminAgcTemplatePayload {
id: string;
title: string;
+14
View File
@@ -20,6 +20,7 @@ import {
import { AdminAccountsPage } from '../pages/AdminAccountsPage';
import { AdminAgcModelsPage } from '../pages/AdminAgcModelsPage';
import { AdminAgcTemplatesPage } from '../pages/AdminAgcTemplatesPage';
import { AdminAgcTrackingPage } from '../pages/AdminAgcTrackingPage';
import { AdminDashboardPage } from '../pages/AdminDashboardPage';
import { AdminDatabaseTablesPage } from '../pages/AdminDatabaseTablesPage';
import { AdminDebugHttpPage } from '../pages/AdminDebugHttpPage';
@@ -27,6 +28,7 @@ import { AdminEditorAssetQueryPage } from '../pages/AdminEditorAssetQueryPage';
import { AdminEditorGenerationPricingPage } from '../pages/AdminEditorGenerationPricingPage';
import { AdminEditorShowcaseReviewPage } from '../pages/AdminEditorShowcaseReviewPage';
import { AdminErrorReportsPage } from '../pages/AdminErrorReportsPage';
import { AdminGameDistributionReviewPage } from '../pages/AdminGameDistributionReviewPage';
import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage';
import { AdminInviteCodePage } from '../pages/AdminInviteCodePage';
import { AdminLoginPage } from '../pages/AdminLoginPage';
@@ -232,6 +234,12 @@ export function AdminApp() {
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'agc-tracking' ? (
<AdminAgcTrackingPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'error-reports' ? (
<AdminErrorReportsPage
token={token}
@@ -307,6 +315,12 @@ export function AdminApp() {
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'game-distribution' ? (
<AdminGameDistributionReviewPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'editor-assets' ? (
<AdminEditorAssetQueryPage
token={token}
+3
View File
@@ -5,6 +5,7 @@ import {
Coins,
Database,
FolderArchive,
Gamepad2,
GitBranch,
Images,
LayoutDashboard,
@@ -39,6 +40,7 @@ const routeIcons = {
tables: Database,
debug: Bug,
tracking: Table2,
'agc-tracking': Table2,
'error-reports': Bug,
'gray-release': GitBranch,
redeem: TicketPercent,
@@ -49,6 +51,7 @@ const routeIcons = {
'recharge-orders': ReceiptText,
'editor-generation-pricing': Coins,
'editor-showcase': Star,
'game-distribution': Gamepad2,
'editor-assets': Images,
'project-snapshots': FolderArchive,
accounts: Users,
+37 -6
View File
@@ -8,6 +8,22 @@ import {
routeHash,
} from './adminRoutes';
test('客户端埋点路由遵守成员页签权限', () => {
expect(resolveAdminRoute('#agc-tracking')).toBe('agc-tracking');
expect(
getAccessibleAdminRoutes({
accountRole: 'member',
tabPermissions: ['agc-tracking'],
}).map((route) => route.id),
).toEqual(['agc-tracking']);
expect(
getAccessibleAdminRoutes({
accountRole: 'member',
tabPermissions: ['tracking'],
}).some((route) => route.id === 'agc-tracking'),
).toBe(false);
});
test('后台默认进入 Dashboard', () => {
expect(adminRoutes[0]).toEqual({
id: 'dashboard',
@@ -43,12 +59,6 @@ test('后台灰度发布路由可通过导航和 hash 访问', () => {
expect(routeHash('gray-release')).toBe('#gray-release');
});
test('后台不再暴露旧创作模板管理路由', () => {
expect(resolveAdminRoute('#creation-entry')).toBe('dashboard');
expect(resolveAdminRoute('#creation-announcement')).toBe('dashboard');
expect(resolveAdminRoute('#work-visibility')).toBe('dashboard');
});
test('后台素材查询路由可通过导航和 hash 访问', () => {
expect(adminRoutes).toContainEqual({
id: 'editor-assets',
@@ -148,6 +158,27 @@ test('项目工程入口对 owner 与已授权 member 开放且可分配权限',
).not.toContainEqual(route);
});
test('后台游戏审核路由可通过导航和 hash 访问', () => {
expect(adminRoutes).toContainEqual({
id: 'game-distribution',
label: '游戏审核',
hash: '#game-distribution',
});
expect(resolveAdminRoute('#game-distribution')).toBe('game-distribution');
expect(routeHash('game-distribution')).toBe('#game-distribution');
});
test('member 可单独获得游戏审核 Tab 权限', () => {
const routes = getAccessibleAdminRoutes({
accountRole: 'member',
tabPermissions: ['game-distribution'],
});
expect(routes.map((route) => route.id)).toEqual(['game-distribution']);
expect(resolveAccessibleAdminRoute('#game-distribution', routes)).toBe(
'game-distribution',
);
});
test('模板管理只对 owner 或具有 agc-templates 权限的 member 可见', () => {
expect(adminRoutes).toContainEqual({
id: 'agc-templates',
+4
View File
@@ -5,6 +5,7 @@ export type AdminRouteId =
| 'tables'
| 'debug'
| 'tracking'
| 'agc-tracking'
| 'error-reports'
| 'gray-release'
| 'redeem'
@@ -15,6 +16,7 @@ export type AdminRouteId =
| 'recharge-orders'
| 'editor-generation-pricing'
| 'editor-showcase'
| 'game-distribution'
| 'editor-assets'
| 'project-snapshots'
| 'agc-models'
@@ -40,6 +42,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
{ id: 'tables', label: '表查询', hash: '#tables' },
{ id: 'debug', label: 'API 调试', hash: '#debug' },
{ id: 'tracking', label: '埋点数据', hash: '#tracking' },
{ id: 'agc-tracking', label: '客户端埋点', hash: '#agc-tracking' },
{ id: 'error-reports', label: '错误报告', hash: '#error-reports' },
{ id: 'gray-release', label: '灰度发布', hash: '#gray-release' },
{ id: 'redeem', label: '兑换码', hash: '#redeem' },
@@ -56,6 +59,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
{ id: 'agc-models', label: 'AGC 模型', hash: '#agc-models', ownerOnly: true },
{ id: 'agc-templates', label: '模板管理', hash: '#agc-templates' },
{ id: 'editor-showcase', label: '精选审核', hash: '#editor-showcase' },
{ id: 'game-distribution', label: '游戏审核', hash: '#game-distribution' },
{ id: 'editor-assets', label: '素材查询', hash: '#editor-assets' },
{ id: 'project-snapshots', label: '项目工程', hash: '#project-snapshots' },
{ id: 'accounts', label: '账号管理', hash: '#accounts', ownerOnly: true },
@@ -339,7 +339,10 @@ test.each([409, 503])(
await confirmWrite();
const message = await screen.findByRole('alert');
const feedback = message.parentElement!;
expect(document.activeElement).toBe(feedback);
// 聚焦发生在 React passive effect 里(AdminAgcTemplatesPage 的 feedback 聚焦 useEffect),
// 而 findByRole 在 alert 节点一挂上就返回,可能早于该 effect 执行;这里等聚焦落地,
// 避免在 CI 负载下抢跑。断言口径不变:焦点最终必须落在提示区而不是弹窗面板。
await waitFor(() => expect(document.activeElement).toBe(feedback));
expect(feedback.tabIndex).toBe(-1);
expect(focus).toHaveBeenCalledWith({ preventScroll: true });
expect(viewport.scrollTop).toBe(20);
@@ -0,0 +1,129 @@
// @vitest-environment jsdom
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import {
AdminApiError,
listAdminAgcTrackingEvents,
} from '../api/adminApiClient';
import type { AdminAgcTrackingEventEntry } from '../api/adminApiTypes';
import { AdminAgcTrackingPage } from './AdminAgcTrackingPage';
vi.mock('../api/adminApiClient', async (original) => ({
...(await original<typeof import('../api/adminApiClient')>()),
listAdminAgcTrackingEvents: vi.fn(),
}));
vi.mock('../components/AdminUserReferenceButton', () => ({
AdminUserReferenceButton: () => null,
}));
const entry: AdminAgcTrackingEventEntry = {
eventId: 'event-1',
schemaVersion: 1,
eventName: 'project_save',
eventTime: '2026-09-21T12:00:00.123Z',
userId: 'user-1',
editorSessionId: 'session-1',
projectId: 'project-1',
creativeTaskId: 'project-1',
agentRunId: null,
agentTurnId: null,
status: 'success',
errorCode: null,
source: 'gui',
clientVersion: '1.0',
properties: { reason: 'manual' },
batchId: 'batch-1',
receivedAt: '2026-09-21T12:15:00.000Z',
};
beforeEach(() => {
vi.mocked(listAdminAgcTrackingEvents).mockReset();
});
afterEach(cleanup);
test('翻页沿用游标,筛选和刷新从第一页重新查询,详情保留 null 和事件属性', async () => {
vi.mocked(listAdminAgcTrackingEvents)
.mockResolvedValueOnce({ entries: [entry], nextCursor: 'cursor-page-2' })
.mockResolvedValueOnce({
entries: [{ ...entry, eventId: 'event-2' }],
nextCursor: null,
})
.mockResolvedValue({ entries: [entry], nextCursor: 'cursor-new' });
render(<AdminAgcTrackingPage token="admin-token" onUnauthorized={vi.fn()} />);
await screen.findByText('保存项目', { selector: 'td' });
expect(listAdminAgcTrackingEvents).toHaveBeenLastCalledWith('admin-token', {
cursor: undefined,
limit: 50,
});
fireEvent.click(screen.getByText('查看详情'));
const dialog = screen.getByRole('dialog');
expect(within(dialog).getAllByText('—').length).toBe(3);
expect(within(dialog).getByText(/"reason": "manual"/)).toBeTruthy();
expect(within(dialog).getByText(entry.eventTime)).toBeTruthy();
fireEvent.click(within(dialog).getByLabelText('关闭详情'));
fireEvent.click(screen.getByText('下一页'));
await waitFor(() =>
expect(listAdminAgcTrackingEvents).toHaveBeenLastCalledWith('admin-token', {
cursor: 'cursor-page-2',
limit: 50,
}),
);
await waitFor(() =>
expect(screen.getByText('查询').closest('button')?.disabled).toBe(false),
);
fireEvent.click(screen.getByText('上一页'));
await screen.findByText('第 1 页');
expect(listAdminAgcTrackingEvents).toHaveBeenCalledTimes(2);
fireEvent.change(screen.getByLabelText('项目 ID'), {
target: { value: 'project-2' },
});
fireEvent.click(screen.getByText('查询'));
await waitFor(() =>
expect(listAdminAgcTrackingEvents).toHaveBeenLastCalledWith(
'admin-token',
expect.objectContaining({
projectId: 'project-2',
cursor: undefined,
limit: 50,
}),
),
);
expect(screen.getByText('第 1 页')).toBeTruthy();
await waitFor(() =>
expect(screen.getByText('刷新').closest('button')?.disabled).toBe(false),
);
fireEvent.click(screen.getByText('刷新'));
await waitFor(() =>
expect(listAdminAgcTrackingEvents).toHaveBeenCalledTimes(4),
);
});
test('空结果、权限失败与登录失效沿用后台反馈', async () => {
const unauthorized = vi.fn();
vi.mocked(listAdminAgcTrackingEvents)
.mockResolvedValueOnce({ entries: [], nextCursor: null })
.mockRejectedValueOnce(
new AdminApiError({ status: 403, message: '无权访问客户端埋点' }),
)
.mockRejectedValueOnce(
new AdminApiError({ status: 401, message: '登录失效' }),
);
render(<AdminAgcTrackingPage token="token" onUnauthorized={unauthorized} />);
await screen.findByText('暂无客户端埋点数据');
fireEvent.click(screen.getByText('刷新'));
expect((await screen.findByRole('alert')).textContent).toContain(
'无权访问客户端埋点',
);
fireEvent.click(screen.getByText('刷新'));
await waitFor(() =>
expect(unauthorized).toHaveBeenCalledWith('登录状态已失效'),
);
});
@@ -0,0 +1,379 @@
import { Modal } from '@genarrative/shared/components';
import { FormEvent, useEffect, useRef, useState } from 'react';
import { listAdminAgcTrackingEvents } from '../api/adminApiClient';
import type {
AdminAgcTrackingEventEntry,
AdminAgcTrackingEventListResponse,
AdminAgcTrackingEventQuery,
} from '../api/adminApiTypes';
import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton';
import { handlePageError } from './pageUtils';
const eventLabels: Record<string, string> = {
editor_session_start: '编辑器会话开始',
editor_session_end: '编辑器会话结束',
editor_focus_start: '编辑器获得焦点',
editor_focus_end: '编辑器失去焦点',
project_create_success: '项目创建成功',
project_open: '打开项目',
creative_task_submit: '首次提交创作目标',
agent_run_completed: 'Agent 运行完成',
agent_run_failed: 'Agent 运行失败',
project_revision_created: '项目产生修改',
preview_ready: '预览就绪',
project_save: '保存项目',
};
const fieldLabels: Record<keyof AdminAgcTrackingEventEntry, string> = {
eventId: '事件 ID',
schemaVersion: '事件版本',
eventName: '事件类型',
eventTime: '发生时间',
userId: '用户 ID',
editorSessionId: '编辑器会话 ID',
projectId: '项目 ID',
creativeTaskId: '创作目标 ID',
agentRunId: 'Agent run ID',
agentTurnId: 'Agent turn ID',
status: '结果',
errorCode: '错误码',
source: '来源',
clientVersion: '客户端版本',
properties: '事件属性',
batchId: '批次 ID',
receivedAt: '入库时间',
};
function formatTime(value: string) {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN');
}
export function AdminAgcTrackingPage({
token,
onUnauthorized,
}: {
token: string;
onUnauthorized: (message?: string) => void;
}) {
const [filters, setFilters] = useState({
userId: '',
projectId: '',
eventName: '',
startTime: '',
endTime: '',
});
const [query, setQuery] = useState<AdminAgcTrackingEventQuery>({});
const [cursors, setCursors] = useState<Array<string | undefined>>([
undefined,
]);
const [page, setPage] = useState(0);
const [refresh, setRefresh] = useState(0);
const [entries, setEntries] = useState<AdminAgcTrackingEventEntry[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [detail, setDetail] = useState<AdminAgcTrackingEventEntry | null>(null);
const cursor = cursors[page];
// 首页没有入站游标,返回首页时保留原快照,刷新才获取新数据。
const firstPage = useRef<{
token: string;
query: AdminAgcTrackingEventQuery;
response: AdminAgcTrackingEventListResponse;
} | null>(null);
useEffect(() => {
const saved = firstPage.current;
if (!cursor && saved?.token === token && saved.query === query) {
setEntries(saved.response.entries);
setNextCursor(saved.response.nextCursor);
setError('');
setLoading(false);
return;
}
let active = true;
setLoading(true);
setError('');
setEntries([]);
setNextCursor(null);
void listAdminAgcTrackingEvents(token, { ...query, cursor, limit: 50 })
.then((response) => {
if (!active) return;
if (!cursor) firstPage.current = { token, query, response };
setEntries(response.entries);
setNextCursor(response.nextCursor);
})
.catch((failure: unknown) => {
if (active) handlePageError(failure, onUnauthorized, setError);
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, [token, query, cursor, refresh, onUnauthorized]);
function resetPages() {
firstPage.current = null;
setCursors([undefined]);
setPage(0);
setDetail(null);
}
function search(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (
filters.startTime &&
filters.endTime &&
new Date(filters.startTime) >= new Date(filters.endTime)
) {
setError('结束时间必须晚于开始时间');
return;
}
resetPages();
setQuery({
userId: filters.userId.trim(),
projectId: filters.projectId.trim(),
eventName: filters.eventName,
startTime: filters.startTime
? new Date(filters.startTime).toISOString()
: undefined,
endTime: filters.endTime
? new Date(filters.endTime).toISOString()
: undefined,
});
}
function related(
key: 'creativeTaskId' | 'agentRunId' | 'clientVersion',
value: string,
) {
resetPages();
setFilters({
userId: '',
projectId: '',
eventName: '',
startTime: '',
endTime: '',
});
setQuery({ [key]: value });
}
return (
<section className="admin-page admin-page-wide">
<div className="admin-page-heading">
<div>
<h2>客户端埋点</h2>
<p>按发生时间倒序展示</p>
</div>
<button
type="button"
className="admin-secondary-button"
disabled={loading}
onClick={() => {
resetPages();
setRefresh((value) => value + 1);
}}
>
刷新
</button>
</div>
<form className="admin-panel admin-form" onSubmit={search}>
<div className="admin-filter-grid">
{(['userId', 'projectId'] as const).map((key) => (
<label key={key} className="admin-field">
<span>{fieldLabels[key]}</span>
<input
value={filters[key]}
onChange={(event) =>
setFilters({ ...filters, [key]: event.target.value })
}
/>
</label>
))}
<label className="admin-field">
<span>事件类型</span>
<select
value={filters.eventName}
onChange={(event) =>
setFilters({ ...filters, eventName: event.target.value })
}
>
<option value="">全部</option>
{Object.entries(eventLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</label>
{(['startTime', 'endTime'] as const).map((key) => (
<label key={key} className="admin-field">
<span>
{key === 'startTime'
? '发生时间起点(含)'
: '发生时间终点(不含)'}
</span>
<input
type="datetime-local"
value={filters[key]}
onChange={(event) =>
setFilters({ ...filters, [key]: event.target.value })
}
/>
</label>
))}
</div>
<div className="admin-action-row">
<button
type="submit"
className="admin-primary-button"
disabled={loading}
>
查询
</button>
</div>
{(['creativeTaskId', 'agentRunId', 'clientVersion'] as const)
.filter((key) => query[key])
.map((key) => (
<p key={key}>
{fieldLabels[key]}:{query[key]}(重新查询可清除)
</p>
))}
</form>
{error ? (
<p role="alert" className="admin-error-message">
{error}
</p>
) : null}
<div className="admin-panel">
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
{[
'入库时间',
'发生时间',
'用户',
'事件名称',
'项目',
'来源',
'结果',
'客户端版本',
'详情',
].map((label) => (
<th key={label}>{label}</th>
))}
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr key={entry.eventId}>
<td>{formatTime(entry.receivedAt)}</td>
<td>{formatTime(entry.eventTime)}</td>
<td>
{entry.userId}
<AdminUserReferenceButton
token={token}
userId={entry.userId}
onUnauthorized={onUnauthorized}
/>
</td>
<td>{eventLabels[entry.eventName] ?? entry.eventName}</td>
<td>{entry.projectId ?? '—'}</td>
<td>{entry.source}</td>
<td>{entry.status ?? '—'}</td>
<td>{entry.clientVersion}</td>
<td>
<button
type="button"
className="admin-ghost-button"
onClick={() => setDetail(entry)}
>
查看详情
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{loading ? (
<p role="status">加载中…</p>
) : !error && entries.length === 0 ? (
<p>暂无客户端埋点数据</p>
) : null}
<div className="admin-action-row">
<button
type="button"
className="admin-secondary-button"
disabled={loading || page === 0}
onClick={() => setPage((value) => value - 1)}
>
上一页
</button>
<span>第 {page + 1} 页</span>
<button
type="button"
className="admin-secondary-button"
disabled={loading || !nextCursor}
onClick={() => {
if (!nextCursor) return;
setCursors([...cursors.slice(0, page + 1), nextCursor]);
setPage(page + 1);
}}
>
下一页
</button>
</div>
</div>
{detail ? (
<Modal
open
title="客户端埋点详情"
closeLabel="关闭详情"
onClose={() => setDetail(null)}
className="genarrative-ui"
>
<dl>
{(
Object.keys(fieldLabels) as Array<
keyof AdminAgcTrackingEventEntry
>
)
.filter((key) => key !== 'properties')
.map((key) => (
<div key={key}>
<dt>{fieldLabels[key]}</dt>
<dd style={{ overflowWrap: 'anywhere' }}>
{detail[key] == null ? '—' : String(detail[key])}
</dd>
</div>
))}
</dl>
<div className="admin-action-row">
{(['creativeTaskId', 'agentRunId', 'clientVersion'] as const).map(
(key) =>
detail[key] ? (
<button
type="button"
key={key}
className="admin-secondary-button"
onClick={() => related(key, detail[key]!)}
>
查询同一{fieldLabels[key]}
</button>
) : null,
)}
</div>
<h3>事件属性</h3>
<pre style={{ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>
{JSON.stringify(detail.properties, null, 2)}
</pre>
</Modal>
) : null}
</section>
);
}
@@ -2641,48 +2641,6 @@ const databaseTableLabelMap: Record<string, string> = {
profile_recharge_order: '充值订单',
profile_feedback_submission: '反馈提交',
profile_save_archive: '存档记录',
story_session: '剧情会话',
story_event: '剧情事件',
npc_state: 'NPC 状态',
inventory_slot: '背包槽位',
battle_state: '战斗状态',
treasure_record: '宝藏记录',
quest_record: '任务记录',
quest_log: '任务日志',
player_progression: '玩家进度',
chapter_progression: '章节进度',
custom_world_profile: '自定义世界档案',
custom_world_session: '自定义世界会话',
custom_world_agent_session: '自定义世界 Agent 会话',
custom_world_agent_message: '自定义世界 Agent 消息',
custom_world_agent_operation: '自定义世界 Agent 操作',
custom_world_draft_card: '自定义世界草稿卡片',
custom_world_gallery_entry: '自定义世界画廊条目',
puzzle_agent_session: '拼图 Agent 会话',
puzzle_agent_message: '拼图 Agent 消息',
puzzle_work_profile: '拼图作品档案',
puzzle_event: '拼图事件',
puzzle_runtime_run: '拼图运行记录',
puzzle_leaderboard_entry: '拼图排行榜条目',
match3d_agent_session: '抓大鹅 Agent 会话',
match3d_agent_message: '抓大鹅 Agent 消息',
match3d_work_profile: '抓大鹅作品档案',
match3d_runtime_run: '抓大鹅运行记录',
square_hole_agent_session: '方洞挑战 Agent 会话',
square_hole_agent_message: '方洞挑战 Agent 消息',
square_hole_work_profile: '方洞挑战作品档案',
square_hole_runtime_run: '方洞挑战运行记录',
visual_novel_agent_session: '视觉小说 Agent 会话',
visual_novel_agent_message: '视觉小说 Agent 消息',
visual_novel_work_profile: '视觉小说作品档案',
visual_novel_runtime_run: '视觉小说运行记录',
visual_novel_runtime_history_entry: '视觉小说历史条目',
visual_novel_runtime_event: '视觉小说运行事件',
big_fish_creation_session: '大鱼吃小鱼创建会话',
big_fish_agent_message: '大鱼吃小鱼 Agent 消息',
big_fish_asset_slot: '大鱼吃小鱼资产槽位',
big_fish_event: '大鱼吃小鱼事件',
big_fish_runtime_run: '大鱼吃小鱼运行记录',
asset_object: '资产对象',
asset_entity_binding: '资产实体绑定',
asset_event: '资产事件',
@@ -2724,48 +2682,6 @@ const databaseTableDescriptionMap: Record<string, string> = {
profile_recharge_order: '充值订单表',
profile_feedback_submission: '反馈提交记录表',
profile_save_archive: '用户存档记录表',
story_session: '剧情会话表',
story_event: '剧情事件表',
npc_state: 'NPC 状态表',
inventory_slot: '背包槽位表',
battle_state: '战斗状态表',
treasure_record: '宝藏记录表',
quest_record: '任务记录表',
quest_log: '任务日志表',
player_progression: '玩家进度表',
chapter_progression: '章节进度表',
custom_world_profile: '自定义世界档案表',
custom_world_session: '自定义世界会话表',
custom_world_agent_session: '自定义世界 Agent 会话表',
custom_world_agent_message: '自定义世界 Agent 消息表',
custom_world_agent_operation: '自定义世界 Agent 操作表',
custom_world_draft_card: '自定义世界草稿卡片表',
custom_world_gallery_entry: '自定义世界画廊条目表',
puzzle_agent_session: '拼图 Agent 会话表',
puzzle_agent_message: '拼图 Agent 消息表',
puzzle_work_profile: '拼图作品档案表',
puzzle_event: '拼图事件表',
puzzle_runtime_run: '拼图运行记录表',
puzzle_leaderboard_entry: '拼图排行榜条目表',
match3d_agent_session: '抓大鹅 Agent 会话表',
match3d_agent_message: '抓大鹅 Agent 消息表',
match3d_work_profile: '抓大鹅作品档案表',
match3d_runtime_run: '抓大鹅运行记录表',
square_hole_agent_session: '方洞挑战 Agent 会话表',
square_hole_agent_message: '方洞挑战 Agent 消息表',
square_hole_work_profile: '方洞挑战作品档案表',
square_hole_runtime_run: '方洞挑战运行记录表',
visual_novel_agent_session: '视觉小说 Agent 会话表',
visual_novel_agent_message: '视觉小说 Agent 消息表',
visual_novel_work_profile: '视觉小说作品档案表',
visual_novel_runtime_run: '视觉小说运行记录表',
visual_novel_runtime_history_entry: '视觉小说历史条目表',
visual_novel_runtime_event: '视觉小说运行事件表',
big_fish_creation_session: '大鱼吃小鱼创建会话表',
big_fish_agent_message: '大鱼吃小鱼 Agent 消息表',
big_fish_asset_slot: '大鱼吃小鱼资产槽位表',
big_fish_event: '大鱼吃小鱼事件表',
big_fish_runtime_run: '大鱼吃小鱼运行记录表',
asset_object: '资产对象表',
asset_entity_binding: '资产实体绑定表',
asset_event: '资产事件表',
@@ -0,0 +1,186 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, expect, test, vi } from 'vitest';
import {
listAdminGameDistributionReviews,
reviewAdminGameDistributionVersion,
suspendAdminGameDistributionGame,
} from '../api/adminApiClient';
import type { AdminGameDistributionReviewEntry } from '../api/adminApiTypes';
import {
AdminGameDistributionReviewPage,
resolveGameReleaseEntryUrlError,
} from './AdminGameDistributionReviewPage';
vi.mock('../api/adminApiClient', () => ({
isAdminApiError: vi.fn(
(error: unknown) =>
typeof error === 'object' &&
error !== null &&
'status' in error &&
typeof error.status === 'number',
),
formatAdminApiError: vi.fn((error: unknown) =>
error instanceof Error ? error.message : '请求失败',
),
listAdminGameDistributionReviews: vi.fn(),
reviewAdminGameDistributionVersion: vi.fn(),
suspendAdminGameDistributionGame: vi.fn(),
}));
const entry: AdminGameDistributionReviewEntry = {
versionId: 'version-1',
gameId: 'game_1',
versionNumber: 2,
packageSha256: 'a'.repeat(64),
packageBytes: 2048,
status: 'pending_review',
publicationRevision: 4,
reviewReason: null,
createdAt: '2026-09-20T08:00:00Z',
updatedAt: '2026-09-20T08:00:00Z',
};
beforeEach(() => {
vi.mocked(listAdminGameDistributionReviews).mockReset();
vi.mocked(reviewAdminGameDistributionVersion).mockReset();
vi.mocked(suspendAdminGameDistributionGame).mockReset();
vi.mocked(listAdminGameDistributionReviews).mockResolvedValue({
entries: [entry],
nextCursor: null,
});
});
test('发行入口必须是带完整来源的 HTTPS 地址', () => {
expect(resolveGameReleaseEntryUrlError('')).toBe('请填写发行入口');
expect(
resolveGameReleaseEntryUrlError('http://games.test/a/index.html'),
).toBe('发行入口必须以 https:// 开头');
expect(
resolveGameReleaseEntryUrlError('https://games.test/a/index.html?token=1'),
).toBe('发行入口不能包含 query 或 fragment');
expect(
resolveGameReleaseEntryUrlError('https://u:p@games.test/a/index.html'),
).toBe('发行入口不能包含凭据');
expect(
resolveGameReleaseEntryUrlError('https://games.test/a/index.html'),
).toBe('');
});
test('通过审核时提交当前 publicationRevision 与发行入口并刷新列表', async () => {
vi.mocked(reviewAdminGameDistributionVersion).mockResolvedValue({
version: { ...entry, status: 'published' },
replayed: false,
});
render(
<AdminGameDistributionReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
await screen.findByText('game_1');
fireEvent.change(screen.getByLabelText('发行入口'), {
target: { value: 'https://games.test/releases/game_1/index.html' },
});
fireEvent.click(screen.getByRole('button', { name: '通过' }));
await waitFor(() =>
expect(reviewAdminGameDistributionVersion).toHaveBeenCalledTimes(1),
);
const [token, versionId, idempotencyKey, payload] =
vi.mocked(reviewAdminGameDistributionVersion).mock.calls[0] ?? [];
expect(token).toBe('admin-token');
expect(versionId).toBe('version-1');
expect(String(idempotencyKey)).toContain('version-1');
expect(payload).toEqual({
decision: 'approve',
expectedPublicationRevision: 4,
entryUrl: 'https://games.test/releases/game_1/index.html',
});
await waitFor(() =>
expect(vi.mocked(listAdminGameDistributionReviews)).toHaveBeenCalledTimes(
2,
),
);
});
test('缺少拒绝理由时不调用审核接口', async () => {
render(
<AdminGameDistributionReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
await screen.findByText('game_1');
fireEvent.click(screen.getByRole('button', { name: '拒绝' }));
expect(await screen.findByText('拒绝审核必须填写理由')).toBeTruthy();
expect(reviewAdminGameDistributionVersion).not.toHaveBeenCalled();
});
test('安全下架需要二次确认,并携带公开修订号与原因', async () => {
vi.mocked(suspendAdminGameDistributionGame).mockResolvedValue({
game: {
id: 'game_1',
title: '测试游戏',
status: 'suspended',
publicationRevision: 5,
},
replayed: false,
});
render(
<AdminGameDistributionReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
await screen.findByText('game_1');
fireEvent.change(screen.getByLabelText('下架原因'), {
target: { value: '盗用素材' },
});
fireEvent.click(screen.getByRole('button', { name: '安全下架' }));
// 第一次点击只弹出确认面板,不直接调用后端。
expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
expect(await screen.findByRole('dialog')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() =>
expect(suspendAdminGameDistributionGame).toHaveBeenCalledTimes(1),
);
const [token, gameId, idempotencyKey, payload] =
vi.mocked(suspendAdminGameDistributionGame).mock.calls[0] ?? [];
expect(token).toBe('admin-token');
expect(gameId).toBe('game_1');
expect(String(idempotencyKey)).toContain('game_1');
expect(payload).toEqual({
expectedPublicationRevision: 4,
reason: '盗用素材',
});
expect(await screen.findByText(/已安全下架/u)).toBeTruthy();
});
test('取消确认时不下架', async () => {
render(
<AdminGameDistributionReviewPage
token="admin-token"
onUnauthorized={vi.fn()}
/>,
);
await screen.findByText('game_1');
fireEvent.click(screen.getByRole('button', { name: '安全下架' }));
await screen.findByRole('dialog');
fireEvent.click(screen.getByRole('button', { name: '取消' }));
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
expect(suspendAdminGameDistributionGame).not.toHaveBeenCalled();
});
@@ -0,0 +1,365 @@
import { RefreshCcw } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import {
listAdminGameDistributionReviews,
reviewAdminGameDistributionVersion,
suspendAdminGameDistributionGame,
} from '../api/adminApiClient';
import type { AdminGameDistributionReviewEntry } from '../api/adminApiTypes';
import { useAdminWriteConfirm } from '../components/useAdminWriteConfirm';
import { handlePageError } from './pageUtils';
interface AdminGameDistributionReviewPageProps {
token: string;
onUnauthorized: (message?: string) => void;
}
function formatBytes(value: number) {
if (value >= 1024 * 1024) {
return `${(value / (1024 * 1024)).toFixed(1)} MiB`;
}
if (value >= 1024) {
return `${(value / 1024).toFixed(1)} KiB`;
}
return `${value} B`;
}
function formatTime(value: string) {
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return parsed.toLocaleString('zh-CN', { hour12: false });
}
function createSuspendIdempotencyKey(gameId: string) {
const random =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `game-suspend-${gameId}-${random}`.slice(0, 128);
}
function createReviewIdempotencyKey(versionId: string) {
const random =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`;
return `game-review-${versionId}-${random}`.slice(0, 128);
}
export function resolveGameReleaseEntryUrlError(value: string) {
const normalized = value.trim();
if (!normalized) return '请填写发行入口';
if (!normalized.startsWith('https://')) {
return '发行入口必须以 https:// 开头';
}
if (normalized.includes('?') || normalized.includes('#')) {
return '发行入口不能包含 query 或 fragment';
}
try {
const parsed = new URL(normalized);
if (parsed.username || parsed.password) {
return '发行入口不能包含凭据';
}
} catch {
return '发行入口不是合法 URL';
}
return '';
}
export function AdminGameDistributionReviewPage({
token,
onUnauthorized,
}: AdminGameDistributionReviewPageProps) {
const [entries, setEntries] = useState<AdminGameDistributionReviewEntry[]>(
[],
);
const [isLoading, setIsLoading] = useState(false);
const [busyVersionId, setBusyVersionId] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const [statusMessage, setStatusMessage] = useState('');
const [entryUrlByVersion, setEntryUrlByVersion] = useState<
Record<string, string>
>({});
const [reasonByVersion, setReasonByVersion] = useState<
Record<string, string>
>({});
const [suspendReasonByGame, setSuspendReasonByGame] = useState<
Record<string, string>
>({});
const [busyGameId, setBusyGameId] = useState('');
const writeConfirm = useAdminWriteConfirm();
const loadReviews = useCallback(async () => {
setIsLoading(true);
setErrorMessage('');
try {
const response = await listAdminGameDistributionReviews(token);
setEntries(response.entries);
} catch (error) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setIsLoading(false);
}
}, [token, onUnauthorized]);
useEffect(() => {
void loadReviews();
}, [loadReviews]);
async function submitReview(
entry: AdminGameDistributionReviewEntry,
decision: 'approve' | 'reject',
) {
const entryUrl = (entryUrlByVersion[entry.versionId] ?? '').trim();
const reason = (reasonByVersion[entry.versionId] ?? '').trim();
if (decision === 'approve') {
const invalid = resolveGameReleaseEntryUrlError(entryUrl);
if (invalid) {
setErrorMessage(invalid);
return;
}
} else if (!reason) {
setErrorMessage('拒绝审核必须填写理由');
return;
}
setBusyVersionId(entry.versionId);
setErrorMessage('');
setStatusMessage('');
try {
await reviewAdminGameDistributionVersion(
token,
entry.versionId,
createReviewIdempotencyKey(entry.versionId),
decision === 'approve'
? {
decision,
expectedPublicationRevision: entry.publicationRevision,
entryUrl,
}
: {
decision,
expectedPublicationRevision: entry.publicationRevision,
reviewReason: reason,
},
);
setStatusMessage(
decision === 'approve'
? `版本 v${entry.versionNumber} 已通过审核`
: `版本 v${entry.versionNumber} 已拒绝`,
);
await loadReviews();
} catch (error) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setBusyVersionId('');
}
}
/**
* 管理员安全下架:先二次确认,再带当前公开修订号调用后端;并发审核导致修订号变化时
* 由服务端返回冲突,前端只提示刷新,不静默重试。
*/
async function suspendGame(entry: AdminGameDistributionReviewEntry) {
const reason = (suspendReasonByGame[entry.gameId] ?? '').trim();
const confirmed = await writeConfirm.confirmWrite({
action: '安全下架游戏',
target: `${entry.gameId}(版本 v${entry.versionNumber})`,
});
if (!confirmed) return;
setBusyGameId(entry.gameId);
setErrorMessage('');
setStatusMessage('');
try {
await suspendAdminGameDistributionGame(
token,
entry.gameId,
createSuspendIdempotencyKey(entry.gameId),
{
expectedPublicationRevision: entry.publicationRevision,
...(reason ? { reason } : {}),
},
);
setStatusMessage(`游戏 ${entry.gameId} 已安全下架,发行入口已关闭`);
setSuspendReasonByGame((current) => ({ ...current, [entry.gameId]: '' }));
await loadReviews();
} catch (error) {
handlePageError(error, onUnauthorized, setErrorMessage);
} finally {
setBusyGameId('');
}
}
return (
<section className="admin-page admin-page-wide">
<div className="admin-page-heading">
<h1>游戏审核</h1>
<button
type="button"
className="admin-secondary-button"
onClick={() => void loadReviews()}
disabled={isLoading}
>
<RefreshCcw aria-hidden="true" />
刷新
</button>
</div>
{errorMessage ? (
<div className="admin-alert admin-alert-warning" role="alert">
{errorMessage}
</div>
) : null}
{statusMessage ? (
<div className="admin-alert admin-alert-success" role="status">
{statusMessage}
</div>
) : null}
<div className="admin-panel">
<div className="admin-panel-heading">
<h2>待审版本</h2>
<span className="admin-muted-text">共 {entries.length} 条</span>
</div>
{isLoading ? (
<p className="admin-muted-text">正在加载待审版本…</p>
) : null}
{!isLoading && entries.length === 0 ? (
<p className="admin-muted-text">当前没有待审核的游戏版本。</p>
) : null}
{!isLoading && entries.length > 0 ? (
<div className="admin-table-wrap">
<table className="admin-table admin-table-wide">
<thead>
<tr>
<th>游戏</th>
<th>版本</th>
<th>发行包</th>
<th>提交时间</th>
<th>审核</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => {
const busy = busyVersionId === entry.versionId;
return (
<tr key={entry.versionId}>
<td>
<code>{entry.gameId}</code>
</td>
<td>
v{entry.versionNumber}
<div className="admin-muted-text">{entry.status}</div>
{entry.reviewReason ? (
<div className="admin-muted-text">
{entry.reviewReason}
</div>
) : null}
</td>
<td>
{formatBytes(entry.packageBytes)}
<div className="admin-muted-text">
<code>{entry.packageSha256.slice(0, 12)}</code>
</div>
</td>
<td>{formatTime(entry.createdAt)}</td>
<td>
<div className="admin-action-row">
<div className="admin-field">
<label
htmlFor={`game-release-url-${entry.versionId}`}
>
发行入口
</label>
<input
id={`game-release-url-${entry.versionId}`}
value={entryUrlByVersion[entry.versionId] ?? ''}
placeholder="https://"
onChange={(event) =>
setEntryUrlByVersion((current) => ({
...current,
[entry.versionId]: event.target.value,
}))
}
disabled={busy}
/>
</div>
<button
type="button"
className="admin-primary-button"
disabled={busy}
onClick={() => void submitReview(entry, 'approve')}
>
通过
</button>
<div className="admin-field">
<label
htmlFor={`game-reject-reason-${entry.versionId}`}
>
拒绝理由
</label>
<input
id={`game-reject-reason-${entry.versionId}`}
value={reasonByVersion[entry.versionId] ?? ''}
onChange={(event) =>
setReasonByVersion((current) => ({
...current,
[entry.versionId]: event.target.value,
}))
}
disabled={busy}
/>
</div>
<button
type="button"
className="admin-ghost-button"
disabled={busy}
onClick={() => void submitReview(entry, 'reject')}
>
拒绝
</button>
<div className="admin-field">
<label
htmlFor={`game-suspend-reason-${entry.versionId}`}
>
下架原因
</label>
<input
id={`game-suspend-reason-${entry.versionId}`}
value={suspendReasonByGame[entry.gameId] ?? ''}
onChange={(event) =>
setSuspendReasonByGame((current) => ({
...current,
[entry.gameId]: event.target.value,
}))
}
disabled={busy}
/>
</div>
<button
type="button"
className="admin-danger-button"
disabled={busy || busyGameId === entry.gameId}
onClick={() => void suspendGame(entry)}
>
{busyGameId === entry.gameId
? '正在下架…'
: '安全下架'}
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : null}
</div>
{writeConfirm.confirmDialog}
</section>
);
}
@@ -175,6 +175,107 @@ test('灰度发布页可选择模板库并默认启用零比例灰度', async ()
);
});
test('灰度发布页可选择游戏发布开关,默认保持「未开启即开放」语义', async () => {
const user = userEvent.setup();
render(
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByRole('button', { name: 'editor.new-toolbar' });
await user.selectOptions(screen.getByLabelText('Gate Key 前缀'), [
'game-distribution',
]);
expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe(
'game-distribution:publish',
);
expect(
(screen.getByLabelText('Gate Key 目标') as HTMLSelectElement).value,
).toBe('publish');
// 该开关的语义是「未配置/关闭 = 默认开放」,所以选中后不能默认打开收紧。
expect((screen.getByLabelText('启用') as HTMLInputElement).checked).toBe(
false,
);
expect((screen.getByLabelText('灰度比例') as HTMLInputElement).value).toBe(
'0',
);
expect(
(screen.getByLabelText('描述') as HTMLTextAreaElement).value,
).toContain('游戏发布入口灰度');
});
test('灰度发布页保存游戏发布开关时写入白名单与比例', async () => {
const user = userEvent.setup();
vi.mocked(upsertAdminFeatureGateConfig).mockResolvedValueOnce({
gates: [
...configResponse.gates,
{
gateKey: 'game-distribution:publish',
enabled: true,
rolloutPercent: 20,
allowUserIds: ['user-internal'],
allowUserTags: [],
denyUserIds: [],
description: '游戏发布入口灰度',
updatedAt: '2026-09-22T10:00:00Z',
},
],
});
render(
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByRole('button', { name: 'editor.new-toolbar' });
await user.selectOptions(screen.getByLabelText('Gate Key 前缀'), [
'game-distribution',
]);
fireEvent.click(screen.getByLabelText('启用'));
fireEvent.change(screen.getByLabelText('灰度比例'), {
target: { value: '20' },
});
fireEvent.change(screen.getByLabelText('允许用户 ID'), {
target: { value: 'user-internal' },
});
fireEvent.change(screen.getByLabelText('描述'), {
target: { value: '游戏发布入口灰度' },
});
await user.click(screen.getByRole('button', { name: '保存配置' }));
await user.click(screen.getByRole('button', { name: '确认' }));
await waitFor(() =>
expect(upsertAdminFeatureGateConfig).toHaveBeenCalledWith('admin-token', {
gateKey: 'game-distribution:publish',
enabled: true,
rolloutPercent: 20,
allowUserIds: ['user-internal'],
allowUserTags: [],
denyUserIds: [],
description: '游戏发布入口灰度',
}),
);
});
test('未创建的预设开关在后台可见并可一键配置', async () => {
const user = userEvent.setup();
render(
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
);
const row = await screen.findByText('game-distribution:publish');
expect(row).not.toBeNull();
// 该开关默认未创建:列表里给出「配置」入口,点击后按默认关闭填充表单。
const configureButton = row.closest('tr')?.querySelector('button');
expect(configureButton).not.toBeNull();
await user.click(configureButton!);
expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe(
'game-distribution:publish',
);
expect((screen.getByLabelText('启用') as HTMLInputElement).checked).toBe(
false,
);
});
test('灰度发布页保存时转换数组和百分比', async () => {
const user = userEvent.setup();
vi.mocked(upsertAdminFeatureGateConfig).mockResolvedValueOnce({
@@ -28,6 +28,7 @@ interface GateTargetOption {
const GATE_PREFIX_LABELS: Record<string, string> = {
'image-editor': '画布',
agc: '客户端',
'game-distribution': '游戏分发',
};
const FIXED_GATE_TARGETS: GateTargetOption[] = [
@@ -45,6 +46,14 @@ const FIXED_GATE_TARGETS: GateTargetOption[] = [
label: 'Agent 侧边栏',
description: '画布 Agent 入口灰度',
},
{
prefix: 'game-distribution',
suffix: 'publish',
key: 'game-distribution:publish',
label: '游戏发布',
description:
'游戏发布入口灰度:未配置或关闭时对已登录作者默认开放,开启后只放行白名单 / 灰度命中',
},
];
export function AdminGrayReleaseConfigPage({
@@ -197,6 +206,11 @@ export function AdminGrayReleaseConfigPage({
setErrorMessage('');
}
// 预设里尚未创建行的开关也要可见:运营需要先看到 key 才能配置灰度。
const unconfiguredGateTargets = FIXED_GATE_TARGETS.filter(
(option) => !gates.some((gate) => gate.gateKey === option.key),
);
function buildPayload(): AdminUpsertFeatureGateConfigRequest {
return {
gateKey: gateKey.trim(),
@@ -443,6 +457,53 @@ export function AdminGrayReleaseConfigPage({
</div>
)}
</section>
<section className="admin-panel">
<div className="admin-panel-heading">
<h3>可配置开关</h3>
<span>{unconfiguredGateTargets.length}</span>
</div>
{unconfiguredGateTargets.length ? (
<div className="admin-table-wrap">
<table className="admin-table admin-table-compact">
<thead>
<tr>
<th>Gate</th>
<th>说明</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{unconfiguredGateTargets.map((option) => (
<tr key={option.key}>
<td>
{option.key}
<small>
{GATE_PREFIX_LABELS[option.prefix] ?? option.prefix} ·{' '}
{option.label}
</small>
</td>
<td>{option.description}</td>
<td>
<button
className="admin-text-button"
type="button"
onClick={() => applyGateTarget(option)}
>
配置
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="admin-empty-state">
{isLoading ? '加载中' : '预设开关都已创建'}
</div>
)}
</section>
</div>
{confirmDialog}
-10
View File
@@ -15,20 +15,10 @@
"skill-pack:test": "node --test scripts/check-skill-pack.test.mjs",
"llm-status": "node scripts/run-cli-with-config.mjs --llm-status",
"agent-task": "node scripts/run-cli-with-config.mjs --agent-task",
"chat": "node scripts/run-cli-with-config.mjs --swarm-chat",
"swarm": "node scripts/run-cli-with-config.mjs --swarm-chat",
"config": "node scripts/game-creator-config-wizard.mjs",
"test:chat": "node scripts/agent-swarm-test-chat.mjs --task \"制作一个可直接试玩的原创植物塔防小游戏:玩家选择并放置原创守卫阻挡敌人,完成波次后可以进入下一关并重新开始。主题、单位名称与视觉语言必须原创,不使用任何现有游戏角色、单位名、Logo 或受保护视觉语言。请自主完成正式产物、静态检查和双视口试玩验证。\" --no-open",
"test:chat:manual": "node scripts/agent-swarm-test-chat.mjs",
"agent-run": "node scripts/run-cli-with-config.mjs --agent-run",
"agent-run:smoke": "node scripts/smoke-agent-run-local-provider.mjs",
"agent-runtime:real-e2e": "node scripts/agent-runtime-real-e2e.mjs",
"agent-runtime:collaboration-policy-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-collaboration-policy-mixed-recovery",
"agent-runtime:mixed-swarm-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-static-isolated-autonomous-chat",
"agent-runtime:supervisor-swarm-autonomous-chat-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-autonomous-chat",
"agent-runtime:supervisor-autonomous-playable-lane-defense-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-autonomous-playable-lane-defense",
"agent-runtime:supervisor-autonomous-playable-lane-defense-deterministic-e2e": "node scripts/agent-runtime-deterministic-playable-e2e.mjs",
"agent-runtime:supervisor-autonomous-playable-lane-defense-deterministic-self-test": "node scripts/agent-runtime-deterministic-playable-e2e.mjs --self-test",
"agent-runtime:supervisor-swarm-transient-retry-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-transient-retry",
"agent-runtime:supervisor-swarm-final-reply-transient-retry-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-final-reply-transient-retry",
"agent-runtime:supervisor-swarm-tool-plan-handoff-runner-kill-real-e2e": "node scripts/agent-runtime-real-e2e.mjs --suite supervisor-swarm-tool-plan-handoff-runner-kill",
File diff suppressed because it is too large Load Diff
@@ -1,69 +1,8 @@
import { createHash } from '../dependencies.mjs';
import { isPlainObject } from '../harness/config.mjs';
import {
interactiveCliOutput,
writeInteractiveCliLine,
} from '../harness/process.mjs';
import {
shutdownWaiters,
state,
userInputAnswerText,
} from '../runtime-state.mjs';
import { shutdownWaiters, state } from '../runtime-state.mjs';
import { disposableProjectPathVariants } from './runtime.mjs';
export async function answerRemainingInteractiveQuestions(session) {
let answeredPromptCount = 1;
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
const output = interactiveCliOutput(session);
if (output.includes(`[\u5df2\u56de\u7b54] ${state.userInput.requestId}`))
return;
const promptCount = output.split('或直接输入其他答案:').length - 1;
while (answeredPromptCount < promptCount && answeredPromptCount < 3) {
writeInteractiveCliLine(session, userInputAnswerText);
answeredPromptCount += 1;
}
if (output.includes('[待确认]')) {
throw codedError('user-input-unexpected-tool-confirmation');
}
if (session.closed) throw codedError('user-input-cli-closed-before-answer');
await sleep(100);
}
throw codedError('user-input-answer-timeout');
}
export function parseSingleSwarmTurnReport(output, codePrefix) {
const reportLines = output
.split(/\r?\n/u)
.filter((line) => line.startsWith('[turn.report] '));
assert(reportLines.length === 1, `${codePrefix}-turn-report-count-invalid`);
const report = JSON.parse(reportLines[0].slice('[turn.report] '.length));
assert(
isPlainObject(report) &&
JSON.stringify(Object.keys(report).sort()) ===
JSON.stringify(
[
'schemaVersion',
'outcome',
'parentAgentId',
'sessionId',
'parentRunId',
'runtimeCount',
'busyRuntimeCount',
'pendingTaskCount',
'runningTaskCount',
'waitingForConfirmationCount',
'waitingForUserInputCount',
'newAssistantMessageCount',
'finalReplyChars',
'reconciliationAgentCount',
].sort(),
),
`${codePrefix}-turn-report-shape-invalid`,
);
return report;
}
export function isFailedTask(task) {
return (
['failed', 'cancelled', 'budget-exhausted'].includes(task.status) ||
@@ -2419,24 +2419,6 @@ export function finalMessageId(agentId, sessionId, runId) {
).slice(0, 32)}`;
}
export function runtimePublicStatusMessageId(
agentId,
sessionId,
runId,
status,
) {
const correlationId = runtimeMessageCorrelationId(
agentId,
sessionId,
runId,
).slice(0, 32);
const statusFingerprint = createHash('sha256')
.update(status)
.digest('hex')
.slice(0, 16);
return `runtime-public-status-${correlationId}-${statusFingerprint}`;
}
export function backgroundTaskMessageId(agentId, sessionId, runId, source) {
const fingerprint = createHash('sha256')
.update(`${agentId}\n${sessionId}\n${runId}\n${source}`)
File diff suppressed because it is too large Load Diff
@@ -37,26 +37,16 @@ import {
state,
steerRunnerKillAppDataSentinelFileName,
steerRunnerKillAppDataSentinelSchema,
supervisorAutonomousPlayableAppDataSentinelFileName,
supervisorAutonomousPlayableAppDataSentinelSchema,
supervisorSwarmAppDataSentinelFileName,
supervisorSwarmAppDataSentinelSchema,
supervisorSwarmAutonomousChatAppDataSentinelFileName,
supervisorSwarmAutonomousChatAppDataSentinelSchema,
supervisorSwarmCollaborationPolicyAppDataSentinelFileName,
supervisorSwarmCollaborationPolicyAppDataSentinelSchema,
supervisorSwarmFinalReplyTransientRetryAppDataSentinelFileName,
supervisorSwarmFinalReplyTransientRetryAppDataSentinelSchema,
supervisorSwarmRequiredAgentIds,
supervisorSwarmStaticIsolatedAutonomousChatAppDataSentinelFileName,
supervisorSwarmStaticIsolatedAutonomousChatAppDataSentinelSchema,
supervisorSwarmToolPlanHandoffRunnerKillAppDataSentinelFileName,
supervisorSwarmToolPlanHandoffRunnerKillAppDataSentinelSchema,
supervisorSwarmTransientRetryAppDataSentinelFileName,
supervisorSwarmTransientRetryAppDataSentinelSchema,
supervisorSwarmTransientRetryTargetAgentId,
userInputAppDataSentinelFileName,
userInputAppDataSentinelSchema,
webSearchAppDataSentinelFileName,
webSearchAppDataSentinelSchema,
windowsProcessHandleHelperSource,
@@ -68,21 +58,14 @@ import { isProjectSkillSuite } from '../suites/project-skill.mjs';
import { isResponseStreamSuite } from '../suites/response-stream.mjs';
import { isScopedAgentsSuite } from '../suites/scoped-agents.mjs';
import { isSteerRunnerKillSuite } from '../suites/steer-runner-kill.mjs';
import { isSupervisorAutonomousPlayableLaneDefenseSuite } from '../suites/supervisor-autonomous-playable.mjs';
import {
isSupervisorSwarmAutonomousChatSuite,
isSupervisorSwarmCollaborationPolicyMixedRecoverySuite,
isSupervisorSwarmFinalReplyTransientRetrySuite,
isSupervisorSwarmInitialTransientRetrySuite,
isSupervisorSwarmInteractiveChatSuite,
isSupervisorSwarmMixedHarnessSuite,
isSupervisorSwarmStaticIsolatedAutonomousChatSuite,
isSupervisorSwarmSuite,
isSupervisorSwarmToolPlanHandoffRunnerKillSuite,
isSupervisorSwarmTransientRetrySuite,
rebuildSupervisorSwarmTranscriptScanner,
} from '../suites/supervisor-swarm.mjs';
import { isUserInputRuntimeSuite } from '../suites/user-input.mjs';
import {
isWebSearchSuite,
sameEffectiveAgentLlmWithoutWebSearch,
@@ -100,19 +83,10 @@ import { appendBounded, runProcess } from './process.mjs';
import { decodeUtf8Fatal, isIsolatedRunnerSuite } from './reporting.mjs';
import { killRunnerOnce, readRunnerStatus, runnerBootId } from './runtime.mjs';
const platformSessionFixtureEnv = 'GENARRATIVE_AGC_PLATFORM_SESSION_FIXTURE';
const platformSessionFixtureMaxBytes = 16 * 1024;
const isolatedPlatformSessionFixtureName =
'.deterministic-platform-session.json';
const platformSessionFixtureSchema =
'genarrative-agc-platform-session-fixture.v1';
export function isolatedSuiteProtectsSourceAppData() {
return (
isSupervisorSwarmTransientRetrySuite() ||
isSupervisorSwarmToolPlanHandoffRunnerKillSuite() ||
isSupervisorAutonomousPlayableLaneDefenseSuite() ||
isSupervisorSwarmInteractiveChatSuite()
isSupervisorSwarmToolPlanHandoffRunnerKillSuite()
);
}
@@ -120,27 +94,6 @@ export function isolatedSuiteUsesSiblingAppData() {
return isWebSearchSuite() || isolatedSuiteProtectsSourceAppData();
}
export function sameSupervisorPlayableProviderBinding(left, right) {
const scalarFields = [
'providerAgentMode',
'providerModel',
'providerApiKind',
'providerReasoningEffort',
'providerBaseUrlSha256',
];
return (
left != null &&
right != null &&
scalarFields.every((field) => left[field] === right[field]) &&
Array.isArray(left.boundAgentIds) &&
Array.isArray(right.boundAgentIds) &&
left.boundAgentIds.length === right.boundAgentIds.length &&
left.boundAgentIds.every(
(agentId, index) => agentId === right.boundAgentIds[index],
)
);
}
export function isolatedSuiteAppDataProfile() {
if (isSteerRunnerKillSuite()) {
return {
@@ -174,14 +127,6 @@ export function isolatedSuiteAppDataProfile() {
codePrefix: 'context-compaction-appdata',
};
}
if (isUserInputRuntimeSuite()) {
return {
prefix: '.agent-runtime-real-e2e-user-input-',
sentinelName: userInputAppDataSentinelFileName,
sentinelSchema: userInputAppDataSentinelSchema,
codePrefix: 'user-input-appdata',
};
}
if (isScopedAgentsSuite()) {
return {
prefix: '.agent-runtime-real-e2e-scoped-agents-',
@@ -236,41 +181,6 @@ export function isolatedSuiteAppDataProfile() {
codePrefix: 'supervisor-swarm-transient-retry-appdata',
};
}
if (isSupervisorAutonomousPlayableLaneDefenseSuite()) {
return {
prefix: '.agent-runtime-real-e2e-supervisor-autonomous-playable-',
sentinelName: supervisorAutonomousPlayableAppDataSentinelFileName,
sentinelSchema: supervisorAutonomousPlayableAppDataSentinelSchema,
codePrefix: 'supervisor-autonomous-playable-appdata',
};
}
if (isSupervisorSwarmAutonomousChatSuite()) {
return {
prefix: '.agent-runtime-real-e2e-supervisor-swarm-autonomous-chat-',
sentinelName: supervisorSwarmAutonomousChatAppDataSentinelFileName,
sentinelSchema: supervisorSwarmAutonomousChatAppDataSentinelSchema,
codePrefix: 'supervisor-swarm-autonomous-chat-appdata',
};
}
if (isSupervisorSwarmStaticIsolatedAutonomousChatSuite()) {
return {
prefix:
'.agent-runtime-real-e2e-supervisor-swarm-static-isolated-autonomous-chat-',
sentinelName:
supervisorSwarmStaticIsolatedAutonomousChatAppDataSentinelFileName,
sentinelSchema:
supervisorSwarmStaticIsolatedAutonomousChatAppDataSentinelSchema,
codePrefix: 'supervisor-swarm-static-isolated-autonomous-chat-appdata',
};
}
if (isSupervisorSwarmCollaborationPolicyMixedRecoverySuite()) {
return {
prefix: '.agent-runtime-real-e2e-supervisor-swarm-collaboration-policy-',
sentinelName: supervisorSwarmCollaborationPolicyAppDataSentinelFileName,
sentinelSchema: supervisorSwarmCollaborationPolicyAppDataSentinelSchema,
codePrefix: 'supervisor-swarm-collaboration-policy-appdata',
};
}
if (isSupervisorSwarmSuite()) {
return {
prefix: '.agent-runtime-real-e2e-supervisor-swarm-',
@@ -505,120 +415,6 @@ export async function verifySourceAppDataDirectoryUntouched() {
state.isolatedRunner.sourceAppDataDirectoryUntouched = true;
}
async function readPlatformSessionFixtureForIsolatedSuite(sourceConfigDir) {
const rawPath = process.env[platformSessionFixtureEnv];
assert(
isNonEmptyString(rawPath) && path.isAbsolute(rawPath),
'supervisor-autonomous-playable-platform-session-fixture-missing',
);
const sourceRealPath = await fs.realpath(sourceConfigDir);
const requestedPath = path.resolve(rawPath);
const requestedMetadata = await fs.lstat(requestedPath).catch((error) => {
if (error?.code === 'ENOENT') return null;
throw error;
});
assert(
requestedMetadata?.isFile() && !requestedMetadata.isSymbolicLink(),
'supervisor-autonomous-playable-platform-session-fixture-not-regular',
);
assert(
requestedMetadata.size <= platformSessionFixtureMaxBytes,
'supervisor-autonomous-playable-platform-session-fixture-too-large',
);
const realPath = await fs.realpath(requestedPath);
assert(
isPathInside(sourceRealPath, realPath),
'supervisor-autonomous-playable-platform-session-fixture-outside-config',
);
const bytes = await fs.readFile(realPath);
assert(
bytes.length <= platformSessionFixtureMaxBytes,
'supervisor-autonomous-playable-platform-session-fixture-too-large',
);
let fixture;
try {
fixture = JSON.parse(
decodeUtf8Fatal(bytes, 'platform-session-fixture-invalid-utf8'),
);
} catch (error) {
throw codedError(
'supervisor-autonomous-playable-platform-session-fixture-invalid',
error,
);
}
const expectedKeys = [
'schemaVersion',
'userId',
'accessToken',
'apiBaseUrl',
'generation',
];
assert(
isPlainObject(fixture) &&
JSON.stringify(Object.keys(fixture).sort()) ===
JSON.stringify([...expectedKeys].sort()) &&
fixture.schemaVersion === platformSessionFixtureSchema &&
isNonEmptyString(fixture.userId) &&
isNonEmptyString(fixture.accessToken) &&
isNonEmptyString(fixture.apiBaseUrl) &&
Number.isSafeInteger(fixture.generation) &&
fixture.generation > 0,
'supervisor-autonomous-playable-platform-session-fixture-invalid',
);
return {
sourcePath: realPath,
bytes,
fixture,
sha256: createHash('sha256').update(bytes).digest('hex'),
};
}
async function installPlatformSessionFixtureIntoIsolatedAppData(
sourceConfigDir,
appDataDir,
) {
if (!isSupervisorAutonomousPlayableLaneDefenseSuite()) return;
const source =
await readPlatformSessionFixtureForIsolatedSuite(sourceConfigDir);
const isolatedPath = path.join(
appDataDir,
isolatedPlatformSessionFixtureName,
);
await fs.copyFile(
source.sourcePath,
isolatedPath,
fsConstants.COPYFILE_EXCL | fsConstants.COPYFILE_FICLONE,
);
await fs.chmod(isolatedPath, 0o600).catch(() => {});
const isolatedMetadata = await fs.lstat(isolatedPath);
assert(
isolatedMetadata.isFile() &&
!isolatedMetadata.isSymbolicLink() &&
isolatedMetadata.size === source.bytes.length,
'supervisor-autonomous-playable-platform-session-fixture-copy-invalid',
);
const isolatedBytes = await fs.readFile(isolatedPath);
assert(
createHash('sha256').update(isolatedBytes).digest('hex') === source.sha256,
'supervisor-autonomous-playable-platform-session-fixture-copy-mismatch',
);
state.isolatedRunner.platformSessionFixtureSourcePath = source.sourcePath;
state.isolatedRunner.platformSessionFixturePath = isolatedPath;
state.isolatedRunner.platformSessionFixtureSha256 = source.sha256;
state.isolatedRunner.platformSessionFixturePreviousEnv =
Object.prototype.hasOwnProperty.call(process.env, platformSessionFixtureEnv)
? process.env[platformSessionFixtureEnv]
: undefined;
process.env[platformSessionFixtureEnv] = isolatedPath;
state.formalConfigPathTranscriptScanner?.addSecrets(
absolutePathVariants(source.sourcePath, isolatedPath),
);
const previousLeakCount = state.transcriptScanner?.count ?? 0;
state.secrets = [...new Set([...state.secrets, source.fixture.accessToken])];
rebuildSupervisorSwarmTranscriptScanner();
state.transcriptScanner.count = previousLeakCount;
}
export async function prepareIsolatedSuiteAppData({
streamAgentId = null,
webSearchAgentId = null,
@@ -773,8 +569,7 @@ export async function prepareIsolatedSuiteAppData({
isScopedAgentsSuite() ||
isProjectSkillSuite() ||
isParallelReadSuite() ||
isSupervisorSwarmSuite() ||
isSupervisorAutonomousPlayableLaneDefenseSuite()
isSupervisorSwarmSuite()
? 'private-copy'
: 'hardlink';
try {
@@ -891,10 +686,6 @@ export async function prepareIsolatedSuiteAppData({
state.secrets = [...suiteSecrets];
rebuildSupervisorSwarmTranscriptScanner();
state.transcriptScanner.count = previousLeakCount;
await installPlatformSessionFixtureIntoIsolatedAppData(
sourceConfigDir,
appDataDir,
);
const unexpectedEndpoint = await fs
.lstat(path.join(appDataDir, runnerEndpointFileName))
.catch((error) => {
@@ -903,46 +694,6 @@ export async function prepareIsolatedSuiteAppData({
});
assert(!unexpectedEndpoint, `${profile.codePrefix}-endpoint-preexisted`);
state.runtimeConfigDir = appDataDir;
if (isSupervisorAutonomousPlayableLaneDefenseSuite()) {
const isolatedConfig = await loadConfig(appDataDir);
const expectedBinding = state.config.providerBinding;
const agentIds = [projectSupervisorAgentId];
const bindings = agentIds.map((agentId) => {
const effective = effectiveAgentLlmConfig(isolatedConfig.config, agentId);
assert(
['apiKey', 'baseUrl', 'model', 'apiKind', 'reasoningEffort'].every(
(key) => isNonEmptyString(effective[key]),
),
'supervisor-autonomous-playable-effective-provider-incomplete',
);
return {
providerModel: effective.model.trim(),
providerApiKind: effective.apiKind.trim(),
providerReasoningEffort: effective.reasoningEffort.trim(),
providerBaseUrlSha256: hashValue(effective.baseUrl.trim()),
};
});
const effectiveBinding = {
...bindings[0],
boundAgentIds: [...agentIds].sort(),
};
assert(
expectedBinding &&
bindings.every(
(binding) => JSON.stringify(binding) === JSON.stringify(bindings[0]),
) &&
sameSupervisorPlayableProviderBinding(
effectiveBinding,
expectedBinding,
),
'supervisor-autonomous-playable-effective-provider-binding-mismatch',
);
state.supervisorAutonomousPlayable.expectedProviderBinding = {
...expectedBinding,
};
state.supervisorAutonomousPlayable.effectiveProviderBinding =
effectiveBinding;
}
if (streamAgentId) {
const isolatedConfig = await loadConfig(appDataDir);
const isolatedEffective = effectiveAgentLlmConfig(
@@ -981,22 +732,6 @@ export async function prepareIsolatedSuiteAppData({
);
state.webSearch.effectiveEnabled = true;
}
if (isUserInputRuntimeSuite()) {
const isolatedConfig = await loadConfig(appDataDir);
const isolatedEffective = effectiveAgentLlmConfig(
isolatedConfig.config,
projectSupervisorAgentId,
);
assert(
isolatedEffective.model === 'gpt-5.5' &&
['apiKey', 'baseUrl', 'model'].every(
(key) =>
typeof isolatedEffective[key] === 'string' &&
isolatedEffective[key].trim().length > 0,
),
'user-input-effective-gpt-5-5-config-invalid',
);
}
if (isScopedAgentsSuite()) {
const isolatedConfig = await loadConfig(appDataDir);
const isolatedEffective = effectiveAgentLlmConfig(
@@ -1073,9 +808,8 @@ export async function prepareIsolatedSuiteAppData({
effective.requestTimeoutMs > 0 &&
Number.isSafeInteger(effective.maxRetries) &&
effective.maxRetries >=
(isSupervisorSwarmInteractiveChatSuite() ||
(isSupervisorSwarmTransientRetrySuite() &&
agentId !== supervisorSwarmTransientRetryTargetAgentId)
agentId !== supervisorSwarmTransientRetryTargetAgentId
? 0
: 1) &&
effective.maxRetries <= 3 &&
@@ -1095,24 +829,6 @@ export async function prepareIsolatedSuiteAppData({
{ llm: isolatedConfig.config.llm },
'',
);
if (isSupervisorSwarmMixedHarnessSuite()) {
assert(
globalEffective.model === 'gpt-5.5' &&
globalEffective.apiKind === 'openai_chat' &&
isNonEmptyString(globalEffective.reasoningEffort) &&
Number.isSafeInteger(globalEffective.requestTimeoutMs) &&
globalEffective.requestTimeoutMs > 0 &&
Number.isSafeInteger(globalEffective.maxRetries) &&
globalEffective.maxRetries >= 0 &&
globalEffective.maxRetries <= 3 &&
Number.isSafeInteger(globalEffective.retryBackoffMs) &&
globalEffective.retryBackoffMs > 0 &&
['apiKey', 'baseUrl', 'model'].every((key) =>
isNonEmptyString(globalEffective[key]),
),
'supervisor-swarm-mixed-default-provider-policy-invalid',
);
}
const configuredAgentIds = Object.keys(
isPlainObject(isolatedConfig.config.agentLlm)
? isolatedConfig.config.agentLlm
@@ -2000,31 +1716,6 @@ export async function verifyIsolatedSuiteConfigLinksUnchanged() {
'isolated-source-config-changed-during-suite',
);
}
const fixture = state.isolatedRunner;
if (
fixture.platformSessionFixtureSourcePath &&
fixture.platformSessionFixturePath &&
fixture.platformSessionFixtureSha256
) {
const [sourceMetadata, isolatedMetadata, sourceBytes, isolatedBytes] =
await Promise.all([
fs.lstat(fixture.platformSessionFixtureSourcePath),
fs.lstat(fixture.platformSessionFixturePath),
fs.readFile(fixture.platformSessionFixtureSourcePath),
fs.readFile(fixture.platformSessionFixturePath),
]);
assert(
sourceMetadata.isFile() &&
!sourceMetadata.isSymbolicLink() &&
isolatedMetadata.isFile() &&
!isolatedMetadata.isSymbolicLink() &&
createHash('sha256').update(sourceBytes).digest('hex') ===
fixture.platformSessionFixtureSha256 &&
createHash('sha256').update(isolatedBytes).digest('hex') ===
fixture.platformSessionFixtureSha256,
'supervisor-autonomous-playable-platform-session-fixture-changed',
);
}
}
export async function verifySourceConfigLinkCountsRestored() {
@@ -2064,22 +1755,7 @@ export async function removeIsolatedSuiteAppData() {
} catch (error) {
ownershipError = error;
}
try {
await fs.rm(appDataDir, { recursive: true, force: false });
} finally {
if (state.isolatedRunner.platformSessionFixtureSourcePath) {
const previous = state.isolatedRunner.platformSessionFixturePreviousEnv;
if (previous === undefined) {
delete process.env[platformSessionFixtureEnv];
} else {
process.env[platformSessionFixtureEnv] = previous;
}
}
state.isolatedRunner.platformSessionFixturePath = null;
state.isolatedRunner.platformSessionFixtureSourcePath = null;
state.isolatedRunner.platformSessionFixtureSha256 = null;
state.isolatedRunner.platformSessionFixturePreviousEnv = undefined;
}
await fs.rm(appDataDir, { recursive: true, force: false });
state.runtimeConfigDir = state.options.configDir;
try {
await verifySourceConfigLinkCountsRestored();
@@ -13,15 +13,10 @@ import {
responseStreamSuite,
scopedAgentsSuite,
steerRunnerKillSuite,
supervisorAutonomousPlayableLaneDefenseSuite,
supervisorSwarmAutonomousChatSuite,
supervisorSwarmCollaborationPolicyMixedRecoverySuite,
supervisorSwarmFinalReplyTransientRetrySuite,
supervisorSwarmStaticIsolatedAutonomousChatSuite,
supervisorSwarmSuite,
supervisorSwarmToolPlanHandoffRunnerKillSuite,
supervisorSwarmTransientRetrySuite,
userInputRuntimeSuite,
webSearchSuite,
} from '../runtime-state.mjs';
import { collectApiKeys, isPathInside } from './io.mjs';
@@ -58,7 +53,6 @@ export function parseArguments(args) {
suite === responseStreamSuite ||
suite === webSearchSuite ||
suite === contextCompactionSuite ||
suite === userInputRuntimeSuite ||
suite === scopedAgentsSuite ||
suite === projectSkillSuite ||
suite === parallelReadSuite ||
@@ -66,10 +60,6 @@ export function parseArguments(args) {
suite === supervisorSwarmTransientRetrySuite ||
suite === supervisorSwarmFinalReplyTransientRetrySuite ||
suite === supervisorSwarmToolPlanHandoffRunnerKillSuite ||
suite === supervisorSwarmAutonomousChatSuite ||
suite === supervisorAutonomousPlayableLaneDefenseSuite ||
suite === supervisorSwarmStaticIsolatedAutonomousChatSuite ||
suite === supervisorSwarmCollaborationPolicyMixedRecoverySuite ||
suite === steerRunnerKillSuite ||
processSessionSuites.has(suite),
'unsupported-suite',
@@ -2,7 +2,6 @@ import {
assert,
codedError,
hashValue,
sleep,
throwIfShutdownRequested,
} from '../assertions/core.mjs';
import { fs, path, spawn, withLoopbackNoProxy } from '../dependencies.mjs';
@@ -13,14 +12,9 @@ import {
manifestPath,
state,
} from '../runtime-state.mjs';
import {
isSupervisorSwarmTransientRetrySuite,
recordSupervisorSwarmChatSessionFailureDiagnostic,
} from '../suites/supervisor-swarm.mjs';
import { isSupervisorSwarmTransientRetrySuite } from '../suites/supervisor-swarm.mjs';
import { isIsolatedRunnerSuite } from './reporting.mjs';
export const activeInteractiveCliSessions = new Set();
export async function prepareCliBinary() {
const cargo = process.platform === 'win32' ? 'cargo.exe' : 'cargo';
await runProcess(
@@ -84,8 +78,8 @@ export function buildCliChildEnvironment() {
NO_COLOR: '1',
RUST_BACKTRACE: '0',
};
// The deterministic playable suite copies its account fixture into the
// sibling isolated AppData directory. Set the path explicitly here so
// The isolated runner copies its account fixture into the sibling isolated
// AppData directory. Set the path explicitly here so
// every CLI and the Runner it launches use the isolated copy, even if the
// parent harness environment was restored or changed after setup.
if (state.isolatedRunner.platformSessionFixturePath) {
@@ -144,197 +138,6 @@ export function codedProcessError(code, details) {
return error;
}
export function startInteractiveCli(args) {
assert(Boolean(state.cliBinary), 'interactive-cli-binary-not-ready');
assert(Boolean(state.runtimeConfigDir), 'interactive-config-dir-not-ready');
if (
isIsolatedRunnerSuite() &&
state.options?.configDir &&
path.resolve(state.runtimeConfigDir) ===
path.resolve(state.options.configDir)
) {
state.isolatedRunner.sourceConfigCliCallCount += 1;
}
const child = spawn(
state.cliBinary,
[...args, '--config-dir', state.runtimeConfigDir],
{
cwd: appRoot,
env: buildCliChildEnvironment(),
stdio: ['pipe', 'pipe', 'pipe'],
},
);
return createInteractiveCliSession(child);
}
export function createInteractiveCliSession(child) {
activeCommandChildren.add(child);
const session = {
child,
stdout: Buffer.alloc(0),
stderr: Buffer.alloc(0),
exited: false,
exitInfo: null,
exitPromise: null,
closed: false,
closeInfo: null,
closePromise: null,
stdioClosed: false,
stdioCloseInfo: null,
spawnError: null,
stdinError: null,
};
activeInteractiveCliSessions.add(session);
session.exitPromise = new Promise((resolve) => {
const settle = (result) => {
if (session.exited) return;
activeCommandChildren.delete(child);
session.exited = true;
session.closed = true;
session.exitInfo = result;
session.closeInfo = result;
resolve(result);
};
child.once('error', (error) => {
session.spawnError = error;
settle({ code: null, signal: null, error });
});
child.once('exit', (code, signal) => {
settle({ code, signal, error: null });
});
});
session.closePromise = new Promise((resolve) => {
child.once('close', (code, signal) => {
activeCommandChildren.delete(child);
activeInteractiveCliSessions.delete(session);
session.stdioClosed = true;
session.stdioCloseInfo = {
code,
signal,
error: session.spawnError,
};
resolve(session.stdioCloseInfo);
});
});
child.stdin?.on('error', (error) => {
session.stdinError ??= error;
});
child.stdout.on('data', (chunk) => {
state.transcriptScanner?.scan('interactive-stdout', chunk);
state.projectPathTranscriptScanner?.scan('interactive-stdout', chunk);
state.formalConfigPathTranscriptScanner?.scan('interactive-stdout', chunk);
session.stdout = appendBounded(session.stdout, chunk, commandOutputLimit);
});
child.stderr.on('data', (chunk) => {
state.transcriptScanner?.scan('interactive-stderr', chunk);
state.projectPathTranscriptScanner?.scan('interactive-stderr', chunk);
state.formalConfigPathTranscriptScanner?.scan('interactive-stderr', chunk);
session.stderr = appendBounded(session.stderr, chunk, commandOutputLimit);
});
return session;
}
export function interactiveCliOutput(session) {
return `${session.stdout.toString('utf8')}\n${session.stderr.toString('utf8')}`;
}
export function writeInteractiveCliLine(session, line) {
assert(!session.closed, 'interactive-cli-already-closed');
assert(session.child.stdin.writable, 'interactive-cli-stdin-not-writable');
session.child.stdin.write(`${line}\n`);
}
export async function waitForInteractiveCliOutput(
session,
predicate,
code,
timeoutMs,
{ allowAfterProcessExit = false } = {},
) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const output = interactiveCliOutput(session);
if (predicate(output)) return output;
if (session.exited && !allowAfterProcessExit) {
if (session === state.supervisorSwarmCliSession) {
recordSupervisorSwarmChatSessionFailureDiagnostic(session);
}
throw codedError(`${code}-cli-exited`);
}
if (session.stdioClosed) {
if (session === state.supervisorSwarmCliSession) {
recordSupervisorSwarmChatSessionFailureDiagnostic(session);
}
throw codedError(`${code}-cli-stdio-closed`);
}
await sleep(50);
}
throw codedError(code);
}
export async function waitForInteractiveCliExit(session, timeoutMs) {
const result = await Promise.race([
session.exitPromise,
sleep(timeoutMs).then(() => null),
]);
if (!result) throw codedError('interactive-cli-exit-timeout');
if (result.error) throw codedError('interactive-cli-process-error');
assert(
result.code === 0 && result.signal === null,
'interactive-cli-exit-invalid',
);
return result;
}
export async function closeInteractiveCli(session) {
if (!session) return null;
if (session.exited) return session.exitInfo;
if (
session.child.stdin.writable &&
!session.child.stdin.writableEnded &&
!session.child.stdin.destroyed
) {
session.child.stdin.write('/quit\n');
}
let result = await Promise.race([
session.exitPromise,
sleep(3_000).then(() => null),
]);
if (!result && !session.exited) {
session.child.kill('SIGTERM');
result = await Promise.race([
session.exitPromise,
sleep(2_000).then(() => null),
]);
}
if (!result && !session.exited) {
session.child.kill('SIGKILL');
result = await Promise.race([
session.exitPromise,
sleep(5_000).then(() => null),
]);
}
assert(Boolean(result), 'interactive-cli-cleanup-timeout');
return result;
}
export async function waitForInteractiveCliStdioClose(session, timeoutMs) {
if (!session || session.stdioClosed) return session?.stdioCloseInfo ?? null;
const result = await Promise.race([
session.closePromise,
sleep(timeoutMs).then(() => null),
]);
if (!result) throw codedError('interactive-cli-stdio-close-timeout');
return result;
}
export function destroyInteractiveCliOutputStreams(session) {
if (!session) return;
for (const stream of [session.child.stdout, session.child.stderr]) {
if (stream && !stream.destroyed) stream.destroy();
}
}
export async function runProcess(
program,
args,
@@ -406,214 +209,6 @@ export async function runProcess(
});
}
export async function listSystemProcessIdentities() {
if (process.platform === 'win32') {
const systemRoot = process.env.SystemRoot ?? process.env.SYSTEMROOT;
assert(
typeof systemRoot === 'string' && path.isAbsolute(systemRoot),
'owned-process-snapshot-system-root-invalid',
);
const powershell = path.join(
systemRoot,
'System32/WindowsPowerShell/v1.0/powershell.exe',
);
const metadata = await fs.lstat(powershell);
assert(
metadata.isFile() && !metadata.isSymbolicLink(),
'owned-process-snapshot-powershell-invalid',
);
const result = await runProcess(
powershell,
[
'-NoProfile',
'-NonInteractive',
'-Command',
'$processes = @(Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,CreationDate,Name); $processes | ConvertTo-Json -Compress',
],
{
cwd: appRoot,
timeoutMs: 30_000,
env: { ...process.env, NO_COLOR: '1', RUST_BACKTRACE: '0' },
},
);
const parsed = JSON.parse(result.stdout);
return (Array.isArray(parsed) ? parsed : [parsed])
.map((record) => ({
pid: Number(record?.ProcessId),
parentPid: Number(record?.ParentProcessId),
startedAt: String(record?.CreationDate ?? ''),
name: String(record?.Name ?? ''),
}))
.filter(validSystemProcessIdentity);
}
assert(
process.platform === 'linux' || process.platform === 'darwin',
'owned-process-snapshot-platform-unsupported',
);
const result = await runProcess(
'ps',
['-A', '-o', 'pid=', '-o', 'ppid=', '-o', 'lstart=', '-o', 'comm='],
{ cwd: appRoot, timeoutMs: 30_000 },
);
return result.stdout
.split(/\r?\n/u)
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const fields = line.split(/\s+/u);
return {
pid: Number(fields[0]),
parentPid: Number(fields[1]),
startedAt: fields.slice(2, 7).join(' '),
name: fields.slice(7).join(' '),
};
})
.filter(validSystemProcessIdentity);
}
function validSystemProcessIdentity(record) {
return (
Number.isSafeInteger(record?.pid) &&
record.pid > 0 &&
Number.isSafeInteger(record.parentPid) &&
record.parentPid >= 0 &&
typeof record.startedAt === 'string' &&
record.startedAt.length > 0 &&
typeof record.name === 'string' &&
record.name.length > 0
);
}
export function buildOwnedProcessCleanupSnapshot(
processRecords,
{ rootPids = [], runnerPid = null, helperPids = [] } = {},
) {
assert(
Array.isArray(processRecords) &&
Array.isArray(rootPids) &&
Array.isArray(helperPids),
'owned-process-snapshot-input-invalid',
);
const records = processRecords.filter(validSystemProcessIdentity);
const byPid = new Map(records.map((record) => [record.pid, record]));
const childrenByParent = new Map();
for (const record of records) {
const children = childrenByParent.get(record.parentPid) ?? [];
children.push(record.pid);
childrenByParent.set(record.parentPid, children);
}
const normalizedRunnerPid = Number.isSafeInteger(runnerPid)
? runnerPid
: null;
const helperPidSet = new Set(
helperPids.filter((pid) => Number.isSafeInteger(pid) && pid > 0),
);
const roots = [
...new Set(
[...rootPids, normalizedRunnerPid, ...helperPidSet].filter(
(pid) => Number.isSafeInteger(pid) && pid > 0,
),
),
];
assert(roots.length > 0, 'owned-process-snapshot-root-missing');
const ownedPids = new Set();
const queue = [...roots];
while (queue.length > 0) {
const pid = queue.shift();
if (ownedPids.has(pid)) continue;
ownedPids.add(pid);
queue.push(...(childrenByParent.get(pid) ?? []));
}
const identities = [...ownedPids]
.map((pid) => byPid.get(pid))
.filter(Boolean)
.map((record) => ({
pid: record.pid,
startedAt: record.startedAt,
name: record.name,
kind: ownedProcessKind(record, normalizedRunnerPid, helperPidSet),
}))
.sort((left, right) => left.pid - right.pid);
return {
identities,
observedCounts: countOwnedProcessKinds(identities),
};
}
function ownedProcessKind(record, runnerPid, helperPids) {
if (record.pid === runnerPid) return 'runner';
if (helperPids.has(record.pid)) return 'helper';
const name = path.basename(record.name).toLowerCase();
if (/^node(?:\.exe)?$/u.test(name)) return 'node';
if (/^(?:chrome|chromium|msedge|google-chrome)(?:\.exe)?$/u.test(name)) {
return 'browser';
}
return 'command';
}
function countOwnedProcessKinds(identities) {
const counts = {
runner: 0,
helper: 0,
node: 0,
browser: 0,
command: 0,
total: identities.length,
};
for (const identity of identities) counts[identity.kind] += 1;
return counts;
}
export function inspectOwnedProcessCleanupResiduals(
snapshot,
processRecords,
{ activeCommandChildCount = 0, activeInteractiveCliSessionCount = 0 } = {},
) {
assert(
Array.isArray(snapshot?.identities) && Array.isArray(processRecords),
'owned-process-residual-input-invalid',
);
const currentByPid = new Map(
processRecords
.filter(validSystemProcessIdentity)
.map((record) => [record.pid, record]),
);
const residualIdentities = snapshot.identities.filter((identity) => {
const current = currentByPid.get(identity.pid);
return (
current?.startedAt === identity.startedAt &&
current?.name === identity.name
);
});
return {
residualCounts: countOwnedProcessKinds(residualIdentities),
activeCommandChildCount,
activeInteractiveCliSessionCount,
clean:
residualIdentities.length === 0 &&
activeCommandChildCount === 0 &&
activeInteractiveCliSessionCount === 0,
};
}
export async function captureOwnedProcessCleanupSnapshot(options) {
return buildOwnedProcessCleanupSnapshot(
await listSystemProcessIdentities(),
options,
);
}
export async function verifyOwnedProcessCleanupSnapshot(snapshot) {
return inspectOwnedProcessCleanupResiduals(
snapshot,
await listSystemProcessIdentities(),
{
activeCommandChildCount: activeCommandChildren.size,
activeInteractiveCliSessionCount: activeInteractiveCliSessions.size,
},
);
}
export function appendBounded(current, chunk, limit) {
const combined = Buffer.concat([current, chunk]);
return combined.length <= limit
@@ -28,12 +28,10 @@ import {
isGoalRuntimeSuite,
} from '../suites/goal.mjs';
import { isResponseStreamSuite } from '../suites/response-stream.mjs';
import { isSupervisorAutonomousPlayableLaneDefenseSuite } from '../suites/supervisor-autonomous-playable.mjs';
import {
isSupervisorSwarmSuite,
supervisorSwarmVerificationFixtureSource,
} from '../suites/supervisor-swarm.mjs';
import { isUserInputRuntimeSuite } from '../suites/user-input.mjs';
import { isWebSearchSuite } from '../suites/web-search.mjs';
import { createSentinelOwnedTempDirectory } from './app-data.mjs';
import { effectiveAgentLlmConfig } from './config.mjs';
@@ -41,19 +39,15 @@ import { runProcess } from './process.mjs';
import { isIsolatedRunnerSuite } from './reporting.mjs';
export function requiredAgentIdsForSuite() {
return isUserInputRuntimeSuite()
? [projectSupervisorAgentId]
: isSupervisorAutonomousPlayableLaneDefenseSuite()
? [projectSupervisorAgentId]
: isSupervisorSwarmSuite()
? [
projectSupervisorAgentId,
supervisorSwarmDesignAgentId,
supervisorSwarmQualityAgentId,
]
: isIsolatedRunnerSuite()
? [mainAgentId]
: [mainAgentId, 'quality-review'];
return isSupervisorSwarmSuite()
? [
projectSupervisorAgentId,
supervisorSwarmDesignAgentId,
supervisorSwarmQualityAgentId,
]
: isIsolatedRunnerSuite()
? [mainAgentId]
: [mainAgentId, 'quality-review'];
}
export function expectedProviderBindingForSuite(config) {
@@ -120,11 +114,9 @@ export async function checkPrerequisites(config) {
return {
llmConfigured,
providerBinding,
chromeAvailable:
!isIsolatedRunnerSuite() ||
isSupervisorAutonomousPlayableLaneDefenseSuite()
? Boolean(await findSupportedBrowser())
: false,
chromeAvailable: !isIsolatedRunnerSuite()
? Boolean(await findSupportedBrowser())
: false,
editorApiConfigured,
};
}
@@ -267,8 +259,7 @@ export async function seedDisposableProject({
? supervisorSwarmVerificationFixtureSource()
: isGoalRuntimeSuite() ||
isResponseStreamSuite() ||
isWebSearchSuite() ||
isSupervisorAutonomousPlayableLaneDefenseSuite()
isWebSearchSuite()
? goalRevisionOneVerificationFixtureSource()
: goalRevisionTwoVerificationFixtureSource(),
),
@@ -405,23 +396,6 @@ export function seededGameHtml() {
`;
}
export function productionDefaultGameIndexHtml() {
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Genarrative Game Draft</title>
<style>
body { margin: 0; display: grid; min-height: 100vh; place-items: center; background: #101827; color: #d9e7ff; font: 16px system-ui, sans-serif; }
main { width: min(720px, calc(100vw - 32px)); }
</style>
</head>
<body><main>还没有生成游戏。回到聊天输入创意并确认生成后,这里会写入可试玩原型。</main></body>
</html>
`;
}
export function buildTaskPrompt(suite) {
const editorAssetOutcome =
suite === 'full'
@@ -20,9 +20,7 @@ import { isProjectSkillSuite } from '../suites/project-skill.mjs';
import { isResponseStreamSuite } from '../suites/response-stream.mjs';
import { isScopedAgentsSuite } from '../suites/scoped-agents.mjs';
import { isSteerRunnerKillSuite } from '../suites/steer-runner-kill.mjs';
import { isSupervisorAutonomousPlayableLaneDefenseSuite } from '../suites/supervisor-autonomous-playable.mjs';
import { isSupervisorSwarmSuite } from '../suites/supervisor-swarm.mjs';
import { isUserInputRuntimeSuite } from '../suites/user-input.mjs';
import { isWebSearchSuite } from '../suites/web-search.mjs';
import { isPathInside, readJson } from './io.mjs';
@@ -88,17 +86,12 @@ export function buildSummary() {
config: state.config,
blocked: state.blocked,
run: {
agentId:
isUserInputRuntimeSuite() ||
isSupervisorAutonomousPlayableLaneDefenseSuite() ||
isSupervisorSwarmSuite()
? projectSupervisorAgentId
: mainAgentId,
agentId: isSupervisorSwarmSuite()
? projectSupervisorAgentId
: mainAgentId,
runIdHash: hashValue(state.initialRunId),
sessionIdHash: hashValue(state.initialSessionId),
runnerKilled: isSupervisorAutonomousPlayableLaneDefenseSuite()
? false
: state.runnerKilled,
runnerKilled: state.runnerKilled,
resumed: state.resumed,
identityStable: state.identityStable,
},
@@ -110,11 +103,9 @@ export function buildSummary() {
projectPathReportLeakCount: state.projectPathReportLeakCount,
...(isWebSearchSuite() ||
isContextCompactionSuite() ||
isUserInputRuntimeSuite() ||
isScopedAgentsSuite() ||
isProjectSkillSuite() ||
isParallelReadSuite() ||
isSupervisorAutonomousPlayableLaneDefenseSuite() ||
isSupervisorSwarmSuite() ||
isSteerRunnerKillSuite()
? {
@@ -408,11 +399,9 @@ export function isIsolatedRunnerSuite() {
isResponseStreamSuite() ||
isWebSearchSuite() ||
isContextCompactionSuite() ||
isUserInputRuntimeSuite() ||
isScopedAgentsSuite() ||
isProjectSkillSuite() ||
isParallelReadSuite() ||
isSupervisorAutonomousPlayableLaneDefenseSuite() ||
isSupervisorSwarmSuite()
);
}

Some files were not shown because too many files have changed in this diff Show More