Compare commits
143 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d9aa686e9a | |||
| 050909a0b7 | |||
| ee2d505eb3 | |||
| 8473877bc3 | |||
| cda7724cae | |||
| e18c6ee502 | |||
| 292e42cf5e | |||
| 669a08c7c2 | |||
| 01a9941cbc | |||
| fd0a37969e | |||
| 7dd7bdd3c7 | |||
| 5049c27f3d | |||
| 129e2234f3 | |||
| 2916e112df | |||
| c5e0c6a14d | |||
| e2809b83c0 | |||
| 058d851a69 | |||
| 444520b464 | |||
| 326729a5ac | |||
| 14740e0ef6 | |||
| 48a5bf06d0 | |||
| 9263d79d42 | |||
| 6d8164d37a | |||
| 66b8c2bed0 | |||
| cb136fb445 | |||
| d1478e3859 | |||
| b41d156edd | |||
| 4363a6219a | |||
| 0ec0a13cbc | |||
| c96d7fe847 | |||
| 8131a70a2f | |||
| 73f34ecbc9 | |||
| 25fe98836f | |||
| b48c34d6b4 | |||
| 5bdabdc7f7 | |||
| 9b1c99f37b | |||
| 9a62941779 | |||
| 7987fed204 | |||
| 02df355281 | |||
| 5d47ef9347 | |||
| fc8a674e91 | |||
| f1d0aed7dd | |||
| 9f4f7341c9 | |||
| 928aee403a | |||
| d24347d78e | |||
| 0ae73f7e0e | |||
| fb9c1d0e29 | |||
| b71d18b166 | |||
| 4bcf0cdb86 | |||
| 104656aa99 | |||
| de8bb9d373 | |||
| f9750bde0b | |||
| 93002f1bd2 | |||
| e7b067e977 | |||
| 10e63ecb6b | |||
| 61fe4c48af | |||
| 9fc55a5cac | |||
| 2691ca8aca | |||
| 14066eea11 | |||
| 609b24c779 | |||
| 3af0d3d90b | |||
| c0d6531e92 | |||
| a4d9418cd3 | |||
| 7d842f0b15 | |||
| d6c7a04a1c | |||
| 13dfe07f94 | |||
| 03d26d46af | |||
| c0fcb8553d | |||
| 25ee11771c | |||
| aebbd3bb99 | |||
| 3e426a1006 | |||
| f732555a44 | |||
| d4af6802fb | |||
| 294b7461ff | |||
| e9af6c0741 | |||
| 0f8ac57593 | |||
| 694a3c6f6c | |||
| 6724f8b707 | |||
| 03d5cde838 | |||
| a87e19444b | |||
| ae426df240 | |||
| 33cd943ff9 | |||
| 7193bd28c2 | |||
| e8467c2870 | |||
| 0982b952cb | |||
| eb4256f883 | |||
| 63c924dba0 | |||
| 02ef84d9b6 | |||
| 8a828a43ba | |||
| 29e554a30c | |||
| ac08ccbe39 | |||
| e809cdd850 | |||
| a8799adf5d | |||
| 4edeadae7d | |||
| 931be509f1 | |||
| dd8f48bad9 | |||
| d012392779 | |||
| 2182b075b8 | |||
| a11fd709fa | |||
| aa1a1a63ed | |||
| 09ea0a5e76 | |||
| 36036eed7b | |||
| e105e34f83 | |||
| 8f6458ffe2 | |||
| f90e114bcb | |||
| 7790a181c4 | |||
| e0108f7d57 | |||
| 93306952d9 | |||
| ddf8e2c213 | |||
| 6db1079610 | |||
| 699f321651 | |||
| 49b6451548 | |||
| 188614d5d2 | |||
| 4c7fd968dd | |||
| c48f0627cf | |||
| 092bca7444 | |||
| b0054fd51c | |||
| 0c4b1c34d9 | |||
| 3c1059aa39 | |||
| 1392303f1c | |||
| 12512f26b2 | |||
| 800da43a8d | |||
| b703f0c374 | |||
| b87d9358e5 | |||
| f4996d7ac2 | |||
| 09ef92420f | |||
| 091938ed59 | |||
| ddb8b9e8ba | |||
| 230e4d49f2 | |||
| 6c209b115a | |||
| b631125a16 | |||
| 771ff88440 | |||
| 950209f038 | |||
| 42a5929a73 | |||
| eda9da4d8d | |||
| 35be25dbd6 | |||
| da9ee65119 | |||
| 527706b84e | |||
| a4beb53dc6 | |||
| 25314d9a4a | |||
| 5be4a51ac1 | |||
| 37b355a814 | |||
| 1c4009ea3b |
@@ -129,12 +129,19 @@ jobs:
|
||||
sleep $((attempt * 2))
|
||||
done
|
||||
|
||||
- name: Prepare isolated Rust compilation cache
|
||||
run: bash scripts/ci-rust-cache.sh prepare
|
||||
|
||||
- name: Run AI game creator shell Rust shard 3/4
|
||||
run: npm run check:native-shells:agc-rust-shard-3
|
||||
|
||||
- name: Run AI game creator shell Rust shard 4/4
|
||||
run: npm run check:native-shells:agc-rust-shard-4
|
||||
|
||||
- name: Report isolated Rust compilation cache
|
||||
if: always()
|
||||
run: bash scripts/ci-rust-cache.sh report
|
||||
|
||||
# agent-run smoke 会 spawn `cargo run`(走壳自己的 manifest),同样不装 npm 依赖,
|
||||
# 单独一个 job,免得把已经压到 4 分钟级的片 job 拖长。
|
||||
ai-game-creator-shell-rust-smoke:
|
||||
@@ -167,9 +174,16 @@ jobs:
|
||||
sleep $((attempt * 2))
|
||||
done
|
||||
|
||||
- name: Prepare isolated Rust compilation cache
|
||||
run: bash scripts/ci-rust-cache.sh prepare
|
||||
|
||||
- name: Run AI game creator shell agent-run smoke
|
||||
run: npm run check:native-shells:agc-rust-smoke
|
||||
|
||||
- name: Report isolated Rust compilation cache
|
||||
if: always()
|
||||
run: bash scripts/ci-rust-cache.sh report
|
||||
|
||||
# AGC 壳依赖的共享 / 平台和编辑器插件 crate 各自预热独立 manifest,再运行对应测试。
|
||||
ai-game-creator-shell-rust-crates:
|
||||
name: AI game creator shell Rust crates
|
||||
@@ -268,9 +282,16 @@ jobs:
|
||||
sleep $((attempt * 2))
|
||||
done
|
||||
|
||||
- name: Prepare isolated Rust compilation cache
|
||||
run: bash scripts/ci-rust-cache.sh prepare
|
||||
|
||||
- name: Run AI game creator shell shared crate gates
|
||||
run: npm run check:native-shells:agc-rust-crates
|
||||
|
||||
- name: Report isolated Rust compilation cache
|
||||
if: always()
|
||||
run: bash scripts/ci-rust-cache.sh report
|
||||
|
||||
backend-tests:
|
||||
name: Backend tests
|
||||
runs-on: genarrative-ci
|
||||
@@ -341,6 +362,9 @@ jobs:
|
||||
- name: Check server-rs boundaries
|
||||
run: npm run check:server-rs-ddd
|
||||
|
||||
- name: Prepare isolated Rust compilation cache
|
||||
run: bash scripts/ci-rust-cache.sh prepare
|
||||
|
||||
- name: Run server-rs workspace tests
|
||||
run: cargo test --locked --workspace --exclude spacetime-module --no-fail-fast --manifest-path server-rs/Cargo.toml
|
||||
|
||||
@@ -353,6 +377,10 @@ jobs:
|
||||
- name: Check SpacetimeDB module
|
||||
run: cargo check --locked -p spacetime-module --manifest-path server-rs/Cargo.toml
|
||||
|
||||
- name: Report isolated Rust compilation cache
|
||||
if: always()
|
||||
run: bash scripts/ci-rust-cache.sh report
|
||||
|
||||
# 客户端的壳级与契约门禁:静态契约断言、H5 / 微信 / 移动 / 桌面壳运行时门禁,
|
||||
# 以及依赖发布产物的构建 smoke。
|
||||
native-shell-tests:
|
||||
@@ -395,15 +423,26 @@ jobs:
|
||||
- name: Run native shell contract gates
|
||||
run: npm run check:native-shells:contract
|
||||
|
||||
- name: Prepare isolated Rust compilation cache
|
||||
run: bash scripts/ci-rust-cache.sh prepare
|
||||
|
||||
- name: Run native shell gates
|
||||
run: npm run check:native-shells:shells
|
||||
|
||||
- name: Run native shell release build smoke
|
||||
# 发布构建有独立 profile/features 和资源 staging,不消费测试对象快照。
|
||||
env:
|
||||
RUSTC_WRAPPER: ''
|
||||
CARGO_BUILD_RUSTC_WRAPPER: ''
|
||||
run: npm run check:native-shells:release
|
||||
|
||||
- name: Ensure native lockfiles are unchanged
|
||||
run: git diff --exit-code -- apps/desktop-shell/src-tauri/Cargo.lock apps/ai-game-creator-shell/src-tauri/Cargo.lock
|
||||
|
||||
- name: Report isolated Rust compilation cache
|
||||
if: always()
|
||||
run: bash scripts/ci-rust-cache.sh report
|
||||
|
||||
frontend-tests:
|
||||
name: Frontend tests
|
||||
runs-on: genarrative-ci
|
||||
|
||||
+13
@@ -96,3 +96,16 @@ scripts/loadtest/data/*.local.json
|
||||
scripts/loadtest/data/k6-*.log
|
||||
scripts/loadtest/data/k6-*summary*.md
|
||||
scripts/loadtest/data/latest-*-prefix.txt
|
||||
|
||||
# 仓库根目录下手工下载的 3D 样例模型(Tripo 联调 / 查看器排查用),不入库;
|
||||
# glTF / FBX 多文件产物会带着 .bin 缓冲与贴图落在同一目录,一并忽略
|
||||
/*.[gG][lL][bB]
|
||||
/*.[gG][lL][tT][fF]
|
||||
/*.[fF][bB][xX]
|
||||
/*.[bB][iI][nN]
|
||||
/*.[pP][nN][gG]
|
||||
/*.[jJ][pP][gG]
|
||||
/*.[jJ][pP][eE][gG]
|
||||
/*.[wW][eE][bB][pP]
|
||||
/*.[kK][tT][xX]2
|
||||
/*.[tT][gG][aA]
|
||||
|
||||
+64
@@ -50,6 +50,70 @@ _Avoid_: 为每个玩法单独发明素材流水线、把系列素材建模成
|
||||
|
||||
## Language
|
||||
|
||||
### 3D Model Generation
|
||||
|
||||
**3D 模型生成操作**:
|
||||
由文本提示或平台图片引用驱动、可异步查询并最终产生模型文件的 provider-neutral 操作;操作状态和产品资源结果分开建模。
|
||||
_Avoid_: 直接把第三方 provider task 当作产品资源、把模型 URL 当作永久资源地址
|
||||
|
||||
**Provider task**:
|
||||
第三方 3D 生成平台返回的任务引用与任务快照,只存在于 provider adapter 的 Rust 边界内;它不是 Genarrative 的内部操作 ID。
|
||||
_Avoid_: 把 Tripo task ID 当作稳定产品 operationId、让 SDK 类型穿透 api-server
|
||||
|
||||
**临时 provider URL**:
|
||||
第三方任务完成后返回的短时模型下载地址,必须通过显式下载流程转换为本地或持久化资源,不能直接当作长期资产地址。
|
||||
_Avoid_: 永久 OSS URL、资源 ID
|
||||
|
||||
**模型 artifact**:
|
||||
由 text-to-model、image-to-model 或 multiview-to-model 任务产生、待应用层登记和持久化的模型文件结果;provider adapter 只提供可下载结果,不负责生成 resourceId 或 assetId。
|
||||
_Avoid_: 仅 UI 中展示的模型链接、没有来源任务的静态文件
|
||||
|
||||
**Tripo API facade**:
|
||||
面向站内已认证调用方的全新 Tripo 3D 生成 API 上下文,负责把 API operation、用户归属、异步状态、产物持久化和正式资源引用连接起来;它独立于历史 Hyper3D adapter,不复用其路由、契约或实现。
|
||||
_Avoid_: 把 Tripo API 当成 Hyper3D 的 provider 替换、让 Hyper3D 路由悄悄切换到 Tripo
|
||||
|
||||
**Tripo operation**:
|
||||
Genarrative 为一次 Tripo 生成请求分配的内部异步操作标识;客户端只看到 operationId,Tripo provider task 只作为服务端 checkpoint 保存,二者不是同一个 ID。
|
||||
_Avoid_: 将 Tripo taskId 直接当公开 operationId、把 SDK task 状态模型穿透到 API
|
||||
|
||||
**模型产物持久化**:
|
||||
Tripo 任务完成后,服务端下载模型与预览、写入受控对象存储并登记资源元数据,成功后才把 Tripo operation 置为 completed;本期按完整字节写入,流式上传是后续目标。
|
||||
_Avoid_: 返回临时签名 URL作为永久资源、在 API handler 中把完整模型读成 Vec<u8>
|
||||
|
||||
**模型生成落点**:
|
||||
3D 生成结果要进入的平台容器,调用方必须在工程资源与素材库资产之间显式二选一;工程分支可附带画布占位框回填,素材库分支必须给出目录与标签。
|
||||
_Avoid_: 两个都不给就默认落素材库、把落点混进 provider 请求参数
|
||||
|
||||
**模型输入资源引用**:
|
||||
image-to-model 请求对平台已有图片的明确 tagged 引用,可指向项目资源或素材库资产;服务端负责 owner 校验和 OSS 对象解析,不接受把多种来源塞进裸 `input` 字符串。
|
||||
_Avoid_: 任意远程 URL、data URL、未区分语义的 resourceId/assetId 字符串
|
||||
|
||||
**3D 模型生成工具**:
|
||||
图片画布工程里从底部工具栏打开、给出「文生 3D 模型」与「图生 3D 模型」两个子项的画布生成工具;提交后按画布既有生成链路的占位框、任务侧栏与失败退款口径推进,产物落成 **3D 模型资源**。
|
||||
_Avoid_: 独立 3D 生成页、把 3D 生成做成玩法类型或作品、把 3D 生成接进画布 Agent 对话
|
||||
|
||||
**3D 生成定价**:
|
||||
3D 模型生成工具在提交前展示的泥点价格,来自编辑器生成定价查询的 `model3d` 段;该段缺失即入口不可提交,前端不内置兜底数值。
|
||||
_Avoid_: 前端写死 3D 价格、借用图片模型的兜底定价配置、按 0 元放行
|
||||
|
||||
### 3D 模型资源预览
|
||||
|
||||
**3D 模型资源**:
|
||||
类别为 `model3d` 的平台资源 / 素材,一个资源同时持有模型本体与模型预览图两个对象;客户端默认消费预览图,模型本体只在 3D 查看器里加载。
|
||||
_Avoid_: 只有模型文件的资源、把模型本体当图片素材、为模型新增并列媒体类型
|
||||
|
||||
**模型预览图**:
|
||||
3D 模型资源的静态代表图,落在资源投影的 `imageSrc` 与 `thumbnailSrc` 上,供画布卡片、素材缩略图、后台列表和精选卡片使用。
|
||||
_Avoid_: 用模型本体充当预览、按需实时渲染缩略图、把预览图当模型真相
|
||||
|
||||
**模型本体**:
|
||||
3D 模型资源里的可下载模型文件,只由资源的 `objectKey` 指向,格式以对象内容类型为准。
|
||||
_Avoid_: 把模型本体当图片地址、用预览图字段承载模型文件、按文件扩展名推断格式
|
||||
|
||||
**3D 查看器预览**:
|
||||
在资源上打开模态把模型本体交给 3D 查看器渲染的只读能力,不修改资源、不产出新资源,也不是画布图层本身的编辑操作。
|
||||
_Avoid_: 在画布里内联渲染模型、把预览当编辑、为预览产出新资源
|
||||
|
||||
### Puzzle Clear
|
||||
|
||||
**拼消消**:
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
getAdminFeatureGateConfig,
|
||||
getAdminUserDetail,
|
||||
importAdminAgcTemplates,
|
||||
listAdminAgcTrackingEvents,
|
||||
listAdminRechargeOrders,
|
||||
reconcileAdminUserConsumption,
|
||||
resolveAdminRechargeRefundManualReview,
|
||||
@@ -22,30 +21,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(
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type {
|
||||
AdminAccountListResponse,
|
||||
AdminAgcTemplateLibraryResponse,
|
||||
AdminAgcTrackingEventListResponse,
|
||||
AdminAgcTrackingEventQuery,
|
||||
AdminConfirmEditorShowcaseCampaignImageUploadRequest,
|
||||
AdminCreateAccountRequest,
|
||||
AdminCreateAccountResponse,
|
||||
@@ -408,31 +406,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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
@@ -233,12 +232,6 @@ export function AdminApp() {
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{activeRouteId === 'agc-tracking' ? (
|
||||
<AdminAgcTrackingPage
|
||||
token={token}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{activeRouteId === 'error-reports' ? (
|
||||
<AdminErrorReportsPage
|
||||
token={token}
|
||||
|
||||
@@ -39,7 +39,6 @@ const routeIcons = {
|
||||
tables: Database,
|
||||
debug: Bug,
|
||||
tracking: Table2,
|
||||
'agc-tracking': Table2,
|
||||
'error-reports': Bug,
|
||||
'gray-release': GitBranch,
|
||||
redeem: TicketPercent,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -5,7 +5,6 @@ export type AdminRouteId =
|
||||
| 'tables'
|
||||
| 'debug'
|
||||
| 'tracking'
|
||||
| 'agc-tracking'
|
||||
| 'error-reports'
|
||||
| 'gray-release'
|
||||
| 'redeem'
|
||||
@@ -41,7 +40,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>
|
||||
);
|
||||
}
|
||||
@@ -516,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_]*::)*([a-z][a-z0-9_]*)\b/g),
|
||||
match[1].matchAll(/\b([a-z][a-z0-9_]+)\b/g),
|
||||
([, command]) => command,
|
||||
);
|
||||
}
|
||||
@@ -549,12 +549,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', {});
|
||||
|
||||
+3
-4
@@ -6312,8 +6312,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "ts-rs"
|
||||
version = "12.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8"
|
||||
source = "git+https://github.com/Aleph-Alpha/ts-rs?rev=fd8679ef20f866f747c046b30bd29c15e3d7a003#fd8679ef20f866f747c046b30bd29c15e3d7a003"
|
||||
dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"ts-rs-macros 12.0.1",
|
||||
@@ -6334,9 +6333,9 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "ts-rs-macros"
|
||||
version = "12.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa"
|
||||
source = "git+https://github.com/Aleph-Alpha/ts-rs?rev=fd8679ef20f866f747c046b30bd29c15e3d7a003#fd8679ef20f866f747c046b30bd29c15e3d7a003"
|
||||
dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
|
||||
@@ -29,7 +29,10 @@ tauri-build = { version = "2.6.2", features = [] }
|
||||
[dependencies]
|
||||
codex-patch-parser = { path = "vendor/codex-patch-parser" }
|
||||
chrono = { version = "0.4", default-features = false, features = ["std"] }
|
||||
ts-rs = "12.0.1"
|
||||
# 与 server-rs 同一口径:已发布的 ts-rs 12.0.1 解析不了 enum 级 `serde(deny_unknown_fields)`
|
||||
# (见 agent/direct_codex_user_item/model.rs),上游修复 #491 未发版,故固定到该 commit;
|
||||
# 等 crates.io 发布 > 12.0.1 后改回 registry 版本。不要改用 `no-serde-warnings` 关告警。
|
||||
ts-rs = { git = "https://github.com/Aleph-Alpha/ts-rs", rev = "fd8679ef20f866f747c046b30bd29c15e3d7a003" }
|
||||
typed_floats = { version = "1.0.7", features = ["serde"] }
|
||||
nalgebra = { version = "0.35.0", features = ["serde-serialize"] }
|
||||
agent-runtime-core = { path = "../../../server-rs/crates/agent-runtime-core" }
|
||||
@@ -66,7 +69,7 @@ platform-agent = { path = "../../../server-rs/crates/platform-agent" }
|
||||
portable-pty = "0.9"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "native-tls", "stream"] }
|
||||
regex = "1"
|
||||
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false, features = ["ts-bindings"] }
|
||||
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false }
|
||||
tauri = { version = "2.11.2", features = [] }
|
||||
tauri-plugin-dialog = "2.7.1"
|
||||
tauri-plugin-http = { version = "2.5.9", default-features = false, features = ["charset", "cookies", "http2", "rustls-tls"] }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -93,17 +93,10 @@ 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 {
|
||||
ledger: ExecutionLedger,
|
||||
analytics_capture: Option<(
|
||||
crate::analytics::contract::Context,
|
||||
crate::analytics::store::AnalyticsWriter,
|
||||
)>,
|
||||
analytics_output_revision: Option<u64>,
|
||||
started: Instant,
|
||||
initial_elapsed_ms: u64,
|
||||
lease_started: BTreeMap<String, Instant>,
|
||||
@@ -160,8 +153,6 @@ 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>,
|
||||
@@ -225,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)?;
|
||||
@@ -420,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());
|
||||
@@ -428,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
|
||||
@@ -483,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
|
||||
@@ -562,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(),
|
||||
@@ -588,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 {
|
||||
// 只继承旧项目账本的消费量,绝不把可编辑的旧成功回执提升为宿主证据。
|
||||
@@ -601,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());
|
||||
@@ -659,13 +614,10 @@ 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,
|
||||
data: Mutex::new(SessionData {
|
||||
ledger,
|
||||
analytics_capture: None,
|
||||
analytics_output_revision: None,
|
||||
started: Instant::now(),
|
||||
initial_elapsed_ms,
|
||||
lease_started: BTreeMap::new(),
|
||||
@@ -786,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() {
|
||||
@@ -817,74 +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 data) = self.data.try_lock() else {
|
||||
return;
|
||||
};
|
||||
data.analytics_capture = capture.and_then(|(mut context, writer)| {
|
||||
// 恢复或账号切换后仍归属于真实受理的原 run。
|
||||
context.route = data.ledger.analytics_run.as_ref()?.context.route.clone();
|
||||
Some((context, writer))
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn analytics_capture(
|
||||
&self,
|
||||
) -> Option<(
|
||||
crate::analytics::contract::Context,
|
||||
crate::analytics::store::AnalyticsWriter,
|
||||
)> {
|
||||
self.data.try_lock().ok()?.analytics_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 data) = self.data.try_lock() else {
|
||||
return;
|
||||
};
|
||||
if data.ledger.analytics_run.is_none() {
|
||||
return;
|
||||
}
|
||||
data.analytics_output_revision =
|
||||
Some(data.analytics_output_revision.unwrap_or(0).max(revision));
|
||||
let capture = data.analytics_capture.clone();
|
||||
let project_id = data.ledger.project_id.clone();
|
||||
drop(data);
|
||||
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.data
|
||||
.try_lock()
|
||||
.ok()?
|
||||
.analytics_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,74 +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 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());
|
||||
}
|
||||
|
||||
fn fixture(config: DirectValidationConfig) -> (tempfile::TempDir, Arc<ExecutionSession>) {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().join("project");
|
||||
@@ -82,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();
|
||||
@@ -229,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!(
|
||||
(
|
||||
@@ -349,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);
|
||||
|
||||
@@ -229,40 +229,6 @@ 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,
|
||||
@@ -353,13 +319,6 @@ 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() },
|
||||
@@ -392,47 +351,6 @@ 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");
|
||||
@@ -497,20 +415,15 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn bundled_patch_roundtrip_preserves_partial_failure_and_rejects_closed_turn() {
|
||||
let (temp, root) = project();
|
||||
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(
|
||||
let session = direct_execution::open_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();
|
||||
@@ -586,29 +499,6 @@ 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,44 +4243,7 @@ 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,
|
||||
@@ -4298,7 +4261,6 @@ pub(crate) async fn run_direct_browser_evidence_with_analytics_at(
|
||||
cancellation,
|
||||
)
|
||||
.await;
|
||||
drop(_analytics_lease);
|
||||
let _ = stop_sender.send(());
|
||||
validation
|
||||
}
|
||||
@@ -4819,8 +4781,6 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type(
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -4832,11 +4792,6 @@ 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());
|
||||
@@ -4859,8 +4814,6 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter(
|
||||
turn_emitter,
|
||||
audit,
|
||||
direct_user_item,
|
||||
capture,
|
||||
analytics_attempt_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -5061,11 +5014,6 @@ 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,
|
||||
@@ -5087,49 +5035,16 @@ async fn run_direct_game_creator_turn_inner(
|
||||
DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error)
|
||||
})?
|
||||
.validation;
|
||||
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_guard =
|
||||
super::direct_execution::begin(root, prompt, requires_contract, execution_config)
|
||||
.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);
|
||||
@@ -5419,118 +5334,6 @@ 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,9 +33,7 @@ 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)?;
|
||||
@@ -64,8 +62,6 @@ 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,15 +1600,6 @@ 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,
|
||||
@@ -1650,11 +1641,6 @@ 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)?;
|
||||
@@ -1667,9 +1653,6 @@ 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!(
|
||||
@@ -3490,82 +3473,6 @@ 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]
|
||||
@@ -4355,293 +4262,6 @@ 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,15 +453,12 @@ async fn run_browser_with_budget(
|
||||
sequence,
|
||||
),
|
||||
)?;
|
||||
let evidence = super::direct_runtime::run_direct_browser_evidence_with_analytics_at(
|
||||
let evidence = super::direct_runtime::run_direct_browser_evidence_with_cancellation_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,9 +126,6 @@ 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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user