Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 07bac0377e | |||
| 46b86527f9 | |||
| 34b95b5826 | |||
| a553967ab9 | |||
| 69f6c2dc69 | |||
| 217f5e8d81 | |||
| 43b7aa809b | |||
| 29d4b24226 | |||
| 258c2f6cae | |||
| b62afb43b6 | |||
| 710d6dddf2 | |||
| 20b594d4f2 | |||
| 8b8e95908c | |||
| 85f2831f16 | |||
| 1e186369c9 | |||
| 5c2f85c089 | |||
| 4380d453d7 | |||
| f502829fde | |||
| a56790eaa6 | |||
| 6e1fbac5d2 | |||
| b93d15e46e | |||
| 57a72f652c | |||
| 55801deb38 | |||
| a46302846a | |||
| 3467042000 | |||
| 1eaa51b72b | |||
| 185649323e | |||
| 05a5ea4d85 | |||
| 944ae9a6eb | |||
| 7bc411119c | |||
| 3103a6e632 | |||
| b417144baf | |||
| fd0c1007ad | |||
| 079466b29b | |||
| 1e6b0e5684 | |||
| a0d72bbef2 | |||
| 8f2e5b4381 | |||
| 7dca17d517 | |||
| 80b15b24ae | |||
| b432556ee3 | |||
| 258645f182 | |||
| 5cf4a018b3 | |||
| 5d9223c32e | |||
| d32c99c927 | |||
| d36f5842b6 | |||
| 981a6b0021 |
@@ -30,8 +30,3 @@ server-rs/.data
|
||||
server-rs/.spacetimedb
|
||||
|
||||
public/generated-*
|
||||
|
||||
scripts/loadtest/data/*.local.json
|
||||
scripts/loadtest/data/k6-*.log
|
||||
scripts/loadtest/data/k6-*summary*.md
|
||||
scripts/loadtest/data/latest-*-prefix.txt
|
||||
|
||||
@@ -239,6 +239,12 @@ GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR="false"
|
||||
# Windows/macOS 是系统维度,不填写 dev-win/dev-mac。
|
||||
GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL="dev"
|
||||
|
||||
# 客户端埋点接收绑定的公开 origin,由 API Server 运行时读取;修改后重启服务。
|
||||
# 必须与客户端登录地址一致,不带 /api、路径或尾部斜杠;未配置/非法时上传接口返回 503。
|
||||
# 本地端口按实际启动结果填写(端口漂移后需同步),localhost 与 127.0.0.1 不可混用。
|
||||
# dev 使用 https://dev.genarrative.world;release 使用 https://www.genarrative.world。
|
||||
GENARRATIVE_AGC_ANALYTICS_ORIGIN="http://127.0.0.1:8082"
|
||||
|
||||
# Optional: official VikingDB credentials for regenerating build-tag similarities
|
||||
# with the Python embedding script. The script auto-loads `.env.local` and uses
|
||||
# the fixed `bge-large-zh` embedding model.
|
||||
|
||||
@@ -365,7 +365,6 @@ module.exports = {
|
||||
'src/types.ts',
|
||||
'src/types/**',
|
||||
'src/uiAssets.ts',
|
||||
'scripts/loadtest/**',
|
||||
'packages/shared/src/contracts/jumpHop.ts',
|
||||
'packages/shared/src/contracts/match3dAgent.ts',
|
||||
'packages/shared/src/contracts/match3dRuntime.ts',
|
||||
|
||||
@@ -561,7 +561,9 @@ jobs:
|
||||
run: bash scripts/ci-npm-ci-with-retry.sh
|
||||
|
||||
- name: Validate CI cache maintenance behavior
|
||||
run: python3 -m unittest discover -s scripts -p 'test_gitea_cache_*.py'
|
||||
run: |
|
||||
python3 -m unittest discover -s scripts -p 'test_gitea_cache_*.py'
|
||||
node --test scripts/export-ci-npm-download-cache.test.mjs
|
||||
|
||||
- name: Run repository checks
|
||||
run: npm run check:repository-ci
|
||||
|
||||
@@ -88,11 +88,3 @@ nohup.out
|
||||
spacetime.local.json
|
||||
deploy/container/api-server.env
|
||||
deploy/container/worker-smoke/
|
||||
|
||||
# Local load-test data extracted from private migration files
|
||||
scripts/loadtest/data/*.local.json
|
||||
|
||||
# Local load-test run artifacts
|
||||
scripts/loadtest/data/k6-*.log
|
||||
scripts/loadtest/data/k6-*summary*.md
|
||||
scripts/loadtest/data/latest-*-prefix.txt
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
getAdminFeatureGateConfig,
|
||||
getAdminUserDetail,
|
||||
importAdminAgcTemplates,
|
||||
listAdminAgcTrackingEvents,
|
||||
listAdminGameDistributionReviews,
|
||||
listAdminRechargeOrders,
|
||||
reconcileAdminUserConsumption,
|
||||
@@ -24,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(
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type {
|
||||
AdminAccountListResponse,
|
||||
AdminAgcTemplateLibraryResponse,
|
||||
AdminAgcTrackingEventListResponse,
|
||||
AdminAgcTrackingEventQuery,
|
||||
AdminConfirmEditorShowcaseCampaignImageUploadRequest,
|
||||
AdminCreateAccountRequest,
|
||||
AdminCreateAccountResponse,
|
||||
@@ -409,6 +411,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: {
|
||||
|
||||
@@ -820,6 +820,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;
|
||||
|
||||
@@ -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';
|
||||
@@ -233,6 +234,12 @@ export function AdminApp() {
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{activeRouteId === 'agc-tracking' ? (
|
||||
<AdminAgcTrackingPage
|
||||
token={token}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{activeRouteId === 'error-reports' ? (
|
||||
<AdminErrorReportsPage
|
||||
token={token}
|
||||
|
||||
@@ -40,6 +40,7 @@ const routeIcons = {
|
||||
tables: Database,
|
||||
debug: Bug,
|
||||
tracking: Table2,
|
||||
'agc-tracking': Table2,
|
||||
'error-reports': Bug,
|
||||
'gray-release': GitBranch,
|
||||
redeem: TicketPercent,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -5,6 +5,7 @@ export type AdminRouteId =
|
||||
| 'tables'
|
||||
| 'debug'
|
||||
| 'tracking'
|
||||
| 'agc-tracking'
|
||||
| 'error-reports'
|
||||
| 'gray-release'
|
||||
| 'redeem'
|
||||
@@ -41,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' },
|
||||
|
||||
@@ -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: '资产事件表',
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -154,7 +154,6 @@ const allowedUncalledTauriCommands = [
|
||||
'steer_game_creator_agent_runtime_task',
|
||||
'write_local_agent_memory',
|
||||
'write_local_game_memory',
|
||||
'write_local_project_file',
|
||||
// Agent 运行时会话 / 目标 / 协作命令由 native 侧与 CLI swarm 驱动,前端没有调用方。
|
||||
'archive_game_creator_agent_session',
|
||||
'clear_game_creator_agent_goal',
|
||||
@@ -517,7 +516,7 @@ function parseTauriHandlerCommandNames(source) {
|
||||
throw new Error('AI game creator shell Tauri handler list is missing');
|
||||
}
|
||||
return Array.from(
|
||||
match[1].matchAll(/\b([a-z][a-z0-9_]+)\b/g),
|
||||
match[1].matchAll(/\b(?:[a-z][a-z0-9_]*::)*([a-z][a-z0-9_]*)\b/g),
|
||||
([, command]) => command,
|
||||
);
|
||||
}
|
||||
@@ -550,6 +549,12 @@ function assertCommandNamesDisjoint(label, leftNames, rightNames) {
|
||||
}
|
||||
|
||||
function runAppInvokeParserRegressionChecks() {
|
||||
assert.deepEqual(
|
||||
parseTauriHandlerCommandNames(
|
||||
'tauri::generate_handler![plain_command, analytics::gui::capture_analytics_context,]',
|
||||
),
|
||||
['plain_command', 'capture_analytics_context'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseAppInvokeCommandNames(`
|
||||
invoke('direct_command', {});
|
||||
|
||||
@@ -125,17 +125,24 @@ fn stage_codex_target(manifest_dir: &std::path::Path, target: &str) {
|
||||
&& sha256_file(&target_path)
|
||||
.map(|target_sha256| target_sha256 == source_sha256)
|
||||
.unwrap_or(false);
|
||||
let source_permissions = fs::metadata(&source_path)
|
||||
.expect("读取组件权限失败")
|
||||
.permissions();
|
||||
if !target_matches_source {
|
||||
fs::copy(&source_path, &target_path).expect("复制内置 Codex CLI 资源失败");
|
||||
fs::set_permissions(&target_path, source_permissions.clone())
|
||||
.expect("保留内置 Codex CLI 组件权限失败");
|
||||
} else if fs::metadata(&target_path)
|
||||
.expect("读取内置 Codex CLI 资源失败")
|
||||
.permissions()
|
||||
!= source_permissions
|
||||
{
|
||||
// 内容相同但曾被错误 chmod 的 staging 文件也必须恢复执行权限。
|
||||
// 权限已一致时不再写元数据:Windows 上这次写入会更新 change time,
|
||||
// 让 tauri dev 的文件监听把每次构建都当成 staging 变更而无限重建。
|
||||
fs::set_permissions(&target_path, source_permissions)
|
||||
.expect("保留内置 Codex CLI 组件权限失败");
|
||||
}
|
||||
// 内容相同但曾被错误 chmod 的 staging 文件也必须恢复执行权限。
|
||||
fs::set_permissions(
|
||||
&target_path,
|
||||
fs::metadata(&source_path)
|
||||
.expect("读取组件权限失败")
|
||||
.permissions(),
|
||||
)
|
||||
.expect("保留内置 Codex CLI 组件权限失败");
|
||||
file_hashes.insert(
|
||||
relative.to_string(),
|
||||
serde_json::Value::String(source_sha256),
|
||||
|
||||
@@ -34,6 +34,8 @@ mod direct_thread_wire;
|
||||
mod direct_tool_bridge;
|
||||
mod direct_tool_calls;
|
||||
mod direct_tools_mcp;
|
||||
mod direct_turn_error;
|
||||
mod direct_turn_failure;
|
||||
mod direct_turn_metrics;
|
||||
mod direct_turn_stream;
|
||||
mod direct_validation;
|
||||
@@ -72,6 +74,8 @@ pub(crate) use direct_thread_wire::*;
|
||||
pub(crate) use direct_tool_bridge::*;
|
||||
pub(crate) use direct_tool_calls::*;
|
||||
pub(crate) use direct_tools_mcp::*;
|
||||
pub(crate) use direct_turn_error::*;
|
||||
pub(crate) use direct_turn_failure::*;
|
||||
pub(crate) use direct_turn_metrics::*;
|
||||
pub(crate) use direct_turn_stream::*;
|
||||
pub(crate) use direct_validation::DirectValidationConfig;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Native / third-party approval adapter. The host execution session owns policy
|
||||
//! and persistence; this module only binds the app-server protocol to its leases.
|
||||
|
||||
use super::super::{direct_delivery, direct_execution, direct_validation};
|
||||
use super::super::{direct_delivery, direct_execution, direct_validation, DirectTurnError};
|
||||
use super::{shutdown_game_creator_codex_app_server_inner, CodexAppServerInner};
|
||||
use direct_execution::{EffectKind, ExecutionLease, ExecutionPhase, ExecutionSession};
|
||||
use serde_json::{json, Value};
|
||||
@@ -147,6 +147,11 @@ pub(super) struct ExecutionAdapter {
|
||||
changed: Notify,
|
||||
shutdown_gate: tokio::sync::Mutex<()>,
|
||||
outcome: watch::Sender<Option<HostOutcome>>,
|
||||
/// 宿主自己判定的"本轮以失败收口":`(分类, 原因)`。有值就代表本轮终态必须是失败,
|
||||
/// 原因与交付报告同一份文本。
|
||||
turn_failure: Mutex<Option<DirectTurnError>>,
|
||||
/// 用户/宿主是否主动要求终止这一轮(界面的「终止」按钮)。用户主动终止不是失败。
|
||||
host_stop_requested: AtomicBool,
|
||||
}
|
||||
|
||||
fn identity(value: Option<&Value>) -> Option<&str> {
|
||||
@@ -263,6 +268,8 @@ impl ExecutionAdapter {
|
||||
changed: Notify::new(),
|
||||
shutdown_gate: tokio::sync::Mutex::new(()),
|
||||
outcome,
|
||||
turn_failure: Mutex::new(None),
|
||||
host_stop_requested: AtomicBool::new(false),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -654,6 +661,7 @@ impl ExecutionAdapter {
|
||||
}
|
||||
|
||||
pub(super) fn cancel_from_host(self: &Arc<Self>) {
|
||||
self.request_host_stop();
|
||||
if self.background_done.load(Ordering::Acquire) || self.closed.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
@@ -672,6 +680,49 @@ impl ExecutionAdapter {
|
||||
let _ = tokio::task::spawn_blocking(move || session.interrupt(message)).await;
|
||||
}
|
||||
|
||||
/// 宿主判定"这一轮以失败收口":记下 `(分类, 原因)`,再把同一条原因写进宿主交付报告。
|
||||
///
|
||||
/// 谁调用:宿主亲眼看到或亲手判定的异常收场——执行通道断开(app-server 进程退出 / 流断 / 回合
|
||||
/// 事件通道关闭)、等待模型回执超时、app-server 单方面把这一轮判成中断。终态判定会读这份事实,
|
||||
/// 于是这些收场不会再被收尾阶段(`ExecutionPhase::Interrupted`)抹成一次没有原因的"已结束"。
|
||||
///
|
||||
/// **宿主自己关的连接不算失败。** 正常终态、用户主动停止、预算与交付收尾都会把连接关掉,回合
|
||||
/// 事件通道上看到的是同一个 `TransportClosed`;判据是 [`Self::is_closed`]——适配器先于连接置位
|
||||
/// 就说明这一轮是宿主在收束,只按既有口径中断收口(原因照样写进报告,便于核对)。
|
||||
///
|
||||
/// **事实要落在适配器上,不能落在调用点的局部变量里。** 回合还开着的时候,看门狗会在同一个
|
||||
/// `inner.closed` 标志上把本轮收束掉(见 [`Self::start_watchdog`]),谁先谁后取决于调度,而终态
|
||||
/// 判定发生在收束之后;记不下原因,界面就只能看到"本轮已结束"、看不到为什么。
|
||||
///
|
||||
/// 只记第一份:第一份最接近现场(连接终止时带 exitStatus / stderr 摘要),后面更粗的收束理由
|
||||
/// 不得覆盖它。
|
||||
pub(super) async fn fail_turn(&self, failure: DirectTurnError) {
|
||||
let reason = failure.to_string();
|
||||
if !self.is_closed() {
|
||||
if let Ok(mut slot) = self.turn_failure.lock() {
|
||||
if slot.is_none() {
|
||||
*slot = Some(failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.interrupt(&reason).await;
|
||||
}
|
||||
|
||||
/// 本轮以什么理由失败;有值就是宿主记下的 typed 事实。终态判定只读这一次。
|
||||
pub(super) fn turn_failure(&self) -> Option<DirectTurnError> {
|
||||
self.turn_failure.lock().ok().and_then(|slot| slot.clone())
|
||||
}
|
||||
|
||||
/// 记下"用户主动要求终止这一轮"。用来把用户主动终止与 app-server 自己中断分开:
|
||||
/// 前者不是失败,后者是(判据不能被事件到达的先后顺序左右,所以用标志而不是看阶段)。
|
||||
pub(super) fn request_host_stop(&self) {
|
||||
self.host_stop_requested.store(true, Ordering::Release);
|
||||
}
|
||||
|
||||
pub(super) fn host_stop_requested(&self) -> bool {
|
||||
self.host_stop_requested.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub(super) fn start_watchdog(self: &Arc<Self>, inner: Weak<CodexAppServerInner>) {
|
||||
let adapter = Arc::clone(self);
|
||||
tokio::spawn(async move {
|
||||
@@ -744,7 +795,19 @@ impl ExecutionAdapter {
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// 本轮是不是**由宿主自己**在收束(正常终态 / 用户主动停止 / 预算收尾 / 交付封口)。
|
||||
///
|
||||
/// 用来把"连接被我们关掉"和"连接自己断了"分开:两种情况下回合事件通道都会收到
|
||||
/// `TransportClosed`,但只有后者才算执行通道失败(见 [`Self::transport_failed`])。
|
||||
/// `finish_model_attempt` 与 `shutdown_and_report` 都会在收束连接之前把它置位。
|
||||
fn is_closed(&self) -> bool {
|
||||
self.closed.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub(super) fn lifecycle_status(&self, fallback: &str) -> String {
|
||||
// 只按收尾阶段归类。失败事实(`fail_turn` 记下的)不在这里翻案:终态由
|
||||
// `direct_turn_terminal` 拿事实判定——否则"模型已经判失败"的一轮会被这里的
|
||||
// `Interrupted` 抹成一次没有原因的"已结束"。
|
||||
match self.session.snapshot().map(|state| state.phase) {
|
||||
Ok(ExecutionPhase::Completed) => "completed",
|
||||
Ok(ExecutionPhase::Exhausted | ExecutionPhase::Interrupted) => "interrupted",
|
||||
@@ -1037,6 +1100,10 @@ pub(super) async fn wait_outcome(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::DirectTurnDeadline;
|
||||
|
||||
/// 通道断开在事件载荷里的稳定分类(`DirectTurnError::wire_kind` 的取值之一)。
|
||||
const EXPECTED_TRANSPORT_KIND: &str = "transport-failed";
|
||||
use super::*;
|
||||
|
||||
fn fixture() -> (tempfile::TempDir, Arc<ExecutionAdapter>) {
|
||||
@@ -1084,6 +1151,57 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn host_observed_failure_is_recorded_with_its_kind_and_reason() {
|
||||
let (_temp, adapter) = fixture();
|
||||
assert!(adapter.turn_failure().is_none());
|
||||
assert!(!adapter.host_stop_requested());
|
||||
|
||||
adapter
|
||||
.fail_turn(DirectTurnError::TransportClosed {
|
||||
diagnostic: "Codex app-server 已退出;exitStatus=signal: 9 (SIGKILL)".into(),
|
||||
})
|
||||
.await;
|
||||
|
||||
// 终态判定读这份事实,界面才有理由把它当失败讲,而不是"本轮已结束"。
|
||||
let failure = adapter.turn_failure().expect("host fact must be recorded");
|
||||
assert_eq!(failure.wire_kind(), Some(EXPECTED_TRANSPORT_KIND));
|
||||
assert!(failure.to_string().contains("SIGKILL"));
|
||||
// 报告与事件载荷同一份原因:用户看到的现象和交付状态对得上。
|
||||
assert!(adapter.report().contains("SIGKILL"));
|
||||
|
||||
// 只认第一份原因:后续更粗的收束理由不得覆盖真实诊断。
|
||||
adapter
|
||||
.fail_turn(DirectTurnError::TimedOut {
|
||||
deadline: DirectTurnDeadline::ResponseIdle,
|
||||
})
|
||||
.await;
|
||||
let failure = adapter.turn_failure().expect("first reason is kept");
|
||||
assert_eq!(failure.wire_kind(), Some(EXPECTED_TRANSPORT_KIND));
|
||||
assert!(failure.to_string().contains("SIGKILL"));
|
||||
assert!(!failure.to_string().contains("超时"));
|
||||
}
|
||||
|
||||
/// 宿主自己关的连接不算失败:正常终态、用户主动停止、预算与交付收尾都会关掉连接,回合事件通道
|
||||
/// 上看到的是同一个 `TransportClosed`。判据是适配器先于连接置位 `closed`。
|
||||
#[tokio::test]
|
||||
async fn host_ended_turn_is_not_a_failure() {
|
||||
let (_temp, adapter) = fixture();
|
||||
adapter.request_host_stop();
|
||||
adapter.closed.store(true, Ordering::Release);
|
||||
|
||||
adapter
|
||||
.fail_turn(DirectTurnError::TransportClosed {
|
||||
diagnostic: "模型本次执行结束,回收原生后台子树".into(),
|
||||
})
|
||||
.await;
|
||||
|
||||
assert!(adapter.turn_failure().is_none());
|
||||
assert!(adapter.host_stop_requested());
|
||||
// 原因照样进报告:不算失败不等于不用记。
|
||||
assert!(adapter.report().contains("模型本次执行结束"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn production_snapshot_identity_uses_canonical_digest_and_preserves_manifest_authority() {
|
||||
let (_temp, adapter) = fixture();
|
||||
|
||||
@@ -3548,12 +3548,20 @@ impl CodexAppServerConnection {
|
||||
let direct_turn_user_item_id = direct_persisted_user_item
|
||||
.as_ref()
|
||||
.and_then(direct_thread_item_identity);
|
||||
// 回合终态兜底:`turn.started` 进队列之后就武装,写完终态即解除。宿主在这两者之间任何
|
||||
// 提前收场(panic、future 被丢弃、以后新增的早退)都由它补一条失败终态,否则前端只能
|
||||
// 永远停在"还在跑"。
|
||||
let mut direct_turn_failure_guard: Option<DirectTurnFailureGuard> = None;
|
||||
if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
|
||||
append_direct_thread_event(
|
||||
&direct_thread_id,
|
||||
DirectThreadEvent::turn_started(direct_turn_started_at_ms)
|
||||
.with_user_item_id(direct_turn_user_item_id.as_deref()),
|
||||
);
|
||||
direct_turn_failure_guard = Some(DirectTurnFailureGuard::arm(
|
||||
direct_thread_id.clone(),
|
||||
direct_turn_user_item_id.clone(),
|
||||
));
|
||||
if let Some(user_item) = direct_persisted_user_item.as_ref() {
|
||||
if let Some(entry_item) = direct_thread_event_item(history_root, user_item) {
|
||||
// 这里的条目时间可能是启动应答后的观测时间;前端按同一用户条目身份
|
||||
@@ -3594,8 +3602,11 @@ impl CodexAppServerConnection {
|
||||
hard_deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||
if remaining.is_zero() {
|
||||
if let Some(adapter) = approval_adapter.as_ref() {
|
||||
// 等不到终态就是这一轮失败:只收口不留原因等于界面静默结束。
|
||||
adapter
|
||||
.interrupt("等待模型回合结束达到硬上限,已停止本轮并核对后台操作。")
|
||||
.fail_turn(DirectTurnError::TimedOut {
|
||||
deadline: DirectTurnDeadline::TurnHardLimit,
|
||||
})
|
||||
.await;
|
||||
return execution::outcome_text(adapter.wait_outcome().await);
|
||||
}
|
||||
@@ -3623,7 +3634,9 @@ impl CodexAppServerConnection {
|
||||
Err(_) => {
|
||||
if let Some(adapter) = approval_adapter.as_ref() {
|
||||
adapter
|
||||
.interrupt("等待模型执行回执超时,不能自动重放未确认操作。")
|
||||
.fail_turn(DirectTurnError::TimedOut {
|
||||
deadline: DirectTurnDeadline::ResponseIdle,
|
||||
})
|
||||
.await;
|
||||
return execution::outcome_text(adapter.wait_outcome().await);
|
||||
}
|
||||
@@ -3953,9 +3966,16 @@ impl CodexAppServerConnection {
|
||||
}
|
||||
"interrupted" => {
|
||||
if let Some(adapter) = approval_adapter.as_ref() {
|
||||
if !adapter.is_host_ending() {
|
||||
// app-server 自己把这一轮判成中断,而宿主没有请求过终止(用户点
|
||||
// 「终止」会先置 `host_stop_requested`、并把阶段推成终态):这是异常
|
||||
// 收场,必须让界面看到原因,不能只是把回合静默收口。
|
||||
if !adapter.is_host_ending() && !adapter.host_stop_requested() {
|
||||
adapter
|
||||
.interrupt("本轮模型执行已中断,正在核对自有后台进程。")
|
||||
.fail_turn(DirectTurnError::TurnInterrupted {
|
||||
detail:
|
||||
"本轮模型执行被中断,正在核对自有后台进程。"
|
||||
.into(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
return execution::outcome_text(adapter.wait_outcome().await);
|
||||
@@ -3968,14 +3988,23 @@ impl CodexAppServerConnection {
|
||||
));
|
||||
}
|
||||
"failed" => {
|
||||
// 原生 `turn.error` 是这一轮最准的原因:先把它投影成 `LlmError`,
|
||||
// 再作为 `collect` 的错误结果走既有的 collect_result 通道。投影之后
|
||||
// 载荷形状(`{kind, message}`)和终态判定都不用为此多一个入参,
|
||||
// 原因文本里带着 `codex-app-server-error:<kind>` 前缀交给界面归类。
|
||||
// 交付报告只说明"收束到哪一步",不能顶掉原因;返修请求
|
||||
// (`RepairRequired`)是宿主复核要求,保持它自己的原语义。
|
||||
let native = game_creator_codex_app_server_failed_turn_error(turn);
|
||||
if let Some(adapter) = approval_adapter.as_ref() {
|
||||
if let Some(outcome) =
|
||||
adapter.finish_model_attempt(&self.inner, false).await
|
||||
{
|
||||
return execution::outcome_text(outcome);
|
||||
if let Err(repair) = execution::outcome_text(outcome) {
|
||||
return Err(repair);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Err(game_creator_codex_app_server_failed_turn_error(turn));
|
||||
return Err(native);
|
||||
}
|
||||
status => {
|
||||
return Err(platform_llm::LlmError::Deserialize(format!(
|
||||
@@ -3987,8 +4016,13 @@ impl CodexAppServerConnection {
|
||||
Some(CodexTurnEvent::TransportClosed(error)) => {
|
||||
if let Some(adapter) = approval_adapter.as_ref() {
|
||||
if !adapter.is_host_ending() {
|
||||
// 事件带的 `error` 就是连接终止时那份诊断。通道断开是不是'失败'由
|
||||
// 适配器判(宿主自己关的连接不算),失败事实也记在它上面,回合终态
|
||||
// 判定之后才读得到:见 `ExecutionAdapter::fail_turn`。
|
||||
adapter
|
||||
.interrupt("执行通道已断开,不能自动重放未确认操作。")
|
||||
.fail_turn(DirectTurnError::TransportClosed {
|
||||
diagnostic: error.clone(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
return execution::outcome_text(adapter.wait_outcome().await);
|
||||
@@ -4002,8 +4036,12 @@ impl CodexAppServerConnection {
|
||||
None => {
|
||||
if let Some(adapter) = approval_adapter.as_ref() {
|
||||
if !adapter.is_host_ending() {
|
||||
// 事件通道在没有终态的情况下关掉,和连接断掉是同一件事:本轮只可能
|
||||
// 以失败收口,不能报成"被中断"。
|
||||
adapter
|
||||
.interrupt("执行事件通道已结束,正在核对后台操作。")
|
||||
.fail_turn(DirectTurnError::TransportClosed {
|
||||
diagnostic: "Codex app-server turn 事件通道已关闭".into(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
return execution::outcome_text(adapter.wait_outcome().await);
|
||||
@@ -4061,11 +4099,33 @@ impl CodexAppServerConnection {
|
||||
.map(|(_, at)| *at)
|
||||
.unwrap_or_else(direct_tool_call_now_ms)
|
||||
};
|
||||
// 终态只有 `turn.completed` 一种事件:失败时同一个事件带 `failure` 载荷(原因由宿主
|
||||
// 脱敏 + 截断后写进去),其余(`completed` / `interrupted` / `aborted`)不带载荷。
|
||||
// 失败不再只写一个 `status="failed"`:那让失败与正常结束在协议上长得一样,前端只能
|
||||
// 另开一条通道(命令返回 / 另一条 IPC)去拿原因,也就等于承认事件流讲不清一轮怎么结束。
|
||||
// 判定拿的是**事实**(模型终态 / 交付结果 / 宿主记下的失败),不是收尾阶段推出来的
|
||||
// `status`:收尾自己会把阶段推成 `Interrupted`,用它判就会把已经失败的回合讲成"已结束"。
|
||||
let turn_failure = approval_adapter
|
||||
.as_ref()
|
||||
.and_then(|adapter| adapter.turn_failure());
|
||||
// 收尾结果在这里投影成 typed 错误:载荷的 `kind` / `message` 都从这一份值出来。
|
||||
let collect_outcome = match collect_result.as_ref() {
|
||||
Ok(report) => Ok(report.as_str()),
|
||||
Err(error) => Err(DirectTurnError::from_model_call(error)),
|
||||
};
|
||||
let terminal = direct_turn_terminal(
|
||||
&status,
|
||||
collect_outcome,
|
||||
turn_failure.as_ref(),
|
||||
history_root,
|
||||
);
|
||||
append_direct_thread_event(
|
||||
&direct_thread_id,
|
||||
DirectThreadEvent::turn_completed(status, completed_at)
|
||||
.with_user_item_id(direct_turn_user_item_id.as_deref()),
|
||||
terminal.event(completed_at, direct_turn_user_item_id.as_deref()),
|
||||
);
|
||||
if let Some(guard) = direct_turn_failure_guard.as_mut() {
|
||||
guard.disarm();
|
||||
}
|
||||
}
|
||||
let text = collect_result?;
|
||||
guard.armed = false;
|
||||
@@ -4964,6 +5024,16 @@ async fn fail_game_creator_codex_app_server_connection(
|
||||
let stderr = inner.stderr_summary.lock().await.diagnostic();
|
||||
let diagnostic = format!("{error};exitStatus={exit_status};{stderr}");
|
||||
app_log!("agent.runner.failed: Codex app-server 连接终止:{diagnostic}");
|
||||
// 连接是在回合进行中断掉的:先把"本轮以传输失败收口"和这份诊断记到执行适配器上,再去收束
|
||||
// 连接。顺序不能反——执行适配器的看门狗盯着同一个 `closed` 标志,它可能先一步把本轮收束成
|
||||
// "被中断";终态一旦算出来,失败原因就只剩日志,界面只会看到"本轮已结束、没有原因"。
|
||||
record_execution_turn_failure(
|
||||
&inner,
|
||||
DirectTurnError::TransportClosed {
|
||||
diagnostic: diagnostic.clone(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
match shutdown_game_creator_codex_app_server_inner(&inner, &diagnostic).await {
|
||||
Ok(proof) if proof.confirmed() => {}
|
||||
Ok(_) => app_log!("Codex app-server 连接终止:process-group-only,完整子树退出未确认"),
|
||||
@@ -4971,6 +5041,24 @@ async fn fail_game_creator_codex_app_server_connection(
|
||||
}
|
||||
}
|
||||
|
||||
/// 把"这一轮以失败收口"的事实记到当前回合的执行适配器上:连接级故障、等待超时、app-server
|
||||
/// 单方面中断都走这一条路径,别在多处各写一份。没有进行中的 DirectProject 回合(适配器已释放)
|
||||
/// 就是空操作。
|
||||
async fn record_execution_turn_failure(inner: &Arc<CodexAppServerInner>, failure: DirectTurnError) {
|
||||
let adapter = {
|
||||
let slot = match inner.execution.lock() {
|
||||
Ok(slot) => slot,
|
||||
Err(_) => return,
|
||||
};
|
||||
// 只借一下指针:后面要 await(写交付报告),不能带着执行槽位的锁等。
|
||||
slot.as_ref().map(Arc::clone)
|
||||
};
|
||||
let Some(adapter) = adapter else {
|
||||
return;
|
||||
};
|
||||
adapter.fail_turn(failure).await;
|
||||
}
|
||||
|
||||
async fn shutdown_game_creator_codex_app_server_inner(
|
||||
inner: &Arc<CodexAppServerInner>,
|
||||
reason: &str,
|
||||
@@ -5037,7 +5125,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at(
|
||||
root: &std::path::Path,
|
||||
system_prompt: String,
|
||||
user_prompt: String,
|
||||
) -> Result<String, String> {
|
||||
) -> Result<String, DirectTurnError> {
|
||||
direct_game_creator_codex_chat_at_with_optional_observer(
|
||||
root,
|
||||
system_prompt,
|
||||
@@ -5055,7 +5143,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_observer(
|
||||
system_prompt: String,
|
||||
user_prompt: String,
|
||||
observer: &mut (dyn FnMut(DirectCodexTurnObservation) + Send),
|
||||
) -> Result<String, String> {
|
||||
) -> Result<String, DirectTurnError> {
|
||||
direct_game_creator_codex_chat_at_with_optional_observer(
|
||||
root,
|
||||
system_prompt,
|
||||
@@ -5076,12 +5164,13 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
|
||||
observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>,
|
||||
audit: Option<&mut DirectCodexTurnAudit>,
|
||||
direct_user_item: Option<serde_json::Value>,
|
||||
) -> Result<String, String> {
|
||||
) -> Result<String, DirectTurnError> {
|
||||
// Resolve project authority before deriving the pool/thread identity. A
|
||||
// caller may hold a stable symlink path whose target changes between
|
||||
// projects, or replace the project manifest in-place; raw path text alone
|
||||
// must never select a connection created for the previous project.
|
||||
let (canonical_root, project_id) = direct_codex_canonical_project_identity(root)?;
|
||||
let (canonical_root, project_id) = direct_codex_canonical_project_identity(root)
|
||||
.map_err(|cause| DirectTurnError::ProjectRootUnanchored { cause })?;
|
||||
let codex_root = if let Some(path) = canonical_root
|
||||
.to_str()
|
||||
.and_then(|value| value.strip_prefix("\\\\?\\"))
|
||||
@@ -5090,9 +5179,13 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
|
||||
} else {
|
||||
canonical_root.clone()
|
||||
};
|
||||
let config = load_game_creator_app_config()?;
|
||||
game_creator_codex_app_server_validate_llm_config(&config.llm)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let config = load_game_creator_app_config()
|
||||
.map_err(|detail| DirectTurnError::EnvironmentNotReady { detail })?;
|
||||
game_creator_codex_app_server_validate_llm_config(&config.llm).map_err(|error| {
|
||||
DirectTurnError::EnvironmentNotReady {
|
||||
detail: error.to_string(),
|
||||
}
|
||||
})?;
|
||||
// 用户回合身份必须在模型目录/连接准备前冻结,不能先复用上一回合进程。
|
||||
let generated_client_turn_id;
|
||||
let effective_client_turn_id = match client_turn_id {
|
||||
@@ -5105,7 +5198,10 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
|
||||
.map(|state| state.client_turn_id)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "宿主 CLI 回合身份读取中断".to_string())??;
|
||||
.map_err(|_| DirectTurnError::HostStateUnavailable {
|
||||
detail: "宿主 CLI 回合身份读取中断".to_string(),
|
||||
})?
|
||||
.map_err(|detail| DirectTurnError::HostStateUnavailable { detail })?;
|
||||
Some(generated_client_turn_id.as_str())
|
||||
}
|
||||
};
|
||||
@@ -5125,8 +5221,11 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
|
||||
web_search_enabled: config.llm.web_search_enabled,
|
||||
allow_idle_context_compaction: false,
|
||||
};
|
||||
let api_kind =
|
||||
parse_game_creator_llm_api_kind(&config.llm.api_kind).map_err(|error| error.to_string())?;
|
||||
let api_kind = parse_game_creator_llm_api_kind(&config.llm.api_kind).map_err(|error| {
|
||||
DirectTurnError::EnvironmentNotReady {
|
||||
detail: error.to_string(),
|
||||
}
|
||||
})?;
|
||||
let metrics_attempt = audit.as_ref().map(|audit| {
|
||||
audit.metrics().attempt(
|
||||
&config.llm.model,
|
||||
@@ -5156,7 +5255,10 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
|
||||
if let Some(attempt) = metrics_attempt.as_ref() {
|
||||
attempt.finish("failed");
|
||||
}
|
||||
error.to_string()
|
||||
// 连接建立失败是环境/凭据层面的前置于失败:这一轮还没有开始。
|
||||
DirectTurnError::EnvironmentNotReady {
|
||||
detail: error.to_string(),
|
||||
}
|
||||
})?;
|
||||
let request = LlmRunRequest::single_turn(system_prompt, user_prompt)
|
||||
.with_api_kind(api_kind)
|
||||
@@ -5178,7 +5280,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
|
||||
)
|
||||
.await
|
||||
.map(|value| value.text)
|
||||
.map_err(|error| error.to_string());
|
||||
.map_err(|error| DirectTurnError::from_model_call(&error));
|
||||
if let Some(attempt) = metrics_attempt.as_ref() {
|
||||
attempt.finish(if result.is_ok() {
|
||||
"completed"
|
||||
|
||||
@@ -203,12 +203,51 @@ pub(in crate::agent) fn game_creator_codex_cli_version_at(
|
||||
Ok(version.to_string())
|
||||
}
|
||||
|
||||
/// 开发态允许从宿主 PATH 里找到 Codex,但宿主必须持有可锚定的绝对文件:
|
||||
/// 裸命令名按 PATH 解析成真实路径,否则 `bind_codex_executor` 的 canonicalize 会按 CWD 解析并失败。
|
||||
/// 发行构建不走这段,候选顺序、校验与返回值都与原先一致(打包环境用内置侧车/npm 绝对路径)。
|
||||
#[cfg(debug_assertions)]
|
||||
fn anchor_codex_cli_executable_candidate(
|
||||
candidate: &Path,
|
||||
path: Option<&std::ffi::OsStr>,
|
||||
) -> Option<PathBuf> {
|
||||
let is_bare_command_name = candidate
|
||||
.parent()
|
||||
.is_some_and(|parent| parent.as_os_str().is_empty());
|
||||
if !is_bare_command_name {
|
||||
return candidate.is_file().then(|| candidate.to_path_buf());
|
||||
}
|
||||
let name = candidate.as_os_str();
|
||||
for directory in path.into_iter().flat_map(std::env::split_paths) {
|
||||
if directory.as_os_str().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let target = directory.join(name);
|
||||
if target.is_file() {
|
||||
// 第一个实际命中的项就是 OS 会执行的项;锚定失败时不再从 PATH 里换另一个。
|
||||
return target.canonicalize().ok();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn game_creator_codex_cli_executable_path() -> Result<PathBuf, String> {
|
||||
let mut last_error = None;
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let bundled =
|
||||
game_creator_bundled_codex_cli_path(game_creator_bundled_resource_dir().as_deref());
|
||||
for candidate in game_creator_codex_cli_executable_candidates() {
|
||||
#[cfg(debug_assertions)]
|
||||
let candidate = match anchor_codex_cli_executable_candidate(
|
||||
&candidate,
|
||||
std::env::var_os("PATH").as_deref(),
|
||||
) {
|
||||
Some(candidate) => candidate,
|
||||
None => {
|
||||
last_error = Some("候选执行器不是可锚定的文件".to_string());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let identity = candidate.to_string_lossy().to_ascii_lowercase();
|
||||
if !seen.insert(identity) {
|
||||
continue;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -697,10 +697,14 @@ pub(super) async fn finish_sealing(
|
||||
}).await.map_err(|_| "delivery-finalize-worker-exited")?
|
||||
}
|
||||
|
||||
/// 回合末的宿主复核:返回要交付的答复,或者一个"还没完,按这份证据继续修"的要求。
|
||||
///
|
||||
/// 返修要求是**控制流**([`DirectTurnError::ReviewRequired`]),不是失败:调用方据此把要求写回
|
||||
/// prompt 再跑一轮,界面不该看到失败文案。其余错误都是真的回合失败,按 typed 错误交给上层。
|
||||
pub(super) async fn review_reply(
|
||||
root: &Path,
|
||||
session: &Arc<ExecutionSession>,
|
||||
) -> Result<Option<String>, String> {
|
||||
) -> Result<Option<String>, DirectTurnError> {
|
||||
if let Some(report) = terminal_report(session) {
|
||||
return Ok(Some(report));
|
||||
}
|
||||
@@ -751,7 +755,9 @@ pub(super) async fn review_reply(
|
||||
.map_err(|_| "delivery-review-worker-exited")??;
|
||||
return Ok(Some(report));
|
||||
}
|
||||
Err(format!("delivery-review-required: {detail}"))
|
||||
Err(DirectTurnError::ReviewRequired {
|
||||
detail: format!("delivery-review-required: {detail}"),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -914,10 +920,10 @@ mod tests {
|
||||
assert_eq!(chat.snapshot().unwrap().delivery_reviews, 0);
|
||||
let (new_game, _new_host, required) = project_session(true);
|
||||
for _ in 0..2 {
|
||||
assert!(review_reply(new_game.path(), &required)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.starts_with("delivery-review-required:"));
|
||||
assert!(matches!(
|
||||
review_reply(new_game.path(), &required).await.unwrap_err(),
|
||||
DirectTurnError::ReviewRequired { .. }
|
||||
));
|
||||
}
|
||||
assert!(review_reply(new_game.path(), &required)
|
||||
.await
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user