Compare commits

..

1 Commits

Author SHA1 Message Date
kdletters 95a3b9f593 后台灰度预设补上游戏发布开关
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m14s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m45s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m46s
Project CI / Backend tests (pull_request) Successful in 6m23s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m36s
Project CI / Native shell tests (pull_request) Successful in 7m8s
Project CI / Frontend tests (pull_request) Successful in 3m0s
Project CI / Repository checks (pull_request) Successful in 3m49s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m11s
- admin-web:灰度发布配置的固定目标新增「游戏分发 → 游戏发布」(game-distribution:publish),前缀标签与描述说明「未配置或关闭时默认开放,开启后只放行白名单/灰度命中」。
- 默认语义:选中预设后保持 enabled=false,避免误把发布收紧成全关;需要灰度时再显式打开并填白名单/比例。
- 用例:新增「可选择游戏发布开关且默认不收紧」与「保存写入白名单与比例」两条后台用例。
- 文档:开发运维文档说明该键配置前不会出现在已有列表、必须从预设或手填新建一行。
2026-09-22 20:06:37 +08:00
584 changed files with 43329 additions and 19034 deletions
+5
View File
@@ -30,3 +30,8 @@ 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
-6
View File
@@ -239,12 +239,6 @@ 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.worldrelease 使用 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.
+1
View File
@@ -365,6 +365,7 @@ 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',
+8
View File
@@ -88,3 +88,11 @@ 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,7 +7,6 @@ import {
getAdminFeatureGateConfig,
getAdminUserDetail,
importAdminAgcTemplates,
listAdminAgcTrackingEvents,
listAdminGameDistributionReviews,
listAdminRechargeOrders,
reconcileAdminUserConsumption,
@@ -25,30 +24,6 @@ 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(
-27
View File
@@ -1,8 +1,6 @@
import type {
AdminAccountListResponse,
AdminAgcTemplateLibraryResponse,
AdminAgcTrackingEventListResponse,
AdminAgcTrackingEventQuery,
AdminConfirmEditorShowcaseCampaignImageUploadRequest,
AdminCreateAccountRequest,
AdminCreateAccountResponse,
@@ -411,31 +409,6 @@ 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: {
-38
View File
@@ -820,44 +820,6 @@ 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;
-7
View File
@@ -20,7 +20,6 @@ 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';
@@ -234,12 +233,6 @@ export function AdminApp() {
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'agc-tracking' ? (
<AdminAgcTrackingPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'error-reports' ? (
<AdminErrorReportsPage
token={token}
-1
View File
@@ -40,7 +40,6 @@ const routeIcons = {
tables: Database,
debug: Bug,
tracking: Table2,
'agc-tracking': Table2,
'error-reports': Bug,
'gray-release': GitBranch,
redeem: TicketPercent,
+6 -16
View File
@@ -8,22 +8,6 @@ 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',
@@ -59,6 +43,12 @@ 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',
-2
View File
@@ -5,7 +5,6 @@ export type AdminRouteId =
| 'tables'
| 'debug'
| 'tracking'
| 'agc-tracking'
| 'error-reports'
| 'gray-release'
| 'redeem'
@@ -42,7 +41,6 @@ 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' },
@@ -1,129 +0,0 @@
// @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('登录状态已失效'),
);
});
@@ -1,379 +0,0 @@
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,6 +2641,48 @@ 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: '资产事件',
@@ -2682,6 +2724,48 @@ 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: '资产事件表',
@@ -255,27 +255,6 @@ test('灰度发布页保存游戏发布开关时写入白名单与比例', async
);
});
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({
@@ -206,11 +206,6 @@ 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(),
@@ -457,53 +452,6 @@ 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,6 +154,7 @@ 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',
@@ -516,7 +517,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_]*::)*([a-z][a-z0-9_]*)\b/g),
match[1].matchAll(/\b([a-z][a-z0-9_]+)\b/g),
([, command]) => command,
);
}
@@ -549,12 +550,6 @@ 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', {});
+8 -15
View File
@@ -125,24 +125,17 @@ 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,8 +34,6 @@ 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;
@@ -74,8 +72,6 @@ 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, DirectTurnError};
use super::super::{direct_delivery, direct_execution, direct_validation};
use super::{shutdown_game_creator_codex_app_server_inner, CodexAppServerInner};
use direct_execution::{EffectKind, ExecutionLease, ExecutionPhase, ExecutionSession};
use serde_json::{json, Value};
@@ -147,11 +147,6 @@ 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> {
@@ -268,8 +263,6 @@ impl ExecutionAdapter {
changed: Notify::new(),
shutdown_gate: tokio::sync::Mutex::new(()),
outcome,
turn_failure: Mutex::new(None),
host_stop_requested: AtomicBool::new(false),
})
}
@@ -661,7 +654,6 @@ 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;
}
@@ -680,49 +672,6 @@ 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 {
@@ -795,19 +744,7 @@ 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",
@@ -1100,10 +1037,6 @@ 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>) {
@@ -1151,57 +1084,6 @@ 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,20 +3548,12 @@ 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) {
// 这里的条目时间可能是启动应答后的观测时间;前端按同一用户条目身份
@@ -3602,11 +3594,8 @@ impl CodexAppServerConnection {
hard_deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
if let Some(adapter) = approval_adapter.as_ref() {
// 等不到终态就是这一轮失败:只收口不留原因等于界面静默结束。
adapter
.fail_turn(DirectTurnError::TimedOut {
deadline: DirectTurnDeadline::TurnHardLimit,
})
.interrupt("等待模型回合结束达到硬上限,已停止本轮并核对后台操作。")
.await;
return execution::outcome_text(adapter.wait_outcome().await);
}
@@ -3634,9 +3623,7 @@ impl CodexAppServerConnection {
Err(_) => {
if let Some(adapter) = approval_adapter.as_ref() {
adapter
.fail_turn(DirectTurnError::TimedOut {
deadline: DirectTurnDeadline::ResponseIdle,
})
.interrupt("等待模型执行回执超时,不能自动重放未确认操作。")
.await;
return execution::outcome_text(adapter.wait_outcome().await);
}
@@ -3966,16 +3953,9 @@ impl CodexAppServerConnection {
}
"interrupted" => {
if let Some(adapter) = approval_adapter.as_ref() {
// app-server 自己把这一轮判成中断,而宿主没有请求过终止(用户点
// 「终止」会先置 `host_stop_requested`、并把阶段推成终态):这是异常
// 收场,必须让界面看到原因,不能只是把回合静默收口。
if !adapter.is_host_ending() && !adapter.host_stop_requested() {
if !adapter.is_host_ending() {
adapter
.fail_turn(DirectTurnError::TurnInterrupted {
detail:
"本轮模型执行被中断,正在核对自有后台进程。"
.into(),
})
.interrupt("本轮模型执行已中断,正在核对自有后台进程。")
.await;
}
return execution::outcome_text(adapter.wait_outcome().await);
@@ -3988,23 +3968,14 @@ 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
{
if let Err(repair) = execution::outcome_text(outcome) {
return Err(repair);
}
return execution::outcome_text(outcome);
}
}
return Err(native);
return Err(game_creator_codex_app_server_failed_turn_error(turn));
}
status => {
return Err(platform_llm::LlmError::Deserialize(format!(
@@ -4016,13 +3987,8 @@ impl CodexAppServerConnection {
Some(CodexTurnEvent::TransportClosed(error)) => {
if let Some(adapter) = approval_adapter.as_ref() {
if !adapter.is_host_ending() {
// 事件带的 `error` 就是连接终止时那份诊断。通道断开是不是'失败'由
// 适配器判(宿主自己关的连接不算),失败事实也记在它上面,回合终态
// 判定之后才读得到:见 `ExecutionAdapter::fail_turn`。
adapter
.fail_turn(DirectTurnError::TransportClosed {
diagnostic: error.clone(),
})
.interrupt("执行通道已断开,不能自动重放未确认操作。")
.await;
}
return execution::outcome_text(adapter.wait_outcome().await);
@@ -4036,12 +4002,8 @@ impl CodexAppServerConnection {
None => {
if let Some(adapter) = approval_adapter.as_ref() {
if !adapter.is_host_ending() {
// 事件通道在没有终态的情况下关掉,和连接断掉是同一件事:本轮只可能
// 以失败收口,不能报成"被中断"。
adapter
.fail_turn(DirectTurnError::TransportClosed {
diagnostic: "Codex app-server turn 事件通道已关闭".into(),
})
.interrupt("执行事件通道已结束,正在核对后台操作。")
.await;
}
return execution::outcome_text(adapter.wait_outcome().await);
@@ -4099,33 +4061,11 @@ 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,
terminal.event(completed_at, direct_turn_user_item_id.as_deref()),
DirectThreadEvent::turn_completed(status, completed_at)
.with_user_item_id(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;
@@ -5024,16 +4964,6 @@ 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,完整子树退出未确认"),
@@ -5041,24 +4971,6 @@ 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,
@@ -5125,7 +5037,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at(
root: &std::path::Path,
system_prompt: String,
user_prompt: String,
) -> Result<String, DirectTurnError> {
) -> Result<String, String> {
direct_game_creator_codex_chat_at_with_optional_observer(
root,
system_prompt,
@@ -5143,7 +5055,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, DirectTurnError> {
) -> Result<String, String> {
direct_game_creator_codex_chat_at_with_optional_observer(
root,
system_prompt,
@@ -5164,13 +5076,12 @@ 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, DirectTurnError> {
) -> Result<String, String> {
// 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)
.map_err(|cause| DirectTurnError::ProjectRootUnanchored { cause })?;
let (canonical_root, project_id) = direct_codex_canonical_project_identity(root)?;
let codex_root = if let Some(path) = canonical_root
.to_str()
.and_then(|value| value.strip_prefix("\\\\?\\"))
@@ -5179,13 +5090,9 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
} else {
canonical_root.clone()
};
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 config = load_game_creator_app_config()?;
game_creator_codex_app_server_validate_llm_config(&config.llm)
.map_err(|error| error.to_string())?;
// 用户回合身份必须在模型目录/连接准备前冻结,不能先复用上一回合进程。
let generated_client_turn_id;
let effective_client_turn_id = match client_turn_id {
@@ -5198,10 +5105,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
.map(|state| state.client_turn_id)
})
.await
.map_err(|_| DirectTurnError::HostStateUnavailable {
detail: "宿主 CLI 回合身份读取中断".to_string(),
})?
.map_err(|detail| DirectTurnError::HostStateUnavailable { detail })?;
.map_err(|_| "宿主 CLI 回合身份读取中断".to_string())??;
Some(generated_client_turn_id.as_str())
}
};
@@ -5221,11 +5125,8 @@ 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| {
DirectTurnError::EnvironmentNotReady {
detail: error.to_string(),
}
})?;
let api_kind =
parse_game_creator_llm_api_kind(&config.llm.api_kind).map_err(|error| error.to_string())?;
let metrics_attempt = audit.as_ref().map(|audit| {
audit.metrics().attempt(
&config.llm.model,
@@ -5255,10 +5156,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
if let Some(attempt) = metrics_attempt.as_ref() {
attempt.finish("failed");
}
// 连接建立失败是环境/凭据层面的前置于失败:这一轮还没有开始。
DirectTurnError::EnvironmentNotReady {
detail: error.to_string(),
}
error.to_string()
})?;
let request = LlmRunRequest::single_turn(system_prompt, user_prompt)
.with_api_kind(api_kind)
@@ -5280,7 +5178,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at_with_optional_observer(
)
.await
.map(|value| value.text)
.map_err(|error| DirectTurnError::from_model_call(&error));
.map_err(|error| error.to_string());
if let Some(attempt) = metrics_attempt.as_ref() {
attempt.finish(if result.is_ok() {
"completed"
File diff suppressed because it is too large Load Diff
@@ -697,14 +697,10 @@ 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>, DirectTurnError> {
) -> Result<Option<String>, String> {
if let Some(report) = terminal_report(session) {
return Ok(Some(report));
}
@@ -755,9 +751,7 @@ pub(super) async fn review_reply(
.map_err(|_| "delivery-review-worker-exited")??;
return Ok(Some(report));
}
Err(DirectTurnError::ReviewRequired {
detail: format!("delivery-review-required: {detail}"),
})
Err(format!("delivery-review-required: {detail}"))
}
#[cfg(test)]
@@ -920,10 +914,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!(matches!(
review_reply(new_game.path(), &required).await.unwrap_err(),
DirectTurnError::ReviewRequired { .. }
));
assert!(review_reply(new_game.path(), &required)
.await
.unwrap_err()
.starts_with("delivery-review-required:"));
}
assert!(review_reply(new_game.path(), &required)
.await
@@ -93,8 +93,6 @@ pub(super) struct ExecutionLedger {
pub(super) plan: Option<Value>,
#[serde(default)]
pub(super) last_failed_write_revision: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) analytics_run: Option<crate::analytics::run::Metadata>,
}
struct SessionData {
@@ -108,17 +106,6 @@ struct SessionData {
elapsed_offset_ms: u64,
}
// 与业务持久化锁分离;只保存内存状态,持锁期间不执行 I/O 或投递事件。
struct SessionAnalytics {
project_id: String,
route: Option<crate::analytics::contract::Route>,
capture: Option<(
crate::analytics::contract::Context,
crate::analytics::store::AnalyticsWriter,
)>,
output_revision: Option<u64>,
}
#[derive(Clone, PartialEq, Eq)]
struct CodexExecutorIdentity {
path: PathBuf,
@@ -166,12 +153,9 @@ fn executor_digest(path: &Path) -> Result<String, String> {
pub(super) struct ExecutionSession {
pub(super) root: PathBuf,
/// 本次确实新建执行账本;恢复和旧预算迁移均不构成新的用户受理。
pub(super) newly_accepted: bool,
state_path: PathBuf,
_owner: File,
data: Mutex<SessionData>,
analytics: Mutex<SessionAnalytics>,
changed: tokio::sync::watch::Sender<u64>,
cancellation: Arc<std::sync::atomic::AtomicBool>,
abort_requested: std::sync::atomic::AtomicBool,
@@ -232,16 +216,6 @@ pub(crate) struct WritePermit {
id: String,
}
impl WritePermit {
pub(super) fn record_analytics_revision(
&self,
revision: u64,
change_kind: crate::analytics::contract::ChangeKind,
files_changed_count: u64,
) {
self.session
.record_analytics_revision(revision, change_kind, files_changed_count);
}
pub(crate) fn run<T>(&self, write: impl FnOnce() -> Result<T, String>) -> Result<T, String> {
let mut data = self.session.lock()?;
self.session.tick_locked(&mut data)?;
@@ -427,7 +401,6 @@ pub(super) async fn begin(
prompt: &str,
requires_contract: bool,
config: DirectValidationConfig,
analytics_run: Option<crate::analytics::run::Metadata>,
) -> Result<ExecutionSessionGuard, String> {
let root = root.to_path_buf();
let prompt_hash = hash(prompt.as_bytes());
@@ -435,14 +408,13 @@ pub(super) async fn begin(
let turn = super::direct_taonier_active_invocation_id_at(&root)?;
let host = crate::game_creator_runtime_config_dir()
.ok_or("direct-execution-host: 需要客户端私有配置目录,CLI 请提供 --config-dir")?;
open_with_analytics_at(
open_at(
&host.join("direct-executions"),
&root,
&turn,
&prompt_hash,
requires_contract,
&config,
analytics_run,
)
})
.await
@@ -490,26 +462,6 @@ pub(super) fn open_at(
request_hash: &str,
requires_contract: bool,
config: &DirectValidationConfig,
) -> Result<Arc<ExecutionSession>, String> {
open_with_analytics_at(
host,
root,
turn,
request_hash,
requires_contract,
config,
None,
)
}
pub(super) fn open_with_analytics_at(
host: &Path,
root: &Path,
turn: &str,
request_hash: &str,
requires_contract: bool,
config: &DirectValidationConfig,
analytics_run: Option<crate::analytics::run::Metadata>,
) -> Result<Arc<ExecutionSession>, String> {
config.validate()?;
let root = root
@@ -569,7 +521,6 @@ pub(super) fn open_with_analytics_at(
};
let project_id = super::read_existing_manifest_for_project(&root)?.project_id;
let is_new = existing.is_none();
let mut newly_accepted = is_new;
let mut ledger = existing.unwrap_or_else(|| ExecutionLedger {
schema_version: SCHEMA.into(),
client_turn_id: turn.into(),
@@ -595,7 +546,6 @@ pub(super) fn open_with_analytics_at(
delivery_reviews: 0,
plan: None,
last_failed_write_revision: None,
analytics_run,
});
if is_new {
// 只继承旧项目账本的消费量,绝不把可编辑的旧成功回执提升为宿主证据。
@@ -608,8 +558,6 @@ pub(super) fn open_with_analytics_at(
512 * 1024,
)?;
if let Some(legacy) = legacy {
newly_accepted = false;
ledger.analytics_run = None;
let used = legacy["usedRuns"]
.as_u64()
.and_then(|n| u32::try_from(n).ok());
@@ -666,18 +614,8 @@ pub(super) fn open_with_analytics_at(
let (changed, _) = tokio::sync::watch::channel(ledger.revision);
let session = Arc::new(ExecutionSession {
root,
newly_accepted,
state_path,
_owner: owner,
analytics: Mutex::new(SessionAnalytics {
project_id: ledger.project_id.clone(),
route: ledger
.analytics_run
.as_ref()
.map(|run| run.context.route.clone()),
capture: None,
output_revision: None,
}),
data: Mutex::new(SessionData {
ledger,
started: Instant::now(),
@@ -800,10 +738,6 @@ impl ExecutionSession {
pub(super) fn cancel_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
Arc::clone(&self.cancellation)
}
pub(super) fn was_aborted(&self) -> bool {
self.abort_requested
.load(std::sync::atomic::Ordering::Acquire)
}
pub(super) fn record_delivery_review(&self) -> Result<u32, String> {
let mut data = self.lock()?;
if data.ledger.phase.is_terminal() {
@@ -831,73 +765,6 @@ impl ExecutionSession {
self.commit(&mut data, next)?;
Ok(json!({"plan":plan,"revision":data.ledger.revision,"acceptancePassed":false}))
}
pub(super) fn set_analytics_capture(
&self,
capture: Option<(
crate::analytics::contract::Context,
crate::analytics::store::AnalyticsWriter,
)>,
) {
let Ok(mut analytics) = self.analytics.lock() else {
return;
};
analytics.capture = capture.and_then(|(mut context, writer)| {
// 恢复或账号切换后仍归属于真实受理的原 run。
context.route = analytics.route.clone()?;
Some((context, writer))
});
}
pub(super) fn analytics_capture(
&self,
) -> Option<(
crate::analytics::contract::Context,
crate::analytics::store::AnalyticsWriter,
)> {
self.analytics.lock().ok()?.capture.clone()
}
pub(super) fn record_analytics_revision(
&self,
revision: u64,
change_kind: crate::analytics::contract::ChangeKind,
files_changed_count: u64,
) {
use crate::analytics::contract::{RevisionCreated, RevisionSource, Source};
if files_changed_count == 0 {
return;
}
let Ok(mut analytics) = self.analytics.lock() else {
return;
};
if analytics.route.is_none() {
return;
}
analytics.output_revision = Some(analytics.output_revision.unwrap_or(0).max(revision));
let capture = analytics.capture.clone();
let project_id = analytics.project_id.clone();
drop(analytics);
crate::analytics::project::revision(
capture,
&project_id,
Source::Direct,
RevisionCreated {
revision_id: revision.to_string(),
revision_source: RevisionSource::Agent,
change_kind,
files_changed_count: Some(files_changed_count),
},
);
}
pub(super) fn analytics_output_revision(&self) -> Option<String> {
self.analytics
.lock()
.ok()?
.output_revision
.map(|revision| revision.to_string())
}
pub(super) fn snapshot(&self) -> Result<ExecutionLedger, String> {
let data = self.lock()?;
let mut state = data.ledger.clone();
@@ -1,192 +1,5 @@
use super::*;
fn analytics_metadata(user: &str) -> crate::analytics::run::Metadata {
use crate::analytics::contract::{Context, Route, RunSource, Source};
crate::analytics::run::Metadata::new(
Context {
route: Route::from_identity(Some(user.into()), Some("https://example.com")),
editor_session_id: uuid::Uuid::new_v4().to_string(),
client_version: "1.0.0".into(),
},
Source::Direct,
RunSource::UserSubmit,
)
}
#[test]
fn analytics_survives_business_lock_contention_and_preserves_replayed_run_identity() {
use crate::analytics::{contract::ChangeKind, store::AnalyticsWriter};
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("project");
let host = temp.path().join("host");
crate::init_local_game_project_at(&root, "analytics-lock", "采集锁隔离").unwrap();
let original = open_with_analytics_at(
&host,
&root,
"turn",
&hash(b"request"),
false,
&Default::default(),
Some(analytics_metadata("A")),
)
.unwrap();
drop(original);
let current = analytics_metadata("B");
let session = open_with_analytics_at(
&host,
&root,
"turn",
&hash(b"request"),
false,
&Default::default(),
Some(current.clone()),
)
.unwrap();
let config = temp.path().join("config");
std::fs::create_dir_all(&config).unwrap();
let writer = AnalyticsWriter::start(config.clone(), current.context.editor_session_id.clone());
let capture = (current.context.clone(), writer.clone());
// 模拟业务提交长期占锁;采集必须在释放该锁之前完成。
let business_lock = session.data.lock().unwrap();
let task_session = session.clone();
let (sender, receiver) = std::sync::mpsc::channel();
let worker = std::thread::spawn(move || {
task_session.set_analytics_capture(Some(capture));
task_session.record_analytics_revision(7, ChangeKind::Code, 1);
task_session.record_analytics_revision(5, ChangeKind::Code, 1);
task_session.record_analytics_revision(99, ChangeKind::Code, 0);
sender
.send((
task_session.analytics_capture(),
task_session.analytics_output_revision(),
))
.unwrap();
});
let result = receiver.recv_timeout(std::time::Duration::from_secs(5));
// 即使回归成等待业务锁,也先释放锁和回收线程,让测试明确失败而非挂死。
drop(business_lock);
worker.join().unwrap();
let (capture, revision) = result.expect("采集不得等待业务持久化锁");
let (context, _) = capture.expect("锁竞争不得丢失采集身份");
assert_eq!(context.route.user_id.as_deref(), Some("A"));
assert_eq!(context.editor_session_id, current.context.editor_session_id);
assert_eq!(revision.as_deref(), Some("7"));
assert!(writer.flush());
let batches = config
.join("analytics/instances")
.join(&current.context.editor_session_id)
.join("batches");
let deadline = Instant::now() + std::time::Duration::from_secs(5);
loop {
let events: Vec<Value> = std::fs::read_dir(&batches)
.into_iter()
.flatten()
.flatten()
.filter(|entry| !entry.file_name().to_string_lossy().starts_with('.'))
.filter_map(|entry| std::fs::read_to_string(entry.path().join("events.jsonl")).ok())
.flat_map(|text| {
text.lines()
.map(|line| serde_json::from_str::<Value>(line).unwrap())
.collect::<Vec<_>>()
})
.collect();
if events.len() == 2 {
for (event, expected_revision) in events.iter().zip(["7", "5"]) {
assert_eq!(event["event_name"], "project_revision_created");
assert_eq!(event["user_id"], "A");
assert_eq!(event["project_id"], "analytics-lock");
assert_eq!(event["properties"]["revision_id"], expected_revision);
}
break;
}
assert!(Instant::now() < deadline, "成果事件未落盘");
std::thread::sleep(std::time::Duration::from_millis(5));
}
drop(session);
let resumed = open_with_analytics_at(
&host,
&root,
"turn",
&hash(b"request"),
false,
&Default::default(),
Some(current),
)
.unwrap();
assert_eq!(
resumed.analytics_output_revision(),
None,
"恢复不补造历史成果编号"
);
}
#[test]
fn run_metadata_is_persisted_with_new_ledger_and_replay_keeps_original_identity() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("project");
crate::init_local_game_project_at(&root, "analytics-run", "执行身份").unwrap();
let original = analytics_metadata("A");
let host = temp.path().join("host");
let session = open_with_analytics_at(
&host,
&root,
"turn",
&hash(b"request"),
false,
&Default::default(),
Some(original.clone()),
)
.unwrap();
assert!(session.newly_accepted);
assert_eq!(
session.snapshot().unwrap().analytics_run,
Some(original.clone())
);
drop(session);
let replay = open_with_analytics_at(
&host,
&root,
"turn",
&hash(b"request"),
false,
&Default::default(),
Some(analytics_metadata("B")),
)
.unwrap();
assert!(!replay.newly_accepted);
assert_eq!(replay.snapshot().unwrap().analytics_run, Some(original));
}
#[test]
fn legacy_run_without_metadata_is_not_assigned_current_users_identity() {
let (temp, session) = fixture(Default::default());
let root = session.root.clone();
assert!(session.snapshot().unwrap().analytics_run.is_none());
drop(session);
let replay = open_with_analytics_at(
&temp.path().join("host"),
&root,
"turn-test",
&hash(b"request"),
false,
&Default::default(),
Some(analytics_metadata("B")),
)
.unwrap();
assert!(replay.snapshot().unwrap().analytics_run.is_none());
let config = temp.path().join("config");
std::fs::create_dir_all(&config).unwrap();
let current = analytics_metadata("B");
let writer = crate::analytics::store::AnalyticsWriter::start(
config,
current.context.editor_session_id.clone(),
);
replay.set_analytics_capture(Some((current.context, writer)));
replay.record_analytics_revision(1, crate::analytics::contract::ChangeKind::Code, 1);
assert!(replay.analytics_capture().is_none());
assert!(replay.analytics_output_revision().is_none());
}
fn fixture(config: DirectValidationConfig) -> (tempfile::TempDir, Arc<ExecutionSession>) {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("project");
@@ -200,7 +13,6 @@ fn fixture(config: DirectValidationConfig) -> (tempfile::TempDir, Arc<ExecutionS
&config,
)
.unwrap();
assert!(session.newly_accepted);
session
.freeze_contract(json!({"requirements":[{"id":"test"}]}))
.unwrap();
@@ -347,7 +159,6 @@ fn reopened_budget_and_deadline_cannot_be_increased_by_configuration() {
)
.unwrap();
let state = reopened.snapshot().unwrap();
assert!(!reopened.newly_accepted);
assert_eq!(state.delivery_reviews, 1);
assert_eq!(
(
@@ -467,7 +278,6 @@ fn legacy_budget_is_inherited_without_trusting_project_success_evidence() {
&Default::default(),
)
.unwrap();
assert!(!session.newly_accepted);
assert!(session.admit(EffectKind::Execute, None).is_err());
let state = session.snapshot().unwrap();
assert_eq!(state.used_passes, 2);

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