Compare commits

..

13 Commits

Author SHA1 Message Date
lhk229 85f2831f16 补齐客户端埋点环境变量示例
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m21s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m1s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 6m30s
Project CI / Backend tests (pull_request) Successful in 5m28s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m23s
Project CI / Native shell tests (pull_request) Successful in 6m43s
Project CI / Frontend tests (pull_request) Successful in 2m26s
Project CI / Repository checks (pull_request) Successful in 2m52s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m3s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m17s
Project CI / AI game creator shell Rust crates (push) Successful in 1m2s
Project CI / Backend tests (push) Successful in 4m49s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m6s
Project CI / Native shell tests (push) Successful in 6m0s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 8m48s
Project CI / Frontend tests (push) Successful in 2m8s
Project CI / Repository checks (push) Successful in 1m49s
Project CI / AI game creator shell web tests (push) Successful in 1m18s
补充本地、生产及容器的埋点 origin 配置示例和 dev/release 说明。
同步技术方案中的运行时配置入口与地址匹配要求。
2026-09-22 16:14:05 +00:00
lhk229 1e186369c9 客户端埋点设置 (#446)
Project CI / AI game creator shell Rust crates (push) Successful in 1m24s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m56s
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/446
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
2026-09-23 00:09:12 +08:00
lhk229 5c2f85c089 修复 CI 缓存镜像组装的基础镜像引用
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Successful in 1m18s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m50s
Project CI / Backend tests (push) Successful in 3m40s
Project CI / Frontend tests (push) Successful in 2m0s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 7m13s
Project CI / Native shell tests (push) Successful in 5m47s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 8m34s
Project CI / AI game creator shell web tests (push) Successful in 1m32s
Project CI / Repository checks (push) Successful in 1m55s
使用临时本地标签和宿主默认 builder 组装缓存镜像
组装成功或失败后清理临时标签,保持基础镜像清理归属
补充基础镜像复用、重建及失败清理测试
同步部署文档和项目共享记忆
2026-09-22 15:03:05 +00:00
lhk229 4380d453d7 修复 CI 缓存对象误判与产物下载完整性检查
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
按 ZIP 成员内容和元数据识别等价缓存对象,保留真实冲突拒绝规则。
核对产物大小与 ZIP 完整性,为截断和临时传输失败增加有界重试。
补充回归测试并同步部署文档与共享记忆。
通过 80 项缓存测试、17 项工作流测试及真实产物和 HTTP 截断验证。
2026-09-22 14:40:55 +00:00
kdletters f502829fde 发布灰度默认关闭:修掉客户端“看得到点不动”与后台看不到 key (#482)
Project CI / AI game creator shell Rust crates (push) Successful in 1m28s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m2s
Project CI / Backend tests (push) Successful in 5m19s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 6m59s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 8m19s
Project CI / Native shell tests (push) Successful in 6m48s
Project CI / Frontend tests (push) Successful in 2m54s
Project CI / Repository checks (push) Successful in 2m58s
Project CI / AI game creator shell web tests (push) Successful in 2m34s
## 解决什么

你反馈的两个现象都是真问题,这个 PR 一次修掉:

1. **没开灰度也能看到发布按钮**:发布开关此前是「无 gate 行 = 默认开放」,所以没配灰度时后端把 `gameDistributionPublishEnabled` 判成 true,客户端就渲染了发布入口。
2. **按钮点了没反应**:发布动作的提示原先只写 `workspaceStatus`,而普通项目走 `DirectProjectChatView` 时并不渲染工作台状态行,于是「先打开一个项目再发布」「构建失败…」这类信息全都看不见。
3. **后台看不到 key**:预设项此前在未合并的另一个 PR 里,而且后台只列「已创建」的 gate 行。

## 改成什么

- `is_game_distribution_publish_enabled_for_user` 要求 gate 行存在且 `enabled=true`:**灰度默认关闭**,未登录/无行/`enabled=false` 一律不开放;只有白名单 / 灰度比例 / 用户标签命中才开放。作者写入与新版本激活都按这条判据。
- 新增与作者无关的总开关 `is_game_distribution_publish_open`:管理员审核激活新版本只看「灰度是否开启」,不会因为审核员不在作者白名单里而被挡住(这是合入后立刻发现的回归,已修 + 有用例)。
- AGC 新增 `announcePublishMessage`:发布相关提示同时写工作台状态与 DirectProject 对话,点「发布到游戏广场」不会再像没反应。
- 后台「灰度发布配置」新增「可配置开关」列表:`game-distribution:publish` 等预设即使还没创建行也会显示,点「配置」按默认关闭填表,运营开启后填白名单/比例即可放量。

## 验证

- `npm run check:game-distribution-media-e2e`(真实 Phaser 构建产物)现在覆盖:灰度关闭 → 作者拿不到入口 + 写入口 503 → 开启灰度 → 真实封面/截图直传 → 创建游戏/版本/上传/送审 → 管理员审核通过 → 网关 index.html 与 bundle 200 → nosniff;全流程通过。
- 后端:`cargo test -p api-server game_distribution` 19 passed(含新增 `game_distribution_publish_open_ignores_author_allowlist_for_activation`)、`frontend_runtime_config` 6 passed。
- 前端:admin 灰度页 10 用例(含「未创建的预设开关可见并可一键配置」)、AGC 聊天头 3 用例、网页发布页 15 用例;三端 typecheck、`check:encoding`、`check:doc-index`、`check:rustfmt`、全量 `npm test`(400 文件 / 4441 用例)与 `check:repository-ci -- origin/master HEAD` 全绿。

## 上线注意

灰度默认关闭是**刻意**的:合入后到运营在后台把 `game-distribution:publish` 配置并开启之前,作者看不到发布入口、写入口 503;已公开游戏、目录、详情、发行网关、安全下架不受影响。

Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/482
Co-authored-by: kdletters <kdletters@qq.com>
Co-committed-by: kdletters <kdletters@qq.com>
2026-09-22 21:38:03 +08:00
kdletters a56790eaa6 合并 Phaser 一键发布自动构建与打包
Project CI / AI game creator shell Rust crates (push) Successful in 1m36s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m9s
Project CI / Backend tests (push) Successful in 5m41s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 7m33s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 8m53s
Project CI / Native shell tests (push) Successful in 7m28s
Project CI / Frontend tests (push) Successful in 3m38s
Project CI / Repository checks (push) Successful in 3m36s
Project CI / AI game creator shell web tests (push) Successful in 2m55s
2026-09-22 21:03:57 +08:00
kdletters 6e1fbac5d2 合并旧创作模板历史表删除
Project CI / AI game creator shell Rust crates (push) Successful in 1m27s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m0s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 6m54s
Project CI / Backend tests (push) Successful in 5m29s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 8m28s
Project CI / Native shell tests (push) Successful in 6m54s
Project CI / Frontend tests (push) Successful in 3m11s
Project CI / Repository checks (push) Successful in 3m20s
Project CI / AI game creator shell web tests (push) Successful in 3m6s
2026-09-22 20:46:37 +08:00
kdletters 55801deb38 Merge remote-tracking branch 'origin/master' into codex/clear-retired-tables-phase2
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
2026-09-22 20:22:09 +08:00
kdletters 1eaa51b72b 记录阶段二生产发布结果
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m41s
Project CI / Backend tests (pull_request) Failing after 17s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
记录 release 直接切换 2.8.3 锁定二进制
记录阶段二 wasm 发布后的 84 张现役表与旧 schema 清零
记录运行时服务恢复和公网 200
2026-09-22 20:09:35 +08:00
kdletters 05a5ea4d85 补充历史表清空与备份现场证据
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
记录 release 阶段一 apply、全零复核和观察窗口结果
记录 files-minimal 备份与 restore dry-run 验证
记录大库非 minimal files 备份的 catalog 序列化边界
2026-09-22 19:58:06 +08:00
kdletters 3103a6e632 Merge remote-tracking branch 'origin/master' into codex/clear-retired-tables-phase2
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m56s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m55s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 8m20s
Project CI / Backend tests (pull_request) Successful in 7m15s
Project CI / Native shell tests (pull_request) Successful in 8m18s
Project CI / Frontend tests (pull_request) Successful in 3m28s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m18s
Project CI / Repository checks (pull_request) Successful in 3m38s
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
2026-09-22 19:43:10 +08:00
kdletters b417144baf Merge remote-tracking branch 'origin/master' into codex/clear-retired-tables-phase2 2026-09-22 19:42:44 +08:00
kdletters fd0c1007ad 清除旧创作模板历史表定义与绑定
删除 63 张旧玩法表的 module 定义与 legacy schema
移除清空 procedure、迁移白名单和旧行兼容逻辑
重新生成 spacetime-client bindings
新增一次性 schema guard 删除白名单与回归测试
清理后台表名映射、旧回填工具和权威文档
2026-09-22 19:42:29 +08:00
503 changed files with 15986 additions and 29071 deletions
+6
View File
@@ -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.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.
@@ -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(
+27
View File
@@ -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: {
+38
View File
@@ -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;
+7
View File
@@ -20,6 +20,7 @@ import {
import { AdminAccountsPage } from '../pages/AdminAccountsPage';
import { AdminAgcModelsPage } from '../pages/AdminAgcModelsPage';
import { AdminAgcTemplatesPage } from '../pages/AdminAgcTemplatesPage';
import { AdminAgcTrackingPage } from '../pages/AdminAgcTrackingPage';
import { AdminDashboardPage } from '../pages/AdminDashboardPage';
import { AdminDatabaseTablesPage } from '../pages/AdminDatabaseTablesPage';
import { AdminDebugHttpPage } from '../pages/AdminDebugHttpPage';
@@ -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}
+1
View File
@@ -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',
+2
View File
@@ -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', {});
File diff suppressed because it is too large Load Diff
@@ -93,6 +93,8 @@ 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 {
@@ -106,6 +108,17 @@ 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,
@@ -153,9 +166,12 @@ 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,
@@ -216,6 +232,16 @@ 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)?;
@@ -401,6 +427,7 @@ 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());
@@ -408,13 +435,14 @@ 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_at(
open_with_analytics_at(
&host.join("direct-executions"),
&root,
&turn,
&prompt_hash,
requires_contract,
&config,
analytics_run,
)
})
.await
@@ -462,6 +490,26 @@ 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
@@ -521,6 +569,7 @@ pub(super) fn open_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(),
@@ -546,6 +595,7 @@ pub(super) fn open_at(
delivery_reviews: 0,
plan: None,
last_failed_write_revision: None,
analytics_run,
});
if is_new {
// 只继承旧项目账本的消费量,绝不把可编辑的旧成功回执提升为宿主证据。
@@ -558,6 +608,8 @@ pub(super) fn open_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());
@@ -614,8 +666,18 @@ pub(super) fn open_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(),
@@ -738,6 +800,10 @@ 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() {
@@ -765,6 +831,73 @@ 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,5 +1,192 @@
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");
@@ -13,6 +200,7 @@ fn fixture(config: DirectValidationConfig) -> (tempfile::TempDir, Arc<ExecutionS
&config,
)
.unwrap();
assert!(session.newly_accepted);
session
.freeze_contract(json!({"requirements":[{"id":"test"}]}))
.unwrap();
@@ -159,6 +347,7 @@ 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!(
(
@@ -278,6 +467,7 @@ 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);
@@ -229,6 +229,40 @@ fn target_fingerprints(targets: &[PatchTarget]) -> BTreeMap<String, Option<Strin
.collect()
}
fn analytics_patch_changes(
before: &BTreeMap<String, Option<String>>,
after: &BTreeMap<String, Option<String>>,
) -> Option<(crate::analytics::contract::ChangeKind, u64)> {
use crate::analytics::contract::ChangeKind;
let mut result = None;
for (path, before) in before {
let Some((before, after)) = before
.as_ref()
.zip(after.get(path).and_then(Option::as_ref))
else {
continue;
};
if before == after {
continue;
}
let Some(kind) = crate::analytics::project::file_change_kind(path) else {
continue;
};
result = Some(match result {
None => (kind, 1),
Some((current, count)) => (
if current == kind {
current
} else {
ChangeKind::Mixed
},
count + 1,
),
});
}
result
}
fn run_transaction(
root: &Path,
parsed: codex_patch_parser::ApplyPatchArgs,
@@ -319,6 +353,13 @@ fn run_transaction(
None
};
lease.finish(passed && !uncertain, changed, None)?;
if passed && !uncertain {
if let (Some(revision), Some((kind, count))) =
(revision, analytics_patch_changes(&before, &after))
{
session.record_analytics_revision(revision, kind, count);
}
}
Ok(json!({
"status": if passed && !uncertain { "completed" } else { "failed" },
"changedPaths": if started { changed_paths } else { BTreeSet::new() },
@@ -351,6 +392,47 @@ pub(super) async fn apply(root: &Path, arguments: &Value) -> Result<Value, Strin
mod tests {
use super::*;
#[test]
fn analytics_patch_counts_only_known_changed_outputs() {
let fingerprints = |entries: &[(&str, Option<&str>)]| {
entries
.iter()
.map(|(path, value)| (path.to_string(), value.map(str::to_string)))
.collect()
};
let before = fingerprints(&[
("game/a.js", Some("a")),
("game/same.js", Some("same")),
("game/unknown.js", None),
("game/unreadable.js", Some("old")),
(".agent/state.json", Some("old")),
]);
let after = fingerprints(&[
("game/a.js", Some("b")),
("game/same.js", Some("same")),
("game/unknown.js", Some("new")),
("game/unreadable.js", None),
(".agent/state.json", Some("new")),
]);
assert_eq!(
analytics_patch_changes(&before, &after),
Some((crate::analytics::contract::ChangeKind::Code, 1))
);
assert_eq!(analytics_patch_changes(&before, &before), None);
let before = fingerprints(&[
("game/a.js", Some("missing")),
("assets/a.png", Some("old")),
]);
let after = fingerprints(&[
("game/a.js", Some("new")),
("assets/a.png", Some("missing")),
]);
assert_eq!(
analytics_patch_changes(&before, &after),
Some((crate::analytics::contract::ChangeKind::Mixed, 2))
);
}
fn project() -> (tempfile::TempDir, PathBuf) {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("project");
@@ -415,15 +497,20 @@ mod tests {
#[tokio::test]
async fn bundled_patch_roundtrip_preserves_partial_failure_and_rejects_closed_turn() {
let (temp, root) = project();
let session = direct_execution::open_at(
let config = temp.path().join("analytics-config");
let (metadata, context, writer) =
super::super::direct_tool_bridge::analytics_test_writer(&config);
let session = direct_execution::open_with_analytics_at(
&temp.path().join("host"),
&root,
"patch-roundtrip",
&format!("{:x}", Sha256::digest(b"request")),
false,
&direct_validation::DirectValidationConfig::default(),
Some(metadata),
)
.unwrap();
session.set_analytics_capture(Some((context.clone(), writer.clone())));
session
.freeze_contract(json!({"fixture":"patch protocol only"}))
.unwrap();
@@ -499,6 +586,29 @@ mod tests {
"writes do not invent execution passes"
);
assert!(session.snapshot().unwrap().active.is_empty());
assert_eq!(
session.analytics_output_revision(),
None,
"text fixture files are not classified as成果"
);
let outputs = apply(&root, &json!({"patch":"*** Begin Patch\n*** Add File: game/result.js\n+const result = 1;\n*** Add File: game/style.css\n+body { color: red; }\n*** End Patch"})).await.unwrap();
assert_eq!(outputs["status"], "completed", "{outputs}");
let revision = outputs["revision"].as_u64().unwrap().to_string();
assert_eq!(session.analytics_output_revision(), Some(revision.clone()));
let partial_output = apply(&root, &json!({"patch":"*** Begin Patch\n*** Add File: game/partial.js\n+const partial = 1;\n*** Update File: game/absent.js\n@@\n-old\n+new\n*** End Patch"})).await.unwrap();
assert_eq!(partial_output["status"], "failed");
assert_eq!(session.analytics_output_revision(), Some(revision.clone()));
let events = super::super::direct_tool_bridge::drain_analytics_test_writer(
&config, &context, &writer,
);
let revisions: Vec<_> = events
.iter()
.filter(|event| event["event_name"] == "project_revision_created")
.collect();
assert_eq!(revisions.len(), 1);
assert_eq!(revisions[0]["user_id"], "A");
assert_eq!(revisions[0]["properties"]["revision_id"], revision);
assert_eq!(revisions[0]["properties"]["files_changed_count"], 2);
session.interrupt("fixture stopped".into()).unwrap();
assert!(apply(
&root,
@@ -4243,7 +4243,44 @@ pub(crate) async fn run_direct_browser_evidence_with_cancellation_at(
advisory_interaction: bool,
cancellation: Option<Arc<std::sync::atomic::AtomicBool>>,
) -> Result<BrowserValidationResult, String> {
run_direct_browser_evidence_with_analytics_at(
root,
evidence_root,
scenario,
advisory_interaction,
cancellation,
None,
)
.await
}
pub(crate) async fn run_direct_browser_evidence_with_analytics_at(
root: &Path,
evidence_root: PathBuf,
scenario: Option<crate::browser::BrowserPlaytestScenario>,
advisory_interaction: bool,
cancellation: Option<Arc<std::sync::atomic::AtomicBool>>,
capture: Option<(
crate::analytics::contract::Context,
crate::analytics::store::AnalyticsWriter,
)>,
) -> Result<BrowserValidationResult, String> {
let observation = crate::analytics::preview::prepare(
root,
capture,
crate::analytics::contract::Source::Direct,
crate::analytics::contract::PreviewSource::Agent,
);
let (_analytics_lease, observation) = match observation {
Some((lease, observation)) => (Some(lease), Some(observation)),
None => (None, None),
};
let (preview, stop_sender) = start_local_game_preview_for_project(root)?;
if let Some(observation) = observation {
observation
.with_cancellation(cancellation.clone())
.schedule(preview.port);
}
let validation = crate::browser::validate_local_preview_in_browser_with_cancellation(
BrowserValidationInput {
url: preview.url,
@@ -4261,6 +4298,7 @@ pub(crate) async fn run_direct_browser_evidence_with_cancellation_at(
cancellation,
)
.await;
drop(_analytics_lease);
let _ = stop_sender.send(());
validation
}
@@ -4781,6 +4819,8 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type(
None,
None,
None,
None,
None,
)
.await
}
@@ -4792,6 +4832,11 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter(
turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>,
audit: Option<&mut DirectCodexTurnAudit>,
direct_user_item: Option<serde_json::Value>,
capture: Option<(
crate::analytics::contract::Context,
crate::analytics::store::AnalyticsWriter,
)>,
analytics_attempt_id: Option<&str>,
) -> Result<String, String> {
if !root.is_absolute() || !root.is_dir() {
return Err("当前项目目录不存在或不是绝对路径".to_string());
@@ -4814,6 +4859,8 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter(
turn_emitter,
audit,
direct_user_item,
capture,
analytics_attempt_id,
)
.await
{
@@ -5014,6 +5061,11 @@ async fn run_direct_game_creator_turn_inner(
turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>,
audit: Option<&mut DirectCodexTurnAudit>,
direct_user_item: Option<serde_json::Value>,
capture: Option<(
crate::analytics::contract::Context,
crate::analytics::store::AnalyticsWriter,
)>,
analytics_attempt_id: Option<&str>,
) -> Result<String, DirectCodexTurnFailure> {
let requires_contract = super::direct_delivery::requires_new_web_contract(
root,
@@ -5035,16 +5087,49 @@ async fn run_direct_game_creator_turn_inner(
DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error)
})?
.validation;
let execution_guard =
super::direct_execution::begin(root, prompt, requires_contract, execution_config)
.await
.map_err(|error| {
DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error)
})?;
let analytics_run = capture.as_ref().map(|(context, _)| {
crate::analytics::run::Metadata::new(
context.clone(),
crate::analytics::contract::Source::Direct,
crate::analytics::contract::RunSource::UserSubmit,
)
});
let execution_guard = super::direct_execution::begin(
root,
prompt,
requires_contract,
execution_config,
analytics_run,
)
.await
.map_err(|error| DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error))?;
let execution_session = execution_guard.session();
execution_session.set_analytics_capture(capture.clone());
let started = execution_session
.newly_accepted
.then(std::time::Instant::now);
if execution_session.newly_accepted {
if let Ok(ledger) = execution_session.snapshot() {
if let (Some((_, writer)), Some(metadata)) = (&capture, &ledger.analytics_run) {
crate::analytics::run::accepted(writer, root, &ledger.project_id, metadata);
}
}
}
// 在 guard 仍存活时冻结整体结果,避免 Drop 的中断收尾覆盖真实失败原因。
let result: Result<String, DirectCodexTurnFailure> = async {
if let Some(report) = super::direct_delivery::terminal_report(&execution_session) {
return Ok(report);
}
if execution_session.newly_accepted {
if let Ok(ledger) = execution_session.snapshot() {
crate::analytics::goal::accepted(
capture.clone(),
root,
&ledger.project_id,
crate::analytics::contract::Source::Direct,
);
}
}
emit_direct_game_creator_progress(root, "codex.turn", "陶泥儿正在处理这条消息");
if let Some(emitter) = turn_emitter {
emitter.emit("running", Some("preparing"), None, None);
@@ -5334,6 +5419,118 @@ async fn run_direct_game_creator_turn_inner(
})?;
}
Ok(visible_reply)
}.await;
if let Ok(ledger) = execution_session.snapshot() {
if let (Some(metadata), Some((end_reason, error_code))) = (
&ledger.analytics_run,
direct_analytics_outcome(
ledger.phase,
ledger.requires_contract || ledger.contract.is_some(),
result.is_err(),
execution_session.was_aborted(),
),
) {
let output_revision = execution_session.analytics_output_revision();
crate::analytics::run::direct_finished(
capture,
root,
&ledger.project_id,
metadata,
analytics_attempt_id,
crate::analytics::run::Outcome {
turn_id: Some(ledger.client_turn_id.clone()),
end_reason,
error_code,
duration_ms: started
.and_then(|start| u64::try_from(start.elapsed().as_millis()).ok()),
output_change_detected: output_revision.as_ref().map(|_| true),
revision_id: output_revision,
},
);
}
}
result
}
fn direct_analytics_outcome(
phase: super::direct_execution::ExecutionPhase,
has_contract: bool,
failed: bool,
aborted: bool,
) -> Option<(
crate::analytics::contract::RunEndReason,
Option<crate::analytics::contract::ErrorCode>,
)> {
use super::direct_execution::ExecutionPhase;
use crate::analytics::contract::{ErrorCode, RunEndReason};
if phase == ExecutionPhase::Interrupted || aborted {
return None;
}
if phase == ExecutionPhase::Exhausted {
return Some((RunEndReason::Failed, Some(ErrorCode::RuntimeFailed)));
}
if failed {
// Direct 当前只保留 stage 和展示错误字符串,不从正文猜 Provider 错误类别。
return Some((
RunEndReason::Failed,
Some(ErrorCode::RuntimeErrorUnclassified),
));
}
if phase == ExecutionPhase::Completed || !has_contract {
return Some((RunEndReason::Finished, None));
}
None
}
#[cfg(test)]
mod direct_analytics_tests {
use super::*;
use crate::agent::direct_execution::ExecutionPhase;
use crate::analytics::contract::{ErrorCode, RunEndReason};
#[test]
fn terminal_reports_do_not_turn_exhaustion_or_cancellation_into_success() {
assert_eq!(
direct_analytics_outcome(ExecutionPhase::Exhausted, true, false, false),
Some((RunEndReason::Failed, Some(ErrorCode::RuntimeFailed)))
);
for failed in [true, false] {
assert_eq!(
direct_analytics_outcome(ExecutionPhase::Interrupted, true, failed, false),
None
);
assert_eq!(
direct_analytics_outcome(ExecutionPhase::Working, false, failed, true),
None
);
}
}
#[test]
fn complete_delivery_does_not_hide_later_projection_failure() {
assert_eq!(
direct_analytics_outcome(ExecutionPhase::Completed, true, true, false),
Some((
RunEndReason::Failed,
Some(ErrorCode::RuntimeErrorUnclassified)
))
);
assert_eq!(
direct_analytics_outcome(ExecutionPhase::Completed, true, false, false),
Some((RunEndReason::Finished, None))
);
assert_eq!(
direct_analytics_outcome(ExecutionPhase::Working, false, false, false),
Some((RunEndReason::Finished, None))
);
for phase in [
ExecutionPhase::Working,
ExecutionPhase::Draining,
ExecutionPhase::Sealing,
] {
assert_eq!(direct_analytics_outcome(phase, true, false, false), None);
}
}
}
#[cfg(test)]
@@ -33,7 +33,9 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
user_item: DirectCodexUserItem,
creation_type: Option<String>,
client_turn_id: Option<String>,
analytics_attempt_id: Option<String>,
) -> Result<String, String> {
let capture = crate::analytics::gui::capture_writer_context();
let root = Path::new(project_path.trim());
let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?;
let _active_invocation = DirectTaonierActiveInvocationGuard::enter(root, &turn_id)?;
@@ -62,6 +64,8 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
// DirectProject 的完整回合权威已经落在 project.jsonl;不再创建平行审计日志。
None,
canonical_user_item,
capture,
analytics_attempt_id.as_deref(),
)
.await
{
@@ -1600,6 +1600,15 @@ fn bridge_write_file(root: &Path, arguments: &Value) -> Value {
bridge_write_file_with_permit(root, arguments, None)
}
fn bridge_file_content_changed(root: &Path, path: &str, content: &[u8]) -> Option<bool> {
crate::analytics::project::file_content_changed(
root,
path,
content,
DIRECT_TOOL_BRIDGE_MAX_WRITE_CONTENT_BYTES as u64,
)
}
fn bridge_write_file_with_permit(
root: &Path,
arguments: &Value,
@@ -1641,6 +1650,11 @@ fn bridge_write_file_with_permit(
"direct-codex.file.write",
)?;
let lock_wait_ms = acquire_started.elapsed().as_millis();
let analytics_change_kind = write_permit.and_then(|_| {
let kind = crate::analytics::project::file_change_kind(&path)?;
(bridge_file_content_changed(root, &path, content.as_bytes()) == Some(true))
.then_some(kind)
});
let write_started = std::time::Instant::now();
let commit = || {
let written = write_local_project_file_at(root, &path, content)?;
@@ -1653,6 +1667,9 @@ fn bridge_write_file_with_permit(
Some(permit) => permit.run(commit)?,
None => commit()?,
};
if let (Some(permit), Some(kind)) = (write_permit, analytics_change_kind) {
permit.record_analytics_revision(revision, kind, 1);
}
// 现场一次 2.6KB 写入实测 5.5 秒。只在明显偏慢时记账,正常写入不刷日志。
if lock_wait_ms + write_ms > 200 {
app_log!(
@@ -3473,6 +3490,82 @@ pub(in crate::agent) async fn generate_images_concurrently_for_test(
.await
}
#[cfg(test)]
pub(super) fn analytics_test_writer(
config: &Path,
) -> (
crate::analytics::run::Metadata,
crate::analytics::contract::Context,
crate::analytics::store::AnalyticsWriter,
) {
use crate::analytics::{
contract::{Context, Route, RunSource, Source},
run,
store::AnalyticsWriter,
};
let mut context = Context {
route: Route::from_identity(Some("A".into()), Some("https://example.com")),
editor_session_id: uuid::Uuid::new_v4().to_string(),
client_version: "1.0.0".into(),
};
fs::create_dir_all(config).expect("create analytics test config directory");
let metadata = run::Metadata::new(context.clone(), Source::Direct, RunSource::UserSubmit);
context.route.user_id = Some("B".into());
let writer = AnalyticsWriter::start(config.into(), context.editor_session_id.clone());
(metadata, context, writer)
}
#[cfg(test)]
pub(super) fn drain_analytics_test_writer(
config: &Path,
context: &crate::analytics::contract::Context,
writer: &crate::analytics::store::AnalyticsWriter,
) -> Vec<Value> {
use crate::analytics::contract::{EntrySource, EventData, SessionStart, Source};
let marker = context
.capture(
EventData::EditorSessionStart(SessionStart {
entry_source: EntrySource::DirectLaunch,
first_project_id: None,
}),
None,
Source::Editor,
None,
)
.unwrap();
let marker_id = marker.event_id.clone();
assert!(writer.try_record(context.route.clone(), marker, marker_id.clone()));
assert!(writer.flush());
let batches = config
.join("analytics/instances")
.join(&context.editor_session_id)
.join("batches");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
let events: Vec<Value> = fs::read_dir(&batches)
.into_iter()
.flatten()
.flatten()
.filter(|entry| !entry.file_name().to_string_lossy().starts_with('.'))
.filter_map(|entry| fs::read_to_string(entry.path().join("events.jsonl")).ok())
.flat_map(|contents| {
contents
.lines()
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
.collect::<Vec<_>>()
})
.collect();
if events.iter().any(|event| event["event_id"] == marker_id) {
return events;
}
assert!(
std::time::Instant::now() < deadline,
"analytics FIFO sentinel timed out"
);
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
#[cfg(test)]
mod tests {
#[tokio::test]
@@ -4262,6 +4355,293 @@ mod tests {
assert_eq!(importability.get("assets/vector.svg"), Some(&true));
}
#[tokio::test]
async fn analytics_real_file_write_preserves_original_identity_and_failed_run_revision() {
use crate::analytics::{
contract::{ErrorCode, RunEndReason},
run,
};
let temporary = tempfile::tempdir().unwrap();
let root = temporary.path().join("project");
let config = temporary.path().join("config");
let (metadata, context, writer) = analytics_test_writer(&config);
let original_capture = Some((metadata.context.clone(), writer.clone()));
let mut lifecycle = crate::analytics::gui::LifecycleFixture::start(
metadata.context.clone(),
writer.clone(),
);
lifecycle.create_and_open(&root, "direct-analytics");
let session = super::super::direct_execution::open_with_analytics_at(
&temporary.path().join("host"),
&root,
"analytics-write",
&format!("{:x}", Sha256::digest(b"request")),
false,
&Default::default(),
Some(metadata.clone()),
)
.unwrap();
session.set_analytics_capture(Some((context.clone(), writer.clone())));
super::super::direct_delivery::register_contract(
&root,
&session,
&json!({
"scope": "核对当前项目宿主写入的成果采集",
"changeKind": "project",
"requirements": [{"id": "analytics-output", "kind": "artifact", "path": "game/index.html"}]
}),
)
.await
.expect("freeze a validated delivery contract before writing");
let project_id = session.snapshot().unwrap().project_id;
run::accepted(&writer, &root, &project_id, &metadata);
crate::analytics::goal::accepted(
original_capture.clone(),
&root,
&project_id,
crate::analytics::contract::Source::Direct,
);
let lease = session
.admit(super::super::direct_execution::EffectKind::Write, None)
.unwrap();
let permit = lease.write_permit().unwrap();
let arguments = json!({"path":"game/index.html", "content":"<!doctype html><html><body>真实预览</body></html>"});
let changed = bridge_write_file_with_permit(&root, &arguments, Some(&permit));
assert_eq!(changed["isError"], false);
let payload: Value =
serde_json::from_str(changed["content"][0]["text"].as_str().unwrap()).unwrap();
let revision = payload["revision"].as_u64().unwrap().to_string();
assert_eq!(session.analytics_output_revision(), Some(revision.clone()));
assert_eq!(
bridge_write_file_with_permit(&root, &arguments, Some(&permit))["isError"],
false
);
assert_eq!(
bridge_write_file_with_permit(
&root,
&json!({"path":"../bad.js","content":"bad"}),
Some(&permit)
)["isError"],
true
);
assert_eq!(session.analytics_output_revision(), Some(revision.clone()));
lease.finish(true, true, None).unwrap();
let attempt = uuid::Uuid::new_v4().to_string();
run::direct_finished(
Some((context.clone(), writer.clone())),
&root,
&project_id,
&metadata,
Some(&attempt),
run::Outcome {
turn_id: Some("analytics-write".into()),
end_reason: RunEndReason::Failed,
error_code: Some(ErrorCode::RuntimeErrorUnclassified),
duration_ms: None,
output_change_detected: Some(true),
revision_id: session.analytics_output_revision(),
},
);
run::settle(Some((context.clone(), writer.clone())), &attempt, false);
// 默认项目使用 npm:预览服务读取真实构建目录,夹具提供构建入口,不调用构建器。
let served_root = crate::project_game_root(&root);
fs::create_dir_all(&served_root).unwrap();
fs::write(
served_root.join("index.html"),
"<!doctype html><html><body>真实预览构建</body></html>",
)
.unwrap();
let (preview_lease, observation) = crate::analytics::preview::prepare(
&root,
original_capture.clone(),
crate::analytics::contract::Source::Editor,
crate::analytics::contract::PreviewSource::User,
)
.unwrap();
let (preview, stop) = crate::start_local_game_preview_for_project(&root).unwrap();
observation.observe(preview.port).await;
let preview_revision = crate::read_game_creator_agent_runtime_project_revision(&root)
.unwrap()
.revision
.to_string();
drop(preview_lease);
let _ = stop.send(());
let checkpoint = crate::commands::checkpoint_with_capture_for_test(
root.to_string_lossy().into_owned(),
original_capture,
)
.unwrap();
lifecycle.exit();
let events = drain_analytics_test_writer(&config, &context, &writer);
let ids: std::collections::HashSet<_> = events
.iter()
.map(|event| event["event_id"].as_str().unwrap())
.collect();
assert_eq!(ids.len(), events.len());
let chain: Vec<_> = events
.iter()
.filter(|event| event["user_id"] == "A")
.collect();
for name in [
"editor_session_start",
"editor_focus_start",
"project_create_success",
"project_open",
"creative_task_submit",
"project_revision_created",
"agent_run_failed",
"preview_ready",
"project_save",
"editor_focus_end",
"editor_session_end",
] {
assert_eq!(
chain
.iter()
.filter(|event| event["event_name"] == name)
.count(),
1,
"{name}"
);
}
for event in &chain {
assert_eq!(
event["editor_session_id"],
metadata.context.editor_session_id
);
if !event["project_id"].is_null() {
assert_eq!(event["project_id"], project_id);
}
if !event["creative_task_id"].is_null() {
assert_eq!(event["creative_task_id"], project_id);
}
if !event["agent_run_id"].is_null() {
assert_eq!(event["agent_run_id"], metadata.run_id);
}
let name = event["event_name"].as_str().unwrap();
if matches!(
name,
"project_create_success"
| "project_open"
| "creative_task_submit"
| "project_revision_created"
| "agent_run_failed"
| "preview_ready"
| "project_save"
) {
assert_eq!(
event["project_id"], project_id,
"{name} must identify its project"
);
}
if matches!(
name,
"creative_task_submit"
| "project_revision_created"
| "agent_run_failed"
| "preview_ready"
| "project_save"
) {
assert_eq!(
event["creative_task_id"], project_id,
"{name} must identify its goal"
);
}
if name == "agent_run_failed" {
assert_eq!(event["agent_run_id"], metadata.run_id);
assert_eq!(event["agent_turn_id"], "analytics-write");
}
}
let save = chain
.iter()
.find(|event| event["event_name"] == "project_save")
.unwrap();
assert_eq!(save["properties"]["save_source"], "checkpoint");
assert!(Path::new(&checkpoint.checkpoint_path).is_dir());
let mut digest = Sha256::new();
digest.update(serde_json::to_vec(&metadata.context.route).unwrap());
digest.update([0]);
digest.update(format!("{project_id}:{}:project_save", checkpoint.checkpoint_id).as_bytes());
let checkpoint_fact = format!("{:x}", digest.finalize());
let batches = config
.join("analytics/instances")
.join(&context.editor_session_id)
.join("batches");
let checkpoint_fact_matches = fs::read_dir(batches)
.unwrap()
.flatten()
.filter_map(|entry| fs::read(entry.path().join("meta.json")).ok())
.filter_map(|bytes| serde_json::from_slice::<Value>(&bytes).ok())
.filter(|batch| batch["facts"][&checkpoint_fact] == save["event_id"])
.count();
assert_eq!(
checkpoint_fact_matches, 1,
"save fact must use the actual checkpoint ID"
);
let ready = chain
.iter()
.find(|event| event["event_name"] == "preview_ready")
.unwrap();
assert_eq!(ready["properties"]["preview_version"], preview_revision);
let focus_start = chain
.iter()
.find(|event| event["event_name"] == "editor_focus_start")
.unwrap();
let focus_end = chain
.iter()
.find(|event| event["event_name"] == "editor_focus_end")
.unwrap();
assert_eq!(
focus_start["properties"]["focus_interval_id"],
focus_end["properties"]["focus_interval_id"]
);
let revisions: Vec<_> = events
.iter()
.filter(|event| event["event_name"] == "project_revision_created")
.collect();
assert_eq!(revisions.len(), 1);
assert_eq!(revisions[0]["user_id"], "A");
assert_eq!(revisions[0]["properties"]["revision_id"], revision);
let failed: Vec<_> = events
.iter()
.filter(|event| event["event_name"] == "agent_run_failed")
.collect();
assert_eq!(failed.len(), 1);
assert_eq!(failed[0]["user_id"], "A");
assert_eq!(failed[0]["properties"]["revision_id"], revision);
assert_eq!(failed[0]["properties"]["output_change_detected"], true);
}
#[test]
fn analytics_file_comparison_requires_known_bounded_content() {
let temporary = tempfile::tempdir().unwrap();
let root = temporary.path();
assert_eq!(
bridge_file_content_changed(root, "new.js", b"new"),
Some(true)
);
fs::write(root.join("new.js"), b"new").unwrap();
assert_eq!(
bridge_file_content_changed(root, "new.js", b"new"),
Some(false)
);
assert_eq!(
bridge_file_content_changed(root, "new.js", b"changed"),
Some(true)
);
fs::create_dir(root.join("directory.js")).unwrap();
assert_eq!(
bridge_file_content_changed(root, "directory.js", b"new"),
None
);
fs::write(
root.join("large.js"),
vec![0; DIRECT_TOOL_BRIDGE_MAX_WRITE_CONTENT_BYTES + 1],
)
.unwrap();
assert_eq!(bridge_file_content_changed(root, "large.js", b"new"), None);
}
#[test]
fn bridge_write_file_writes_project_relative_text_without_runtime_tasks() {
let temporary = tempfile::tempdir().expect("create direct write root");
@@ -453,12 +453,15 @@ async fn run_browser_with_budget(
sequence,
),
)?;
let evidence = super::direct_runtime::run_direct_browser_evidence_with_cancellation_at(
let evidence = super::direct_runtime::run_direct_browser_evidence_with_analytics_at(
root,
evidence_root,
scenario,
false,
reservation.as_ref().map(|r| r.session.cancel_flag()),
reservation
.as_ref()
.and_then(|r| r.session.analytics_capture()),
)
.await;
let (result, passed) = match evidence {
@@ -126,6 +126,9 @@ pub(crate) struct DesignTurn {
pub(crate) attempt: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) model_selection: Option<DesignModelSelection>,
/// 仅持久化埋点关联,旧回合缺失时不补历史执行。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) analytics: Option<crate::analytics::run::Metadata>,
}
/// 仅保存恢复所需的用户选择,不包含连接配置或凭据。
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,277 @@
use super::*;
use serde_json::json;
fn context() -> Context {
Context {
route: Route::from_identity(Some("123".into()), Some("https://example.com/api")),
editor_session_id: Uuid::new_v4().to_string(),
client_version: "0.1.67".into(),
}
}
fn run() -> RunFinished {
RunFinished {
agent_type: AgentType::GameAgent,
run_source: RunSource::UserSubmit,
duration_ms: None,
retry_index: 0,
output_change_detected: None,
revision_id: None,
end_reason: RunEndReason::Finished,
}
}
fn samples() -> Vec<Event> {
let mut failed = run();
failed.end_reason = RunEndReason::Failed;
let values = vec![
(
EventData::EditorSessionStart(SessionStart {
entry_source: EntrySource::DirectLaunch,
first_project_id: None,
}),
None,
Source::Editor,
),
(
EventData::EditorSessionEnd(SessionEnd {
end_reason: SessionEndReason::UserExit,
session_duration_ms: Some(10),
last_project_id: None,
}),
None,
Source::Editor,
),
(
EventData::EditorFocusStart(FocusStart {
focus_interval_id: Uuid::new_v4().to_string(),
focus_reason: FocusReason::InitialFocus,
active_project_id: None,
}),
None,
Source::Editor,
),
(
EventData::EditorFocusEnd(FocusEnd {
focus_interval_id: Uuid::new_v4().to_string(),
blur_reason: BlurReason::WindowBlur,
focus_duration_ms: None,
active_project_id: None,
}),
None,
Source::Editor,
),
(
EventData::ProjectCreateSuccess(ProjectCreated {
creation_source: CreationSource::HomeGame,
project_template_id: None,
}),
Some("project".into()),
Source::Editor,
),
(
EventData::ProjectOpen(ProjectOpened {
open_source: OpenSource::Recent,
is_first_open: None,
}),
Some("project".into()),
Source::Editor,
),
(
EventData::CreativeTaskSubmit(EmptyProperties {}),
Some("project".into()),
Source::Direct,
),
(
EventData::AgentRunCompleted(run()),
Some("project".into()),
Source::Direct,
),
(
EventData::AgentRunFailed(failed),
Some("project".into()),
Source::Direct,
),
(
EventData::ProjectRevisionCreated(RevisionCreated {
revision_id: "42".into(),
revision_source: RevisionSource::UiEditor,
change_kind: ChangeKind::Ui,
files_changed_count: Some(1),
}),
Some("project".into()),
Source::UiEditor,
),
(
EventData::PreviewReady(PreviewReady {
preview_source: PreviewSource::User,
preview_version: "42".into(),
ready_duration_ms: None,
}),
Some("project".into()),
Source::Editor,
),
(
EventData::ProjectSave(ProjectSaved {
save_source: SaveSource::Checkpoint,
revision_id: Some("42".into()),
}),
Some("project".into()),
Source::Manual,
),
];
values
.into_iter()
.map(|(data, project, source)| {
let identity = matches!(
data,
EventData::AgentRunCompleted(_) | EventData::AgentRunFailed(_)
)
.then(|| RunIdentity {
run_id: Uuid::new_v4().to_string(),
turn_id: Some("real-turn".into()),
error_code: matches!(data, EventData::AgentRunFailed(_))
.then_some(ErrorCode::RuntimeErrorUnclassified),
});
context().capture(data, project, source, identity).unwrap()
})
.collect()
}
#[test]
fn all_events_round_trip_with_explicit_nullable_envelopes() {
let events = samples();
assert_eq!(events.len(), 12);
for event in events {
let value = serde_json::to_value(&event).unwrap();
assert_eq!(value.as_object().unwrap().len(), 15);
let decoded: Event = serde_json::from_value(value).unwrap();
decoded.validate().unwrap();
assert_eq!(decoded.event_id, event.event_id);
assert_eq!(decoded.event_time, event.event_time);
}
}
#[test]
fn missing_top_level_fields_and_extra_fields_are_rejected() {
for event in samples() {
let value = serde_json::to_value(event).unwrap();
for field in value.as_object().unwrap().keys() {
let mut missing = value.clone();
missing.as_object_mut().unwrap().remove(field);
assert!(
serde_json::from_value::<Event>(missing).is_err(),
"missing {field}"
);
}
let mut extra = value;
extra["prompt"] = json!("must not be accepted");
assert!(serde_json::from_value::<Event>(extra).is_err());
}
}
#[test]
fn event_properties_are_closed_and_nullable_fields_are_required() {
for event in samples() {
let mut extra = event.clone();
extra.properties["access_token"] = json!("never collected");
assert!(extra.validate().is_err());
for (field, value) in event.properties.as_object().unwrap() {
if !value.is_null() {
continue;
}
let mut missing = event.clone();
missing.properties.as_object_mut().unwrap().remove(field);
assert!(
missing.validate().is_err(),
"{} missing {field}",
event.event_name
);
}
}
let mut nested = samples().pop().unwrap();
nested.properties["pending_approval"] =
json!({"request_id":"a", "phase":"concept", "question":"private"});
assert!(nested.validate().is_err());
nested.properties["pending_approval"] = Value::Null;
nested.properties["pending_clarification"] = json!({"request_id":"a", "options":[]});
assert!(nested.validate().is_err());
}
#[test]
fn inconsistent_identity_status_and_numbers_are_rejected() {
let event = samples()
.into_iter()
.find(|e| e.event_name == "agent_run_completed")
.unwrap();
let mutate: Vec<(&str, Value)> = vec![
("creative_task_id", json!("other-project")),
("project_id", Value::Null),
("agent_run_id", json!("project")),
("status", json!("failed")),
("source", json!("design_agent")),
("error_code", json!("runtime_failed")),
("schema_version", json!(2)),
("event_time", json!("2026-09-21T00:00:00Z")),
("event_id", json!("not-a-uuid")),
("user_id", json!("")),
];
for (key, value) in mutate {
let mut raw = serde_json::to_value(&event).unwrap();
raw[key] = value;
let invalid: Event = serde_json::from_value(raw).unwrap();
assert!(invalid.validate().is_err(), "{key}");
}
for (key, value) in [
("duration_ms", json!(-1)),
("retry_index", json!(MAX_SAFE_INTEGER + 1)),
("duration_ms", json!(1.5)),
] {
let mut invalid = event.clone();
invalid.properties[key] = value;
assert!(invalid.validate().is_err());
}
}
#[test]
fn frozen_context_and_origin_do_not_inherit_new_account() {
let a = context();
let mut b = a.clone();
b.route = Route::from_identity(
Some("456".into()),
Some("https://other.example.com/api?token=private"),
);
let event = a
.capture(
EventData::CreativeTaskSubmit(EmptyProperties {}),
Some("p".into()),
Source::Direct,
None,
)
.unwrap();
assert_eq!(event.user_id.as_deref(), Some("123"));
assert_eq!(
a.route.destination_origin.as_deref(),
Some("https://example.com")
);
assert_eq!(
b.route.destination_origin.as_deref(),
Some("https://other.example.com")
);
assert_eq!(
Route::from_identity(None, Some("file:///private")).destination_origin,
None
);
assert_eq!(
Route::from_identity(None, Some("https://user:secret@example.com")).destination_origin,
None
);
assert!(
Route {
user_id: None,
destination_origin: Some("https://example.com/api".into())
}
.validate()
== false
);
}

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