From 1e186369c97b71105c61d15ea63887144b6334b5 Mon Sep 17 00:00:00 2001 From: Linghong Date: Wed, 23 Sep 2026 00:09:12 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF=E5=9F=8B=E7=82=B9?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=20(#446)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewed-on: http://genarrative-station/git/GenarrativeAI/Genarrative/pulls/446 Co-authored-by: Linghong Co-committed-by: Linghong --- apps/admin-web/src/api/adminApiClient.test.ts | 25 + apps/admin-web/src/api/adminApiClient.ts | 27 + apps/admin-web/src/api/adminApiTypes.ts | 38 + apps/admin-web/src/app/AdminApp.tsx | 7 + apps/admin-web/src/app/AdminShell.tsx | 1 + apps/admin-web/src/app/adminRoutes.test.ts | 16 + apps/admin-web/src/app/adminRoutes.ts | 2 + .../src/pages/AdminAgcTrackingPage.test.tsx | 129 ++ .../src/pages/AdminAgcTrackingPage.tsx | 379 +++++ .../scripts/check-config.mjs | 9 +- .../src-tauri/src/agent/design_runtime.rs | 863 +++++++++++- .../src-tauri/src/agent/direct_execution.rs | 135 +- .../src/agent/direct_execution/tests.rs | 190 +++ .../src-tauri/src/agent/direct_patch.rs | 112 +- .../src-tauri/src/agent/direct_runtime/mod.rs | 209 ++- .../src/agent/direct_runtime/user_input.rs | 4 + .../src-tauri/src/agent/direct_tool_bridge.rs | 380 +++++ .../src-tauri/src/agent/direct_validation.rs | 5 +- .../agent/runtime_protocol/design_session.rs | 3 + .../src-tauri/src/analytics/contract.rs | 529 +++++++ .../src-tauri/src/analytics/contract_tests.rs | 277 ++++ .../src-tauri/src/analytics/design.rs | 55 + .../src-tauri/src/analytics/goal.rs | 285 ++++ .../src-tauri/src/analytics/gui.rs | 648 +++++++++ .../src-tauri/src/analytics/gui_tests.rs | 189 +++ .../src-tauri/src/analytics/mod.rs | 11 + .../src-tauri/src/analytics/preview.rs | 146 ++ .../src-tauri/src/analytics/project.rs | 104 ++ .../src-tauri/src/analytics/run.rs | 400 ++++++ .../src-tauri/src/analytics/session.rs | 248 ++++ .../src-tauri/src/analytics/session_tests.rs | 101 ++ .../src-tauri/src/analytics/store.rs | 1027 ++++++++++++++ .../src-tauri/src/analytics/store_tests.rs | 1245 +++++++++++++++++ .../src-tauri/src/analytics/upload.rs | 228 +++ .../src-tauri/src/commands.rs | 147 +- .../src/commands_manual_analytics_tests.rs | 195 +++ .../src-tauri/src/main.rs | 65 +- .../src-tauri/src/platform_session.rs | 67 +- .../src-tauri/src/preview.rs | 49 +- .../src-tauri/src/preview_analytics_tests.rs | 297 ++++ .../src-tauri/src/template_library.rs | 15 +- .../src-tauri/src/tests/sessions.rs | 10 +- apps/ai-game-creator-shell/src/App.tsx | 2 +- .../features/app-shell/WorkspaceLauncher.tsx | 28 +- .../app-shell/useHomeProjectCreation.ts | 227 ++- .../template-library/useTemplateLibrary.ts | 8 +- .../src/services/clientAnalytics.ts | 134 ++ .../useDirectProjectChatController.ts | 29 +- .../src/view/ui-editor/useUiEditorPage.ts | 19 +- .../tests/appSurface/chat-composer.suite.ts | 51 + .../tests/appSurface/home.suite.ts | 5 + .../tests/clientAnalytics.test.tsx | 332 +++++ .../tests/directRunAnalytics.test.ts | 91 ++ .../tests/homeWebPreflight.test.tsx | 2 +- ...runVersionSwitchEventSubscription.test.tsx | 1 + .../tests/sessionPreview.test.ts | 2 +- .../tests/uiEditorPage.test.ts | 125 ++ docs/README.md | 2 + .../shared-memory/decision-log.md | 48 + ...案】客户端本地埋点与主站入库契约-2026-09-21.md | 735 ++++++++++ ...来源】GameAgent埋点设计原始方案-2026-09-05.md | 431 ++++++ ...作规范】文档生命周期与现状索引-2026-09-12.md | 2 + ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 8 + scripts/agc-analytics-smoke.mjs | 410 ++++++ server-rs/Cargo.lock | 3 + server-rs/crates/api-server/src/admin.rs | 27 + .../crates/api-server/src/agc_analytics.rs | 226 +++ server-rs/crates/api-server/src/app.rs | 229 +++ server-rs/crates/api-server/src/config.rs | 6 + server-rs/crates/api-server/src/main.rs | 1 + server-rs/crates/api-server/src/tracking.rs | 2 +- server-rs/crates/module-runtime/Cargo.toml | 4 + .../module-runtime/src/agc_analytics.rs | 428 ++++++ server-rs/crates/module-runtime/src/lib.rs | 1 + .../crates/shared-contracts/src/admin.rs | 47 +- .../shared-contracts/src/agc_analytics.rs | 282 ++++ server-rs/crates/shared-contracts/src/lib.rs | 1 + .../crates/spacetime-client/src/active.rs | 1 + .../spacetime-client/src/agc_analytics.rs | 64 + .../spacetime-client/src/module_bindings.rs | 41 +- .../agc_tracking_event_table.rs | 228 +++ .../agc_tracking_event_type.rs | 99 ++ .../list_agc_tracking_events_procedure.rs | 56 + .../upload_agc_analytics_batch_procedure.rs | 56 + .../crates/spacetime-module/src/active.rs | 3 + .../spacetime-module/src/agc_analytics.rs | 314 +++++ .../crates/spacetime-module/src/migration.rs | 1 + 87 files changed, 13506 insertions(+), 178 deletions(-) create mode 100644 apps/admin-web/src/pages/AdminAgcTrackingPage.test.tsx create mode 100644 apps/admin-web/src/pages/AdminAgcTrackingPage.tsx create mode 100644 apps/ai-game-creator-shell/src-tauri/src/analytics/contract.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/analytics/contract_tests.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/analytics/design.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/analytics/goal.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/analytics/gui.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/analytics/gui_tests.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/analytics/mod.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/analytics/preview.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/analytics/project.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/analytics/run.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/analytics/session.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/analytics/session_tests.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/analytics/store.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/analytics/store_tests.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/analytics/upload.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/commands_manual_analytics_tests.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/preview_analytics_tests.rs create mode 100644 apps/ai-game-creator-shell/src/services/clientAnalytics.ts create mode 100644 apps/ai-game-creator-shell/tests/clientAnalytics.test.tsx create mode 100644 apps/ai-game-creator-shell/tests/directRunAnalytics.test.ts create mode 100644 docs/technical/【技术方案】客户端本地埋点与主站入库契约-2026-09-21.md create mode 100644 docs/technical/【需求来源】GameAgent埋点设计原始方案-2026-09-05.md create mode 100644 scripts/agc-analytics-smoke.mjs create mode 100644 server-rs/crates/api-server/src/agc_analytics.rs create mode 100644 server-rs/crates/module-runtime/src/agc_analytics.rs create mode 100644 server-rs/crates/shared-contracts/src/agc_analytics.rs create mode 100644 server-rs/crates/spacetime-client/src/agc_analytics.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/agc_tracking_event_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/agc_tracking_event_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/list_agc_tracking_events_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/upload_agc_analytics_batch_procedure.rs create mode 100644 server-rs/crates/spacetime-module/src/agc_analytics.rs diff --git a/apps/admin-web/src/api/adminApiClient.test.ts b/apps/admin-web/src/api/adminApiClient.test.ts index a5c27f641..05cfdc10e 100644 --- a/apps/admin-web/src/api/adminApiClient.test.ts +++ b/apps/admin-web/src/api/adminApiClient.test.ts @@ -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( diff --git a/apps/admin-web/src/api/adminApiClient.ts b/apps/admin-web/src/api/adminApiClient.ts index 4688975e0..da313c68b 100644 --- a/apps/admin-web/src/api/adminApiClient.ts +++ b/apps/admin-web/src/api/adminApiClient.ts @@ -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( + `/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: { diff --git a/apps/admin-web/src/api/adminApiTypes.ts b/apps/admin-web/src/api/adminApiTypes.ts index 161ef7320..71159500c 100644 --- a/apps/admin-web/src/api/adminApiTypes.ts +++ b/apps/admin-web/src/api/adminApiTypes.ts @@ -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; + batchId: string; + receivedAt: string; +} + +export interface AdminAgcTrackingEventListResponse { + entries: AdminAgcTrackingEventEntry[]; + nextCursor: string | null; +} + export interface AdminTrackingEventKeyPayload { eventKey: string; eventTitle: string; diff --git a/apps/admin-web/src/app/AdminApp.tsx b/apps/admin-web/src/app/AdminApp.tsx index e91c6ca8a..73b48d326 100644 --- a/apps/admin-web/src/app/AdminApp.tsx +++ b/apps/admin-web/src/app/AdminApp.tsx @@ -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' ? ( + + ) : null} {activeRouteId === 'error-reports' ? ( { + 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', diff --git a/apps/admin-web/src/app/adminRoutes.ts b/apps/admin-web/src/app/adminRoutes.ts index 11764bbe9..46aa13750 100644 --- a/apps/admin-web/src/app/adminRoutes.ts +++ b/apps/admin-web/src/app/adminRoutes.ts @@ -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' }, diff --git a/apps/admin-web/src/pages/AdminAgcTrackingPage.test.tsx b/apps/admin-web/src/pages/AdminAgcTrackingPage.test.tsx new file mode 100644 index 000000000..8bbfb8dac --- /dev/null +++ b/apps/admin-web/src/pages/AdminAgcTrackingPage.test.tsx @@ -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()), + 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(); + 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(); + await screen.findByText('暂无客户端埋点数据'); + fireEvent.click(screen.getByText('刷新')); + expect((await screen.findByRole('alert')).textContent).toContain( + '无权访问客户端埋点', + ); + fireEvent.click(screen.getByText('刷新')); + await waitFor(() => + expect(unauthorized).toHaveBeenCalledWith('登录状态已失效'), + ); +}); diff --git a/apps/admin-web/src/pages/AdminAgcTrackingPage.tsx b/apps/admin-web/src/pages/AdminAgcTrackingPage.tsx new file mode 100644 index 000000000..239270c42 --- /dev/null +++ b/apps/admin-web/src/pages/AdminAgcTrackingPage.tsx @@ -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 = { + 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 = { + 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({}); + const [cursors, setCursors] = useState>([ + undefined, + ]); + const [page, setPage] = useState(0); + const [refresh, setRefresh] = useState(0); + const [entries, setEntries] = useState([]); + const [nextCursor, setNextCursor] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [detail, setDetail] = useState(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) { + 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 ( +
+
+
+

客户端埋点

+

按发生时间倒序展示

+
+ +
+
+
+ {(['userId', 'projectId'] as const).map((key) => ( + + ))} + + {(['startTime', 'endTime'] as const).map((key) => ( + + ))} +
+
+ +
+ {(['creativeTaskId', 'agentRunId', 'clientVersion'] as const) + .filter((key) => query[key]) + .map((key) => ( +

+ {fieldLabels[key]}:{query[key]}(重新查询可清除) +

+ ))} +
+ {error ? ( +

+ {error} +

+ ) : null} +
+
+ + + + {[ + '入库时间', + '发生时间', + '用户', + '事件名称', + '项目', + '来源', + '结果', + '客户端版本', + '详情', + ].map((label) => ( + + ))} + + + + {entries.map((entry) => ( + + + + + + + + + + + + ))} + +
{label}
{formatTime(entry.receivedAt)}{formatTime(entry.eventTime)} + {entry.userId} + + {eventLabels[entry.eventName] ?? entry.eventName}{entry.projectId ?? '—'}{entry.source}{entry.status ?? '—'}{entry.clientVersion} + +
+
+ {loading ? ( +

加载中…

+ ) : !error && entries.length === 0 ? ( +

暂无客户端埋点数据

+ ) : null} +
+ + 第 {page + 1} 页 + +
+
+ {detail ? ( + setDetail(null)} + className="genarrative-ui" + > +
+ {( + Object.keys(fieldLabels) as Array< + keyof AdminAgcTrackingEventEntry + > + ) + .filter((key) => key !== 'properties') + .map((key) => ( +
+
{fieldLabels[key]}
+
+ {detail[key] == null ? '—' : String(detail[key])} +
+
+ ))} +
+
+ {(['creativeTaskId', 'agentRunId', 'clientVersion'] as const).map( + (key) => + detail[key] ? ( + + ) : null, + )} +
+

事件属性

+
+            {JSON.stringify(detail.properties, null, 2)}
+          
+
+ ) : null} +
+ ); +} diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 967bf3eb5..89edb4cf8 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -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', {}); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs index e3c2c6f9e..bc53e4d73 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs @@ -1,12 +1,13 @@ use super::design_tools::*; use super::*; +use crate::analytics::contract::{ErrorCode, RunEndReason, RunSource, Source}; use futures::FutureExt; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::fs::File; use std::path::{Path, PathBuf}; use std::sync::OnceLock; -use std::time::Duration; +use std::time::{Duration, Instant}; use tauri::Emitter; use uuid::Uuid; @@ -335,6 +336,7 @@ fn begin_design_turn(session: &mut DesignSession, id: &str) { request_index: 0, attempt: 0, model_selection: None, + analytics: None, }); session.last_error = None; session.updated_at = unix_timestamp(); @@ -725,12 +727,66 @@ fn design_debug(root: &Path, kind: &str, data: Value) { let _ = sender.try_send((path, data)); } +#[derive(Debug)] +struct DesignFailure { + message: String, + code: ErrorCode, +} + +impl From for DesignFailure { + fn from(message: String) -> Self { + Self { + message, + code: ErrorCode::RuntimeErrorUnclassified, + } + } +} + +impl From<&str> for DesignFailure { + fn from(message: &str) -> Self { + message.to_string().into() + } +} + +impl DesignFailure { + fn local_io(message: String) -> Self { + Self { + message, + code: ErrorCode::LocalIoFailed, + } + } +} + +fn design_provider_failure(error: &platform_llm::LlmError, message: String) -> DesignFailure { + use platform_llm::LlmError; + let code = match error { + LlmError::Timeout { .. } => ErrorCode::ProviderTimeout, + LlmError::Upstream { + status_code: 401 | 403, + .. + } => ErrorCode::ProviderAuthFailed, + LlmError::Upstream { + status_code: 429, .. + } => ErrorCode::ProviderRateLimited, + LlmError::Upstream { + status_code: 500..=599, + .. + } + | LlmError::Connectivity { .. } + | LlmError::Transport(_) + | LlmError::StreamUnavailable => ErrorCode::ProviderUnavailable, + LlmError::EmptyResponse | LlmError::Deserialize(_) => ErrorCode::ProviderInvalidResponse, + _ => ErrorCode::RuntimeErrorUnclassified, + }; + DesignFailure { message, code } +} + async fn request_design_provider( root: &Path, session: &mut DesignSession, resources: &DesignResources, emit: &mut (impl FnMut(DesignEvent) + Send), -) -> Result { +) -> Result { #[cfg(test)] if fake_provider::is_active() { return request_scripted_design_provider(root, session, emit).await; @@ -749,7 +805,7 @@ async fn request_design_provider( let message_id = format!("{}:response:{}", turn.id, turn.request_index); for attempt in 0..=max_retries { session.turn.as_mut().unwrap().attempt = attempt; - checkpoint_design(root, session)?; + checkpoint_design(root, session).map_err(DesignFailure::local_io)?; design_debug( root, "request", @@ -870,7 +926,7 @@ async fn request_design_provider( Some(&message_id), String::new(), )); - return Err(detail); + return Err(design_provider_failure(&error, detail)); } tokio::time::sleep(Duration::from_millis( game_creator_agent_runtime_transient_retry_backoff_ms( @@ -890,14 +946,14 @@ async fn request_scripted_design_provider( root: &Path, session: &mut DesignSession, emit: &mut (impl FnMut(DesignEvent) + Send), -) -> Result { +) -> Result { let max_retries = fake_provider::max_retries(); let turn = session.turn.as_ref().unwrap(); let turn_id = turn.id.clone(); let message_id = format!("{}:response:{}", turn.id, turn.request_index); for attempt in 0..=max_retries { session.turn.as_mut().unwrap().attempt = attempt; - checkpoint_design(root, session)?; + checkpoint_design(root, session).map_err(DesignFailure::local_io)?; emit(design_event( root, &turn_id, @@ -952,7 +1008,7 @@ async fn request_scripted_design_provider( Some(&message_id), String::new(), )); - return Err(detail); + return Err(design_provider_failure(&error, detail)); } } None => { @@ -1010,15 +1066,18 @@ async fn run_design_loop( resources: &DesignResources, session: &mut DesignSession, emit: &mut (impl FnMut(DesignEvent) + Send), -) -> Result<(), String> { +) -> Result<(), DesignFailure> { while session.turn.as_ref().is_some_and(|turn| turn.pending) { - process_design_batch(root, resources, session, emit)?; + process_design_batch(root, resources, session, emit).map_err(DesignFailure::local_io)?; if session.pending_approval.is_some() || session.pending_clarification.is_some() { break; } let response = request_design_provider(root, session, resources, emit).await?; - accept_design_response(session, response)?; - checkpoint_design(root, session)?; + accept_design_response(session, response).map_err(|message| DesignFailure { + message, + code: ErrorCode::ProviderInvalidResponse, + })?; + checkpoint_design(root, session).map_err(DesignFailure::local_io)?; let turn = session.turn.as_ref().unwrap(); emit(design_event( root, @@ -1032,14 +1091,28 @@ async fn run_design_loop( Ok(()) } +#[derive(Clone, Copy, PartialEq, Eq)] +enum DesignExecution { + Idle, + New, + Recovery, +} + async fn finish_design_command( root: &Path, resources: &DesignResources, mut session: DesignSession, active: File, - run: bool, + execution: DesignExecution, + phase_change: Option, + capture: Option<( + crate::analytics::contract::Context, + crate::analytics::store::AnalyticsWriter, + )>, mut emit: impl FnMut(DesignEvent) + Send, ) -> Result { + let run = execution != DesignExecution::Idle; + let new_run = execution == DesignExecution::New; if run && session .turn @@ -1049,6 +1122,25 @@ async fn finish_design_command( resolve_design_turn_llm_config(&mut session, &load_game_creator_app_config()?)?; } checkpoint_design(root, &session)?; + let started = (new_run && capture.is_some()).then(Instant::now); + let metadata = session + .turn + .as_ref() + .and_then(|turn| turn.analytics.clone()); + if new_run { + if let (Some(metadata), Some((_, writer))) = (&metadata, &capture) { + crate::analytics::run::accepted(writer, root, &session.project_id, metadata); + } + crate::analytics::goal::accepted( + capture.clone(), + root, + &session.project_id, + crate::analytics::contract::Source::DesignAgent, + ); + } + if let Some(change) = phase_change { + change.record(); + } let turn_id = session .turn .as_ref() @@ -1074,12 +1166,43 @@ async fn finish_design_command( let outcome = std::panic::AssertUnwindSafe(run).catch_unwind().await; let result = match outcome { Ok(result) => result, - Err(payload) => Err(design_panic_error(payload)), + Err(payload) => Err(DesignFailure { + message: design_panic_error(payload), + code: ErrorCode::RuntimeFailed, + }), }; + if let Some(metadata) = &metadata { + let end_reason = if result.is_err() { + RunEndReason::Failed + } else if session.pending_approval.is_some() { + RunEndReason::WaitingForApproval + } else if session.pending_clarification.is_some() { + RunEndReason::WaitingForUser + } else { + RunEndReason::Finished + }; + crate::analytics::run::finished( + capture + .clone() + .or_else(crate::analytics::gui::capture_writer_context), + root, + &session.project_id, + metadata, + crate::analytics::run::Outcome { + turn_id: Some(turn_id.clone()), + end_reason, + error_code: result.as_ref().err().map(|error| error.code), + duration_ms: started + .and_then(|start| u64::try_from(start.elapsed().as_millis()).ok()), + output_change_detected: metadata.output_revision.as_ref().map(|_| true), + revision_id: metadata.output_revision.clone(), + }, + ); + } if let Err(error) = result { // 从最后一个持久检查点恢复,防止写后未记结果被误认为已完成。 session = read_design_session(root)?.ok_or("策划会话丢失")?; - session.last_error = Some(redact_agent_runtime_error(root, &error, 1800)); + session.last_error = Some(redact_agent_runtime_error(root, &error.message, 1800)); checkpoint_design(root, &session)?; } } @@ -1108,6 +1231,21 @@ pub(crate) async fn continue_design_agent_at( id: &str, input: DesignInput, emit: impl FnMut(DesignEvent) + Send, +) -> Result { + let capture = crate::analytics::gui::capture_writer_context(); + continue_design_agent_with_capture_at(root, resources, id, input, capture, emit).await +} + +async fn continue_design_agent_with_capture_at( + root: &Path, + resources: &DesignResources, + id: &str, + input: DesignInput, + capture: Option<( + crate::analytics::contract::Context, + crate::analytics::store::AnalyticsWriter, + )>, + emit: impl FnMut(DesignEvent) + Send, ) -> Result { ensure_design_runtime_active(root)?; let project_id = design_project_id(root)?; @@ -1121,11 +1259,36 @@ pub(crate) async fn continue_design_agent_at( if session.project_id != project_id { return Err("策划会话与当前项目不匹配".into()); } + let run_source = match &input { + DesignInput::Retry => RunSource::UserRetry, + DesignInput::Clarification { .. } => RunSource::Clarification, + DesignInput::Message { .. } if session.pending_clarification.is_some() => { + RunSource::Clarification + } + DesignInput::Message { .. } => RunSource::UserSubmit, + }; let run = prepare_design_input(&mut session, id, input)?; if run { select_design_turn_model(&mut session, &load_game_creator_app_config()?)?; + session.turn.as_mut().unwrap().analytics = capture.as_ref().map(|(context, _)| { + crate::analytics::run::Metadata::new(context.clone(), Source::DesignAgent, run_source) + }); } - finish_design_command(root, resources, session, active, run, emit).await + finish_design_command( + root, + resources, + session, + active, + if run { + DesignExecution::New + } else { + DesignExecution::Idle + }, + None, + capture, + emit, + ) + .await } async fn recover_uncertain_design_batch( @@ -1134,7 +1297,18 @@ async fn recover_uncertain_design_batch( session: DesignSession, active: File, ) -> Result { - finish_design_command(root, resources, session, active, true, |_| {}).await + let capture = crate::analytics::gui::capture_writer_context(); + finish_design_command( + root, + resources, + session, + active, + DesignExecution::Recovery, + None, + capture, + |_| {}, + ) + .await } pub(crate) async fn decide_design_phase_at( @@ -1144,6 +1318,23 @@ pub(crate) async fn decide_design_phase_at( request_id: &str, approved: bool, emit: impl FnMut(DesignEvent) + Send, +) -> Result { + let capture = crate::analytics::gui::capture_writer_context(); + decide_design_phase_with_capture_at(root, resources, id, request_id, approved, capture, emit) + .await +} + +async fn decide_design_phase_with_capture_at( + root: &Path, + resources: &DesignResources, + id: &str, + request_id: &str, + approved: bool, + capture: Option<( + crate::analytics::contract::Context, + crate::analytics::store::AnalyticsWriter, + )>, + emit: impl FnMut(DesignEvent) + Send, ) -> Result { ensure_design_runtime_active(root)?; let project_id = design_project_id(root)?; @@ -1154,11 +1345,48 @@ pub(crate) async fn decide_design_phase_at( if session.project_id != project_id { return Err("策划会话与当前项目不匹配".into()); } + let previous_phase = session.current_phase.clone(); let run = prepare_design_decision(&mut session, id, request_id, approved)?; + let phase_change = run + .then(|| { + crate::analytics::design::PhaseChange::new( + capture.clone(), + &project_id, + &session.session_id, + &previous_phase, + &session.current_phase, + ) + }) + .flatten(); if run { select_design_turn_model(&mut session, &load_game_creator_app_config()?)?; + session.turn.as_mut().unwrap().analytics = capture.as_ref().map(|(context, _)| { + let mut metadata = crate::analytics::run::Metadata::new( + context.clone(), + Source::DesignAgent, + RunSource::Approval, + ); + metadata.output_revision = phase_change + .as_ref() + .map(|change| change.revision_id().to_string()); + metadata + }); } - finish_design_command(root, resources, session, active, run, emit).await + finish_design_command( + root, + resources, + session, + active, + if run { + DesignExecution::New + } else { + DesignExecution::Idle + }, + phase_change, + capture, + emit, + ) + .await } fn ensure_design_runtime_active(root: &Path) -> Result<(), String> { @@ -1392,12 +1620,20 @@ pub(crate) async fn continue_design_agent_session( client_turn_id: String, input: DesignInput, ) -> Result { + let capture = crate::analytics::gui::capture_writer_context(); let root = PathBuf::from(project_path.trim()); enforce_project_permission_policy(&root, "conversation.write")?; let resources = DesignResources::new(resolve_design_resources_root(&app)?)?; - continue_design_agent_at(&root, &resources, &client_turn_id, input, |event| { - let _ = app.emit("design-agent-update", event); - }) + continue_design_agent_with_capture_at( + &root, + &resources, + &client_turn_id, + input, + capture, + |event| { + let _ = app.emit("design-agent-update", event); + }, + ) .await } @@ -1409,15 +1645,17 @@ pub(crate) async fn decide_design_phase( request_id: String, approved: bool, ) -> Result { + let capture = crate::analytics::gui::capture_writer_context(); let root = PathBuf::from(project_path.trim()); enforce_project_permission_policy(&root, "conversation.write")?; let resources = DesignResources::new(resolve_design_resources_root(&app)?)?; - decide_design_phase_at( + decide_design_phase_with_capture_at( &root, &resources, &client_turn_id, &request_id, approved, + capture, |event| { let _ = app.emit("design-agent-update", event); }, @@ -1499,6 +1737,64 @@ mod fake_provider { mod tests { use super::*; + fn analytics_capture() -> ( + tempfile::TempDir, + crate::analytics::contract::Context, + crate::analytics::store::AnalyticsWriter, + ) { + let temp = tempfile::tempdir().unwrap(); + let context = crate::analytics::contract::Context { + route: crate::analytics::contract::Route::from_identity( + Some("approver-a".into()), + Some("https://example.com"), + ), + editor_session_id: Uuid::new_v4().to_string(), + client_version: "1.0.0".into(), + }; + let writer = crate::analytics::store::AnalyticsWriter::start( + temp.path().into(), + context.editor_session_id.clone(), + ); + (temp, context, writer) + } + + fn read_analytics_events( + config: &Path, + session: &str, + marker_id: &str, + ) -> Vec { + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + let mut events = Vec::new(); + if let Ok(batches) = fs::read_dir( + config + .join("analytics/instances") + .join(session) + .join("batches"), + ) { + for batch in batches.flatten() { + if Uuid::parse_str(&batch.file_name().to_string_lossy()).is_err() { + continue; + } + if let Ok(lines) = fs::read_to_string(batch.path().join("events.jsonl")) { + events.extend(lines.lines().map(|line| { + serde_json::from_str::(line).unwrap() + })); + } + } + } + if events.iter().any(|event| event.event_id == marker_id) { + return events; + } + assert!( + std::time::Instant::now() < deadline, + "等待 FIFO 哨兵超时,已读取 {} 条事件", + events.len() + ); + std::thread::sleep(Duration::from_millis(10)); + } + } + #[test] fn runtime_mode_restore_prefers_explicit_mode_over_existing_design_session() { let temporary = tempfile::tempdir().expect("tempdir"); @@ -1530,6 +1826,448 @@ mod tests { ); } + fn drain_analytics_with_marker( + config: &Path, + context: &crate::analytics::contract::Context, + writer: &crate::analytics::store::AnalyticsWriter, + ) -> Vec { + use crate::analytics::contract::*; + // 同一 FIFO 队列中的哨兵落盘后,之前可能误发的事件也一定已被处理。 + 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 events = read_analytics_events(config, &context.editor_session_id, &marker_id); + assert!(events.iter().any(|event| event.event_id == marker_id)); + events + } + + fn assert_revision_count_after_drain( + config: &Path, + context: &crate::analytics::contract::Context, + writer: &crate::analytics::store::AnalyticsWriter, + expected: usize, + ) { + let events = drain_analytics_with_marker(config, context, writer); + assert_eq!( + events + .iter() + .filter(|event| event.event_name == "project_revision_created") + .count(), + expected + ); + } + + #[tokio::test(flavor = "current_thread")] + async fn approved_phase_revision_survives_model_failure_and_command_replay() { + let (_temp, root, resources) = init_design_project(); + let (analytics_dir, context, writer) = analytics_capture(); + concept_artifacts(&root); + let mut session = new_design_session(design_project_id(&root).unwrap(), "quality"); + let approval = submit_design_phase_for_approval(&root, &mut session).unwrap(); + write_design_session(&root, &session).unwrap(); + let _fake = fake_provider::install( + vec![Err(platform_llm::LlmError::Upstream { + status_code: 500, + message: "test failure".into(), + })], + 0, + ); + let view = decide_design_phase_with_capture_at( + &root, + &resources, + "approval-once", + &approval.request_id, + true, + Some((context.clone(), writer.clone())), + |_| {}, + ) + .await + .unwrap(); + assert_eq!(view.session.current_phase, "top_design"); + assert!(view.session.last_error.is_some()); + let events = drain_analytics_with_marker(analytics_dir.path(), &context, &writer); + let revision_event = events + .iter() + .find(|event| event.event_name == "project_revision_created") + .unwrap(); + assert_eq!( + revision_event.properties["revision_id"], + format!("design:{}:top_design", session.session_id) + ); + assert_eq!(revision_event.user_id.as_deref(), Some("approver-a")); + let failed = events + .iter() + .find(|event| event.event_name == "agent_run_failed") + .unwrap(); + assert_eq!(failed.properties["output_change_detected"], true); + assert_eq!( + failed.properties["revision_id"], + revision_event.properties["revision_id"] + ); + assert_eq!(failed.error_code, Some(ErrorCode::ProviderUnavailable)); + assert_eq!(failed.properties["run_source"], "approval"); + + let mut another_user = context.clone(); + another_user.route.user_id = Some("approver-b".into()); + decide_design_phase_with_capture_at( + &root, + &resources, + "approval-once", + &approval.request_id, + true, + Some((another_user, writer.clone())), + |_| {}, + ) + .await + .unwrap(); + assert_revision_count_after_drain(analytics_dir.path(), &context, &writer, 1); + } + + #[tokio::test(flavor = "current_thread")] + async fn phase_checkpoint_failure_does_not_record_revision_or_start_model() { + let (_temp, root, resources) = init_design_project(); + let (analytics_dir, context, writer) = analytics_capture(); + concept_artifacts(&root); + let mut session = new_design_session(design_project_id(&root).unwrap(), "quality"); + crate::analytics::goal::created(Some(&writer), &root, &session.project_id); + let approval = submit_design_phase_for_approval(&root, &mut session).unwrap(); + let old_phase = session.current_phase.clone(); + assert!( + prepare_design_decision(&mut session, "approve", &approval.request_id, true).unwrap() + ); + select_design_turn_model(&mut session, &GameCreatorAppConfig::default()).unwrap(); + let change = crate::analytics::design::PhaseChange::new( + Some((context.clone(), writer.clone())), + &session.project_id, + &session.session_id, + &old_phase, + &session.current_phase, + ); + fs::create_dir_all(root.join(".agent/design-agent/session.json")).unwrap(); + let active = try_open_game_creator_agent_runtime_task_lock_file(&root, DESIGN_ACTIVE_LOCK) + .unwrap() + .unwrap(); + let _fake = fake_provider::install(vec![Ok(fake_response("unused", "unused", vec![]))], 0); + assert!(finish_design_command( + &root, + &resources, + session, + active, + DesignExecution::New, + change, + Some((context.clone(), writer.clone())), + |_| {} + ) + .await + .is_err()); + assert_revision_count_after_drain(analytics_dir.path(), &context, &writer, 0); + assert!(fake_provider::take().is_some()); + } + + #[tokio::test(flavor = "current_thread")] + async fn design_first_submit_keeps_accepting_user_after_model_failure_and_replay() { + let (_temp, root, resources) = init_design_project(); + let (analytics_dir, context, writer) = analytics_capture(); + crate::analytics::goal::created(Some(&writer), &root, &design_project_id(&root).unwrap()); + let _fake = fake_provider::install( + vec![Err(platform_llm::LlmError::Upstream { + status_code: 500, + message: "test failure".into(), + })], + 0, + ); + let input = || DesignInput::Message { + text: "设计一个游戏".into(), + }; + let view = continue_design_agent_with_capture_at( + &root, + &resources, + "first-submit", + input(), + Some((context.clone(), writer.clone())), + |_| {}, + ) + .await + .unwrap(); + assert!(view.session.last_error.is_some()); + let mut another_user = context.clone(); + another_user.route.user_id = Some("approver-b".into()); + continue_design_agent_with_capture_at( + &root, + &resources, + "first-submit", + input(), + Some((another_user, writer.clone())), + |_| {}, + ) + .await + .unwrap(); + let events = drain_analytics_with_marker(analytics_dir.path(), &context, &writer); + let submitted = events + .iter() + .find(|event| event.event_name == "creative_task_submit") + .unwrap(); + assert_eq!(submitted.user_id.as_deref(), Some("approver-a")); + assert_eq!( + submitted.source, + crate::analytics::contract::Source::DesignAgent + ); + assert_eq!(submitted.project_id, submitted.creative_task_id); + } + + #[tokio::test(flavor = "current_thread")] + async fn design_run_wait_failure_and_user_retry_have_distinct_stable_results() { + let (_temp, root, resources) = init_design_project(); + let (analytics_dir, context, writer) = analytics_capture(); + let _fake = fake_provider::install( + vec![ + Ok(fake_response( + "question", + "", + vec![platform_llm::LlmToolCall { + id: "ask".into(), + name: "ask_clarification".into(), + arguments: json!({"question":"选择方向?","options":["解谜"]}).to_string(), + }], + )), + Err(platform_llm::LlmError::Upstream { + status_code: 401, + message: "private provider detail".into(), + }), + Err(platform_llm::LlmError::Upstream { + status_code: 500, + message: "transient".into(), + }), + Ok(fake_response("answer", "完成本轮", vec![])), + ], + 1, + ); + let view = continue_design_agent_with_capture_at( + &root, + &resources, + "start", + DesignInput::Message { + text: "设计游戏".into(), + }, + Some((context.clone(), writer.clone())), + |_| {}, + ) + .await + .unwrap(); + assert!(view.session.pending_clarification.is_some()); + let failed = continue_design_agent_with_capture_at( + &root, + &resources, + "answer", + DesignInput::Message { + text: "解谜".into(), + }, + Some((context.clone(), writer.clone())), + |_| {}, + ) + .await + .unwrap(); + assert!(failed.session.last_error.is_some()); + continue_design_agent_with_capture_at( + &root, + &resources, + "retry", + DesignInput::Retry, + Some((context.clone(), writer.clone())), + |_| {}, + ) + .await + .unwrap(); + continue_design_agent_with_capture_at( + &root, + &resources, + "retry", + DesignInput::Retry, + Some((context.clone(), writer.clone())), + |_| {}, + ) + .await + .unwrap(); + let events = drain_analytics_with_marker(analytics_dir.path(), &context, &writer); + let runs: Vec<_> = events.iter().filter(|e| e.agent_run_id.is_some()).collect(); + assert_eq!(runs.len(), 3); + let find = |turn: &str| { + *runs + .iter() + .find(|e| e.agent_turn_id.as_deref() == Some(turn)) + .unwrap() + }; + let first = find("start"); + let failed = find("answer"); + let retry = find("retry"); + assert_eq!(first.properties["end_reason"], "waiting_for_user"); + assert_eq!(first.properties["run_source"], "user_submit"); + assert_eq!(failed.error_code, Some(ErrorCode::ProviderAuthFailed)); + assert_eq!(failed.properties["run_source"], "clarification"); + assert_eq!(failed.properties["retry_index"], 0); + assert_eq!(retry.properties["run_source"], "user_retry"); + assert_eq!(retry.properties["retry_index"], 1); + assert_eq!(retry.properties["end_reason"], "finished"); + assert_ne!(first.agent_run_id, failed.agent_run_id); + assert_ne!(failed.agent_run_id, retry.agent_run_id); + for event in runs { + assert!(event.properties["duration_ms"].is_u64()); + assert!(event.properties["output_change_detected"].is_null()); + assert!(!event + .properties + .to_string() + .contains("private provider detail")); + } + } + + #[tokio::test(flavor = "current_thread")] + async fn design_recovered_run_keeps_original_identity_and_has_unknown_duration() { + let (_temp, root, resources) = init_design_project(); + let (analytics_dir, context, writer) = analytics_capture(); + let mut session = new_design_session(&design_project_id(&root).unwrap(), ""); + begin_design_turn(&mut session, "recovered-turn"); + let metadata = crate::analytics::run::Metadata::new( + context.clone(), + Source::DesignAgent, + RunSource::UserSubmit, + ); + session.turn.as_mut().unwrap().analytics = Some(metadata.clone()); + write_design_session(&root, &session).unwrap(); + crate::analytics::run::accepted(&writer, &root, &session.project_id, &metadata); + drain_analytics_with_marker(analytics_dir.path(), &context, &writer); + let mut current = context.clone(); + current.editor_session_id = Uuid::new_v4().to_string(); + current.client_version = "2.0.0".into(); + current.route.user_id = Some("restoring-user".into()); + let current_writer = crate::analytics::store::AnalyticsWriter::start( + analytics_dir.path().into(), + current.editor_session_id.clone(), + ); + let _fake = + fake_provider::install(vec![Ok(fake_response("restored", "恢复后完成", vec![]))], 0); + let active = try_open_game_creator_agent_runtime_task_lock_file(&root, DESIGN_ACTIVE_LOCK) + .unwrap() + .unwrap(); + finish_design_command( + &root, + &resources, + read_design_session(&root).unwrap().unwrap(), + active, + DesignExecution::Recovery, + None, + Some((current.clone(), current_writer.clone())), + |_| {}, + ) + .await + .unwrap(); + let events = drain_analytics_with_marker(analytics_dir.path(), ¤t, ¤t_writer); + let result = events + .iter() + .find(|event| event.event_name == "agent_run_completed") + .unwrap(); + assert_eq!( + result.agent_run_id.as_deref(), + Some(metadata.run_id.as_str()) + ); + assert_eq!(result.event_id, metadata.terminal_event_id); + assert_eq!(result.user_id, context.route.user_id); + assert_eq!(result.editor_session_id, current.editor_session_id); + assert_eq!(result.client_version, "2.0.0"); + assert!(result.properties["duration_ms"].is_null()); + assert_eq!(result.properties["retry_index"], 0); + assert!(!events + .iter() + .any(|event| event.event_name == "creative_task_submit")); + } + + #[tokio::test(flavor = "current_thread")] + async fn design_old_command_replay_does_not_consume_newly_available_goal_qualification() { + let (_temp, root, resources) = init_design_project(); + let (analytics_dir, context, writer) = analytics_capture(); + let _fake = fake_provider::install(vec![Ok(fake_response("answer", "完成", vec![]))], 0); + let input = || DesignInput::Message { + text: "设计一个游戏".into(), + }; + continue_design_agent_with_capture_at( + &root, + &resources, + "old-command", + input(), + None, + |_| {}, + ) + .await + .unwrap(); + crate::analytics::goal::created(Some(&writer), &root, &design_project_id(&root).unwrap()); + continue_design_agent_with_capture_at( + &root, + &resources, + "old-command", + input(), + Some((context.clone(), writer.clone())), + |_| {}, + ) + .await + .unwrap(); + let events = drain_analytics_with_marker(analytics_dir.path(), &context, &writer); + assert!(!events + .iter() + .any(|event| event.event_name == "creative_task_submit")); + } + + #[tokio::test(flavor = "current_thread")] + async fn design_invalid_input_preserves_qualification_for_later_accepting_user() { + let (_temp, root, resources) = init_design_project(); + let (analytics_dir, context, writer) = analytics_capture(); + crate::analytics::goal::created(Some(&writer), &root, &design_project_id(&root).unwrap()); + assert!(continue_design_agent_with_capture_at( + &root, + &resources, + "invalid", + DesignInput::Message { text: " ".into() }, + Some((context.clone(), writer.clone())), + |_| {}, + ) + .await + .is_err()); + let mut accepting_user = context.clone(); + accepting_user.route.user_id = Some("approver-b".into()); + let _fake = fake_provider::install(vec![Ok(fake_response("answer", "完成", vec![]))], 0); + continue_design_agent_with_capture_at( + &root, + &resources, + "valid", + DesignInput::Message { + text: "设计游戏".into(), + }, + Some((accepting_user, writer.clone())), + |_| {}, + ) + .await + .unwrap(); + let events = drain_analytics_with_marker(analytics_dir.path(), &context, &writer); + assert_eq!( + events + .iter() + .find(|event| event.event_name == "creative_task_submit") + .unwrap() + .user_id + .as_deref(), + Some("approver-b") + ); + } + #[test] fn design_runtime_rejects_design_execution_in_game_mode() { let temporary = tempfile::tempdir().expect("create runtime mode root"); @@ -1818,6 +2556,7 @@ mod tests { request_index: 0, attempt: 0, model_selection: None, + analytics: None, }); session.pending_batch = Some(DesignToolBatch { calls: vec![ @@ -2228,6 +2967,7 @@ mod tests { #[tokio::test(flavor = "current_thread")] async fn fake_provider_walks_five_phases_and_enters_consultant() { let (_temp, root, resources) = init_design_project(); + let (analytics_dir, context, writer) = analytics_capture(); let _fake = fake_provider::install( vec![ Ok(phase_write_and_submit( @@ -2286,9 +3026,17 @@ mod tests { ("t-tdd", "tdd"), ("t-consultant", "consultant"), ] { - let view = decide_design_phase_at(&root, &resources, turn, &request, true, |_| {}) - .await - .expect("approve"); + let view = decide_design_phase_with_capture_at( + &root, + &resources, + turn, + &request, + true, + Some((context.clone(), writer.clone())), + |_| {}, + ) + .await + .expect("approve"); assert_eq!(view.session.current_phase, expected); if expected == "consultant" { assert!(view.session.pending_approval.is_none()); @@ -2308,11 +3056,60 @@ mod tests { design_workflow_status(&restored)["current_phase"], json!("consultant") ); + let events = drain_analytics_with_marker(analytics_dir.path(), &context, &writer); + let revisions: Vec<_> = events + .iter() + .filter(|event| event.event_name == "project_revision_created") + .collect(); + assert_eq!(revisions.len(), 5); + let completed: Vec<_> = events + .iter() + .filter(|event| event.event_name == "agent_run_completed") + .collect(); + assert_eq!(completed.len(), 5); + assert_eq!( + completed + .iter() + .filter(|e| e.properties["end_reason"] == "waiting_for_approval") + .count(), + 4 + ); + assert_eq!( + completed + .iter() + .filter(|e| e.properties["end_reason"] == "finished") + .count(), + 1 + ); + assert!(completed + .iter() + .all(|e| e.properties["output_change_detected"] == true + && e.properties["retry_index"] == 0)); + for phase in &DESIGN_PHASES[1..] { + let revision = format!("design:{}:{phase}", restored.session_id); + let event = revisions + .iter() + .find(|event| event.properties["revision_id"] == revision) + .unwrap(); + assert_eq!(event.event_name, "project_revision_created"); + assert_eq!(event.user_id.as_deref(), Some("approver-a")); + assert_eq!(event.project_id, Some(restored.project_id.clone())); + assert_eq!(event.creative_task_id, event.project_id); + assert_eq!( + event.source, + crate::analytics::contract::Source::DesignAgent + ); + assert_eq!(event.properties["revision_source"], "agent"); + assert_eq!(event.properties["change_kind"], "design_document"); + assert!(event.properties.get("files_changed_count").is_none()); + assert!(event.agent_run_id.is_none() && event.agent_turn_id.is_none()); + } } #[tokio::test(flavor = "current_thread")] async fn fake_provider_reject_does_not_wake_and_session_survives_restart() { let (_temp, root, resources) = init_design_project(); + let (analytics_dir, context, writer) = analytics_capture(); let _fake = fake_provider::install( vec![Ok(phase_write_and_submit( "concept", @@ -2344,14 +3141,23 @@ mod tests { .map(|item| item.request_id.as_str()), Some(request.as_str()) ); - let rejected = - decide_design_phase_at(&root, &resources, "t-reject", &request, false, |_| {}) - .await - .expect("reject"); + crate::analytics::goal::created(Some(&writer), &root, &persisted.project_id); + let rejected = decide_design_phase_with_capture_at( + &root, + &resources, + "t-reject", + &request, + false, + Some((context.clone(), writer.clone())), + |_| {}, + ) + .await + .expect("reject"); assert_eq!(rejected.session.current_phase, "concept"); assert!(rejected.session.pending_approval.is_none()); assert!(rejected.session.approved_phases.is_empty()); assert!(fake_provider::take().is_none()); + assert_revision_count_after_drain(analytics_dir.path(), &context, &writer, 0); let debug = root.join(".debug/design-agent"); if debug.exists() { @@ -2469,6 +3275,7 @@ mod tests { request_index: 0, attempt: 0, model_selection: None, + analytics: None, }); session.pending_batch = Some(DesignToolBatch { calls: vec![call], diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution.rs index 8f276ad31..a31c360f1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution.rs @@ -93,6 +93,8 @@ pub(super) struct ExecutionLedger { pub(super) plan: Option, #[serde(default)] pub(super) last_failed_write_revision: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(super) analytics_run: Option, } struct SessionData { @@ -106,6 +108,17 @@ struct SessionData { elapsed_offset_ms: u64, } +// 与业务持久化锁分离;只保存内存状态,持锁期间不执行 I/O 或投递事件。 +struct SessionAnalytics { + project_id: String, + route: Option, + capture: Option<( + crate::analytics::contract::Context, + crate::analytics::store::AnalyticsWriter, + )>, + output_revision: Option, +} + #[derive(Clone, PartialEq, Eq)] struct CodexExecutorIdentity { path: PathBuf, @@ -153,9 +166,12 @@ fn executor_digest(path: &Path) -> Result { pub(super) struct ExecutionSession { pub(super) root: PathBuf, + /// 本次确实新建执行账本;恢复和旧预算迁移均不构成新的用户受理。 + pub(super) newly_accepted: bool, state_path: PathBuf, _owner: File, data: Mutex, + analytics: Mutex, changed: tokio::sync::watch::Sender, cancellation: Arc, 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(&self, write: impl FnOnce() -> Result) -> Result { 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, ) -> Result { 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, 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, ) -> Result, 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 { 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 { 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 { + self.analytics + .lock() + .ok()? + .output_revision + .map(|revision| revision.to_string()) + } + pub(super) fn snapshot(&self) -> Result { let data = self.lock()?; let mut state = data.ledger.clone(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution/tests.rs index af4503af2..9f8ddb1f6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_execution/tests.rs @@ -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(¤t.context.editor_session_id) + .join("batches"); + let deadline = Instant::now() + std::time::Duration::from_secs(5); + loop { + let events: Vec = 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::(line).unwrap()) + .collect::>() + }) + .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) { let temp = tempfile::tempdir().unwrap(); let root = temp.path().join("project"); @@ -13,6 +200,7 @@ fn fixture(config: DirectValidationConfig) -> (tempfile::TempDir, Arc BTreeMap>, + after: &BTreeMap>, +) -> 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)]| { + 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, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 26b97678f..b7d19cda3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -4243,7 +4243,44 @@ pub(crate) async fn run_direct_browser_evidence_with_cancellation_at( advisory_interaction: bool, cancellation: Option>, ) -> Result { + 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, + advisory_interaction: bool, + cancellation: Option>, + capture: Option<( + crate::analytics::contract::Context, + crate::analytics::store::AnalyticsWriter, + )>, +) -> Result { + 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, + capture: Option<( + crate::analytics::contract::Context, + crate::analytics::store::AnalyticsWriter, + )>, + analytics_attempt_id: Option<&str>, ) -> Result { 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, + capture: Option<( + crate::analytics::contract::Context, + crate::analytics::store::AnalyticsWriter, + )>, + analytics_attempt_id: Option<&str>, ) -> Result { 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 = 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, +)> { + 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)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs index 43554cd85..6b3a85455 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs @@ -33,7 +33,9 @@ pub(crate) async fn chat_with_game_creator_direct_codex( user_item: DirectCodexUserItem, creation_type: Option, client_turn_id: Option, + analytics_attempt_id: Option, ) -> Result { + 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 { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs index 19790ef61..25224ba5d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_bridge.rs @@ -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 { + 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 { + 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 = 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::(line).ok()) + .collect::>() + }) + .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":"真实预览"}); + 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"), + "真实预览构建", + ) + .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::(&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"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs index 2c62c5b18..33b849435 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_validation.rs @@ -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 { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/design_session.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/design_session.rs index 10f26a650..2b1833ef9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/design_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/design_session.rs @@ -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, + /// 仅持久化埋点关联,旧回合缺失时不补历史执行。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) analytics: Option, } /// 仅保存恢复所需的用户选择,不包含连接配置或凭据。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/contract.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/contract.rs new file mode 100644 index 000000000..2c3e38b94 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/contract.rs @@ -0,0 +1,529 @@ +//! 产品事件合同。正文和凭据不属于此模块的输入,nullable 字段仍必须显式存在。 +use chrono::{DateTime, SecondsFormat, Utc}; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::Value; +use std::time::{SystemTime, UNIX_EPOCH}; +use uuid::Uuid; + +const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991; + +fn required_nullable<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + Option::::deserialize(deserializer) +} + +macro_rules! values { + ($name:ident { $($variant:ident),+ $(,)? }) => { + #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] + #[serde(rename_all = "snake_case")] + pub(crate) enum $name { $($variant),+ } + }; +} + +values!(Source { + Editor, + Direct, + DesignAgent, + AssetCanvas, + ResourceEditor, + UiEditor, + Manual, + System +}); +values!(Status { Success, Failed }); +values!(EntrySource { + DirectLaunch, + ProjectAssociation, + AppRestore +}); +values!(SessionEndReason { + UserExit, + AppRestart +}); +values!(FocusReason { + InitialFocus, + WindowFocus, + Restore, + AccountChange +}); +values!(BlurReason { + WindowBlur, + Minimized, + AppExit, + SystemSuspend, + AccountChange +}); +values!(CreationSource { + HomeGame, + HomeDesign, + Template, + SelectedDirectory +}); +values!(OpenSource { + Create, + Picker, + Recent, + AppRestore, + ProjectAssociation +}); +values!(AgentType { + GameAgent, + DesignAgent +}); +values!(RunSource { + UserSubmit, + UserContinue, + Clarification, + Approval, + UserRetry +}); +values!(RunEndReason { + Finished, + WaitingForUser, + WaitingForApproval, + Failed +}); +values!(ErrorCode { + ProviderAuthFailed, + ProviderRateLimited, + ProviderUnavailable, + ProviderTimeout, + ProviderInvalidResponse, + LocalIoFailed, + RuntimeFailed, + RuntimeErrorUnclassified +}); +values!(RevisionSource { + Agent, + AssetCanvas, + ResourceEditor, + UiEditor, + ManualEdit, + SystemProjection +}); +values!(ChangeKind { + Code, + Asset, + Ui, + DesignDocument, + Mixed +}); +values!(PreviewSource { + User, + Agent, + AutoRestore +}); +values!(SaveSource { + Manual, + Auto, + Checkpoint +}); +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub(crate) struct Route { + #[serde(deserialize_with = "required_nullable")] + pub destination_origin: Option, + #[serde(deserialize_with = "required_nullable")] + pub user_id: Option, +} + +impl Route { + pub fn from_identity(user_id: Option, api_base_url: Option<&str>) -> Self { + let destination_origin = api_base_url.and_then(|raw| { + let parsed = url::Url::parse(raw).ok()?; + (matches!(parsed.scheme(), "http" | "https") + && parsed.host_str().is_some() + && parsed.username().is_empty() + && parsed.password().is_none()) + .then(|| parsed.origin().ascii_serialization()) + }); + Self { + user_id, + destination_origin, + } + } + + pub fn validate(&self) -> bool { + optional_id(&self.user_id) + && self.destination_origin.as_ref().is_none_or(|origin| { + origin.len() <= 2048 + && Self::from_identity(None, Some(origin)) + .destination_origin + .as_ref() + == Some(origin) + }) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct SessionStart { + pub entry_source: EntrySource, + #[serde(deserialize_with = "required_nullable")] + pub first_project_id: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct SessionEnd { + pub end_reason: SessionEndReason, + #[serde(deserialize_with = "required_nullable")] + pub session_duration_ms: Option, + #[serde(deserialize_with = "required_nullable")] + pub last_project_id: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct FocusStart { + pub focus_interval_id: String, + pub focus_reason: FocusReason, + #[serde(deserialize_with = "required_nullable")] + pub active_project_id: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct FocusEnd { + pub focus_interval_id: String, + pub blur_reason: BlurReason, + #[serde(deserialize_with = "required_nullable")] + pub focus_duration_ms: Option, + #[serde(deserialize_with = "required_nullable")] + pub active_project_id: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ProjectCreated { + pub creation_source: CreationSource, + #[serde(skip_serializing_if = "Option::is_none")] + pub project_template_id: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ProjectOpened { + pub open_source: OpenSource, + #[serde(deserialize_with = "required_nullable")] + pub is_first_open: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct EmptyProperties {} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct RunFinished { + pub agent_type: AgentType, + pub run_source: RunSource, + #[serde(deserialize_with = "required_nullable")] + pub duration_ms: Option, + pub retry_index: u64, + #[serde(deserialize_with = "required_nullable")] + pub output_change_detected: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub revision_id: Option, + pub end_reason: RunEndReason, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct RevisionCreated { + pub revision_id: String, + pub revision_source: RevisionSource, + pub change_kind: ChangeKind, + #[serde(skip_serializing_if = "Option::is_none")] + pub files_changed_count: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct PreviewReady { + pub preview_source: PreviewSource, + pub preview_version: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub ready_duration_ms: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ProjectSaved { + pub save_source: SaveSource, + #[serde(skip_serializing_if = "Option::is_none")] + pub revision_id: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde( + tag = "event_name", + content = "properties", + rename_all = "snake_case", + deny_unknown_fields +)] +pub(crate) enum EventData { + EditorSessionStart(SessionStart), + EditorSessionEnd(SessionEnd), + EditorFocusStart(FocusStart), + EditorFocusEnd(FocusEnd), + ProjectCreateSuccess(ProjectCreated), + ProjectOpen(ProjectOpened), + CreativeTaskSubmit(EmptyProperties), + AgentRunCompleted(RunFinished), + AgentRunFailed(RunFinished), + ProjectRevisionCreated(RevisionCreated), + PreviewReady(PreviewReady), + ProjectSave(ProjectSaved), +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Event { + pub schema_version: u32, + pub event_id: String, + pub event_name: String, + pub event_time: String, + #[serde(deserialize_with = "required_nullable")] + pub user_id: Option, + pub editor_session_id: String, + #[serde(deserialize_with = "required_nullable")] + pub project_id: Option, + #[serde(deserialize_with = "required_nullable")] + pub creative_task_id: Option, + #[serde(deserialize_with = "required_nullable")] + pub agent_run_id: Option, + #[serde(deserialize_with = "required_nullable")] + pub agent_turn_id: Option, + #[serde(deserialize_with = "required_nullable")] + pub status: Option, + #[serde(deserialize_with = "required_nullable")] + pub error_code: Option, + pub source: Source, + pub client_version: String, + pub properties: Value, +} + +/// 每次操作先冻结此上下文。它不持有登录凭据,也不在后台重新读取当前账号。 +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub(crate) struct Context { + pub route: Route, + pub editor_session_id: String, + pub client_version: String, +} + +#[derive(Clone, Debug)] +pub(crate) struct RunIdentity { + pub run_id: String, + pub turn_id: Option, + pub error_code: Option, +} + +impl Context { + pub fn capture( + &self, + data: EventData, + project_id: Option, + source: Source, + run: Option, + ) -> Result { + if !self.route.validate() { + return Err("invalid_route"); + } + let status = data.status(); + let creative_task_id = data.has_goal().then(|| project_id.clone()).flatten(); + let payload = serde_json::to_value(data).map_err(|_| "serialize_failed")?; + let event = Event { + schema_version: 1, + event_id: Uuid::new_v4().to_string(), + event_name: payload["event_name"] + .as_str() + .ok_or("invalid_event")? + .to_string(), + event_time: timestamp_now(), + user_id: self.route.user_id.clone(), + editor_session_id: self.editor_session_id.clone(), + project_id, + creative_task_id, + agent_run_id: run.as_ref().map(|r| r.run_id.clone()), + agent_turn_id: run.as_ref().and_then(|r| r.turn_id.clone()), + error_code: run.and_then(|r| r.error_code), + status, + source, + client_version: self.client_version.clone(), + properties: payload["properties"].clone(), + }; + event.validate()?; + Ok(event) + } +} + +impl EventData { + fn status(&self) -> Option { + match self { + Self::EditorFocusStart(_) | Self::EditorFocusEnd(_) => None, + Self::AgentRunFailed(_) => Some(Status::Failed), + _ => Some(Status::Success), + } + } + + fn has_goal(&self) -> bool { + matches!( + self, + Self::CreativeTaskSubmit(_) + | Self::AgentRunCompleted(_) + | Self::AgentRunFailed(_) + | Self::ProjectRevisionCreated(_) + | Self::PreviewReady(_) + | Self::ProjectSave(_) + ) + } +} + +impl Event { + pub fn data(&self) -> Result { + // 可选字段不可得时省略;显式 null 仅用于合同指定的 nullable 字段。 + let optional = match self.event_name.as_str() { + "project_create_success" => &["project_template_id"][..], + "agent_run_completed" | "agent_run_failed" | "project_save" => &["revision_id"][..], + "project_revision_created" => &["files_changed_count"][..], + "preview_ready" => &["ready_duration_ms"][..], + _ => &[][..], + }; + if optional + .iter() + .any(|key| self.properties.get(key).is_some_and(Value::is_null)) + { + return Err("invalid_optional_property"); + } + serde_json::from_value(serde_json::json!({ + "event_name": self.event_name, + "properties": self.properties, + })) + .map_err(|_| "invalid_properties") + } + + pub fn validate(&self) -> Result<(), &'static str> { + if self.schema_version != 1 + || !uuid(&self.event_id) + || !uuid(&self.editor_session_id) + || !id(&self.client_version) + || !optional_id(&self.user_id) + || !optional_id(&self.project_id) + || !optional_id(&self.creative_task_id) + || !optional_id(&self.agent_turn_id) + || !valid_time(&self.event_time) + { + return Err("invalid_envelope"); + } + let data = self.data()?; + if self.status != data.status() + || self.error_code.is_some() != matches!(data, EventData::AgentRunFailed(_)) + { + return Err("invalid_status"); + } + if data.has_goal() { + if self.project_id.is_none() || self.creative_task_id != self.project_id { + return Err("invalid_goal"); + } + } else if self.creative_task_id.is_some() { + return Err("unexpected_goal"); + } + let is_run = matches!( + data, + EventData::AgentRunCompleted(_) | EventData::AgentRunFailed(_) + ); + if is_run { + if !self.agent_run_id.as_deref().is_some_and(uuid) { + return Err("invalid_run"); + } + } else if self.agent_run_id.is_some() || self.agent_turn_id.is_some() { + return Err("unexpected_run"); + } + let editor = self.source == Source::Editor; + let agent = matches!(self.source, Source::Direct | Source::DesignAgent); + let valid = match data { + EventData::EditorSessionStart(p) => editor && p.first_project_id == self.project_id, + EventData::EditorSessionEnd(p) => { + editor && p.last_project_id == self.project_id && safe(p.session_duration_ms) + } + EventData::EditorFocusStart(p) => { + editor && uuid(&p.focus_interval_id) && p.active_project_id == self.project_id + } + EventData::EditorFocusEnd(p) => { + editor + && uuid(&p.focus_interval_id) + && p.active_project_id == self.project_id + && safe(p.focus_duration_ms) + } + EventData::ProjectCreateSuccess(p) => { + editor && self.project_id.is_some() && optional_id(&p.project_template_id) + } + EventData::ProjectOpen(_) => editor && self.project_id.is_some(), + EventData::CreativeTaskSubmit(_) => agent, + EventData::AgentRunCompleted(p) | EventData::AgentRunFailed(p) => { + agent + && ((self.source == Source::Direct) == (p.agent_type == AgentType::GameAgent)) + && ((self.status == Some(Status::Failed)) + == (p.end_reason == RunEndReason::Failed)) + && safe(p.duration_ms) + && safe(Some(p.retry_index)) + && optional_id(&p.revision_id) + } + EventData::ProjectRevisionCreated(p) => { + id(&p.revision_id) + && safe(p.files_changed_count) + && match p.revision_source { + RevisionSource::Agent => agent, + RevisionSource::AssetCanvas => self.source == Source::AssetCanvas, + RevisionSource::ResourceEditor => self.source == Source::ResourceEditor, + RevisionSource::UiEditor => self.source == Source::UiEditor, + RevisionSource::ManualEdit => self.source == Source::Manual, + RevisionSource::SystemProjection => self.source == Source::System, + } + } + EventData::PreviewReady(p) => id(&p.preview_version) && safe(p.ready_duration_ms), + EventData::ProjectSave(p) => optional_id(&p.revision_id), + }; + if valid { + Ok(()) + } else { + Err("invalid_event_fields") + } + } +} + +fn id(value: &str) -> bool { + !value.trim().is_empty() && value.len() <= 256 && !value.chars().any(char::is_control) +} +fn optional_id(value: &Option) -> bool { + value.as_deref().is_none_or(id) +} +fn uuid(value: &str) -> bool { + Uuid::parse_str(value).is_ok_and(|v| v.get_version_num() == 4 && v.to_string() == value) +} +fn safe(value: Option) -> bool { + value.is_none_or(|v| v <= MAX_SAFE_INTEGER) +} +pub(super) fn valid_time(value: &str) -> bool { + DateTime::parse_from_rfc3339(value) + .is_ok_and(|v| v.to_utc().to_rfc3339_opts(SecondsFormat::Millis, true) == value) +} + +pub(crate) fn timestamp_now() -> String { + let ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + DateTime::::from_timestamp_millis(ms.min(i64::MAX as u128) as i64) + .unwrap_or_default() + .to_rfc3339_opts(SecondsFormat::Millis, true) +} + +#[cfg(test)] +#[path = "contract_tests.rs"] +mod tests; diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/contract_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/contract_tests.rs new file mode 100644 index 000000000..25eacdf5c --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/contract_tests.rs @@ -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 { + 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::(missing).is_err(), + "missing {field}" + ); + } + let mut extra = value; + extra["prompt"] = json!("must not be accepted"); + assert!(serde_json::from_value::(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 + ); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/design.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/design.rs new file mode 100644 index 000000000..e3c4d42c3 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/design.rs @@ -0,0 +1,55 @@ +//! 策划阶段推进按约定视为成果变化;不检查文档版本或记录工作流快照。 +use super::contract::{ChangeKind, Context, EventData, RevisionCreated, RevisionSource, Source}; +use super::store::AnalyticsWriter; + +pub(crate) struct PhaseChange { + context: Context, + writer: AnalyticsWriter, + project_id: String, + revision_id: String, +} + +impl PhaseChange { + pub(crate) fn new( + capture: Option<(Context, AnalyticsWriter)>, + project_id: &str, + session_id: &str, + previous_phase: &str, + phase: &str, + ) -> Option { + if previous_phase == phase { + return None; + } + let (context, writer) = capture?; + Some(Self { + context, + writer, + project_id: project_id.to_string(), + revision_id: format!("design:{session_id}:{phase}"), + }) + } + + pub(crate) fn revision_id(&self) -> &str { + &self.revision_id + } + + // 必须在阶段与审批命令成功持久化后调用,时间与事件 ID 在此刻生成。 + pub(crate) fn record(self) { + let key = format!( + "{}:{}:project_revision_created", + self.project_id, self.revision_id + ); + let data = EventData::ProjectRevisionCreated(RevisionCreated { + revision_id: self.revision_id, + revision_source: RevisionSource::Agent, + change_kind: ChangeKind::DesignDocument, + files_changed_count: None, + }); + if let Ok(event) = + self.context + .capture(data, Some(self.project_id), Source::DesignAgent, None) + { + self.writer.try_record(self.context.route, event, key); + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/goal.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/goal.rs new file mode 100644 index 000000000..c01db32bd --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/goal.rs @@ -0,0 +1,285 @@ +//! 新建项目的目标采集资格。只在后台持锁读改写,不等待业务线程。 +use super::contract::{Context, EmptyProperties, Event, EventData, Route, Source}; +use super::store::AnalyticsWriter; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; + +const MARKER: &str = ".agent/analytics-goal.json"; +const MAX_MARKER_BYTES: usize = 4096; +const MAX_PENDING_PROJECTS: usize = 1024; +const MAX_PENDING_BYTES: usize = 1024 * 1024; + +#[derive(Default)] +struct PendingGoals { + projects: HashSet<(PathBuf, String)>, + bytes: usize, +} + +impl PendingGoals { + fn remember(&mut self, root: &Path, project_id: &str) { + let key = (root.to_path_buf(), project_id.to_owned()); + let bytes = root.as_os_str().as_encoded_bytes().len() + project_id.len(); + if self.projects.len() < MAX_PENDING_PROJECTS + && self.bytes.saturating_add(bytes) <= MAX_PENDING_BYTES + && self.projects.insert(key) + { + self.bytes += bytes; + } + } + + fn forget(&mut self, root: &Path, project_id: &str) { + if let Some((root, project_id)) = self + .projects + .take(&(root.to_path_buf(), project_id.to_owned())) + { + self.bytes -= root.as_os_str().as_encoded_bytes().len() + project_id.len(); + } + } +} + +fn pending() -> &'static Mutex { + static PENDING: OnceLock> = OnceLock::new(); + PENDING.get_or_init(|| Mutex::new(PendingGoals::default())) +} + +fn forget_pending(root: &Path, project_id: &str) { + pending() + .lock() + .unwrap_or_else(|error| error.into_inner()) + .forget(root, project_id); +} + +#[derive(Serialize)] +pub(super) enum Request { + Created { + root: PathBuf, + project_id: String, + }, + Accepted { + root: PathBuf, + route: Route, + event: Event, + }, +} + +impl Request { + pub(super) fn validate(&self) -> bool { + let (root, project_id) = match self { + Self::Created { root, project_id } => (root, Some(project_id.as_str())), + Self::Accepted { root, route, event } => { + if !route.validate() + || route.user_id != event.user_id + || event.event_name != "creative_task_submit" + || event.validate().is_err() + || !matches!(event.source, Source::Direct | Source::DesignAgent) + { + return false; + } + (root, event.project_id.as_deref()) + } + }; + root.is_absolute() + && root.as_os_str().len() <= 32768 + && project_id.is_some_and(|id| { + !id.trim().is_empty() && id.len() <= 256 && !id.chars().any(char::is_control) + }) + } +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Marker { + schema_version: u32, + project_id: String, + submitted: bool, +} + +pub(crate) fn created(writer: Option<&AnalyticsWriter>, root: &Path, project_id: &str) { + let request = Request::Created { + root: root.into(), + project_id: project_id.into(), + }; + if !request.validate() { + return; + } + // 只串行修改有界内存;任何路径检查、文件锁和落盘均留在后台。 + pending() + .lock() + .unwrap_or_else(|error| error.into_inner()) + .remember(root, project_id); + if let Some(writer) = writer { + writer.try_goal(request); + } +} + +pub(super) fn retry_pending(writer: &AnalyticsWriter) { + let projects: Vec<_> = pending() + .lock() + .unwrap_or_else(|error| error.into_inner()) + .projects + .iter() + .cloned() + .collect(); + for (root, project_id) in projects { + // 投递失败不移除资格;后续真实受理仍可重试。 + writer.try_goal(Request::Created { root, project_id }); + } +} + +// 调用方已取得项目独立埋点锁;不得在文件 I/O 期间持有 pending 内存锁。 +fn initialize_pending(root: &Path, project_id: &str, path: &Path) -> Result<(), String> { + let known_created = pending() + .lock() + .unwrap_or_else(|error| error.into_inner()) + .projects + .contains(&(root.to_path_buf(), project_id.to_owned())); + if !known_created { + return Ok(()); + } + let backup = crate::agent::agent_runtime_json_sidecar_backup_path(path); + for candidate in [path, backup.as_path()] { + match std::fs::symlink_metadata(candidate) { + Ok(_) => { + // 已有主标记或恢复副本,无论完好与否都不重授资格。 + forget_pending(root, project_id); + return Ok(()); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.to_string()), + } + } + crate::agent::write_agent_runtime_json_sidecar_with_max_bytes( + root, + MARKER, + "埋点目标资格", + &Marker { + schema_version: 1, + project_id: project_id.into(), + submitted: false, + }, + MAX_MARKER_BYTES, + )?; + forget_pending(root, project_id); + Ok(()) +} + +pub(crate) fn accepted( + capture: Option<(Context, AnalyticsWriter)>, + root: &Path, + project_id: &str, + source: Source, +) { + if !matches!(source, Source::Direct | Source::DesignAgent) { + return; + } + let Some((context, writer)) = capture else { + return; + }; + let Ok(event) = context.capture( + EventData::CreativeTaskSubmit(EmptyProperties {}), + Some(project_id.into()), + source, + None, + ) else { + return; + }; + writer.try_goal(Request::Accepted { + root: root.into(), + route: context.route, + event, + }); +} + +/// 成功消费资格才返回事件;消费后队列落盘失败允许漏记。 +pub(super) fn process(request: Request) -> Result, String> { + if !request.validate() { + return Err("invalid analytics goal request".into()); + } + let (root, project_id) = match &request { + Request::Created { root, project_id } => (root, project_id.as_str()), + Request::Accepted { root, event, .. } => (root, event.project_id.as_deref().unwrap()), + }; + let Some(_lock) = + crate::agent::try_acquire_game_creator_agent_runtime_task_lock(root, "analytics-goal")? + else { + return Ok(None); + }; + let manifest_path = crate::project::resolve_local_project_path(root, ".agent/manifest.json")?; + if crate::project::read_manifest(&manifest_path)?.project_id != project_id { + forget_pending(root, project_id); + return Ok(None); + } + let path = root.join(MARKER); + initialize_pending(root, project_id, &path)?; + let path = crate::project::resolve_local_project_path(root, MARKER)?; + match &request { + Request::Created { .. } => Ok(None), + Request::Accepted { .. } => { + // 明确要求主文件存在,不启用 sidecar 的 previous 自动回退。 + let Ok(metadata) = std::fs::symlink_metadata(&path) else { + return Ok(None); + }; + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Ok(None); + } + let Some(mut marker): Option = + crate::agent::read_agent_runtime_json_sidecar_with_max_bytes( + root, + MARKER, + "埋点目标资格", + MAX_MARKER_BYTES, + )? + else { + return Ok(None); + }; + if marker.schema_version != 1 || marker.project_id != project_id || marker.submitted { + return Ok(None); + } + marker.submitted = true; + crate::agent::write_agent_runtime_json_sidecar_with_max_bytes( + root, + MARKER, + "埋点目标资格", + &marker, + MAX_MARKER_BYTES, + )?; + match request { + Request::Accepted { route, event, .. } => Ok(Some((route, event))), + _ => unreachable!(), + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pending_goals_are_bounded_and_duplicate_creation_does_not_charge_bytes() { + let mut pending = PendingGoals::default(); + let root = std::env::temp_dir().join("analytics-pending-bound"); + pending.remember(&root, "project"); + let bytes = pending.bytes; + pending.remember(&root, "project"); + assert_eq!(pending.bytes, bytes); + pending.forget(&root, "other-project"); + assert_eq!(pending.bytes, bytes); + pending.forget(&root, "project"); + assert_eq!(pending.bytes, 0); + for index in 0..MAX_PENDING_PROJECTS + 1 { + pending.remember(&root.join(index.to_string()), "project"); + } + assert_eq!(pending.projects.len(), MAX_PENDING_PROJECTS); + + let mut pending = PendingGoals::default(); + let large_root = root.join("x".repeat(30_000)); + for index in 0..100 { + pending.remember(&large_root, &index.to_string()); + } + assert!(pending.bytes <= MAX_PENDING_BYTES); + assert!(pending.projects.len() < 100); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/gui.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/gui.rs new file mode 100644 index 000000000..71e58c272 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/gui.rs @@ -0,0 +1,648 @@ +//! GUI 生命周期的内存状态。仅串行捕获事实,所有文件操作交给现有后台写入器。 +use super::contract::*; +use super::session::{LifecycleState, SessionRecord}; +use super::store::AnalyticsWriter; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::{Mutex, OnceLock}; +use std::time::Instant; +use tauri::Manager; +use uuid::Uuid; + +struct OpenProject { + id: String, + path: String, + operation_id: String, +} + +struct Focus { + id: String, + started: Instant, +} + +struct GuiState { + context: Context, + identity_sequence: u64, + writer: AnalyticsWriter, + started: Instant, + focus: Option, + focused_window: Option, + active_project: Option, + last_project: Option, + projects: HashMap, + pending_open: HashMap, + navigation_time: HashMap, + restart: bool, + closed: bool, +} + +struct Service { + session_id: String, + version: String, + writer: AnalyticsWriter, + state: Mutex, +} + +static GUI: OnceLock = OnceLock::new(); +// 仅串行化发布服务与退出;后台等待认证状态时不占此锁。 +static EXITING: Mutex = Mutex::new(false); + +#[cfg(test)] +pub(crate) struct LifecycleFixture(GuiState); + +#[cfg(test)] +impl LifecycleFixture { + pub(crate) fn start(context: Context, writer: AnalyticsWriter) -> Self { + let mut state = GuiState::new(context, 1, writer); + state.start(); + state.windows(Some("main".into()), false, true); + Self(state) + } + + pub(crate) fn create_and_open(&mut self, root: &std::path::Path, project_id: &str) { + crate::init_local_game_project_at(root, project_id, "采集完整链路测试").unwrap(); + super::goal::created(Some(&self.0.writer), root, project_id); + self.0.emit( + EventData::ProjectCreateSuccess(ProjectCreated { + creation_source: CreationSource::HomeGame, + project_template_id: None, + }), + Some(project_id.into()), + format!("{project_id}:project-create"), + ); + self.0.project_opened( + "main".into(), + self.0.context.clone(), + OpenProject { + id: project_id.into(), + path: root.to_string_lossy().into_owned(), + operation_id: Uuid::new_v4().to_string(), + }, + OpenSource::Create, + timestamp_now(), + ); + } + + pub(crate) fn exit(&mut self) { + self.0.exit(); + } +} + +impl GuiState { + fn new(context: Context, identity_sequence: u64, writer: AnalyticsWriter) -> Self { + Self { + context, + identity_sequence, + writer, + started: Instant::now(), + focus: None, + focused_window: None, + active_project: None, + last_project: None, + projects: HashMap::new(), + pending_open: HashMap::new(), + navigation_time: HashMap::new(), + restart: false, + closed: false, + } + } + + fn emit(&self, data: EventData, project: Option, key: String) { + if let Ok(event) = self.context.capture(data, project, Source::Editor, None) { + self.writer + .try_record(self.context.route.clone(), event, key); + } + } + + fn persist(&self) { + self.writer.try_session(SessionRecord { + schema_version: 1, + editor_session_id: self.context.editor_session_id.clone(), + route: self.context.route.clone(), + lifecycle_state: if self.closed { + LifecycleState::Closed + } else { + LifecycleState::Active + }, + focus_interval_id: self.focus.as_ref().map(|f| f.id.clone()), + updated_at: timestamp_now(), + incomplete_detected_at: None, + }); + } + + fn start(&self) { + self.emit( + EventData::EditorSessionStart(SessionStart { + entry_source: EntrySource::DirectLaunch, + first_project_id: None, + }), + None, + format!("{}:session-start", self.context.editor_session_id), + ); + self.persist(); + } + + fn begin_focus(&mut self, reason: FocusReason) { + if self.focus.is_some() || self.closed { + return; + } + let id = Uuid::new_v4().to_string(); + self.emit( + EventData::EditorFocusStart(FocusStart { + focus_interval_id: id.clone(), + focus_reason: reason, + active_project_id: self.active_project.clone(), + }), + self.active_project.clone(), + format!("{id}:focus-start"), + ); + self.focus = Some(Focus { + id, + started: Instant::now(), + }); + } + + fn end_focus(&mut self, reason: BlurReason) { + let Some(focus) = self.focus.take() else { + return; + }; + self.emit( + EventData::EditorFocusEnd(FocusEnd { + focus_interval_id: focus.id.clone(), + blur_reason: reason, + focus_duration_ms: Some(elapsed_ms(focus.started)), + active_project_id: self.active_project.clone(), + }), + self.active_project.clone(), + format!("{}:focus-end", focus.id), + ); + } + + fn windows(&mut self, focused: Option, minimized: bool, initial: bool) { + if self.closed { + return; + } + let changed = self.focused_window.is_some() != focused.is_some(); + if let Some(label) = &focused { + self.active_project = self.projects.get(label).map(|p| p.id.clone()); + if self.active_project.is_some() { + self.last_project = self.active_project.clone(); + } + } + match (self.focused_window.is_some(), focused.is_some()) { + (false, true) => self.begin_focus(if initial { + FocusReason::InitialFocus + } else { + FocusReason::WindowFocus + }), + (true, false) => self.end_focus(if minimized { + BlurReason::Minimized + } else { + BlurReason::WindowBlur + }), + _ => {} + } + // A 窗口切到 B 窗口时整体仍为前台,不生成重叠区间。 + self.focused_window = focused; + if changed { + self.persist(); + } + } + + fn identity(&mut self, route: Route, sequence: u64) { + if self.closed || sequence <= self.identity_sequence || !route.validate() { + return; + } + self.identity_sequence = sequence; + if route == self.context.route { + return; + } + self.end_focus(BlurReason::AccountChange); + // 身份切换会卸载旧工作区,旧项目不能归入新账号的前台区间。 + let left_at = timestamp_now(); + for time in self.navigation_time.values_mut() { + *time = left_at.clone(); + } + self.projects.clear(); + self.pending_open.clear(); + self.active_project = None; + self.last_project = None; + self.context.route = route; + if self.focused_window.is_some() { + self.begin_focus(FocusReason::AccountChange); + } + self.persist(); + } + + fn reserve_open(&mut self, window: &str, operation: &str, path: &str, time: &str) { + match self + .navigation_time + .get(window) + .map(|previous| time.cmp(previous.as_str())) + { + None | Some(std::cmp::Ordering::Greater) => { + self.navigation_time + .insert(window.to_string(), time.to_string()); + self.pending_open.insert( + window.to_string(), + (operation.to_string(), path.to_string()), + ); + } + Some(std::cmp::Ordering::Equal) + if self + .pending_open + .get(window) + .is_none_or(|(id, _)| id != operation) + && self + .projects + .get(window) + .is_none_or(|project| project.operation_id != operation) => + { + // 同一毫秒但投递次序不确定时保留事件,不猜当前项目归属。 + self.pending_open.remove(window); + self.projects.remove(window); + if self.focused_window.as_deref() == Some(window) { + self.active_project = None; + } + } + _ => {} + } + } + + fn project_opened( + &mut self, + window: String, + context: Context, + project: OpenProject, + source: OpenSource, + time: String, + ) { + if self.closed + || self + .projects + .get(&window) + .is_some_and(|old| old.operation_id == project.operation_id) + { + return; + } + let same_identity = context.route == self.context.route; + if let Ok(mut event) = context.capture( + EventData::ProjectOpen(ProjectOpened { + open_source: source, + is_first_open: None, // 后台依据有界的历史观察补充;未知仍保留 null。 + }), + Some(project.id.clone()), + Source::Editor, + None, + ) { + event.event_time = time; + event.event_id = project.operation_id.clone(); + if event.validate().is_err() { + return; + } + self.writer.try_record( + context.route, + event, + format!("{}:project-open", project.operation_id), + ); + } + // 身份捕获或 manifest 读取较慢时,仍记录当时已成功的事实,但不倒退当前工作区。 + if !same_identity + || self + .pending_open + .get(&window) + .is_none_or(|(id, _)| id != &project.operation_id) + { + return; + } + self.pending_open.remove(&window); + self.last_project = Some(project.id.clone()); + if self.focused_window.as_deref() == Some(&window) { + self.active_project = Some(project.id.clone()); + } + self.projects.insert(window, project); + } + + fn leave(&mut self, window: &str, expected_path: Option<&str>) { + self.navigation_time + .insert(window.to_string(), timestamp_now()); + if expected_path.is_none() + || self + .pending_open + .get(window) + .is_some_and(|(_, path)| Some(path.as_str()) == expected_path) + { + self.pending_open.remove(window); + } + if expected_path + .is_some_and(|path| self.projects.get(window).is_none_or(|p| p.path != path)) + { + return; + } + self.projects.remove(window); + if self.focused_window.as_deref() == Some(window) { + self.active_project = None; + } + } + + fn exit(&mut self) { + if self.closed { + return; + } + self.end_focus(BlurReason::AppExit); + self.emit( + EventData::EditorSessionEnd(SessionEnd { + end_reason: if self.restart { + SessionEndReason::AppRestart + } else { + SessionEndReason::UserExit + }, + session_duration_ms: Some(elapsed_ms(self.started)), + last_project_id: self.last_project.clone(), + }), + self.last_project.clone(), + format!("{}:session-end", self.context.editor_session_id), + ); + self.closed = true; + self.persist(); + self.writer.flush(); + } +} + +fn elapsed_ms(start: Instant) -> u64 { + start.elapsed().as_millis().min(9_007_199_254_740_991) as u64 +} + +pub(crate) fn initialize(app: tauri::AppHandle, config_dir: PathBuf, version: String) { + tauri::async_runtime::spawn_blocking(move || { + crate::platform_session::initialize_analytics_identity(|route, sequence| { + let Ok(exiting) = EXITING.lock() else { + return; + }; + if !*exiting { + initialize_with_identity(config_dir, version, route, sequence); + } + }); + if let Some(gui) = GUI.get() { + super::goal::retry_pending(&gui.writer); + } + let handle = app.clone(); + let _ = app.run_on_main_thread(move || observe_windows(&handle, None, false, true)); + }); +} + +fn initialize_with_identity(config_dir: PathBuf, version: String, route: Route, sequence: u64) { + if GUI.get().is_some() { + return; + } + let session_id = Uuid::new_v4().to_string(); + let writer = AnalyticsWriter::start(config_dir.clone(), session_id.clone()); + let context = Context { + route, + editor_session_id: session_id.clone(), + client_version: version.clone(), + }; + let state = GuiState::new(context, sequence, writer.clone()); + if GUI + .set(Service { + session_id, + version, + writer, + state: Mutex::new(state), + }) + .is_ok() + { + with_state(|state| state.start()); + super::upload::start(config_dir); + } +} + +fn with_state(f: impl FnOnce(&mut GuiState) -> T) -> Option { + let gui = GUI.get()?; + // 临界区只更新内存并 try_send,不做磁盘/网络操作,也不重新取得业务锁。 + // 必须串行处理身份与退出通知,不能因瞬时竞争丢失后持续使用旧身份。 + let mut state = gui.state.lock().ok()?; + Some(f(&mut state)) +} + +pub(crate) fn identity_changed(route: Route, sequence: u64) { + with_state(|state| state.identity(route, sequence)); +} + +#[tauri::command] +pub(crate) fn capture_analytics_context() -> Option { + let gui = GUI.get()?; + let (route, _) = crate::platform_session::analytics_identity_snapshot()?; + Some(Context { + route, + editor_session_id: gui.session_id.clone(), + client_version: gui.version.clone(), + }) +} + +pub(crate) fn capture_writer_context() -> Option<(Context, AnalyticsWriter)> { + Some((capture_analytics_context()?, GUI.get()?.writer.clone())) +} + +#[tauri::command] +pub(crate) fn settle_direct_run_analytics(attempt_id: String, discard: bool) { + super::run::settle(capture_writer_context(), &attempt_id, discard); +} + +fn valid_context(context: &Context) -> bool { + GUI.get().is_some_and(|gui| { + context.editor_session_id == gui.session_id + && context.client_version == gui.version + && context.route.validate() + }) +} + +pub(crate) fn created( + context: Option, + project_root: &std::path::Path, + project_id: String, + source: CreationSource, + template_id: Option, +) { + super::goal::created(GUI.get().map(|gui| &gui.writer), project_root, &project_id); + let (Some(context), Some(gui)) = (context, GUI.get()) else { + return; + }; + if !valid_context(&context) { + return; + } + if let Ok(event) = context.capture( + EventData::ProjectCreateSuccess(ProjectCreated { + creation_source: source, + project_template_id: template_id, + }), + Some(project_id.clone()), + Source::Editor, + None, + ) { + gui.writer + .try_record(context.route, event, format!("{project_id}:project-create")); + } +} + +#[tauri::command] +pub(crate) async fn record_analytics_project_open( + window: tauri::Window, + context: Context, + project_path: String, + operation_id: String, + open_source: OpenSource, + event_time: String, +) { + if !valid_context(&context) + || Uuid::parse_str(&operation_id).is_err() + || project_path.len() > 32768 + || !valid_time(&event_time) + { + return; + } + let label = window.label().to_string(); + if with_state(|state| { + state.reserve_open(&label, &operation_id, &project_path, &event_time); + }) + .is_none() + { + return; + } + // 目录/manifest 读取不占用 GUI 线程,也不会把读失败返回给业务操作。 + let _ = tauri::async_runtime::spawn_blocking(move || { + let Ok(root) = crate::validated_local_project_directory_path(&project_path) else { + return; + }; + let Ok(manifest) = crate::read_existing_manifest_for_project(&root) else { + return; + }; + let project = OpenProject { + id: manifest.project_id, + path: project_path, + operation_id, + }; + with_state(|state| state.project_opened(label, context, project, open_source, event_time)); + }) + .await; +} + +#[tauri::command] +pub(crate) fn record_analytics_project_leave(window: tauri::Window, project_path: String) { + with_state(|state| state.leave(window.label(), Some(&project_path))); +} + +#[tauri::command] +pub(crate) async fn record_analytics_ui_save( + context: Context, + project_path: String, + operation_id: String, + save_source: SaveSource, + changed: bool, + event_time: String, +) { + if !valid_context(&context) + || Uuid::parse_str(&operation_id).is_err() + || project_path.len() > 32768 + || !valid_time(&event_time) + || !matches!(save_source, SaveSource::Manual | SaveSource::Auto) + || (save_source == SaveSource::Auto && !changed) + { + return; + } + let Some(gui) = GUI.get() else { return }; + let writer = gui.writer.clone(); + let _ = tauri::async_runtime::spawn_blocking(move || { + let Ok(root) = crate::validated_local_project_directory_path(&project_path) else { + return; + }; + let Ok(manifest) = crate::read_existing_manifest_for_project(&root) else { + return; + }; + super::project::saved( + Some((context, writer)), + &manifest.project_id, + Source::UiEditor, + &operation_id, + ProjectSaved { + save_source, + revision_id: None, + }, + Some(&event_time), + ); + }) + .await; +} + +pub(crate) fn mark_restart() { + with_state(|state| state.restart = true); +} + +pub(crate) fn page_loading(window: &str) { + with_state(|state| state.leave(window, None)); +} + +pub(crate) fn exit() { + if let Ok(mut exiting) = EXITING.lock() { + *exiting = true; + } + with_state(GuiState::exit); +} + +pub(crate) fn observe_windows( + app: &tauri::AppHandle, + excluded: Option<&str>, + minimized: bool, + initial: bool, +) { + if GUI.get().is_none() { + return; + } + let mut focused = None; + let mut unknown = false; + for (label, window) in app.webview_windows() { + if excluded == Some(label.as_str()) { + continue; + } + match ( + window.is_focused(), + window.is_minimized(), + window.is_visible(), + ) { + (Ok(true), Ok(false), Ok(true)) => { + focused = Some(label); + break; + } + (Ok(_), Ok(_), Ok(_)) => {} + _ => unknown = true, + } + } + if focused.is_none() && unknown { + return; + } + with_state(|state| state.windows(focused, minimized, initial)); +} + +pub(crate) fn window_event(window: &tauri::Window, event: &tauri::WindowEvent) { + let destroyed = matches!(event, tauri::WindowEvent::Destroyed); + if destroyed { + with_state(|state| state.leave(window.label(), None)); + } + if destroyed + || matches!( + event, + tauri::WindowEvent::Focused(_) | tauri::WindowEvent::Resized(_) + ) + { + observe_windows( + window.app_handle(), + destroyed.then_some(window.label()), + window.is_minimized().unwrap_or(false), + false, + ); + } +} + +#[cfg(test)] +#[path = "gui_tests.rs"] +mod tests; diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/gui_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/gui_tests.rs new file mode 100644 index 000000000..8c584e9fd --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/gui_tests.rs @@ -0,0 +1,189 @@ +use super::*; +use std::fs; +use std::time::Duration; + +fn state() -> (tempfile::TempDir, GuiState) { + let dir = tempfile::tempdir().unwrap(); + let context = Context { + route: Route::from_identity(Some("A".into()), Some("https://example.com")), + editor_session_id: Uuid::new_v4().to_string(), + client_version: "1.0.0".into(), + }; + let writer = AnalyticsWriter::start(dir.path().into(), context.editor_session_id.clone()); + (dir, GuiState::new(context, 1, writer)) +} + +fn read_events(dir: &std::path::Path, session: &str, expected: usize) -> Vec { + let until = Instant::now() + Duration::from_secs(5); + loop { + let mut events = Vec::new(); + let batches = dir + .join("analytics/instances") + .join(session) + .join("batches"); + if let Ok(entries) = fs::read_dir(batches) { + for entry in entries.flatten() { + if Uuid::parse_str(&entry.file_name().to_string_lossy()).is_err() { + continue; + } + if let Ok(text) = fs::read_to_string(entry.path().join("events.jsonl")) { + events.extend( + text.lines() + .map(|line| serde_json::from_str::(line).unwrap()), + ); + } + } + } + if events.len() == expected { + return events; + } + assert!( + Instant::now() < until, + "expected {expected} events, found {}", + events.len() + ); + std::thread::sleep(Duration::from_millis(10)); + } +} + +#[test] +fn window_union_and_repeated_notifications_form_one_interval() { + let (dir, mut state) = state(); + state.start(); + state.windows(Some("main".into()), false, true); + let interval = state.focus.as_ref().unwrap().id.clone(); + state.windows(Some("launcher".into()), false, false); + state.windows(Some("launcher".into()), false, false); + assert_eq!(state.focus.as_ref().unwrap().id, interval); + state.windows(None, true, false); + state.windows(None, true, false); + state.exit(); + state.exit(); + let events = read_events(dir.path(), &state.context.editor_session_id, 4); + let end = events + .iter() + .find(|e| e.event_name == "editor_focus_end") + .unwrap(); + assert_eq!(end.properties["focus_interval_id"], interval); + assert_eq!(end.properties["blur_reason"], "minimized"); + assert_eq!( + events + .iter() + .filter(|e| e.event_name == "editor_session_end") + .count(), + 1 + ); +} + +#[test] +fn identity_change_splits_focus_but_refresh_and_stale_notice_do_not() { + let (dir, mut state) = state(); + state.start(); + state.windows(Some("main".into()), false, true); + let first = state.focus.as_ref().unwrap().id.clone(); + open( + &mut state, + "project-a", + &Uuid::new_v4().to_string(), + "2000-01-01T00:00:00.000Z", + ); + state.identity(state.context.route.clone(), 2); + assert_eq!(state.focus.as_ref().unwrap().id, first); + let route_b = Route::from_identity(Some("B".into()), Some("https://example.com")); + state.identity(route_b.clone(), 4); + state.identity( + Route::from_identity(Some("A".into()), Some("https://example.com")), + 3, + ); + assert_eq!(state.context.route, route_b); + assert!(state.projects.is_empty()); + assert!(state.active_project.is_none()); + assert_ne!(state.focus.as_ref().unwrap().id, first); + state.restart = true; + state.exit(); + let events = read_events(dir.path(), &state.context.editor_session_id, 7); + let old_end = events + .iter() + .find(|e| e.event_name == "editor_focus_end" && e.user_id.as_deref() == Some("A")) + .unwrap(); + assert_eq!(old_end.properties["blur_reason"], "account_change"); + let new_start = events + .iter() + .find(|e| e.event_name == "editor_focus_start" && e.user_id.as_deref() == Some("B")) + .unwrap(); + assert_eq!(new_start.properties["focus_reason"], "account_change"); + assert!(new_start.project_id.is_none()); + let end = events + .iter() + .find(|e| e.event_name == "editor_session_end") + .unwrap(); + assert_eq!(end.properties["end_reason"], "app_restart"); +} + +fn open(state: &mut GuiState, project_id: &str, operation: &str, time: &str) { + state.reserve_open("main", operation, project_id, time); + state.project_opened( + "main".into(), + state.context.clone(), + OpenProject { + id: project_id.into(), + path: project_id.into(), + operation_id: operation.into(), + }, + OpenSource::Recent, + time.into(), + ); +} + +#[test] +fn explicit_reopen_records_new_operation_and_duplicate_delivery_does_not() { + let (dir, mut state) = state(); + let id = Uuid::new_v4().to_string(); + open(&mut state, "project", &id, "2026-09-21T12:00:00.001Z"); + // 同一次已采纳操作的重复回执由幂等事实键抑制。 + open(&mut state, "project", &id, "2026-09-21T12:00:00.001Z"); + let next = Uuid::new_v4().to_string(); + assert_eq!(state.projects.get("main").unwrap().operation_id, id); + open(&mut state, "project", &next, "2026-09-21T12:00:00.002Z"); + state.writer.flush(); + let events = read_events(dir.path(), &state.context.editor_session_id, 2); + assert!(events.iter().any(|e| e.event_id == id)); + assert!(events.iter().any(|e| e.event_id == next)); + assert!(events[0].properties["is_first_open"].is_null()); + assert_eq!(events[1].properties["is_first_open"], false); +} + +#[test] +fn delayed_capture_or_manifest_read_does_not_restore_a_left_project() { + let (dir, mut state) = state(); + state.windows(Some("main".into()), false, true); + let old = Uuid::new_v4().to_string(); + state.reserve_open("main", &old, "old", "2026-09-21T12:00:00.001Z"); + let new = Uuid::new_v4().to_string(); + open(&mut state, "new", &new, "2026-09-21T12:00:00.002Z"); + state.project_opened( + "main".into(), + state.context.clone(), + OpenProject { + id: "old".into(), + path: "old".into(), + operation_id: old, + }, + OpenSource::Recent, + "2026-09-21T12:00:00.001Z".into(), + ); + assert_eq!(state.active_project.as_deref(), Some("new")); + state.leave("main", Some("new")); + let late = Uuid::new_v4().to_string(); + open(&mut state, "old", &late, "2000-01-01T00:00:00.000Z"); + assert!(state.active_project.is_none()); + state.writer.flush(); + let events = read_events(dir.path(), &state.context.editor_session_id, 4); + assert_eq!( + events + .iter() + .filter(|e| e.event_name == "project_open") + .count(), + 3 + ); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/mod.rs new file mode 100644 index 000000000..59398e170 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/mod.rs @@ -0,0 +1,11 @@ +//! 客户端产品埋点:本地采集、持久化与确认后上传清理。 +pub(crate) mod contract; +pub(crate) mod design; +pub(crate) mod goal; +pub(crate) mod gui; +pub(crate) mod preview; +pub(crate) mod project; +pub(crate) mod run; +pub(crate) mod session; +pub(crate) mod store; +pub(crate) mod upload; diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/preview.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/preview.rs new file mode 100644 index 000000000..3b73b042b --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/preview.rs @@ -0,0 +1,146 @@ +//! 正式 Web 预览的短时可访问性观察;不影响预览业务生命周期。 +use super::{ + contract::{Context, EventData, PreviewReady, PreviewSource, Source}, + store::AnalyticsWriter, +}; +use std::{ + path::{Path, PathBuf}, + sync::{Arc, Weak}, + time::{Duration, Instant}, +}; + +pub(crate) struct Lease { + _alive: Arc<()>, +} + +pub(crate) struct Observation { + alive: Weak<()>, + instance_id: String, + root: PathBuf, + entry: PathBuf, + project_id: String, + revision: u64, + context: Context, + writer: AnalyticsWriter, + source: Source, + preview_source: PreviewSource, + started: Instant, + cancellation: Option>, +} + +fn nonempty_entry(entry: &Path) -> bool { + std::fs::metadata(entry).is_ok_and(|metadata| metadata.is_file() && metadata.len() > 0) +} + +pub(crate) fn prepare( + root: &Path, + capture: Option<(Context, AnalyticsWriter)>, + source: Source, + preview_source: PreviewSource, +) -> Option<(Lease, Observation)> { + let (context, writer) = capture?; + let entry = crate::project_game_root(root).join("index.html"); + if !nonempty_entry(&entry) { + return None; + } + let project_id = crate::read_existing_manifest_for_project(root) + .ok()? + .project_id; + let revision = crate::read_game_creator_agent_runtime_project_revision(root) + .ok()? + .revision; + let alive = Arc::new(()); + let observation = Observation { + alive: Arc::downgrade(&alive), + instance_id: uuid::Uuid::new_v4().to_string(), + root: root.into(), + entry, + project_id, + revision, + context, + writer, + source, + preview_source, + started: Instant::now(), + cancellation: None, + }; + Some((Lease { _alive: alive }, observation)) +} + +impl Observation { + pub(crate) fn with_cancellation( + mut self, + cancellation: Option>, + ) -> Self { + self.cancellation = cancellation; + self + } + + fn current(&self) -> bool { + !self + .cancellation + .as_ref() + .is_some_and(|flag| flag.load(std::sync::atomic::Ordering::Acquire)) + && self.alive.strong_count() > 0 + && self.entry == crate::project_game_root(&self.root).join("index.html") + && nonempty_entry(&self.entry) + && crate::read_game_creator_agent_runtime_project_revision(&self.root) + .is_ok_and(|revision| revision.revision == self.revision) + && crate::read_existing_manifest_for_project(&self.root) + .is_ok_and(|manifest| manifest.project_id == self.project_id) + } + + pub(crate) fn schedule(self, port: u16) { + tauri::async_runtime::spawn(self.observe(port)); + } + + pub(crate) async fn observe(self, port: u16) { + if !self.current() { + return; + } + let reachable = tokio::time::timeout(Duration::from_secs(2), async { + let client = reqwest::Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .build() + .ok()?; + let mut response = client + .get(format!("http://127.0.0.1:{port}/")) + .send() + .await + .ok()?; + if !response.status().is_success() { + return None; + } + while let Some(chunk) = response.chunk().await.ok()? { + if !chunk.is_empty() { + return Some(()); + } + } + None + }) + .await; + if !matches!(reachable, Ok(Some(()))) || !self.current() { + return; + } + let event = self.context.capture( + EventData::PreviewReady(PreviewReady { + preview_source: self.preview_source, + preview_version: self.revision.to_string(), + ready_duration_ms: u64::try_from(self.started.elapsed().as_millis()).ok(), + }), + Some(self.project_id.clone()), + self.source, + None, + ); + if let Ok(event) = event { + if self.current() { + let key = format!( + "{}:{}:{}:preview_ready", + self.project_id, self.instance_id, self.revision + ); + self.writer.try_record(self.context.route, event, key); + } + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/project.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/project.rs new file mode 100644 index 000000000..1a8defebc --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/project.rs @@ -0,0 +1,104 @@ +//! 正式项目成果事件。只接收宿主已提交的版本,不参与业务写入。 +use super::contract::{ChangeKind, Context, EventData, ProjectSaved, RevisionCreated, Source}; +use super::store::AnalyticsWriter; +use std::io::Read; +use std::path::Path; + +pub(crate) fn saved( + capture: Option<(Context, AnalyticsWriter)>, + project_id: &str, + source: Source, + operation_id: &str, + data: ProjectSaved, + event_time: Option<&str>, +) { + let Some((context, writer)) = capture else { + return; + }; + if let Ok(mut event) = context.capture( + EventData::ProjectSave(data), + Some(project_id.into()), + source, + None, + ) { + if let Some(time) = event_time { + event.event_time = time.into(); + } + writer.try_record( + context.route, + event, + format!("{project_id}:{operation_id}:project_save"), + ); + } +} + +pub(crate) fn file_content_changed( + root: &Path, + path: &str, + content: &[u8], + max_bytes: u64, +) -> Option { + let target = crate::resolve_local_project_path(root, path).ok()?; + match std::fs::symlink_metadata(&target) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Some(true), + Err(_) => return None, + Ok(_) => {} + } + let (file, metadata) = + crate::open_project_snapshot_regular_file(&target, "成果内容比较").ok()?; + if metadata.len() > max_bytes { + return None; + } + let mut before = Vec::new(); + file.take(max_bytes.saturating_add(1)) + .read_to_end(&mut before) + .ok()?; + (before.len() as u64 <= max_bytes).then(|| before != content) +} + +pub(crate) fn revision( + capture: Option<(Context, AnalyticsWriter)>, + project_id: &str, + source: Source, + data: RevisionCreated, +) { + let Some((context, writer)) = capture else { + return; + }; + let key = format!("{project_id}:{}:project_revision_created", data.revision_id); + if let Ok(event) = context.capture( + EventData::ProjectRevisionCreated(data), + Some(project_id.into()), + source, + None, + ) { + writer.try_record(context.route, event, key); + } +} + +// 只给已知成果分类;未知文件不猜字段,控制面和构建缓存不计成果。 +pub(crate) fn file_change_kind(path: &str) -> Option { + let path = path.to_ascii_lowercase(); + if crate::should_skip_project_snapshot_path(&path) + || path.split('/').any(|part| part.starts_with('.')) + { + return None; + } + if path.starts_with("design_artifacts/") { + return Some(ChangeKind::DesignDocument); + } + if path.starts_with("ui/") { + return Some(ChangeKind::Ui); + } + let extension = path.rsplit_once('.')?.1; + match extension { + "png" | "jpg" | "jpeg" | "webp" | "gif" | "svg" | "avif" | "mp3" | "wav" | "ogg" + | "flac" | "mp4" | "webm" | "glb" | "gltf" | "ttf" | "woff" | "woff2" => { + Some(ChangeKind::Asset) + } + "js" | "jsx" | "ts" | "tsx" | "mjs" | "cjs" | "html" | "css" | "scss" | "json" | "vue" + | "svelte" | "gd" | "tscn" | "tres" | "cs" | "shader" | "glsl" | "wgsl" | "vert" + | "frag" | "rs" | "py" | "lua" | "cpp" | "h" | "c" | "hpp" => Some(ChangeKind::Code), + _ => None, + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/run.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/run.rs new file mode 100644 index 000000000..49673bb20 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/run.rs @@ -0,0 +1,400 @@ +//! 可观测运行序号和终态消费。双 Agent 各保留一个槽位,不保存历史或身份。 +use super::contract::{ + self, AgentType, Context, ErrorCode, Event, EventData, Route, RunEndReason, RunFinished, + RunIdentity, RunSource, Source, +}; +use super::store::AnalyticsWriter; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +const PATH: &str = ".agent/analytics-runs.json"; +const MAX_BYTES: usize = 4096; +const MAX_INTEGER: u64 = 9_007_199_254_740_991; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub(crate) struct Metadata { + pub context: Context, + pub run_id: String, + pub terminal_event_id: String, + pub source: Source, + pub run_source: RunSource, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_revision: Option, +} + +impl Metadata { + pub(crate) fn new(context: Context, source: Source, run_source: RunSource) -> Self { + Self { + context, + source, + run_source, + run_id: uuid::Uuid::new_v4().to_string(), + terminal_event_id: uuid::Uuid::new_v4().to_string(), + output_revision: None, + } + } + fn validate(&self) -> bool { + matches!(self.source, Source::Direct | Source::DesignAgent) + && uuid::Uuid::parse_str(&self.run_id).is_ok() + && uuid::Uuid::parse_str(&self.terminal_event_id).is_ok() + && self.context.route.validate() + && self.output_revision.as_deref().is_none_or(valid_id) + } +} + +#[derive(Clone, Debug, Serialize)] +pub(crate) struct Outcome { + pub turn_id: Option, + pub end_reason: RunEndReason, + pub error_code: Option, + pub duration_ms: Option, + pub output_change_detected: Option, + pub revision_id: Option, +} + +#[derive(Serialize)] +pub(super) enum Request { + DirectCandidate { + attempt_id: String, + terminal: Box, + }, + Settle { + editor_session_id: String, + attempt_id: String, + discard: bool, + }, + Accepted { + root: PathBuf, + project_id: String, + metadata: Metadata, + }, + Terminal { + root: PathBuf, + project_id: String, + metadata: Metadata, + context: Context, + event_time: String, + outcome: Outcome, + }, +} + +impl Request { + pub(super) fn session_id(&self) -> &str { + match self { + Self::DirectCandidate { terminal, .. } => terminal.session_id(), + Self::Settle { + editor_session_id, .. + } => editor_session_id, + Self::Accepted { metadata, .. } => &metadata.context.editor_session_id, + Self::Terminal { context, .. } => &context.editor_session_id, + } + } + pub(super) fn validate(&self) -> bool { + match self { + Self::DirectCandidate { + attempt_id, + terminal, + } => { + return uuid::Uuid::parse_str(attempt_id).is_ok() + && matches!(terminal.as_ref(), Self::Terminal { metadata, .. } if metadata.source == Source::Direct) + && terminal.validate(); + } + Self::Settle { + editor_session_id, + attempt_id, + .. + } => { + return uuid::Uuid::parse_str(editor_session_id).is_ok() + && uuid::Uuid::parse_str(attempt_id).is_ok(); + } + _ => {} + } + let (root, project_id, metadata) = match self { + Self::Accepted { + root, + project_id, + metadata, + } => (root, project_id, metadata), + Self::Terminal { + root, + project_id, + metadata, + .. + } => (root, project_id, metadata), + _ => return false, + }; + root.is_absolute() + && root.as_os_str().len() <= 32768 + && valid_id(project_id) + && metadata.validate() + } +} + +pub(crate) fn accepted( + writer: &AnalyticsWriter, + root: &Path, + project_id: &str, + metadata: &Metadata, +) { + writer.try_run(Request::Accepted { + root: root.into(), + project_id: project_id.into(), + metadata: metadata.clone(), + }); +} + +pub(crate) fn finished( + capture: Option<(Context, AnalyticsWriter)>, + root: &Path, + project_id: &str, + metadata: &Metadata, + outcome: Outcome, +) { + let Some((mut context, writer)) = capture else { + return; + }; + // 恢复后的 GUI 会话属于新实例,但账号和目标平台仍属于原运行。 + context.route = metadata.context.route.clone(); + writer.try_run(Request::Terminal { + root: root.into(), + project_id: project_id.into(), + metadata: metadata.clone(), + context, + event_time: contract::timestamp_now(), + outcome, + }); +} + +pub(crate) fn direct_finished( + capture: Option<(Context, AnalyticsWriter)>, + root: &Path, + project_id: &str, + metadata: &Metadata, + attempt_id: Option<&str>, + outcome: Outcome, +) { + let Some(attempt_id) = attempt_id else { return }; + let Some((mut context, writer)) = capture else { + return; + }; + if metadata.source != Source::Direct { + return; + } + context.route = metadata.context.route.clone(); + writer.try_run(Request::DirectCandidate { + attempt_id: attempt_id.into(), + terminal: Box::new(Request::Terminal { + root: root.into(), + project_id: project_id.into(), + metadata: metadata.clone(), + context, + event_time: contract::timestamp_now(), + outcome, + }), + }); +} + +pub(crate) fn settle(capture: Option<(Context, AnalyticsWriter)>, attempt_id: &str, discard: bool) { + let Some((context, writer)) = capture else { + return; + }; + writer.try_run(Request::Settle { + editor_session_id: context.editor_session_id, + attempt_id: attempt_id.into(), + discard, + }); +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Slot { + run_id: String, + retry_index: u64, + terminal_consumed: bool, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct State { + schema_version: u32, + project_id: String, + retry_count: u64, + direct: Option, + design: Option, +} + +impl State { + fn slot(&mut self, source: Source) -> &mut Option { + if source == Source::Direct { + &mut self.direct + } else { + &mut self.design + } + } + fn validate(&self, project_id: &str) -> bool { + self.schema_version == 1 + && self.project_id == project_id + && self.retry_count <= MAX_INTEGER + && [&self.direct, &self.design] + .into_iter() + .flatten() + .all(|slot| { + uuid::Uuid::parse_str(&slot.run_id).is_ok() + && slot.retry_index <= self.retry_count + }) + } +} + +fn valid_id(value: &str) -> bool { + !value.trim().is_empty() && value.len() <= 256 && !value.chars().any(char::is_control) +} + +pub(super) fn process(request: Request) -> Result, String> { + if !request.validate() { + return Ok(None); + } + let (root, project_id, metadata) = match &request { + Request::Accepted { + root, + project_id, + metadata, + } + | Request::Terminal { + root, + project_id, + metadata, + .. + } => (root, project_id, metadata), + _ => return Ok(None), + }; + let Some(_lock) = + crate::agent::try_acquire_game_creator_agent_runtime_task_lock(root, "analytics-runs")? + else { + return Ok(None); + }; + let manifest_path = crate::project::resolve_local_project_path(root, ".agent/manifest.json")?; + if crate::project::read_manifest(&manifest_path)?.project_id != *project_id { + return Ok(None); + } + let path = crate::project::resolve_local_project_path(root, PATH)?; + let mut state = match std::fs::symlink_metadata(&path) { + Ok(meta) if meta.is_file() && !meta.file_type().is_symlink() => { + let Some(state): Option = + crate::agent::read_agent_runtime_json_sidecar_with_max_bytes( + root, + PATH, + "运行埋点序号", + MAX_BYTES, + )? + else { + return Ok(None); + }; + if !state.validate(project_id) { + return Ok(None); + } + state + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + if !matches!(request, Request::Accepted { .. }) { + return Ok(None); + } + let backup = crate::agent::agent_runtime_json_sidecar_backup_path(&path); + if !matches!(std::fs::symlink_metadata(backup), Err(ref e) if e.kind() == std::io::ErrorKind::NotFound) + { + return Ok(None); + } + State { + schema_version: 1, + project_id: project_id.clone(), + retry_count: 0, + direct: None, + design: None, + } + } + _ => return Ok(None), + }; + let event = match &request { + Request::Accepted { .. } => { + if state + .slot(metadata.source) + .as_ref() + .is_some_and(|slot| slot.run_id == metadata.run_id) + { + return Ok(None); + } + if metadata.run_source == RunSource::UserRetry { + if state.retry_count == MAX_INTEGER { + return Ok(None); + } + state.retry_count += 1; + } + let ordinal = state.retry_count; + *state.slot(metadata.source) = Some(Slot { + run_id: metadata.run_id.clone(), + retry_index: ordinal, + terminal_consumed: false, + }); + None + } + Request::Terminal { + context, + event_time, + outcome, + .. + } => { + let Some(slot) = state.slot(metadata.source) else { + return Ok(None); + }; + if slot.run_id != metadata.run_id || slot.terminal_consumed { + return Ok(None); + } + let data = RunFinished { + agent_type: if metadata.source == Source::Direct { + AgentType::GameAgent + } else { + AgentType::DesignAgent + }, + run_source: metadata.run_source, + duration_ms: outcome.duration_ms, + retry_index: slot.retry_index, + output_change_detected: outcome.output_change_detected, + revision_id: outcome.revision_id.clone(), + end_reason: outcome.end_reason, + }; + let data = if outcome.end_reason == RunEndReason::Failed { + EventData::AgentRunFailed(data) + } else { + EventData::AgentRunCompleted(data) + }; + let Ok(mut event) = context.capture( + data, + Some(project_id.clone()), + metadata.source, + Some(RunIdentity { + run_id: metadata.run_id.clone(), + turn_id: outcome.turn_id.clone(), + error_code: outcome.error_code, + }), + ) else { + return Ok(None); + }; + event.event_id = metadata.terminal_event_id.clone(); + event.event_time = event_time.clone(); + if event.validate().is_err() { + return Ok(None); + } + slot.terminal_consumed = true; + Some((context.route.clone(), event)) + } + _ => return Ok(None), + }; + crate::agent::write_agent_runtime_json_sidecar_with_max_bytes( + root, + PATH, + "运行埋点序号", + &state, + MAX_BYTES, + )?; + Ok(event) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/session.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/session.rs new file mode 100644 index 000000000..9c86a3590 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/session.rs @@ -0,0 +1,248 @@ +//! 实例所有权与本地会话状态;仅后台 writer 访问磁盘。 +use super::contract::Route; +use super::store::{invalid_data, read_bounded, safe_metadata}; +use serde::{Deserialize, Serialize}; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Write}; +use std::path::Path; +use std::time::{Duration, SystemTime}; + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub(crate) enum LifecycleState { + Active, + Closed, + Incomplete, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct SessionRecord { + pub schema_version: u32, + pub editor_session_id: String, + pub route: Route, + pub lifecycle_state: LifecycleState, + pub focus_interval_id: Option, + pub updated_at: String, + pub incomplete_detected_at: Option, +} + +impl SessionRecord { + pub(crate) fn validate(&self) -> bool { + self.updated_at.len() <= 64 + && self + .incomplete_detected_at + .as_ref() + .is_none_or(|s| s.len() <= 64) + && self.route.user_id.as_ref().is_none_or(|s| s.len() <= 4096) + && self + .route + .destination_origin + .as_ref() + .is_none_or(|s| s.len() <= 4096) + && self.schema_version == 1 + && uuid::Uuid::parse_str(&self.editor_session_id).is_ok() + && self.route.validate() + && self + .focus_interval_id + .as_ref() + .is_none_or(|id| uuid::Uuid::parse_str(id).is_ok()) + && chrono::DateTime::parse_from_rfc3339(&self.updated_at).is_ok() + && self + .incomplete_detected_at + .as_ref() + .is_none_or(|s| chrono::DateTime::parse_from_rfc3339(s).is_ok()) + && (self.lifecycle_state == LifecycleState::Incomplete) + == self.incomplete_detected_at.is_some() + } +} + +pub(super) fn claim(instance: &Path) -> io::Result { + let path = instance.join("owner.lock"); + if path.exists() { + safe_metadata(&path)?; + } + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(path)?; + file.try_lock().map_err(|_| invalid_data())?; + Ok(file) +} + +fn existing_owner(instance: &Path) -> io::Result { + let path = instance.join("owner.lock"); + if !safe_metadata(&path)?.is_file() { + return Err(invalid_data()); + } + let file = OpenOptions::new().read(true).write(true).open(path)?; + file.try_lock().map_err(|_| invalid_data())?; + Ok(file) +} + +pub(super) fn write(instance: &Path, record: &SessionRecord, session: &str) -> io::Result<()> { + if !record.validate() || record.editor_session_id != session { + return Err(invalid_data()); + } + let bytes = serde_json::to_vec(record)?; + if bytes.len() > 16 * 1024 { + return Err(invalid_data()); + } + let target = instance.join("session.json"); + if target.exists() { + safe_metadata(&target)?; + } + let temporary = instance.join(format!(".session-{}.tmp", uuid::Uuid::new_v4())); + let result = (|| { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary)?; + file.write_all(&bytes)?; + file.sync_all()?; + drop(file); + fs::rename(&temporary, target) + })(); + if result.is_err() { + let _ = fs::remove_file(temporary); + } + result +} + +fn read(instance: &Path) -> io::Result { + let record: SessionRecord = + serde_json::from_slice(&read_bounded(&instance.join("session.json"), 16 * 1024)?)?; + if !record.validate() + || instance.file_name().and_then(|s| s.to_str()) != Some(record.editor_session_id.as_str()) + { + return Err(invalid_data()); + } + Ok(record) +} + +pub(super) fn recover(instances: &Path, current: &str) { + let Ok(entries) = fs::read_dir(instances) else { + return; + }; + for entry in entries.flatten() { + if entry.file_name() == current || !safe_metadata(&entry.path()).is_ok_and(|m| m.is_dir()) { + continue; + } + let Ok(_owner) = existing_owner(&entry.path()) else { + continue; + }; + let Ok(mut record) = read(&entry.path()) else { + continue; + }; + if record.lifecycle_state != LifecycleState::Active { + continue; + } + record.lifecycle_state = LifecycleState::Incomplete; + record.incomplete_detected_at = Some(super::contract::timestamp_now()); + let _ = write(&entry.path(), &record, &record.editor_session_id); + } +} + +pub(super) fn prune( + instances: &Path, + current: &str, + reserve: u64, + limit: u64, + retention: Duration, + size: impl Fn() -> u64, +) { + let Ok(entries) = fs::read_dir(instances) else { + return; + }; + let mut candidates = Vec::new(); + let mut inactive = Vec::new(); + for entry in entries.flatten() { + if entry.file_name() == current + || uuid::Uuid::parse_str(&entry.file_name().to_string_lossy()).is_err() + || !safe_metadata(&entry.path()).is_ok_and(|m| m.is_dir()) + { + continue; + } + // 文件锁是存活证明;损坏或未写完的 JSON 不能永久阻止队列清理。 + let Ok(_owner) = existing_owner(&entry.path()) else { + continue; + }; + let Ok(files) = fs::read_dir(entry.path()) else { + continue; + }; + for file in files.flatten() { + let name = file.file_name(); + let name = name.to_string_lossy(); + let temporary = name + .strip_prefix(".session-") + .and_then(|name| name.strip_suffix(".tmp")) + .is_some_and(|id| uuid::Uuid::parse_str(id).is_ok()); + if name != "session.json" && !temporary { + continue; + } + let Ok(meta) = safe_metadata(&file.path()) else { + continue; + }; + if !meta.is_file() { + continue; + } + let Ok(modified) = meta.modified() else { + continue; + }; + candidates.push((modified, entry.path(), file.path())); + } + inactive.push(entry.path()); + } + candidates.sort_by_key(|(modified, _, _)| *modified); + for (modified, instance, path) in candidates { + let expired = SystemTime::now() + .duration_since(modified) + .is_ok_and(|age| age >= retention); + if !expired && size().saturating_add(reserve) <= limit { + continue; + } + let Ok(_owner) = existing_owner(&instance) else { + continue; + }; + if safe_metadata(&path).is_ok_and(|meta| meta.is_file()) { + let _ = fs::remove_file(path); + } + } + for instance in inactive { + let Ok(_owner) = existing_owner(&instance) else { + continue; + }; + remove_empty_instance(&instance); + } +} + +fn remove_empty_instance(instance: &Path) { + // 只删除空目录及普通 owner.lock,不递归删除未知文件或跟随链接。 + let batches = instance.join("batches"); + if safe_metadata(&batches).is_ok_and(|meta| meta.is_dir()) { + let _ = fs::remove_dir(&batches); + } + let Ok(entries) = fs::read_dir(instance) else { + return; + }; + let Ok(entries) = entries.collect::, _>>() else { + return; + }; + if entries.len() != 1 || entries[0].file_name() != "owner.lock" { + return; + } + let owner = instance.join("owner.lock"); + if !safe_metadata(&owner).is_ok_and(|meta| meta.is_file()) { + return; + } + // 调用者仍持有锁;实例 UUID 不复用,其他清理者不能同时取得所有权。 + if fs::remove_file(owner).is_ok() { + let _ = fs::remove_dir(instance); + } +} + +#[cfg(test)] +#[path = "session_tests.rs"] +mod tests; diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/session_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/session_tests.rs new file mode 100644 index 000000000..fd4c84c9b --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/session_tests.rs @@ -0,0 +1,101 @@ +use super::*; + +fn fixture() -> (tempfile::TempDir, std::path::PathBuf, SessionRecord) { + let dir = tempfile::tempdir().unwrap(); + let id = uuid::Uuid::new_v4().to_string(); + let instance = dir.path().join(&id); + fs::create_dir(&instance).unwrap(); + let record = SessionRecord { + schema_version: 1, + editor_session_id: id, + route: Route { + user_id: Some("user".into()), + destination_origin: Some("https://example.com".into()), + }, + lifecycle_state: LifecycleState::Active, + focus_interval_id: None, + updated_at: "2026-09-21T00:00:00.000Z".into(), + incomplete_detected_at: None, + }; + (dir, instance, record) +} + +#[test] +fn locked_owner_is_live_and_released_owner_is_recovered_once() { + let (dir, path, record) = fixture(); + let owner = claim(&path).unwrap(); + write(&path, &record, &record.editor_session_id).unwrap(); + recover(dir.path(), "another"); + assert_eq!(read(&path).unwrap().lifecycle_state, LifecycleState::Active); + drop(owner); + recover(dir.path(), "another"); + let recovered = read(&path).unwrap(); + assert_eq!(recovered.lifecycle_state, LifecycleState::Incomplete); + assert_eq!(recovered.updated_at, record.updated_at); + let bytes = fs::read(path.join("session.json")).unwrap(); + recover(dir.path(), "another"); + assert_eq!(fs::read(path.join("session.json")).unwrap(), bytes); +} + +#[test] +fn missing_proof_and_wrong_identity_are_untouched() { + let (dir, path, record) = fixture(); + write(&path, &record, &record.editor_session_id).unwrap(); + recover(dir.path(), "another"); + assert_eq!(read(&path).unwrap().lifecycle_state, LifecycleState::Active); + drop(claim(&path).unwrap()); + let mut wrong = record.clone(); + wrong.editor_session_id = uuid::Uuid::new_v4().to_string(); + fs::write( + path.join("session.json"), + serde_json::to_vec(&wrong).unwrap(), + ) + .unwrap(); + let before = fs::read(path.join("session.json")).unwrap(); + recover(dir.path(), "another"); + assert_eq!(fs::read(path.join("session.json")).unwrap(), before); +} + +#[test] +fn metadata_prune_skips_live_owner_and_removes_inactive_record() { + let (dir, path, mut record) = fixture(); + let owner = claim(&path).unwrap(); + record.lifecycle_state = LifecycleState::Closed; + write(&path, &record, &record.editor_session_id).unwrap(); + prune(dir.path(), "other", 0, 0, Duration::ZERO, || 100); + assert!(path.join("session.json").exists()); + drop(owner); + prune(dir.path(), "other", 0, 0, Duration::ZERO, || 100); + assert!(!path.join("session.json").exists()); +} + +#[test] +fn corrupt_and_abandoned_session_files_are_bounded_and_empty_instance_is_removed() { + let (dir, path, _) = fixture(); + drop(claim(&path).unwrap()); + fs::create_dir(path.join("batches")).unwrap(); + fs::write(path.join("session.json"), b"broken-json").unwrap(); + let temporary = path.join(format!(".session-{}.tmp", uuid::Uuid::new_v4())); + fs::write(&temporary, b"unfinished").unwrap(); + prune(dir.path(), "other", 0, u64::MAX, Duration::ZERO, || 100); + assert!(!path.exists()); +} + +#[test] +fn pressure_prunes_corrupt_metadata_but_keeps_live_and_unknown_files() { + let (dir, path, _) = fixture(); + let owner = claim(&path).unwrap(); + fs::write(path.join("session.json"), b"broken").unwrap(); + let temporary = path.join(format!(".session-{}.tmp", uuid::Uuid::new_v4())); + fs::write(&temporary, b"unfinished").unwrap(); + fs::write(path.join("unknown.json"), b"untouched").unwrap(); + prune(dir.path(), "other", 1, 0, Duration::MAX, || 100); + assert!(path.join("session.json").exists()); + assert!(temporary.exists()); + drop(owner); + prune(dir.path(), "other", 1, 0, Duration::MAX, || 100); + assert!(!path.join("session.json").exists()); + assert!(!temporary.exists()); + assert!(path.join("unknown.json").exists()); + assert!(path.join("owner.lock").exists()); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/store.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/store.rs new file mode 100644 index 000000000..f9bb615be --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/store.rs @@ -0,0 +1,1027 @@ +//! 本地产品事件队列。业务调用只投递有界通道,磁盘工作由单独线程完成。 +use super::contract::{Event, Route}; +use super::session::{self, SessionRecord}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::fs::{self, File}; +use std::io::{self, Read, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{mpsc, Arc}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +const MAX_EVENTS: usize = 500; +const MAX_BYTES: usize = 1024 * 1024; +const MAX_TOTAL_BYTES: u64 = 20 * 1024 * 1024; +const RETENTION: Duration = Duration::from_secs(7 * 24 * 60 * 60); +const SEAL_AFTER: Duration = Duration::from_secs(5 * 60); +const QUEUE_CAPACITY: usize = 1024; +const MAX_META_BYTES: usize = 256 * 1024; +const MAX_LIVE_OBSERVATIONS: usize = 16_384; + +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct StoreCounters { + pub accepted: u64, + pub dropped: u64, + pub duplicate: u64, + pub corrupt_batches: u64, + pub io_errors: u64, +} + +#[derive(Default)] +struct Counters { + queued_bytes: AtomicU64, + accepted: AtomicU64, + dropped: AtomicU64, + duplicate: AtomicU64, + corrupt_batches: AtomicU64, + io_errors: AtomicU64, +} + +enum Command { + Record(Route, Event, String, u64), + Session(SessionRecord, u64), + Goal(super::goal::Request, u64), + Run(super::run::Request, u64), + Flush, +} + +#[derive(Clone)] +pub(crate) struct AnalyticsWriter { + sender: mpsc::SyncSender, + counters: Arc, +} + +impl AnalyticsWriter { + pub(crate) fn start(config_dir: PathBuf, session_id: String) -> Self { + let (sender, receiver) = mpsc::sync_channel(QUEUE_CAPACITY); + let counters = Arc::new(Counters::default()); + let worker_counters = counters.clone(); + let result = std::thread::Builder::new() + .name("local-analytics".into()) + .spawn(move || { + let mut store = match Store::open(config_dir, session_id, worker_counters.clone()) { + Ok(store) => store, + Err(_) => { + worker_counters.io_errors.fetch_add(1, Ordering::Relaxed); + return; + } + }; + loop { + if store + .started + .is_some_and(|start| start.elapsed() >= SEAL_AFTER) + { + store.seal(); + } + let wait = store + .started + .map(|start| SEAL_AFTER.saturating_sub(start.elapsed())) + .unwrap_or(SEAL_AFTER); + match receiver.recv_timeout(wait) { + Ok(Command::Record(route, event, key, bytes)) => { + worker_counters + .queued_bytes + .fetch_sub(bytes, Ordering::Relaxed); + store.record(route, event, &key); + } + Ok(Command::Run(request, bytes)) => { + worker_counters + .queued_bytes + .fetch_sub(bytes, Ordering::Relaxed); + store.run_request(request, bytes); + } + Ok(Command::Goal(request, bytes)) => { + worker_counters + .queued_bytes + .fetch_sub(bytes, Ordering::Relaxed); + // 身份校验必须发生在消费资格前。 + let valid_session = match &request { + super::goal::Request::Accepted { event, .. } => { + event.editor_session_id == store.session_id + } + _ => true, + }; + if valid_session { + match super::goal::process(request) { + Ok(Some((route, event))) => { + let fact = format!( + "{}:creative_task_submit", + event.project_id.as_deref().unwrap() + ); + store.record(route, event, &fact); + } + Ok(None) => {} + Err(_) => { + worker_counters.io_errors.fetch_add(1, Ordering::Relaxed); + } + } + } else { + worker_counters.dropped.fetch_add(1, Ordering::Relaxed); + } + } + Ok(Command::Session(record, bytes)) => { + worker_counters + .queued_bytes + .fetch_sub(bytes, Ordering::Relaxed); + if store.write_session(&record, bytes).is_err() { + worker_counters.io_errors.fetch_add(1, Ordering::Relaxed); + } + } + Ok(Command::Flush) | Err(mpsc::RecvTimeoutError::Timeout) => store.seal(), + Err(mpsc::RecvTimeoutError::Disconnected) => { + store.seal(); + break; + } + } + } + }); + if result.is_err() { + counters.io_errors.fetch_add(1, Ordering::Relaxed); + } + Self { sender, counters } + } + + /// 不等待队列、磁盘或后台线程;调用方不得将 false 转成业务错误。 + pub(crate) fn try_record(&self, route: Route, event: Event, fact_key: String) -> bool { + // 同时限制条数与字节数,巨型输入不得先占满队列再交给后台拒绝。 + let mut size = LimitedSize(1); + if fact_key.is_empty() + || fact_key.len() > 4096 + || !route.validate() + || event.validate().is_err() + || route.user_id != event.user_id + || serde_json::to_writer(&mut size, &event).is_err() + || serde_json::to_writer(&mut size, &route).is_err() + { + self.counters.dropped.fetch_add(1, Ordering::Relaxed); + return false; + } + let bytes = (size.0 + fact_key.len()) as u64; + if self + .counters + .queued_bytes + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| { + (current.saturating_add(bytes) <= MAX_BYTES as u64).then_some(current + bytes) + }) + .is_err() + { + self.counters.dropped.fetch_add(1, Ordering::Relaxed); + return false; + } + if self + .sender + .try_send(Command::Record(route, event, fact_key, bytes)) + .is_ok() + { + true + } else { + self.counters + .queued_bytes + .fetch_sub(bytes, Ordering::Relaxed); + self.counters.dropped.fetch_add(1, Ordering::Relaxed); + false + } + } + + pub(crate) fn try_session(&self, record: SessionRecord) -> bool { + let mut size = LimitedSize(0); + if !record.validate() + || serde_json::to_writer(&mut size, &record).is_err() + || size.0 > 16 * 1024 + { + self.counters.dropped.fetch_add(1, Ordering::Relaxed); + return false; + } + let bytes = size.0 as u64; + if self + .counters + .queued_bytes + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| { + (current.saturating_add(bytes) <= MAX_BYTES as u64).then_some(current + bytes) + }) + .is_err() + { + self.counters.dropped.fetch_add(1, Ordering::Relaxed); + return false; + } + if self + .sender + .try_send(Command::Session(record, bytes)) + .is_ok() + { + true + } else { + self.counters + .queued_bytes + .fetch_sub(bytes, Ordering::Relaxed); + self.counters.dropped.fetch_add(1, Ordering::Relaxed); + false + } + } + + pub(super) fn try_goal(&self, request: super::goal::Request) -> bool { + let mut size = LimitedSize(0); + if !request.validate() || serde_json::to_writer(&mut size, &request).is_err() { + self.counters.dropped.fetch_add(1, Ordering::Relaxed); + return false; + } + let bytes = size.0 as u64; + if self + .counters + .queued_bytes + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| { + (current.saturating_add(bytes) <= MAX_BYTES as u64).then_some(current + bytes) + }) + .is_err() + { + self.counters.dropped.fetch_add(1, Ordering::Relaxed); + return false; + } + if self.sender.try_send(Command::Goal(request, bytes)).is_ok() { + true + } else { + self.counters + .queued_bytes + .fetch_sub(bytes, Ordering::Relaxed); + self.counters.dropped.fetch_add(1, Ordering::Relaxed); + false + } + } + + pub(super) fn try_run(&self, request: super::run::Request) -> bool { + let mut size = LimitedSize(0); + if !request.validate() || serde_json::to_writer(&mut size, &request).is_err() { + self.counters.dropped.fetch_add(1, Ordering::Relaxed); + return false; + } + let bytes = size.0 as u64; + if self + .counters + .queued_bytes + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| { + (current.saturating_add(bytes) <= MAX_BYTES as u64).then_some(current + bytes) + }) + .is_err() + { + self.counters.dropped.fetch_add(1, Ordering::Relaxed); + return false; + } + if self.sender.try_send(Command::Run(request, bytes)).is_ok() { + true + } else { + self.counters + .queued_bytes + .fetch_sub(bytes, Ordering::Relaxed); + self.counters.dropped.fetch_add(1, Ordering::Relaxed); + false + } + } + + pub(crate) fn flush(&self) -> bool { + self.sender.try_send(Command::Flush).is_ok() + } + + pub(crate) fn counters(&self) -> StoreCounters { + StoreCounters { + accepted: self.counters.accepted.load(Ordering::Relaxed), + dropped: self.counters.dropped.load(Ordering::Relaxed), + duplicate: self.counters.duplicate.load(Ordering::Relaxed), + corrupt_batches: self.counters.corrupt_batches.load(Ordering::Relaxed), + io_errors: self.counters.io_errors.load(Ordering::Relaxed), + } + } +} + +struct LimitedSize(usize); + +impl Write for LimitedSize { + fn write(&mut self, bytes: &[u8]) -> io::Result { + if self.0.saturating_add(bytes.len()) > MAX_BYTES { + return Err(invalid_data()); + } + self.0 += bytes.len(); + Ok(bytes.len()) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct BatchMeta { + schema_version: u32, + batch_id: String, + editor_session_id: String, + route: Route, + created_at_ms: u64, + event_count: usize, + event_ids: Vec, + facts: HashMap, +} + +struct Batch { + path: PathBuf, + modified: SystemTime, +} + +struct Store { + pending_runs: VecDeque<(String, super::run::Request, u64)>, + pending_run_bytes: u64, + root: PathBuf, + _owner: File, + projects: HashMap, + batches: PathBuf, + session_id: String, + route: Option, + events: Vec<(Event, String, Vec)>, + bytes: usize, + started: Option, + live_facts: VecDeque<(Instant, String, String)>, + live_projects: HashMap, + known: HashMap, + known_ids: HashSet, + counters: Arc, +} + +impl Store { + fn open(config: PathBuf, session_id: String, counters: Arc) -> io::Result { + if uuid::Uuid::parse_str(&session_id).is_err() { + return Err(invalid_data()); + } + let root = config.join("analytics"); + let instances = root.join("instances"); + let instance = instances.join(&session_id); + let batches = instance.join("batches"); + for directory in [&root, &instances, &instance, &batches] { + ensure_directory(directory)?; + } + let owner = session::claim(&instance)?; + session::recover(&instances, &session_id); + let mut store = Self { + pending_runs: VecDeque::new(), + pending_run_bytes: 0, + root, + _owner: owner, + projects: HashMap::new(), + batches, + session_id, + route: None, + events: Vec::new(), + bytes: 0, + started: None, + live_facts: VecDeque::new(), + live_projects: HashMap::new(), + known: HashMap::new(), + known_ids: HashSet::new(), + counters, + }; + // session UUID 必须由 GUI 为本次新实例生成;不触碰其他实例的临时目录。 + for entry in fs::read_dir(&store.batches)? { + let entry = entry?; + if entry.file_name().to_string_lossy().starts_with(".tmp-") { + let _ = remove_batch(&entry.path()); + } + } + store.prune(0)?; + Ok(store) + } + + fn run_request(&mut self, request: super::run::Request, bytes: u64) { + use super::run::Request; + if !request.validate() || request.session_id() != self.session_id { + self.counters.dropped.fetch_add(1, Ordering::Relaxed); + return; + } + let request = match request { + Request::DirectCandidate { + attempt_id, + terminal, + } => { + if bytes > MAX_BYTES as u64 { + return; + } + if self.pending_runs.iter().any(|(id, _, _)| id == &attempt_id) { + return; + } + while self.pending_runs.len() >= 16 + || self.pending_run_bytes + bytes > MAX_BYTES as u64 + { + let Some((_, _, removed)) = self.pending_runs.pop_front() else { + break; + }; + self.pending_run_bytes -= removed; + self.counters.dropped.fetch_add(1, Ordering::Relaxed); + } + self.pending_run_bytes += bytes; + self.pending_runs.push_back((attempt_id, *terminal, bytes)); + return; + } + Request::Settle { + attempt_id, + discard, + .. + } => { + let Some(index) = self + .pending_runs + .iter() + .position(|(id, _, _)| id == &attempt_id) + else { + return; + }; + let (_, terminal, removed) = self.pending_runs.remove(index).unwrap(); + self.pending_run_bytes -= removed; + if discard { + return; + } + terminal + } + other => other, + }; + match super::run::process(request) { + Ok(Some((route, event))) => { + let fact = format!("{}:terminal", event.agent_run_id.as_deref().unwrap()); + self.record(route, event, &fact); + } + Ok(None) => {} + Err(_) => { + self.counters.io_errors.fetch_add(1, Ordering::Relaxed); + } + } + } + + fn write_session(&mut self, record: &SessionRecord, bytes: u64) -> io::Result<()> { + // 原子替换期间旧文件与临时文件同时存在,预留完整新文件大小。 + self.prune(bytes)?; + session::write(self.batches.parent().unwrap(), record, &self.session_id) + } + + fn recover(&mut self) { + let Ok(batches) = list_batches(&self.root) else { + return; + }; + for batch in batches { + match read_batch(&batch.path) { + Ok(meta) => { + if meta.facts.iter().any(|(key, id)| { + self.known.contains_key(key) || self.known_ids.contains(id) + }) { + self.counters + .corrupt_batches + .fetch_add(1, Ordering::Relaxed); + continue; + } + if let Ok(events) = read_events(&batch.path) { + for event in events { + observe_project(&mut self.projects, &meta.route, &event); + } + } + self.known_ids.extend(meta.event_ids); + self.known.extend(meta.facts); + } + Err(_) => { + // 已发布批次不重写、不重新生成事件;其他实例的文件只读跳过。 + self.counters + .corrupt_batches + .fetch_add(1, Ordering::Relaxed); + } + } + } + } + + fn record(&mut self, route: Route, mut event: Event, fact: &str) { + if event.validate().is_err() + || !route.validate() + || route.user_id != event.user_id + || event.editor_session_id != self.session_id + || fact.is_empty() + || fact.len() > 4096 + { + self.counters.dropped.fetch_add(1, Ordering::Relaxed); + return; + } + let digest = fact_digest(&route, fact); + if self.known.contains_key(&digest) || self.events.iter().any(|(_, key, _)| key == &digest) + { + self.counters.duplicate.fetch_add(1, Ordering::Relaxed); + return; + } + if self.known_ids.contains(&event.event_id) + || self + .events + .iter() + .any(|(old, _, _)| old.event_id == event.event_id) + { + self.counters.dropped.fetch_add(1, Ordering::Relaxed); + return; + } + if event.event_name == "project_open" { + if let Some(project) = &event.project_id { + let key = fact_digest(&route, project); + let mut opened = self.projects.get(&key).copied(); + if self.route.as_ref() == Some(&route) { + for (pending, _, _) in &self.events { + if pending.project_id.as_ref() != Some(project) { + continue; + } + match pending.event_name.as_str() { + "project_open" => opened = Some(true), + "project_create_success" if opened.is_none() => opened = Some(false), + _ => {} + } + } + } + event.properties["is_first_open"] = opened.map(|opened| !opened).into(); + } + } + if event.validate().is_err() { + self.counters.dropped.fetch_add(1, Ordering::Relaxed); + return; + } + let Ok(mut line) = serde_json::to_vec(&event) else { + self.counters.dropped.fetch_add(1, Ordering::Relaxed); + return; + }; + line.push(b'\n'); + if line.len() > MAX_BYTES { + self.counters.dropped.fetch_add(1, Ordering::Relaxed); + return; + } + if self.route.as_ref().is_some_and(|current| current != &route) + || self.bytes + line.len() > MAX_BYTES + || self + .started + .is_some_and(|start| start.elapsed() >= SEAL_AFTER) + { + self.seal(); + } + self.route = Some(route); + self.started.get_or_insert_with(Instant::now); + self.bytes += line.len(); + self.events.push((event, digest, line)); + self.counters.accepted.fetch_add(1, Ordering::Relaxed); + if self.events.len() >= MAX_EVENTS || self.bytes >= MAX_BYTES { + self.seal(); + } + } + + fn seal(&mut self) { + if self.events.is_empty() { + if self.prune(0).is_err() { + self.counters.io_errors.fetch_add(1, Ordering::Relaxed); + } + return; + } + let events = std::mem::take(&mut self.events); + let route = self.route.take().expect("nonempty batch has route"); + self.started = None; + self.bytes = 0; + let id = uuid::Uuid::new_v4().to_string(); + let meta = BatchMeta { + schema_version: 1, + batch_id: id.clone(), + editor_session_id: self.session_id.clone(), + route, + created_at_ms: now_ms(), + event_count: events.len(), + event_ids: events + .iter() + .map(|(event, _, _)| event.event_id.clone()) + .collect(), + facts: events + .iter() + .map(|(event, key, _)| (key.clone(), event.event_id.clone())) + .collect(), + }; + let temporary = self.batches.join(format!(".tmp-{id}")); + let result = (|| -> io::Result<()> { + let meta_bytes = serde_json::to_vec(&meta)?; + if meta_bytes.len() > MAX_META_BYTES { + return Err(invalid_data()); + } + let event_bytes: usize = events.iter().map(|(_, _, line)| line.len()).sum(); + self.prune((event_bytes + meta_bytes.len()) as u64)?; + fs::create_dir(&temporary)?; + let mut file = File::create(temporary.join("events.jsonl"))?; + for (_, _, line) in &events { + file.write_all(line)?; + } + file.sync_all()?; + let mut metadata = File::create(temporary.join("meta.json"))?; + metadata.write_all(&meta_bytes)?; + metadata.sync_all()?; + drop(file); + drop(metadata); + fs::rename(&temporary, self.batches.join(&id))?; + Ok(()) + })(); + if result.is_ok() { + for (event, _, _) in &events { + observe_project(&mut self.projects, &meta.route, event); + if let Some(project) = &event.project_id { + let key = fact_digest(&meta.route, project); + if let Some(opened) = self.projects.get(&key) { + self.live_projects.insert(key, (Instant::now(), *opened)); + } + } + } + self.live_facts.extend( + meta.facts + .iter() + .map(|(key, id)| (Instant::now(), key.clone(), id.clone())), + ); + self.trim_live(); + self.known_ids.extend(meta.event_ids); + self.known.extend(meta.facts); + } else { + let _ = remove_batch(&temporary); + self.counters.io_errors.fetch_add(1, Ordering::Relaxed); + self.counters + .dropped + .fetch_add(events.len() as u64, Ordering::Relaxed); + } + } + + fn prune(&mut self, reserve: u64) -> io::Result<()> { + let result = self.prune_files(reserve); + // 重建磁盘索引,但保留当前实例已观测事实,上传删除不能重授回调资格。 + self.projects.clear(); + self.known.clear(); + self.known_ids.clear(); + self.recover(); + self.trim_live(); + for (_, key, id) in &self.live_facts { + self.known.insert(key.clone(), id.clone()); + self.known_ids.insert(id.clone()); + } + for (key, (_, opened)) in &self.live_projects { + self.projects + .entry(key.clone()) + .and_modify(|known| *known |= *opened) + .or_insert(*opened); + } + result + } + + fn trim_live(&mut self) { + self.trim_live_at(Instant::now()); + } + + fn trim_live_at(&mut self, now: Instant) { + // 仅当前进程内保留近期观测,固定条数上限;不形成永久历史账本。 + while self.live_facts.len() > MAX_LIVE_OBSERVATIONS + || self + .live_facts + .front() + .is_some_and(|(time, _, _)| now.saturating_duration_since(*time) >= RETENTION) + { + self.live_facts.pop_front(); + } + self.live_projects + .retain(|_, (time, _)| now.saturating_duration_since(*time) < RETENTION); + while self.live_projects.len() > MAX_LIVE_OBSERVATIONS { + let oldest = self + .live_projects + .iter() + .min_by_key(|(_, (time, _))| *time) + .map(|(key, _)| key.clone()) + .unwrap(); + self.live_projects.remove(&oldest); + } + } + + fn prune_files(&mut self, reserve: u64) -> io::Result<()> { + session::prune( + &self.root.join("instances"), + &self.session_id, + reserve, + MAX_TOTAL_BYTES, + RETENTION, + || directory_size(&self.root, 0, &self.counters), + ); + let mut bytes = directory_size(&self.root, 0, &self.counters); + let mut batches = list_batches(&self.root)?; + batches.sort_by_key(|batch| batch.modified); + for batch in batches { + let expired = SystemTime::now() + .duration_since(batch.modified) + .is_ok_and(|age| age >= RETENTION); + if !expired && bytes.saturating_add(reserve) <= MAX_TOTAL_BYTES { + continue; + } + let size = directory_size(&batch.path, 0, &self.counters); + let meta = read_batch(&batch.path).ok(); + if remove_batch(&batch.path).is_err() { + self.counters.io_errors.fetch_add(1, Ordering::Relaxed); + continue; + } + bytes = bytes.saturating_sub(size); + if let Some(meta) = meta { + self.counters + .dropped + .fetch_add(meta.event_count as u64, Ordering::Relaxed); + } + } + if bytes.saturating_add(reserve) > MAX_TOTAL_BYTES { + return Err(invalid_data()); + } + Ok(()) + } +} + +pub(super) fn invalid_data() -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, "invalid analytics data") +} + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .min(u64::MAX as u128) as u64 +} + +fn fact_digest(route: &Route, fact: &str) -> String { + let mut hash = Sha256::new(); + hash.update(serde_json::to_vec(route).expect("route contains serializable strings")); + hash.update([0]); + hash.update(fact.as_bytes()); + format!("{:x}", hash.finalize()) +} + +pub(super) fn safe_metadata(path: &Path) -> io::Result { + let meta = fs::symlink_metadata(path)?; + if meta.file_type().is_symlink() { + return Err(invalid_data()); + } + #[cfg(windows)] + { + use std::os::windows::fs::MetadataExt; + if meta.file_attributes() & 0x400 != 0 { + return Err(invalid_data()); + } + } + Ok(meta) +} + +fn ensure_directory(path: &Path) -> io::Result<()> { + match fs::create_dir(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { + if safe_metadata(path)?.is_dir() { + Ok(()) + } else { + Err(invalid_data()) + } + } + Err(error) => Err(error), + } +} + +pub(super) fn read_bounded(path: &Path, limit: usize) -> io::Result> { + let meta = safe_metadata(path)?; + if !meta.is_file() || meta.len() > limit as u64 { + return Err(invalid_data()); + } + let mut bytes = Vec::new(); + File::open(path)? + .take(limit as u64 + 1) + .read_to_end(&mut bytes)?; + if bytes.len() > limit { + return Err(invalid_data()); + } + Ok(bytes) +} + +fn read_batch(path: &Path) -> io::Result { + if !safe_metadata(path)?.is_dir() { + return Err(invalid_data()); + } + let meta: BatchMeta = + serde_json::from_slice(&read_bounded(&path.join("meta.json"), MAX_META_BYTES)?)?; + if meta.schema_version != 1 + || !meta.route.validate() + || meta.event_count == 0 + || meta.event_count > MAX_EVENTS + || meta.event_count != meta.event_ids.len() + || meta.event_count != meta.facts.len() + || path.file_name().and_then(|name| name.to_str()) != Some(meta.batch_id.as_str()) + || path + .parent() + .and_then(Path::parent) + .and_then(Path::file_name) + .and_then(|name| name.to_str()) + != Some(meta.editor_session_id.as_str()) + || uuid::Uuid::parse_str(&meta.batch_id).is_err() + { + return Err(invalid_data()); + } + let bytes = read_bounded(&path.join("events.jsonl"), MAX_BYTES)?; + if bytes.last() != Some(&b'\n') { + return Err(invalid_data()); + } + let mut ids = HashSet::new(); + for (index, line) in bytes[..bytes.len() - 1] + .split(|byte| *byte == b'\n') + .enumerate() + { + let event: Event = serde_json::from_slice(line)?; + if event.validate().is_err() + || event.user_id != meta.route.user_id + || event.editor_session_id != meta.editor_session_id + || meta.event_ids.get(index) != Some(&event.event_id) + || !ids.insert(event.event_id) + { + return Err(invalid_data()); + } + } + if ids.len() != meta.event_count + || meta.facts.iter().any(|(key, id)| { + key.len() != 64 || !key.bytes().all(|c| c.is_ascii_hexdigit()) || !ids.contains(id) + }) + || meta.facts.values().collect::>().len() != ids.len() + { + return Err(invalid_data()); + } + Ok(meta) +} + +fn list_batches(root: &Path) -> io::Result> { + let mut batches = Vec::new(); + let instances = root.join("instances"); + if !safe_metadata(&instances)?.is_dir() { + return Err(invalid_data()); + } + for instance in fs::read_dir(instances)? { + let Ok(instance) = instance else { continue }; + if !safe_metadata(&instance.path()).is_ok_and(|meta| meta.is_dir()) { + continue; + } + let path = instance.path().join("batches"); + if !safe_metadata(&path).is_ok_and(|meta| meta.is_dir()) { + continue; + } + let Ok(entries) = fs::read_dir(path) else { + continue; + }; + for batch in entries { + let Ok(batch) = batch else { continue }; + // 临时、隔离和未知目录不能被当成可上传批次,也不能猜其写入者已退出。 + if uuid::Uuid::parse_str(&batch.file_name().to_string_lossy()).is_err() { + continue; + } + if let Ok(meta) = safe_metadata(&batch.path()) { + if meta.is_dir() { + if batches.len() >= 65536 { + return Err(invalid_data()); + } + batches.push(Batch { + path: batch.path(), + modified: meta.modified()?, + }); + } + } + } + } + Ok(batches) +} + +fn directory_size(path: &Path, depth: usize, counters: &Counters) -> u64 { + if depth > 8 { + counters.corrupt_batches.fetch_add(1, Ordering::Relaxed); + return 0; + } + let meta = match safe_metadata(path) { + Ok(meta) => meta, + Err(_) => { + counters.corrupt_batches.fetch_add(1, Ordering::Relaxed); + // 链接只计链接本身的元数据,不打开或跟随其目标。 + return fs::symlink_metadata(path) + .map(|meta| meta.len()) + .unwrap_or(0); + } + }; + if meta.is_file() { + return meta.len(); + } + if !meta.is_dir() { + return 0; + } + let mut bytes = 0u64; + let Ok(entries) = fs::read_dir(path) else { + counters.io_errors.fetch_add(1, Ordering::Relaxed); + return 0; + }; + for entry in entries { + if let Ok(entry) = entry { + bytes = bytes.saturating_add(directory_size(&entry.path(), depth + 1, counters)); + } else { + counters.io_errors.fetch_add(1, Ordering::Relaxed); + } + } + bytes +} + +fn remove_batch(path: &Path) -> io::Result<()> { + if !safe_metadata(path)?.is_dir() { + return Err(invalid_data()); + } + let files = fs::read_dir(path)?.collect::, _>>()?; + // 删除范围严格停留在已核实的批次普通文件,绝不递归穿过未知目录或链接。 + for file in &files { + if !safe_metadata(&file.path())?.is_file() { + return Err(invalid_data()); + } + } + for file in files { + fs::remove_file(file.path())?; + } + fs::remove_dir(path) +} + +#[cfg(test)] +#[path = "store_tests.rs"] +mod tests; + +fn read_events(path: &Path) -> io::Result> { + let bytes = read_bounded(&path.join("events.jsonl"), MAX_BYTES)?; + bytes + .split(|b| *b == b'\n') + .filter(|line| !line.is_empty()) + .map(|line| serde_json::from_slice(line).map_err(io::Error::from)) + .collect() +} + +fn observe_project(projects: &mut HashMap, route: &Route, event: &Event) { + let Some(project) = &event.project_id else { + return; + }; + let key = fact_digest(route, project); + match event.event_name.as_str() { + "project_open" => { + projects.insert(key, true); + } + "project_create_success" => { + projects.entry(key).or_insert(false); + } + _ => {} + } +} + +/// 已验证的封存批次;路径只来自本地扫描,不接受服务端输入。 +pub(super) struct UploadBatch { + path: PathBuf, + pub request: serde_json::Value, + pub batch_id: String, + pub route: Route, + pub event_count: usize, +} + +pub(super) fn upload_candidates(root: &Path) -> io::Result> { + let mut batches = list_batches(root)?; + batches.sort_by_key(|batch| batch.modified); + Ok(batches.into_iter().map(|batch| batch.path).collect()) +} + +pub(super) fn load_upload_batch(path: PathBuf) -> io::Result { + let meta = read_batch(&path)?; + let events = read_events(&path)?; + // 同一份内存副本再次核对,避免保留清理或外部修改造成两次读取不一致。 + if events.len() != meta.event_count + || events.iter().zip(&meta.event_ids).any(|(event, id)| { + &event.event_id != id + || event.validate().is_err() + || event.user_id != meta.route.user_id + || event.editor_session_id != meta.editor_session_id + }) + { + return Err(invalid_data()); + } + let request = serde_json::json!({ + "schema_version": 1, "batch_id": meta.batch_id, + "destination_origin": meta.route.destination_origin, "user_id": meta.route.user_id, + "events": events, + }); + Ok(UploadBatch { + path, + request, + batch_id: meta.batch_id, + route: meta.route, + event_count: meta.event_count, + }) +} + +impl UploadBatch { + pub(super) fn exists(&self) -> bool { + safe_metadata(&self.path).is_ok_and(|m| m.is_dir()) + } + + pub(super) fn acknowledge(&self) -> io::Result<()> { + match remove_batch(&self.path) { + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + result => result, + } + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/store_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/store_tests.rs new file mode 100644 index 000000000..61b830f31 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/store_tests.rs @@ -0,0 +1,1245 @@ +use super::*; +use serde_json::json; + +/// 由 API 测试启动隔离主站与数据库;本进程走实际封存、上传和确认清理。 +#[tokio::test] +#[ignore = "requires isolated local API and database smoke bridge"] +async fn analytics_live_api_upload_cleans_confirmed_batch() { + #[derive(serde::Deserialize)] + #[serde(rename_all = "camelCase", deny_unknown_fields)] + struct Bridge { + origin: String, + user_id: String, + access_token: String, + result_path: PathBuf, + } + let bridge_path = std::env::var_os("AGC_ANALYTICS_SMOKE_BRIDGE") + .expect("isolated smoke bridge path required"); + let bridge: Bridge = serde_json::from_slice( + &read_bounded(Path::new(&bridge_path), 16 * 1024).expect("read smoke bridge"), + ) + .expect("valid smoke bridge"); + let url = url::Url::parse(&bridge.origin).expect("valid smoke origin"); + assert!( + url.scheme() == "http" + && url.origin().ascii_serialization() == bridge.origin + && url.host_str().is_some_and(|host| { + host == "localhost" + || host + .trim_matches(['[', ']']) + .parse::() + .is_ok_and(|ip| ip.is_loopback()) + }), + "smoke requires a loopback origin" + ); + let dir = tempfile::tempdir().unwrap(); + let mut store = open(dir.path()); + let event = event(&store.session_id, &bridge.user_id); + let event_id = event.event_id.clone(); + store.record( + Route::from_identity(Some(bridge.user_id.clone()), Some(&bridge.origin)), + event, + "live-api-session-start", + ); + store.seal(); + let candidates = upload_candidates(&store.root).unwrap(); + assert_eq!(candidates.len(), 1, "real local batch must be sealed"); + let batch = load_upload_batch(candidates.into_iter().next().unwrap()).unwrap(); + let _identity = crate::platform_session::install_test_platform_session( + &bridge.user_id, + &bridge.access_token, + &bridge.origin, + ); + let client = reqwest::Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .timeout(Duration::from_secs(30)) + .build() + .unwrap(); + super::super::upload::cycle(&store.root, &client).await; + assert!( + !batch.exists(), + "only a matching server acknowledgement permits cleanup" + ); + assert!(upload_candidates(&store.root).unwrap().is_empty()); + fs::write( + &bridge.result_path, + serde_json::to_vec(&json!({"eventId": event_id, "batchId": batch.batch_id})).unwrap(), + ) + .expect("write non-secret smoke result"); +} + +#[tokio::test] +async fn upload_keeps_failures_then_deletes_only_acknowledged_batch_without_regranting_fact() { + use std::net::TcpListener; + let dir = tempfile::tempdir().unwrap(); + let mut store = open(dir.path()); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let origin = format!("http://{}", listener.local_addr().unwrap()); + let route = Route::from_identity(Some("A".into()), Some(&origin)); + store.record(route.clone(), event(&store.session_id, "A"), "single-fact"); + store.seal(); + let batch = load_upload_batch(upload_candidates(&store.root).unwrap().remove(0)).unwrap(); + store.record( + Route::from_identity(Some("B".into()), Some(&origin)), + event(&store.session_id, "B"), + "other-account", + ); + store.seal(); + let id = batch.batch_id.clone(); + let server = std::thread::spawn(move || { + for (status, body) in [ + ( + 200, + json!({"ok": true, "data": {"acknowledged_batch_ids": ["wrong"], "event_count": 1}, "error": null}), + ), + (503, json!({"ok": false})), + ( + 200, + json!({"ok": true, "data": {"acknowledged_batch_ids": [id], "event_count": 1}, "error": null}), + ), + ] { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut bytes = Vec::new(); + let mut chunk = [0; 4096]; + loop { + let count = stream.read(&mut chunk).unwrap(); + assert!(count > 0); + bytes.extend_from_slice(&chunk[..count]); + if let Some(end) = bytes.windows(4).position(|w| w == b"\r\n\r\n") { + let headers = String::from_utf8_lossy(&bytes[..end]).to_lowercase(); + let length: usize = headers + .lines() + .find_map(|line| line.strip_prefix("content-length: ")) + .unwrap() + .parse() + .unwrap(); + if bytes.len() >= end + 4 + length { + assert!(headers.contains("authorization: bearer test-token")); + assert!(headers.contains("x-genarrative-response-envelope: 1")); + let request: serde_json::Value = + serde_json::from_slice(&bytes[end + 4..]).unwrap(); + assert_eq!(request["user_id"], "A"); + assert_eq!(request["events"].as_array().unwrap().len(), 1); + break; + } + } + } + let body = body.to_string(); + write!(stream, "HTTP/1.1 {status} Test\r\nContent-Length: {}\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{body}", body.len()).unwrap(); + } + }); + let _identity = + crate::platform_session::install_test_platform_session("A", "test-token", &origin); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .unwrap(); + for _ in 0..2 { + super::super::upload::cycle(&store.root, &client).await; + assert!(batch.exists()); + } + super::super::upload::cycle(&store.root, &client).await; + assert!(!batch.exists()); + assert_eq!(upload_candidates(&store.root).unwrap().len(), 1); + server.join().unwrap(); + store.prune(0).unwrap(); + store.record(route, event(&store.session_id, "A"), "single-fact"); + assert!(store.events.is_empty()); + assert_eq!(store.counters.duplicate.load(Ordering::Relaxed), 1); +} + +fn route(user: &str) -> Route { + Route { + user_id: Some(user.into()), + destination_origin: Some("https://example.com".into()), + } +} + +fn event(session: &str, user: &str) -> Event { + serde_json::from_value(json!({ + "schema_version": 1, + "event_id": uuid::Uuid::new_v4().to_string(), + "event_name": "editor_session_start", + "event_time": "2026-09-21T00:00:00.000Z", + "user_id": user, + "editor_session_id": session, + "project_id": null, "creative_task_id": null, + "agent_run_id": null, "agent_turn_id": null, + "status": "success", "error_code": null, "source": "editor", "client_version": "1.0.0", + "properties": { "entry_source": "direct_launch", "first_project_id": null } + })) + .unwrap() +} + +fn open(config: &Path) -> Store { + Store::open( + config.to_path_buf(), + uuid::Uuid::new_v4().to_string(), + Arc::new(Counters::default()), + ) + .unwrap() +} + +#[test] +fn buffered_and_published_replay_keep_original_identity() { + let dir = tempfile::tempdir().unwrap(); + let mut store = open(dir.path()); + let first = event(&store.session_id, "A"); + let original_id = first.event_id.clone(); + store.record(route("A"), first, "start-original-session"); + store.record( + route("A"), + event(&store.session_id, "A"), + "start-original-session", + ); + assert_eq!(store.events.len(), 1); + assert!(list_batches(&store.root).unwrap().is_empty()); + store.seal(); + let batches = list_batches(&store.root).unwrap(); + assert_eq!(batches.len(), 1); + assert_eq!( + read_batch(&batches[0].path).unwrap().event_ids, + [original_id.clone()] + ); + let mut restored = open(dir.path()); + restored.record( + route("A"), + event(&restored.session_id, "A"), + "start-original-session", + ); + assert!(restored.events.is_empty()); + assert_eq!(restored.known.values().next(), Some(&original_id)); + assert_eq!(restored.counters.duplicate.load(Ordering::Relaxed), 1); +} + +#[test] +fn account_switch_and_count_limit_publish_complete_separate_batches() { + let dir = tempfile::tempdir().unwrap(); + let mut store = open(dir.path()); + store.record(route("A"), event(&store.session_id, "A"), "A"); + store.record(route("B"), event(&store.session_id, "B"), "B"); + let batches = list_batches(&store.root).unwrap(); + assert_eq!(read_batch(&batches[0].path).unwrap().route, route("A")); + for index in 1..MAX_EVENTS { + store.record( + route("B"), + event(&store.session_id, "B"), + &format!("B-{index}"), + ); + } + assert!(store.events.is_empty()); + let batches = list_batches(&store.root).unwrap(); + assert_eq!(batches.len(), 2); + assert_eq!( + batches + .iter() + .map(|batch| read_batch(&batch.path).unwrap().event_count) + .sum::(), + 501 + ); + assert!(fs::read_dir(&store.batches).unwrap().all(|entry| !entry + .unwrap() + .file_name() + .to_string_lossy() + .starts_with('.'))); +} + +#[test] +fn deadline_and_size_rotate_without_empty_files() { + let dir = tempfile::tempdir().unwrap(); + let mut store = open(dir.path()); + store.seal(); + assert!(list_batches(&store.root).unwrap().is_empty()); + store.record(route("A"), event(&store.session_id, "A"), "first"); + store.started = Some(Instant::now() - SEAL_AFTER); + store.record(route("A"), event(&store.session_id, "A"), "next"); + assert_eq!(list_batches(&store.root).unwrap().len(), 1); + store.bytes = MAX_BYTES; + store.record(route("A"), event(&store.session_id, "A"), "third"); + assert_eq!(list_batches(&store.root).unwrap().len(), 2); + let mut huge = event(&store.session_id, "A"); + huge.client_version = "x".repeat(MAX_BYTES + 1); + store.record(route("A"), huge, "oversized"); + assert_eq!(store.events.len(), 1); + assert_eq!(store.counters.dropped.load(Ordering::Relaxed), 1); +} + +#[test] +fn corrupt_batch_does_not_rewrite_valid_batches_or_other_instance_temporary_files() { + let dir = tempfile::tempdir().unwrap(); + let mut store = open(dir.path()); + store.record(route("A"), event(&store.session_id, "A"), "good"); + store.seal(); + let good = list_batches(&store.root).unwrap().pop().unwrap().path; + let original = fs::read(good.join("events.jsonl")).unwrap(); + store.record(route("A"), event(&store.session_id, "A"), "bad"); + store.seal(); + let bad = list_batches(&store.root) + .unwrap() + .into_iter() + .find(|batch| batch.path != good) + .unwrap() + .path; + fs::write(bad.join("events.jsonl"), b"{truncated").unwrap(); + let temporary = store.batches.join(".tmp-other-writer"); + fs::create_dir(&temporary).unwrap(); + fs::write(temporary.join("events.jsonl"), b"in progress").unwrap(); + let restored = open(dir.path()); + assert_eq!(restored.known.len(), 1); + assert_eq!(restored.counters.corrupt_batches.load(Ordering::Relaxed), 1); + assert_eq!(fs::read(good.join("events.jsonl")).unwrap(), original); + assert!(temporary.exists()); + assert!(bad.exists()); +} + +#[test] +fn retention_counts_corrupt_files_and_removes_expired_sealed_batches() { + let dir = tempfile::tempdir().unwrap(); + let mut store = open(dir.path()); + store.record(route("A"), event(&store.session_id, "A"), "old"); + store.seal(); + let old = list_batches(&store.root).unwrap().pop().unwrap().path; + // 发布目录 mtime 是封存时间;不需要等待一周。 + let old_time = SystemTime::now() - RETENTION - Duration::from_secs(1); + store.trim_live_at(Instant::now() + RETENTION + Duration::from_secs(1)); + #[cfg(not(windows))] + File::open(&old) + .unwrap() + .set_times(fs::FileTimes::new().set_modified(old_time)) + .unwrap(); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + fs::OpenOptions::new() + .write(true) + .custom_flags(0x02000000) + .open(&old) + .unwrap() + .set_times(fs::FileTimes::new().set_modified(old_time)) + .unwrap(); + } + store.prune(0).unwrap(); + assert!(!old.exists()); + assert!(store.known.is_empty()); + let corrupt = store.batches.join(uuid::Uuid::new_v4().to_string()); + fs::create_dir(&corrupt).unwrap(); + File::create(corrupt.join("events.jsonl")) + .unwrap() + .set_len(MAX_TOTAL_BYTES + 1) + .unwrap(); + store.prune(0).unwrap(); + assert!(!corrupt.exists()); +} + +#[test] +fn full_or_disconnected_channel_never_waits_and_counts_drops() { + let (sender, receiver) = mpsc::sync_channel(1); + let writer = AnalyticsWriter { + sender, + counters: Arc::new(Counters::default()), + }; + let session = uuid::Uuid::new_v4().to_string(); + assert!(writer.try_record(route("A"), event(&session, "A"), "first".into())); + let before = Instant::now(); + assert!(!writer.try_record(route("A"), event(&session, "A"), "full".into())); + assert!(before.elapsed() < Duration::from_secs(1)); + drop(receiver); + assert!(!writer.try_record(route("A"), event(&session, "A"), "closed".into())); + assert_eq!(writer.counters().dropped, 2); +} + +#[test] +fn disk_failure_drops_only_current_batch_without_returning_business_error() { + let dir = tempfile::tempdir().unwrap(); + let mut store = open(dir.path()); + fs::remove_dir(&store.batches).unwrap(); + fs::write(&store.batches, b"not a directory").unwrap(); + store.record(route("A"), event(&store.session_id, "A"), "failure"); + store.seal(); + assert_eq!(store.counters.io_errors.load(Ordering::Relaxed), 1); + assert_eq!(store.counters.dropped.load(Ordering::Relaxed), 1); + assert!(store.events.is_empty()); +} + +#[test] +fn oversized_input_is_rejected_before_channel_and_byte_budget_is_bounded() { + let (sender, receiver) = mpsc::sync_channel(QUEUE_CAPACITY); + let writer = AnalyticsWriter { + sender, + counters: Arc::new(Counters::default()), + }; + let session = uuid::Uuid::new_v4().to_string(); + let mut huge = event(&session, "A"); + huge.client_version = "x".repeat(MAX_BYTES + 1); + assert!(!writer.try_record(route("A"), huge, "huge".into())); + assert!(matches!( + receiver.try_recv(), + Err(mpsc::TryRecvError::Empty) + )); + assert!(!writer.try_record(route("A"), event(&session, "A"), "x".repeat(4097))); + writer + .counters + .queued_bytes + .store(MAX_BYTES as u64, Ordering::Relaxed); + assert!(!writer.try_record(route("A"), event(&session, "A"), "full-bytes".into())); + assert!(matches!( + receiver.try_recv(), + Err(mpsc::TryRecvError::Empty) + )); +} + +#[test] +fn real_background_writer_flushes_when_last_sender_closes() { + let dir = tempfile::tempdir().unwrap(); + let session = uuid::Uuid::new_v4().to_string(); + let writer = AnalyticsWriter::start(dir.path().to_path_buf(), session.clone()); + assert!(writer.try_record(route("A"), event(&session, "A"), "background".into())); + drop(writer); + let until = Instant::now() + Duration::from_secs(5); + loop { + if let Ok(batches) = list_batches(&dir.path().join("analytics")) { + if let Some(batch) = batches.first() { + assert_eq!(read_batch(&batch.path).unwrap().event_count, 1); + break; + } + } + assert!(Instant::now() < until, "background batch was not published"); + std::thread::sleep(Duration::from_millis(10)); + } +} + +#[test] +fn retention_preserves_recent_live_facts_after_another_instance_removed_a_batch() { + let dir = tempfile::tempdir().unwrap(); + let mut store = open(dir.path()); + store.record( + route("A"), + event(&store.session_id, "A"), + "removed-by-another-instance", + ); + store.seal(); + let batch = list_batches(&store.root).unwrap().pop().unwrap(); + remove_batch(&batch.path).unwrap(); + assert_eq!(store.known.len(), 1); + store.prune(0).unwrap(); + assert_eq!(store.known.len(), 1); + assert_eq!(store.known_ids.len(), 1); + store.trim_live_at(Instant::now() + RETENTION); + store.prune(0).unwrap(); + assert!(store.known.is_empty()); + assert!(store.known_ids.is_empty()); +} + +#[test] +fn unexpected_deep_tree_does_not_disable_unrelated_batches() { + let dir = tempfile::tempdir().unwrap(); + let mut store = open(dir.path()); + let mut deep = store.root.join("unrecognized"); + for _ in 0..10 { + deep = deep.join("nested"); + } + fs::create_dir_all(&deep).unwrap(); + fs::write(deep.join("foreign"), b"leave intact").unwrap(); + store.record(route("A"), event(&store.session_id, "A"), "valid"); + store.seal(); + assert_eq!(list_batches(&store.root).unwrap().len(), 1); + assert!(deep.join("foreign").exists()); + assert!(store.counters.corrupt_batches.load(Ordering::Relaxed) > 0); +} + +fn project_event(session: &str, user: &str, name: &str) -> Event { + let mut event = event(session, user); + event.event_name = name.into(); + event.project_id = Some("project-1".into()); + event.creative_task_id = None; + event.properties = if name == "project_open" { + json!({"open_source":"recent","is_first_open":null}) + } else { + json!({"creation_source":"home_game"}) + }; + event +} + +#[test] +fn first_open_is_derived_from_route_scoped_known_facts_and_survives_restart() { + let dir = tempfile::tempdir().unwrap(); + let mut store = open(dir.path()); + store.record( + route("A"), + project_event(&store.session_id, "A", "project_create_success"), + "create", + ); + store.record( + route("A"), + project_event(&store.session_id, "A", "project_open"), + "open1", + ); + assert_eq!( + store.events.last().unwrap().0.properties["is_first_open"], + true + ); + store.record( + route("A"), + project_event(&store.session_id, "A", "project_open"), + "open1", + ); + assert_eq!(store.events.len(), 2); + store.seal(); + let mut restored = open(dir.path()); + restored.record( + route("A"), + project_event(&restored.session_id, "A", "project_open"), + "open2", + ); + assert_eq!( + restored.events.last().unwrap().0.properties["is_first_open"], + false + ); + restored.record( + route("B"), + project_event(&restored.session_id, "B", "project_open"), + "open3", + ); + assert!(restored.events.last().unwrap().0.properties["is_first_open"].is_null()); +} + +fn session_record(session: &str) -> SessionRecord { + SessionRecord { + schema_version: 1, + editor_session_id: session.into(), + route: route("A"), + lifecycle_state: crate::analytics::session::LifecycleState::Active, + focus_interval_id: None, + updated_at: "2026-09-21T00:00:00.000Z".into(), + incomplete_detected_at: None, + } +} + +#[test] +fn session_commands_share_the_event_byte_budget_and_restore_on_send_failure() { + let (sender, receiver) = mpsc::sync_channel(4); + let counters = Arc::new(Counters::default()); + let writer = AnalyticsWriter { + sender, + counters: counters.clone(), + }; + let record = session_record(&uuid::Uuid::new_v4().to_string()); + let bytes = serde_json::to_vec(&record).unwrap().len() as u64; + counters + .queued_bytes + .store(MAX_BYTES as u64 - bytes + 1, Ordering::Relaxed); + assert!(!writer.try_session(record.clone())); + assert_eq!( + counters.queued_bytes.load(Ordering::Relaxed), + MAX_BYTES as u64 - bytes + 1 + ); + counters + .queued_bytes + .store(MAX_BYTES as u64 - bytes, Ordering::Relaxed); + assert!(writer.try_session(record.clone())); + assert_eq!( + counters.queued_bytes.load(Ordering::Relaxed), + MAX_BYTES as u64 + ); + match receiver.recv().unwrap() { + Command::Session(_, charged) => { + assert_eq!(charged, bytes); + counters.queued_bytes.fetch_sub(charged, Ordering::Relaxed); + } + _ => panic!("expected session"), + } + drop(receiver); + let before = counters.queued_bytes.load(Ordering::Relaxed); + assert!(!writer.try_session(record)); + assert_eq!(counters.queued_bytes.load(Ordering::Relaxed), before); +} + +#[test] +fn session_atomic_write_reserves_full_temporary_file_capacity() { + let dir = tempfile::tempdir().unwrap(); + let mut store = open(dir.path()); + let record = session_record(&store.session_id); + let bytes = serde_json::to_vec(&record).unwrap().len() as u64; + store.write_session(&record, bytes).unwrap(); + let path = store.batches.parent().unwrap().join("session.json"); + let original = fs::read(&path).unwrap(); + let padding = File::create(store.root.join("unknown-padding")).unwrap(); + padding.set_len(MAX_TOTAL_BYTES - bytes).unwrap(); + assert!(store.write_session(&record, bytes).is_err()); + assert_eq!(fs::read(&path).unwrap(), original); + assert_eq!( + directory_size(&store.root, 0, &store.counters), + MAX_TOTAL_BYTES + ); +} + +fn goal_project() -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("project"); + crate::init_local_game_project_at(&root, "goal-project", "目标采集测试").unwrap(); + (dir, root) +} + +fn goal_writer(config: &Path, user: &str) -> (super::super::contract::Context, AnalyticsWriter) { + let context = super::super::contract::Context { + route: route(user), + editor_session_id: uuid::Uuid::new_v4().to_string(), + client_version: "1.0.0".into(), + }; + let writer = AnalyticsWriter::start(config.into(), context.editor_session_id.clone()); + (context, writer) +} + +// 同 FIFO 的真实事件哨兵:哨兵落盘后,前面的命令必已执行,不靠 sleep 猜零事件。 +fn drain_goal_writer( + config: &Path, + context: &super::super::contract::Context, + writer: &AnalyticsWriter, +) -> Vec { + let marker = event( + &context.editor_session_id, + context.route.user_id.as_deref().unwrap(), + ); + let marker_id = marker.event_id.clone(); + assert!(writer.try_record(context.route.clone(), marker, marker_id.clone())); + assert!(writer.flush()); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let events: Vec<_> = list_batches(&config.join("analytics")) + .unwrap_or_default() + .iter() + .flat_map(|batch| read_events(&batch.path).unwrap_or_default()) + .collect(); + if events.iter().any(|event| event.event_id == marker_id) { + return events; + } + assert!( + Instant::now() < deadline, + "analytics FIFO sentinel timed out" + ); + std::thread::sleep(Duration::from_millis(5)); + } +} + +#[test] +fn project_goal_is_once_across_sources_writers_and_batch_retention_with_frozen_identity() { + use super::super::{contract::Source, goal}; + let (_project_dir, root) = goal_project(); + let config = tempfile::tempdir().unwrap(); + let (a, writer) = goal_writer(config.path(), "A"); + goal::created(Some(&writer), &root, "goal-project"); + goal::accepted( + Some((a.clone(), writer.clone())), + &root, + "goal-project", + Source::DesignAgent, + ); + let mut b = a.clone(); + b.route = route("B"); + goal::accepted( + Some((b, writer.clone())), + &root, + "goal-project", + Source::Direct, + ); + let events = drain_goal_writer(config.path(), &a, &writer); + let submissions: Vec<_> = events + .iter() + .filter(|e| e.event_name == "creative_task_submit") + .collect(); + assert_eq!(submissions.len(), 1); + assert_eq!(submissions[0].user_id.as_deref(), Some("A")); + assert_eq!(submissions[0].source, Source::DesignAgent); + assert_eq!(submissions[0].properties, json!({})); + for batch in list_batches(&config.path().join("analytics")).unwrap() { + remove_batch(&batch.path).unwrap(); + } + let (restarted, next) = goal_writer(config.path(), "B"); + goal::created(Some(&next), &root, "goal-project"); // 不覆盖 consumed + goal::accepted( + Some((restarted.clone(), next.clone())), + &root, + "goal-project", + Source::Direct, + ); + let events = drain_goal_writer(config.path(), &restarted, &next); + assert!(!events + .iter() + .any(|e| e.event_name == "creative_task_submit")); +} + +#[test] +fn goal_unknown_corrupt_backup_and_mismatched_projects_do_not_submit() { + use super::super::{contract::Source, goal}; + let (_project_dir, root) = goal_project(); + let config = tempfile::tempdir().unwrap(); + let (context, writer) = goal_writer(config.path(), "A"); + let accept = |project| { + goal::accepted( + Some((context.clone(), writer.clone())), + &root, + project, + Source::Direct, + ) + }; + accept("goal-project"); + assert!(!drain_goal_writer(config.path(), &context, &writer) + .iter() + .any(|e| e.event_name == "creative_task_submit")); + let marker = root.join(".agent/analytics-goal.json"); + fs::write(&marker, b"broken").unwrap(); + goal::created(Some(&writer), &root, "goal-project"); + accept("goal-project"); + assert!(!drain_goal_writer(config.path(), &context, &writer) + .iter() + .any(|e| e.event_name == "creative_task_submit")); + assert_eq!(fs::read(&marker).unwrap(), b"broken"); + fs::remove_file(&marker).unwrap(); + let backup = crate::agent::agent_runtime_json_sidecar_backup_path(&marker); + fs::write( + &backup, + br#"{"schema_version":1,"project_id":"goal-project","submitted":false}"#, + ) + .unwrap(); + goal::created(Some(&writer), &root, "goal-project"); + accept("goal-project"); + assert!(!drain_goal_writer(config.path(), &context, &writer) + .iter() + .any(|e| e.event_name == "creative_task_submit")); + assert!(!marker.exists()); + fs::remove_file(backup).unwrap(); + goal::created(Some(&writer), &root, "goal-project"); + accept("other-project"); + assert!(!drain_goal_writer(config.path(), &context, &writer) + .iter() + .any(|e| e.event_name == "creative_task_submit")); +} + +#[test] +fn goal_creation_without_analytics_context_recovers_on_later_acceptance() { + use super::super::{ + contract::{CreationSource, Source}, + goal, gui, + }; + let (_project_dir, root) = goal_project(); + gui::created( + None, + &root, + "goal-project".into(), + CreationSource::HomeGame, + None, + ); + assert!(!root.join(".agent/analytics-goal.json").exists()); + let config = tempfile::tempdir().unwrap(); + let (context, writer) = goal_writer(config.path(), "A"); + goal::accepted( + Some((context.clone(), writer.clone())), + &root, + "goal-project", + Source::Direct, + ); + let events = drain_goal_writer(config.path(), &context, &writer); + assert_eq!( + events + .iter() + .filter(|event| event.event_name == "creative_task_submit") + .count(), + 1 + ); + // 已落盘资格之后即使标记丢失,也不能凭旧的进程内待办重新授予。 + let marker = root.join(".agent/analytics-goal.json"); + fs::remove_file(&marker).unwrap(); + let backup = crate::agent::agent_runtime_json_sidecar_backup_path(&marker); + if backup.exists() { + fs::remove_file(backup).unwrap(); + } + goal::accepted( + Some((context.clone(), writer.clone())), + &root, + "goal-project", + Source::Direct, + ); + let events = drain_goal_writer(config.path(), &context, &writer); + assert_eq!( + events + .iter() + .filter(|event| event.event_name == "creative_task_submit") + .count(), + 1 + ); + assert!(!marker.exists()); +} + +#[test] +fn goal_creation_lock_and_io_failures_recover_on_later_acceptance() { + use super::super::{contract::Source, goal}; + for io_failure in [false, true] { + let (_project_dir, root) = goal_project(); + let config = tempfile::tempdir().unwrap(); + let (context, writer) = goal_writer(config.path(), "A"); + let lock_path = root.join(".agent/runtime/locks/analytics-goal.lock"); + let lock = if io_failure { + // 用目录占据埋点锁文件,模拟临时文件系统故障,不改业务 manifest。 + fs::create_dir_all(&lock_path).unwrap(); + None + } else { + Some( + crate::agent::try_acquire_game_creator_agent_runtime_task_lock( + &root, + "analytics-goal", + ) + .unwrap() + .unwrap(), + ) + }; + goal::created(Some(&writer), &root, "goal-project"); + drain_goal_writer(config.path(), &context, &writer); + assert!(!root.join(".agent/analytics-goal.json").exists()); + drop(lock); + if io_failure { + fs::remove_dir(&lock_path).unwrap(); + } + goal::accepted( + Some((context.clone(), writer.clone())), + &root, + "goal-project", + Source::Direct, + ); + let events = drain_goal_writer(config.path(), &context, &writer); + assert_eq!( + events + .iter() + .filter(|event| event.event_name == "creative_task_submit") + .count(), + 1 + ); + } +} + +#[test] +fn goal_queue_capacity_rejection_is_silent_and_does_not_charge_bytes() { + use super::super::goal; + let (sender, receiver) = mpsc::sync_channel(1); + let counters = Arc::new(Counters::default()); + let writer = AnalyticsWriter { + sender, + counters: counters.clone(), + }; + let (_dir, root) = goal_project(); + assert!(writer.flush()); // 占满队列,让创建通知从第一次起就无法投递。 + goal::created(Some(&writer), &root, "goal-project"); + let charged = counters.queued_bytes.load(Ordering::Relaxed); + assert_eq!(charged, 0); + goal::created(Some(&writer), &root, "goal-project"); + assert_eq!(counters.queued_bytes.load(Ordering::Relaxed), charged); + assert_eq!(counters.dropped.load(Ordering::Relaxed), 2); + assert!(!root.join(".agent/analytics-goal.json").exists()); + drop(receiver); + counters + .queued_bytes + .store(MAX_BYTES as u64, Ordering::Relaxed); + goal::created(Some(&writer), &root, "goal-project"); + assert_eq!( + counters.queued_bytes.load(Ordering::Relaxed), + MAX_BYTES as u64 + ); + let config = tempfile::tempdir().unwrap(); + let (context, recovered) = goal_writer(config.path(), "A"); + goal::accepted( + Some((context.clone(), recovered.clone())), + &root, + "goal-project", + super::super::contract::Source::Direct, + ); + let events = drain_goal_writer(config.path(), &context, &recovered); + assert_eq!( + events + .iter() + .filter(|event| event.event_name == "creative_task_submit") + .count(), + 1 + ); +} + +#[test] +fn later_real_acceptance_can_consume_after_lock_contention_and_two_writers_do_not_duplicate() { + use super::super::{contract::Source, goal}; + let (_project_dir, root) = goal_project(); + let config = tempfile::tempdir().unwrap(); + let (a, first) = goal_writer(config.path(), "A"); + let (b, second) = goal_writer(config.path(), "B"); + goal::created(Some(&first), &root, "goal-project"); + drain_goal_writer(config.path(), &a, &first); + let lock = + crate::agent::try_acquire_game_creator_agent_runtime_task_lock(&root, "analytics-goal") + .unwrap() + .unwrap(); + goal::accepted( + Some((a.clone(), first.clone())), + &root, + "goal-project", + Source::DesignAgent, + ); + assert!(!drain_goal_writer(config.path(), &a, &first) + .iter() + .any(|event| event.event_name == "creative_task_submit")); + drop(lock); + // 两个 worker 的独立线程竞争同一资格,只可能其中一个消费成功。 + goal::accepted( + Some((a.clone(), first.clone())), + &root, + "goal-project", + Source::DesignAgent, + ); + goal::accepted( + Some((b.clone(), second.clone())), + &root, + "goal-project", + Source::Direct, + ); + drain_goal_writer(config.path(), &a, &first); + let events = drain_goal_writer(config.path(), &b, &second); + assert_eq!( + events + .iter() + .filter(|event| event.event_name == "creative_task_submit") + .count(), + 1 + ); +} + +fn run_outcome() -> super::super::run::Outcome { + super::super::run::Outcome { + turn_id: None, + end_reason: super::super::contract::RunEndReason::Finished, + error_code: None, + duration_ms: Some(123), + output_change_detected: None, + revision_id: None, + } +} + +#[test] +fn run_retry_counter_is_project_scoped_and_duplicate_acceptance_does_not_reopen_terminal() { + use super::super::{ + contract::{RunSource, Source}, + run, + }; + let (_project_dir, root) = goal_project(); + let config = tempfile::tempdir().unwrap(); + let (context, writer) = goal_writer(config.path(), "A"); + let initial = run::Metadata::new(context.clone(), Source::Direct, RunSource::UserSubmit); + let retry = run::Metadata::new(context.clone(), Source::DesignAgent, RunSource::UserRetry); + let later = run::Metadata::new(context.clone(), Source::Direct, RunSource::UserContinue); + for metadata in [&initial, &retry, &later] { + run::accepted(&writer, &root, "goal-project", metadata); + run::accepted(&writer, &root, "goal-project", metadata); + run::finished( + Some((context.clone(), writer.clone())), + &root, + "goal-project", + metadata, + run_outcome(), + ); + run::accepted(&writer, &root, "goal-project", metadata); + run::finished( + Some((context.clone(), writer.clone())), + &root, + "goal-project", + metadata, + run_outcome(), + ); + } + let events = drain_goal_writer(config.path(), &context, &writer); + for (metadata, ordinal) in [(&initial, 0), (&retry, 1), (&later, 1)] { + let matching: Vec<_> = events + .iter() + .filter(|e| e.agent_run_id.as_deref() == Some(&metadata.run_id)) + .collect(); + assert_eq!(matching.len(), 1); + assert_eq!(matching[0].event_id, metadata.terminal_event_id); + assert_eq!(matching[0].properties["retry_index"], ordinal); + } + let state: serde_json::Value = + serde_json::from_slice(&fs::read(root.join(".agent/analytics-runs.json")).unwrap()) + .unwrap(); + assert_eq!(state["retry_count"], 1); + assert_eq!(state["direct"]["terminal_consumed"], true); + assert!(state.get("context").is_none()); +} + +#[test] +fn recovered_run_uses_original_account_and_current_gui_session_without_recounting() { + use super::super::{ + contract::{RunSource, Source}, + run, + }; + let (_project_dir, root) = goal_project(); + let config = tempfile::tempdir().unwrap(); + let (original, writer) = goal_writer(config.path(), "A"); + let metadata = run::Metadata::new(original.clone(), Source::DesignAgent, RunSource::UserRetry); + run::accepted(&writer, &root, "goal-project", &metadata); + drain_goal_writer(config.path(), &original, &writer); + let persisted: run::Metadata = + serde_json::from_slice(&serde_json::to_vec(&metadata).unwrap()).unwrap(); + let (mut current, recovered) = goal_writer(config.path(), "B"); + current.client_version = "2.0.0".into(); + let mut outcome = run_outcome(); + outcome.duration_ms = None; + run::finished( + Some((current.clone(), recovered.clone())), + &root, + "goal-project", + &persisted, + outcome.clone(), + ); + run::finished( + Some((current.clone(), recovered.clone())), + &root, + "goal-project", + &persisted, + outcome, + ); + let events = drain_goal_writer(config.path(), ¤t, &recovered); + let matching: Vec<_> = events + .iter() + .filter(|event| event.event_id == metadata.terminal_event_id) + .collect(); + assert_eq!(matching.len(), 1); + assert_eq!(matching[0].user_id.as_deref(), Some("A")); + assert_eq!(matching[0].editor_session_id, current.editor_session_id); + assert_eq!(matching[0].client_version, "2.0.0"); + assert_eq!(matching[0].properties["retry_index"], 1); + assert!(matching[0].properties["duration_ms"].is_null()); +} + +#[test] +fn missing_overwritten_or_unwritable_run_slots_do_not_fabricate_terminal_events() { + use super::super::{ + contract::{RunSource, Source}, + run, + }; + let (_project_dir, root) = goal_project(); + let config = tempfile::tempdir().unwrap(); + let (context, writer) = goal_writer(config.path(), "A"); + let first = run::Metadata::new(context.clone(), Source::Direct, RunSource::UserSubmit); + let next = run::Metadata::new(context.clone(), Source::Direct, RunSource::UserContinue); + run::finished( + Some((context.clone(), writer.clone())), + &root, + "goal-project", + &first, + run_outcome(), + ); + run::accepted(&writer, &root, "goal-project", &first); + run::accepted(&writer, &root, "goal-project", &next); + run::finished( + Some((context.clone(), writer.clone())), + &root, + "goal-project", + &first, + run_outcome(), + ); + assert!(!drain_goal_writer(config.path(), &context, &writer) + .iter() + .any(|e| e.agent_run_id.is_some())); + let lock = + crate::agent::try_acquire_game_creator_agent_runtime_task_lock(&root, "analytics-runs") + .unwrap() + .unwrap(); + run::finished( + Some((context.clone(), writer.clone())), + &root, + "goal-project", + &next, + run_outcome(), + ); + assert!(!drain_goal_writer(config.path(), &context, &writer) + .iter() + .any(|e| e.agent_run_id.is_some())); + drop(lock); + // 阻止sidecar完成替换后的备份清理:写入返回失败时不得投递成功事件。 + let path = root.join(".agent/analytics-runs.json"); + let backup = crate::agent::agent_runtime_json_sidecar_backup_path(&path); + fs::create_dir(&backup).unwrap(); + run::finished( + Some((context.clone(), writer.clone())), + &root, + "goal-project", + &next, + run_outcome(), + ); + assert!(!drain_goal_writer(config.path(), &context, &writer) + .iter() + .any(|e| e.agent_run_id.is_some())); + fs::remove_dir(backup).unwrap(); +} + +#[test] +fn direct_terminal_waits_for_exact_attempt_settlement_and_discards_intermediate_failures() { + use super::super::{ + contract::{ErrorCode, RunEndReason, RunSource, Source}, + run, + }; + let (_project_dir, root) = goal_project(); + let config = tempfile::tempdir().unwrap(); + let (context, writer) = goal_writer(config.path(), "A"); + let metadata = run::Metadata::new(context.clone(), Source::Direct, RunSource::UserSubmit); + run::accepted(&writer, &root, "goal-project", &metadata); + let first = uuid::Uuid::new_v4().to_string(); + let final_attempt = uuid::Uuid::new_v4().to_string(); + let capture = || Some((context.clone(), writer.clone())); + let mut failure = run_outcome(); + failure.end_reason = RunEndReason::Failed; + failure.error_code = Some(ErrorCode::ProviderTimeout); + run::direct_finished( + capture(), + &root, + "goal-project", + &metadata, + Some(&first), + failure.clone(), + ); + run::settle(capture(), &uuid::Uuid::new_v4().to_string(), false); + assert!(!drain_goal_writer(config.path(), &context, &writer) + .iter() + .any(|e| e.agent_run_id.is_some())); + // 另一个被取消的尝试不消费槽位;保留第一次失败,验证最终确认不会选错它。 + let cancelled = uuid::Uuid::new_v4().to_string(); + run::direct_finished( + capture(), + &root, + "goal-project", + &metadata, + Some(&cancelled), + failure, + ); + run::settle(capture(), &cancelled, true); + run::settle(capture(), &cancelled, false); + assert!(!drain_goal_writer(config.path(), &context, &writer) + .iter() + .any(|e| e.agent_run_id.is_some())); + run::direct_finished( + capture(), + &root, + "goal-project", + &metadata, + None, + run_outcome(), + ); + run::direct_finished( + capture(), + &root, + "goal-project", + &metadata, + Some(&final_attempt), + run_outcome(), + ); + assert!(!drain_goal_writer(config.path(), &context, &writer) + .iter() + .any(|e| e.agent_run_id.is_some())); + run::settle(capture(), &final_attempt, false); + run::settle(capture(), &final_attempt, false); + let events = drain_goal_writer(config.path(), &context, &writer); + let terminal: Vec<_> = events.iter().filter(|e| e.agent_run_id.is_some()).collect(); + assert_eq!(terminal.len(), 1); + assert_eq!(terminal[0].event_name, "agent_run_completed"); + assert_eq!(terminal[0].event_id, metadata.terminal_event_id); +} + +#[test] +fn direct_pending_capacity_evicts_oldest_without_settlement_fallback() { + use super::super::{ + contract::{RunSource, Source}, + run, + }; + let (_project_dir, root) = goal_project(); + let config = tempfile::tempdir().unwrap(); + let (context, writer) = goal_writer(config.path(), "A"); + let metadata = run::Metadata::new(context.clone(), Source::Direct, RunSource::UserSubmit); + run::accepted(&writer, &root, "goal-project", &metadata); + let ids: Vec<_> = (0..17).map(|_| uuid::Uuid::new_v4().to_string()).collect(); + for id in &ids { + run::direct_finished( + Some((context.clone(), writer.clone())), + &root, + "goal-project", + &metadata, + Some(id), + run_outcome(), + ); + } + run::settle(Some((context.clone(), writer.clone())), &ids[0], false); + assert!(!drain_goal_writer(config.path(), &context, &writer) + .iter() + .any(|e| e.agent_run_id.is_some())); + assert!(writer.counters().dropped >= 1); + run::settle(Some((context.clone(), writer.clone())), &ids[16], false); + assert_eq!( + drain_goal_writer(config.path(), &context, &writer) + .iter() + .filter(|e| e.agent_run_id.is_some()) + .count(), + 1 + ); +} + +#[test] +fn direct_pending_byte_budget_and_session_are_checked_before_consumption() { + use super::super::{ + contract::{Context, RunSource, Source}, + run, + }; + let config = tempfile::tempdir().unwrap(); + let mut store = open(config.path()); + let context = Context { + route: route("A"), + editor_session_id: store.session_id.clone(), + client_version: "1.0.0".into(), + }; + let metadata = run::Metadata::new(context.clone(), Source::Direct, RunSource::UserSubmit); + let mut last = String::new(); + for _ in 0..10 { + last = uuid::Uuid::new_v4().to_string(); + // worker接收到的序列化预算是独立的保守保留量。 + store.run_request( + run::Request::DirectCandidate { + attempt_id: last.clone(), + terminal: Box::new(run::Request::Terminal { + root: config.path().into(), + project_id: "project".into(), + metadata: metadata.clone(), + context: context.clone(), + event_time: "2026-09-21T00:00:00.000Z".into(), + outcome: run_outcome(), + }), + }, + 128 * 1024, + ); + } + assert_eq!(store.pending_runs.len(), 8); + assert_eq!(store.pending_run_bytes, MAX_BYTES as u64); + store.run_request( + run::Request::Settle { + editor_session_id: uuid::Uuid::new_v4().to_string(), + attempt_id: last.clone(), + discard: true, + }, + 128, + ); + assert_eq!(store.pending_runs.len(), 8); + store.run_request( + run::Request::Settle { + editor_session_id: context.editor_session_id, + attempt_id: last, + discard: true, + }, + 128, + ); + assert_eq!(store.pending_runs.len(), 7); + assert_eq!(store.pending_run_bytes, 7 * 128 * 1024); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/analytics/upload.rs b/apps/ai-game-creator-shell/src-tauri/src/analytics/upload.rs new file mode 100644 index 000000000..8d6bda7b9 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/analytics/upload.rs @@ -0,0 +1,228 @@ +//! 独立后台上传轮次;网络等待不占用 writer 或业务锁。 +use super::{contract::Route, store}; +use crate::platform_session::{current_platform_session, PlatformSessionSnapshot}; +use std::fs::{File, OpenOptions}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +const INTERVAL: Duration = Duration::from_secs(15 * 60); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +const CYCLE_BUDGET: Duration = Duration::from_secs(120); +const MAX_BATCHES: usize = 20; +static FAILURES: AtomicU64 = AtomicU64::new(0); + +fn failure() { + let _ = FAILURES.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |n| { + Some(n.saturating_add(1)) + }); +} + +pub(super) fn start(config_dir: PathBuf) { + tauri::async_runtime::spawn(async move { + let Ok(client) = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(REQUEST_TIMEOUT) + .build() + else { + return; + }; + let root = config_dir.join("analytics"); + let mut next = tokio::time::Instant::now() + INTERVAL; + loop { + tokio::time::sleep_until(next).await; + // 休眠恢复只跑一次,下一轮从当前时间重新计时。 + if advance_schedule(tokio::time::Instant::now(), &mut next) { + cycle(&root, &client).await; + } + } + }); +} + +fn advance_schedule(now: tokio::time::Instant, next: &mut tokio::time::Instant) -> bool { + if now < *next { + return false; + } + *next = now + INTERVAL; + true +} + +fn claim(root: &Path) -> std::io::Result { + if !store::safe_metadata(root)?.is_dir() { + return Err(store::invalid_data()); + } + let path = root.join("upload.lock"); + if path.exists() { + store::safe_metadata(&path)?; + } + let lock = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(path)?; + lock.try_lock().map_err(|_| store::invalid_data())?; + Ok(lock) +} + +fn matches_identity(route: &Route, session: &PlatformSessionSnapshot) -> bool { + route.user_id.is_some() + && route.destination_origin.is_some() + && *route + == Route::from_identity(Some(session.user_id.clone()), Some(&session.api_base_url)) +} + +fn acknowledged(value: &serde_json::Value, batch: &store::UploadBatch) -> bool { + value.get("ok") == Some(&serde_json::Value::Bool(true)) + && value.get("error") == Some(&serde_json::Value::Null) + && value.pointer("/data/acknowledged_batch_ids") + == Some(&serde_json::json!([batch.batch_id])) + && value + .pointer("/data/event_count") + .and_then(serde_json::Value::as_u64) + == Some(batch.event_count as u64) +} + +fn stop_after(status: u16) -> bool { + matches!(status, 401 | 403 | 429 | 500..=599) +} + +pub(super) async fn cycle(root: &Path, client: &reqwest::Client) { + let Ok(_lock) = claim(root) else { return }; + let started = Instant::now(); + let Ok(candidates) = store::upload_candidates(root) else { + failure(); + return; + }; + let mut attempted = 0; + for path in candidates { + if attempted >= MAX_BATCHES || started.elapsed() >= CYCLE_BUDGET { + break; + } + let Ok(batch) = store::load_upload_batch(path) else { + continue; + }; + let Some(session) = current_platform_session() else { + break; + }; + if !matches_identity(&batch.route, &session) || !batch.exists() { + continue; + } + let Some(origin) = &batch.route.destination_origin else { + continue; + }; + attempted += 1; + // 身份只在发起前读取;在途请求保持原凭据,切换账号后仍可清理原批次。 + let response = client + .post(format!("{origin}/api/agc/analytics/batches")) + .bearer_auth(&session.access_token) + .header("x-genarrative-response-envelope", "1") + .timeout(REQUEST_TIMEOUT.min(CYCLE_BUDGET.saturating_sub(started.elapsed()))) + .json(&batch.request) + .send() + .await; + let Ok(mut response) = response else { + failure(); + break; + }; + let status = response.status().as_u16(); + if status != 200 { + failure(); + if stop_after(status) { + break; + } + continue; + } + let mut bytes = Vec::new(); + let mut valid = true; + let mut unavailable = false; + loop { + match response.chunk().await { + Ok(Some(chunk)) if bytes.len() + chunk.len() <= 16 * 1024 => { + bytes.extend_from_slice(&chunk) + } + Ok(None) => break, + Err(_) => { + valid = false; + unavailable = true; + break; + } + _ => { + valid = false; + break; + } + } + } + if valid && serde_json::from_slice(&bytes).is_ok_and(|value| acknowledged(&value, &batch)) { + if batch.acknowledge().is_err() { + failure(); + } + } else { + failure(); + } + if unavailable { + break; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn first_cycle_waits_fifteen_minutes_and_resume_does_not_catch_up() { + let start = tokio::time::Instant::now(); + let mut next = start + INTERVAL; + assert!(!advance_schedule(start, &mut next)); + assert!(!advance_schedule( + start + INTERVAL - Duration::from_secs(1), + &mut next + )); + assert!(advance_schedule(start + INTERVAL, &mut next)); + let resumed = start + INTERVAL * 8; + assert!(advance_schedule(resumed, &mut next)); + assert!(!advance_schedule(resumed, &mut next)); + assert_eq!(next, resumed + INTERVAL); + } + + #[test] + fn identity_does_not_claim_anonymous_or_other_account_or_platform() { + let session = PlatformSessionSnapshot { + user_id: "A".into(), + access_token: "test".into(), + api_base_url: "https://example.com/api".into(), + identity_generation: 1, + revision: 1, + }; + let route = Route::from_identity(Some("A".into()), Some("https://example.com")); + assert!(matches_identity(&route, &session)); + for route in [ + Route::from_identity(None, Some("https://example.com")), + Route::from_identity(Some("B".into()), Some("https://example.com")), + Route::from_identity(Some("A".into()), Some("https://other.example")), + ] { + assert!(!matches_identity(&route, &session)); + } + } + + #[test] + fn upload_lock_is_nonblocking_and_single_owner() { + let dir = tempfile::tempdir().unwrap(); + let lock = claim(dir.path()).unwrap(); + assert!(claim(dir.path()).is_err()); + drop(lock); + assert!(claim(dir.path()).is_ok()); + } + + #[test] + fn toxic_batch_can_be_skipped_but_auth_and_service_failures_end_cycle() { + for status in [400, 409, 413] { + assert!(!stop_after(status)); + } + for status in [401, 403, 429, 500, 503] { + assert!(stop_after(status)); + } + assert_eq!(INTERVAL, Duration::from_secs(900)); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 3a0ec0140..b5c09bb9d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -590,11 +590,26 @@ pub(crate) fn create_automatic_local_game_project( planning: Option, projects_root: Option, ) -> Result { - create_automatic_local_game_project_at( + let analytics_context = crate::analytics::gui::capture_analytics_context(); + let result = create_automatic_local_game_project_at( &resolve_game_project_creation_root(&app, projects_root.as_deref())?, name.as_deref(), planning.unwrap_or(false), - ) + ); + if let Ok(project) = &result { + crate::analytics::gui::created( + analytics_context, + Path::new(&project.project_path), + project.manifest.project_id.clone(), + if planning.unwrap_or(false) { + crate::analytics::contract::CreationSource::HomeDesign + } else { + crate::analytics::contract::CreationSource::HomeGame + }, + None, + ); + } + result } #[tauri::command] @@ -603,10 +618,24 @@ pub(crate) fn init_local_game_project( project_id: String, name: String, ) -> Result { + let analytics_context = crate::analytics::gui::capture_analytics_context(); let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "project.create")?; let _lock = acquire_project_write_lock(root, "project.create")?; - init_local_game_project_at(root, project_id.trim(), name.trim()) + let new_project = matches!(fs::symlink_metadata(root.join(".agent/manifest.json")), Err(error) if error.kind() == std::io::ErrorKind::NotFound); + let result = init_local_game_project_at(root, project_id.trim(), name.trim()); + if new_project { + if let Ok(project) = &result { + crate::analytics::gui::created( + analytics_context, + Path::new(&project.project_path), + project.manifest.project_id.clone(), + crate::analytics::contract::CreationSource::SelectedDirectory, + None, + ); + } + } + result } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] @@ -649,13 +678,27 @@ pub(crate) fn import_local_godot_project( project_id: String, name: String, ) -> Result { + let analytics_context = crate::analytics::gui::capture_analytics_context(); let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "project.create")?; if discover_local_godot_project_root(root)?.is_none() { return Err("所选工作区未在根目录或一层子目录发现 project.godot".to_string()); } let _lock = acquire_project_write_lock(root, "project.create")?; - import_local_godot_project_at(root, project_id.trim(), name.trim()) + let new_project = matches!(fs::symlink_metadata(root.join(".agent/manifest.json")), Err(error) if error.kind() == std::io::ErrorKind::NotFound); + let result = import_local_godot_project_at(root, project_id.trim(), name.trim()); + if new_project { + if let Ok(project) = &result { + crate::analytics::gui::created( + analytics_context, + Path::new(&project.project_path), + project.manifest.project_id.clone(), + crate::analytics::contract::CreationSource::SelectedDirectory, + None, + ); + } + } + result } #[tauri::command] @@ -664,6 +707,7 @@ pub(crate) fn import_local_cocos_project( project_id: String, name: String, ) -> Result { + let analytics_context = crate::analytics::gui::capture_analytics_context(); let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "project.create")?; if discover_local_cocos_project_root(root)?.is_none() { @@ -673,7 +717,20 @@ pub(crate) fn import_local_cocos_project( ); } let _lock = acquire_project_write_lock(root, "project.create")?; - import_local_cocos_project_at(root, project_id.trim(), name.trim()) + let new_project = matches!(fs::symlink_metadata(root.join(".agent/manifest.json")), Err(error) if error.kind() == std::io::ErrorKind::NotFound); + let result = import_local_cocos_project_at(root, project_id.trim(), name.trim()); + if new_project { + if let Ok(project) = &result { + crate::analytics::gui::created( + analytics_context, + Path::new(&project.project_path), + project.manifest.project_id.clone(), + crate::analytics::contract::CreationSource::SelectedDirectory, + None, + ); + } + } + result } #[tauri::command] @@ -682,13 +739,27 @@ pub(crate) fn import_local_unity_project( project_id: String, name: String, ) -> Result { + let analytics_context = crate::analytics::gui::capture_analytics_context(); let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "project.create")?; if discover_local_unity_project_root(root)?.is_none() { return Err("所选目录不是有效的 Unity 项目".to_string()); } let _lock = acquire_project_write_lock(root, "project.create")?; - import_local_unity_project_at(root, project_id.trim(), name.trim()) + let new_project = matches!(fs::symlink_metadata(root.join(".agent/manifest.json")), Err(error) if error.kind() == std::io::ErrorKind::NotFound); + let result = import_local_unity_project_at(root, project_id.trim(), name.trim()); + if new_project { + if let Ok(project) = &result { + crate::analytics::gui::created( + analytics_context, + Path::new(&project.project_path), + project.manifest.project_id.clone(), + crate::analytics::contract::CreationSource::SelectedDirectory, + None, + ); + } + } + result } #[tauri::command] @@ -5644,18 +5715,14 @@ pub(crate) fn cancel_local_project_resource_preview_scope( preview_manager.cancel_scope(&scope_id) } -#[tauri::command] -pub(crate) fn write_local_project_file( - project_path: String, - relative_path: String, - content: String, -) -> Result { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "file.write")?; - let _lock = acquire_project_write_lock(root, "file.write")?; - advance_agent_runtime_project_revision_locked(root)?; - write_local_project_file_at(root, relative_path.trim(), &content) -} +type ManualAnalyticsCapture = Option<( + crate::analytics::contract::Context, + crate::analytics::store::AnalyticsWriter, +)>; + +#[cfg(test)] +#[path = "commands_manual_analytics_tests.rs"] +mod manual_analytics_tests; #[tauri::command] pub(crate) fn read_local_game_memory( @@ -5889,7 +5956,7 @@ pub(crate) async fn read_direct_project_history_slice( return Err( "DirectProject 历史切片只接受一个锚点(beforeItemId / throughItemId)" .to_string(), - ) + ); } (Some(before), None) => DirectProjectHistoryAnchor::Before(before), (None, Some(through)) => DirectProjectHistoryAnchor::Through(through), @@ -5968,10 +6035,50 @@ pub(crate) fn build_local_project_index( pub(crate) fn create_local_project_checkpoint( project_path: String, ) -> Result { + create_local_project_checkpoint_with_capture( + project_path, + crate::analytics::gui::capture_writer_context(), + ) +} + +fn create_local_project_checkpoint_with_capture( + project_path: String, + capture: ManualAnalyticsCapture, +) -> Result { + use crate::analytics::{ + contract::{ProjectSaved, SaveSource, Source}, + project, + }; let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "project.checkpoint")?; let _lock = acquire_project_write_lock(root, "project.checkpoint")?; - create_local_project_checkpoint_at(root) + let project_id = capture + .as_ref() + .and_then(|_| read_existing_manifest_for_project(root).ok()) + .map(|manifest| manifest.project_id); + let result = create_local_project_checkpoint_at(root)?; + if let Some(project_id) = project_id { + project::saved( + capture, + &project_id, + Source::Manual, + &result.checkpoint_id, + ProjectSaved { + save_source: SaveSource::Checkpoint, + revision_id: None, + }, + None, + ); + } + Ok(result) +} + +#[cfg(test)] +pub(crate) fn checkpoint_with_capture_for_test( + project_path: String, + capture: ManualAnalyticsCapture, +) -> Result { + create_local_project_checkpoint_with_capture(project_path, capture) } /// 为发布导出试玩包:项目还没有可玩入口时先跑项目自己的 `npm run build`。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands_manual_analytics_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/commands_manual_analytics_tests.rs new file mode 100644 index 000000000..b8bb1a793 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/commands_manual_analytics_tests.rs @@ -0,0 +1,195 @@ +use super::*; + +#[test] +fn ui_gui_save_records_only_saved_revision_with_frozen_identity() { + use crate::ui_editor::persistence::{ + initialize_ui_design_state_at, SaveUiDesignStateInput, SaveUiDesignStateResult, + }; + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("project"); + let project_id = "ui-analytics"; + init_local_game_project_at(&root, project_id, "UI 成果采集").unwrap(); + let relative_path = "ui/design.json"; + fs::create_dir_all(root.join("ui")).unwrap(); + fs::write(root.join(relative_path), b"").unwrap(); + let asset = register_local_asset_at( + &root, + relative_path, + GameCreationAppAssetKind::UiDesignDoc, + shared_contracts::game_creation_app::GAME_CREATION_APP_UI_DESIGN_DOC_MEDIA_TYPE, + "test", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Generated, + canvas_project_id: None, + resource_id: Some("ui:test".into()), + asset_object_id: None, + task_id: None, + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + ) + .unwrap(); + initialize_ui_design_state_at(&root, project_id, &asset.id).unwrap(); + let config = temp.path().join("config"); + fs::create_dir_all(&config).unwrap(); + let mut current = 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(), + }; + let writer = AnalyticsWriter::start(config.clone(), current.editor_session_id.clone()); + let capture = Some((current.clone(), writer.clone())); + current.route.user_id = Some("B".into()); + let state = serde_json::from_value(serde_json::json!({ + "ui_trees": [], "ui_design_images": {"page": { + "metadata": {"name":"主界面", "description":"", "role":"Page", "slave_to":null}, + "path":"assets/missing-reference.png", "pixel_size":[1280.0,720.0], "pixels_per_unit":1.0 + }}, "sprite_assets": {}, "font_assets": {} + })).unwrap(); + let mut input = SaveUiDesignStateInput { + project_path: root.to_string_lossy().into_owned(), + expected_project_id: project_id.into(), + asset_id: asset.id, + expected_revision: 0, + state, + }; + let saved = crate::save_ui_design_state_with_capture(input.clone(), capture.clone()).unwrap(); + let SaveUiDesignStateResult::Saved { + revision, + committed_project_revision, + .. + } = saved + else { + panic!("expected real Saved") + }; + assert!(matches!( + crate::save_ui_design_state_with_capture(input.clone(), capture.clone()).unwrap(), + SaveUiDesignStateResult::Conflict { .. } + )); + input.expected_revision = revision; + assert!(matches!( + crate::save_ui_design_state_with_capture(input.clone(), capture.clone()).unwrap(), + SaveUiDesignStateResult::Unchanged { .. } + )); + input.expected_project_id = "wrong-project".into(); + assert!(crate::save_ui_design_state_with_capture(input, capture).is_err()); + let events = drain(&config, ¤t, &writer); + let revisions: Vec<_> = events + .iter() + .filter(|event| event["event_name"] == "project_revision_created") + .collect(); + assert_eq!(revisions.len(), 1); + let event = revisions[0]; + assert_eq!(event["user_id"], "A"); + assert_eq!(event["project_id"], project_id); + assert_eq!(event["source"], "ui_editor"); + assert_eq!(event["properties"]["revision_source"], "ui_editor"); + assert_eq!(event["properties"]["change_kind"], "ui"); + assert_eq!( + event["properties"]["revision_id"], + committed_project_revision.to_string() + ); + assert!(!events + .iter() + .any(|event| event["event_name"] == "project_save")); +} +use crate::analytics::{ + contract::{Context, EntrySource, EventData, Route, SessionStart, Source}, + store::AnalyticsWriter, +}; + +fn drain(config: &Path, context: &Context, writer: &AnalyticsWriter) -> Vec { + 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 = 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(|text| { + text.lines() + .filter_map(|line| serde_json::from_str(line).ok()) + .collect::>() + }) + .collect(); + if events.iter().any(|event| event["event_id"] == marker_id) { + return events; + } + assert!( + std::time::Instant::now() < deadline, + "manual analytics FIFO sentinel timed out" + ); + std::thread::sleep(std::time::Duration::from_millis(5)); + } +} + +#[test] +fn manual_checkpoint_records_only_complete_save_with_frozen_identity() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("project"); + init_local_game_project_at(&root, "manual-analytics", "人工操作采集").unwrap(); + let config = temp.path().join("config"); + fs::create_dir_all(&config).unwrap(); + let mut current = 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(), + }; + let writer = AnalyticsWriter::start(config.clone(), current.editor_session_id.clone()); + let captured = Some((current.clone(), writer.clone())); + current.route.user_id = Some("B".into()); + let path = root.to_string_lossy().into_owned(); + fs::create_dir_all(root.join("game")).unwrap(); + fs::write(root.join("game/result.js"), "const result = 1;").unwrap(); + let checkpoint = + create_local_project_checkpoint_with_capture(path.clone(), captured.clone()).unwrap(); + assert!(checkpoint.file_count > 0); + let blocked = temp.path().join("blocked-checkpoint"); + init_local_game_project_at(&blocked, "blocked-checkpoint", "checkpoint 失败夹具").unwrap(); + fs::write(blocked.join(".agent/checkpoints"), b"not a directory").unwrap(); + assert!(create_local_project_checkpoint_with_capture( + blocked.to_string_lossy().into_owned(), + captured, + ) + .is_err()); + let events = drain(&config, ¤t, &writer); + let saves: Vec<_> = events + .iter() + .filter(|event| event["event_name"] == "project_save") + .collect(); + assert_eq!(saves.len(), 1, "only the complete checkpoint is saved"); + let checkpoint_event = saves + .iter() + .find(|event| event["properties"]["save_source"] == "checkpoint") + .unwrap(); + assert!(checkpoint_event["properties"].get("revision_id").is_none()); + for event in &saves { + assert_eq!(event["user_id"], "A"); + assert_eq!(event["project_id"], "manual-analytics"); + assert_eq!(event["source"], "manual"); + assert!(event["agent_run_id"].is_null()); + assert!(event["agent_turn_id"].is_null()); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 263f74d52..9d5f1ae18 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -61,6 +61,7 @@ use tauri_plugin_opener::OpenerExt; /// 更新完成后的进程重启:Windows 由 NSIS 安装程序代为重启,macOS / Linux 由客户端在安装后调用。 #[tauri::command] fn restart_agc_app(app: tauri::AppHandle) { + analytics::gui::mark_restart(); app.restart(); } @@ -110,6 +111,7 @@ include!(concat!(env!("OUT_DIR"), "/agent_runtime_prompt_bundle.rs")); mod agent; mod agent_native_tools; +mod analytics; mod asset_generation_tasks; mod assets; mod browser; @@ -275,9 +277,38 @@ fn load_ui_design_state( fn save_ui_design_state( input: ui_editor::persistence::SaveUiDesignStateInput, ) -> Result { + save_ui_design_state_with_capture(input, analytics::gui::capture_writer_context()) +} + +fn save_ui_design_state_with_capture( + input: ui_editor::persistence::SaveUiDesignStateInput, + capture: Option<( + analytics::contract::Context, + analytics::store::AnalyticsWriter, + )>, +) -> Result { + let project_id = input.expected_project_id.clone(); let root = Path::new(input.project_path.trim()); enforce_project_permission_policy(root, "asset.register")?; - ui_editor::persistence::save_ui_design_state_at(input) + let result = ui_editor::persistence::save_ui_design_state_at(input)?; + if let ui_editor::persistence::SaveUiDesignStateResult::Saved { + committed_project_revision, + .. + } = &result + { + analytics::project::revision( + capture, + &project_id, + analytics::contract::Source::UiEditor, + analytics::contract::RevisionCreated { + revision_id: committed_project_revision.to_string(), + revision_source: analytics::contract::RevisionSource::UiEditor, + change_kind: analytics::contract::ChangeKind::Ui, + files_changed_count: None, + }, + ); + } + Ok(result) } #[tauri::command] @@ -2447,7 +2478,15 @@ fn main() { .plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(context_menu::init()) - .on_window_event(|window, event| handle_project_snapshot_window_event(window, event)) + .on_page_load(|webview, payload| { + if matches!(payload.event(), tauri::webview::PageLoadEvent::Started) { + analytics::gui::page_loading(webview.window().label()); + } + }) + .on_window_event(|window, event| { + analytics::gui::window_event(window, event); + handle_project_snapshot_window_event(window, event); + }) .manage(game_creator_preview_registry()) .manage(ProjectResourcePreviewReadManager::default()) .manage(PluginHost::default()) @@ -2572,6 +2611,11 @@ fn main() { Ok(()) }) .invoke_handler(tauri::generate_handler![ + analytics::gui::capture_analytics_context, + analytics::gui::settle_direct_run_analytics, + analytics::gui::record_analytics_project_open, + analytics::gui::record_analytics_project_leave, + analytics::gui::record_analytics_ui_save, start_game_creator_external_mcp, stop_game_creator_external_mcp, create_automatic_local_game_project, @@ -2706,7 +2750,6 @@ fn main() { read_local_project_structured_preview, read_local_project_media_preview, cancel_local_project_resource_preview_scope, - write_local_project_file, read_local_game_memory, read_local_agent_memory, write_local_agent_memory, @@ -2776,7 +2819,21 @@ fn main() { std::process::exit(1); } }; - app.run(move |_app_handle, event| handle_game_creator_gui_run_event(&event)); + app.run(move |app_handle, event| { + if matches!(event, tauri::RunEvent::Ready) { + if let Some(directory) = game_creator_runtime_config_dir() { + analytics::gui::initialize( + app_handle.clone(), + directory, + app_handle.package_info().version.to_string(), + ); + } + } + if matches!(event, tauri::RunEvent::Exit) { + analytics::gui::exit(); + } + handle_game_creator_gui_run_event(&event); + }); } #[cfg(test)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs b/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs index 41fe89783..a58d9eebf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs @@ -289,6 +289,46 @@ struct PlatformSessionState { static PLATFORM_SESSION: OnceLock> = OnceLock::new(); +// 仅用于同进程埋点通知排序,不替代认证的 revision/身份代次,不持久化凭据。 +static ANALYTICS_IDENTITY_SEQUENCE: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(0); + +fn analytics_route(current: &PlatformSessionState) -> crate::analytics::contract::Route { + crate::analytics::contract::Route::from_identity( + current.snapshot.as_ref().map(|s| s.user_id.clone()), + current.snapshot.as_ref().map(|s| s.api_base_url.as_str()), + ) +} + +fn analytics_identity_notice( + current: &PlatformSessionState, +) -> (crate::analytics::contract::Route, u64) { + let sequence = + ANALYTICS_IDENTITY_SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; + (analytics_route(current), sequence) +} + +pub(crate) fn analytics_identity_snapshot() -> Option<(crate::analytics::contract::Route, u64)> { + let current = platform_session().try_lock().ok()?; + Some(( + analytics_route(¤t), + ANALYTICS_IDENTITY_SEQUENCE.load(std::sync::atomic::Ordering::Relaxed), + )) +} + +// 仅供后台初始化使用:持锁发布 GUI 身份,避免快照与通知之间出现空窗。 +// 回调只允许更新内存或非阻塞投递,不能访问磁盘或重新取得认证锁。 +pub(crate) fn initialize_analytics_identity( + initialize: impl FnOnce(crate::analytics::contract::Route, u64), +) { + if let Ok(current) = platform_session().lock() { + initialize( + analytics_route(¤t), + ANALYTICS_IDENTITY_SEQUENCE.load(std::sync::atomic::Ordering::Relaxed), + ); + } +} + fn platform_session() -> &'static Mutex { PLATFORM_SESSION.get_or_init(|| Mutex::new(PlatformSessionState::default())) } @@ -380,6 +420,9 @@ pub(crate) fn install_platform_session( snapshot.identity_generation, snapshot.revision, ); + let (route, sequence) = analytics_identity_notice(¤t); + drop(current); + crate::analytics::gui::identity_changed(route, sequence); Ok(()) } @@ -453,6 +496,9 @@ pub(crate) fn replace_platform_session_for_gui_owner( current.revision = snapshot.revision; current.identity_generation = snapshot.identity_generation; current.snapshot = Some(snapshot); + let (route, sequence) = analytics_identity_notice(¤t); + drop(current); + crate::analytics::gui::identity_changed(route, sequence); Ok(()) } @@ -481,7 +527,11 @@ pub(crate) fn install_platform_session_checked( snapshot.identity_generation, snapshot.revision, ); - if current.snapshot.as_ref() == Some(&snapshot) { + let accepted = current.snapshot.as_ref() == Some(&snapshot); + let (route, sequence) = analytics_identity_notice(¤t); + drop(current); + crate::analytics::gui::identity_changed(route, sequence); + if accepted { Ok(()) } else { Err("authentication-required: 平台登录态写入已过期或主体冲突".to_string()) @@ -519,6 +569,9 @@ pub(crate) fn clear_platform_session(identity_generation: u64, revision: u64) { .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); clear_platform_session_in(&mut current, identity_generation, revision); + let (route, sequence) = analytics_identity_notice(¤t); + drop(current); + crate::analytics::gui::identity_changed(route, sequence); } pub(crate) fn clear_platform_session_for_gui_owner(identity_generation: u64, revision: u64) { @@ -529,6 +582,9 @@ pub(crate) fn clear_platform_session_for_gui_owner(identity_generation: u64, rev current.revision = revision; current.identity_generation = identity_generation; current.snapshot = None; + let (route, sequence) = analytics_identity_notice(¤t); + drop(current); + crate::analytics::gui::identity_changed(route, sequence); } pub(crate) fn clear_platform_session_checked( @@ -539,10 +595,13 @@ pub(crate) fn clear_platform_session_checked( .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); clear_platform_session_in(&mut current, identity_generation, revision); - if current.revision >= revision + let accepted = current.revision >= revision && current.identity_generation >= identity_generation - && current.snapshot.is_none() - { + && current.snapshot.is_none(); + let (route, sequence) = analytics_identity_notice(¤t); + drop(current); + crate::analytics::gui::identity_changed(route, sequence); + if accepted { Ok(()) } else { Err("authentication-required: 平台登出写入已过期".to_string()) diff --git a/apps/ai-game-creator-shell/src-tauri/src/preview.rs b/apps/ai-game-creator-shell/src-tauri/src/preview.rs index e678d6438..6a6520abe 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/preview.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/preview.rs @@ -8,6 +8,7 @@ pub(crate) struct PreviewRegistry { struct PreviewServer { preview: LocalPreviewResult, stop: mpsc::Sender<()>, + _analytics_lease: Option, } impl PreviewRegistry { @@ -15,6 +16,15 @@ impl PreviewRegistry { &self, preview: LocalPreviewResult, stop: mpsc::Sender<()>, + ) -> (LocalPreviewResult, Option) { + self.set_running_with_lease(preview, stop, None) + } + + fn set_running_with_lease( + &self, + preview: LocalPreviewResult, + stop: mpsc::Sender<()>, + analytics_lease: Option, ) -> (LocalPreviewResult, Option) { let mut current = self.current.lock().expect("preview registry lock"); let previous_preview = if let Some(previous) = current.take() { @@ -27,6 +37,7 @@ impl PreviewRegistry { *current = Some(PreviewServer { preview: preview.clone(), stop, + _analytics_lease: analytics_lease, }); (preview, previous_preview) } @@ -713,10 +724,14 @@ pub(crate) fn filter_preview_status_for_project( pub(crate) fn start_local_game_preview( project_path: String, expected_revision: Option, + preview_source: Option, registry: tauri::State<'_, PreviewRegistry>, ) -> Result { let root = Path::new(project_path.trim()); - start_local_game_preview_at_revision(root, expected_revision, ®istry) + let capture = (preview_source == Some(crate::analytics::contract::PreviewSource::User)) + .then(crate::analytics::gui::capture_writer_context) + .flatten(); + start_local_game_preview_with_capture(root, expected_revision, ®istry, capture) } pub(crate) fn start_local_game_preview_at( @@ -730,6 +745,18 @@ pub(crate) fn start_local_game_preview_at_revision( root: &Path, expected_revision: Option, registry: &PreviewRegistry, +) -> Result { + start_local_game_preview_with_capture(root, expected_revision, registry, None) +} + +fn start_local_game_preview_with_capture( + root: &Path, + expected_revision: Option, + registry: &PreviewRegistry, + capture: Option<( + crate::analytics::contract::Context, + crate::analytics::store::AnalyticsWriter, + )>, ) -> Result { enforce_project_permission_policy(root, "preview.start")?; let _lock = acquire_project_write_lock(root, "preview.start")?; @@ -741,6 +768,16 @@ pub(crate) fn start_local_game_preview_at_revision( )); } } + let observation = crate::analytics::preview::prepare( + root, + capture, + crate::analytics::contract::Source::Editor, + crate::analytics::contract::PreviewSource::User, + ); + let (analytics_lease, observation) = match observation { + Some((lease, observation)) => (Some(lease), Some(observation)), + None => (None, None), + }; let (preview, stop) = start_local_game_preview_for_project(root)?; if let Err(error) = record_preview_state( root, @@ -756,7 +793,8 @@ pub(crate) fn start_local_game_preview_at_revision( let _ = record_preview_state(root, GameCreationAppPreviewStatus::Stopped, None, None); return Err(error); } - let (preview, previous_preview) = registry.set_running(preview, stop); + let (preview, previous_preview) = + registry.set_running_with_lease(preview, stop, analytics_lease); if let Some(previous_preview) = previous_preview.as_ref() { // 同一个项目重启预览(换监听线程、换端口)时,旧的 registry 身份与新预览共享 // 同一份落盘记录:上面刚写进去的是 running,这里若再用旧预览收尾,就会把它覆盖 @@ -772,9 +810,16 @@ pub(crate) fn start_local_game_preview_at_revision( let _ = record_preview_state(root, GameCreationAppPreviewStatus::Stopped, None, None); return Err(error); } + if let Some(observation) = observation { + observation.schedule(preview.port); + } Ok(preview) } +#[cfg(test)] +#[path = "preview_analytics_tests.rs"] +mod analytics_tests; + #[tauri::command] pub(crate) fn stop_local_game_preview( project_path: Option, diff --git a/apps/ai-game-creator-shell/src-tauri/src/preview_analytics_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/preview_analytics_tests.rs new file mode 100644 index 000000000..99caee428 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/preview_analytics_tests.rs @@ -0,0 +1,297 @@ +use super::*; +use crate::analytics::{ + contract::{Context, EntrySource, EventData, PreviewSource, Route, SessionStart, Source}, + preview::{prepare, Lease, Observation}, + store::AnalyticsWriter, +}; + +struct Fixture { + _temp: tempfile::TempDir, + root: PathBuf, + config: PathBuf, + context: Context, + writer: AnalyticsWriter, +} + +impl Fixture { + fn new() -> Self { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("project"); + init_local_game_project_at(&root, "preview-analytics", "预览埋点").unwrap(); + fs::create_dir_all(project_game_root(&root)).unwrap(); + fs::write( + project_game_root(&root).join("index.html"), + "游戏入口", + ) + .unwrap(); + let config = temp.path().join("config"); + fs::create_dir_all(&config).unwrap(); + let 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(), + }; + let writer = AnalyticsWriter::start(config.clone(), context.editor_session_id.clone()); + Self { + _temp: temp, + root, + config, + context, + writer, + } + } + + fn capture(&self) -> Option<(Context, AnalyticsWriter)> { + Some((self.context.clone(), self.writer.clone())) + } + + fn observation(&self, source: Source, preview_source: PreviewSource) -> (Lease, Observation) { + prepare(&self.root, self.capture(), source, preview_source).unwrap() + } + + fn events(&self) -> Vec { + let batches = self + .config + .join("analytics/instances") + .join(&self.context.editor_session_id) + .join("batches"); + 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(|text| { + text.lines() + .filter_map(|line| serde_json::from_str(line).ok()) + .collect::>() + }) + .collect() + } + + async fn drained(&self) -> Vec { + let marker = self + .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!(self + .writer + .try_record(self.context.route.clone(), marker, marker_id.clone())); + assert!(self.writer.flush()); + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + let events = self.events(); + if events.iter().any(|event| event["event_id"] == marker_id) { + return events; + } + assert!( + std::time::Instant::now() < deadline, + "preview writer FIFO timeout" + ); + tokio::time::sleep(Duration::from_millis(5)).await; + } + } +} + +fn ready(events: &[serde_json::Value]) -> Vec<&serde_json::Value> { + events + .iter() + .filter(|event| event["event_name"] == "preview_ready") + .collect() +} + +#[tokio::test] +async fn preview_analytics_real_entry_and_reopen_have_distinct_instances_and_original_identity() { + let fixture = Fixture::new(); + let version = read_game_creator_agent_runtime_project_revision(&fixture.root) + .unwrap() + .revision; + let mut current = fixture.context.clone(); + for source in [PreviewSource::User, PreviewSource::Agent] { + let event_source = if source == PreviewSource::User { + Source::Editor + } else { + Source::Direct + }; + let (_lease, observation) = fixture.observation(event_source, source); + current.route.user_id = Some("B".into()); + let (preview, stop) = start_local_game_preview_for_project(&fixture.root).unwrap(); + observation.observe(preview.port).await; + let _ = stop.send(()); + } + assert_eq!(current.route.user_id.as_deref(), Some("B")); + let events = fixture.drained().await; + let events = ready(&events); + assert_eq!(events.len(), 2); + assert_ne!(events[0]["event_id"], events[1]["event_id"]); + for event in &events { + assert_eq!(event["user_id"], "A"); + assert_eq!(event["project_id"], "preview-analytics"); + assert_eq!(event["creative_task_id"], "preview-analytics"); + assert_eq!(event["properties"]["preview_version"], version.to_string()); + assert!(event["agent_run_id"].is_null()); + assert!(event["agent_turn_id"].is_null()); + assert!(!event.to_string().contains("127.0.0.1")); + } + assert!(events.iter().any( + |event| event["source"] == "editor" && event["properties"]["preview_source"] == "user" + )); + assert!(events.iter().any( + |event| event["source"] == "direct" && event["properties"]["preview_source"] == "agent" + )); +} + +#[tokio::test] +async fn preview_analytics_missing_empty_stopped_or_changed_entry_is_not_ready() { + let fixture = Fixture::new(); + let entry = project_game_root(&fixture.root).join("index.html"); + let (preview, stop) = start_local_game_preview_for_project(&fixture.root).unwrap(); + let (lease, observation) = fixture.observation(Source::Direct, PreviewSource::Agent); + drop(lease); + observation.observe(preview.port).await; + let (_lease, observation) = fixture.observation(Source::Editor, PreviewSource::User); + fs::write(&entry, b"").unwrap(); + observation.observe(preview.port).await; + assert!(prepare( + &fixture.root, + fixture.capture(), + Source::Editor, + PreviewSource::User + ) + .is_none()); + fs::write(&entry, b"entry").unwrap(); + let (_lease, observation) = fixture.observation(Source::Editor, PreviewSource::User); + fs::remove_file(&entry).unwrap(); + observation.observe(preview.port).await; + fs::write(&entry, b"entry").unwrap(); + let (_lease, observation) = fixture.observation(Source::Editor, PreviewSource::User); + { + let _lock = acquire_project_write_lock(&fixture.root, "file.write").unwrap(); + advance_agent_runtime_project_revision_locked(&fixture.root).unwrap(); + } + observation.observe(preview.port).await; + let _ = stop.send(()); + assert!(ready(&fixture.drained().await).is_empty()); +} + +#[tokio::test] +async fn preview_analytics_checks_version_again_after_the_actual_request() { + let fixture = Fixture::new(); + let (_lease, observation) = fixture.observation(Source::Editor, PreviewSource::User); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let (requested, request_received) = tokio::sync::oneshot::channel(); + let (allow_response, response_allowed) = mpsc::channel(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + assert!(read_preview_request_line(&mut stream).unwrap().is_some()); + requested.send(()).unwrap(); + response_allowed + .recv_timeout(Duration::from_secs(3)) + .unwrap(); + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\nConnection: close\r\n\r\nready") + .unwrap(); + }); + let probe = tokio::spawn(observation.observe(port)); + tokio::time::timeout(Duration::from_secs(3), request_received) + .await + .unwrap() + .unwrap(); + { + let _lock = acquire_project_write_lock(&fixture.root, "file.write").unwrap(); + advance_agent_runtime_project_revision_locked(&fixture.root).unwrap(); + } + allow_response.send(()).unwrap(); + probe.await.unwrap(); + server.join().unwrap(); + assert!(ready(&fixture.drained().await).is_empty()); +} + +#[tokio::test] +async fn preview_analytics_http_failure_or_empty_response_is_not_ready() { + let fixture = Fixture::new(); + for response in [ + "HTTP/1.1 404 Not Found\r\nContent-Length: 3\r\nConnection: close\r\n\r\nbad", + "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ] { + let (_lease, observation) = fixture.observation(Source::Editor, PreviewSource::User); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + assert!(read_preview_request_line(&mut stream).unwrap().is_some()); + stream.write_all(response.as_bytes()).unwrap(); + }); + observation.observe(port).await; + server.join().unwrap(); + } + assert!(ready(&fixture.drained().await).is_empty()); +} + +#[tokio::test] +async fn preview_analytics_gui_start_schedules_without_waiting_and_registry_invalidates_old_instance( +) { + let fixture = Fixture::new(); + let registry = PreviewRegistry::default(); + let started = + start_local_game_preview_with_capture(&fixture.root, None, ®istry, fixture.capture()) + .unwrap(); + assert_eq!(registry.status().port, Some(started.port)); + let deadline = std::time::Instant::now() + Duration::from_secs(5); + loop { + if !ready(&fixture.drained().await).is_empty() { + break; + } + assert!( + std::time::Instant::now() < deadline, + "GUI scheduled ready timeout" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + registry.stop(); + let (lease, old_observation) = fixture.observation(Source::Editor, PreviewSource::User); + let (old_preview, old_stop) = start_local_game_preview_for_project(&fixture.root).unwrap(); + registry.set_running_with_lease(old_preview.clone(), old_stop, Some(lease)); + let (lease, stopped_observation) = fixture.observation(Source::Editor, PreviewSource::User); + let (new_preview, new_stop) = start_local_game_preview_for_project(&fixture.root).unwrap(); + registry.set_running_with_lease(new_preview.clone(), new_stop, Some(lease)); + old_observation.observe(old_preview.port).await; + registry.stop(); + stopped_observation.observe(new_preview.port).await; + assert_eq!(ready(&fixture.drained().await).len(), 1); + + // 空入口仍遵循原有启动行为,采集跳过不能变成业务启动失败。 + fs::write(project_game_root(&fixture.root).join("index.html"), b"").unwrap(); + assert!(start_local_game_preview_with_capture( + &fixture.root, + None, + ®istry, + fixture.capture() + ) + .is_ok()); + registry.stop(); + assert_eq!(ready(&fixture.drained().await).len(), 1); +} + +#[tokio::test] +async fn preview_analytics_cancelled_direct_scope_does_not_publish_ready() { + let fixture = Fixture::new(); + let (_lease, observation) = fixture.observation(Source::Direct, PreviewSource::Agent); + let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let observation = observation.with_cancellation(Some(cancelled.clone())); + let (preview, stop) = start_local_game_preview_for_project(&fixture.root).unwrap(); + cancelled.store(true, std::sync::atomic::Ordering::Release); + observation.observe(preview.port).await; + let _ = stop.send(()); + assert!(ready(&fixture.drained().await).is_empty()); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/template_library.rs b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs index fbf8fbc10..45f9b0330 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/template_library.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/template_library.rs @@ -1032,6 +1032,7 @@ pub(crate) async fn create_automatic_local_game_project_from_template( planning: Option, projects_root: Option, ) -> Result { + let analytics_context = crate::analytics::gui::capture_analytics_context(); let identity = require_template_library_access().await?; let projects_root = crate::resolve_game_project_creation_root(&app, projects_root.as_deref())?; let cache_root = template_cache_root(&app)?; @@ -1043,14 +1044,24 @@ pub(crate) async fn create_automatic_local_game_project_from_template( &identity, ) .await?; - with_validated_platform_session_identity(&identity, || { + let result = with_validated_platform_session_identity(&identity, || { create_project_from_installed_template_at( &projects_root, Path::new(&record.project_dir), name.as_deref(), planning.unwrap_or(false), ) - }) + }); + if let Ok(project) = &result { + crate::analytics::gui::created( + analytics_context, + Path::new(&project.project_path), + project.manifest.project_id.clone(), + crate::analytics::contract::CreationSource::Template, + Some(template_id), + ); + } + result } #[cfg(test)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs index af671e1c2..64426bcb8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/sessions.rs @@ -2777,17 +2777,11 @@ fn agent_run_history_prunes_to_latest_hundred_traces() { } #[test] -fn developer_project_file_and_memory_writes_advance_project_revision() { +fn developer_memory_writes_advance_project_revision() { let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "开发面板 revision").expect("project init"); let project_path = root.to_string_lossy().into_owned(); - write_local_project_file( - project_path.clone(), - "game/revision.txt".to_string(), - "v1".to_string(), - ) - .expect("write project file through command"); write_local_agent_memory( project_path.clone(), "design-director".to_string(), @@ -2804,7 +2798,7 @@ fn developer_project_file_and_memory_writes_advance_project_revision() { read_game_creator_agent_runtime_project_revision(&root) .expect("read developer mutation revision") .revision, - 3 + 2 ); fs::remove_dir_all(root).ok(); } diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 374560d0b..30427f1be 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -1865,7 +1865,7 @@ export function App({ } const previewResult = await invoke( 'start_local_game_preview', - { projectPath: nextProjectPath }, + { projectPath: nextProjectPath, previewSource: 'user' }, ); updateClientPreview(previewResult); void refreshManifest(nextProjectPath); diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index 296b1d776..440107a2d 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -101,8 +101,12 @@ export function WorkspaceLauncherShell({ }); const templateLibrary = useTemplateLibrary({ userId: currentUser.id, - onProjectCreated: async (result, isCurrent) => { - await homeProject.enterCreatedTemplateProject(result, isCurrent); + onProjectCreated: async (result, isCurrent, analytics) => { + await homeProject.enterCreatedTemplateProject( + result, + isCurrent, + analytics, + ); }, }); useEffect(() => { @@ -608,6 +612,17 @@ export function WorkspaceLauncherShell({ ); }, []); + function navigateLauncher(view: LauncherView) { + if ( + launcherView === 'project-development' && + view !== launcherView && + currentProjectContext + ) { + homeProject.leaveProjectAnalytics(); + } + setLauncherView(view); + } + const handleRunNotice = useCallback((notice: ProjectRunNotice) => { setRunNotice((current) => ({ id: (current?.id ?? 0) + 1, ...notice })); }, []); @@ -680,6 +695,7 @@ export function WorkspaceLauncherShell({ templateLibraryEnabled={templateLibrary.enabled} currentUser={currentUser} onLogout={() => { + homeProject.leaveProjectAnalytics(); resetLauncherHomeDraft(); accountWallet.resetWalletBalance(); onLogout(); @@ -687,7 +703,7 @@ export function WorkspaceLauncherShell({ onNoticeRequest={showLauncherNotice} onRechargeRequest={accountWallet.openRecharge} onRuntimeConfigOpen={() => setRuntimeConfigOpen(true)} - onViewChange={setLauncherView} + onViewChange={navigateLauncher} />
setLauncherView('projects')} + onProjectsOpen={() => navigateLauncher('projects')} onProjectOpen={(path) => { setProjectPath(path); void openProject(path, 'open'); @@ -839,8 +855,8 @@ export function WorkspaceLauncherShell({ } onNotice={handleRunNotice} onManifestChange={syncActiveProjectManifest} - onHomeOpen={() => setLauncherView('home')} - onProjectsOpen={() => setLauncherView('projects')} + onHomeOpen={() => navigateLauncher('home')} + onProjectsOpen={() => navigateLauncher('projects')} chat={ (null); + const leaveProjectAnalytics = useCallback(() => { + const path = analyticsProjectPathRef.current; + analyticsProjectPathRef.current = null; + const invoke = resolveTauriInvoke(); + if (path && invoke) recordAnalyticsProjectLeave(invoke, path); + }, []); + useLayoutEffect(() => { + const lifecycle = lifecycleRef.current; + lifecycle.mounted = true; + const onPageHide = () => { + lifecycle.mounted = false; + lifecycle.generation += 1; + leaveProjectAnalytics(); + }; + window.addEventListener('pagehide', onPageHide); + return () => { + lifecycle.mounted = false; + lifecycle.generation += 1; + window.removeEventListener('pagehide', onPageHide); + // StrictMode 的同步 setup 重放不是用户离开;真实卸载才清理宿主登记。 + queueMicrotask(() => { + if (!lifecycle.mounted) leaveProjectAnalytics(); + }); + }; + }, [leaveProjectAnalytics]); const [projectPath, setProjectPathState] = useState(''); const [projectAction, setProjectAction] = useState< 'opening' | 'creating' | null @@ -241,7 +274,10 @@ export function useHomeProjectCreation({ async function enterProjectDevelopment( context: LauncherProjectContext, isCurrent: () => boolean = () => true, + analytics: ProjectOpenAnalytics = null, ) { + if (!lifecycleRef.current.mounted) return; + const generation = lifecycleRef.current.generation; const entryToken = (projectEntryTokenRef.current += 1); /** * 会话预览只认"内存 registry 里真的还在跑"的那一个(见 @@ -254,7 +290,12 @@ export function useHomeProjectCreation({ projectPath: context.projectPath, recordedPreview: context.manifest.preview ?? null, }); - if (entryToken !== projectEntryTokenRef.current || !isCurrent()) { + if ( + !lifecycleRef.current.mounted || + generation !== lifecycleRef.current.generation || + entryToken !== projectEntryTokenRef.current || + !isCurrent() + ) { // 更晚的一次进项目已经接管工作区:这一次的结果(预览与项目上下文)全部丢弃, // 否则慢请求后到会把新项目覆盖回旧项目。 return; @@ -276,6 +317,8 @@ export function useHomeProjectCreation({ setProjectPath(context.projectPath); setLauncherView('project-development'); rememberRecentWorkspace(context.projectPath); + analyticsProjectPathRef.current = context.projectPath; + analytics?.record(context.projectPath); } async function readCurrentProjectRevision( @@ -314,6 +357,7 @@ export function useHomeProjectCreation({ prompt: string, attachments: HomeAttachmentDraft[], startMode: ProjectStartMode, + analytics: ProjectOpenAnalytics, ) { if (startMode === 'planning') { await invoke('set_design_agent_runtime_mode', { @@ -333,29 +377,33 @@ export function useHomeProjectCreation({ startMode === 'planning' ? [] : await importHomeAttachments(invoke, result.projectPath, attachments); - await enterProjectDevelopment({ - projectPath: result.projectPath, - projectName: - result.manifest.name || projectNameFromPath(result.projectPath), - projectKind: 'web', - manifest: result.manifest, - projectRevision: await readCurrentProjectRevision( - invoke, - result.projectPath, - ), - creationType, - startMode, - initialPrompt: - prompt.trim() || - (attachments.length > 0 && startMode !== 'planning' - ? '用户上传了参考附件,等待后续补充需求。' - : ''), - attachments: importedAttachments, - fileImportNotice, - recentRunStatus: null, - recentRunStopReason: null, - createdAt: Date.now(), - }); + await enterProjectDevelopment( + { + projectPath: result.projectPath, + projectName: + result.manifest.name || projectNameFromPath(result.projectPath), + projectKind: 'web', + manifest: result.manifest, + projectRevision: await readCurrentProjectRevision( + invoke, + result.projectPath, + ), + creationType, + startMode, + initialPrompt: + prompt.trim() || + (attachments.length > 0 && startMode !== 'planning' + ? '用户上传了参考附件,等待后续补充需求。' + : ''), + attachments: importedAttachments, + fileImportNotice, + recentRunStatus: null, + recentRunStopReason: null, + createdAt: Date.now(), + }, + undefined, + analytics, + ); resetLauncherHomeDraft(); } @@ -366,6 +414,7 @@ export function useHomeProjectCreation({ attachments: HomeAttachmentDraft[], startMode: ProjectStartMode, skipNonEmptyCheck = false, + analytics?: ProjectOpenAnalytics, ) { const trimmedProjectPath = validateProjectPath(nextProjectPath); if (!trimmedProjectPath) { @@ -376,6 +425,10 @@ export function useHomeProjectCreation({ setStatus('需要在 Tauri App 内运行'); return '需要在 Tauri App 内运行'; } + analytics = + analytics === undefined + ? beginProjectOpenAnalytics(invoke, 'create') + : analytics; setStatus('正在创建项目'); try { if (!skipNonEmptyCheck) { @@ -416,6 +469,7 @@ export function useHomeProjectCreation({ prompt, attachments, startMode, + analytics, ); setStatus('已创建项目,正在开始智能创作'); } catch (error) { @@ -439,6 +493,7 @@ export function useHomeProjectCreation({ // 首页输入框里已经写好的要求:打开已有项目时不能再丢掉(此前写死空串, // 用户写的内容既不发首轮也不进对话历史)。 initialPrompt = '', + analytics?: ProjectOpenAnalytics, ) { if (projectActionRef.current) { return; @@ -454,6 +509,10 @@ export function useHomeProjectCreation({ } projectActionRef.current = 'creating'; setProjectAction('creating'); + analytics = + analytics === undefined + ? beginProjectOpenAnalytics(invoke, 'create') + : analytics; setStatus('正在创建项目'); try { if (!skipNonEmptyCheck) { @@ -479,24 +538,28 @@ export function useHomeProjectCreation({ }, ); setStatus('已创建项目'); - await enterProjectDevelopment({ - projectPath: result.projectPath, - projectName: - result.manifest.name || projectNameFromPath(result.projectPath), - projectKind: 'web', - manifest: result.manifest, - projectRevision: await readCurrentProjectRevision( - invoke, - result.projectPath, - ), - creationType: null, - startMode: null, - initialPrompt, - attachments: [], - recentRunStatus: null, - recentRunStopReason: null, - createdAt: Date.now(), - }); + await enterProjectDevelopment( + { + projectPath: result.projectPath, + projectName: + result.manifest.name || projectNameFromPath(result.projectPath), + projectKind: 'web', + manifest: result.manifest, + projectRevision: await readCurrentProjectRevision( + invoke, + result.projectPath, + ), + creationType: null, + startMode: null, + initialPrompt, + attachments: [], + recentRunStatus: null, + recentRunStopReason: null, + createdAt: Date.now(), + }, + undefined, + analytics, + ); } catch (error) { setStatus(error instanceof Error ? error.message : String(error)); } finally { @@ -514,6 +577,7 @@ export function useHomeProjectCreation({ async function enterCreatedTemplateProject( result: InitLocalProjectResult, isCurrent: () => boolean = () => true, + analytics: ProjectOpenAnalytics = null, ) { const invoke = resolveTauriInvoke(); if (!invoke) { @@ -541,10 +605,15 @@ export function useHomeProjectCreation({ createdAt: Date.now(), }, isCurrent, + analytics, ); } - async function openProject(nextProjectPath: string, mode: 'open' | 'create') { + async function openProject( + nextProjectPath: string, + mode: 'open' | 'create', + analytics?: ProjectOpenAnalytics, + ) { if (mode === 'create') { await createProjectFromProjectPage(nextProjectPath); return; @@ -563,6 +632,10 @@ export function useHomeProjectCreation({ } projectActionRef.current = 'opening'; setProjectAction('opening'); + analytics = + analytics === undefined + ? beginProjectOpenAnalytics(invoke, 'recent') + : analytics; setStatus('正在打开'); try { const directoryStatus = await invoke( @@ -626,32 +699,37 @@ export function useHomeProjectCreation({ projectPath: trimmedProjectPath, }); setStatus('已打开项目'); - await enterProjectDevelopment({ - projectPath: trimmedProjectPath, - projectName: - directoryStatus.projectName || - projectNameFromPath(trimmedProjectPath), - projectKind: directoryStatus.isUnityProject - ? 'unity' - : directoryStatus.isCocosProject - ? 'cocos' - : directoryStatus.godotProjectRoot !== null && - directoryStatus.godotProjectRoot !== undefined - ? 'godot' - : 'web', - manifest: projectManifest, - projectRevision: await readCurrentProjectRevision( - invoke, - trimmedProjectPath, - ), - creationType: null, - startMode: runtimeMode?.activeRuntime === 'design' ? 'planning' : null, - initialPrompt: homeDraftPromptText(), - attachments: [], - recentRunStatus: directoryStatus.recentRunStatus, - recentRunStopReason: directoryStatus.recentRunStopReason, - createdAt: Date.now(), - }); + await enterProjectDevelopment( + { + projectPath: trimmedProjectPath, + projectName: + directoryStatus.projectName || + projectNameFromPath(trimmedProjectPath), + projectKind: directoryStatus.isUnityProject + ? 'unity' + : directoryStatus.isCocosProject + ? 'cocos' + : directoryStatus.godotProjectRoot !== null && + directoryStatus.godotProjectRoot !== undefined + ? 'godot' + : 'web', + manifest: projectManifest, + projectRevision: await readCurrentProjectRevision( + invoke, + trimmedProjectPath, + ), + creationType: null, + startMode: + runtimeMode?.activeRuntime === 'design' ? 'planning' : null, + initialPrompt: homeDraftPromptText(), + attachments: [], + recentRunStatus: directoryStatus.recentRunStatus, + recentRunStopReason: directoryStatus.recentRunStopReason, + createdAt: Date.now(), + }, + undefined, + analytics, + ); } catch (error) { setStatus(error instanceof Error ? error.message : String(error)); } finally { @@ -704,6 +782,7 @@ export function useHomeProjectCreation({ if (!invoke) { throw new Error('需要在 Tauri App 内运行'); } + const analytics = beginProjectOpenAnalytics(invoke, 'create'); const selectedPath = await invoke( 'pick_local_project_directory', projectPath.trim() ? { initialPath: projectPath.trim() } : undefined, @@ -717,6 +796,8 @@ export function useHomeProjectCreation({ draft.prompt, draft.attachments, startMode, + false, + analytics, ); } @@ -748,6 +829,7 @@ export function useHomeProjectCreation({ if (!invoke) { throw new Error('需要在陶泥儿客户端内运行'); } + const analytics = beginProjectOpenAnalytics(invoke, 'create'); const attempt = (homeCreationAttemptRef.current += 1); const isCurrentAttempt = () => homeCreationAttemptRef.current === attempt; const operation = createClientOperation( @@ -849,6 +931,7 @@ export function useHomeProjectCreation({ draft.prompt, draft.attachments, startMode, + analytics, ); setHomeCreationOperation( transitionClientOperation(operation, 'success', { @@ -910,6 +993,7 @@ export function useHomeProjectCreation({ } projectActionRef.current = 'opening'; setProjectAction('opening'); + const analytics = beginProjectOpenAnalytics(invoke, 'picker'); setStatus('正在选择项目'); try { const selectedPath = await invoke( @@ -923,7 +1007,7 @@ export function useHomeProjectCreation({ setProjectPath(selectedPath); projectActionRef.current = null; setProjectAction(null); - await openProject(selectedPath, 'open'); + await openProject(selectedPath, 'open', analytics); } catch (error) { setStatus(error instanceof Error ? error.message : String(error)); } finally { @@ -945,6 +1029,7 @@ export function useHomeProjectCreation({ } projectActionRef.current = 'creating'; setProjectAction('creating'); + const analytics = beginProjectOpenAnalytics(invoke, 'create'); setStatus('正在选择新项目文件夹'); try { const selectedPath = await invoke( @@ -962,6 +1047,7 @@ export function useHomeProjectCreation({ selectedPath, false, homeDraftPromptText(), + analytics, ); } catch (error) { setStatus(error instanceof Error ? error.message : String(error)); @@ -999,6 +1085,7 @@ export function useHomeProjectCreation({ } return { + leaveProjectAnalytics, projectPath, setProjectPath, currentProjectContext, diff --git a/apps/ai-game-creator-shell/src/features/template-library/useTemplateLibrary.ts b/apps/ai-game-creator-shell/src/features/template-library/useTemplateLibrary.ts index f2f835f09..2d38e27f9 100644 --- a/apps/ai-game-creator-shell/src/features/template-library/useTemplateLibrary.ts +++ b/apps/ai-game-creator-shell/src/features/template-library/useTemplateLibrary.ts @@ -10,6 +10,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { resolveTauriInvoke } from '../../app/tauri'; import type { InitLocalProjectResult } from '../../app/types'; +import { + beginProjectOpenAnalytics, + type ProjectOpenAnalytics, +} from '../../services/clientAnalytics'; import { currentPlatformSessionGeneration, subscribePlatformSessionGeneration, @@ -38,6 +42,7 @@ type UseTemplateLibraryOptions = { onProjectCreated: ( result: InitLocalProjectResult, isCurrent: () => boolean, + analytics: ProjectOpenAnalytics, ) => Promise | void; }; @@ -247,6 +252,7 @@ export function useTemplateLibrary({ if (!invoke) { throw new Error('需要在陶泥儿客户端内运行'); } + const analytics = beginProjectOpenAnalytics(invoke, 'create'); try { if (needsTemplateDownload(template)) { await downloadTemplate(template); @@ -268,7 +274,7 @@ export function useTemplateLibrary({ }, ); if (!isCurrent()) throw new Error('登录态已变化,模板操作已停止'); - await onProjectCreated(result, isCurrent); + await onProjectCreated(result, isCurrent, analytics); if (isCurrent()) setNotice(`已用模板「${template.title}」创建项目`); return result; } catch (nextError) { diff --git a/apps/ai-game-creator-shell/src/services/clientAnalytics.ts b/apps/ai-game-creator-shell/src/services/clientAnalytics.ts new file mode 100644 index 000000000..7e5c928c6 --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/clientAnalytics.ts @@ -0,0 +1,134 @@ +import type { TauriInvoke } from '../app/types'; + +// 一个 run 可因自动认证刷新调用多次 native,只确认最后一次尝试。 +export function beginDirectRunAnalytics( + invoke: TauriInvoke, + currentGeneration: () => number, +) { + const generation = currentGeneration(); + let attemptId: string | undefined; + let settled = false; + return { + nextAttempt() { + attemptId = undefined; + try { + attemptId = crypto.randomUUID(); + } catch { + // 最后一次 UUID 失败不能留下前一次失败候选的标识。 + } + return attemptId; + }, + settle() { + if (settled) return; + settled = true; + if (!attemptId) return; + try { + void invoke('settle_direct_run_analytics', { + attemptId, + discard: currentGeneration() !== generation, + }).catch(() => undefined); + } catch { + // 不等待埋点,不改变调用链的成功、失败和取消结果。 + } + }, + }; +} + +// 宿主签发的无凭据上下文:前端只传回原值,不从当前登录态补身份。 +type AnalyticsContext = { + route: { user_id: string | null; destination_origin: string | null }; + editor_session_id: string; + client_version: string; +}; + +export type ProjectOpenAnalytics = ReturnType; + +export function beginProjectOpenAnalytics( + invoke: TauriInvoke, + openSource: 'create' | 'picker' | 'recent', +) { + try { + const operationId = crypto.randomUUID(); + const context = invoke( + 'capture_analytics_context', + ).catch(() => null); + let recorded = false; + return { + record(projectPath: string) { + if (recorded) return; + recorded = true; + const eventTime = new Date().toISOString(); + void context + .then((captured) => { + if (!captured) return; + return invoke('record_analytics_project_open', { + context: captured, + projectPath, + operationId, + openSource, + eventTime, + }); + }) + .catch(() => undefined); + }, + }; + } catch { + // UUID、桥接与写入失败都不能改变业务操作结果。 + return null; + } +} + +export function recordAnalyticsProjectLeave( + invoke: TauriInvoke, + projectPath: string, +) { + try { + void invoke('record_analytics_project_leave', { projectPath }).catch( + () => undefined, + ); + } catch { + // 离开项目不能等待或依赖埋点。 + } +} + +// 保存开始冻结身份,完成时冻结时间;观察器不能影响保存结果。 +export function beginUiSaveAnalytics( + invoke: TauriInvoke, + projectPath: string, + saveSource: 'manual' | 'auto', +) { + try { + const operationId = crypto.randomUUID(); + const context = invoke( + 'capture_analytics_context', + ).catch(() => null); + let recorded = false; + return { + record(changed: boolean) { + if (recorded) return; + recorded = true; + if (saveSource === 'auto' && !changed) return; + try { + const eventTime = new Date().toISOString(); + void context + .then((captured) => { + if (!captured) return; + return invoke('record_analytics_ui_save', { + context: captured, + projectPath, + operationId, + saveSource, + changed, + eventTime, + }); + }) + .catch(() => undefined); + } catch { + // 时间或桥接失败仅丢弃本条埋点。 + } + }, + }; + } catch { + return null; + } +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts index 97c39081e..57356ad13 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts @@ -14,8 +14,12 @@ import { hasMeaningfulDirectCodexContent, resourceLabelResolver, } from '../../../../features/project-workspace/resourceReferences'; +import { beginDirectRunAnalytics } from '../../../../services/clientAnalytics'; import { captureAgentRuntimeError } from '../../../../services/errorReporting'; -import { requestPlatformSessionRefresh } from '../../../../services/platformSession'; +import { + currentPlatformSessionGeneration, + requestPlatformSessionRefresh, +} from '../../../../services/platformSession'; import { chatQueueFullNotice, createQueuedChatTurn, @@ -557,14 +561,23 @@ export function useDirectProjectChatController({ input: DirectProjectTurnInput, ) { try { - await withDirectCodexSessionRefresh(() => - invoke('chat_with_game_creator_direct_codex', { - projectPath: nextProjectPath, - clientTurnId: input.clientTurnId, - userItem: input.userItem, - ...(input.creationType ? { creationType: input.creationType } : {}), - }), + const runAnalytics = beginDirectRunAnalytics( + invoke, + currentPlatformSessionGeneration, ); + try { + await withDirectCodexSessionRefresh(() => + invoke('chat_with_game_creator_direct_codex', { + projectPath: nextProjectPath, + clientTurnId: input.clientTurnId, + userItem: input.userItem, + analyticsAttemptId: runAnalytics.nextAttempt(), + ...(input.creationType ? { creationType: input.creationType } : {}), + }), + ); + } finally { + runAnalytics.settle(); + } // 清单刷新统一交给 startTurn 的 finally:成功与报错路径都覆盖,且只读一次。 } catch (error) { if ( diff --git a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts index 2d2ccd165..6a2d84664 100644 --- a/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts +++ b/apps/ai-game-creator-shell/src/view/ui-editor/useUiEditorPage.ts @@ -50,6 +50,7 @@ import { type NodeTransformOptions, useUiEditorState, } from '../../features/ui-editor/useUiEditorState'; +import { beginUiSaveAnalytics } from '../../services/clientAnalytics'; import { cancelLocalProjectResourcePreviewScope, createProjectResourcePreviewRequestId, @@ -1216,7 +1217,7 @@ export function useUiEditorSession( if (separationResult === null) throw new Error('自动切分素材没有返回结果'); const completedResult = separationResult as SeparationDTO; - if (!(await save({ allowDuringSeparation: true }))) { + if (!(await save({ allowDuringSeparation: true, saveSource: 'auto' }))) { throw new Error( '自动切分素材结果已写入编辑器,但 State 保存失败;sidecar 已保留,可继续恢复。', ); @@ -1375,7 +1376,10 @@ export function useUiEditorSession( } } - async function save(options?: { allowDuringSeparation?: boolean }) { + async function save(options?: { + allowDuringSeparation?: boolean; + saveSource?: 'manual' | 'auto'; + }) { const allowDuringSeparation = options?.allowDuringSeparation === true; if ( !resourceId || @@ -1389,6 +1393,11 @@ export function useUiEditorSession( ) { return false; } + const analytics = beginUiSaveAnalytics( + invoke, + projectPath, + options?.saveSource ?? 'manual', + ); setSaveError(null); setGenerateError(null); setIsSaving(true); @@ -1406,6 +1415,7 @@ export function useUiEditorSession( } setPersistedRevision(result.revision); setSavedStateSignature(snapshotSignature); + analytics?.record(result.status === 'saved'); return true; }); } catch { @@ -1457,6 +1467,7 @@ export function useUiEditorSession( ) { return null; } + const analytics = beginUiSaveAnalytics(invoke, projectPath, 'manual'); setSaveError(null); setGenerateError(null); setIsSaving(true); @@ -1475,7 +1486,9 @@ export function useUiEditorSession( } setPersistedRevision(saved.revision); setSavedStateSignature(snapshotSignature); - return await stateStore.generateCode(resourceId); + const generated = await stateStore.generateCode(resourceId); + analytics?.record(saved.status === 'saved'); + return generated; }); } catch (cause) { setGenerateError(cause instanceof Error ? cause.message : String(cause)); diff --git a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts index 8d4889fa6..205bd0fca 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/chat-composer.suite.ts @@ -1,4 +1,5 @@ import { directCodexUserItemFromContent } from '../../src/features/project-workspace/resourceReferences'; +import * as platformSession from '../../src/services/platformSession'; import { chatQueueFullNotice, createQueuedChatTurn, @@ -31,6 +32,7 @@ import { renderLauncherProjectsAt, screen, setComposerText, + testAuthUser, vi, waitFor, within, @@ -395,6 +397,55 @@ export function registerChatComposerControlTests() { expect(speechRecognitionErrorMessage('no-speech')).toContain('重试'); }); + it('settles only the final analytics attempt after a DirectProject authentication retry', async () => { + const { invoke, surface } = await openDirectCodexSurface({ + chat_with_game_creator_direct_codex: (() => { + let attempts = 0; + return () => { + if (++attempts === 1) throw new Error('authentication-required'); + return '完成'; + }; + })(), + }); + const refresh = vi + .spyOn(platformSession, 'requestPlatformSessionRefresh') + .mockResolvedValue({ + status: 'refreshed', + user: testAuthUser, + generation: platformSession.currentPlatformSessionGeneration(), + }); + try { + const composer = within(surface).getByLabelText('陶泥儿对话内容'); + await submitDirectTurn(surface, composer, '继续制作'); + await waitFor(() => { + expect( + invoke.mock.calls.filter( + ([command]) => command === 'settle_direct_run_analytics', + ), + ).toHaveLength(1); + }); + const attempts = invoke.mock.calls + .filter( + ([command]) => command === 'chat_with_game_creator_direct_codex', + ) + .map(([, args]) => args); + expect(attempts).toHaveLength(2); + expect(attempts[0]?.clientTurnId).toBe(attempts[1]?.clientTurnId); + expect(attempts[0]?.analyticsAttemptId).toEqual(expect.any(String)); + expect(attempts[1]?.analyticsAttemptId).toEqual(expect.any(String)); + expect(attempts[0]?.analyticsAttemptId).not.toBe( + attempts[1]?.analyticsAttemptId, + ); + expect(invoke).toHaveBeenCalledWith('settle_direct_run_analytics', { + attemptId: attempts[1]?.analyticsAttemptId, + discard: false, + }); + expect(refresh).toHaveBeenCalledTimes(1); + } finally { + refresh.mockRestore(); + } + }); + it('queues messages sent while a turn runs, cancels one chip, and sends the rest in order', async () => { const pending: Array<{ resolve: (value: string) => void; diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 339508627..e4607c0d0 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -1461,6 +1461,7 @@ export function registerHomeProjectCreationTests() { projectPath: automaticProjectPath, creationType: 'game', clientTurnId: expect.any(String), + analyticsAttemptId: expect.any(String), userItem: { id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), type: 'message', @@ -1583,6 +1584,7 @@ export function registerHomeProjectCreationTests() { projectPath: automaticProjectPath, creationType: 'game', clientTurnId: expect.any(String), + analyticsAttemptId: expect.any(String), userItem: { id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), type: 'message', @@ -1622,6 +1624,7 @@ export function registerHomeProjectCreationTests() { expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_direct_codex', { projectPath: automaticProjectPath, clientTurnId: expect.any(String), + analyticsAttemptId: expect.any(String), userItem: { id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), type: 'message', @@ -2374,6 +2377,7 @@ export function registerHomeProjectCreationTests() { { projectPath, clientTurnId: expect.any(String), + analyticsAttemptId: expect.any(String), userItem: { id: expect.stringMatching(/^direct-codex:[A-Za-z0-9-]+:user$/), type: 'message', @@ -2428,6 +2432,7 @@ export function registerHomeProjectCreationTests() { expect(args).toEqual({ projectPath, clientTurnId: expect.any(String), + analyticsAttemptId: expect.any(String), userItem: { id: `direct-codex:${clientTurnId}:user`, type: 'message', diff --git a/apps/ai-game-creator-shell/tests/clientAnalytics.test.tsx b/apps/ai-game-creator-shell/tests/clientAnalytics.test.tsx new file mode 100644 index 000000000..043f7ec8d --- /dev/null +++ b/apps/ai-game-creator-shell/tests/clientAnalytics.test.tsx @@ -0,0 +1,332 @@ +// @vitest-environment jsdom +import { act, cleanup, renderHook } from '@testing-library/react'; +import { StrictMode } from 'react'; +import { afterEach, expect, it, vi } from 'vitest'; + +import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; +import type { TauriInvoke } from '../src/app/types'; +import { useHomeProjectCreation } from '../src/features/app-shell/useHomeProjectCreation'; +import { + beginProjectOpenAnalytics, + beginUiSaveAnalytics, +} from '../src/services/clientAnalytics'; + +const context = { + route: { user_id: 'user-a', destination_origin: 'https://a.example' }, + editor_session_id: 'session-a', + client_version: '1', +}; +function deferred() { + let resolve!: (value: T) => void; + return { + promise: new Promise((done) => { + resolve = done; + }), + resolve: (value: T) => resolve(value), + }; +} +afterEach(() => { + cleanup(); + delete window.__TAURI__; + vi.restoreAllMocks(); +}); + +function mount( + capture: () => Promise = async () => context, + preview: (path: string) => Promise = async () => null, +) { + vi.spyOn(crypto, 'randomUUID').mockReturnValue( + '12345678-1234-4234-8234-123456789012', + ); + const manifest = createGameCreationAppManifest('project', '项目'); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'capture_analytics_context') return capture(); + if (command === 'get_local_game_preview_status') + return preview(String(args?.projectPath)); + if (command === 'inspect_local_project_directory') + return { exists: true, isDirectory: true, isGameCreatorProject: true }; + if (command === 'get_local_game_manifest') return manifest; + if (command === 'pick_local_project_directory') return 'C:/picker'; + if (command === 'init_local_game_project') + return { projectPath: args?.projectPath, manifest }; + if (command === 'get_local_game_project_revision') return { revision: 1 }; + return null; + }, + ); + window.__TAURI__ = { core: { invoke } } as unknown as typeof window.__TAURI__; + const hook = renderHook( + () => + useHomeProjectCreation({ + setStatus: vi.fn(), + setLauncherView: vi.fn(), + rememberRecentWorkspace: vi.fn(), + }), + { wrapper: StrictMode }, + ); + return { + ...hook, + invoke, + manifest, + records: () => + invoke.mock.calls.filter( + ([command]) => command === 'record_analytics_project_open', + ), + }; +} + +it.each(['recent', 'picker', 'create'] as const)( + '成功进入项目记录真实 %s 来源', + async (source) => { + const { result, records } = mount(); + await act(async () => { + if (source === 'picker') await result.current.pickAndOpenProject(); + else + await result.current.openProject( + 'C:/project', + source === 'create' ? 'create' : 'open', + ); + }); + expect(records()).toHaveLength(1); + expect(records()[0][1]).toMatchObject({ context, openSource: source }); + }, +); + +it('身份抓取挂起不阻塞打开,成功时间在抓取完成前冻结', async () => { + const captured = deferred(); + const { result, records } = mount(() => captured.promise); + await act(async () => { + await result.current.openProject('C:/project', 'open'); + }); + expect(result.current.currentProjectContext?.projectPath).toBe('C:/project'); + const latestSuccessTime = Date.now(); + expect(records()).toHaveLength(0); + await act(async () => { + captured.resolve(context); + }); + expect(records()).toHaveLength(1); + expect(Date.parse(String(records()[0][1]?.eventTime))).toBeLessThanOrEqual( + latestSuccessTime, + ); +}); + +it('预览核验挂起时卸载,不记录从未进入的工作区', async () => { + const preview = deferred(); + const { result, unmount, records, invoke } = mount( + undefined, + () => preview.promise, + ); + let pending!: Promise; + await act(async () => { + pending = result.current.openProject('C:/old', 'open'); + }); + unmount(); + await act(async () => { + preview.resolve(null); + await pending; + }); + expect(records()).toHaveLength(0); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'record_analytics_project_leave', + ), + ).toHaveLength(0); +}); + +it('StrictMode 重放不产生离开,真正卸载清除已进入的项目', async () => { + const { result, unmount, invoke } = mount(); + await act(async () => { + await result.current.openProject('C:/project', 'open'); + }); + const leaves = () => + invoke.mock.calls.filter( + ([command]) => command === 'record_analytics_project_leave', + ); + expect(leaves()).toHaveLength(0); + unmount(); + await act(async () => {}); + expect(leaves()).toEqual([ + ['record_analytics_project_leave', { projectPath: 'C:/project' }], + ]); +}); + +it('页面关闭通知尽力清理,随后卸载不会重复通知', async () => { + const { result, unmount, invoke } = mount(); + await act(async () => { + await result.current.openProject('C:/project', 'open'); + }); + window.dispatchEvent(new Event('pagehide')); + unmount(); + await act(async () => {}); + expect( + invoke.mock.calls.filter( + ([command]) => command === 'record_analytics_project_leave', + ), + ).toHaveLength(1); +}); + +it('同项目再次显式打开是新操作,重渲染不会产生额外打开', async () => { + const { result, rerender, records } = mount(); + vi.mocked(crypto.randomUUID) + .mockReturnValueOnce('12345678-1234-4234-8234-123456789011') + .mockReturnValueOnce('12345678-1234-4234-8234-123456789012'); + await act(async () => { + await result.current.openProject('C:/project', 'open'); + }); + rerender(); + expect(records()).toHaveLength(1); + await act(async () => { + await result.current.openProject('C:/project', 'open'); + }); + expect(records()).toHaveLength(2); + expect(records()[0][1]?.operationId).not.toBe(records()[1][1]?.operationId); +}); + +it('身份抓取和记录写入失败静默,不改变成功打开', async () => { + const { result, records, invoke } = mount(async () => { + throw new Error('offline'); + }); + await act(async () => { + await result.current.openProject('C:/project', 'open'); + }); + expect(result.current.currentProjectContext?.projectPath).toBe('C:/project'); + expect(records()).toHaveLength(0); + const failing = vi.fn(async (command: string) => { + if (command === 'capture_analytics_context') return context; + throw new Error('write'); + }); + beginProjectOpenAnalytics(failing as TauriInvoke, 'recent')?.record( + 'C:/project', + ); + await act(async () => {}); + expect(failing).toHaveBeenCalledWith( + 'record_analytics_project_open', + expect.anything(), + ); + expect(invoke).toHaveBeenCalledWith('capture_analytics_context'); +}); + +it('较晚的导航获采纳,旧预览核验迟到不记录打开', async () => { + const oldPreview = deferred(); + const { result, invoke, manifest, records } = mount( + undefined, + async (path) => (path === 'C:/old' ? oldPreview.promise : null), + ); + let old!: Promise; + await act(async () => { + old = result.current.enterCreatedTemplateProject( + { projectPath: 'C:/old', manifestPath: '', manifest }, + () => true, + beginProjectOpenAnalytics(invoke as TauriInvoke, 'create'), + ); + }); + await act(async () => { + await result.current.enterCreatedTemplateProject( + { projectPath: 'C:/new', manifestPath: '', manifest }, + () => true, + beginProjectOpenAnalytics(invoke as TauriInvoke, 'create'), + ); + oldPreview.resolve(null); + await old; + }); + expect(result.current.currentProjectContext?.projectPath).toBe('C:/new'); + expect(records().map(([, args]) => args?.projectPath)).toEqual(['C:/new']); +}); + +it('模板权限失效后不记录被舍弃的导航,同次成功通知只记一次', async () => { + const pending = deferred(); + const { result, invoke, manifest, records } = mount( + undefined, + () => pending.promise, + ); + let current = true; + let entry!: Promise; + const action = beginProjectOpenAnalytics(invoke as TauriInvoke, 'create'); + await act(async () => { + entry = result.current.enterCreatedTemplateProject( + { projectPath: 'C:/old', manifestPath: '', manifest }, + () => current, + action, + ); + }); + await act(async () => { + current = false; + pending.resolve(null); + await entry; + }); + expect(records()).toHaveLength(0); + action?.record('C:/accepted'); + action?.record('C:/accepted'); + await act(async () => {}); + expect(records()).toHaveLength(1); +}); + +it('freezes UI save identity and completion time while context delivery is delayed', async () => { + const captured = deferred(); + const invoke = vi.fn(async (command: string) => + command === 'capture_analytics_context' ? captured.promise : undefined, + ); + const now = vi + .spyOn(Date.prototype, 'toISOString') + .mockReturnValue('2026-09-21T01:00:00.000Z'); + const analytics = beginUiSaveAnalytics( + invoke as TauriInvoke, + 'C:/project', + 'manual', + ); + analytics?.record(false); + now.mockReturnValue('2026-09-21T02:00:00.000Z'); + analytics?.record(true); + captured.resolve(context); + await act(async () => { + await captured.promise; + }); + const calls = invoke.mock.calls.filter( + ([command]) => command === 'record_analytics_ui_save', + ); + expect(calls).toHaveLength(1); + expect(invoke).toHaveBeenCalledWith( + 'record_analytics_ui_save', + expect.objectContaining({ + context, + changed: false, + eventTime: '2026-09-21T01:00:00.000Z', + }), + ); +}); + +it('isolates UI save analytics UUID and sync/async bridge failures', async () => { + const throwing = vi.fn(() => { + throw new Error('bridge unavailable'); + }); + expect(() => + beginUiSaveAnalytics( + throwing as TauriInvoke, + 'C:/project', + 'manual', + )?.record(true), + ).not.toThrow(); + const rejected = vi.fn().mockRejectedValue(new Error('bridge rejected')); + beginUiSaveAnalytics(rejected as TauriInvoke, 'C:/project', 'manual')?.record( + true, + ); + const recordFailure = vi.fn((command: string) => { + if (command === 'capture_analytics_context') + return Promise.resolve(context); + throw new Error('record failed'); + }); + beginUiSaveAnalytics( + recordFailure as TauriInvoke, + 'C:/project', + 'manual', + )?.record(true); + vi.spyOn(crypto, 'randomUUID').mockImplementation(() => { + throw new Error('no UUID'); + }); + expect( + beginUiSaveAnalytics(rejected as TauriInvoke, 'C:/project', 'manual'), + ).toBeNull(); + await act(async () => { + await Promise.resolve(); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/directRunAnalytics.test.ts b/apps/ai-game-creator-shell/tests/directRunAnalytics.test.ts new file mode 100644 index 000000000..245e2ebe8 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/directRunAnalytics.test.ts @@ -0,0 +1,91 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from 'vitest'; + +import type { TauriInvoke } from '../src/app/types'; +import { beginDirectRunAnalytics } from '../src/services/clientAnalytics'; + +afterEach(() => vi.restoreAllMocks()); + +it('自动重试保持业务回合,只确认最后一次尝试且确认不等待桥接', async () => { + const first = '11111111-1111-4111-8111-111111111111'; + const last = '22222222-2222-4222-8222-222222222222'; + vi.spyOn(crypto, 'randomUUID') + .mockReturnValueOnce(first) + .mockReturnValueOnce(last); + const invoke = vi.fn(() => new Promise(() => {})); + const analytics = beginDirectRunAnalytics(invoke as TauriInvoke, () => 1); + const attempts: string[] = []; + const operation = async () => { + attempts.push(analytics.nextAttempt()!); + if (attempts.length === 1) throw new Error('authentication-required'); + return '完成'; + }; + let result: string; + try { + result = await operation().catch(() => operation()); + expect(invoke).not.toHaveBeenCalled(); + } finally { + analytics.settle(); + } + expect(result).toBe('完成'); + expect(attempts).toEqual([first, last]); + expect(invoke).toHaveBeenCalledTimes(1); + expect(invoke).toHaveBeenCalledWith('settle_direct_run_analytics', { + attemptId: last, + discard: false, + }); + analytics.settle(); + expect(invoke).toHaveBeenCalledTimes(1); +}); + +it('最终 UUID 失败不能回退确认第一次失败', () => { + vi.spyOn(crypto, 'randomUUID') + .mockReturnValueOnce('11111111-1111-4111-8111-111111111111') + .mockImplementationOnce(() => { + throw new Error('UUID unavailable'); + }); + const invoke = vi.fn(); + const analytics = beginDirectRunAnalytics(invoke as TauriInvoke, () => 1); + expect(analytics.nextAttempt()).toBeDefined(); + expect(analytics.nextAttempt()).toBeUndefined(); + analytics.settle(); + expect(invoke).not.toHaveBeenCalled(); +}); + +it('账号代次变化只丢弃候选,不提交成功失败结论', () => { + let generation = 1; + const invoke = vi.fn(async () => undefined); + const analytics = beginDirectRunAnalytics( + invoke as TauriInvoke, + () => generation, + ); + const attemptId = analytics.nextAttempt(); + generation = 2; + analytics.settle(); + expect(invoke).toHaveBeenCalledWith('settle_direct_run_analytics', { + attemptId, + discard: true, + }); +}); + +it.each(['sync', 'async'])( + '确认桥接 %s 失败不会覆盖最终业务错误', + async (mode) => { + const invoke = vi.fn(() => { + if (mode === 'sync') throw new Error('bridge'); + return Promise.reject(new Error('bridge')); + }); + const analytics = beginDirectRunAnalytics(invoke as TauriInvoke, () => 1); + const failure = new Error('最终执行失败'); + const operation = async () => { + try { + analytics.nextAttempt(); + throw failure; + } finally { + analytics.settle(); + } + }; + await expect(operation()).rejects.toBe(failure); + expect(invoke).toHaveBeenCalledTimes(1); + }, +); diff --git a/apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx b/apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx index 44b64ae0a..1bee3d950 100644 --- a/apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx +++ b/apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx @@ -14,7 +14,7 @@ afterEach(() => { function mount(preflight: () => Promise) { const calls: string[] = []; const invoke = vi.fn(async (command: string) => { - calls.push(command); + if (command !== 'capture_analytics_context') calls.push(command); if (command === 'preflight_web_game_creation') return preflight(); if (command === 'suggest_automatic_project_name') return '预检项目'; if (command === 'create_automatic_local_game_project') diff --git a/apps/ai-game-creator-shell/tests/runVersionSwitchEventSubscription.test.tsx b/apps/ai-game-creator-shell/tests/runVersionSwitchEventSubscription.test.tsx index 866a2b039..bd748bc1c 100644 --- a/apps/ai-game-creator-shell/tests/runVersionSwitchEventSubscription.test.tsx +++ b/apps/ai-game-creator-shell/tests/runVersionSwitchEventSubscription.test.tsx @@ -142,6 +142,7 @@ describe('运行模块切换版本时的事件订阅', () => { await waitFor(() => expect(invoke).toHaveBeenCalledWith('start_local_game_preview', { projectPath: PROJECT_PATH, + previewSource: 'user', }), ); await openRunModuleAndPickVersion(/智能体修订/); diff --git a/apps/ai-game-creator-shell/tests/sessionPreview.test.ts b/apps/ai-game-creator-shell/tests/sessionPreview.test.ts index 872fb48d1..90f8e6750 100644 --- a/apps/ai-game-creator-shell/tests/sessionPreview.test.ts +++ b/apps/ai-game-creator-shell/tests/sessionPreview.test.ts @@ -195,7 +195,7 @@ describe('本地预览记录的生命周期(Rust 侧结构性守卫)', () => // record_replaced_preview_stop 只允许在旧预览属于别的项目时调用:同项目重启时它会把 // 刚写进去的 running 覆盖成 stopped。 const replaceBranch = - /let \(preview, previous_preview\) = registry\.set_running\(preview, stop\);([\s\S]*?)\n {4}\}/u.exec( + /let \(preview, previous_preview\) =\s*registry\.set_running_with_lease\(preview, stop, analytics_lease\);([\s\S]*?)\n {4}\}/u.exec( previewSource, )?.[1] ?? ''; expect(replaceBranch).toContain('ensure_preview_belongs_to_project'); diff --git a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts index d631c5051..503d02003 100644 --- a/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts +++ b/apps/ai-game-creator-shell/tests/uiEditorPage.test.ts @@ -747,4 +747,129 @@ describe('UiEditorPage', () => { '资源已在别处更新;请重新加载后再保存。', ); }); + it.each([ + ['manual', 'saved', 1], + ['manual', 'unchanged', 1], + ['auto', 'saved', 1], + ['auto', 'unchanged', 0], + ['manual', 'conflict', 0], + ['manual', 'failure', 0], + ] as const)( + 'records the real %s save outcome %s', + async (saveSource, status, count) => { + vi.mocked(invoke).mockImplementation(async (command) => + command === 'capture_analytics_context' + ? { + route: { + user_id: 'A', + destination_origin: 'https://example.com', + }, + editor_session_id: 'session', + client_version: '1', + } + : undefined, + ); + const stateStore: IUiDesignStateStore = { + load: vi.fn().mockResolvedValue(structuredClone(EMPTY_SNAPSHOT)), + save: + status === 'failure' + ? vi.fn().mockRejectedValue(new Error('write failed')) + : vi.fn().mockResolvedValue({ + status, + revision: 1, + state: EMPTY_SNAPSHOT.state, + committedProjectRevision: 3, + }), + generateCode: vi.fn(), + }; + const hook = renderHook(() => + useUiEditorSession('/tmp/ui-editor', 'ui-resource', stateStore), + ); + await waitFor(() => + expect(hook.result.current.save.isLoading).toBe(false), + ); + await act(async () => { + await hook.result.current.save.save({ saveSource }); + }); + const calls = vi + .mocked(invoke) + .mock.calls.filter( + ([command]) => command === 'record_analytics_ui_save', + ); + expect(calls).toHaveLength(count); + if (count) + expect(calls[0][1]).toMatchObject({ + projectPath: '/tmp/ui-editor', + saveSource, + changed: status === 'saved', + }); + }, + ); + + it.each([true, false])( + 'records combined save only after successful code generation: %s', + async (success) => { + vi.mocked(invoke).mockImplementation(async (command) => + command === 'capture_analytics_context' + ? { + route: { user_id: 'A', destination_origin: null }, + editor_session_id: 'session', + client_version: '1', + } + : undefined, + ); + let complete!: () => void; + const generation = new Promise((resolve) => { + complete = resolve; + }); + const stateStore: IUiDesignStateStore = { + load: vi.fn().mockResolvedValue(structuredClone(EMPTY_SNAPSHOT)), + save: vi.fn().mockResolvedValue({ + status: 'saved', + revision: 1, + state: EMPTY_SNAPSHOT.state, + committedProjectRevision: 3, + }), + generateCode: vi.fn(async () => { + await generation; + if (!success) throw new Error('generation failed'); + return { + relativePath: 'ui/generated.js', + treeExports: [], + treeCount: 0, + nodeCount: 0, + }; + }), + }; + const hook = renderHook(() => + useUiEditorSession('/tmp/ui-editor', 'ui-resource', stateStore), + ); + await waitFor(() => + expect(hook.result.current.save.isLoading).toBe(false), + ); + let operation!: Promise; + await act(async () => { + operation = hook.result.current.save.saveAndGenerateCode(); + await Promise.resolve(); + }); + expect( + vi + .mocked(invoke) + .mock.calls.filter( + ([command]) => command === 'record_analytics_ui_save', + ), + ).toHaveLength(0); + await act(async () => { + complete(); + await operation; + }); + expect( + vi + .mocked(invoke) + .mock.calls.filter( + ([command]) => command === 'record_analytics_ui_save', + ), + ).toHaveLength(success ? 1 : 0); + }, + ); }); diff --git a/docs/README.md b/docs/README.md index 9bcef1589..b1eea2e71 100644 --- a/docs/README.md +++ b/docs/README.md @@ -25,6 +25,8 @@ ## AI 游戏创作与 Agent Runtime +- [客户端本地埋点与主站入库契约](./technical/【技术方案】客户端本地埋点与主站入库契约-2026-09-21.md):本地 12 类事件采集、每 15 分钟上传、私有事件表、确认后清理与后台明细查询已完成隔离环境验收;不扩充采集范围、不做加密,未部署生产。配置要求及验证边界见第 13 节。 + - [AGC 资源 kind 枚举化契约](./technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md#2026-09-15-gamecreationapp-资源-kind-枚举化当前权威口径):GameCreationApp 资源 kind 的 Rust enum、ts-rs 绑定、Unknown 可观测性和 shell 内重构边界。 - [策划 Agent 生产迁移与工作区浏览](./technical/【技术方案】策划Agent生产迁移与工作区浏览-2026-09-10.md):已完成;当前策划入口统一使用 Design Agent,采用阶段审批与用户工作区文件浏览。旧 V1/V2 会话、命令、专用展示和测试不再作为兼容目标。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 0e2c608b7..4d95ac128 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -1,5 +1,53 @@ # 决策记录 +## 2026-09-22 Direct 埋点与业务持久化锁隔离 + +- Direct 采集身份和最新成果编号改由独立纯内存状态保存,初始化时从最终执行账本冻结项目与原 run 身份;成果采集、预览采集上下文和终态成果读取不再争用业务落盘锁。 +- 内存锁释放后才投递事件,不等待文件 I/O、后台队列或网络,不新增用户报错。缺少 run 元数据不套用当前用户身份,恢复不补造历史成果;退出仍尽力封存,不增加退出等待。 +- 修正后台埋点查询 DTO 的分页注释:按 `(event_time, event_id)` 倒序,入库时间仅限制快照;接口和查询行为不变。 + +## 2026-09-22 新项目埋点资格支持有限恢复 + +- 真实创建成功先登记有界进程内待办,不依赖埋点服务或身份快照;后台服务就绪及后续真实受理可重试初始化资格。资格文件格式、每项目最多一次首次提交和上传合同不变。 +- 业务线程仅更新内存和非阻塞投递;资格文件读写和独立锁操作仍在后台,不向用户报错或要求介入。队列满、暂时 I/O 失败和锁竞争保留待办;已有标记、备份或项目身份不符不重新授予资格。 +- 内存最多 1024 项、路径与 ID 合计 1 MiB;超限或持久化前退出仍允许漏记,不补旧项目历史,不承诺零丢失。细节与验证见客户端埋点主规范。 + +## 2026-09-22 清理无调用方的 GUI 文件写入命令 + +- 确认 `write_local_project_file` 无现役前端或业务调用方后,删除命令、Tauri 注册及命令检查豁免;移除对应测试片段,保留 checkpoint、UI 保存和记忆写入的既有测试。 +- Agent 使用的底层 `write_local_project_file_at` 保留。GUI 人工文件写入不再列为采集入口;不扩充其他文件操作埋点,不修改已有事件数据合同。 + +## 2026-09-22 客户端埋点后台按发生时间排序 + +- 按技术负责人要求,列表改为 `(event_time, event_id)` 倒序,历史补传按发生时间归位。入库时间仍用于固定分页快照,翻页期间新入库的事件在刷新后显示。 +- 分页游标改存发生时间;旧游标刷新后重新取得,不迁移持久表、不新增索引、不改变采集与上传行为。 + +## 2026-09-22 埋点分支同步发布入口与工作台更新 + +- 合并 master `079466b29`,同时保留项目离开埋点与工作台运行通知、后台埋点查询与游戏发布审核入口;不扩大采集范围。 +- 本次冲突位于工作台相邻回调、后台 API 测试导入及本文件新增记录,均保留双方现役内容;Direct 聊天的认证重试埋点与 master 回合状态调整继续共存。 + +## 2026-09-22 埋点分支合并 DirectProject 聊天重构 + +- 保留 master 的独立 DirectProject 聊天控制器及 canonical `userItem` 合同;旧 Supervisor、独立 prompt/attachments IPC 参数和已退役删除命令不恢复。 +- 原有 Direct run 埋点从 App 聊天链迁至 `useDirectProjectChatController.runTurn`,认证重试仍逐次生成 attempt ID,并仅确认最后一次;账号代次变化时丢弃确认。项目进入/离开及用户预览的原有接线继续保留,不扩大采集范围。 +- 过期测试按现役聊天入口调整,新增实际 UI→controller 认证重试用例验证最终尝试确认。上传、数据库和后台合同不变。 +- 合并验证:Rust analytics 56 项通过(真实服务桥接用例维持 ignored),App 界面 203 项通过 / 13 项既有跳过;预览激活、版本切换、工作台清单及客户端埋点辅助测试通过,客户端 TypeScript、原生契约、编码、文档索引和 diff 检查通过。本次未重跑上传真实服务 smoke 或完整 GUI/Provider 创作。 + +## 2026-09-21 客户端埋点方案进入团队共享文档 + +- 本期验收完成:原始需求与已确认口径的 12 类事件入口已核对,同一 writer/session/project/goal 的宿主组件链路通过真实文件、HTTP、checkpoint 与 JSONL 关联验证;最终 51 项 Rust、46 项前端测试和类型/格式/文档检查通过。仅测试辅助模拟创建投递、run 结果和构建产物,不宣称完整 GUI/Provider 端到端验证;未提交或发布。禁止把全部资源操作逐项接线重新当作本期必做范围。 +- 最新口径:共 12 类启用事件;策划审批通过后实际进入下一阶段并成功持久化,复用 project_revision_created,revision_id=design::、source=design_agent、revision_source=agent、change_kind=design_document。同会话同目标阶段幂等;不严格校验文档版本/差异,阶段内文件修改不逐次采集,重开不补历史。不新增独立策划进度事件或审批、澄清状态字段。Direct 文件/补丁、UI 成果、预览与保存已有定向验收,基础事件入口已核对,同一宿主组件链路验收已通过。 +- 两类 Agent run 元数据随真实受理保存,后台维护项目累计观测重试与双 Agent 当前终态槽位;重放不新建,恢复保留原身份且耗时未知,取消/不确定不伪造失败。Direct 自动认证刷新只确认最后一次原生尝试的有界内存候选,账号代次变化丢弃;缺失不回退旧失败,不为观测增加业务写盘等待。运行结果已通过独立验收,真实付费 Provider/完整 GUI run 尚未 smoke,现有 Direct 合同中断恢复行为未改变。 +- Direct 宿主文件写入/正式补丁仅在已知成果内容变化且原事务 revision 成功提交后记成果,原 run 用户归属不变,末尾 projection 不重复记。当前 session 内存关联最新可信成果到 run;缺证据为 null,不据全局 fingerprint 推断作者。真实 writer、bundled patch 执行器和定向测试已通过。 +- GUI 人工文件写入命令已于 2026-09-22 清理,不再作为采集入口;完整项目 checkpoint 按真实 checkpoint_id 记保存。UI State 仅 Saved 记成果;手动 Saved/Unchanged 可记保存,自动保存仅 Saved,保存并生成需全操作成功。起点冻结身份,埋点失败不影响业务。63 项 Rust、41 项前端测试及独立验收通过;无完整 GUI 跨层保存 smoke。旧 Runtime 开发/CLI 文件及 UI workflow 不因存在代码就纳入正式 GUI 必需采集。 +- 正式 Web preview_ready 由 GUI 用户持续预览和 Direct 临时浏览器预览接入,冻结原身份、版本与实例;异步2秒loopback GET禁代理/重定向,原入口及响应非空、版本/实例仍匹配才记录。实例停止/替换、验证结束或取消后丢弃迟到结果;可访问不等于JS/游戏验证成功。合并后78项Rust、104项前端测试及独立验收通过,未调用真实Provider或跑完整Chrome双端验证。资源操作全面接线计划已撤销;现有采集只表示已观测变化,不代表全部资源操作或项目全部修订。 +- 首次提交按本地观测口径每个新项目最多一条:真实受理候选在后台持久消费 `.agent/analytics-goal.json` 资格后投递。资格跨批次清理保留,旧项目不初始化;此前候选丢失时允许后续真实受理消费,使用后者自己的用户与时间,不宣称绝对首次。消费后事件丢失可零条,重放与恢复不补历史;不得为埋点扫描双 Agent 完整历史或阻塞业务写盘。 + +- 当前合同唯一维护入口为[客户端本地埋点与主站入库契约](../../technical/【技术方案】客户端本地埋点与主站入库契约-2026-09-21.md),原始需求作为仓库内历史来源保存;后续里程碑规范与实施计划放在 `docs/project-memory/plans/`。 +- 本地采集阶段已验收明文 JSONL 持久化;当前仍不做加密。一个项目对应一个目标,事件按业务节点采集,5 分钟封存,7 天或 20 MiB 清理;上传失败也受保留上限约束。 +- 当前上传实现已完成隔离环境验收,证据见同一主规范第 13 节:每 15 分钟上传匹配当前账号与平台的封存批次,新增一张客户端事件私有表、批次原子入库与幂等确认、成功清理及独立后台明细栏目;失败静默留待下周期重试。真实客户端文件、HTTP、数据库与后台查询已关联同一事件验证,浏览器列表/筛选/详情通过;未部署生产。保持原 12 类事件和原采集边界。上线须配置 `GENARRATIVE_AGC_ANALYTICS_ORIGIN`,按数据库、API/后台、客户端顺序发布。 +- 已按技术负责人授权开始实施:合同与本地队列、会话窗口与项目接入、策划阶段成果、首次提交及两类 Agent run 已实现并经独立审查;定向测试、生产编译和前序 GUI 启停证据统一见主规范第 12 节。不得宣称完整产品采集已上线。 ## 2026-09-22 引用输入区改为宿主注入引用 provider,选择器面板与输入区分离 - 背景:`ResourceReferenceInput`(`apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx`)同时承担「拿数据」与「编辑数据」:素材以未过滤 manifest 传入后由组件自己派生候选、显示名与「当前版本素材」scope,Skill 候选由组件自己 invoke `list_agc_skill_catalog` 与 `list_client_extensions`(只在用户敲出 `$` 时触发),素材选择面板与缩略图预览 invoke 也住在组件内部。后果是 5 个宿主(DirectProject 聊天、策划输入盒、画布生成面板、资源卡快速编辑、测试夹具)无差别获得 `$` Skill 候选,而只有 DirectProject 回合会把 `agc_skill_reference` 解析成真 Skill(Rust `direct_codex_user_item_to_codex_turn_input`,`apps/ai-game-creator-shell/src-tauri/src/agent/direct_codex_user_item/wire.rs`),其余宿主只把它退化成字面文本,形成误导入口。 diff --git a/docs/technical/【技术方案】客户端本地埋点与主站入库契约-2026-09-21.md b/docs/technical/【技术方案】客户端本地埋点与主站入库契约-2026-09-21.md new file mode 100644 index 000000000..3b6b8be97 --- /dev/null +++ b/docs/technical/【技术方案】客户端本地埋点与主站入库契约-2026-09-21.md @@ -0,0 +1,735 @@ +# 客户端本地埋点与主站入库契约 + +Version: 0.31 +Status: 本地采集及上传、入库、成功清理、后台查询已实现并完成本地隔离环境验收;未部署生产 +Date: 2026-09-21 +需求来源:[Game Agent 埋点设计原始方案](./【需求来源】GameAgent埋点设计原始方案-2026-09-05.md);原始方案与后续已确认决策有差异时,以本文为准。 + +## 1. 交付目标与范围 + +2026-09-22 CI 检查适配:Tauri 命令清单解析仅提取模块路径末尾的函数名,避免将 `analytics::gui` 误判为命令;预览生命周期结构性测试匹配当前 `set_running_with_lease` 调用,继续检查同项目重启不覆盖新预览记录的保护逻辑。此修正不改变运行行为或采集范围。 + +阅读说明:第 1 至 12 节记录本地采集合同及其已完成的验收证据;当前上传、入库、清理与后台查询的实施合同统一见第 13 节。第 13 节细化第 8 节,不改变现有 12 类事件和采集触发点。当前整体仍在实施验收中,不表示已经上线。 + +交付结果:在 Game Agent 与现役 Design Agent 客户端的真实业务节点产生有明确语义的产品事件,以明文 JSONL 批次持久化在应用级目录;当前实施增加主站入库、确认后清理与后台明细查询,验收要求见第 13 节。 + +本地采集阶段的既有验收以本地记录为准:字段来源可追溯、事件不会冒充业务成功、正常重放不重复、跨账号不串归属、埋点故障不影响正常操作。这些证据不替代当前上传成功、服务端去重与后台查询的验收。 + +优先级: + +- 必须项:字段合同、身份与因果关联、原方案 13 类事件逐项对照(采集其中 12 类,暂不采集 session_timeout)、策划阶段推进复用成果事件、本地未闭合会话标记、本地 JSONL 持久化、静默失败、定向验证。本版合计启用 12 类事件。 +- 风险项:项目目标身份稳定性、跨账号在途操作、多窗口前台时间、崩溃未闭合区间、revision 与保存事实、重复回调。 +- 可选项:无;本阶段不扩充分析维度或细粒度点击事件。 + +当前实施包括上传定时器、HTTP 接口、一张私有事件表与后台明细栏目;不实现文件加密、服务端聚合或统计看板。不读取或批量转换已有对话历史为埋点,不改变现有 Agent 产物与恢复账本的职责。 + +2026-09-21 存储范围确认:用户决定本阶段先不做加密,直接保存明文事件批次;无公钥配置、密钥生成或轮换前置要求。以后若启用加密,另行定义文件格式与接收兼容合同。 + +2026-09-21 产品口径确认:一个项目就是一个创作目标。项目内全部创作请求、澄清、审批、重试及跨会话继续均属于同一目标;不再根据消息内容划分新目标。 + +2026-09-21 异常会话口径确认:下次启动时,确认旧实例已退出且旧会话没有正常结束标记,再将本地会话标为 incomplete(不完整)。不设置周期心跳或检查点定时器、不等待超时阈值、不补记 session_timeout,不补造退出时间或未闭合前台时长。 + +2026-09-21 执行终态口径确认:等待澄清或审批算本次 run 正常完成,通过 end_reason 区分;用户取消和进程崩溃不计入 agent_run_failed,不进入已知成功/失败终态的分母。 + +2026-09-21 本地保留策略确认:埋点队列及相关索引最多保留 7 天、总量上限 20 MiB。任一条件达到即按第 7.3 节清理,未上传的数据也适用;不影响项目、Agent 对话或创作成果。上传失败的数据同样不会无限保留。 + +2026-09-21 本地封存时间确认:从当前批次第一条事件进入开始,5 分钟到期后封存落盘;没有事件不生成空批次。该时间与后续每 15 分钟上传的周期独立,本轮实施评审采用 500 条及 1 MiB 的提前封存上限,作为内部常量。 + +2026-09-21 策划成果采集确认:审批通过后实际进入下一阶段,且该阶段变化成功持久化,视为一次 project_revision_created;不严格校验文档版本或文件差异,不为埋点新增文件 revision 机制。阶段内随意修改文件不逐次采集,打开、重开或关闭项目不补历史阶段事件。同策划会话同目标阶段幂等;不独立采集策划进度事件或审批、澄清状态字段。5 分钟是封存落盘周期,后续 15 分钟是上传周期,均不是采集周期。 + +用户已确定并授权实施的上传要求:上传至主站数据库;客户端每 15 分钟上传一次;绑定真实用户与业务标识;成功后删除对应待上传副本;失败无弹窗、不阻塞正常进程。当前实现采用失败保留至下一个周期重试,不做立即重试,仍受本地保留上限约束;整体完成情况以第 13 节验收为准。 + +本文是团队共享的埋点技术方案唯一维护入口,原始需求副本仅用于追溯。正式编码前,按仓库规范驱动工作流在 `docs/project-memory/plans/` 补齐里程碑规范及当前里程碑实施计划,并完成对应评审;本文的建议项不因迁入仓库而自动视为已确认。本次按技术负责人授权推进实施,里程碑仍按规范逐项评审和验收。 + +## 2. 已核实的现有记录与复用边界 + +| 现有记录 | 当前事实 | 本阶段复用方式 | +| --- | --- | --- | +| `.agent/conversations/project.jsonl` | Game Agent 正式对话历史,包含正文 | 复用正式受理、终态的业务入口;不复制正文到埋点 | +| `.agent/runtime/direct-codex/turns/.jsonl` | 回合工具审计,条目有上限,写入可失败 | 参考执行事实;不能把其条数作为完整产品事件数量 | +| `.agent/agent.db` | 逐行 JSON 本地索引与审计,非 SQLite | 保持原用途,不作为待上传队列 | +| `.agent/design-agent/session.json` | 策划会话、对话、工具结果、阶段、审批、当前回合与恢复状态 | 审批通过且实际推进阶段成功持久化后记录成果事件;不上传完整会话文件 | +| `design_artifacts/` | 正式策划成果 | 保持原文件保存行为,本版不为统计新增 revision;文件内容不进入事件 | +| 应用级 `direct-executions/`、`direct-delivery/` | 执行控制与交付状态 | 关联正式执行生命周期;不额外复制一套业务状态机 | +| `.agent/analytics-goal.json` | 新增的项目目标采集资格:格式版本、项目 ID、已消费状态 | 仅后台初始化和消费;跨批次清理保留,不包含用户、对话或执行内容,不参与业务放行 | + +旧 Planning V1/V2 与 Fast GDD 不作为新埋点接线目标。主站已有 HTTP tracking 也不等于本方案事件已经被接收或保存。 + +## 3. 总体链路与非阻塞原则 + +```text +真实业务节点产生不可变事件候选(冻结身份、时间与业务引用) + → 有界内存队列,非阻塞投递 + → 单个后台写入器校验,在有界内存中组成 JSON 批次 + → 序列化为明文 JSONL,写入事件文件与最小控制元数据 + → 应用级本地待上传目录,批次不可变 + → 后续阶段:15 分钟上传 JSON 事件批次、主站入库、整批确认后删除 +``` + +- 业务线程不等待磁盘写入、文件清理或网络。队列满时丢弃本次埋点并增加本地计数,不拖慢业务。 +- 写入失败、权限异常、格式错误、磁盘满:埋点失败静默处理,不向业务调用返回错误、不显示弹窗、不回滚已经成功的操作。 +- 字段缺失不能通过伪造值满足校验。整条事件不合规时不写入正式事件文件,在内部记录事件名、原因码及丢弃数量,不记录正文或凭据。 +- 捕获时生成 event_id 与 event_time,后台排队延迟不得改变事件发生时间。 +- 正常退出仅尽力排空已经在队列中的事件,不新增等待上传或等待写盘的退出门禁。 +- 这一取舍允许“业务已成功,但埋点尚未持久化就进程退出”的丢数窗口。不能同时宣称完全不等待与绝对零丢数;本方案优先满足用户要求的不阻塞。 + +## 4. 公共事件合同 + +### 4.1 序列化规则 + +- UTF-8、无 BOM;字段名统一 snake_case。本地使用 JSONL,每行一个完整公共事件对象,按第 7 节组织批次文件。 +- 新写入事件固定 `schema_version: 1`;语义或类型发生不兼容变化时升版本,不能悄悄改变旧字段含义。 +- 顶层字段全部显式出现;不适用的可空字段为 null,不使用空串、0、`unknown` 或设备 ID 冒充真实业务身份。 +- properties 是按 event_name 定义的封闭对象,只允许本方案列出的字段;必填字段不可缺少,可选字段不可得时省略。 +- 公共字段不在 properties 中重复。所有 ID 为字符串,不将数据库整数 ID 转为可能丢精度的 JavaScript number。 +- 时间为 UTC ISO 8601,毫秒精度;时长和计数为非负整数,限制在 JavaScript 安全整数范围。时间倒退、跨重启无法可靠计算时写 null 或省略,不写负数或猜测值。 + +### 4.2 字段定义 + +| 字段 | JSON 类型 | 来源与约束 | +| --- | --- | --- | +| schema_version | integer | 固定 1;本文新增,供本地读取与后续服务端识别版本 | +| event_id | string | 客户端 UUID v4,单条事件唯一;重试、复制批次与恢复重放沿用原值 | +| event_name | string | 仅允许第 6 节本版启用的 12 个英文事件名;session_timeout 不在本版写入白名单中 | +| event_time | string | 业务事实发生时捕获的 UTC 时间;不能以重启检测时间冒充旧会话的退出时间 | +| user_id | string/null | 由已确认的平台登录会话取得;匿名明确 null,不从邮箱、设备或项目所有者猜测 | +| editor_session_id | string | 埋点专用 UUID;不复用 Runtime sessionId、threadId 或 clientTurnId | +| project_id | string/null | 项目 manifest 的稳定 ID;第 6 节规定项目事件必须非空 | +| creative_task_id | string/null | 项目对应的唯一创作目标 ID;有任务关联时直接等于该事件的 project_id,规则见第 5 节;不另造随机任务身份 | +| agent_run_id | string/null | 一次受理的 Agent 执行 ID;仅 run 终态事件填写 | +| agent_turn_id | string/null | 真实底层技术回合标识,可选;禁止把 run ID 复制进来凑字段 | +| status | string/null | 按本版事件固定映射为 success、failed 或 null;不能任意填写;本地会话 incomplete 不属于本字段 | +| error_code | string/null | 仅 agent_run_failed 非空,来自稳定错误类别映射;其余为 null | +| source | string | 仅 editor、direct、design_agent、asset_canvas、resource_editor、ui_editor、manual、system;表示事实产生模块 | +| client_version | string | 从实际运行的应用版本元数据读取,不使用示例值或空串 | +| properties | object | 对应事件的专属字段,允许空对象 | + +source 新增 design_agent 以表达现役策划实现;不使用旧 supervisor 标签代替。尚未接线的来源不能因枚举存在而被认为已覆盖。 + +### 4.3 用户归属与主站目标 + +- 用户与平台地址一并在事件发生时冻结。队列的本地路由元数据保存 `destination_origin`(经规范化的平台协议、主机、端口,无路径、查询串与凭据)和 user_id;它不是额外的产品指标。 +- 同一批文件只包含同一个 destination_origin 和 user_id 的事件。使用不含用户标识的随机批次文件名;路由元数据不保存 Token、Cookie 或 API Key。文件内容与路由元数据均为明文,其采集边界见第 7.4 节。 +- 账号 A 的在途任务结束事件继续归属发起执行时的 A;不得在后台写入时读取当前 B 账号并替换。 +- 账号切换、登录与注销:闭合旧身份下的 focus 区间,原子切换埋点身份后按实际窗口状态开启新区间。编辑器会话可保持不变,按用户统计时必须按事件归属拆分,不能将整个会话时长归给最后登录者。 +- 匿名事件保持 user_id=null;不因之后登录自动回填。缺乏可解析的平台目标时可以以 destination_origin=null 本地保存,后续禁止默认发给当前任意服务器,待上传阶段明确处理策略。 +- 后续上传必须验证凭据主体与批次所属账号、平台一致。无法认证 A 时保留 A 的数据,不使用 B 的凭据代传;匿名接收规则在上传阶段明确。 +- 本地 user_id 只是待验证声明,后续服务端不能不核验鉴权就信任客户端填报的用户身份。 + +## 5. ID、因果关系与执行语义 + +### 5.1 编辑器会话与多窗口 + +一个 GUI 应用实例对应一个 editor_session_id,从可用初始化到该实例实际退出。新进程新 ID;WebView 重载不重建会话。Runner 继续在后台运行不等于编辑器仍有前台会话。 + +同实例多个可交互编辑器窗口的前台区间取并集,不累加重叠时长。以宿主查询的窗口总体状态为准,聚合窗口切换;不同应用实例独立记会话。后台 Runner 自己不产生 editor_session_start。 + +现役生产界面由 WorkspaceLauncher 进入项目工作区,内嵌 App 的 hydration 不是第二次打开。打开采集以 Launcher 成功采纳的显式导航操作为准。当前没有已验证的文件关联或上次工作区自动恢复入口,不为填满枚举而伪造 app_restore/project_association。当前原生回调可观察普通焦点和最小化;尚未接入独立锁屏/休眠系统通知,可能存在前台时长误差,不宣称完整覆盖系统休眠。 + +身份变化、真实工作区卸载和 WebView 开始重载时清除当前项目归属;卸载后迟到的异步导航不能记录为成功打开。已经成功的旧身份操作允许延迟记入历史,但不能恢复当前项目。生命周期状态在只含内存操作与非阻塞投递的短临界区串行更新,身份通知不能因竞争而永久丢失;启动身份快照与服务发布同步,等待认证状态在后台进行,不阻塞 GUI。 + +异常会话以每个 analytics 实例持有的独立文件锁证明存活;写 active 标记前先取得锁,恢复旧会话时取得对应锁并持有到修改完成。锁缺失、格式未知或无法判断所有权时保留原状,不用 Runner 共享参与锁代替。 + +### 5.2 项目与创作目标一一对应 + +- 用户已确认:一个项目就是一个目标。同项目内修改需求、改变方向、普通追加、澄清、审批和用户重试都不产生新目标;只有不同项目才对应不同目标。 +- 建议采用最简单的字段映射:creative_task_id 直接复用 manifest 的 project_id 值,保留两个字段的契约名称但不维护第二份随机 ID 或映射表。该等值映射是本文实现方案,不表示 clientTurnId、agent_run_id 也可复用项目 ID。 +- 项目改名、移动目录、重开、客户端重启、换账号以及从 Design Agent 转到 Game Agent 均不重建目标 ID。另存为新项目时应使用业务实际生成的新 project_id;复制目录但仍保留相同 project_id 的情况按同一项目身份处理,埋点不自行修正项目身份。 +- creative_task_submit 每个目标只记录一次本地成功采集到的真实受理;仅创建或打开项目不算提交。之后的执行用多个 run 表达,不把消息条数算成目标数。首次是本地观测口径,不保证绝对首次操作:较早受理的埋点丢失时,后续真实受理可以成为首条记录,仍使用后者自己的发生时间与身份,不补原历史。 +- 首次提交事实随项目已有受理记录或最小标记持久保存,不能只存在 React 状态或会过期的上传索引中。上传后清理批次不清除目标已提交事实,避免重开项目重复首次提交。 +- 现有项目的目标 ID 可由真实 project_id 直接确定,但不补发历史事件。无法证明是首次创作请求时,不将首次启用埋点或第一次遇见旧项目冒充首次提交;后续 run、revision 等仍可记录并关联项目目标。 +- 不新增“新目标/继续目标”选择器,不使用模型、关键词、消息间隔或最近任务推断目标归属。所有任务相关事件校验 creative_task_id == project_id。 + +### 5.3 Agent 执行 + +- 一次用户指令或明确继续动作被 Runtime 受理后生成 agent_run_id,执行至正常返回、最终失败、取消或中断。一次任务可以包含多个 run。 +- 同一执行内部的 Provider 重试不创建新 run;相同受理请求重放复用原 run ID。 +- 用户主动重试形成新的 run,沿用任务 ID。retry_index 为该项目启用采集后、后台成功处理的累计显式用户重试次数:初始观测为 0,主动重试递增;普通提交、澄清/审批不递增。它不表示 Provider 的请求重试次数,也不能证明启用前的完整历史;受理候选丢失可能少计。现役 Direct 没有明确用户重试入口,普通再次发送仍为 user_submit,不按文本或上次失败猜测 user_retry。 +- 正常等待澄清或审批结束本次 run,以 completed + end_reason 区分;用户答复后新建 run。completed 表示本次执行正常收束,不代表整个任务完成或用户满意。 +- 用户取消、崩溃、结果不确定不伪造 completed 或 failed。原方案未覆盖这些 run 终态,本版不新增对应终态事件,明确它们不进入成功/失败分母。 +- duration_ms 用单调时钟测量本次执行,含内部等待与自动重试,不含等用户答复的间隔;跨进程恢复无法可靠累加时为 null。 +- run UUID、终态事件 UUID、原身份与来源随已有业务受理记录保存,不新增业务线程的埋点写盘。旧执行无元数据时恢复不补事件。新请求重放不再登记受理;同一次自动恢复沿用原 run。 +- 后台项目记录仅维护重试计数及每类 Agent 一个当前 run 槽位,原子分配序号并幂等消费终态;不保存全部历史。新受理可覆盖同类旧槽位,迟到旧终态因而可能漏记;缺失、损坏或锁竞争均静默丢弃,不猜序号。消费成功但事件落盘前退出可能丢数,不重置终态资格。 +- 跨进程恢复的终态继续使用受理时的用户、平台与 run ID,editor_session_id、client_version 使用实际观察终态的当前 GUI 实例,duration_ms=null;不使用恢复时的新账号替换原身份。没有 GUI 埋点 writer 的执行不制造编辑器会话。 +- Direct 以宿主账本和整体执行结果共同判定:Interrupted 或取消/结果不确定不记终态;Exhausted 记 runtime_failed;明确错误记 failed;正常 Completed 或无交付合同的普通对话正常返回记 completed。需要交付合同但仍未完成时不猜 completed。判定在执行 guard 释放前完成,不改变 UI 返回结果。 + +### 5.4 下游因果关联 + +project_revision_created、preview_ready、project_save 已知所属项目,因此 creative_task_id 填同一 project_id,包括人工编辑与保存。这个关联只表示项目目标归属,不表示修改由 Agent 造成;实际来源由 source 与 revision_source/save_source 表达。其 agent_run_id 与 agent_turn_id 按原需求保持 null,不将“当前运行的 Agent”作为推定因果。 + +执行上下文冻结 project_id、creative_task_id、agent_run_id、用户与平台身份;后续不能从当前选中项目重新取值。run 的 output_change_detected 必须根据本次执行可归因的有效修改判定,不能只比较全局 revision 前后值,因为其他窗口也可能改动项目。 + +Direct 的自动登录刷新重试仍属同一个 run。原生单次调用结束只生成终态候选,外层自动重试链结束后才确认最后一次调用的候选;中间鉴权错误不提前消费 run 终态。每次原生调用使用独立的临时尝试标识,确认必须匹配最后一次尝试,避免取消、不确定结果或队列丢弃后误用旧失败。账号代次变化则丢弃候选;同账号凭据续期不改变代次。候选仅有界保存在内存,不写历史、不增加网络或业务等待;窗口关闭、崩溃、候选丢失或最后一次前置拒绝可漏记,不回退确认先前尝试。前端只确认调用链结束,不提供成功或失败结论。恢复或跨原生调用无法证明连续耗时时,duration_ms 为 null。 + +## 6. 事件合同(原方案启用 12 类) + +本节 properties 中“可选”字段在不可得时省略;未标可选的字段必须存在。公共 status、ID 与 source 的赋值同时受第 4、5 节约束。 + +### 6.1 editor_session_start + +- 触发:GUI 完成可用初始化,宿主首次确认会话开始;不是每次组件挂载。 +- 公共字段:status=success;project_id 仅在已成功打开项目时填写;任务/run/turn ID 均为 null;source=editor。 +- properties:entry_source 为 direct_launch / project_association / app_restore;first_project_id 为已确认首次项目 ID 或 null。之后打开项目不回写本事件。 +- 去重事实键:editor_session_id + event_name。 + +### 6.2 editor_session_end + +- 触发:应用实际正常退出,先尝试闭合前台区间;关闭项目窗口但应用仍运行、退出被取消时不记。 +- 公共字段:status=success;project_id 为最后成功活动项目或 null;任务/run/turn ID 均 null;source=editor。 +- properties:end_reason=user_exit / app_restart;session_duration_ms 为可靠时长或 null;last_project_id 为稳定 ID 或 null。 +- 去重事实键:editor_session_id + event_name。正常退出事件也可能因不等待后台写盘而丢失,不能据此声称进程必然崩溃。 + +### 6.3 session_timeout(本版暂不采集) + +- 按用户确认,本版不产生该事件,也不新增其他异常退出产品事件替代它。后续若需要服务端异常会话统计,另行定义接收合同。 +- 下次启动在后台检查本地旧会话:仅当缺少正常结束标记,且能够确认所属旧实例已退出时,将旧会话标为 incomplete。 +- 旧实例仍在运行则不处理;无法确认进程归属或存活状态时保持原记录,不猜测退出。实例确认需防止 PID 被复用,复用宿主实例锁或 PID 与进程启动身份的组合,不仅检查 PID 数字。 +- 发现即可标记,不要求等待 30 分钟,不维护 60 秒检查点定时器,也不添加启动等待门禁。 +- incomplete 只表达记录不完整,不认定原因是崩溃。标记保持旧会话及原用户归属,不改成当前登录用户;重复启动处理保持幂等。 +- 不补记 editor_session_end、editor_focus_end、session_timeout,不改写已封存的历史事件。未闭合 focus 区间不计入完整前台时长,已正确配对的历史区间仍可计入。 +- 用户不再启动时,旧记录保持未闭合;主站后续只可将缺结束或缺配对记录视为不完整,不能从上传缺席推出确定退出时间。当前本地 incomplete 标记不随事件批次上传。 + +### 6.4 editor_focus_start + +- 触发:实例的可交互窗口总体状态从无前台变为有前台;启动时已获得焦点也需要记录。 +- 公共字段:status=null;project_id 为开始时实际活动项目或 null;source=editor;任务/run/turn ID 均 null。 +- properties:focus_interval_id(UUID);focus_reason=initial_focus / window_focus / restore / account_change;active_project_id(可为 null)。 +- focus_interval_id 是本文新增的事件专属关联 ID,确保存在丢数与乱序时仍能准确配对;不新增顶层业务 ID。 +- 项目切换不拆分区间,因此不能用 active_project_id 把整个区间归为某个项目的编辑时长。 +- 去重事实键:focus_interval_id + event_name。 + +### 6.5 editor_focus_end + +- 触发:总体前台状态关闭、最小化、可观测的锁屏/休眠或正常退出。锁屏/休眠监听能力必须实际验证,无法观察的情况标为统计限制。 +- 公共字段:status=null;source=editor;project_id 为结束时活动项目;任务/run/turn ID 均 null。 +- properties:原 focus_interval_id;blur_reason=window_blur / minimized / app_exit / system_suspend / account_change;focus_duration_ms 为单调时钟时长或 null;active_project_id(可为 null)。 +- 重复 blur 不重复结束。缺 start 的 end 或缺 end 的 start 都不组成有效配对,不凭空补齐另一端。 +- 去重事实键:focus_interval_id + event_name。 + +### 6.6 project_create_success + +- 触发:新项目初始化完成,manifest 已持久化且稳定 project_id 已确认;覆盖自动创建与用户选目录创建。 +- 公共字段:status=success;project_id 必填;source=editor;任务/run/turn ID 均 null。 +- properties:creation_source=home_game / home_design / template / selected_directory;project_template_id 可选,仅填真实模板稳定 ID。 +- 打开已有项目、幂等初始化不记创建;文件夹刚建立但后续失败不记成功。 +- 去重事实键:project_id + 本次创建操作 ID + event_name,沿用业务操作身份,不读取路径当项目 ID。 +- 只有成功事件,不能计算创建成功率;创建尝试数不在本版范围。 + +### 6.7 project_open + +- 触发:一次显式导航或启动恢复成功加载项目并进入工作区;后台读取与同页面刷新不记。 +- 公共字段:status=success;project_id 必填;source=editor;任务/run/turn ID 均 null。 +- properties:open_source=create / picker / recent / app_restore / project_association;is_first_open 为 boolean 或 null。 +- is_first_open 口径为“当前账号在本安装可观察范围内首次成功打开此项目”,不是全平台首次。使用本地有界索引保存该事实;无历史覆盖证据、旧项目首次遇到或索引清理后填 null,不将未查到当 true。新创建并首次进入可确认为 true。 +- 去重事实键:本次工作区打开操作 ID + event_name;之后真正重新打开必须有新的操作 ID。 + +### 6.8 creative_task_submit + +- 触发:本版实际新建项目的创作请求通过基本校验并被业务受理,且后台成功消费尚未提交的项目资格;不是创建项目、点击按钮、未提交草稿或请求重放。每项目最多采集一次。 +- 项目资格只在真实创建成功后初始化,记录格式版本、project_id 与已提交状态,随项目保留,不进入 7 天批次清理。创建成功先登记进程内待初始化资格,不依赖埋点服务或身份快照就绪;登记仅包含项目目录与 project_id,最多 1024 项且路径/ID 合计最多 1 MiB,超限静默放弃新增,不影响创建。后台互斥消费后才投递事件;此前候选丢弃而资格尚未消费时,后续真实受理可记录。 +- 埋点服务就绪后尝试投递待办;创建通知和后续真实受理均可在现有后台写入器中重试初始化。不增加定时器或项目扫描,不在业务线程执行资格文件 I/O,也不等待队列、磁盘或网络;失败不弹窗、不返回业务错误、不要求用户介入。队列拒绝、独立锁竞争与暂时 I/O 失败保留待办;只有明确观察到本进程创建、主标记和恢复副本均不存在时才允许初始化。初始化成功、发现任意已有标记/恢复副本或 manifest 项目身份不符后移除待办,不重置已消费状态,不修复损坏标记。 +- 没有待办依据的旧项目缺失资格仍为未知,不补历史。待办未持久化前进程退出或超出内存限额时允许漏记;资格已消费但事件未落盘时同样允许漏记,不反向重建资格。此恢复不承诺零丢失。 +- 并发候选以成功持久消费资格的顺序决定保留哪次受理,不保证业务最早请求或最早 event_time;最终事件可能为零条。项目标记属于持久的观测状态,不保存用户身份、正文或路径,也不作为业务执行门禁。 +- 公共字段:status=success;project_id、creative_task_id 必填;source=direct / design_agent;run/turn ID 均 null。 +- properties:空对象。公共字段不重复,不增加 Prompt、长度、附件或任务分类。 +- 去重事实键:creative_task_id + event_name;已成功消费资格后,同项目澄清、审批、追加和重试不再发本事件。项目级首次提交标记跨应用会话及批次清理保留。 +- creative_task_id 必须等于 project_id;同一项目先策划再开发也不再记第二次目标首次提交。 + +### 6.9 agent_run_completed + +- 触发:本次执行达到确定正常终态;现役 Direct 与 Design 在各自正式生命周期接线,不以“返回一个字符串”或 HTTP 200 代替业务成功。 +- 公共字段:status=success;project_id、creative_task_id、agent_run_id 必填;agent_turn_id 可空;source=direct / design_agent。 +- properties:agent_type=game_agent / design_agent;run_source=user_submit / user_continue / clarification / approval / user_retry;duration_ms(integer/null);retry_index(integer);output_change_detected(boolean/null);revision_id 可选;end_reason=finished / waiting_for_user / waiting_for_approval。 +- output_change_detected=true 必须有本次归属的有效变更证据;false 必须完成检测且确认无变化;无可靠证据填 null。revision_id 仅填本次归属的最新已提交 revision。 +- 对 Design,本次受理操作中已成功持久化的阶段推进即可作为变化依据,output_change_detected=true 并关联该阶段 revision,不额外要求文档版本或文件差异检测。没有阶段推进时仍可能改了文件,若没有其他变化依据则填 null、省略 revision_id,不能因阶段没变而填 false。 +- 去重事实键:agent_run_id + terminal;同一个 run 最多一个正常终态,不因 GUI 与 Rust 双写而重复。 + +### 6.10 agent_run_failed + +- 触发:业务已经判定本次执行最终失败;内部重试尚在继续时不记。 +- 公共字段:status=failed;同 completed 的 ID 要求;error_code 必填。 +- properties:同 completed 的公共执行属性;end_reason=failed。失败前可能已产生有效修改,因此 output_change_detected 可以为 true。 +- error_code v1 白名单:provider_auth_failed、provider_rate_limited、provider_unavailable、provider_timeout、provider_invalid_response、local_io_failed、runtime_failed、runtime_error_unclassified。 +- 错误码由结构化错误种类映射;无法分类才使用 runtime_error_unclassified。不能通过异常正文猜分类,不保存错误正文、文件路径或堆栈。普通工具失败被 Agent 修复后完成不记 run_failed。 +- 去重事实键:agent_run_id + terminal。取消与异常中断不冒充 failed;本版 run 完成率仅比较已观察到的 success/failed。 + +### 6.11 project_revision_created + +- 触发:Game Agent、代码、资源、UI 等已有 revision 路径要求成果确实发生有效变化,且现有业务修改与正式 revision 都成功提交;策划阶段推进采用下述独立口径。存储损坏或不确定提交不记成功。 +- 公共字段:status=success;project_id 必填;creative_task_id 与 project_id 相同;run/turn ID 为 null;source 使用实际修改模块。 +- properties:revision_id(已有正式 revision 转为字符串;策划阶段推进使用下述稳定事实标识,不使用 event_id);revision_source=agent / asset_canvas / resource_editor / ui_editor / manual_edit / system_projection;change_kind=code / asset / ui / design_document / mixed;files_changed_count 可选。 +- 只更新聊天历史、访问时间或运行状态不属于有效变化。system_projection 仅在投影真实业务成果时允许,纯修复缓存/同步元数据不进入有效变化率。 +- 当前 Direct 的 outputs_changed 与 manifest_requires_sync 要区分;不能给 revision 递增函数加一个无条件事件后就宣称满足语义。 +- Direct 的宿主 file.write 与正式补丁事务使用持锁目标内容比较和本次已提交项目 revision,成功且内容确实变化时记 source=direct、revision_source=agent。运行期间的全局 fingerprint 和写许可本身不证明本 run 改了文件;末尾投影登记不再重复统计工具已经提交的成果,也不将纯登记修复计为新成果。未经过可观察宿主提交的外部/命令行写入不推断事件。 +- Direct run 的 output_change_detected=true 与 revision_id 来自本 run 已确认的宿主成果提交;保存当前内存中最后一个真实成果 revision,不为埋点新增业务文件版本或同步写盘。没有观察到成果、恢复后缺少该证据时为 null,不能推断 false。后续 run 失败不撤销已提交成果事件。 +- Direct 的采集身份与最新成果编号使用独立的纯内存状态,项目 ID 和原 run 身份在执行会话创建时从最终账本冻结;采集不争用业务持久化锁,不因业务写入或 tick 占锁而漏记。内存锁只保护采集状态读写,事件投递在释放锁后进行,不等待磁盘、网络或队列,不向用户报错。恢复仍使用原 run 身份,但不补造此前未保留的成果编号;旧账本缺少 run 元数据时不套用当前用户身份。 +- 宿主文件通道仅对已识别成果类型采集:策划成果目录、UI 目录以及支持的代码/媒体扩展名;私有控制面、隐藏记录、构建缓存和类型未知的文件不据此统计。该口径不承诺观察全部磁盘文件;恢复后的未知成果证据不补填。 +- Design Agent:仅审批通过后实际进入下一阶段且成功持久化时记录;不严格检查文档版本或文件差异,不增加业务文件 revision。revision_id=design::,使用真实策划会话 ID 和本次实际进入的目标阶段;source=design_agent,revision_source=agent,change_kind=design_document,files_changed_count 省略。该标识只表示阶段推进事实,不代表文件版本。 +- 策划同会话同目标阶段只记一次;同阶段内文件写入、普通对话、工具执行、提交审批、审批拒绝、等待澄清、没有实际阶段变化的批准,以及持久化失败均不产生该成果事件。项目重开、会话恢复或读取现有阶段不补历史。 +- 策划阶段事件的 event_time 取本次阶段变更成功持久化时间,用户和平台取审批操作开始时捕获的身份,不能归入异步完成时的新账号。 +- 去重事实键:project_id + revision_id + event_name。一个原子 revision 只记一次;多来源合并提交按真实提交范围填写,禁止任选最后一个来源。 +- 外部编辑器的修改仅在宿主已有变化确认与 revision 提交时覆盖;本版不新建全盘监听,不宣称能看到所有磁盘改动。 +- 采集覆盖边界:第一阶段记录基础创作链路中的项目变化,不要求逐项覆盖所有资源操作。资源上传、删除、重命名、派生、图片正规化、音频/画布生成、版本资源替换与批量导入,不因已有业务 revision 就自动成为本期必须接入的埋点入口。撤销此前为这些入口新增的全面接线计划,不新增资源账本采集字段。已接入的 Direct 文件/补丁与 UI 成果按上述真实变化规则记录;统计结果仅表示已观测变化,不能宣称是全量资源操作或项目全部修订次数。 + +### 6.12 preview_ready + +- 触发:宿主对本次预览实例的实际入口执行访问检查成功,且确认检查期间项目版本仍匹配;Web 以真实入口成功响应且内容存在为最低可访问判据,其他引擎需各自真实 ready 信号。 +- 公共字段:status=success;project_id 必填;creative_task_id 与 project_id 相同;run/turn ID 为 null;source 为实际发起模块。 +- properties:preview_source=user / agent / auto_restore;preview_version 为该实例实际服务的项目 revision 字符串;ready_duration_ms 可选。 +- 只启动监听、写 Running、返回 URL 不能直接产生本事件。探测失败或版本漂移不产生 ready;不记录本地 URL、端口或绝对路径。 +- 去重事实键:项目 + 预览实例 ID + preview_version + event_name;轮询与重复回调不重复。同版本真正重开预览可产生新事件,漏斗按任务去重。 +- ready 只证明可访问,不证明 JS 无异常、游戏通关或用户满意。仅策划文档项目不强行产生游戏预览事件。 +- 正式入口覆盖 GUI 用户启动的持续 Web 预览(editor/user),以及 Direct 宿主浏览器验证的临时 Web 预览(direct/agent)。Direct 复用原 run 捕获身份;无新实例的缓存验证不补事件。外部任意 URL、环境自检项目和旧开发/CLI 入口不据 URL 推断本事件;当前无正式 auto_restore 新建实例入口,不伪造来源。 +- 每次实例使用独立内存 ID 和存活标记;GUI 停止/替换、Direct 临时验证结束或取消时失效。异步有界 loopback GET 探测不得延迟或改变预览/验证返回;仅对宿主端口的根入口请求,禁代理与重定向。失败不重试、不弹窗;只记录第一次可信 ready,不等待 JS 或整个浏览器验证成功。 +- 探测前后确认真实入口文件非空,避免空 HTML 被辅助脚本注入后误判;前后项目 revision 和实例须与启动捕获值一致。探测只读已有版本,不推进版本、不持有跨网络请求的项目锁;读取未知则跳过。原始响应、路径、端口不进入事件。 + +### 6.13 project_save + +- 触发:一次面向用户成果的逻辑保存操作已全部成功落盘,包括手动保存、业务自动保存或完整 checkpoint;多文件保存按一次操作记,不按底层 write 次数记。 +- 公共字段:status=success;project_id 必填;creative_task_id 与 project_id 相同;run/turn ID 为 null;source 为实际保存模块。 +- properties:save_source=manual / auto / checkpoint;revision_id 可选,取本次保存结果对应的真实 revision。 +- Agent 会话检查点、审计记录、埋点写入、配置保存、云快照同步不算项目保存。独立素材导出也不自动等于项目保存。 +- 无变化的自动保存不记;用户显式保存成功可记,但不能同时伪造 revision。失败或部分写入不记成功。 +- 无现役调用方的 `write_local_project_file` GUI 命令及其人工文件写入埋点已删除;Agent 仍使用底层文件写入函数。当前 GUI 保存采集来自项目 checkpoint 与 UI 编辑器,不将已删除命令计为采集入口。旧 `delete_local_project_file` 命令已随主分支退役,不恢复这两个入口或扩充其他文件操作采集。 +- GUI 手动项目 checkpoint 完整成功后记 source=manual、save_source=checkpoint,复用真实 checkpoint_id 作为操作身份,revision 不确定时省略;不同于 Agent 会话 checkpoint。其他工具调用该底层函数不自动归为人工。 +- UI 编辑器普通保存与保存并生成是两个逻辑操作。State 的 Saved 结果可记 ui_editor 成果,Unchanged/Conflict 不记成果;普通手动保存 Saved/Unchanged 均可记一次保存,自动保存仅 Saved 记。保存并生成必须等代码生成也成功后才记一次保存,不能在中间 State 成功时提前记;其保存事件省略没有整体提交依据的 revision_id。原生 State 成功事件即使后续生成失败也保留。 +- UI 逻辑保存起点捕获宿主身份和操作 UUID,收尾异步非等待回传;原生校验本 GUI 会话、字段和真实项目再投递。身份捕获、桥接或采集失败不改变业务结果。不增加 UI 业务状态机或保存专用持久化。 +- 去重事实键:项目 + 保存操作 ID + event_name。自动保存不表示用户认可;看板必须按 save_source 区分。 + +## 7. 本地存储、恢复与去重 + +### 7.1 位置与文件格式 + +使用客户端已解析的应用级配置目录下 `analytics/`;正式 Windows 默认基于 `%APPDATA%/world.genarrative.ai-game-creator/`,显式 config-dir 覆盖时随之变化。不放在项目目录或 `.debug` 中。 + +```text +analytics/ + instances// + session.json 本地生命周期标记和恢复所需关联 + batches// + meta.json 最小路由、幂等与清理元数据 + events.jsonl 每行一条事件的明文批次,封存后不可变 +``` + +batch_id 为随机 UUID;每个实例由单个后台写入器负责。批次在内存中组装,在同文件系统的临时批次目录写 meta.json 与 events.jsonl,写入完成后原子发布整个目录。未来上传只处理已发布批次,不读取在写临时目录。账号或目标平台变化时切新批次。 + +已确认时间条件:从当前批次第一条事件进入开始计时,5 分钟到期后由后台写入器封存落盘;后续事件进入新批次,没有事件时不产生空批次。批次达到 500 条或 1 MiB 序列化事件大小时提前封存;该内部上限已在本轮实施评审中采用。单事件超过批次字节上限时静默拒绝,不生成超限批次。正常退出尽力封存,不新增阻塞退出的等待。 + +这些是本地写入参数,与未来 15 分钟上传周期独立。封存前原始事件只存在有界内存,强杀或断电可能损失最近约 5 分钟尚未封存的数据;后台调度或写盘延迟可能扩大窗口,不承诺严格的最大丢数时长。实现时作为内部常量,不增加用户设置界面。 + +session.json 保存会话 owner(含可验证的实例身份)、原用户/平台归属、lifecycle_state(active / closed / incomplete)、未闭合 focus 及必要的幂等事实关联;原子替换。生命周期状态与会话开始、身份/focus 变化、正常退出等实际节点一起由后台写入,不设周期检查点。 + +下次启动识别旧实例退出后,只将符合第 6.3 节条件的 active 会话改为 incomplete;closed 和 incomplete 保持不变。可以保存本地 incomplete_detected_at 表示发现时间,它不是退出时间,不进入公共事件字段。记录缺失或损坏时不猜测重建完整会话。正常退出标记和事件均为尽力持久化,缺失可能来自写盘失败,不能据此确定为崩溃。 + +session.json 是本地恢复元数据,不是待上传事件;事件文件不能代替其中的实例存活身份和生命周期标记。用户下次启动前没有机会作出的标记,不视为已经完成。 + +### 7.2 去重与重放 + +- 第 6 节的事实键是本地逻辑幂等依据,不新增到公共 envelope。首次接收事实时分配 event_id;队列、批次及恢复保留同一身份。 +- 普通重放从已有业务操作/任务记录取得关联 ID,不按相同正文去重;相同文本可能是两次合法操作。 +- 幂等关联持久化与写入器串行执行。meta.json 保存批次的 event_id 清单和事实键摘要到 event_id 的映射,不复制事件正文。恢复从已发布批次元数据重建索引;批次成功发布但索引尚未更新时,不能重新生成 UUID 追加同一事实。 +- 无法证明原事件身份时不补造历史事件,保留丢数限制;不为每条埋点给业务事务增加同步磁盘门禁。 +- 重启保留完整已发布批次,清理未发布临时批次。读取时逐行解析并校验事件及元数据一致性;截断、坏行或身份冲突导致的损坏批次隔离并计数,不清空其他批次,也不自动改写事件或重新生成 event_id。 +- meta.json 记录格式版本、批次身份、路由、创建时间、事件数量及幂等关联。事件白名单校验不等于防篡改认证;后续服务端仍须独立鉴权和校验。 +- 正常退出标记缺失只是“未闭合”,不能证明具体退出原因;后续 incomplete 标记也不得被解释为已确定的 crash。 + +### 7.3 容量与生命周期 + +已确认:本地埋点队列及相关索引整体最多 20 MiB、最多保留 7 天。超过 7 天的批次按期清理;总量超过 20 MiB 时,从最旧封存批次开始清理及其专属索引,直到回到上限内。任一条件达到即执行,未上传批次也适用。清理记录丢弃条数;写入器轮转当前批次后仍无法满足上限则丢弃新事件。损坏文件同样计入限额,不无限隔离积压。 + +清理只作用于埋点队列及相关索引,不删除项目、Agent 对话、业务恢复记录或创作成果。清理由后台执行,失败静默,不阻塞正常客户端操作。 + +实例生命周期标记、打开历史索引与批次幂等映射均需有界清理;索引过期后不承诺识别无限久远的重放或首次打开。不可因此将未知 is_first_open 填 true。项目级首次创作提交标记跟随项目业务记录,不随埋点批次保留期清理。 + +容量/保留期清理与上传成功清理独立:前者不表示已入库,后者必须收到主站匹配的整批确认,详见第 13.7 节。任何批次均不保证无限保留至上传成功。 + +### 7.4 数据最小化与文件边界 + +- 本地 events.jsonl 为可直接读取的明文,不承诺文件保密或防用户修改。本阶段不引入加密库、密钥配置或文件加密开关。 +- 事件只包含本方案白名单字段;session.json 与 meta.json 只保存路由、实例身份、生命周期、幂等和清理所需元数据。 +- 不采集 Prompt、对话正文、工具参数/结果、代码、文件正文、Token、Cookie、API Key 或异常堆栈;不因取消加密扩大采集范围。 +- 正式事件写入 analytics 队列,不在普通应用日志、诊断包中重复输出完整事件正文。目录沿用现有应用私有目录权限。 +- 网络传输使用 HTTPS,本地开发仅允许显式配置的 loopback HTTP;由主站验证用户身份与字段。文件不加密不等于取消传输安全或服务端校验,上传实现见第 13 节。 + +## 8. 主站入库的事件与传输合同 + +### 8.1 保持事件与传输分离 + +本地事件 envelope 保持第 4 至 6 节合同,不添加 uploaded、retry_count、last_upload_error 等会随传输变化的业务字段。批次目标、发送进度与确认信息留在传输元数据。 + +批量请求固定包含 schema_version(整数 1)、batch_id(批次 UUID)、destination_origin(规范平台 origin)、user_id(真实用户 ID 字符串)、events(从 JSONL 解析得到的事件对象数组)。本地匿名事件仍可保留 null,但本次上传不接收匿名批次。全批事件用户必须一致;本地路由只作发送提示,不能作为服务端归属的唯一证据。 + +客户端通过 HTTPS 上传 JSON 批次,传输体类型为 application/json;保持各事件的原 ID、时间和字段值。具体接口、物理表与本地开发例外以第 13.3 至 13.4 节为准。 + +保持整批确认语义:服务端只有在全批事件已可靠保存,或按 event_id 去重确认内容相同时,才返回 `acknowledged_batch_ids`。客户端核对预期主站、成功 envelope、批次 ID 与事件数均匹配本次请求后,删除对应整个批次目录。 + +本次使用单批原子事务,任一事件冲突导致整批回滚且不确认;客户端保留原批次并在下个周期重发,由服务端按 event_id 去重。相同 event_id 不同内容为冲突,不能覆盖原记录或谎报确认。 + +本版不实现逐事件确认后的部分文件重写。永久无效批次的拒收/过期清理不得记为上传成功,也不能因为 HTTP 200、收到字节或仅解析成功就删除本地数据。 + +### 8.2 主站接收的最低语义 + +- 按 schema_version + event_name 校验字段类型、必填项、枚举、事件与 status 的组合;properties 不作为任意 JSON 垃圾桶。 +- 任务相关事件要求 creative_task_id == project_id;项目目标首次提交只能按已确认的项目首次提交事实记录,不能在接收端把每个新用户的第一次消息再算成新目标。 +- 限制请求体积与事件数量,校验 JSON 格式、批次身份和字段版本;解析或校验失败不确认该批次。请求鉴权和 HTTPS 独立于本地文件存储格式。 +- user_id 与鉴权主体必须一致;项目 ID 是客户端本地项目的关联标识,不因此赋予主站项目权限。 +- event_id 是全局唯一去重键。导入时保留原 ID、发生时间、关联字段和客户端版本,不重生成事件身份。 +- 服务端单独生成 received_at,用于观察延迟;不覆盖 event_time。 +- 不要求会话、任务、run 与 revision 按顺序到达,不因父事件丢失或批次乱序拒绝所有子事件;关联不完整需可识别。 +- 查询维度支持 user_id、project_id、creative_task_id、agent_run_id、event_name、event_time 和 client_version。主站独立事件表、索引与后台查询以第 13 节为准,不进入现有主站 tracking 聚合链路。 +- 本地匿名事件、未知平台、未知 schema 版本不能静默改写归属或格式后入库。 +- 每 15 分钟触发后台上传、同一应用数据目录同时最多一个上传轮次;成功清理、失败静默、不阻塞退出。重试策略、请求上限和超时以第 13.6 节为准。 + +### 8.3 可用指标的限制 + +- 匿名事件不纳入按用户的留存;稳定用户也要等主站接收链路验证后才能出正式留存。 +- 创作目标数就是项目目标数,不是消息数或执行次数;目标提交漏斗按有 creative_task_submit 的项目去重。旧项目缺失首次提交事件时不回填,应明确其不在该漏斗 cohort 中。新项目首条记录表示首次成功采集的真实受理,不能声称其时间和用户一定等于绝对首次操作;此前丢失的候选可能来自更早时间或其他用户。 +- 同目标可能跨天、跨会话、跨账号并经历许多 run。回访创作可由后续 run、revision、project_open 观察,不能只用首次 creative_task_submit 判断用户是否继续创作;每会话目标首次提交数不再代表每会话交互次数。 +- 人工修改、预览和保存归入相同项目目标,但不能据此宣传为 Agent 导致的成果;run 的 output_change_detected 仍需要本次执行的真实变化证据。 +- Game Agent 与 Design Agent 的预览适用性不同;不能把所有策划任务算入“游戏预览失败”的分母。 +- 策划成果按成功持久化的阶段推进记录为 project_revision_created,可作为策划成果次数统计;它不证明文档内容改变,也不等于某次 run 产生文件变化。按 source=design_agent 与其他成果来源区分解释,不能混称代码或文件有效变化率。 +- 没有独立策划进度、审批或澄清状态字段;阶段推进记录不能推导完整审批次数或精确阶段耗时,重开旧项目不补历史成果。 +- 自动保存、checkpoint 与手动保存分开解释;focus 仅为前台时长。 +- 只采集创建成功事件无法计算创建成功率;不采 task_type 就不能按任务类型分组;不能拿 agent_type 冒充 task_type。 +- 取消、崩溃与未闭合 run 未纳入失败事件,本版完成率是已知终态样本的完成率,不是所有提交的完成率。 + +## 9. 合规事件示例 + +以下是字段形状示例,ID、版本和时间均为示例值,生产必须从对应事实取得。 + +```json +{ + "schema_version": 1, + "event_id": "708cc064-e1ad-46d1-a26d-181432eef8aa", + "event_name": "agent_run_completed", + "event_time": "2026-09-21T10:30:00.000Z", + "user_id": "123", + "editor_session_id": "a5d5e098-e515-4b28-a27a-6018d47b55dd", + "project_id": "gameagent-example-project", + "creative_task_id": "gameagent-example-project", + "agent_run_id": "36c70186-93c7-4e2d-ad07-80d6b91a1371", + "agent_turn_id": null, + "status": "success", + "error_code": null, + "source": "design_agent", + "client_version": "0.1.0", + "properties": { + "agent_type": "design_agent", + "run_source": "user_submit", + "duration_ms": 32000, + "retry_index": 0, + "output_change_detected": null, + "end_reason": "waiting_for_user" + } +} +``` + +该例表示:本次策划执行正常停在等待用户澄清,没有已记录的阶段推进且文件变化未知;不表示整个任务完成,不记录 revision_id。 + +## 10. 实施拆分与验收依据 + +技术负责人已授权按计划落地;以下为实施里程碑边界。合同与本地队列、会话窗口与项目接入、策划阶段推进成果事件、首次提交、两类 Agent run、Direct 宿主文件/补丁成果、checkpoint 与 UI 保存,以及正式 Web 预览 ready 均已定向验收,并完成下述同一宿主组件链路的关联验证。全部资源操作的逐项接入不是本期完成条件。 + +| 里程碑 | 范围 | 退出判据 | +| --- | --- | --- | +| A 数据合同与本地队列 | 强类型事件、捕获身份、JSONL 批次、有界异步写入与恢复、静默失败 | 字段校验、JSONL 读取、跨账号、不阻塞、损坏隔离、容量与幂等测试通过 | +| B 会话、窗口与项目 | start/end/focus/create/open,以及下次启动的本地 incomplete 标记 | 多窗口、重载、最小化、正常退出、旧实例存活识别、异常未闭合、项目重复打开测试与桌面 smoke | +| C 两类 Agent 与成果链路 | submit/run、策划阶段推进成果、已有真实 revision、preview/save | 成功失败与等待、阶段实际推进且持久化成功与幂等、重试关联、无变化、版本漂移、真实访问与保存边界全部可核对;不新建策划文件 revision 机制 | +| 当前上传阶段 | 上传主站、确认后清理与后台查询 | 按第 13 节及配套里程碑实施,尚待完成整体验收 | + +实施前为选定里程碑生成单独实现计划,前一个里程碑评审/验收后再推进下一项。 + +最小验收矩阵: + +| 场景 | 必须成立的结果 | +| --- | --- | +| 正常完整创作 | 读取本地 JSONL 批次得到原方案完整链路;事件 ID 各自唯一,关联 ID 同语义一致 | +| 策划澄清/审批/继续 | 同一 task、多次 run;等待不误报失败,不重复 task_submit | +| 策划审批通过并推进阶段 | 目标阶段成功持久化后产生一条 revision;ID 为 design::,来源与 change_kind 按第 6.11 节固定 | +| 策划项目 5 分钟内打开又关闭 | 不补历史阶段成果;期间新产生的阶段推进进入应用队列,不随项目关闭消失 | +| 策划阶段重复回调与阶段内修改 | 同策划会话同目标阶段幂等;阶段内任意文件修改、审批拒绝、无阶段变化均不单独生成成果事件 | +| 策划阶段持久化失败或跨账号完成 | 保存失败不记成果;异步结果保留操作开始时项目与用户身份,不绑定到当前其他项目或其他用户 | +| 项目重开、改名、移动、切账号及策划转开发 | project_id 不变时 creative_task_id 恒定且等于 project_id;首次提交不因批次删除再产生 | +| 不同项目与旧项目首次观测 | 不同 project_id 对应不同目标;旧项目不补造历史首次提交,后续事件仍可关联目标 | +| 自动请求重试/用户重试 | 前者原 run,后者新 run;retry_index 口径一致 | +| 同请求重放/双回调 | 同事实不重复;故意重复相同文本的新操作不被误去重 | +| 纯聊天/相同内容保存 | 不生成有效 revision;output_change_detected 不误填 true | +| 失败前已有文件变化 | 可同时有 revision 和 run_failed,不因失败隐藏真实变更 | +| URL 返回但入口失败 | 没有 preview_ready | +| 保存失败/部分提交 | 没有 project_save success | +| A 发起后切 B | A 在途结果不归 B,B 的新操作归 B;平台分区不串 | +| 匿名后登录 | 历史匿名事件不回填用户;focus 归属正确切段 | +| 多窗口/最小化/重载 | 同实例不重复 session_start,前台并集不重计 | +| 强杀与下次启动 | 确认旧实例退出且旧会话未闭合后标记 incomplete;不伪造结束时间、run 失败、完整前台时长或 timeout 事件 | +| 旧实例仍运行、身份不确定或 PID 复用 | 不误标存活或归属不明的旧会话;再次启动不重复改变已确定的 incomplete 状态 | +| 队列满/只读目录/磁盘失败 | 正常创建、对话、保存仍继续,无弹窗;内部可观察丢弃计数 | +| 批次发布中断与重启 | 保留完整已发布事件批次,未发布临时目录可清理;不重新生成已发布事件 ID | +| JSONL 截断、坏行或身份冲突 | 损坏批次隔离并计数,不改写事实、不清空其他批次、不阻塞业务 | +| 数据最小化 | 正式事件和元数据符合白名单,日志不重复完整事件;无 Prompt、凭据或工具正文等禁止字段 | +| 本阶段运行 | 不发起埋点上传或外部 HTTP;预览仅允许第 6.12 节的宿主 loopback 可访问性检查。不创建 15 分钟上传定时器或周期会话检查点,不补发 timeout,不删除“假确认”数据;本地批次封存定时不受影响 | + +主要代码核查入口(相对仓库根目录): + +- 生命周期与配置:`apps/ai-game-creator-shell/src-tauri/src/main.rs`、`config.rs`、`platform_session.rs`。 +- 项目创建与打开:`apps/ai-game-creator-shell/src-tauri/src/commands.rs`、`src/features/app-shell/useHomeProjectCreation.ts`;离开登记由 `WorkspaceLauncher.tsx` 保持。 +- Direct 前端尝试与终态确认:`apps/ai-game-creator-shell/src/view/project-development/chat/controller/useDirectProjectChatController.ts`;沿用 `services/clientAnalytics.ts` 冻结账号代次、每次原生重试生成 attempt ID、只确认最后一次尝试。不在已退役的 App 聊天状态链恢复接线。 +- Direct 执行与审计:`apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs`、`agent/direct_runtime/mod.rs`、`agent/direct_codex_audit.rs`。 +- Design 执行与持久化:`apps/ai-game-creator-shell/src-tauri/src/agent/design_runtime.rs`、`agent/runtime_protocol/design_session.rs`、`agent/design_tools.rs`。 +- revision:`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs`,结合各实际写入调用方。 +- 预览与保存:`apps/ai-game-creator-shell/src-tauri/src/preview.rs`、`ui_editor/persistence.rs`、`project/checkpoint.rs`。 + +上述为定位线索,不表示这些函数已经完成埋点接入;正式实现必须再次核对当时源码。 + +## 11. 需要评审的口径与原需求差异 + +| 项目 | 本草案建议 | 未确认时的限制 | +| --- | --- | --- | +| 目标边界(已确认) | 用户已确认一个项目就是一个目标;方案据此令 creative_task_id=project_id | 不再需要消息意图分类或新增目标选择交互 | +| creative_task_submit 次数(实施采用) | 新建项目每个目标首次成功采集的真实受理一次,之后通过 run 表达继续执行 | 属于本地观测口径;不能证明绝对首次时间或用户,旧项目不得补历史提交 | +| 异常会话处理(已确认) | 下次启动确认旧实例已退出且未正常结束,标记本地 incomplete;本版不采集 session_timeout | 无周期检查点、无超时阈值、无补造退出/前台时长;未再次启动则保持未闭合 | +| 等待用户(已确认) | completed + end_reason,后续继续为新 run | run 完成不是任务完成 | +| 取消与崩溃 run(已确认) | 本版不映射到失败事件 | 成功率仅覆盖已知正常/失败终态 | +| is_first_open | 本安装当前账号可观察范围,未知为 null | 不能声称全平台首次打开 | +| output_change_detected / duration_ms | 证据不足允许 null | 原方案未明确可空性,不能用 false/0 伪造确定值 | +| 策划阶段成果(已确认) | 审批通过并实际进入下一阶段,成功持久化后复用 project_revision_created;同会话同目标阶段幂等 | 不采独立进度与审批字段;不严格校验文档版本/差异;阶段内修改不逐次采集,重开不补历史 | +| revision | Game Agent 与其他已有正式 revision 路径按真实变化采集;策划使用 design:: 阶段事实标识 | 策划标识不是文件版本,不据此伪造 run 文件变化;其他来源剔除纯元数据同步 | +| 本地保留(已确认) | 最多 7 天或总量 20 MiB,任一条件达到即清理;超量先清最旧批次 | 未上传数据也会清理,不保证保留至上传成功;不删除项目及 Agent 业务数据 | +| 本地封存时间(已确认) | 当前批次首条事件进入后 5 分钟封存;无事件不生成空批次 | 与 15 分钟上传独立;强退可能丢失尚未落盘的一批事件 | +| 本地批量上限(实施采用) | 达到 500 条或 1 MiB 序列化事件大小提前封存;超大单事件静默拒绝 | 内部常量,与 5 分钟条件并行;不新增用户设置 | +| 字段补充 | schema_version、focus_interval_id、run end_reason;incomplete 仅是本地生命周期状态 | 需与后续接收端按同版合同实现,不任意扩展 properties,不将本地状态冒充产品事件 | +| 本地格式(已确认) | 本阶段不加密,使用明文 JSONL 批次 | 无密钥前置要求;原字段白名单与数据最小化要求不变 | +| 未来确认删除 | 整批确认、整批删除,失败重发原事件批次 | 服务端仍需逐事件去重;暂不增加逐事件部分重写机制 | + +## 12. 实施与验证状态 + +2026-09-22 Direct 采集锁隔离验证:原生 `analytics` 定向测试 60 项通过、1 项既有跳过,`agent::direct_execution::tests` 24 项全部通过(两组有 1 项重叠)。新增线程用例持续占用业务锁,验证采集身份设置、成果记录、上下文与最新成果读取在释放业务锁前完成,并实际读回原用户的成果事件;另验证恢复不补造历史成果编号、旧账本缺少元数据不套用当前用户。独立代码复核及格式、编码、文档索引、diff 检查通过。分页 DTO 仅修正注释,查询行为不变;退出机制未改,未运行完整 GUI/Provider 联调。 + +2026-09-22 新项目资格有限恢复验证:原生 `analytics` 定向测试 59 项通过、1 项既有跳过;覆盖创建时无埋点上下文、创建通知队列拒绝、独立锁竞争和临时文件系统故障后的真实受理恢复,以及资格成功落盘后不重授、进程内待办容量限制。既有损坏/备份/身份不符、跨 writer 去重测试继续通过。命令配置、Rust 格式、编码、文档索引与 diff 检查通过;独立代码复核确认业务线程不执行资格文件 I/O,内存锁不覆盖后台 I/O。未执行完整 GUI/Provider 联调或生产发布。 + +已做:原产品方案逐项对照;核对现役 Direct/Design 持久化、项目创建、revision、预览、UI 保存与 checkpoint 的源码入口。本文新增合同和参数均以草案标识,不作为已经上线的事实。 + +合同与本地队列、会话窗口与项目接入、策划阶段推进成果事件、首次提交、两类 Agent run、Direct 宿主文件/补丁成果、checkpoint 与 UI 保存,以及正式 Web 预览 ready 已完成实现、独立审查和定向验收。最终按原始需求和已确认口径核对全部 12 类事件入口,并补齐同一 writer、会话、项目和目标的宿主组件集成链路验证,本期本地采集验收通过。资源操作全面接线计划因超出原始需求撤销,未进入资源实现,不作为必做待办保留。本期无主站 API 或数据库行为;验收不表示已经提交、发布或上线。 + +入口核查以正式 Direct/Design GUI 可达链路为准。`src/main.tsx` 的旧 agent-chat/supervisor-chat 入口受 DEV 限制,正式 Direct 项目跳过旧 Runtime 恢复;旧 Runtime 的 file_ops/project_ops/ui.workflow 仍由开发入口和 CLI 使用,但不因这些函数存在就扩张本期正式 GUI 采集范围。通用画布导入/同步也需核实正式面板可达性,不能仅凭 App 内保留命令分支认定已经接入或必须接入。 + +| 已实现合同 | 验证证据 | 结果与边界 | +| --- | --- | --- | +| 基础创作链路的同一身份与关联 ID | 增强既有 `analytics_real_file_write_preserves_original_identity_and_failed_run_revision`;最终 `cargo +stable test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell analytics -- --test-threads=1` | 51 项全部通过,独立审查通过。同一 writer 串联会话/前台、创建/打开、首次受理、真实 Direct 文件事务、失败 run 保留成果、真实宿主 HTTP ready、checkpoint 保存和退出;核对事件唯一、必需 ID 非空且一致、run 成果版本、预览实际版本、focus 配对与真实 checkpoint 事实键。创建投递使用测试辅助入口,run 结果和构建产物由夹具提供;不等同于完整 GUI、Provider 或构建器端到端验证 | +| 最终前端与静态检查 | `clientAnalytics.test.tsx`、`directRunAnalytics.test.ts`、`uiEditorPage.test.ts`;shell `tsc --noEmit`、Rust fmt、编码/索引/diff | 前端 46 项全部通过,其余检查通过。最终收尾只增强测试和 `cfg(test)` 辅助,无产品逻辑变更,生产编译沿用下述合并后成功证据 | +| 12 类事件合同、必填 nullable、身份/来源/状态组合与安全整数 | `analytics::contract::tests` 的 5 项测试 | 通过;已删除未发布的独立策划快照合同和专属测试 | +| 路由切批、恢复去重、JSONL 原子发布、数量/大小/时间阈值、保留清理及项目目标资格 | `analytics::store::tests` 的 18 项测试 | 通过,使用真实临时目录;包括首次打开三态、跨实例索引回收、损坏项隔离、会话队列与写盘空间预留,以及新增的目标资格四项验证 | +| 非阻塞投递、内存条数与字节上限、真实线程封存 | 公开 writer 入队拒绝和最后 sender 关闭后的文件读回测试 | 通过;不等待业务退出,不保证强退零丢数 | +| 正式客户端模块集成 | `cargo +stable test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell analytics:: -- --test-threads=1` | 32 passed,0 failed;非独立替身 harness | +| 会话一次启动、实际退出、窗口并集、身份切分、迟到打开与重复回执 | `analytics::gui::tests` 的 4 项状态测试及原生接线独立复查 | 通过;纯内存临界区串行更新,启动身份发布与通知同步 | +| 未闭合会话恢复、活动实例保护、损坏/临时元数据回收 | `analytics::session::tests` 的 5 项真实文件锁与目录测试 | 通过;只对已取得旧 owner 锁的实例操作,未知所有权保持原样 | +| 创建成功、已有项目不误计、实际打开来源与卸载保护 | 前端 clientAnalytics/homeWebPreflight/useTemplateLibrary 共 27 项测试与 shell TypeScript 检查;同一 Rust 测试可执行文件的自动创建 3 项、初始化 2 项及认证会话 11 项回归 | 全部通过;内部 hydration 不计第二次打开,未宣称覆盖不存在的恢复/文件关联入口 | +| 生产 Tauri 入口编译与真实生命周期 | `cargo +stable build --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell`;临时配置目录启动真实客户端后正常关闭并读回 | 构建通过;start/end 各一条,end_reason=user_exit,单调时长 32297 ms,session.json 为 closed,未遗留该配置的子进程 | +| 格式与文档 | `cargo +stable fmt --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --all -- --check`,文档索引、编码、差异检查 | 通过 | +| 策划阶段成果、审批幂等及失败边界 | 正式测试可执行文件 `agent::design_runtime::tests::` 19 项;`design_session` 筛选 3 项(其中 1 项重叠) | 全部通过;五阶段业务流程配合模拟 Provider,真实文件及 writer 读回 5 条阶段事件。后续模型失败仍保留一条;跨用户重放仍一条;拒绝与 checkpoint 失败零条。否定断言在同队列哨兵落盘后检查 | +| 策划审批生产入口 | `cargo +stable check --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell`;独立代码及验收审查 | 通过;Tauri 入口在资源加载前冻结身份。未调用外部付费模型;run 的阶段成果关联已由下述最新运行时测试覆盖 | +| 新项目首次观测提交、跨来源与批次清理不重复 | 新增四项真实 writer/项目测试,包含在上述 32 项中 | 通过;独立锁竞争后允许后续真实受理消费剩余资格。跨账号和双 writer 最多一条,未知/损坏/备用文件不重建,队列条数与字节拒绝不执行项目 I/O | +| 策划受理与失败、重放、审批拒绝和 checkpoint 失败 | 同一正式测试可执行文件 `agent::design_runtime::tests::` 22 项 | 通过;模拟 Provider 走真实运行时和文件队列。模型失败仍计已受理提交;旧命令重放不消费新出现资格,无效输入不消费后续用户资格 | +| Direct 新受理、账本重放与旧预算迁移 | 同一测试可执行文件 `agent::direct_execution::tests::` 21 项,生产入口独立代码审查及 `cargo +stable check` | 通过;新增的纯内存新受理标志不改变账本格式,恢复/迁移为 false,真实持久受理后才投递。未调用外部 Direct Provider 做完整创作 smoke | +| 六类创建入口与现有建项行为 | 自动创建 3 项、初始化 2 项、模板创建/缺失拒绝 2 项、Godot manifest/import 17 项;生产 check、格式检查 | 24 项通过;六个创建调用传实际项目目录,已有 manifest 不重授资格。当前仓库模板正文无 `.agent`;复制时保留同一 project_id 的项目仍按同一目标处理 | +| 两类 Agent 的运行身份、重试序号、恢复与终态去重 | 最新正式测试可执行文件 analytics 38 项、Design Runtime 24 项、Direct execution 23 项、Direct 终态分类 2 项 | 共 87 项通过;真实 writer/项目文件与模拟 Provider 验证。Design 等待正常完成、结构化失败、显式重试和阶段成果关联;恢复保留原身份、耗时未知;Direct 未完合同和取消不误记成功 | +| Direct 自动认证刷新后的最终尝试确认 | 上述 analytics 中 3 项候选测试;前端 directRunAnalytics 5 项及 clientAnalytics 11 项;shell `tsc --noEmit` | 通过;首次失败与最终成功同时暂存时只确认最终成功;未知尝试、discard、重复确认、16 项/1 MiB 上限和会话校验覆盖。前端 UUID 失败不回退、代次变化丢弃、桥接失败不阻塞或覆盖业务结果。独立代码审查通过 | +| Agent 运行结果生产入口与验收 | 最新 `cargo +stable check --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell`;fmt、编码、索引、差异检查;独立条款验收 | 全部通过;生产 check 有既存告警,无编译错误。未新增上传、加密、网络请求或业务埋点 I/O 等待;运行结果临时计划已融合删除 | +| Direct 宿主文件与补丁成果、原身份及 run 成果关联 | `cargo +stable test ... analytics -- --test-threads=1` 43 项;同正式测试可执行文件 `agent::direct_patch::tests::` 6 项、`bridge_write_file` 4 项、`queued_write_rechecks_the_original_lease_after_project_lock_release` 1 项、`agent::direct_execution::tests::` 23 项 | 去重 76 项全部通过。真实写许可/文件/JSONL验证新内容记成果、同内容不记、原用户 A 归属;真实 bundled patch 两文件成功仅一条/count=2,随后部分失败不覆盖成果。失败 run 的关联为 writer 集成测试加生产接线审查,未宣称完整 Provider 失败流程;两个成功版本取最新经代码审查 | +| Direct 成果生产编译、故障隔离及条款验收 | 最新生产 `cargo +stable check`、fmt/编码/索引/diff及独立代码与条款审查 | 全部通过;读取未知/队列拒绝不改变工具结果,不新增业务版本或同步埋点写盘。测试夹具已补真实合同与 config 父目录;Windows patch smoke 将既有 `target/debug/coding-agent` 完整复制至测试 exe 相邻的 `target/debug/deps/coding-agent`,使用校验通过的 0.155.1 执行器,未放宽版本校验或更改全局 Codex。成果采集临时计划已融合删除 | +| checkpoint 与原生 UI 成果 | 最新 `cargo +stable test ... analytics -- --test-threads=1` 45 项;同正式测试二进制 UI persistence 14 项、bridge_write_file 4 项 | 此前合计 63 项通过;其中已退役的 GUI 文件写入/删除测试不再作为现役入口证据。当前保留完整及失败 checkpoint、UI Saved/Unchanged/Conflict/错误和原用户归属验证。checkpoint 失败夹具使用真实项目中被普通文件占用的检查点目录,不把业务允许初始化的缺失目录当作失败 | +| UI 逻辑保存及故障隔离 | 前端 clientAnalytics/uiEditorPage 两文件 41 项、shell `tsc --noEmit`,生产 `cargo +stable check`、fmt/编码/索引/diff,独立代码审查及条款验收 | 全部通过;普通手动保存、自动保存、组合生成成功/失败、重复回调、桥接失败与冻结身份已验证。保存不等待埋点;没有更改业务 DTO 或上传/加密。临时计划已融合删除;未执行 GUI 交互或前端到原生 UI 保存命令的完整跨层 smoke | +| 正式 Web 预览实际 ready、实例寿命与身份 | 最新正式测试二进制 analytics 51 项(含新增六项真实 loopback/JSONL 测试)、既有本地预览及 registry/lifecycle 21 项、Direct validation 6 项 | 合计 78 项通过。覆盖真实 GUI helper 自动探测、user/agent 来源、同版本新实例、原用户、空/缺失入口、HTTP失败/空响应、请求期间版本漂移、停止/替换/取消。一个旧 Runtime 模拟模型测试在并发编译时触发原有 2 秒等待超时,编译结束后单独 exact 复跑通过,未修改断言 | +| 预览前端来源、生产编译及条款验收 | 合并 master 后 `appSurface.test.ts --threads=false -t 'preview\|预览\|play request\|run local\|local game'` 104 项、shell tsc、生产 `cargo +stable check`、fmt/编码/索引/diff及独立验收 | 全部通过;旧草稿自动预览不传 user,不据任意外部 URL 推断项目成果。Direct 原 run capture 与临时 guard 经独立接线审查;实测 host HTTP 可访问性不等价于完整 Chrome 双端验证。无上传、外部探测或新增业务版本;临时计划已融合删除 | + +环境限制:本机固定 1.98.1 工具链缺可用 cargo,实际显式使用 stable(1.96.0)完成编译测试,未更改仓库固定版本。真实 GUI smoke 使用隐藏窗口,只证明启动与正常退出,不证明实际焦点操作;窗口并集、最小化和重复通知以状态测试验证,锁屏/休眠为已知观测限制。真实付费 Provider 和完整 GUI run smoke 未执行,Runtime 模拟 Provider 与前端 helper 测试不等价于真实模型验证。Direct 未完成交付合同的 guard 释放可能将账本中断,本版不改变其业务恢复行为,不承诺所有认证刷新都能继续生成;埋点不会将此类中断报告记为成功。人工和 UI 保存已有真实业务函数/文件/JSONL及前端 hook 验证,尚无完整 GUI 跨层操作 smoke;预览已有真实宿主 HTTP 和 JSONL 验证,未运行完整 Chrome 双端验证。 + +## 13. 上传、入库、成功清理与后台查询工程方案 + +### 13.1 交付目标与边界 + +状态:implemented / verified;技术负责人已授权开工,本地隔离环境验收完成,证据与限制见 §13.11,未核验或变更线上 schema。客户端定期上传已有封存批次,主站可靠保存并确认后清理对应本地副本,管理员能够查询真实入库的事件明细。 + +- 必须项:一张私有持久表、批量接收 API、后台上传器、确认后删除、一个后台明细栏目、身份和幂等验证。 +- 风险项:服务端已提交但响应丢失、跨账号/平台串传、保留清理与上传并发、删除导致观测资格重置、查询截断冒充最新数据。 +- 可选项:本版无。暂不做加密、匿名接收、聚合表、统计看板、Excel 导出、后台编辑/删除、立即重试或新增采集事件。 +- 采集仍为现有 12 类事件;不上传对话、提示词、文件内容、策划快照、本地路径、Token、项目资格账本或会话 incomplete 元数据。 +- 5 分钟 / 500 条 / 1 MiB 封存和 7 天 / 20 MiB 本地保留合同继续生效。上传成功清理与保留上限清理是两种独立原因,不能把后者统计为上传成功。 + +### 13.2 现状与接入边界 + +现有 `tracking_event` 保存主站的 event_key、scope、用户及 metadata_json,`tracking_daily_stat` 保存每日聚合;后台已有 `#tracking` 和 `#tables`。本方案新增 `agc_tracking_event`,不改主站埋点表、不进入个人任务奖励或主站每日统计链路。 + +正式路径为:客户端 Rust 上传器 → api-server 用户鉴权与 DTO 校验 → module-* 中的事件规则 → spacetime-client typed facade → spacetime-module 原子事务。后台通过管理端鉴权 BFF 和同一 facade 读取私有表,浏览器不直连数据库。 + +主站接受的是客户端观测数据,不因此确认项目所有权、成果真实性或奖励资格。允许父事件缺失、乱序、迟到,不给本地项目 ID 添加主站项目外键约束。 + +### 13.3 数据表与字段映射 + +新增普通私有持久表 `agc_tracking_event`,不是仅供实时订阅的 SpacetimeDB event table。一条事件一行。 + +| 字段 | 数据库类型 | 来源与约束 | +| --- | --- | --- | +| event_id | String,主键 | 原事件 UUID;唯一去重键 | +| schema_version | u32 | 原事件版本,首版只接收 1 | +| event_name | String | 原事件名,现有 12 类白名单 | +| event_time | Timestamp | 原 UTC 毫秒时间,无损转换,不用接收时间替代 | +| user_id | String | 本版只接收非匿名用户;与鉴权主体及批次 user_id 相同 | +| editor_session_id | String | 原编辑器会话 ID | +| project_id | Option | 原值,保留 null | +| creative_task_id | Option | 原值,按事件合同与 project_id 一致 | +| agent_run_id | Option | 原值,保留 null | +| agent_turn_id | Option | 原值,保留 null | +| status | Option | 原枚举 success / failed 或 null | +| error_code | Option | 原错误码白名单或 null | +| source | String | 原来源枚举 | +| client_version | String | 原客户端版本 | +| properties_json | String | 按事件类型验证后的专属属性 JSON;不接受任意字段 | +| batch_id | String | 首次接收入库时的批次 UUID;重复事件不改写 | +| received_at | Timestamp | 首次插入事务的 ctx.timestamp;重传不刷新 | + +字段和 nullable 语义以第 4 至 6 节为准,不能用空串或零替代未知。传输 origin 不作为事件新增采集字段或表内分析维度;部署环境由服务端配置及批次 origin 校验隔离。 + +索引:received_at、(user_id, received_at)、(event_name, received_at)。主键承担去重。SpacetimeDB 2.8.3 的索引过滤不支持 Option 项目字段(已由编译核验),项目条件使用上述时间索引候选进行精确筛选,不增加空串哨兵或重复项目字段。其余关联字段先作为受限查询条件,不为了预想报表遍建索引。 + +本版只新增表,同步 migration 注册、架构表目录、生成绑定和 schema 检查;不迁移旧主站埋点,不改动已有表字段。服务端暂不设置自动到期删除;本地 7 天 / 20 MiB 不适用于数据库。数据库容量与后续留存另行评估,不在本次加入自动清库。 + +### 13.4 上传接口与确认合同 + +新增 `POST /api/agc/analytics/batches`,使用现有用户 Bearer 认证与平台会话能力,HTTPS、`Content-Type: application/json`。本地开发仅复用既有显式配置的开发服务地址。该路径不是 external v1,不引入开发者 API Key。 + +请求保持第 8 节合同,snake_case: + +| 字段 | 类型与来源 | +| --- | --- | +| schema_version | 整数 1,上传协议版本 | +| batch_id | 原批次 UUID 字符串 | +| destination_origin | 批次原目标平台 origin | +| user_id | 批次原真实用户 ID 字符串 | +| events | 从原 JSONL 解析的事件对象数组,完整对象示例见第 9 节 | + +- 一次请求只发一个已封存批次,1 至 500 条。事件数据沿用 1 MiB 上限,JSON 请求体上限 2 MiB,容纳数组及 envelope 开销;服务端不依赖 Content-Length 自报大小。 +- 批次与所有事件的 user_id 必须相同且等于鉴权主体;匿名、未知 origin 批次不上传,不补认领到后来登录的账号。 +- destination_origin 必须匹配当前服务部署的可信公开 origin 配置,不使用未经信任的 Host 头作为判据。 +- 接收端配置 `GENARRATIVE_AGC_ANALYTICS_ORIGIN` 为规范 origin(无路径和尾部斜杠):正式站 `https://www.genarrative.world`,测试站 `https://dev.genarrative.world`,本地联调填写客户端实际连接的 loopback origin。未配置/非法配置时接口返回 503,不猜测默认站点;允许 HTTPS 和本地 loopback HTTP。 +- 复用现有事件版本、枚举、字段长度与必填 nullable 校验;批内重复 event_id、未知字段或非法事件整批拒绝。 +- 本接口不进入普通成功路由 tracking 映射,不为每次上传另生成主站产品事件。 + +成功响应使用现有 API 成功 envelope,其 payload 为: + +```json +{ + "acknowledged_batch_ids": ["<本次请求的 batch_id>"], + "event_count": 12 +} +``` + +`event_count` 是本次确认的事件总数,包含内容一致的已有事件,不是新增行数。客户端只在成功 envelope、批次 ID 和本次事件数均匹配时删除。HTTP 200 本身不足以证明入库。 + +| 返回 | 意义 | 客户端处理 | +| --- | --- | --- | +| 200 且确认匹配 | 整批已提交或已存在且内容相同 | 删除该批次待上传副本 | +| 400 | 格式、事件合同或 schema 不支持 | 本轮跳过,保留至后续周期或保留清理 | +| 401 / 403 | 凭据失效、用户或 origin 不匹配 | 本轮停止该身份上传,不弹登录窗口、不替换归属 | +| 409 | event_id 已存在但内容不同 | 不覆盖、不确认,保留原批次 | +| 413 | 请求超限 | 不拆分或改写原批次,保留 | +| 429 / 5xx / 网络超时 | 本轮不可确认 | 保留,下周期重试,不立即循环重试 | +| 响应缺字段、ID/数量不符 | 确认无效 | 保留 | + +沿用现有错误 envelope;响应不回显事件正文、凭据或其他用户数据。 + +### 13.5 原子入库与幂等 + +首版单批最多 500 条 / 1 MiB,采用一个原子事务完成,不新增批次回执表或服务端任务队列。 + +1. API 先鉴权并校验完整请求,再通过受信任服务身份调用数据库写路径;普通客户端身份不能直接写表。 +2. 事务核验全批事件。event_id 不存在则插入;已存在时比较原事件全部业务字段,properties 按解析后的结构比较,JSON 对象键顺序不影响等价。 +3. 相同事件视为已接收,保留第一次的 batch_id 和 received_at;事件 ID 相同但用户或任意事件内容不同则冲突,整批回滚。 +4. 只在确认数据库事务已提交后响应。Reducer 如被采用,facade 必须等待其已提交结果,不能将“已发送调用”当作完成。 +5. 服务端已提交但响应丢失、客户端确认后崩溃或本地删除失败,均可用原批次重发;不增加事件行数。 + +batch_id 是传输关联标识,event_id 是持久幂等依据。一张表不提供独立批次审计、批次内容哈希登记或永久批次状态机;本版不承诺这些能力。 + +### 13.6 客户端调度与身份 + +- 从客户端后台上传器启动时计时,每 15 分钟触发一次;首次不立即上传。休眠恢复最多执行一次到期轮次,不补跑错过的全部轮次。运行不足 15 分钟的数据留待后续启动并达到上传周期;退出不强制联网。 +- 每轮串行处理当时已封存且身份匹配的批次,优先旧批次;轮次开始后新封存的批次留待下一轮。 +- 同一应用数据目录通过一个非阻塞上传锁只允许一个实例执行上传轮次;竞争者本轮跳过。不使用长时间文件锁包住正常采集或网络等待。 +- 单请求总超时 30 秒;每轮最多处理 20 批、最多 2 分钟,不重叠执行。下一轮重新扫描;以上均为内部常量,不新增设置界面。 +- 仅使用与批次 user_id、destination_origin 完全一致的当前认证会话。账号或平台切换后不发起旧身份的新请求;已经发出的请求仍绑定原身份,收到有效确认可以清理原批次。 +- Token 只在内存请求中使用,不写入批次、表或日志。复用现有凭据获取能力,不新增埋点专属登录、刷新循环或登录提示。 +- 未登录、匿名、未知平台数据保留到原有保留策略清理;不主动登录所有历史账号来上传。 +- 400/409/413 等单批错误跳过该批继续本轮剩余候选,避免毒批次独占队列;认证错误或暂时服务故障结束本轮。下一周期仍按相同有界规则处理,不无限立即重试。 + +### 13.7 本地成功清理与现有状态边界 + +上传只读取已发布批次的 meta.json 和 events.jsonl,使用现有安全路径、大小、事件清单及一致性校验;不扫描项目内容。不得根据服务端返回字符串直接拼出待删路径,删除目标只能是本次本地选中的批次目录。 + +上传器持有一个有界批次内存副本再执行网络请求,不在网络等待期间占用 writer。保留清理可能先删原目录:发送前已缺失就跳过;发送后已缺失则确认清理视为无事可做。允许既有保留策略丢弃未上传数据,不建立第二套 durable outbox。 + +有效确认后调用本地存储层的批次删除入口,只删除对应目录,并同步/失效化有关批次缓存。删除失败保留,下一轮允许重传。上传异常只增加有界诊断计数,不弹窗、不阻塞 Agent、项目操作或退出。 + +不能删除项目 `.agent/analytics-goal.json`、`.agent/analytics-runs.json`、正式会话、对话或成果。保留会话恢复所需的控制元数据,空实例目录交给既有保留清理。 + +现有 store 的去重索引会随批次清理重建。上传更早删除批次不能重授首次提交资格或重置 run 身份:项目资格/run 账本保持原生命周期。当前实例另保留有界内存中的近期事实及项目观测,分别最多 16,384 项、最多 7 天,超限淘汰最旧项而非停止后续采集,避免最近已确认批次的重复回调重新生成 ID。内存缓存不承诺无限历史去重,不新增永久历史索引,也不从服务器或完整对话回填历史。 + +### 13.8 后台“客户端埋点”栏目 + +新增路由 `#agc-tracking`,显示名“客户端埋点”,与现有主站“埋点数据”并列;复用后台布局、筛选表单、用户引用组件及详情弹窗。owner 按现有规则可访问,member 必须具备新增页签权限 `agc-tracking`,接口同时执行权限校验。 + +新增 `GET /admin/api/agc/tracking-events`,管理端 DTO 沿用 camelCase: + +- 筛选参数:`userId`、`projectId`、`creativeTaskId`、`agentRunId`、`eventName`、`clientVersion`、`startTime`、`endTime`、`cursor`、`limit`。 +- UI 首版展示发生时间范围、用户、项目和事件类型;其他关联筛选可通过详情入口携带,均使用精确匹配,不引入全文搜索。 +- 时间范围按 event_time,起点包含、终点不包含;时间格式为带时区 RFC3339。默认不设时间过滤,默认每页 50 条,最大 200 条。 +- 返回 payload:`entries`、`nextCursor`。条目包括表内字段的 camelCase 映射,`properties` 返回解析后的 JSON 对象;时间转为 UTC 字符串,Option 转 null,不向 UI 暴露 SATS 原始值。 +- 列表按发生时间 event_time 倒序,同时间按 event_id 倒序稳定排序;明确列标题为“入库时间”和“发生时间”,补传的历史事件按实际发生时间归位。 +- cursor 固定入库时间快照上界、最后一行的 (event_time, event_id) 和筛选摘要;切换筛选重置游标,非法游标返回 400。新增数据点击刷新后出现,分页不依赖会随插入漂移的 offset。旧排序游标失效,刷新列表重新查询;不改变存储表及索引。 +- 由数据库查询路径按索引及条件确定排序候选与页边界;若当前 SQL 不支持排序,通过受限 typed procedure 完成。不能任取 LIMIT N 行后排序并宣称为全表最新;实现计划须在现役 2.8.3 上验证此点,不照搬旧页面的截断查询方式。 + +列表列:入库时间、发生时间、用户、事件中文名、项目、来源、结果、客户端版本。空值显示“—”。详情展示全部既有字段与格式化 properties,包括会话/run/批次 ID;不新增编辑或删除按钮。空结果、加载失败、无权限沿用后台现有反馈,移动端表格支持横向滚动。 + +### 13.9 兼容、上线与回退 + +- 本地事件 schema_version 继续为 1,不改写已封存批次;升级后只处理仍在保留范围内且身份匹配的数据,不恢复已过期数据。 +- 发布顺序:新增数据库表/事务 → API 与后台 → 带上传器的客户端。旧客户端继续只写本地,旧批次无须迁移。 +- API 未部署或服务不可用时客户端静默保留并按周期重试;现有本地保留上限仍有效。 +- 回退客户端上传器或接收路由时保留已入库表,不删除正式数据;本地清理只对真实确认发生。没有新增远程开关或复杂灰度框架。 +- 新增事件版本必须显式扩展接收合同,不能靠忽略未知字段兼容。首次接收字段校验必须与客户端合同做对应测试。 + +### 13.10 实施边界与验收 + +技术负责人已授权按本方案开工;里程碑验收证据已融合至 §13.11,完成的临时里程碑规范与实施计划已删除。实现可在正确配置的部署环境使用,不代表已发布上线。 + +实现涉及 analytics 存储/上传器与平台会话、shared-contracts、现有 module-* 领域、spacetime-module 表/事务/migration、spacetime-client facade/绑定、api-server 用户及后台路由、admin-web 页面/权限。不更改 Game/Design Agent 埋点触发逻辑。 + +| 验收项 | 必须取得的证据 | +| --- | --- | +| 正常闭环 | 真实本地批次 → 测试主站数据库 → 确认 → 本地删除 → 后台可查相同 event_id | +| 原子与去重 | 首传、原批重传不增行;任一冲突导致本次新行全部不落库;确认丢失后重传成功 | +| 身份 | A 批次不能用 B 凭据或另一平台发送;匿名不认领;后台未授权读被拒绝 | +| 静默失败 | 断网、超时、非成功/无效确认时保留;业务调用和退出不等待上传;下一周期可恢复 | +| 清理边界 | 删除失败可重传;保留清理并发不误删其他批次;首次提交资格/run 身份保持;现有 7 天/20 MiB 不变 | +| 调度 | 15 分钟、单目录互斥、有界轮次、休眠不积压补跑、毒批次不阻塞本轮其他数据 | +| 后台查询 | 筛选、null/时间/属性显示、跨页稳定顺序及新数据刷新;数据量超过一页,不用截断结果冒充全量 | +| 回归 | 现有事件合同、store/goal/run 定向测试及主站 tracking 不受影响 | +| 工程检查 | schema 生成/检查、相关 Rust 测试与编译、后台类型检查、文档索引、编码和 diff 检查 | + +运行时 smoke 必须包含真实 SpacetimeDB 提交及后台页面;mock 请求成功不算完成入库验收。不需要为本轮重新调用付费模型或扩大 Agent 创作测试。 + +本版实施口径为:只接收已登录用户、一张事件表、原子整批提交、首轮等待 15 分钟、30 秒请求超时、20 批/2 分钟轮次预算、失败下周期重试、后台按发生时间倒序分页、服务端暂不自动过期。已确认的采集范围、上传周期、静默非阻塞和成功删除原则不变。 + +### 13.11 实现与验证记录(2026-09-21) + +实现沿用既有 12 类事件和采集入口。新增 `analytics/upload.rs`、`agc_tracking_event` 私有持久表、批量接收与后台查询接口,以及后台“客户端埋点”页;未部署生产。 + +| 验证 | 结果与边界 | +| --- | --- | +| 客户端 | `cargo +stable test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell analytics -- --test-threads=1`:56 项通过,真实服务联调用例默认 ignored;普通客户端程序同时编译通过。覆盖调度、锁、身份匹配、错误确认/503 保留、成功清理、近期事实去重和既有 store/goal/run 回归 | +| 领域与 API | module-runtime `agc_analytics` 4 项、api-server `agc_analytics` 3 项和后台权限 2 项通过;覆盖 12 类事件合同、非法字段、身份/origin、请求大小和后台权限 | +| 真实数据库 | `scripts/agc-analytics-smoke.mjs` 在独立临时 SpacetimeDB 2.8.3 上使用最终源码 WASM 通过首传、重传、属性键序等价、冲突整批回滚、保留首次入库值、用户筛选、稳定分页、分页期间插入与刷新、UTC/null 和服务身份限制 | +| 同一事件完整链路 | 手工执行 ignored `app::tests::agc_analytics_real_database_http_roundtrip`:真实客户端 Store 封存 JSONL → 生产上传器 → Axum HTTP → 真实数据库事务 → 确认后删除 → 后台接口查到相同 event_id/batch_id;同一事件重传只保留一行。用户认证使用测试夹具,未通过完整客户端 GUI 登录 | +| 后台静态验证 | 28 项定向测试、admin-web 类型检查与作用域 ESLint 通过 | +| 后台运行时 | 已编译 API 以临时 test 配置连接同一隔离数据库,`/healthz` 返回 200;真实浏览器登录、进入客户端埋点页、查看上传器生成记录、用户筛选及详情成功,时间、null 与 properties 显示正常。分页及刷新由真实数据库多页测试和页面定向测试验证 | +| 数据契约 | migration 注册、表目录与生成绑定已同步;schema 检查通过 145 张表,DDD 边界检查通过;未改变既有表字段 | + +编码、文档索引、作用域 Rust 格式与 diff 检查通过。`npm run dev:api-server` 编译通过,但本机启动脚本以既有短信配置覆盖测试环境变量,因缺少阿里云短信凭据而退出;页面 smoke 改为直接启动同一已编译 API,使用进程级 mock 认证/test 配置,未改本地配置文件。未验证完整客户端 GUI 登录、15 分钟墙钟等待、操作系统休眠及生产部署;调度周期与恢复通过定向状态测试验证。本轮不调用付费 Provider,不扩大 Agent 创作验收。 diff --git a/docs/technical/【需求来源】GameAgent埋点设计原始方案-2026-09-05.md b/docs/technical/【需求来源】GameAgent埋点设计原始方案-2026-09-05.md new file mode 100644 index 000000000..135e64bbc --- /dev/null +++ b/docs/technical/【需求来源】GameAgent埋点设计原始方案-2026-09-05.md @@ -0,0 +1,431 @@ +> 文档状态:`historical`(原始需求存档,仅用于来源追溯,不作为当前实施依据) + +归档日期:2026-09-21。下方保留原始正文;其中目标边界、异常会话、上传阶段等口径已由后续决策调整。当前合同见[客户端本地埋点与主站入库契约](./【技术方案】客户端本地埋点与主站入库契约-2026-09-21.md)。 + +# Game Agent 埋点设计方案|早期地基版 v1.0 + +状态:正式方案;待技术负责人拆解实施 +日期:2026-09-05 +适用产品:当前 Game Agent 桌面端编辑器 / 项目工作台 + +## 1. 方案目的 + +第一阶段不建设完整数据平台,也不一次性覆盖所有细粒度编辑动作。本方案只解决三个问题: + +1. 能不能知道用户进入了编辑器、创建或打开了哪个项目。 +2. 能不能把一次创作任务和 Agent 执行、项目变化、预览结果串起来。 +3. 能不能判断用户是否回到同一个项目继续创作。 + +本版本新增**编辑器前台时长**。它表示编辑器窗口处于前台/获得焦点的累计时间,不等于用户持续操作,也不等于真实编辑时长。本版本仍不统计编辑器活跃编辑时长和单个项目完整创作时长。 + +核心分析对象从旧版的“浏览/消费行为”切换为当前产品的“持续创作行为”。 + +## 2. 当前用户路径 + +```text +进入编辑器 + → 创建项目 / 打开已有项目 + → 提交一次创作任务 + → Agent 执行 + → 用户澄清、确认或追加指令 + → 文件、代码或资源发生有效变化 + → 项目产生 revision + → 预览就绪 + → 用户试玩或继续修改 + → 保存并退出 + → 之后重新打开同一项目继续创作 +``` + +第一阶段不要求把 Asset Canvas、Resource Editor、UI Editor 的每一个点击都拆成独立事件;先通过项目、任务、运行、revision 和前台时长关系判断用户是否真的在使用创作工作台。 + +## 3. 第一阶段事件清单 + +| 事件 | 所在环节 | 触发条件 | 可回答的问题 | +|---|---|---|---| +| `editor_session_start` | 进入编辑器 | 编辑器启动并完成可用初始化 | 有多少编辑器会话、用户从哪里开始 | +| `editor_session_end` | 离开编辑器 | 正常退出或明确关闭编辑器 | 正常结束的会话数;粗略会话时长 | +| `session_timeout` | 异常离开 | 超过约定时间没有心跳或前台状态 | 哪些会话可能异常中断;不可替代真实退出 | +| `editor_focus_start` | 进入前台 | 编辑器窗口获得焦点并处于可交互前台 | 用户把多少时间留在编辑器前台 | +| `editor_focus_end` | 离开前台 | 编辑器失去焦点、最小化或退出 | 前台时长区间和累计前台时长 | +| `project_create_success` | 创建项目 | 项目创建成功且拿到稳定 `project_id` | 创建项目人数、创建成功率 | +| `project_open` | 打开项目 | 项目被成功加载并进入工作区 | 回访项目数、项目复访率 | +| `creative_task_submit` | 发起创作 | 用户提交一次可执行的创作请求 | 用户发起了多少次真实创作任务 | +| `agent_run_completed` | Agent 执行结束 | 一次 Agent run 正常完成 | Agent 任务完成率、耗时、重试情况 | +| `agent_run_failed` | Agent 执行结束 | 一次 Agent run 明确失败 | 失败率、错误类型、失败后的修复行为 | +| `project_revision_created` | 产生有效变化 | 项目产生可识别的新 revision | Agent 或人工操作是否真正改变了项目 | +| `preview_ready` | 预览 | 当前项目预览达到可打开/可运行状态 | 有多少项目走到可预览;从任务到预览的转化 | +| `project_save` | 保存 | 用户或系统完成一次项目保存 | 用户是否保存成果;保存与继续创作关系 | + +说明:`project_revision_created` 是项目变化事件,不等于用户满意;`preview_ready` 是技术/产品中间成功,不等于用户完成试玩或认可结果。 + +## 4. 公共事件字段 + +每条正式产品事件使用统一 envelope。`properties` 只放该事件特有字段,不重复创造新的顶层 ID。 + +| 字段 | 类型 | 是否必填 | 字段说明 | +|---|---|---:|---| +| `event_id` | string | 是 | 单条事件唯一 ID,用于去重;建议 UUID | +| `event_name` | string | 是 | 事件英文名,如 `creative_task_submit` | +| `event_time` | datetime | 是 | 事件发生时间,统一 ISO 8601;不要只记录上传时间 | +| `user_id` | string/null | 条件必填 | 稳定用户标识;没有登录用户时明确为空,不用设备 ID 冒充 | +| `editor_session_id` | string | 是 | 一次编辑器打开到结束/超时的会话 ID | +| `project_id` | string/null | 条件必填 | 当前项目的稳定 ID;编辑器入口事件可以为空 | +| `creative_task_id` | string/null | 条件必填 | 一次用户创作任务的 ID;任务相关事件必须携带 | +| `agent_run_id` | string/null | 条件必填 | 一次 Agent 执行的 ID;仅 Agent run 相关事件填写 | +| `agent_turn_id` | string/null | 否 | Direct 的底层 turn 技术记录 ID;不能替代 `agent_run_id` | +| `status` | string/null | 条件必填 | `success`、`failed`、`timeout`、`cancelled` 等有限枚举 | +| `error_code` | string/null | 失败时必填 | 稳定错误码;不要把整段异常堆栈当作分析字段 | +| `source` | string | 是 | 事件来源,如 `editor`、`supervisor`、`direct`、`asset_canvas`、`ui_editor`、`manual`、`system` | +| `client_version` | string | 是 | 客户端/编辑器版本,用于按版本比较问题 | +| `properties` | object | 是 | 事件专属属性;允许为空对象 | + +### 4.1 ID 语义规则 + +- `editor_session_id`:编辑器会话,不能使用 Runtime 的 `sessionId`。 +- `creative_task_id`:用户的一次创作意图,可能包含多次 Agent run 和多轮追加指令。 +- `agent_run_id`:一次可独立判断成功/失败的 Agent 执行。Direct 需要单独生成,不能把 `clientTurnId` 直接当作 run ID。 +- `agent_turn_id`:底层技术 turn 记录,用于排错和技术审计,不直接作为产品任务口径。 +- `project_id`:项目身份,优先使用 manifest 中稳定的项目 ID,不使用路径作为长期主键。 + +## 5. 各事件最小字段 + +### 5.1 编辑器会话 + +`editor_session_start`: + +```text +entry_source +first_project_id +client_version +``` + +`editor_session_end` / `session_timeout`: + +```text +end_reason +session_duration_ms(若可可靠计算) +last_project_id +``` + +`editor_focus_start`: + +```text +focus_reason +active_project_id +``` + +`editor_focus_end`: + +```text +blur_reason +focus_duration_ms(若可可靠计算) +active_project_id +``` + +前台时长计算规则:同一 `editor_session_id` 下,将成对的 `editor_focus_start` 与 `editor_focus_end` 区间相加。正常退出时补齐最后一个区间;崩溃、断电或强制结束造成的未闭合区间必须标记为不完整,不估算为完整前台时长。 + +### 5.2 项目 + +`project_create_success`: + +```text +project_template_id(如有) +creation_source +``` + +`project_open`: + +```text +open_source +is_first_open +``` + +`project_revision_created`: + +```text +revision_id +revision_source +change_kind +files_changed_count(如可得) +``` + +`project_save`: + +```text +save_source +revision_id(如有) +``` + +`revision_source` 建议至少使用:`agent`、`asset_canvas`、`resource_editor`、`ui_editor`、`manual_edit`、`system_projection`。 + +### 5.3 创作任务与 Agent + +`creative_task_submit`: + +第一阶段只要求带上: + +```text +creative_task_id +project_id +source +``` + +不记录完整自然语言 prompt,也不要求第一阶段记录任务类型、输入方式、指令长度或附件信息。上述属性属于后续需要分析任务结构时再增加的可选字段。 + +`agent_run_completed` / `agent_run_failed`: + +```text +agent_type +run_source +duration_ms +retry_index +output_change_detected +revision_id(如已产生) +``` + +这里的 `agent_run_id` 只是一次 Agent 执行的技术关联 ID,不代表要记录每次执行的提示词内容。完成事件只能说明执行状态。`output_change_detected` 和后续 `project_revision_created` 用于区分“跑完了但没改变项目”。 + +### 5.4 预览 + +`preview_ready`: + +```text +preview_source +preview_version +ready_duration_ms(从触发构建到就绪,如可得) +``` + +第一阶段的 `preview_ready` 必须有明确技术触发条件,例如预览服务确认可访问或本地运行状态确认 ready;不能用“返回了 URL”直接代替。 + +## 6. 可以看的数据 + +### 6.1 创作漏斗 + +```text +编辑器进入 +→ 创建/打开项目 +→ 提交创作任务 +→ Agent 完成 +→ 项目产生 revision +→ 预览就绪 +→ 保存 +→ 后续重新打开项目 +``` + +可计算: + +- 编辑器到项目创建/打开转化率。 +- 项目到首次创作任务转化率。 +- 创作任务提交率:有项目用户中发生 `creative_task_submit` 的用户数 / 有项目用户数。 +- Agent 完成率:`agent_run_completed` /(`agent_run_completed` + `agent_run_failed`)。 +- 有效变化率:产生 `project_revision_created` 的任务数 / 创作任务数。 +- 预览到达率:产生 `preview_ready` 的任务数 / 创作任务数。 +- 保存率:产生 `project_save` 的项目用户数 / 产生 revision 的项目用户数。 + +### 6.2 创作行为 + +第一阶段可以看: + +- 用户每次会话提交多少创作任务。 +- 一个项目累计发生多少次任务、run 和 revision。 +- Agent 完成后是否真的产生项目变化。 +- 失败后是否重试、追加指令或重新打开项目。 +- 用户是一次性尝试,还是回到同一个项目继续创作。 +- 不同来源、版本、任务类型的成功率差异。 + +第一阶段暂时不能可靠看: + +- 编辑器活跃时长。 +- 完整项目创作总时长。 +- 用户是否满意或接受 Agent 结果。 +- 可靠的试玩成功率。 +- 仅凭这些事件直接得到 D1/D3/D7 留存,除非先确认 `user_id` 稳定且会话事件可靠落库。 + +## 7. 留存、LTV 与 ARPU 的当前口径 + +### 7.1 留存 + +当前先定义“创作者回访留存”,不定义泛产品活跃留存: + +```text +某 cohort 用户在 D0 发生 project_create_success 或 creative_task_submit +在 D1/D3/D7 再次发生 project_open、creative_task_submit 或 project_revision_created +``` + +公式: + +```text +Dk 创作者留存率 = D0 cohort 中在第 k 天至少发生一次创作相关事件的用户数 / D0 cohort 用户数 +``` + +前提是 `user_id` 稳定、事件可靠落库、日期按统一时区计算。当前代码审计结论是这些条件尚未全部确认,因此先把公式写入方案,不把结果宣称为已可用。 + +### 7.2 LTV 与单用户 ARPU + +埋点本身不能产生 LTV 或 ARPU。需要另外存在可靠的订单/扣费/退款事实表,并用 `user_id` 关联。 + +```text +ARPU = 统计周期内总收入 / 统计周期内活跃用户数 +``` + +如果看创作者商业价值,可另算: + +```text +创作者 ARPU = 统计周期内创作者收入 / 统计周期内发生创作行为的去重用户数 +``` + +```text +LTV = 用户在定义生命周期内的累计净收入 / cohort 用户数 +``` + +其中净收入应扣除退款、赠送额度和必要的渠道/支付成本,具体财务口径需要业务和财务确认。当前早期地基埋点只负责提供用户行为侧的 cohort 和创作分群,不负责替代收入系统。 + +## 8. 第一阶段建议看板 + +只建议做四组: + +1. **基础使用**:编辑器会话数、创建项目用户数、打开项目用户数、项目复访数。 +2. **创作漏斗**:任务提交、Agent 成功/失败、revision、preview ready、保存。 +3. **失败与恢复**:失败错误码、失败后重试率、失败后产生 revision 的比例。 +4. **回访创作**:D1/D3/D7 创作者回访,按任务类型、客户端版本、入口来源分组。 + +不要在第一阶段做几十个按钮点击看板,也不要把技术 JSONL、Runtime 状态和正式产品事件混成一张业务报表。 + +## 8.1 编辑时长的边界 + +本版本已经纳入前台时长事件,并区分三种时长: + +- **会话时长**:`editor_session_start` 到 `editor_session_end`,包含用户离开电脑或切到其他窗口的时间,不等于编辑时长。 +- **前台时长**:`editor_focus_start` 到 `editor_focus_end` 的累计时间。 +- **活跃编辑时长**:前台期间发生有效编辑、任务提交、预览、保存等行为的累计时间。 + +本版本只承诺会话时长和前台时长两个粗粒度指标;即使记录了 `focus_duration_ms`,也不能把它解释成编辑器活跃时长。 + +前台时长的解释限制: + +- 编辑器在前台但用户没有操作,仍会被计入。 +- 多窗口、系统锁屏、远程桌面或窗口状态异常时,可能出现边界误差。 +- 前台时长适合看停留和使用深度,不适合直接作为生产效率指标。 + +## 8.2 数据可靠性原则 + +已确认采用: + +- 事件先写本地短暂 outbox。 +- 网络恢复后自动重试上传。 +- 服务端或接收端使用 `event_id` 去重。 +- 关闭、断网、崩溃导致的可能丢数需要在技术验收中明确记录。 + +## 9. 当前已收口的产品口径 + +根据当前讨论,本版本采用以下口径: + +1. “创作成功”采用分层口径:`agent_run_completed` 表示执行完成,`project_revision_created` 表示项目发生有效变化,`preview_ready` 表示达到可预览状态;第一阶段不加入用户满意/接受结果事件。 +2. `creative_task_id` 表示用户一次创作意图;第一阶段不记录完整 Prompt,也不拆分 Prompt 内容。 +3. 第一阶段不加入 `task_type`、`input_mode`、指令长度和附件属性,避免早期方案过重。 +4. 事件允许先写本地短暂 outbox;网络恢复后自动重试;接收端按 `event_id` 去重。 +5. 创作者 D1/D3/D7 暂按 `project_open`、`creative_task_submit`、`project_revision_created` 作为回访事件,但只有在 `user_id` 和数据落库可靠后才正式出数。 +6. 编辑器前台时长纳入本版本;编辑器活跃编辑时长留到后续阶段。 + +## 9.1 仍需你确认的一项产品边界 + +只剩一个可能影响报表口径的问题:用户对同一个创作目标进行澄清或追加指令时,是否始终沿用同一个 `creative_task_id`。本方案默认沿用同一个任务 ID,只有用户明确开始新的创作目标时才生成新的任务 ID。 + +如果你没有特别异议,后续按这个默认口径执行即可;其余未收口项属于技术实现确认,不需要你继续定义。 + +## 10. 需要 master 开发对话回答的技术问题 + +开发对话只回答代码事实和实现成本: + +- 是否已有服务端 analytics 接收接口和正式数据落点。 +- 若没有,第一阶段事件落本地 outbox、现有服务端 tracking,还是其他已有入口。 +- Tauri/Rust 是否能统一生成 `event_id`、`event_time`、`project_id`、`client_version`。 +- 当前能否稳定取得 `user_id`。 +- Direct 是否需要新增独立 `agent_run_id`。 +- `editor_session_id`、`creative_task_id` 是否能在现有生命周期生成。 +- `project_revision_created` 和 `preview_ready` 的可靠触发点在哪里。 +- 是否需要本地缓存、重试和去重;哪些异常场景会丢数。 +- 每个第一阶段事件对应的代码文件、触发函数、测试方式和估算成本。 + +前台时长还需要确认: + +- Tauri 当前是否能可靠监听窗口 focus、blur、minimize、restore 和退出事件。 +- 窗口失焦后是否立即落一条 `editor_focus_end`,还是由统一会话管理器补齐。 +- 锁屏、系统休眠、崩溃和强制结束时,如何标记未闭合前台区间。 +- 多窗口场景是否存在;如存在,`editor_session_id` 是按应用实例还是按窗口生成。 + +## 11. 第一阶段验收标准 + +技术实现完成后,至少能够用一条测试链路证明: + +```text +editor_session_start +→ editor_focus_start +→ project_create_success +→ creative_task_submit +→ agent_run_completed 或 agent_run_failed +→ project_revision_created(如果确实发生变化) +→ preview_ready(如果确实达到 ready) +→ project_save +→ editor_focus_end +→ editor_session_end +``` + +并满足: + +- 同一次链路中的 ID 能串联。 +- 重复上传不会制造重复事件。 +- Agent 失败不会被记成成功。 +- Agent 完成但没有项目变化时,不能伪造 `project_revision_created`。 +- 预览 URL 返回但不可访问时,不能伪造 `preview_ready`。 +- 关闭、断网、崩溃等场景的丢数风险已明确记录。 +- 能按 `user_id`、`project_id`、`creative_task_id`、`client_version` 做基本筛选。 +- 正常切换到其他窗口时,能闭合前台区间并计算 `focus_duration_ms`。 +- 最小化、恢复、正常退出至少有明确的 focus 结束/重新开始行为。 +- 崩溃或强制结束不会伪造一条完整的前台时长;未闭合区间必须可识别。 + +## 12. 对抗性自检审查 + +### 12.1 是否把技术完成误当创作成功 + +没有。方案明确区分 Agent 执行完成、项目产生 revision、预览就绪。三者分别是执行层、项目变化层和可预览层,不代表用户满意。 + +### 12.2 是否把前台时长误当编辑时长 + +没有。事件名、字段名和看板解释统一使用“前台时长”;用户没有操作但窗口保持前台的时间会被计入,并在文档中标明限制。 + +### 12.3 是否记录得过细、造成第一阶段过重 + +当前 P0 不记录完整 Prompt、任务类型、输入方式、指令长度和附件属性。保留的是会话、项目、任务、Agent 状态、revision、预览、保存和前台区间,属于基础漏斗与创作回访所需的最小集合。 + +### 12.4 前台事件是否会制造大量噪音 + +会比核心业务事件多,但仍是成对的窗口状态事件,不是按键或鼠标级事件。前台事件只用于时长聚合,不作为单独的产品成功指标。 + +### 12.5 断网、退出和崩溃是否会导致数据不可信 + +不能完全消除,但通过本地 outbox、重试和 `event_id` 去重降低风险。未闭合的 focus 区间必须标记不完整,不把估算时间写成事实。 + +### 12.6 是否能直接计算 D1/D3/D7、LTV、ARPU + +不能直接保证。留存依赖稳定 `user_id` 和可靠落库;LTV/ARPU 还依赖订单、扣费、退款和净收入事实表。当前方案只提供行为 cohort 和创作者分群基础。 + +### 12.7 是否仍有未收口问题 + +有,但已经集中到技术实现确认,不影响产品方案定稿: + +- 当前服务端 analytics 接口和正式落点是否存在。 +- Direct 是否能在现有生命周期生成独立 `agent_run_id`。 +- `project_revision_created` 和 `preview_ready` 的实际可靠触发点。 +- Tauri focus/blur 等窗口事件在当前多窗口、锁屏、休眠和崩溃场景下的行为。 +- `user_id` 是否稳定,以及匿名用户后续是否需要身份合并。 + +这些不是继续扩展事件的理由,而是技术负责人需要逐项确认的实现事实。 + +## 13. 当前结论 + +这套早期地基版埋点足以回答:用户有没有进入编辑器、在前台停留了多久、有没有创建项目、有没有发起创作、Agent 是否完成、项目是否真的变化、是否走到预览、是否回到同一项目继续创作。 + +它暂时不能回答:用户是否喜欢结果、是否完成试玩、编辑器真正活跃了多久、完整 LTV/ARPU,以及在用户身份和数据落库尚未确认前的可靠 D1/D3/D7 留存。 + +因此本方案的产品层已经基本收口。下一步是把第 10 节交给最新 master 开发对话核实,再由技术负责人拆成最小实现任务;如果技术核实发现 focus/blur 触发不稳定,只需要调整前台时长实现方式或降级为会话时长,不需要推翻整个埋点方案。 diff --git a/docs/【协作规范】文档生命周期与现状索引-2026-09-12.md b/docs/【协作规范】文档生命周期与现状索引-2026-09-12.md index ec52ae65f..62302d25d 100644 --- a/docs/【协作规范】文档生命周期与现状索引-2026-09-12.md +++ b/docs/【协作规范】文档生命周期与现状索引-2026-09-12.md @@ -51,6 +51,8 @@ 以下文档明确是历史记录、实施记录、专利材料或问题记录: +- `docs/technical/【需求来源】GameAgent埋点设计原始方案-2026-09-05.md` + - `docs/【实施记录】SFX生成优化V2.0T6测试与发布门禁-2026-08-07.md` - `docs/【专利交底】一种极低成本快速生成高质量2D小游戏高一致性美术素材的解决方案-2026-05-25.md` - `docs/technical/【问题记录】DirectProject客户端Skill自然语言触发能力缺口-2026-09-01.md` diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 70623b09a..adb2590e5 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -994,6 +994,14 @@ RPG 创作入口的配置 ID 是 `rpg`,当前 `visible=true`、`open=true`。 - 源码:`server-rs/crates/spacetime-module/src/runtime/profile.rs` - 写入:由单条或批量 tracking procedure 在同一事务中随 `tracking_event` 更新,作为运营查询和个人任务进度的聚合投影。 +### `agc_tracking_event` + +- 普通私有持久表,保存客户端既有 12 类事件;以 `event_id` 为主键,不进入主站任务奖励或每日聚合。 +- 字段保留本地事件合同:schema_version、event_name、event_time、user_id、editor_session_id、project_id、creative_task_id、agent_run_id、agent_turn_id、status、error_code、source、client_version、properties_json;另存首次接收的 batch_id 和 received_at。 +- `upload_agc_analytics_batch` 只允许受信任服务身份调用,整批原子提交;相同事件重传不改首次批次和接收时间,内容冲突则整批回滚。 +- `list_agc_tracking_events` 经后台 BFF 调用,以 received_at 索引及用户/事件复合索引扫描候选(nullable 项目字段不建索引),在数据库事务中保留有界最新页;按 (received_at, event_id) 倒序及固定快照游标分页。 +- 不上传正文、快照、路径或凭据。字段/接口详见 [客户端本地埋点与主站入库契约](technical/【技术方案】客户端本地埋点与主站入库契约-2026-09-21.md) 第 13 节。 + ### `tracking_event` - Rust 结构体:`TrackingEvent` diff --git a/scripts/agc-analytics-smoke.mjs b/scripts/agc-analytics-smoke.mjs new file mode 100644 index 000000000..d0c8504c5 --- /dev/null +++ b/scripts/agc-analytics-smoke.mjs @@ -0,0 +1,410 @@ +#!/usr/bin/env node +// 仅在临时 standalone 验证客户端埋点;显式传入带测试 bootstrap hash 的 WASM。 +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { once } from 'node:events'; +import { access, chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import { fileURLToPath } from 'node:url'; + +import { createSpacetimeWebIdentity } from './spacetime-migration-common.mjs'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const database = 'agc-analytics-smoke'; +const testSecret = 'a'.repeat(64); // 公开测试值,绝不能用于正式部署。 +const sensitive = [testSecret]; +const redact = (value) => + sensitive.reduce( + (text, secret) => text.replaceAll(secret, '[REDACTED]'), + String(value), + ); + +function command(args) { + return new Promise((resolve, reject) => { + const child = spawn('spacetime', args, { + cwd: root, + windowsHide: true, + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let output = ''; + for (const stream of [child.stdout, child.stderr]) + stream.on('data', (chunk) => { + output = (output + chunk).slice(-16000); + }); + const timer = setTimeout(() => { + child.kill(); + reject(new Error('SpacetimeDB command timed out')); + }, 120000); + child.once('error', (error) => { + clearTimeout(timer); + reject(error); + }); + child.once('exit', (code) => { + clearTimeout(timer); + if (code === 0) resolve(output); + else reject(new Error(redact(output))); + }); + }); +} + +async function localPort() { + const listener = net.createServer(); + listener.listen(0, '127.0.0.1'); + await once(listener, 'listening'); + const port = listener.address().port; + await new Promise((resolve) => listener.close(resolve)); + return port; +} + +async function call(url, token, name, input) { + const response = await fetch(`${url}/v1/database/${database}/call/${name}`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify([input]), + signal: AbortSignal.timeout(30000), + }); + const text = await response.text(); + assert(response.ok, `${name}: HTTP ${response.status}: ${redact(text)}`); + return JSON.parse(text); +} + +function ok(result) { + assert.equal(result[0], 0, `Expected Ok, got ${JSON.stringify(result)}`); + return JSON.parse(result[1]); +} + +const event = (user = 'smoke-user-a') => ({ + schema_version: 1, + event_id: randomUUID(), + event_name: 'editor_session_start', + event_time: '2026-09-21T12:00:00.123Z', + user_id: user, + editor_session_id: randomUUID(), + project_id: null, + creative_task_id: null, + agent_run_id: null, + agent_turn_id: null, + status: 'success', + error_code: null, + source: 'editor', + client_version: 'smoke-1', + properties: { entry_source: 'direct_launch', first_project_id: null }, +}); +const batch = (events) => ({ + schema_version: 1, + batch_id: randomUUID(), + destination_origin: 'https://analytics-smoke.invalid', + user_id: events[0].user_id, + events, +}); + +function pricingInput() { + const tiered = (model, unit, keys) => ({ + model, + unit, + price: [1, []], + prices: keys.map((key) => ({ key, price: 1 })), + }); + return { + admin_user_id: 'smoke-bootstrap', + updated_at_micros: 1, + bootstrap_secret: testSecret, + models: [ + tiered('gemini-3.1-flash-image-preview', 'perGeneration', [ + '0.5K', + '1K', + '2K', + ]), + tiered('gpt-image-2', 'perGeneration', ['1K', '2K']), + ...['audio1.0', 'eleven_text_to_sound_v2', 'chirp-v5'].map((model) => ({ + model, + unit: 'perGeneration', + price: [0, 1], + prices: [], + })), + ...[ + 'seedance2.0-fast', + 'seedance2.0', + 'kling3.0', + 'kling3.0-omni', + 'veo3.1', + 'veo3.1-fast', + ].map((model) => tiered(model, 'perSecond', ['480p', '720p', '1080p'])), + ], + }; +} + +async function verify(url, serviceToken, outsiderToken) { + const upload = async (payload) => + call( + url, + serviceToken, + 'upload_agc_analytics_batch', + JSON.stringify(payload), + ); + const list = async (query = {}) => + ok( + await call( + url, + serviceToken, + 'list_agc_tracking_events', + JSON.stringify(query), + ), + ); + const first = batch([event(), event(), event()]); + first.events[0].event_time = '2026-09-21T12:01:00.123Z'; + assert.deepEqual(ok(await upload(first)), { + acknowledged_batch_ids: [first.batch_id], + event_count: 3, + }); + const initial = await list(); + assert.equal(initial.entries.length, 3); + ok(await upload(first)); + assert.deepEqual( + await list(), + initial, + 'Replay must preserve rows, first batch ID and receipt time', + ); + const reordered = structuredClone(first); + reordered.events[0].properties = { + first_project_id: null, + entry_source: 'direct_launch', + }; + ok(await upload(reordered)); + assert.deepEqual( + await list(), + initial, + 'JSON property order must not cause conflict', + ); + + const rollbackCandidate = event(); + const conflict = batch([ + rollbackCandidate, + { ...first.events[0], client_version: 'changed' }, + ]); + const rejected = await upload(conflict); + assert.equal(rejected[0], 1); + assert.equal(rejected[1], 'agc_event_conflict'); + assert.deepEqual( + await list(), + initial, + 'Earlier insert in failed batch must roll back', + ); + const userB = batch([event('smoke-user-b')]); + ok(await upload(userB)); + assert.equal( + (await list({ userId: 'smoke-user-b' })).entries[0].eventId, + userB.events[0].event_id, + ); + + const page1 = await list({ limit: 2 }); + assert(page1.nextCursor); + const late = batch([event()]); + late.events[0].event_time = '2026-09-21T11:00:00.123Z'; + ok(await upload(late)); + const seen = [...page1.entries]; + let cursor = page1.nextCursor; + while (cursor) { + const page = await list({ limit: 2, cursor }); + seen.push(...page.entries); + cursor = page.nextCursor; + } + assert.equal(seen.length, 4); + assert.equal(new Set(seen.map((row) => row.eventId)).size, 4); + assert( + !seen.some((row) => row.eventId === late.events[0].event_id), + 'Cursor snapshot must exclude later insert', + ); + for (let index = 1; index < seen.length; index++) { + const previous = seen[index - 1]; + const current = seen[index]; + assert( + previous.eventTime > current.eventTime || + (previous.eventTime === current.eventTime && + previous.eventId > current.eventId), + 'Stable event time/event ID descending order', + ); + } + const refreshed = await list(); + assert.equal(refreshed.entries.length, 5); + assert.equal(refreshed.entries[0].eventId, first.events[0].event_id); + assert.equal(refreshed.entries.at(-1).eventId, late.events[0].event_id); + assert.equal(refreshed.entries[0].projectId, null); + assert.equal(refreshed.entries[0].eventTime, '2026-09-21T12:01:00.123Z'); + for (const [name, input] of [ + ['upload_agc_analytics_batch', first], + ['list_agc_tracking_events', {}], + ]) { + const unauthorized = await call( + url, + outsiderToken, + name, + JSON.stringify(input), + ); + assert.equal(unauthorized[0], 1, 'Nonservice identity must be rejected'); + assert.match(unauthorized[1], /无权/); + } + console.log( + '[agc-analytics-smoke] PASS: first/replay, JSON order, atomic rollback, user filter, stable snapshot pagination, refresh, null/time, service authorization. 5 rows persisted.', + ); +} + +async function main() { + const wasm = process.env.GENARRATIVE_AGC_ANALYTICS_SMOKE_WASM; + assert( + wasm, + 'Set GENARRATIVE_AGC_ANALYTICS_SMOKE_WASM to an isolated test WASM compiled with SHA256(a repeated 64 times) bootstrap hash.', + ); + await access(wasm); + const version = await command(['--version']); + assert( + version.includes('version 2.8.3') && + version.includes('8e410d2842147bd8e5a32a9589cc00c19f7478e2'), + ); + const temp = await mkdtemp( + path.join(os.tmpdir(), 'genarrative-agc-analytics-smoke-'), + ); + let standalone; + try { + const port = await localPort(); + const url = `http://127.0.0.1:${port}`; + standalone = spawn( + 'spacetime', + [ + 'start', + '--data-dir', + path.join(temp, 'data'), + '--listen-addr', + `127.0.0.1:${port}`, + '--non-interactive', + ], + { windowsHide: true, shell: false, stdio: ['ignore', 'pipe', 'pipe'] }, + ); + let logs = ''; + for (const stream of [standalone.stdout, standalone.stderr]) + stream.on('data', (chunk) => { + logs = (logs + chunk).slice(-8000); + }); + const deadline = Date.now() + 30000; + for (;;) { + assert(standalone.exitCode === null, redact(logs)); + try { + if ( + (await fetch(`${url}/v1/ping`, { signal: AbortSignal.timeout(1000) })) + .ok + ) + break; + } catch { + /* 等待启动 */ + } + assert( + Date.now() < deadline, + `Standalone startup timeout: ${redact(logs)}`, + ); + await delay(200); + } + const owner = await createSpacetimeWebIdentity({ + database, + serverUrl: url, + }); + const service = await createSpacetimeWebIdentity({ + database, + serverUrl: url, + }); + sensitive.push(owner.token, service.token); + const config = path.join(temp, 'cli.toml'); + await command(['--config-path', config, 'login', '--token', owner.token]); + await chmod(config, 0o600); + await command([ + '--config-path', + config, + 'publish', + database, + '--server', + url, + '--yes=all', + '--no-config', + '--bin-path', + path.resolve(wasm), + ]); + const bootstrap = await call( + url, + service.token, + 'initialize_editor_generation_pricing_config_if_missing_and_return', + pricingInput(), + ); + assert.equal( + bootstrap[0], + true, + `Pricing service bootstrap failed: ${redact(JSON.stringify(bootstrap))}`, + ); + await verify(url, service.token, owner.token); + if (process.env.AGC_ANALYTICS_SMOKE_KEEP === '1') { + const stopFile = path.join(temp, 'stop'); + const contextPath = path.join(temp, 'connection.json'); + await writeFile( + contextPath, + JSON.stringify({ + serverUrl: url, + database, + token: service.token, + operatorToken: owner.token, + configPath: config, + stopFile, + }), + { mode: 0o600 }, + ); + console.log( + `[agc-analytics-smoke] Integration connection file: ${contextPath}`, + ); + const stopDeadline = Date.now() + 45 * 60 * 1000; + while (Date.now() < stopDeadline) { + try { + await access(stopFile); + break; + } catch { + /* 联调结束后由调用方创建 stop 文件 */ + } + await delay(1000); + } + } + } finally { + if (standalone && standalone.exitCode === null) { + if (process.platform === 'win32') { + const killer = spawn( + 'taskkill', + ['/PID', String(standalone.pid), '/T', '/F'], + { windowsHide: true, stdio: 'ignore' }, + ); + await once(killer, 'exit'); + } else { + standalone.kill('SIGTERM'); + } + if (standalone.exitCode === null) await once(standalone, 'exit'); + } + // 仅删除本脚本 mkdtemp 创建的系统临时子目录。 + assert( + path.dirname(path.resolve(temp)) === path.resolve(os.tmpdir()) && + path.basename(temp).startsWith('genarrative-agc-analytics-smoke-'), + ); + await rm(temp, { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 200, + }); + } +} + +main().catch((error) => { + console.error(redact(error.stack ?? error)); + process.exitCode = 1; +}); diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index 8fee3df57..ac57523c6 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -2916,9 +2916,12 @@ version = "0.1.0" dependencies = [ "serde", "serde_json", + "shared-contracts", "shared-kernel", "spacetimedb", "time", + "url", + "uuid", ] [[package]] diff --git a/server-rs/crates/api-server/src/admin.rs b/server-rs/crates/api-server/src/admin.rs index f66bb1b3a..2f9838531 100644 --- a/server-rs/crates/api-server/src/admin.rs +++ b/server-rs/crates/api-server/src/admin.rs @@ -2195,6 +2195,7 @@ fn admin_permission_requirement(_method: &Method, path: &str) -> AdminPermission "/admin/api/external-api-keys" => AnyTab(&["tables"]), "/admin/api/debug/http" => AnyTab(&["debug"]), "/admin/api/tracking/events" => AnyTab(&["tracking"]), + "/admin/api/agc/tracking-events" => AnyTab(&["agc-tracking"]), "/admin/api/tracking/event-keys" => AnyTab(&["tracking", "tasks"]), "/admin/api/error-reports" => AnyTab(&["error-reports"]), "/admin/api/project-snapshots" => AnyTab(&["project-snapshots"]), @@ -2222,6 +2223,7 @@ fn admin_permission_requirement(_method: &Method, path: &str) -> AdminPermission "/admin/api/profile/users/detail" => AnyTab(&[ "tables", "tracking", + "agc-tracking", "recharge-orders", "editor-showcase", "editor-assets", @@ -6883,6 +6885,26 @@ mod tests { #[test] fn admin_tab_permissions_cover_shared_and_sensitive_routes() { + assert!( + enforce_admin_request_permission( + "member", + &["agc-tracking".into()], + &[], + &Method::GET, + "/admin/api/profile/users/detail" + ) + .is_ok() + ); + assert!( + enforce_admin_request_permission( + "member", + &["tracking".into()], + &[], + &Method::GET, + "/admin/api/agc/tracking-events" + ) + .is_err() + ); assert!( enforce_admin_request_permission( "member", @@ -6913,6 +6935,11 @@ mod tests { ("tables", Method::GET, "/admin/api/database/tables"), ("debug", Method::POST, "/admin/api/debug/http"), ("tracking", Method::GET, "/admin/api/tracking/events"), + ( + "agc-tracking", + Method::GET, + "/admin/api/agc/tracking-events", + ), ("error-reports", Method::GET, "/admin/api/error-reports"), ( "project-snapshots", diff --git a/server-rs/crates/api-server/src/agc_analytics.rs b/server-rs/crates/api-server/src/agc_analytics.rs new file mode 100644 index 000000000..b69542f28 --- /dev/null +++ b/server-rs/crates/api-server/src/agc_analytics.rs @@ -0,0 +1,226 @@ +//! 客户端埋点只在数据库提交后确认;不使用普通路由 tracking outbox。 +use axum::{ + Json, Router, + extract::{DefaultBodyLimit, Extension, Query, State}, + http::StatusCode, + middleware, + routing::{get, post}, +}; +use serde_json::Value; +use shared_contracts::{admin::AdminAgcTrackingEventListQuery, agc_analytics::AgcAnalyticsBatch}; +use spacetime_client::SpacetimeClientError; + +use crate::{ + admin::{AuthenticatedAdmin, require_admin_auth}, + api_response::json_success_body, + auth::{AuthenticatedAccessToken, require_bearer_auth}, + http_error::AppError, + request_context::RequestContext, + state::AppState, +}; + +const MAX_REQUEST_BYTES: usize = 2 * 1024 * 1024; + +pub fn router(state: AppState) -> Router { + Router::new() + .route( + "/api/agc/analytics/batches", + post(upload_batch) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )) + .layer(DefaultBodyLimit::max(MAX_REQUEST_BYTES)), + ) + .route( + "/admin/api/agc/tracking-events", + get(list_events).route_layer(middleware::from_fn_with_state(state, require_admin_auth)), + ) +} + +async fn upload_batch( + State(state): State, + Extension(context): Extension, + Extension(auth): Extension, + payload: Result, axum::extract::rejection::JsonRejection>, +) -> Result, AppError> { + let Json(raw) = payload.map_err(|error| { + let status = if error.status() == StatusCode::PAYLOAD_TOO_LARGE { + StatusCode::PAYLOAD_TOO_LARGE + } else { + StatusCode::BAD_REQUEST + }; + AppError::from_status(status).with_message("客户端埋点请求格式无效") + })?; + // serde 也能将位置数组解成 struct;上传合同只接受命名字段的 JSON 对象。 + if !raw.is_object() + || !raw + .get("events") + .and_then(Value::as_array) + .is_some_and(|events| events.iter().all(Value::is_object)) + { + return Err(AppError::from_status(StatusCode::BAD_REQUEST) + .with_message("客户端埋点必须使用事件对象")); + } + let batch: AgcAnalyticsBatch = serde_json::from_value(raw).map_err(|_| { + AppError::from_status(StatusCode::BAD_REQUEST).with_message("客户端埋点字段不符合合同") + })?; + validate_subject_and_origin( + &batch, + auth.claims().user_id(), + &state.config.agc_analytics_origin, + )?; + module_runtime::agc_analytics::validate_agc_analytics_batch(&batch).map_err(|error| { + let status = if error == "events_too_large" { + StatusCode::PAYLOAD_TOO_LARGE + } else { + StatusCode::BAD_REQUEST + }; + AppError::from_status(status).with_message("客户端埋点批次格式不符合合同") + })?; + let acknowledgement = state + .spacetime_client() + .upload_agc_analytics_batch(batch) + .await + .map_err(map_database_error)?; + Ok(json_success_body(Some(&context), acknowledgement)) +} + +async fn list_events( + State(state): State, + Extension(context): Extension, + Extension(_admin): Extension, + Query(query): Query, +) -> Result, AppError> { + module_runtime::agc_analytics::validate_agc_tracking_query(&query).map_err(|_| { + AppError::from_status(StatusCode::BAD_REQUEST).with_message("客户端埋点查询参数无效") + })?; + let payload = state + .spacetime_client() + .list_agc_tracking_events(query) + .await + .map_err(map_database_error)?; + Ok(json_success_body(Some(&context), payload)) +} + +fn validate_subject_and_origin( + batch: &AgcAnalyticsBatch, + user_id: &str, + expected_origin: &str, +) -> Result<(), AppError> { + // 公开地址必须来自部署配置;客户端 body 和 Host 头均不是配置来源。 + let origin = url::Url::parse(expected_origin).ok().filter(|url| { + let loopback = url.host_str().is_some_and(|host| { + host == "localhost" + || host + .trim_matches(['[', ']']) + .parse::() + .is_ok_and(|ip| ip.is_loopback()) + }); + (url.scheme() == "https" || (url.scheme() == "http" && loopback)) + && url.host_str().is_some() + && url.username().is_empty() + && url.password().is_none() + && url.origin().ascii_serialization() == expected_origin + }); + if origin.is_none() { + return Err(AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) + .with_message("客户端埋点接收地址尚未配置")); + } + if batch.user_id != user_id + || batch.destination_origin != expected_origin + || batch + .events + .iter() + .any(|event| event.user_id.as_deref() != Some(user_id)) + { + return Err( + AppError::from_status(StatusCode::FORBIDDEN).with_message("客户端埋点身份或平台不匹配") + ); + } + Ok(()) +} + +fn map_database_error(error: SpacetimeClientError) -> AppError { + match error { + SpacetimeClientError::Procedure(message) if message == "agc_event_conflict" => { + AppError::from_status(StatusCode::CONFLICT).with_message("客户端埋点事件 ID 内容冲突") + } + SpacetimeClientError::Procedure(message) + if matches!(message.as_str(), "invalid_agc_query" | "invalid_agc_cursor") => + { + AppError::from_status(StatusCode::BAD_REQUEST).with_message("客户端埋点查询参数无效") + } + // 不回显数据库或上传内容,超时/未知提交结果不产生确认。 + _ => AppError::from_status(StatusCode::SERVICE_UNAVAILABLE) + .with_message("客户端埋点数据服务暂不可用"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn batch() -> AgcAnalyticsBatch { + AgcAnalyticsBatch { + schema_version: 1, + batch_id: uuid::Uuid::new_v4().to_string(), + destination_origin: "https://dev.genarrative.world".into(), + user_id: "user-a".into(), + events: vec![], + } + } + + #[test] + fn agc_analytics_subject_and_deployment_are_both_required() { + let batch = batch(); + assert!(validate_subject_and_origin(&batch, "user-a", &batch.destination_origin).is_ok()); + for (user, origin, expected) in [ + ( + "user-b", + "https://dev.genarrative.world", + StatusCode::FORBIDDEN, + ), + ( + "user-a", + "https://www.genarrative.world", + StatusCode::FORBIDDEN, + ), + ("user-a", "", StatusCode::SERVICE_UNAVAILABLE), + ( + "user-a", + "https://dev.genarrative.world/path", + StatusCode::SERVICE_UNAVAILABLE, + ), + ( + "user-a", + "http://dev.genarrative.world", + StatusCode::SERVICE_UNAVAILABLE, + ), + ] { + assert_eq!( + validate_subject_and_origin(&batch, user, origin) + .unwrap_err() + .status_code(), + expected + ); + } + let mut local = batch; + local.destination_origin = "http://127.0.0.1:8082".into(); + assert!(validate_subject_and_origin(&local, "user-a", &local.destination_origin).is_ok()); + } + + #[test] + fn agc_analytics_database_failure_never_becomes_acknowledgement() { + for (message, status) in [ + ("agc_event_conflict", StatusCode::CONFLICT), + ("invalid_agc_cursor", StatusCode::BAD_REQUEST), + ("unknown", StatusCode::SERVICE_UNAVAILABLE), + ] { + assert_eq!( + map_database_error(SpacetimeClientError::Procedure(message.into())).status_code(), + status + ); + } + } +} diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 5920b4594..88b2d90f9 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -57,6 +57,7 @@ pub fn build_router(state: AppState) -> Router { .merge(modules::raw::router(state.clone())) .merge(modules::project_snapshots::router(state.clone())) .merge(crate::error_reports::router(state.clone())) + .merge(crate::agc_analytics::router(state.clone())) .route( "/api/profile/recharge/wechat/notify", post(handle_wechat_pay_notify), @@ -257,6 +258,234 @@ mod tests { const TEST_PASSWORD: &str = "secret123"; const INTERNAL_TEST_SECRET: &str = "test-internal-secret"; + /// 需 scripts/agc-analytics-smoke.mjs 创建的隔离数据库;认证用户为测试夹具, + /// HTTP、facade、数据库事务和后台查询均走正式实现。 + #[tokio::test] + #[ignore = "requires isolated AGC analytics smoke database"] + async fn agc_analytics_real_database_http_roundtrip() { + let path = + std::env::var("AGC_ANALYTICS_SMOKE_CONNECTION").expect("isolated connection file"); + let connection: Value = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); + let db_url = connection["serverUrl"].as_str().unwrap(); + assert!( + db_url.starts_with("http://127.0.0.1:"), + "only isolated local database" + ); + assert!( + connection["database"] + .as_str() + .unwrap() + .starts_with("agc-analytics-smoke") + ); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let origin = format!("http://{}", listener.local_addr().unwrap()); + let state = AppState::new(AppConfig { + spacetime_server_url: db_url.into(), + spacetime_database: connection["database"].as_str().unwrap().into(), + spacetime_token: Some(connection["token"].as_str().unwrap().into()), + agc_analytics_origin: origin.clone(), + admin_username: Some("analytics-smoke-owner".into()), + admin_password: Some("analytics-smoke-fixture-password".into()), + ..AppConfig::default() + }) + .unwrap(); + let user = seed_phone_user_with_password(&state, "13800138001", TEST_PASSWORD).await; + let token = sign_test_user_token(&state, &user, "analytics-live-db"); + let app = build_router(state); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let client = Client::builder() + .no_proxy() + .timeout(std::time::Duration::from_secs(30)) + .build() + .unwrap(); + let id = uuid::Uuid::new_v4().to_string(); + let batch_id = uuid::Uuid::new_v4().to_string(); + let payload = serde_json::json!({ + "schema_version":1, "batch_id":batch_id, "destination_origin":origin, "user_id":user.id, + "events":[{ + "schema_version":1, "event_id":id, "event_name":"editor_session_start", + "event_time":"2026-09-21T00:00:00.000Z", "user_id":user.id, + "editor_session_id":uuid::Uuid::new_v4().to_string(), "project_id":null, + "creative_task_id":null, "agent_run_id":null, "agent_turn_id":null, + "status":"success", "error_code":null, "source":"editor", "client_version":"smoke", + "properties":{"entry_source":"direct_launch", "first_project_id":null} + }] + }); + for _ in 0..2 { + let response = client + .post(format!("{origin}/api/agc/analytics/batches")) + .bearer_auth(&token) + .header("x-genarrative-response-envelope", "1") + .json(&payload) + .send() + .await + .unwrap(); + assert_eq!(response.status().as_u16(), 200); + let ack: Value = response.json().await.unwrap(); + assert_eq!(ack["ok"], true); + assert_eq!( + ack["data"]["acknowledged_batch_ids"], + serde_json::json!([batch_id]) + ); + assert_eq!(ack["data"]["event_count"], 1); + } + let response = client.post(format!("{origin}/admin/api/login")) + .json(&serde_json::json!({"username":"analytics-smoke-owner", "password":"analytics-smoke-fixture-password"})) + .send().await.unwrap(); + assert_eq!(response.status().as_u16(), 200); + let login: Value = response.json().await.unwrap(); + let admin_token = login["token"].as_str().unwrap(); + let response = client + .get(format!("{origin}/admin/api/agc/tracking-events")) + .bearer_auth(admin_token) + .query(&[("userId", user.id.as_str())]) + .send() + .await + .unwrap(); + assert_eq!(response.status().as_u16(), 200); + let rows: Value = response.json().await.unwrap(); + let matches = rows["entries"] + .as_array() + .unwrap() + .iter() + .filter(|row| row["eventId"] == id) + .count(); + assert_eq!( + matches, 1, + "two uploads produce one persisted row in admin read" + ); + // 启动客户端现役 store/upload 集成用例,关联实际磁盘批次与同一个主站数据库。 + let bridge_dir = tempfile::tempdir().unwrap(); + let bridge = bridge_dir.path().join("bridge.json"); + let result_path = bridge_dir.path().join("result.json"); + std::fs::write( + &bridge, + serde_json::to_vec(&serde_json::json!({ + "origin":origin, "userId":user.id, "accessToken":token, "resultPath":result_path + })) + .unwrap(), + ) + .unwrap(); + let executable = std::env::var("AGC_ANALYTICS_SMOKE_CLIENT_BINARY") + .expect("compiled client test binary"); + let mut child = tokio::process::Command::new(executable); + child + .args([ + "analytics::store::tests::analytics_live_api_upload_cleans_confirmed_batch", + "--ignored", + "--exact", + "--test-threads=1", + ]) + .env("AGC_ANALYTICS_SMOKE_BRIDGE", &bridge) + .kill_on_drop(true); + #[cfg(windows)] + child.creation_flags(0x08000000); + let output = tokio::time::timeout(std::time::Duration::from_secs(90), child.output()) + .await + .unwrap() + .unwrap(); + assert!( + output.status.success(), + "client live upload fixture failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let uploaded: Value = serde_json::from_slice( + &std::fs::read(result_path) + .expect("client result required, zero matched tests is not success"), + ) + .unwrap(); + let response = client + .get(format!("{origin}/admin/api/agc/tracking-events")) + .bearer_auth(admin_token) + .query(&[("userId", user.id.as_str())]) + .send() + .await + .unwrap(); + assert_eq!(response.status().as_u16(), 200); + let rows: Value = response.json().await.unwrap(); + assert_eq!( + rows["entries"] + .as_array() + .unwrap() + .iter() + .filter(|row| row["eventId"] == uploaded["eventId"] + && row["batchId"] == uploaded["batchId"]) + .count(), + 1 + ); + server.abort(); + } + + #[tokio::test] + async fn agc_analytics_upload_enforces_auth_identity_contract_and_body_limit() { + let state = AppState::new(AppConfig { + agc_analytics_origin: "https://dev.genarrative.world".into(), + admin_username: Some("analytics-smoke-owner".into()), + admin_password: Some("analytics-smoke-fixture-password".into()), + ..AppConfig::default() + }) + .expect("state"); + let user = seed_phone_user_with_password(&state, "13800138001", TEST_PASSWORD).await; + let token = sign_test_user_token(&state, &user, "analytics-upload-test"); + let app = build_router(state); + let payload = serde_json::json!({ + "schema_version":1, "batch_id":uuid::Uuid::new_v4().to_string(), + "destination_origin":"https://dev.genarrative.world", "user_id":user.id, + "events": [{ + "schema_version":1, "event_id":uuid::Uuid::new_v4().to_string(), + "event_name":"editor_session_start", "event_time":"2026-09-21T00:00:00.000Z", + "user_id":user.id, "editor_session_id":uuid::Uuid::new_v4().to_string(), + "project_id":null, "creative_task_id":null, "agent_run_id":null, + "agent_turn_id":null, "status":"success", "error_code":null, + "source":"editor", "client_version":"1.0.0", + "properties":{"entry_source":"direct_launch", "first_project_id":null} + }] + }); + let mut wrong_user = payload.clone(); + wrong_user["user_id"] = "other-user".into(); + let mut wrong_origin = payload.clone(); + wrong_origin["destination_origin"] = "https://www.genarrative.world".into(); + let mut bad_contract = payload.clone(); + bad_contract["events"][0]["properties"]["prompt"] = "not collected".into(); + for (body, authenticated, expected) in [ + (payload.to_string(), false, StatusCode::UNAUTHORIZED), + (wrong_user.to_string(), true, StatusCode::FORBIDDEN), + (wrong_origin.to_string(), true, StatusCode::FORBIDDEN), + (bad_contract.to_string(), true, StatusCode::BAD_REQUEST), + ("{".into(), true, StatusCode::BAD_REQUEST), + ( + " ".repeat(2 * 1024 * 1024 + 1), + true, + StatusCode::PAYLOAD_TOO_LARGE, + ), + ] { + let mut request = Request::builder() + .method("POST") + .uri("/api/agc/analytics/batches") + .header("content-type", "application/json") + .header("x-genarrative-response-envelope", "1"); + if authenticated { + request = request.header("authorization", format!("Bearer {token}")); + } + let response = app + .clone() + .oneshot(request.body(Body::from(body)).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), expected); + } + let response = app + .oneshot( + Request::builder() + .uri("/admin/api/agc/tracking-events") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + mod http_tracing { use std::{ collections::BTreeMap, diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 6bf7edc92..e64d5c8ab 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -92,6 +92,8 @@ pub struct AppConfig { pub editor_bgfilter_circuit_cooldown: Duration, pub image_editor_agent_sidebar_enabled: bool, pub client_download_channel: String, + /// 客户端埋点接收所绑定的公开 origin;未配置时拒绝上传。 + pub agc_analytics_origin: String, /// AGC 项目快照的部署渠道:上传与后台默认查询都按它分区。 pub project_snapshot_channel: String, pub log_filter: String, @@ -404,6 +406,7 @@ impl Default for AppConfig { ), image_editor_agent_sidebar_enabled: false, client_download_channel: "dev".to_string(), + agc_analytics_origin: String::new(), project_snapshot_channel: "dev".to_string(), log_filter: "info,tower_http=info".to_string(), otel_enabled: false, @@ -727,6 +730,9 @@ impl AppConfig { // 显式空值或非法值也保留,由下载入口失败关闭,不能悄悄改读 dev。 config.client_download_channel = channel.trim().to_string(); } + if let Ok(origin) = std::env::var("GENARRATIVE_AGC_ANALYTICS_ORIGIN") { + config.agc_analytics_origin = origin.trim().to_string(); + } // 快照渠道缺省沿用同一个部署渠道(本部署的客户端渠道),显式配置优先; // 显式空值或非法值同样保留,由快照入口失败关闭。 config.project_snapshot_channel = config.client_download_channel.clone(); diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index ed69e82d2..241551c49 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -5,6 +5,7 @@ mod admin_accounts; mod admin_project_snapshots; mod admin_recharge; mod admin_templates; +mod agc_analytics; mod agc_models; mod ai_tasks; mod aliyun_matting; diff --git a/server-rs/crates/api-server/src/tracking.rs b/server-rs/crates/api-server/src/tracking.rs index 0632f992f..9a02ec3ea 100644 --- a/server-rs/crates/api-server/src/tracking.rs +++ b/server-rs/crates/api-server/src/tracking.rs @@ -804,7 +804,7 @@ fn resolve_route_tracking_spec(method: &Method, path: &str) -> Option bool { - path.starts_with("/admin/") + path.starts_with("/admin/") || path == "/api/agc/analytics/batches" } fn route_spec( diff --git a/server-rs/crates/module-runtime/Cargo.toml b/server-rs/crates/module-runtime/Cargo.toml index 786ff327a..a37c20d67 100644 --- a/server-rs/crates/module-runtime/Cargo.toml +++ b/server-rs/crates/module-runtime/Cargo.toml @@ -14,3 +14,7 @@ serde_json = { workspace = true } shared-kernel = { workspace = true } spacetimedb = { workspace = true, optional = true } time = { workspace = true, features = ["formatting", "parsing"] } + +shared-contracts = { workspace = true } +uuid = { workspace = true } +url = { workspace = true } diff --git a/server-rs/crates/module-runtime/src/agc_analytics.rs b/server-rs/crates/module-runtime/src/agc_analytics.rs new file mode 100644 index 000000000..c0c2ed4a4 --- /dev/null +++ b/server-rs/crates/module-runtime/src/agc_analytics.rs @@ -0,0 +1,428 @@ +//! 客户端观测事件校验,不推导项目所有权或奖励资格。 +use serde_json::Value; +use shared_contracts::agc_analytics::*; +use uuid::Uuid; +const MAX_SAFE_INTEGER: u64 = 9_007_199_254_740_991; +trait EventDataRules { + fn status(&self) -> Option; + fn has_goal(&self) -> bool; +} +pub trait EventRules { + fn data(&self) -> Result; + fn validate(&self) -> Result<(), &'static str>; +} +impl EventDataRules for EventData { + fn status(&self) -> Option { + match self { + Self::EditorFocusStart(_) | Self::EditorFocusEnd(_) => None, + Self::AgentRunFailed(_) => Some(Status::Failed), + _ => Some(Status::Success), + } + } + + fn has_goal(&self) -> bool { + matches!( + self, + Self::CreativeTaskSubmit(_) + | Self::AgentRunCompleted(_) + | Self::AgentRunFailed(_) + | Self::ProjectRevisionCreated(_) + | Self::PreviewReady(_) + | Self::ProjectSave(_) + ) + } +} + +impl EventRules for Event { + fn data(&self) -> Result { + if !self.properties.is_object() { + return Err("invalid_properties"); + } + // 可选字段不可得时省略;显式 null 仅用于合同指定的 nullable 字段。 + let optional = match self.event_name.as_str() { + "project_create_success" => &["project_template_id"][..], + "agent_run_completed" | "agent_run_failed" | "project_save" => &["revision_id"][..], + "project_revision_created" => &["files_changed_count"][..], + "preview_ready" => &["ready_duration_ms"][..], + _ => &[][..], + }; + if optional + .iter() + .any(|key| self.properties.get(key).is_some_and(Value::is_null)) + { + return Err("invalid_optional_property"); + } + serde_json::from_value(serde_json::json!({ + "event_name": self.event_name, + "properties": self.properties, + })) + .map_err(|_| "invalid_properties") + } + + fn validate(&self) -> Result<(), &'static str> { + if self.schema_version != 1 + || !uuid(&self.event_id) + || !uuid(&self.editor_session_id) + || !id(&self.client_version) + || !optional_id(&self.user_id) + || !optional_id(&self.project_id) + || !optional_id(&self.creative_task_id) + || !optional_id(&self.agent_turn_id) + || !valid_time(&self.event_time) + { + return Err("invalid_envelope"); + } + let data = self.data()?; + if self.status != data.status() + || self.error_code.is_some() != matches!(data, EventData::AgentRunFailed(_)) + { + return Err("invalid_status"); + } + if data.has_goal() { + if self.project_id.is_none() || self.creative_task_id != self.project_id { + return Err("invalid_goal"); + } + } else if self.creative_task_id.is_some() { + return Err("unexpected_goal"); + } + let is_run = matches!( + data, + EventData::AgentRunCompleted(_) | EventData::AgentRunFailed(_) + ); + if is_run { + if !self.agent_run_id.as_deref().is_some_and(uuid) { + return Err("invalid_run"); + } + } else if self.agent_run_id.is_some() || self.agent_turn_id.is_some() { + return Err("unexpected_run"); + } + let editor = self.source == Source::Editor; + let agent = matches!(self.source, Source::Direct | Source::DesignAgent); + let valid = match data { + EventData::EditorSessionStart(p) => editor && p.first_project_id == self.project_id, + EventData::EditorSessionEnd(p) => { + editor && p.last_project_id == self.project_id && safe(p.session_duration_ms) + } + EventData::EditorFocusStart(p) => { + editor && uuid(&p.focus_interval_id) && p.active_project_id == self.project_id + } + EventData::EditorFocusEnd(p) => { + editor + && uuid(&p.focus_interval_id) + && p.active_project_id == self.project_id + && safe(p.focus_duration_ms) + } + EventData::ProjectCreateSuccess(p) => { + editor && self.project_id.is_some() && optional_id(&p.project_template_id) + } + EventData::ProjectOpen(_) => editor && self.project_id.is_some(), + EventData::CreativeTaskSubmit(_) => agent, + EventData::AgentRunCompleted(p) | EventData::AgentRunFailed(p) => { + agent + && ((self.source == Source::Direct) == (p.agent_type == AgentType::GameAgent)) + && ((self.status == Some(Status::Failed)) + == (p.end_reason == RunEndReason::Failed)) + && safe(p.duration_ms) + && safe(Some(p.retry_index)) + && optional_id(&p.revision_id) + } + EventData::ProjectRevisionCreated(p) => { + id(&p.revision_id) + && safe(p.files_changed_count) + && match p.revision_source { + RevisionSource::Agent => agent, + RevisionSource::AssetCanvas => self.source == Source::AssetCanvas, + RevisionSource::ResourceEditor => self.source == Source::ResourceEditor, + RevisionSource::UiEditor => self.source == Source::UiEditor, + RevisionSource::ManualEdit => self.source == Source::Manual, + RevisionSource::SystemProjection => self.source == Source::System, + } + } + EventData::PreviewReady(p) => id(&p.preview_version) && safe(p.ready_duration_ms), + EventData::ProjectSave(p) => optional_id(&p.revision_id), + }; + if valid { + Ok(()) + } else { + Err("invalid_event_fields") + } + } +} + +fn id(value: &str) -> bool { + !value.trim().is_empty() && value.len() <= 256 && !value.chars().any(char::is_control) +} +fn optional_id(value: &Option) -> bool { + value.as_deref().is_none_or(id) +} +fn uuid(value: &str) -> bool { + Uuid::parse_str(value).is_ok_and(|v| v.get_version_num() == 4 && v.to_string() == value) +} +fn safe(value: Option) -> bool { + value.is_none_or(|v| v <= MAX_SAFE_INTEGER) +} +fn valid_time(value: &str) -> bool { + value.len() == 24 + && value.ends_with("Z") + && value.as_bytes()[10] == b'T' + && value.as_bytes()[19] == b'.' + && time::OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339) + .is_ok() +} + +pub fn validate_agc_analytics_batch(batch: &AgcAnalyticsBatch) -> Result<(), &'static str> { + if batch.schema_version != 1 + || batch.destination_origin.len() > 2048 + || !uuid(&batch.batch_id) + || !id(&batch.user_id) + || batch.events.is_empty() + || batch.events.len() > 500 + { + return Err("invalid_batch"); + } + let origin = url::Url::parse(&batch.destination_origin).map_err(|_| "invalid_origin")?; + if !matches!(origin.scheme(), "https" | "http") + || origin.host_str().is_none() + || !origin.username().is_empty() + || origin.password().is_some() + || origin.origin().ascii_serialization() != batch.destination_origin + { + return Err("invalid_origin"); + } + let mut ids = std::collections::HashSet::new(); + let mut bytes = 0usize; + for event in &batch.events { + event.validate()?; + if event.user_id.as_deref() != Some(batch.user_id.as_str()) { + return Err("identity_mismatch"); + } + if !ids.insert(&event.event_id) { + return Err("duplicate_event_id"); + } + bytes += serde_json::to_vec(event) + .map_err(|_| "invalid_event")? + .len() + + 1; + } + if bytes > 1024 * 1024 { + return Err("events_too_large"); + } + Ok(()) +} + +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] +#[serde(deny_unknown_fields)] +pub struct AgcTrackingCursor { + pub snapshot: i64, + pub event_time: i64, + pub event_id: String, + pub filter_key: String, +} +/// 游标绑定查询条件;分页大小不参与条件,刷新时不带游标。 +pub fn agc_tracking_filter_key( + query: &shared_contracts::admin::AdminAgcTrackingEventListQuery, +) -> String { + let mut filters = query.clone(); + filters.cursor = None; + filters.limit = None; + serde_json::to_string(&filters).expect("query serializes") +} +pub fn agc_time_micros(value: &str) -> Result { + let time = time::OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339) + .map_err(|_| "invalid_agc_query")?; + i64::try_from(time.unix_timestamp_nanos() / 1000).map_err(|_| "invalid_agc_query") +} +pub fn validate_agc_tracking_query( + query: &shared_contracts::admin::AdminAgcTrackingEventListQuery, +) -> Result, &'static str> { + for value in [ + &query.user_id, + &query.project_id, + &query.creative_task_id, + &query.agent_run_id, + &query.event_name, + &query.client_version, + ] + .into_iter() + .flatten() + { + if !id(value) { + return Err("invalid_agc_query"); + } + } + let start = query + .start_time + .as_deref() + .map(agc_time_micros) + .transpose()?; + let end = query.end_time.as_deref().map(agc_time_micros).transpose()?; + if start.zip(end).is_some_and(|(a, b)| a >= b) { + return Err("invalid_agc_query"); + } + if let Some(raw) = &query.cursor { + if raw.len() > 8192 { + return Err("invalid_agc_cursor"); + } + let cursor: AgcTrackingCursor = + serde_json::from_str(raw).map_err(|_| "invalid_agc_cursor")?; + if cursor.filter_key != agc_tracking_filter_key(query) || !uuid(&cursor.event_id) { + return Err("invalid_agc_cursor"); + } + Ok(Some(cursor)) + } else { + Ok(None) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + const ID: &str = "790a1275-a0a0-405c-8bfd-287201bef10a"; + fn event(name: &str, properties: Value) -> Event { + serde_json::from_value(json!({ + "schema_version": 1, "event_id": ID, "event_name": name, + "event_time": "2026-09-21T12:00:00.000Z", "user_id": "user-a", + "editor_session_id": ID, "project_id": null, "creative_task_id": null, + "agent_run_id": null, "agent_turn_id": null, "status": "success", + "error_code": null, "source": "editor", "client_version": "1.0.0", "properties": properties + })).unwrap() + } + fn batch(event: Event) -> AgcAnalyticsBatch { + AgcAnalyticsBatch { + schema_version: 1, + batch_id: ID.into(), + destination_origin: "https://example.com".into(), + user_id: "user-a".into(), + events: vec![event], + } + } + #[test] + fn agc_accepts_twelve_existing_event_contracts() { + let cases = [ + ( + "editor_session_start", + json!({"entry_source":"direct_launch","first_project_id":null}), + ), + ( + "editor_session_end", + json!({"end_reason":"user_exit","session_duration_ms":null,"last_project_id":null}), + ), + ( + "editor_focus_start", + json!({"focus_interval_id":ID,"focus_reason":"window_focus","active_project_id":null}), + ), + ( + "editor_focus_end", + json!({"focus_interval_id":ID,"blur_reason":"window_blur","focus_duration_ms":1,"active_project_id":null}), + ), + ( + "project_create_success", + json!({"creation_source":"home_game"}), + ), + ( + "project_open", + json!({"open_source":"recent","is_first_open":null}), + ), + ("creative_task_submit", json!({})), + ( + "agent_run_completed", + json!({"agent_type":"game_agent","run_source":"user_submit","duration_ms":1,"retry_index":0,"output_change_detected":null,"end_reason":"waiting_for_approval"}), + ), + ( + "agent_run_failed", + json!({"agent_type":"game_agent","run_source":"user_submit","duration_ms":1,"retry_index":0,"output_change_detected":true,"end_reason":"failed"}), + ), + ( + "project_revision_created", + json!({"revision_id":"design:session:phase","revision_source":"agent","change_kind":"design_document"}), + ), + ( + "preview_ready", + json!({"preview_source":"user","preview_version":"version1"}), + ), + ("project_save", json!({"save_source":"checkpoint"})), + ]; + for (index, (name, properties)) in cases.into_iter().enumerate() { + let mut e = event(name, properties); + if index == 2 || index == 3 { + e.status = None; + } + if index >= 4 { + e.project_id = Some("project-a".into()); + } + if index >= 6 { + e.creative_task_id = e.project_id.clone(); + e.source = Source::Direct; + } + if index == 7 || index == 8 { + e.agent_run_id = Some(ID.into()); + } + if index == 8 { + e.status = Some(Status::Failed); + e.error_code = Some(ErrorCode::ProviderTimeout); + } + assert_eq!(validate_agc_analytics_batch(&batch(e)), Ok(()), "{name}"); + } + } + #[test] + fn agc_rejects_identity_duplicate_and_unknown_content() { + let e = event( + "editor_session_start", + json!({"entry_source":"direct_launch","first_project_id":null}), + ); + let mut b = batch(e.clone()); + b.user_id = "other-user".into(); + assert_eq!(validate_agc_analytics_batch(&b), Err("identity_mismatch")); + let mut b = batch(e.clone()); + b.events.push(e.clone()); + assert_eq!(validate_agc_analytics_batch(&b), Err("duplicate_event_id")); + let mut b = batch(e.clone()); + b.events[0].properties["prompt"] = json!("should never be accepted"); + assert_eq!(validate_agc_analytics_batch(&b), Err("invalid_properties")); + b.events[0].properties = json!(["direct_launch", null]); + assert_eq!(validate_agc_analytics_batch(&b), Err("invalid_properties")); + let mut raw = serde_json::to_value(e).unwrap(); + raw["file_path"] = json!("secret"); + assert!(serde_json::from_value::(raw).is_err()); + } + #[test] + fn agc_requires_explicit_nullable_fields_and_no_optional_nulls() { + let mut raw = + serde_json::to_value(event("project_save", json!({"save_source":"manual"}))).unwrap(); + raw.as_object_mut().unwrap().remove("agent_turn_id"); + assert!(serde_json::from_value::(raw).is_err()); + let mut e = event( + "project_save", + json!({"save_source":"manual","revision_id":null}), + ); + e.project_id = Some("p".into()); + e.creative_task_id = e.project_id.clone(); + assert_eq!(e.validate(), Err("invalid_optional_property")); + } + #[test] + fn agc_query_cursor_is_bound_to_filters_and_rejects_invalid_time_range() { + let mut query = shared_contracts::admin::AdminAgcTrackingEventListQuery::default(); + let cursor = AgcTrackingCursor { + snapshot: 100, + // 客户端时钟可能领先;快照限制入库时间,不能限制发生时间。 + event_time: 110, + event_id: ID.into(), + filter_key: agc_tracking_filter_key(&query), + }; + query.cursor = Some(serde_json::to_string(&cursor).unwrap()); + assert!(validate_agc_tracking_query(&query).is_ok()); + query.user_id = Some("changed-user".into()); + assert_eq!( + validate_agc_tracking_query(&query).unwrap_err(), + "invalid_agc_cursor" + ); + query.cursor = None; + query.start_time = Some("2026-09-22T00:00:00Z".into()); + query.end_time = Some("2026-09-21T00:00:00Z".into()); + assert_eq!( + validate_agc_tracking_query(&query).unwrap_err(), + "invalid_agc_query" + ); + } +} diff --git a/server-rs/crates/module-runtime/src/lib.rs b/server-rs/crates/module-runtime/src/lib.rs index adc3b96e1..c6698f7a6 100644 --- a/server-rs/crates/module-runtime/src/lib.rs +++ b/server-rs/crates/module-runtime/src/lib.rs @@ -1,3 +1,4 @@ +pub mod agc_analytics; mod agc_models; mod llm_billing; pub use llm_billing::*; diff --git a/server-rs/crates/shared-contracts/src/admin.rs b/server-rs/crates/shared-contracts/src/admin.rs index e899fefbb..f9730feb4 100644 --- a/server-rs/crates/shared-contracts/src/admin.rs +++ b/server-rs/crates/shared-contracts/src/admin.rs @@ -10,12 +10,13 @@ use crate::creation_entry_config::{ }; /// 后台 member 可被授予的一级 Tab 权限;账号管理仅 owner 可见,不进入该集合。 -pub const ADMIN_TAB_PERMISSIONS: [&str; 18] = [ +pub const ADMIN_TAB_PERMISSIONS: [&str; 19] = [ "dashboard", "overview", "tables", "debug", "tracking", + "agc-tracking", "gray-release", "redeem", "invite", @@ -1495,3 +1496,47 @@ pub struct AdminAgcModelCatalog { pub default_model_id: String, pub models: Vec, } + +// 客户端埋点明细;时间范围按发生时间,按 (event_time, event_id) 倒序分页。 +// received_at 仅用于固定分页快照的入库时间上界。 +#[derive(Clone, Debug, Default, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AdminAgcTrackingEventListQuery { + pub user_id: Option, + pub project_id: Option, + pub creative_task_id: Option, + pub agent_run_id: Option, + pub event_name: Option, + pub client_version: Option, + pub start_time: Option, + pub end_time: Option, + pub cursor: Option, + pub limit: Option, +} +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminAgcTrackingEventEntry { + pub event_id: String, + pub schema_version: u32, + pub event_name: String, + pub event_time: String, + pub user_id: String, + pub editor_session_id: String, + pub project_id: Option, + pub creative_task_id: Option, + pub agent_run_id: Option, + pub agent_turn_id: Option, + pub status: Option, + pub error_code: Option, + pub source: String, + pub client_version: String, + pub properties: serde_json::Value, + pub batch_id: String, + pub received_at: String, +} +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AdminAgcTrackingEventListPayload { + pub entries: Vec, + pub next_cursor: Option, +} diff --git a/server-rs/crates/shared-contracts/src/agc_analytics.rs b/server-rs/crates/shared-contracts/src/agc_analytics.rs new file mode 100644 index 000000000..bb26bdebd --- /dev/null +++ b/server-rs/crates/shared-contracts/src/agc_analytics.rs @@ -0,0 +1,282 @@ +//! 客户端埋点上传合同;字段与本地 schema v1 保持一致。 +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::Value; + +fn required_nullable<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + Option::::deserialize(deserializer) +} + +macro_rules! values { + ($name:ident { $($variant:ident),+ $(,)? }) => { + #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] + #[serde(rename_all = "snake_case")] + pub enum $name { $($variant),+ } + }; +} + +values!(Source { + Editor, + Direct, + DesignAgent, + AssetCanvas, + ResourceEditor, + UiEditor, + Manual, + System +}); +values!(Status { Success, Failed }); +values!(EntrySource { + DirectLaunch, + ProjectAssociation, + AppRestore +}); +values!(SessionEndReason { + UserExit, + AppRestart +}); +values!(FocusReason { + InitialFocus, + WindowFocus, + Restore, + AccountChange +}); +values!(BlurReason { + WindowBlur, + Minimized, + AppExit, + SystemSuspend, + AccountChange +}); +values!(CreationSource { + HomeGame, + HomeDesign, + Template, + SelectedDirectory +}); +values!(OpenSource { + Create, + Picker, + Recent, + AppRestore, + ProjectAssociation +}); +values!(AgentType { + GameAgent, + DesignAgent +}); +values!(RunSource { + UserSubmit, + UserContinue, + Clarification, + Approval, + UserRetry +}); +values!(RunEndReason { + Finished, + WaitingForUser, + WaitingForApproval, + Failed +}); +values!(ErrorCode { + ProviderAuthFailed, + ProviderRateLimited, + ProviderUnavailable, + ProviderTimeout, + ProviderInvalidResponse, + LocalIoFailed, + RuntimeFailed, + RuntimeErrorUnclassified +}); +values!(RevisionSource { + Agent, + AssetCanvas, + ResourceEditor, + UiEditor, + ManualEdit, + SystemProjection +}); +values!(ChangeKind { + Code, + Asset, + Ui, + DesignDocument, + Mixed +}); +values!(PreviewSource { + User, + Agent, + AutoRestore +}); +values!(SaveSource { + Manual, + Auto, + Checkpoint +}); +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct SessionStart { + pub entry_source: EntrySource, + #[serde(deserialize_with = "required_nullable")] + pub first_project_id: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct SessionEnd { + pub end_reason: SessionEndReason, + #[serde(deserialize_with = "required_nullable")] + pub session_duration_ms: Option, + #[serde(deserialize_with = "required_nullable")] + pub last_project_id: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct FocusStart { + pub focus_interval_id: String, + pub focus_reason: FocusReason, + #[serde(deserialize_with = "required_nullable")] + pub active_project_id: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct FocusEnd { + pub focus_interval_id: String, + pub blur_reason: BlurReason, + #[serde(deserialize_with = "required_nullable")] + pub focus_duration_ms: Option, + #[serde(deserialize_with = "required_nullable")] + pub active_project_id: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ProjectCreated { + pub creation_source: CreationSource, + #[serde(skip_serializing_if = "Option::is_none")] + pub project_template_id: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ProjectOpened { + pub open_source: OpenSource, + #[serde(deserialize_with = "required_nullable")] + pub is_first_open: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct EmptyProperties {} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct RunFinished { + pub agent_type: AgentType, + pub run_source: RunSource, + #[serde(deserialize_with = "required_nullable")] + pub duration_ms: Option, + pub retry_index: u64, + #[serde(deserialize_with = "required_nullable")] + pub output_change_detected: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub revision_id: Option, + pub end_reason: RunEndReason, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct RevisionCreated { + pub revision_id: String, + pub revision_source: RevisionSource, + pub change_kind: ChangeKind, + #[serde(skip_serializing_if = "Option::is_none")] + pub files_changed_count: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct PreviewReady { + pub preview_source: PreviewSource, + pub preview_version: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub ready_duration_ms: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ProjectSaved { + pub save_source: SaveSource, + #[serde(skip_serializing_if = "Option::is_none")] + pub revision_id: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde( + tag = "event_name", + content = "properties", + rename_all = "snake_case", + deny_unknown_fields +)] +pub enum EventData { + EditorSessionStart(SessionStart), + EditorSessionEnd(SessionEnd), + EditorFocusStart(FocusStart), + EditorFocusEnd(FocusEnd), + ProjectCreateSuccess(ProjectCreated), + ProjectOpen(ProjectOpened), + CreativeTaskSubmit(EmptyProperties), + AgentRunCompleted(RunFinished), + AgentRunFailed(RunFinished), + ProjectRevisionCreated(RevisionCreated), + PreviewReady(PreviewReady), + ProjectSave(ProjectSaved), +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Event { + pub schema_version: u32, + pub event_id: String, + pub event_name: String, + pub event_time: String, + #[serde(deserialize_with = "required_nullable")] + pub user_id: Option, + pub editor_session_id: String, + #[serde(deserialize_with = "required_nullable")] + pub project_id: Option, + #[serde(deserialize_with = "required_nullable")] + pub creative_task_id: Option, + #[serde(deserialize_with = "required_nullable")] + pub agent_run_id: Option, + #[serde(deserialize_with = "required_nullable")] + pub agent_turn_id: Option, + #[serde(deserialize_with = "required_nullable")] + pub status: Option, + #[serde(deserialize_with = "required_nullable")] + pub error_code: Option, + pub source: Source, + pub client_version: String, + pub properties: Value, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct AgcAnalyticsBatch { + pub schema_version: u32, + pub batch_id: String, + pub destination_origin: String, + pub user_id: String, + pub events: Vec, +} +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct AgcAnalyticsAcknowledgement { + pub acknowledged_batch_ids: Vec, + pub event_count: u32, +} diff --git a/server-rs/crates/shared-contracts/src/lib.rs b/server-rs/crates/shared-contracts/src/lib.rs index 255253954..377e9d75f 100644 --- a/server-rs/crates/shared-contracts/src/lib.rs +++ b/server-rs/crates/shared-contracts/src/lib.rs @@ -1,4 +1,5 @@ pub mod admin; +pub mod agc_analytics; pub mod agc_project_snapshots; pub mod ai; pub mod api; diff --git a/server-rs/crates/spacetime-client/src/active.rs b/server-rs/crates/spacetime-client/src/active.rs index c20123daf..f53356bed 100644 --- a/server-rs/crates/spacetime-client/src/active.rs +++ b/server-rs/crates/spacetime-client/src/active.rs @@ -9,6 +9,7 @@ pub use mapper::*; pub mod admin_account; pub mod admin_dashboard; +pub mod agc_analytics; pub mod agc_models; #[path = "active/ai.rs"] pub mod ai; diff --git a/server-rs/crates/spacetime-client/src/agc_analytics.rs b/server-rs/crates/spacetime-client/src/agc_analytics.rs new file mode 100644 index 000000000..dff3d0b13 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/agc_analytics.rs @@ -0,0 +1,64 @@ +use super::*; +use shared_contracts::admin::{AdminAgcTrackingEventListPayload, AdminAgcTrackingEventListQuery}; +use shared_contracts::agc_analytics::{AgcAnalyticsAcknowledgement, AgcAnalyticsBatch}; +impl SpacetimeClient { + pub async fn upload_agc_analytics_batch( + &self, + input: AgcAnalyticsBatch, + ) -> Result { + let input_json = serde_json::to_string(&input) + .map_err(|_| SpacetimeClientError::validation_failed("invalid_agc_payload"))?; + self.call_after_connect("upload_agc_analytics_batch", move |connection, sender| { + connection.procedures().upload_agc_analytics_batch_then( + input_json, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(|r| { + r.map_err(|message| { + SpacetimeClientError::procedure_failed(Some(message)) + }) + }) + .and_then(|json| { + serde_json::from_str(&json).map_err(|_| { + SpacetimeClientError::procedure_failed(Some( + "invalid_agc_response".into(), + )) + }) + }); + send_once(&sender, mapped); + }, + ); + }) + .await + } + pub async fn list_agc_tracking_events( + &self, + input: AdminAgcTrackingEventListQuery, + ) -> Result { + let input_json = serde_json::to_string(&input) + .map_err(|_| SpacetimeClientError::validation_failed("invalid_agc_payload"))?; + self.call_after_connect("list_agc_tracking_events", move |connection, sender| { + connection + .procedures() + .list_agc_tracking_events_then(input_json, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(|r| { + r.map_err(|message| { + SpacetimeClientError::procedure_failed(Some(message)) + }) + }) + .and_then(|json| { + serde_json::from_str(&json).map_err(|_| { + SpacetimeClientError::procedure_failed(Some( + "invalid_agc_response".into(), + )) + }) + }); + send_once(&sender, mapped); + }); + }) + .await + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 1ad32a79e..94d10bb47 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -53,6 +53,8 @@ pub mod admin_upsert_profile_wallet_manual_restriction_and_return_procedure; pub mod advance_profile_recharge_refund_bill_checkpoint_and_return_procedure; pub mod agc_model_catalog_row_type; pub mod agc_model_catalog_table; +pub mod agc_tracking_event_table; +pub mod agc_tracking_event_type; pub mod ai_result_reference_input_type; pub mod ai_result_reference_kind_type; pub mod ai_result_reference_snapshot_type; @@ -466,6 +468,7 @@ pub mod import_database_migration_incremental_from_chunks_procedure; pub mod import_database_migration_incremental_from_file_procedure; pub mod initialize_editor_generation_pricing_config_if_missing_and_return_procedure; pub mod list_admin_accounts_and_return_procedure; +pub mod list_agc_tracking_events_procedure; pub mod list_asset_history_and_return_procedure; pub mod list_editor_agent_conversations_and_return_procedure; pub mod list_editor_projects_and_return_procedure; @@ -761,6 +764,7 @@ pub mod update_editor_project_resource_showcase_and_return_procedure; pub mod update_editor_showcase_asset_display_and_return_procedure; pub mod update_error_report_and_return_procedure; pub mod update_external_generation_job_phase_and_return_procedure; +pub mod upload_agc_analytics_batch_procedure; pub mod upsert_editor_generation_pricing_config_and_return_procedure; pub mod upsert_editor_showcase_campaign_config_and_return_procedure; pub mod upsert_feature_gate_config_procedure; @@ -819,6 +823,8 @@ pub use admin_upsert_profile_wallet_manual_restriction_and_return_procedure::adm pub use advance_profile_recharge_refund_bill_checkpoint_and_return_procedure::advance_profile_recharge_refund_bill_checkpoint_and_return; pub use agc_model_catalog_row_type::AgcModelCatalogRow; pub use agc_model_catalog_table::*; +pub use agc_tracking_event_table::*; +pub use agc_tracking_event_type::AgcTrackingEvent; pub use ai_result_reference_input_type::AiResultReferenceInput; pub use ai_result_reference_kind_type::AiResultReferenceKind; pub use ai_result_reference_snapshot_type::AiResultReferenceSnapshot; @@ -1232,6 +1238,7 @@ pub use import_database_migration_incremental_from_chunks_procedure::import_data pub use import_database_migration_incremental_from_file_procedure::import_database_migration_incremental_from_file; pub use initialize_editor_generation_pricing_config_if_missing_and_return_procedure::initialize_editor_generation_pricing_config_if_missing_and_return; pub use list_admin_accounts_and_return_procedure::list_admin_accounts_and_return; +pub use list_agc_tracking_events_procedure::list_agc_tracking_events; pub use list_asset_history_and_return_procedure::list_asset_history_and_return; pub use list_editor_agent_conversations_and_return_procedure::list_editor_agent_conversations_and_return; pub use list_editor_projects_and_return_procedure::list_editor_projects_and_return; @@ -1527,6 +1534,7 @@ pub use update_editor_project_resource_showcase_and_return_procedure::update_edi pub use update_editor_showcase_asset_display_and_return_procedure::update_editor_showcase_asset_display_and_return; pub use update_error_report_and_return_procedure::update_error_report_and_return; pub use update_external_generation_job_phase_and_return_procedure::update_external_generation_job_phase_and_return; +pub use upload_agc_analytics_batch_procedure::upload_agc_analytics_batch; pub use upsert_editor_generation_pricing_config_and_return_procedure::upsert_editor_generation_pricing_config_and_return; pub use upsert_editor_showcase_campaign_config_and_return_procedure::upsert_editor_showcase_campaign_config_and_return; pub use upsert_feature_gate_config_procedure::upsert_feature_gate_config; @@ -1648,6 +1656,7 @@ impl __sdk::Reducer for Reducer { pub struct DbUpdate { admin_account: __sdk::TableUpdate, agc_model_catalog: __sdk::TableUpdate, + agc_tracking_event: __sdk::TableUpdate, ai_result_reference: __sdk::TableUpdate, ai_task: __sdk::TableUpdate, ai_task_event: __sdk::TableUpdate, @@ -1749,6 +1758,9 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "agc_model_catalog" => db_update .agc_model_catalog .append(agc_model_catalog_table::parse_table_update(table_update)?), + "agc_tracking_event" => db_update + .agc_tracking_event + .append(agc_tracking_event_table::parse_table_update(table_update)?), "ai_result_reference" => db_update .ai_result_reference .append(ai_result_reference_table::parse_table_update(table_update)?), @@ -2081,6 +2093,9 @@ impl __sdk::DbUpdate for DbUpdate { diff.agc_model_catalog = cache .apply_diff_to_table::("agc_model_catalog", &self.agc_model_catalog) .with_updates_by_pk(|row| &row.id); + diff.agc_tracking_event = cache + .apply_diff_to_table::("agc_tracking_event", &self.agc_tracking_event) + .with_updates_by_pk(|row| &row.event_id); diff.ai_result_reference = cache .apply_diff_to_table::( "ai_result_reference", @@ -2525,6 +2540,9 @@ impl __sdk::DbUpdate for DbUpdate { "agc_model_catalog" => db_update .agc_model_catalog .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "agc_tracking_event" => db_update + .agc_tracking_event + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "ai_result_reference" => db_update .ai_result_reference .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -2790,6 +2808,9 @@ impl __sdk::DbUpdate for DbUpdate { "agc_model_catalog" => db_update .agc_model_catalog .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "agc_tracking_event" => db_update + .agc_tracking_event + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "ai_result_reference" => db_update .ai_result_reference .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -3053,6 +3074,7 @@ impl __sdk::DbUpdate for DbUpdate { pub struct AppliedDiff<'r> { admin_account: __sdk::TableAppliedDiff<'r, AdminAccount>, agc_model_catalog: __sdk::TableAppliedDiff<'r, AgcModelCatalogRow>, + agc_tracking_event: __sdk::TableAppliedDiff<'r, AgcTrackingEvent>, ai_result_reference: __sdk::TableAppliedDiff<'r, AiResultReference>, ai_task: __sdk::TableAppliedDiff<'r, AiTask>, ai_task_event: __sdk::TableAppliedDiff<'r, AiTaskEvent>, @@ -3166,6 +3188,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.agc_model_catalog, event, ); + callbacks.invoke_table_row_callbacks::( + "agc_tracking_event", + &self.agc_tracking_event, + event, + ); callbacks.invoke_table_row_callbacks::( "ai_result_reference", &self.ai_result_reference, @@ -3823,19 +3850,19 @@ impl __sdk::SubscriptionHandle for SubscriptionHandle { /// either a [`DbConnection`] or an [`EventContext`] and operate on either. pub trait RemoteDbContext: __sdk::DbContext< - DbView = RemoteTables, - Reducers = RemoteReducers, - SubscriptionBuilder = __sdk::SubscriptionBuilder, -> + DbView = RemoteTables, + Reducers = RemoteReducers, + SubscriptionBuilder = __sdk::SubscriptionBuilder, + > { } impl< - Ctx: __sdk::DbContext< + Ctx: __sdk::DbContext< DbView = RemoteTables, Reducers = RemoteReducers, SubscriptionBuilder = __sdk::SubscriptionBuilder, >, - > RemoteDbContext for Ctx +> RemoteDbContext for Ctx { } @@ -4230,6 +4257,7 @@ impl __sdk::SpacetimeModule for RemoteModule { fn register_tables(client_cache: &mut __sdk::ClientCache) { admin_account_table::register_table(client_cache); agc_model_catalog_table::register_table(client_cache); + agc_tracking_event_table::register_table(client_cache); ai_result_reference_table::register_table(client_cache); ai_task_table::register_table(client_cache); ai_task_event_table::register_table(client_cache); @@ -4316,6 +4344,7 @@ impl __sdk::SpacetimeModule for RemoteModule { const ALL_TABLE_NAMES: &'static [&'static str] = &[ "admin_account", "agc_model_catalog", + "agc_tracking_event", "ai_result_reference", "ai_task", "ai_task_event", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/agc_tracking_event_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/agc_tracking_event_table.rs new file mode 100644 index 000000000..2c3712e0f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/agc_tracking_event_table.rs @@ -0,0 +1,228 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::agc_tracking_event_type::AgcTrackingEvent; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `agc_tracking_event`. +/// +/// Obtain a handle from the [`AgcTrackingEventTableAccess::agc_tracking_event`] method on [`super::RemoteTables`], +/// like `ctx.db.agc_tracking_event()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.agc_tracking_event().on_insert(...)`. +pub struct AgcTrackingEventTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +/// Lifetime-aware accessor marker for the table `agc_tracking_event`. +pub struct AgcTrackingEventTableAccessor; + +impl __sdk::TableAccessor for AgcTrackingEventTableAccessor { + type Row = AgcTrackingEvent; + type Handle<'db> = AgcTrackingEventTableHandle<'db>; + + fn get<'db>(db: &'db super::RemoteTables) -> Self::Handle<'db> { + db.agc_tracking_event() + } +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `agc_tracking_event`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait AgcTrackingEventTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`AgcTrackingEventTableHandle`], which mediates access to the table `agc_tracking_event`. + fn agc_tracking_event(&self) -> AgcTrackingEventTableHandle<'_>; +} + +impl AgcTrackingEventTableAccess for super::RemoteTables { + fn agc_tracking_event(&self) -> AgcTrackingEventTableHandle<'_> { + AgcTrackingEventTableHandle { + imp: self.imp.get_table::("agc_tracking_event"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct AgcTrackingEventInsertCallbackId(__sdk::CallbackId); +pub struct AgcTrackingEventDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableLike for AgcTrackingEventTableHandle<'ctx> { + type Row = AgcTrackingEvent; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } +} + +impl<'ctx> __sdk::Table for AgcTrackingEventTableHandle<'ctx> { + type Row = AgcTrackingEvent; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = AgcTrackingEventInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> AgcTrackingEventInsertCallbackId { + AgcTrackingEventInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: AgcTrackingEventInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = AgcTrackingEventDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> AgcTrackingEventDeleteCallbackId { + AgcTrackingEventDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: AgcTrackingEventDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +impl<'ctx> __sdk::WithInsert for AgcTrackingEventTableHandle<'ctx> { + type InsertCallbackId = AgcTrackingEventInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> AgcTrackingEventInsertCallbackId { + AgcTrackingEventInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: AgcTrackingEventInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } +} + +impl<'ctx> __sdk::WithDelete for AgcTrackingEventTableHandle<'ctx> { + type DeleteCallbackId = AgcTrackingEventDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> AgcTrackingEventDeleteCallbackId { + AgcTrackingEventDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: AgcTrackingEventDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct AgcTrackingEventUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for AgcTrackingEventTableHandle<'ctx> { + type UpdateCallbackId = AgcTrackingEventUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> AgcTrackingEventUpdateCallbackId { + AgcTrackingEventUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: AgcTrackingEventUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +impl<'ctx> __sdk::WithUpdate for AgcTrackingEventTableHandle<'ctx> { + type UpdateCallbackId = AgcTrackingEventUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> AgcTrackingEventUpdateCallbackId { + AgcTrackingEventUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: AgcTrackingEventUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `event_id` unique index on the table `agc_tracking_event`, +/// which allows point queries on the field of the same name +/// via the [`AgcTrackingEventEventIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.agc_tracking_event().event_id().find(...)`. +pub struct AgcTrackingEventEventIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> AgcTrackingEventTableHandle<'ctx> { + /// Get a handle on the `event_id` unique index on the table `agc_tracking_event`. + pub fn event_id(&self) -> AgcTrackingEventEventIdUnique<'ctx> { + AgcTrackingEventEventIdUnique { + imp: self.imp.get_unique_constraint::("event_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> AgcTrackingEventEventIdUnique<'ctx> { + /// Find the subscribed row whose `event_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("agc_tracking_event"); + _table.add_unique_constraint::("event_id", |row| &row.event_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `AgcTrackingEvent`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait agc_tracking_eventQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `AgcTrackingEvent`. + fn agc_tracking_event(&self) -> __sdk::__query_builder::Table; +} + +impl agc_tracking_eventQueryTableAccess for __sdk::QueryTableAccessor { + fn agc_tracking_event(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("agc_tracking_event") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/agc_tracking_event_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/agc_tracking_event_type.rs new file mode 100644 index 000000000..750fe691f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/agc_tracking_event_type.rs @@ -0,0 +1,99 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AgcTrackingEvent { + pub event_id: String, + pub schema_version: u32, + pub event_name: String, + pub event_time: __sdk::Timestamp, + pub user_id: String, + pub editor_session_id: String, + pub project_id: Option, + pub creative_task_id: Option, + pub agent_run_id: Option, + pub agent_turn_id: Option, + pub status: Option, + pub error_code: Option, + pub source: String, + pub client_version: String, + pub properties_json: String, + pub batch_id: String, + pub received_at: __sdk::Timestamp, +} + +impl __sdk::InModule for AgcTrackingEvent { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `AgcTrackingEvent`. +/// +/// Provides typed access to columns for query building. +pub struct AgcTrackingEventCols { + pub event_id: __sdk::__query_builder::Col, + pub schema_version: __sdk::__query_builder::Col, + pub event_name: __sdk::__query_builder::Col, + pub event_time: __sdk::__query_builder::Col, + pub user_id: __sdk::__query_builder::Col, + pub editor_session_id: __sdk::__query_builder::Col, + pub project_id: __sdk::__query_builder::Col>, + pub creative_task_id: __sdk::__query_builder::Col>, + pub agent_run_id: __sdk::__query_builder::Col>, + pub agent_turn_id: __sdk::__query_builder::Col>, + pub status: __sdk::__query_builder::Col>, + pub error_code: __sdk::__query_builder::Col>, + pub source: __sdk::__query_builder::Col, + pub client_version: __sdk::__query_builder::Col, + pub properties_json: __sdk::__query_builder::Col, + pub batch_id: __sdk::__query_builder::Col, + pub received_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for AgcTrackingEvent { + type Cols = AgcTrackingEventCols; + fn cols(table_name: &'static str) -> Self::Cols { + AgcTrackingEventCols { + event_id: __sdk::__query_builder::Col::new(table_name, "event_id"), + schema_version: __sdk::__query_builder::Col::new(table_name, "schema_version"), + event_name: __sdk::__query_builder::Col::new(table_name, "event_name"), + event_time: __sdk::__query_builder::Col::new(table_name, "event_time"), + user_id: __sdk::__query_builder::Col::new(table_name, "user_id"), + editor_session_id: __sdk::__query_builder::Col::new(table_name, "editor_session_id"), + project_id: __sdk::__query_builder::Col::new(table_name, "project_id"), + creative_task_id: __sdk::__query_builder::Col::new(table_name, "creative_task_id"), + agent_run_id: __sdk::__query_builder::Col::new(table_name, "agent_run_id"), + agent_turn_id: __sdk::__query_builder::Col::new(table_name, "agent_turn_id"), + status: __sdk::__query_builder::Col::new(table_name, "status"), + error_code: __sdk::__query_builder::Col::new(table_name, "error_code"), + source: __sdk::__query_builder::Col::new(table_name, "source"), + client_version: __sdk::__query_builder::Col::new(table_name, "client_version"), + properties_json: __sdk::__query_builder::Col::new(table_name, "properties_json"), + batch_id: __sdk::__query_builder::Col::new(table_name, "batch_id"), + received_at: __sdk::__query_builder::Col::new(table_name, "received_at"), + } + } +} + +/// Indexed column accessor struct for the table `AgcTrackingEvent`. +/// +/// Provides typed access to indexed columns for query building. +pub struct AgcTrackingEventIxCols { + pub event_id: __sdk::__query_builder::IxCol, + pub received_at: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for AgcTrackingEvent { + type IxCols = AgcTrackingEventIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + AgcTrackingEventIxCols { + event_id: __sdk::__query_builder::IxCol::new(table_name, "event_id"), + received_at: __sdk::__query_builder::IxCol::new(table_name, "received_at"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for AgcTrackingEvent {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_agc_tracking_events_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_agc_tracking_events_procedure.rs new file mode 100644 index 000000000..6244fd337 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_agc_tracking_events_procedure.rs @@ -0,0 +1,56 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ListAgcTrackingEventsArgs { + pub query_json: String, +} + +impl __sdk::InModule for ListAgcTrackingEventsArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `list_agc_tracking_events`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait list_agc_tracking_events { + fn list_agc_tracking_events(&self, query_json: String) { + self.list_agc_tracking_events_then(query_json, |_, _| {}); + } + + fn list_agc_tracking_events_then( + &self, + query_json: String, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, __sdk::InternalError>, + ) + Send + + 'static, + ); +} + +impl list_agc_tracking_events for super::RemoteProcedures { + fn list_agc_tracking_events_then( + &self, + query_json: String, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, __sdk::InternalError>, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, Result>( + "list_agc_tracking_events", + ListAgcTrackingEventsArgs { query_json }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/upload_agc_analytics_batch_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/upload_agc_analytics_batch_procedure.rs new file mode 100644 index 000000000..c6fb47968 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/upload_agc_analytics_batch_procedure.rs @@ -0,0 +1,56 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct UploadAgcAnalyticsBatchArgs { + pub payload_json: String, +} + +impl __sdk::InModule for UploadAgcAnalyticsBatchArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `upload_agc_analytics_batch`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait upload_agc_analytics_batch { + fn upload_agc_analytics_batch(&self, payload_json: String) { + self.upload_agc_analytics_batch_then(payload_json, |_, _| {}); + } + + fn upload_agc_analytics_batch_then( + &self, + payload_json: String, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, __sdk::InternalError>, + ) + Send + + 'static, + ); +} + +impl upload_agc_analytics_batch for super::RemoteProcedures { + fn upload_agc_analytics_batch_then( + &self, + payload_json: String, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, __sdk::InternalError>, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, Result>( + "upload_agc_analytics_batch", + UploadAgcAnalyticsBatchArgs { payload_json }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-module/src/active.rs b/server-rs/crates/spacetime-module/src/active.rs index 04ebe6dfc..de0a60919 100644 --- a/server-rs/crates/spacetime-module/src/active.rs +++ b/server-rs/crates/spacetime-module/src/active.rs @@ -10,6 +10,9 @@ pub(crate) use serde_json::{Map as JsonMap, Value as JsonValue, json}; pub use spacetimedb::{ Identity, ProcedureContext, ReducerContext, ScheduleAt, SpacetimeType, Table, Timestamp, }; +#[path = "agc_analytics.rs"] +mod agc_analytics_storage; +pub use agc_analytics_storage::*; mod admin_account_storage; mod admin_dashboard; #[path = "agc_models.rs"] diff --git a/server-rs/crates/spacetime-module/src/agc_analytics.rs b/server-rs/crates/spacetime-module/src/agc_analytics.rs new file mode 100644 index 000000000..74f78b026 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/agc_analytics.rs @@ -0,0 +1,314 @@ +use crate::*; +use module_runtime::agc_analytics::{ + AgcTrackingCursor, agc_time_micros, agc_tracking_filter_key, validate_agc_analytics_batch, + validate_agc_tracking_query, +}; +use shared_contracts::admin::{ + AdminAgcTrackingEventEntry, AdminAgcTrackingEventListPayload, AdminAgcTrackingEventListQuery, +}; +use shared_contracts::agc_analytics::{AgcAnalyticsAcknowledgement, AgcAnalyticsBatch, Event}; +use std::collections::BTreeMap; + +#[spacetimedb::table( + accessor = agc_tracking_event, + index(accessor = by_agc_tracking_received, btree(columns = [received_at])), + index(accessor = by_agc_tracking_user_received, btree(columns = [user_id, received_at])), + index(accessor = by_agc_tracking_name_received, btree(columns = [event_name, received_at])) +)] +#[derive(Clone, Debug, PartialEq)] +pub struct AgcTrackingEvent { + #[primary_key] + pub event_id: String, + pub schema_version: u32, + pub event_name: String, + pub event_time: Timestamp, + pub user_id: String, + pub editor_session_id: String, + pub project_id: Option, + pub creative_task_id: Option, + pub agent_run_id: Option, + pub agent_turn_id: Option, + pub status: Option, + pub error_code: Option, + pub source: String, + pub client_version: String, + pub properties_json: String, + pub batch_id: String, + pub received_at: Timestamp, +} + +fn event_row( + event: &Event, + batch_id: &str, + received_at: Timestamp, +) -> Result { + let enum_string = |v: serde_json::Value| v.as_str().unwrap_or_default().to_owned(); + Ok(AgcTrackingEvent { + event_id: event.event_id.clone(), + schema_version: event.schema_version, + event_name: event.event_name.clone(), + event_time: Timestamp::from_micros_since_unix_epoch(agc_time_micros(&event.event_time)?), + user_id: event.user_id.clone().ok_or("invalid_batch")?, + editor_session_id: event.editor_session_id.clone(), + project_id: event.project_id.clone(), + creative_task_id: event.creative_task_id.clone(), + agent_run_id: event.agent_run_id.clone(), + agent_turn_id: event.agent_turn_id.clone(), + status: event + .status + .map(|v| enum_string(serde_json::to_value(v).unwrap())), + error_code: event + .error_code + .map(|v| enum_string(serde_json::to_value(v).unwrap())), + source: enum_string(serde_json::to_value(event.source).unwrap()), + client_version: event.client_version.clone(), + properties_json: serde_json::to_string(&event.properties) + .map_err(|_| "invalid_properties")?, + batch_id: batch_id.to_owned(), + received_at, + }) +} + +// 接收批次和时间不属于原始事件内容,重传保留首次值。 +fn same_event(existing: &AgcTrackingEvent, incoming: &AgcTrackingEvent) -> bool { + let mut incoming = incoming.clone(); + incoming.batch_id = existing.batch_id.clone(); + incoming.received_at = existing.received_at; + let same_properties = serde_json::from_str::(&existing.properties_json).ok() + == serde_json::from_str::(&incoming.properties_json).ok(); + incoming.properties_json = existing.properties_json.clone(); + same_properties && existing == &incoming +} + +#[spacetimedb::procedure] +pub fn upload_agc_analytics_batch( + ctx: &mut ProcedureContext, + payload_json: String, +) -> Result { + if payload_json.len() > 2 * 1024 * 1024 { + return Err("invalid_batch".into()); + } + let batch: AgcAnalyticsBatch = + serde_json::from_str(&payload_json).map_err(|_| "invalid_batch")?; + validate_agc_analytics_batch(&batch)?; + let caller = ctx.sender(); + ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + // 同一事务内任一冲突返回 Err,之前插入的行一起回滚。 + for event in &batch.events { + let incoming = event_row(event, &batch.batch_id, tx.timestamp)?; + if let Some(existing) = tx.db.agc_tracking_event().event_id().find(&event.event_id) { + if !same_event(&existing, &incoming) { + return Err("agc_event_conflict".into()); + } + } else { + tx.db.agc_tracking_event().insert(incoming); + } + } + serde_json::to_string(&AgcAnalyticsAcknowledgement { + acknowledged_batch_ids: vec![batch.batch_id.clone()], + event_count: batch.events.len() as u32, + }) + .map_err(|_| "acknowledgement_failed".into()) + }) +} + +fn matches_query( + row: &AgcTrackingEvent, + query: &AdminAgcTrackingEventListQuery, + start: Option, + end: Option, +) -> bool { + query.user_id.as_ref().is_none_or(|v| v == &row.user_id) + && query + .project_id + .as_ref() + .is_none_or(|v| Some(v) == row.project_id.as_ref()) + && query + .creative_task_id + .as_ref() + .is_none_or(|v| Some(v) == row.creative_task_id.as_ref()) + && query + .agent_run_id + .as_ref() + .is_none_or(|v| Some(v) == row.agent_run_id.as_ref()) + && query + .event_name + .as_ref() + .is_none_or(|v| v == &row.event_name) + && query + .client_version + .as_ref() + .is_none_or(|v| v == &row.client_version) + && start.is_none_or(|v| row.event_time.to_micros_since_unix_epoch() >= v) + && end.is_none_or(|v| row.event_time.to_micros_since_unix_epoch() < v) +} + +fn entry(row: AgcTrackingEvent) -> Result { + Ok(AdminAgcTrackingEventEntry { + event_id: row.event_id, + schema_version: row.schema_version, + event_name: row.event_name, + event_time: module_runtime::format_utc_micros(row.event_time.to_micros_since_unix_epoch()), + user_id: row.user_id, + editor_session_id: row.editor_session_id, + project_id: row.project_id, + creative_task_id: row.creative_task_id, + agent_run_id: row.agent_run_id, + agent_turn_id: row.agent_turn_id, + status: row.status, + error_code: row.error_code, + source: row.source, + client_version: row.client_version, + properties: serde_json::from_str(&row.properties_json) + .map_err(|_| "invalid_stored_properties")?, + batch_id: row.batch_id, + received_at: module_runtime::format_utc_micros( + row.received_at.to_micros_since_unix_epoch(), + ), + }) +} + +#[spacetimedb::procedure] +pub fn list_agc_tracking_events( + ctx: &mut ProcedureContext, + query_json: String, +) -> Result { + if query_json.len() > 16384 { + return Err("invalid_agc_query".into()); + } + let query: AdminAgcTrackingEventListQuery = + serde_json::from_str(&query_json).map_err(|_| "invalid_agc_query")?; + let cursor = validate_agc_tracking_query(&query)?; + let start = query + .start_time + .as_deref() + .map(agc_time_micros) + .transpose()?; + let end = query.end_time.as_deref().map(agc_time_micros).transpose()?; + let caller = ctx.sender(); + ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + let snapshot = cursor + .as_ref() + .map(|v| v.snapshot) + .unwrap_or(tx.timestamp.to_micros_since_unix_epoch()); + let limit = query.limit.unwrap_or(50).clamp(1, 200) as usize; + let upper = Timestamp::from_micros_since_unix_epoch(snapshot); + let mut selected = BTreeMap::new(); + // 在数据库事务中扫描完整索引候选,再保留最大的 limit+1 个键。 + // 不依赖 SDK 迭代器顺序,不将任意 LIMIT 截断误作最新一页,内存只保留一页。 + let mut consider = |row: AgcTrackingEvent| { + let key = ( + row.event_time.to_micros_since_unix_epoch(), + row.event_id.clone(), + ); + if row.received_at.to_micros_since_unix_epoch() > snapshot + || cursor + .as_ref() + .is_some_and(|v| key >= (v.event_time, v.event_id.clone())) + || !matches_query(&row, &query, start, end) + { + return; + } + selected.insert(key, row); + if selected.len() > limit + 1 { + selected.pop_first(); + } + }; + if let Some(user_id) = &query.user_id { + for row in tx + .db + .agc_tracking_event() + .by_agc_tracking_user_received() + .filter((user_id.as_str(), ..=upper)) + { + consider(row); + } + } else if let Some(event_name) = &query.event_name { + for row in tx + .db + .agc_tracking_event() + .by_agc_tracking_name_received() + .filter((event_name.as_str(), ..=upper)) + { + consider(row); + } + } else { + for row in tx + .db + .agc_tracking_event() + .by_agc_tracking_received() + .filter(..=upper) + { + consider(row); + } + } + let has_more = selected.len() > limit; + let mut rows: Vec<_> = selected.into_iter().rev().take(limit).collect(); + let next_cursor = if has_more { + let ((event_time, event_id), _) = rows.last().expect("nonempty page"); + Some( + serde_json::to_string(&AgcTrackingCursor { + snapshot, + event_time: *event_time, + event_id: event_id.clone(), + filter_key: agc_tracking_filter_key(&query), + }) + .map_err(|_| "cursor_failed")?, + ) + } else { + None + }; + let entries = rows + .drain(..) + .map(|(_, row)| entry(row)) + .collect::, _>>()?; + serde_json::to_string(&AdminAgcTrackingEventListPayload { + entries, + next_cursor, + }) + .map_err(|_| "query_failed".into()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn agc_replay_compares_business_fields_but_preserves_first_receipt() { + let event: Event = serde_json::from_value(serde_json::json!({ + "schema_version":1,"event_id":"790a1275-a0a0-405c-8bfd-287201bef10a", + "event_name":"editor_session_start","event_time":"2026-09-21T12:00:00.000Z", + "user_id":"a","editor_session_id":"790a1275-a0a0-405c-8bfd-287201bef10a", + "project_id":null,"creative_task_id":null,"agent_run_id":null,"agent_turn_id":null, + "status":"success","error_code":null,"source":"editor","client_version":"1", + "properties":{"entry_source":"direct_launch","first_project_id":null} + })) + .unwrap(); + let first = event_row( + &event, + "batch-1", + Timestamp::from_micros_since_unix_epoch(10), + ) + .unwrap(); + let mut replay = event_row( + &event, + "batch-2", + Timestamp::from_micros_since_unix_epoch(20), + ) + .unwrap(); + replay.properties_json = + r#"{"first_project_id":null,"entry_source":"direct_launch"}"#.into(); + assert!(same_event(&first, &replay)); + replay.user_id = "b".into(); + assert!(!same_event(&first, &replay)); + replay.user_id = "a".into(); + replay.client_version = "2".into(); + assert!(!same_event(&first, &replay)); + } +} diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index 2086c0775..b674363ad 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -173,6 +173,7 @@ macro_rules! migration_tables { profile_wallet_config, analytics_date_dimension, tracking_event, + agc_tracking_event, tracking_daily_stat, profile_task_config, profile_task_progress,