Compare commits

...

10 Commits

Author SHA1 Message Date
lhk229 1a0704e4da 再次合并最新master到埋点开发分支
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m0s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m35s
Project CI / Native shell tests (pull_request) Failing after 2m28s
Project CI / Frontend tests (pull_request) Failing after 2m40s
Project CI / Backend tests (pull_request) Successful in 5m23s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m27s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m11s
Project CI / AI game creator shell web tests (pull_request) Failing after 58s
Project CI / Repository checks (pull_request) Failing after 2m34s
同步主分支最新改动并保留客户端埋点实现
2026-09-21 17:08:31 +00:00
lhk229 9d060de185 合并最新master并兼容策划附件导入与埋点
合并主分支085181c79的策划附件导入改动
同时保留首页策划工作区文件导入与项目埋点关联
更新首页对话测试以校验已有埋点尝试标识
2026-09-21 16:42:41 +00:00
lhk229 8d83b14ae5 实现客户端埋点上传清理与后台查询
新增客户端每十五分钟上传及入库确认后批次清理
新增私有事件表、原子幂等入库及鉴权查询接口
新增后台客户端埋点明细、筛选、分页与权限
补充定向测试、真实数据库联调脚本及工程验收文档
2026-09-21 16:38:09 +00:00
lhk229 f983663caf 合并最新master到埋点开发分支
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m57s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m49s
Project CI / Native shell tests (pull_request) Failing after 2m1s
Project CI / Backend tests (pull_request) Successful in 5m0s
Project CI / Frontend tests (pull_request) Failing after 2m45s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m41s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m44s
Project CI / AI game creator shell web tests (pull_request) Failing after 1m10s
Project CI / Repository checks (pull_request) Failing after 2m31s
同步主分支至2d61a4c05并保留客户端本地埋点实现
合并决策记录顶部新增内容,保留双方条目
验证客户端Rust编译、TypeScript、编码与文档索引通过
2026-09-21 12:25:38 +00:00
lhk229 9f225b7a41 实现客户端本地埋点与创作链路采集
新增统一事件合同、非阻塞明文队列、批次封存及保留清理
接入会话前台时长、项目创建打开、创作提交和双智能体运行结果
按策划阶段推进及已接入成果路径记录变化、预览就绪与保存
补齐身份隔离、异常会话和同一宿主创作链路验证
同步团队方案与验收证据,本期不上传、不加密、不扩展资源操作采集
2026-09-21 12:18:47 +00:00
lhk229 5087b600a2 合并最新master到埋点开发分支
同步origin/master最新变更。

合并决策记录中的双方新增条目。
2026-09-21 08:03:52 +00:00
lhk229 8fd85d0426 合并最新master到埋点开发分支
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
同步macOS单架构发布及渠道测试修复
保留埋点方案与macOS发布两项共享决策记录
2026-09-21 04:55:52 +00:00
lhk229 b840973d82 Merge remote-tracking branch 'origin/master' into feat/agc_track
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
合并最新master
2026-09-21 04:18:38 +00:00
lhk229 ca82e6b745 合并最新 master 到埋点方案分支
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
同步主分支最新客户端和运维变更。
解决共享决策记录顶部冲突,保留双方新增记录。
2026-09-21 04:16:32 +00:00
lhk229 3e657e6a6a 文档:纳入客户端本地埋点方案与需求来源
新增客户端本地埋点与后续主站入库契约,明确本期不加密、不上传。
保存原始需求为仓库内历史来源,消除对个人文档目录的引用依赖。
更新文档入口、生命周期索引和团队共享决策。
2026-09-21 04:14:57 +00:00
84 changed files with 13211 additions and 164 deletions
+31 -8
View File
@@ -7,6 +7,7 @@ import {
getAdminFeatureGateConfig, getAdminFeatureGateConfig,
getAdminUserDetail, getAdminUserDetail,
importAdminAgcTemplates, importAdminAgcTemplates,
listAdminAgcTrackingEvents,
listAdminRechargeOrders, listAdminRechargeOrders,
reconcileAdminUserConsumption, reconcileAdminUserConsumption,
resolveAdminRechargeRefundManualReview, resolveAdminRechargeRefundManualReview,
@@ -21,6 +22,30 @@ afterEach(() => {
vi.unstubAllGlobals(); 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 () => { test('模板管理读取和更新复用认证封装,提交 revision 和封面但不提交 ZIP 或版本', async () => {
const library = { revision: 'revision-new', writable: true, templates: [] }; const library = { revision: 'revision-new', writable: true, templates: [] };
const fetchMock = vi.fn().mockImplementation( const fetchMock = vi.fn().mockImplementation(
@@ -81,14 +106,12 @@ test('模板批量导入走 multipart,不预设 JSON Content-Type', async () =
}, },
], ],
}; };
const fetchMock = vi const fetchMock = vi.fn().mockImplementation(
.fn() async () =>
.mockImplementation( new Response(JSON.stringify({ ok: true, data: imported }), {
async () => status: 200,
new Response(JSON.stringify({ ok: true, data: imported }), { }),
status: 200, );
}),
);
vi.stubGlobal('fetch', fetchMock); vi.stubGlobal('fetch', fetchMock);
const form = new FormData(); const form = new FormData();
+27
View File
@@ -1,6 +1,8 @@
import type { import type {
AdminAccountListResponse, AdminAccountListResponse,
AdminAgcTemplateLibraryResponse, AdminAgcTemplateLibraryResponse,
AdminAgcTrackingEventListResponse,
AdminAgcTrackingEventQuery,
AdminConfirmEditorShowcaseCampaignImageUploadRequest, AdminConfirmEditorShowcaseCampaignImageUploadRequest,
AdminCreateAccountRequest, AdminCreateAccountRequest,
AdminCreateAccountResponse, AdminCreateAccountResponse,
@@ -406,6 +408,31 @@ export function listAdminTrackingEventKeys(token: string) {
); );
} }
export function listAdminAgcTrackingEvents(
token: string,
query: AdminAgcTrackingEventQuery = {},
) {
return request<AdminAgcTrackingEventListResponse>(
`/admin/api/agc/tracking-events${buildQueryString((params) => {
for (const key of [
'userId',
'projectId',
'creativeTaskId',
'agentRunId',
'eventName',
'clientVersion',
'startTime',
'endTime',
'cursor',
] as const) {
appendQueryParam(params, key, query[key]);
}
appendNumericQueryParam(params, 'limit', query.limit);
})}`,
{ token },
);
}
export function listAdminErrorReports( export function listAdminErrorReports(
token: string, token: string,
query: { query: {
+38
View File
@@ -820,6 +820,44 @@ export interface AdminTrackingEventListResponse {
entries: AdminTrackingEventEntryPayload[]; entries: AdminTrackingEventEntryPayload[];
} }
export interface AdminAgcTrackingEventQuery {
userId?: string;
projectId?: string;
creativeTaskId?: string;
agentRunId?: string;
eventName?: string;
clientVersion?: string;
startTime?: string;
endTime?: string;
cursor?: string;
limit?: number;
}
export interface AdminAgcTrackingEventEntry {
eventId: string;
schemaVersion: number;
eventName: string;
eventTime: string;
userId: string;
editorSessionId: string;
projectId: string | null;
creativeTaskId: string | null;
agentRunId: string | null;
agentTurnId: string | null;
status: string | null;
errorCode: string | null;
source: string;
clientVersion: string;
properties: Record<string, unknown>;
batchId: string;
receivedAt: string;
}
export interface AdminAgcTrackingEventListResponse {
entries: AdminAgcTrackingEventEntry[];
nextCursor: string | null;
}
export interface AdminTrackingEventKeyPayload { export interface AdminTrackingEventKeyPayload {
eventKey: string; eventKey: string;
eventTitle: string; eventTitle: string;
+7
View File
@@ -20,6 +20,7 @@ import {
import { AdminAccountsPage } from '../pages/AdminAccountsPage'; import { AdminAccountsPage } from '../pages/AdminAccountsPage';
import { AdminAgcModelsPage } from '../pages/AdminAgcModelsPage'; import { AdminAgcModelsPage } from '../pages/AdminAgcModelsPage';
import { AdminAgcTemplatesPage } from '../pages/AdminAgcTemplatesPage'; import { AdminAgcTemplatesPage } from '../pages/AdminAgcTemplatesPage';
import { AdminAgcTrackingPage } from '../pages/AdminAgcTrackingPage';
import { AdminDashboardPage } from '../pages/AdminDashboardPage'; import { AdminDashboardPage } from '../pages/AdminDashboardPage';
import { AdminDatabaseTablesPage } from '../pages/AdminDatabaseTablesPage'; import { AdminDatabaseTablesPage } from '../pages/AdminDatabaseTablesPage';
import { AdminDebugHttpPage } from '../pages/AdminDebugHttpPage'; import { AdminDebugHttpPage } from '../pages/AdminDebugHttpPage';
@@ -232,6 +233,12 @@ export function AdminApp() {
onUnauthorized={handleUnauthorized} onUnauthorized={handleUnauthorized}
/> />
) : null} ) : null}
{activeRouteId === 'agc-tracking' ? (
<AdminAgcTrackingPage
token={token}
onUnauthorized={handleUnauthorized}
/>
) : null}
{activeRouteId === 'error-reports' ? ( {activeRouteId === 'error-reports' ? (
<AdminErrorReportsPage <AdminErrorReportsPage
token={token} token={token}
+1
View File
@@ -39,6 +39,7 @@ const routeIcons = {
tables: Database, tables: Database,
debug: Bug, debug: Bug,
tracking: Table2, tracking: Table2,
'agc-tracking': Table2,
'error-reports': Bug, 'error-reports': Bug,
'gray-release': GitBranch, 'gray-release': GitBranch,
redeem: TicketPercent, redeem: TicketPercent,
@@ -8,6 +8,22 @@ import {
routeHash, routeHash,
} from './adminRoutes'; } from './adminRoutes';
test('客户端埋点路由遵守成员页签权限', () => {
expect(resolveAdminRoute('#agc-tracking')).toBe('agc-tracking');
expect(
getAccessibleAdminRoutes({
accountRole: 'member',
tabPermissions: ['agc-tracking'],
}).map((route) => route.id),
).toEqual(['agc-tracking']);
expect(
getAccessibleAdminRoutes({
accountRole: 'member',
tabPermissions: ['tracking'],
}).some((route) => route.id === 'agc-tracking'),
).toBe(false);
});
test('后台默认进入 Dashboard', () => { test('后台默认进入 Dashboard', () => {
expect(adminRoutes[0]).toEqual({ expect(adminRoutes[0]).toEqual({
id: 'dashboard', id: 'dashboard',
+2
View File
@@ -5,6 +5,7 @@ export type AdminRouteId =
| 'tables' | 'tables'
| 'debug' | 'debug'
| 'tracking' | 'tracking'
| 'agc-tracking'
| 'error-reports' | 'error-reports'
| 'gray-release' | 'gray-release'
| 'redeem' | 'redeem'
@@ -40,6 +41,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
{ id: 'tables', label: '表查询', hash: '#tables' }, { id: 'tables', label: '表查询', hash: '#tables' },
{ id: 'debug', label: 'API 调试', hash: '#debug' }, { id: 'debug', label: 'API 调试', hash: '#debug' },
{ id: 'tracking', label: '埋点数据', hash: '#tracking' }, { id: 'tracking', label: '埋点数据', hash: '#tracking' },
{ id: 'agc-tracking', label: '客户端埋点', hash: '#agc-tracking' },
{ id: 'error-reports', label: '错误报告', hash: '#error-reports' }, { id: 'error-reports', label: '错误报告', hash: '#error-reports' },
{ id: 'gray-release', label: '灰度发布', hash: '#gray-release' }, { id: 'gray-release', label: '灰度发布', hash: '#gray-release' },
{ id: 'redeem', label: '兑换码', hash: '#redeem' }, { id: 'redeem', label: '兑换码', hash: '#redeem' },
@@ -0,0 +1,129 @@
// @vitest-environment jsdom
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import {
AdminApiError,
listAdminAgcTrackingEvents,
} from '../api/adminApiClient';
import type { AdminAgcTrackingEventEntry } from '../api/adminApiTypes';
import { AdminAgcTrackingPage } from './AdminAgcTrackingPage';
vi.mock('../api/adminApiClient', async (original) => ({
...(await original<typeof import('../api/adminApiClient')>()),
listAdminAgcTrackingEvents: vi.fn(),
}));
vi.mock('../components/AdminUserReferenceButton', () => ({
AdminUserReferenceButton: () => null,
}));
const entry: AdminAgcTrackingEventEntry = {
eventId: 'event-1',
schemaVersion: 1,
eventName: 'project_save',
eventTime: '2026-09-21T12:00:00.123Z',
userId: 'user-1',
editorSessionId: 'session-1',
projectId: 'project-1',
creativeTaskId: 'project-1',
agentRunId: null,
agentTurnId: null,
status: 'success',
errorCode: null,
source: 'gui',
clientVersion: '1.0',
properties: { reason: 'manual' },
batchId: 'batch-1',
receivedAt: '2026-09-21T12:15:00.000Z',
};
beforeEach(() => {
vi.mocked(listAdminAgcTrackingEvents).mockReset();
});
afterEach(cleanup);
test('翻页沿用游标,筛选和刷新从第一页重新查询,详情保留 null 和事件属性', async () => {
vi.mocked(listAdminAgcTrackingEvents)
.mockResolvedValueOnce({ entries: [entry], nextCursor: 'cursor-page-2' })
.mockResolvedValueOnce({
entries: [{ ...entry, eventId: 'event-2' }],
nextCursor: null,
})
.mockResolvedValue({ entries: [entry], nextCursor: 'cursor-new' });
render(<AdminAgcTrackingPage token="admin-token" onUnauthorized={vi.fn()} />);
await screen.findByText('保存项目', { selector: 'td' });
expect(listAdminAgcTrackingEvents).toHaveBeenLastCalledWith('admin-token', {
cursor: undefined,
limit: 50,
});
fireEvent.click(screen.getByText('查看详情'));
const dialog = screen.getByRole('dialog');
expect(within(dialog).getAllByText('—').length).toBe(3);
expect(within(dialog).getByText(/"reason": "manual"/)).toBeTruthy();
expect(within(dialog).getByText(entry.eventTime)).toBeTruthy();
fireEvent.click(within(dialog).getByLabelText('关闭详情'));
fireEvent.click(screen.getByText('下一页'));
await waitFor(() =>
expect(listAdminAgcTrackingEvents).toHaveBeenLastCalledWith('admin-token', {
cursor: 'cursor-page-2',
limit: 50,
}),
);
await waitFor(() =>
expect(screen.getByText('查询').closest('button')?.disabled).toBe(false),
);
fireEvent.click(screen.getByText('上一页'));
await screen.findByText('第 1 页');
expect(listAdminAgcTrackingEvents).toHaveBeenCalledTimes(2);
fireEvent.change(screen.getByLabelText('项目 ID'), {
target: { value: 'project-2' },
});
fireEvent.click(screen.getByText('查询'));
await waitFor(() =>
expect(listAdminAgcTrackingEvents).toHaveBeenLastCalledWith(
'admin-token',
expect.objectContaining({
projectId: 'project-2',
cursor: undefined,
limit: 50,
}),
),
);
expect(screen.getByText('第 1 页')).toBeTruthy();
await waitFor(() =>
expect(screen.getByText('刷新').closest('button')?.disabled).toBe(false),
);
fireEvent.click(screen.getByText('刷新'));
await waitFor(() =>
expect(listAdminAgcTrackingEvents).toHaveBeenCalledTimes(4),
);
});
test('空结果、权限失败与登录失效沿用后台反馈', async () => {
const unauthorized = vi.fn();
vi.mocked(listAdminAgcTrackingEvents)
.mockResolvedValueOnce({ entries: [], nextCursor: null })
.mockRejectedValueOnce(
new AdminApiError({ status: 403, message: '无权访问客户端埋点' }),
)
.mockRejectedValueOnce(
new AdminApiError({ status: 401, message: '登录失效' }),
);
render(<AdminAgcTrackingPage token="token" onUnauthorized={unauthorized} />);
await screen.findByText('暂无客户端埋点数据');
fireEvent.click(screen.getByText('刷新'));
expect((await screen.findByRole('alert')).textContent).toContain(
'无权访问客户端埋点',
);
fireEvent.click(screen.getByText('刷新'));
await waitFor(() =>
expect(unauthorized).toHaveBeenCalledWith('登录状态已失效'),
);
});
@@ -0,0 +1,379 @@
import { Modal } from '@genarrative/shared/components';
import { FormEvent, useEffect, useRef, useState } from 'react';
import { listAdminAgcTrackingEvents } from '../api/adminApiClient';
import type {
AdminAgcTrackingEventEntry,
AdminAgcTrackingEventListResponse,
AdminAgcTrackingEventQuery,
} from '../api/adminApiTypes';
import { AdminUserReferenceButton } from '../components/AdminUserReferenceButton';
import { handlePageError } from './pageUtils';
const eventLabels: Record<string, string> = {
editor_session_start: '编辑器会话开始',
editor_session_end: '编辑器会话结束',
editor_focus_start: '编辑器获得焦点',
editor_focus_end: '编辑器失去焦点',
project_create_success: '项目创建成功',
project_open: '打开项目',
creative_task_submit: '首次提交创作目标',
agent_run_completed: 'Agent 运行完成',
agent_run_failed: 'Agent 运行失败',
project_revision_created: '项目产生修改',
preview_ready: '预览就绪',
project_save: '保存项目',
};
const fieldLabels: Record<keyof AdminAgcTrackingEventEntry, string> = {
eventId: '事件 ID',
schemaVersion: '事件版本',
eventName: '事件类型',
eventTime: '发生时间',
userId: '用户 ID',
editorSessionId: '编辑器会话 ID',
projectId: '项目 ID',
creativeTaskId: '创作目标 ID',
agentRunId: 'Agent run ID',
agentTurnId: 'Agent turn ID',
status: '结果',
errorCode: '错误码',
source: '来源',
clientVersion: '客户端版本',
properties: '事件属性',
batchId: '批次 ID',
receivedAt: '入库时间',
};
function formatTime(value: string) {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('zh-CN');
}
export function AdminAgcTrackingPage({
token,
onUnauthorized,
}: {
token: string;
onUnauthorized: (message?: string) => void;
}) {
const [filters, setFilters] = useState({
userId: '',
projectId: '',
eventName: '',
startTime: '',
endTime: '',
});
const [query, setQuery] = useState<AdminAgcTrackingEventQuery>({});
const [cursors, setCursors] = useState<Array<string | undefined>>([
undefined,
]);
const [page, setPage] = useState(0);
const [refresh, setRefresh] = useState(0);
const [entries, setEntries] = useState<AdminAgcTrackingEventEntry[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [detail, setDetail] = useState<AdminAgcTrackingEventEntry | null>(null);
const cursor = cursors[page];
// 首页没有入站游标,返回首页时保留原快照,刷新才获取新数据。
const firstPage = useRef<{
token: string;
query: AdminAgcTrackingEventQuery;
response: AdminAgcTrackingEventListResponse;
} | null>(null);
useEffect(() => {
const saved = firstPage.current;
if (!cursor && saved?.token === token && saved.query === query) {
setEntries(saved.response.entries);
setNextCursor(saved.response.nextCursor);
setError('');
setLoading(false);
return;
}
let active = true;
setLoading(true);
setError('');
setEntries([]);
setNextCursor(null);
void listAdminAgcTrackingEvents(token, { ...query, cursor, limit: 50 })
.then((response) => {
if (!active) return;
if (!cursor) firstPage.current = { token, query, response };
setEntries(response.entries);
setNextCursor(response.nextCursor);
})
.catch((failure: unknown) => {
if (active) handlePageError(failure, onUnauthorized, setError);
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, [token, query, cursor, refresh, onUnauthorized]);
function resetPages() {
firstPage.current = null;
setCursors([undefined]);
setPage(0);
setDetail(null);
}
function search(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (
filters.startTime &&
filters.endTime &&
new Date(filters.startTime) >= new Date(filters.endTime)
) {
setError('结束时间必须晚于开始时间');
return;
}
resetPages();
setQuery({
userId: filters.userId.trim(),
projectId: filters.projectId.trim(),
eventName: filters.eventName,
startTime: filters.startTime
? new Date(filters.startTime).toISOString()
: undefined,
endTime: filters.endTime
? new Date(filters.endTime).toISOString()
: undefined,
});
}
function related(
key: 'creativeTaskId' | 'agentRunId' | 'clientVersion',
value: string,
) {
resetPages();
setFilters({
userId: '',
projectId: '',
eventName: '',
startTime: '',
endTime: '',
});
setQuery({ [key]: value });
}
return (
<section className="admin-page admin-page-wide">
<div className="admin-page-heading">
<div>
<h2></h2>
<p></p>
</div>
<button
type="button"
className="admin-secondary-button"
disabled={loading}
onClick={() => {
resetPages();
setRefresh((value) => value + 1);
}}
>
</button>
</div>
<form className="admin-panel admin-form" onSubmit={search}>
<div className="admin-filter-grid">
{(['userId', 'projectId'] as const).map((key) => (
<label key={key} className="admin-field">
<span>{fieldLabels[key]}</span>
<input
value={filters[key]}
onChange={(event) =>
setFilters({ ...filters, [key]: event.target.value })
}
/>
</label>
))}
<label className="admin-field">
<span></span>
<select
value={filters.eventName}
onChange={(event) =>
setFilters({ ...filters, eventName: event.target.value })
}
>
<option value=""></option>
{Object.entries(eventLabels).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</label>
{(['startTime', 'endTime'] as const).map((key) => (
<label key={key} className="admin-field">
<span>
{key === 'startTime'
? '发生时间起点(含)'
: '发生时间终点(不含)'}
</span>
<input
type="datetime-local"
value={filters[key]}
onChange={(event) =>
setFilters({ ...filters, [key]: event.target.value })
}
/>
</label>
))}
</div>
<div className="admin-action-row">
<button
type="submit"
className="admin-primary-button"
disabled={loading}
>
</button>
</div>
{(['creativeTaskId', 'agentRunId', 'clientVersion'] as const)
.filter((key) => query[key])
.map((key) => (
<p key={key}>
{fieldLabels[key]}{query[key]}
</p>
))}
</form>
{error ? (
<p role="alert" className="admin-error-message">
{error}
</p>
) : null}
<div className="admin-panel">
<div className="admin-table-wrap">
<table className="admin-table">
<thead>
<tr>
{[
'入库时间',
'发生时间',
'用户',
'事件名称',
'项目',
'来源',
'结果',
'客户端版本',
'详情',
].map((label) => (
<th key={label}>{label}</th>
))}
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr key={entry.eventId}>
<td>{formatTime(entry.receivedAt)}</td>
<td>{formatTime(entry.eventTime)}</td>
<td>
{entry.userId}
<AdminUserReferenceButton
token={token}
userId={entry.userId}
onUnauthorized={onUnauthorized}
/>
</td>
<td>{eventLabels[entry.eventName] ?? entry.eventName}</td>
<td>{entry.projectId ?? '—'}</td>
<td>{entry.source}</td>
<td>{entry.status ?? '—'}</td>
<td>{entry.clientVersion}</td>
<td>
<button
type="button"
className="admin-ghost-button"
onClick={() => setDetail(entry)}
>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{loading ? (
<p role="status"></p>
) : !error && entries.length === 0 ? (
<p></p>
) : null}
<div className="admin-action-row">
<button
type="button"
className="admin-secondary-button"
disabled={loading || page === 0}
onClick={() => setPage((value) => value - 1)}
>
</button>
<span> {page + 1} </span>
<button
type="button"
className="admin-secondary-button"
disabled={loading || !nextCursor}
onClick={() => {
if (!nextCursor) return;
setCursors([...cursors.slice(0, page + 1), nextCursor]);
setPage(page + 1);
}}
>
</button>
</div>
</div>
{detail ? (
<Modal
open
title="客户端埋点详情"
closeLabel="关闭详情"
onClose={() => setDetail(null)}
className="genarrative-ui"
>
<dl>
{(
Object.keys(fieldLabels) as Array<
keyof AdminAgcTrackingEventEntry
>
)
.filter((key) => key !== 'properties')
.map((key) => (
<div key={key}>
<dt>{fieldLabels[key]}</dt>
<dd style={{ overflowWrap: 'anywhere' }}>
{detail[key] == null ? '—' : String(detail[key])}
</dd>
</div>
))}
</dl>
<div className="admin-action-row">
{(['creativeTaskId', 'agentRunId', 'clientVersion'] as const).map(
(key) =>
detail[key] ? (
<button
type="button"
key={key}
className="admin-secondary-button"
onClick={() => related(key, detail[key]!)}
>
{fieldLabels[key]}
</button>
) : null,
)}
</div>
<h3></h3>
<pre style={{ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }}>
{JSON.stringify(detail.properties, null, 2)}
</pre>
</Modal>
) : null}
</section>
);
}
File diff suppressed because it is too large Load Diff
@@ -93,10 +93,17 @@ pub(super) struct ExecutionLedger {
pub(super) plan: Option<Value>, pub(super) plan: Option<Value>,
#[serde(default)] #[serde(default)]
pub(super) last_failed_write_revision: Option<u64>, pub(super) last_failed_write_revision: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(super) analytics_run: Option<crate::analytics::run::Metadata>,
} }
struct SessionData { struct SessionData {
ledger: ExecutionLedger, ledger: ExecutionLedger,
analytics_capture: Option<(
crate::analytics::contract::Context,
crate::analytics::store::AnalyticsWriter,
)>,
analytics_output_revision: Option<u64>,
started: Instant, started: Instant,
initial_elapsed_ms: u64, initial_elapsed_ms: u64,
lease_started: BTreeMap<String, Instant>, lease_started: BTreeMap<String, Instant>,
@@ -153,6 +160,8 @@ fn executor_digest(path: &Path) -> Result<String, String> {
pub(super) struct ExecutionSession { pub(super) struct ExecutionSession {
pub(super) root: PathBuf, pub(super) root: PathBuf,
/// 本次确实新建执行账本;恢复和旧预算迁移均不构成新的用户受理。
pub(super) newly_accepted: bool,
state_path: PathBuf, state_path: PathBuf,
_owner: File, _owner: File,
data: Mutex<SessionData>, data: Mutex<SessionData>,
@@ -216,6 +225,16 @@ pub(crate) struct WritePermit {
id: String, id: String,
} }
impl WritePermit { impl WritePermit {
pub(super) fn record_analytics_revision(
&self,
revision: u64,
change_kind: crate::analytics::contract::ChangeKind,
files_changed_count: u64,
) {
self.session
.record_analytics_revision(revision, change_kind, files_changed_count);
}
pub(crate) fn run<T>(&self, write: impl FnOnce() -> Result<T, String>) -> Result<T, String> { pub(crate) fn run<T>(&self, write: impl FnOnce() -> Result<T, String>) -> Result<T, String> {
let mut data = self.session.lock()?; let mut data = self.session.lock()?;
self.session.tick_locked(&mut data)?; self.session.tick_locked(&mut data)?;
@@ -401,6 +420,7 @@ pub(super) async fn begin(
prompt: &str, prompt: &str,
requires_contract: bool, requires_contract: bool,
config: DirectValidationConfig, config: DirectValidationConfig,
analytics_run: Option<crate::analytics::run::Metadata>,
) -> Result<ExecutionSessionGuard, String> { ) -> Result<ExecutionSessionGuard, String> {
let root = root.to_path_buf(); let root = root.to_path_buf();
let prompt_hash = hash(prompt.as_bytes()); let prompt_hash = hash(prompt.as_bytes());
@@ -408,13 +428,14 @@ pub(super) async fn begin(
let turn = super::direct_taonier_active_invocation_id_at(&root)?; let turn = super::direct_taonier_active_invocation_id_at(&root)?;
let host = crate::game_creator_runtime_config_dir() let host = crate::game_creator_runtime_config_dir()
.ok_or("direct-execution-host: 需要客户端私有配置目录,CLI 请提供 --config-dir")?; .ok_or("direct-execution-host: 需要客户端私有配置目录,CLI 请提供 --config-dir")?;
open_at( open_with_analytics_at(
&host.join("direct-executions"), &host.join("direct-executions"),
&root, &root,
&turn, &turn,
&prompt_hash, &prompt_hash,
requires_contract, requires_contract,
&config, &config,
analytics_run,
) )
}) })
.await .await
@@ -462,6 +483,26 @@ pub(super) fn open_at(
request_hash: &str, request_hash: &str,
requires_contract: bool, requires_contract: bool,
config: &DirectValidationConfig, config: &DirectValidationConfig,
) -> Result<Arc<ExecutionSession>, String> {
open_with_analytics_at(
host,
root,
turn,
request_hash,
requires_contract,
config,
None,
)
}
pub(super) fn open_with_analytics_at(
host: &Path,
root: &Path,
turn: &str,
request_hash: &str,
requires_contract: bool,
config: &DirectValidationConfig,
analytics_run: Option<crate::analytics::run::Metadata>,
) -> Result<Arc<ExecutionSession>, String> { ) -> Result<Arc<ExecutionSession>, String> {
config.validate()?; config.validate()?;
let root = root let root = root
@@ -521,6 +562,7 @@ pub(super) fn open_at(
}; };
let project_id = super::read_existing_manifest_for_project(&root)?.project_id; let project_id = super::read_existing_manifest_for_project(&root)?.project_id;
let is_new = existing.is_none(); let is_new = existing.is_none();
let mut newly_accepted = is_new;
let mut ledger = existing.unwrap_or_else(|| ExecutionLedger { let mut ledger = existing.unwrap_or_else(|| ExecutionLedger {
schema_version: SCHEMA.into(), schema_version: SCHEMA.into(),
client_turn_id: turn.into(), client_turn_id: turn.into(),
@@ -546,6 +588,7 @@ pub(super) fn open_at(
delivery_reviews: 0, delivery_reviews: 0,
plan: None, plan: None,
last_failed_write_revision: None, last_failed_write_revision: None,
analytics_run,
}); });
if is_new { if is_new {
// 只继承旧项目账本的消费量,绝不把可编辑的旧成功回执提升为宿主证据。 // 只继承旧项目账本的消费量,绝不把可编辑的旧成功回执提升为宿主证据。
@@ -558,6 +601,8 @@ pub(super) fn open_at(
512 * 1024, 512 * 1024,
)?; )?;
if let Some(legacy) = legacy { if let Some(legacy) = legacy {
newly_accepted = false;
ledger.analytics_run = None;
let used = legacy["usedRuns"] let used = legacy["usedRuns"]
.as_u64() .as_u64()
.and_then(|n| u32::try_from(n).ok()); .and_then(|n| u32::try_from(n).ok());
@@ -614,10 +659,13 @@ pub(super) fn open_at(
let (changed, _) = tokio::sync::watch::channel(ledger.revision); let (changed, _) = tokio::sync::watch::channel(ledger.revision);
let session = Arc::new(ExecutionSession { let session = Arc::new(ExecutionSession {
root, root,
newly_accepted,
state_path, state_path,
_owner: owner, _owner: owner,
data: Mutex::new(SessionData { data: Mutex::new(SessionData {
ledger, ledger,
analytics_capture: None,
analytics_output_revision: None,
started: Instant::now(), started: Instant::now(),
initial_elapsed_ms, initial_elapsed_ms,
lease_started: BTreeMap::new(), lease_started: BTreeMap::new(),
@@ -738,6 +786,10 @@ impl ExecutionSession {
pub(super) fn cancel_flag(&self) -> Arc<std::sync::atomic::AtomicBool> { pub(super) fn cancel_flag(&self) -> Arc<std::sync::atomic::AtomicBool> {
Arc::clone(&self.cancellation) Arc::clone(&self.cancellation)
} }
pub(super) fn was_aborted(&self) -> bool {
self.abort_requested
.load(std::sync::atomic::Ordering::Acquire)
}
pub(super) fn record_delivery_review(&self) -> Result<u32, String> { pub(super) fn record_delivery_review(&self) -> Result<u32, String> {
let mut data = self.lock()?; let mut data = self.lock()?;
if data.ledger.phase.is_terminal() { if data.ledger.phase.is_terminal() {
@@ -765,6 +817,74 @@ impl ExecutionSession {
self.commit(&mut data, next)?; self.commit(&mut data, next)?;
Ok(json!({"plan":plan,"revision":data.ledger.revision,"acceptancePassed":false})) Ok(json!({"plan":plan,"revision":data.ledger.revision,"acceptancePassed":false}))
} }
pub(super) fn set_analytics_capture(
&self,
capture: Option<(
crate::analytics::contract::Context,
crate::analytics::store::AnalyticsWriter,
)>,
) {
let Ok(mut data) = self.data.try_lock() else {
return;
};
data.analytics_capture = capture.and_then(|(mut context, writer)| {
// 恢复或账号切换后仍归属于真实受理的原 run。
context.route = data.ledger.analytics_run.as_ref()?.context.route.clone();
Some((context, writer))
});
}
pub(super) fn analytics_capture(
&self,
) -> Option<(
crate::analytics::contract::Context,
crate::analytics::store::AnalyticsWriter,
)> {
self.data.try_lock().ok()?.analytics_capture.clone()
}
pub(super) fn record_analytics_revision(
&self,
revision: u64,
change_kind: crate::analytics::contract::ChangeKind,
files_changed_count: u64,
) {
use crate::analytics::contract::{RevisionCreated, RevisionSource, Source};
if files_changed_count == 0 {
return;
}
let Ok(mut data) = self.data.try_lock() else {
return;
};
if data.ledger.analytics_run.is_none() {
return;
}
data.analytics_output_revision =
Some(data.analytics_output_revision.unwrap_or(0).max(revision));
let capture = data.analytics_capture.clone();
let project_id = data.ledger.project_id.clone();
drop(data);
crate::analytics::project::revision(
capture,
&project_id,
Source::Direct,
RevisionCreated {
revision_id: revision.to_string(),
revision_source: RevisionSource::Agent,
change_kind,
files_changed_count: Some(files_changed_count),
},
);
}
pub(super) fn analytics_output_revision(&self) -> Option<String> {
self.data
.try_lock()
.ok()?
.analytics_output_revision
.map(|revision| revision.to_string())
}
pub(super) fn snapshot(&self) -> Result<ExecutionLedger, String> { pub(super) fn snapshot(&self) -> Result<ExecutionLedger, String> {
let data = self.lock()?; let data = self.lock()?;
let mut state = data.ledger.clone(); let mut state = data.ledger.clone();
@@ -1,5 +1,74 @@
use super::*; use super::*;
fn analytics_metadata(user: &str) -> crate::analytics::run::Metadata {
use crate::analytics::contract::{Context, Route, RunSource, Source};
crate::analytics::run::Metadata::new(
Context {
route: Route::from_identity(Some(user.into()), Some("https://example.com")),
editor_session_id: uuid::Uuid::new_v4().to_string(),
client_version: "1.0.0".into(),
},
Source::Direct,
RunSource::UserSubmit,
)
}
#[test]
fn run_metadata_is_persisted_with_new_ledger_and_replay_keeps_original_identity() {
let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("project");
crate::init_local_game_project_at(&root, "analytics-run", "执行身份").unwrap();
let original = analytics_metadata("A");
let host = temp.path().join("host");
let session = open_with_analytics_at(
&host,
&root,
"turn",
&hash(b"request"),
false,
&Default::default(),
Some(original.clone()),
)
.unwrap();
assert!(session.newly_accepted);
assert_eq!(
session.snapshot().unwrap().analytics_run,
Some(original.clone())
);
drop(session);
let replay = open_with_analytics_at(
&host,
&root,
"turn",
&hash(b"request"),
false,
&Default::default(),
Some(analytics_metadata("B")),
)
.unwrap();
assert!(!replay.newly_accepted);
assert_eq!(replay.snapshot().unwrap().analytics_run, Some(original));
}
#[test]
fn legacy_run_without_metadata_is_not_assigned_current_users_identity() {
let (temp, session) = fixture(Default::default());
let root = session.root.clone();
assert!(session.snapshot().unwrap().analytics_run.is_none());
drop(session);
let replay = open_with_analytics_at(
&temp.path().join("host"),
&root,
"turn-test",
&hash(b"request"),
false,
&Default::default(),
Some(analytics_metadata("B")),
)
.unwrap();
assert!(replay.snapshot().unwrap().analytics_run.is_none());
}
fn fixture(config: DirectValidationConfig) -> (tempfile::TempDir, Arc<ExecutionSession>) { fn fixture(config: DirectValidationConfig) -> (tempfile::TempDir, Arc<ExecutionSession>) {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("project"); let root = temp.path().join("project");
@@ -13,6 +82,7 @@ fn fixture(config: DirectValidationConfig) -> (tempfile::TempDir, Arc<ExecutionS
&config, &config,
) )
.unwrap(); .unwrap();
assert!(session.newly_accepted);
session session
.freeze_contract(json!({"requirements":[{"id":"test"}]})) .freeze_contract(json!({"requirements":[{"id":"test"}]}))
.unwrap(); .unwrap();
@@ -159,6 +229,7 @@ fn reopened_budget_and_deadline_cannot_be_increased_by_configuration() {
) )
.unwrap(); .unwrap();
let state = reopened.snapshot().unwrap(); let state = reopened.snapshot().unwrap();
assert!(!reopened.newly_accepted);
assert_eq!(state.delivery_reviews, 1); assert_eq!(state.delivery_reviews, 1);
assert_eq!( assert_eq!(
( (
@@ -278,6 +349,7 @@ fn legacy_budget_is_inherited_without_trusting_project_success_evidence() {
&Default::default(), &Default::default(),
) )
.unwrap(); .unwrap();
assert!(!session.newly_accepted);
assert!(session.admit(EffectKind::Execute, None).is_err()); assert!(session.admit(EffectKind::Execute, None).is_err());
let state = session.snapshot().unwrap(); let state = session.snapshot().unwrap();
assert_eq!(state.used_passes, 2); assert_eq!(state.used_passes, 2);
@@ -229,6 +229,40 @@ fn target_fingerprints(targets: &[PatchTarget]) -> BTreeMap<String, Option<Strin
.collect() .collect()
} }
fn analytics_patch_changes(
before: &BTreeMap<String, Option<String>>,
after: &BTreeMap<String, Option<String>>,
) -> Option<(crate::analytics::contract::ChangeKind, u64)> {
use crate::analytics::contract::ChangeKind;
let mut result = None;
for (path, before) in before {
let Some((before, after)) = before
.as_ref()
.zip(after.get(path).and_then(Option::as_ref))
else {
continue;
};
if before == after {
continue;
}
let Some(kind) = crate::analytics::project::file_change_kind(path) else {
continue;
};
result = Some(match result {
None => (kind, 1),
Some((current, count)) => (
if current == kind {
current
} else {
ChangeKind::Mixed
},
count + 1,
),
});
}
result
}
fn run_transaction( fn run_transaction(
root: &Path, root: &Path,
parsed: codex_patch_parser::ApplyPatchArgs, parsed: codex_patch_parser::ApplyPatchArgs,
@@ -319,6 +353,13 @@ fn run_transaction(
None None
}; };
lease.finish(passed && !uncertain, changed, 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!({ Ok(json!({
"status": if passed && !uncertain { "completed" } else { "failed" }, "status": if passed && !uncertain { "completed" } else { "failed" },
"changedPaths": if started { changed_paths } else { BTreeSet::new() }, "changedPaths": if started { changed_paths } else { BTreeSet::new() },
@@ -351,6 +392,47 @@ pub(super) async fn apply(root: &Path, arguments: &Value) -> Result<Value, Strin
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn analytics_patch_counts_only_known_changed_outputs() {
let fingerprints = |entries: &[(&str, Option<&str>)]| {
entries
.iter()
.map(|(path, value)| (path.to_string(), value.map(str::to_string)))
.collect()
};
let before = fingerprints(&[
("game/a.js", Some("a")),
("game/same.js", Some("same")),
("game/unknown.js", None),
("game/unreadable.js", Some("old")),
(".agent/state.json", Some("old")),
]);
let after = fingerprints(&[
("game/a.js", Some("b")),
("game/same.js", Some("same")),
("game/unknown.js", Some("new")),
("game/unreadable.js", None),
(".agent/state.json", Some("new")),
]);
assert_eq!(
analytics_patch_changes(&before, &after),
Some((crate::analytics::contract::ChangeKind::Code, 1))
);
assert_eq!(analytics_patch_changes(&before, &before), None);
let before = fingerprints(&[
("game/a.js", Some("missing")),
("assets/a.png", Some("old")),
]);
let after = fingerprints(&[
("game/a.js", Some("new")),
("assets/a.png", Some("missing")),
]);
assert_eq!(
analytics_patch_changes(&before, &after),
Some((crate::analytics::contract::ChangeKind::Mixed, 2))
);
}
fn project() -> (tempfile::TempDir, PathBuf) { fn project() -> (tempfile::TempDir, PathBuf) {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let root = temp.path().join("project"); let root = temp.path().join("project");
@@ -415,15 +497,20 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn bundled_patch_roundtrip_preserves_partial_failure_and_rejects_closed_turn() { async fn bundled_patch_roundtrip_preserves_partial_failure_and_rejects_closed_turn() {
let (temp, root) = project(); 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"), &temp.path().join("host"),
&root, &root,
"patch-roundtrip", "patch-roundtrip",
&format!("{:x}", Sha256::digest(b"request")), &format!("{:x}", Sha256::digest(b"request")),
false, false,
&direct_validation::DirectValidationConfig::default(), &direct_validation::DirectValidationConfig::default(),
Some(metadata),
) )
.unwrap(); .unwrap();
session.set_analytics_capture(Some((context.clone(), writer.clone())));
session session
.freeze_contract(json!({"fixture":"patch protocol only"})) .freeze_contract(json!({"fixture":"patch protocol only"}))
.unwrap(); .unwrap();
@@ -499,6 +586,29 @@ mod tests {
"writes do not invent execution passes" "writes do not invent execution passes"
); );
assert!(session.snapshot().unwrap().active.is_empty()); 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(); session.interrupt("fixture stopped".into()).unwrap();
assert!(apply( assert!(apply(
&root, &root,
@@ -4243,7 +4243,44 @@ pub(crate) async fn run_direct_browser_evidence_with_cancellation_at(
advisory_interaction: bool, advisory_interaction: bool,
cancellation: Option<Arc<std::sync::atomic::AtomicBool>>, cancellation: Option<Arc<std::sync::atomic::AtomicBool>>,
) -> Result<BrowserValidationResult, String> { ) -> Result<BrowserValidationResult, String> {
run_direct_browser_evidence_with_analytics_at(
root,
evidence_root,
scenario,
advisory_interaction,
cancellation,
None,
)
.await
}
pub(crate) async fn run_direct_browser_evidence_with_analytics_at(
root: &Path,
evidence_root: PathBuf,
scenario: Option<crate::browser::BrowserPlaytestScenario>,
advisory_interaction: bool,
cancellation: Option<Arc<std::sync::atomic::AtomicBool>>,
capture: Option<(
crate::analytics::contract::Context,
crate::analytics::store::AnalyticsWriter,
)>,
) -> Result<BrowserValidationResult, String> {
let observation = crate::analytics::preview::prepare(
root,
capture,
crate::analytics::contract::Source::Direct,
crate::analytics::contract::PreviewSource::Agent,
);
let (_analytics_lease, observation) = match observation {
Some((lease, observation)) => (Some(lease), Some(observation)),
None => (None, None),
};
let (preview, stop_sender) = start_local_game_preview_for_project(root)?; 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( let validation = crate::browser::validate_local_preview_in_browser_with_cancellation(
BrowserValidationInput { BrowserValidationInput {
url: preview.url, url: preview.url,
@@ -4261,6 +4298,7 @@ pub(crate) async fn run_direct_browser_evidence_with_cancellation_at(
cancellation, cancellation,
) )
.await; .await;
drop(_analytics_lease);
let _ = stop_sender.send(()); let _ = stop_sender.send(());
validation validation
} }
@@ -4781,6 +4819,8 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type(
None, None,
None, None,
None, None,
None,
None,
) )
.await .await
} }
@@ -4792,6 +4832,11 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter(
turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>, turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>,
audit: Option<&mut DirectCodexTurnAudit>, audit: Option<&mut DirectCodexTurnAudit>,
direct_user_item: Option<serde_json::Value>, direct_user_item: Option<serde_json::Value>,
capture: Option<(
crate::analytics::contract::Context,
crate::analytics::store::AnalyticsWriter,
)>,
analytics_attempt_id: Option<&str>,
) -> Result<String, String> { ) -> Result<String, String> {
if !root.is_absolute() || !root.is_dir() { if !root.is_absolute() || !root.is_dir() {
return Err("当前项目目录不存在或不是绝对路径".to_string()); return Err("当前项目目录不存在或不是绝对路径".to_string());
@@ -4814,6 +4859,8 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter(
turn_emitter, turn_emitter,
audit, audit,
direct_user_item, direct_user_item,
capture,
analytics_attempt_id,
) )
.await .await
{ {
@@ -5014,6 +5061,11 @@ async fn run_direct_game_creator_turn_inner(
turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>, turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>,
audit: Option<&mut DirectCodexTurnAudit>, audit: Option<&mut DirectCodexTurnAudit>,
direct_user_item: Option<serde_json::Value>, direct_user_item: Option<serde_json::Value>,
capture: Option<(
crate::analytics::contract::Context,
crate::analytics::store::AnalyticsWriter,
)>,
analytics_attempt_id: Option<&str>,
) -> Result<String, DirectCodexTurnFailure> { ) -> Result<String, DirectCodexTurnFailure> {
let requires_contract = super::direct_delivery::requires_new_web_contract( let requires_contract = super::direct_delivery::requires_new_web_contract(
root, root,
@@ -5035,16 +5087,49 @@ async fn run_direct_game_creator_turn_inner(
DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error)
})? })?
.validation; .validation;
let execution_guard = let analytics_run = capture.as_ref().map(|(context, _)| {
super::direct_execution::begin(root, prompt, requires_contract, execution_config) crate::analytics::run::Metadata::new(
.await context.clone(),
.map_err(|error| { crate::analytics::contract::Source::Direct,
DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) 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(); let execution_session = execution_guard.session();
execution_session.set_analytics_capture(capture.clone());
let started = execution_session
.newly_accepted
.then(std::time::Instant::now);
if execution_session.newly_accepted {
if let Ok(ledger) = execution_session.snapshot() {
if let (Some((_, writer)), Some(metadata)) = (&capture, &ledger.analytics_run) {
crate::analytics::run::accepted(writer, root, &ledger.project_id, metadata);
}
}
}
// 在 guard 仍存活时冻结整体结果,避免 Drop 的中断收尾覆盖真实失败原因。
let result: Result<String, DirectCodexTurnFailure> = async {
if let Some(report) = super::direct_delivery::terminal_report(&execution_session) { if let Some(report) = super::direct_delivery::terminal_report(&execution_session) {
return Ok(report); 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", "陶泥儿正在处理这条消息"); emit_direct_game_creator_progress(root, "codex.turn", "陶泥儿正在处理这条消息");
if let Some(emitter) = turn_emitter { if let Some(emitter) = turn_emitter {
emitter.emit("running", Some("preparing"), None, None); emitter.emit("running", Some("preparing"), None, None);
@@ -5334,6 +5419,118 @@ async fn run_direct_game_creator_turn_inner(
})?; })?;
} }
Ok(visible_reply) Ok(visible_reply)
}.await;
if let Ok(ledger) = execution_session.snapshot() {
if let (Some(metadata), Some((end_reason, error_code))) = (
&ledger.analytics_run,
direct_analytics_outcome(
ledger.phase,
ledger.requires_contract || ledger.contract.is_some(),
result.is_err(),
execution_session.was_aborted(),
),
) {
let output_revision = execution_session.analytics_output_revision();
crate::analytics::run::direct_finished(
capture,
root,
&ledger.project_id,
metadata,
analytics_attempt_id,
crate::analytics::run::Outcome {
turn_id: Some(ledger.client_turn_id.clone()),
end_reason,
error_code,
duration_ms: started
.and_then(|start| u64::try_from(start.elapsed().as_millis()).ok()),
output_change_detected: output_revision.as_ref().map(|_| true),
revision_id: output_revision,
},
);
}
}
result
}
fn direct_analytics_outcome(
phase: super::direct_execution::ExecutionPhase,
has_contract: bool,
failed: bool,
aborted: bool,
) -> Option<(
crate::analytics::contract::RunEndReason,
Option<crate::analytics::contract::ErrorCode>,
)> {
use super::direct_execution::ExecutionPhase;
use crate::analytics::contract::{ErrorCode, RunEndReason};
if phase == ExecutionPhase::Interrupted || aborted {
return None;
}
if phase == ExecutionPhase::Exhausted {
return Some((RunEndReason::Failed, Some(ErrorCode::RuntimeFailed)));
}
if failed {
// Direct 当前只保留 stage 和展示错误字符串,不从正文猜 Provider 错误类别。
return Some((
RunEndReason::Failed,
Some(ErrorCode::RuntimeErrorUnclassified),
));
}
if phase == ExecutionPhase::Completed || !has_contract {
return Some((RunEndReason::Finished, None));
}
None
}
#[cfg(test)]
mod direct_analytics_tests {
use super::*;
use crate::agent::direct_execution::ExecutionPhase;
use crate::analytics::contract::{ErrorCode, RunEndReason};
#[test]
fn terminal_reports_do_not_turn_exhaustion_or_cancellation_into_success() {
assert_eq!(
direct_analytics_outcome(ExecutionPhase::Exhausted, true, false, false),
Some((RunEndReason::Failed, Some(ErrorCode::RuntimeFailed)))
);
for failed in [true, false] {
assert_eq!(
direct_analytics_outcome(ExecutionPhase::Interrupted, true, failed, false),
None
);
assert_eq!(
direct_analytics_outcome(ExecutionPhase::Working, false, failed, true),
None
);
}
}
#[test]
fn complete_delivery_does_not_hide_later_projection_failure() {
assert_eq!(
direct_analytics_outcome(ExecutionPhase::Completed, true, true, false),
Some((
RunEndReason::Failed,
Some(ErrorCode::RuntimeErrorUnclassified)
))
);
assert_eq!(
direct_analytics_outcome(ExecutionPhase::Completed, true, false, false),
Some((RunEndReason::Finished, None))
);
assert_eq!(
direct_analytics_outcome(ExecutionPhase::Working, false, false, false),
Some((RunEndReason::Finished, None))
);
for phase in [
ExecutionPhase::Working,
ExecutionPhase::Draining,
ExecutionPhase::Sealing,
] {
assert_eq!(direct_analytics_outcome(phase, true, false, false), None);
}
}
} }
#[cfg(test)] #[cfg(test)]
@@ -34,8 +34,10 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
mut user_item: DirectCodexUserItem, mut user_item: DirectCodexUserItem,
creation_type: Option<String>, creation_type: Option<String>,
client_turn_id: Option<String>, client_turn_id: Option<String>,
analytics_attempt_id: Option<String>,
attachments: Option<Vec<DirectCodexTurnAttachment>>, attachments: Option<Vec<DirectCodexTurnAttachment>>,
) -> Result<String, String> { ) -> Result<String, String> {
let capture = crate::analytics::gui::capture_writer_context();
let root = Path::new(project_path.trim()); let root = Path::new(project_path.trim());
let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?; let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?;
let _active_invocation = DirectTaonierActiveInvocationGuard::enter(root, &turn_id)?; let _active_invocation = DirectTaonierActiveInvocationGuard::enter(root, &turn_id)?;
@@ -99,6 +101,8 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
Some(&turn_emitter), Some(&turn_emitter),
Some(&mut audit), Some(&mut audit),
canonical_user_item, canonical_user_item,
capture,
analytics_attempt_id.as_deref(),
) )
.await .await
{ {
@@ -1600,6 +1600,15 @@ fn bridge_write_file(root: &Path, arguments: &Value) -> Value {
bridge_write_file_with_permit(root, arguments, None) bridge_write_file_with_permit(root, arguments, None)
} }
fn bridge_file_content_changed(root: &Path, path: &str, content: &[u8]) -> Option<bool> {
crate::analytics::project::file_content_changed(
root,
path,
content,
DIRECT_TOOL_BRIDGE_MAX_WRITE_CONTENT_BYTES as u64,
)
}
fn bridge_write_file_with_permit( fn bridge_write_file_with_permit(
root: &Path, root: &Path,
arguments: &Value, arguments: &Value,
@@ -1641,6 +1650,11 @@ fn bridge_write_file_with_permit(
"direct-codex.file.write", "direct-codex.file.write",
)?; )?;
let lock_wait_ms = acquire_started.elapsed().as_millis(); 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 write_started = std::time::Instant::now();
let commit = || { let commit = || {
let written = write_local_project_file_at(root, &path, content)?; 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)?, Some(permit) => permit.run(commit)?,
None => 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 秒。只在明显偏慢时记账,正常写入不刷日志。 // 现场一次 2.6KB 写入实测 5.5 秒。只在明显偏慢时记账,正常写入不刷日志。
if lock_wait_ms + write_ms > 200 { if lock_wait_ms + write_ms > 200 {
app_log!( app_log!(
@@ -3473,6 +3490,82 @@ pub(in crate::agent) async fn generate_images_concurrently_for_test(
.await .await
} }
#[cfg(test)]
pub(super) fn analytics_test_writer(
config: &Path,
) -> (
crate::analytics::run::Metadata,
crate::analytics::contract::Context,
crate::analytics::store::AnalyticsWriter,
) {
use crate::analytics::{
contract::{Context, Route, RunSource, Source},
run,
store::AnalyticsWriter,
};
let mut context = Context {
route: Route::from_identity(Some("A".into()), Some("https://example.com")),
editor_session_id: uuid::Uuid::new_v4().to_string(),
client_version: "1.0.0".into(),
};
fs::create_dir_all(config).expect("create analytics test config directory");
let metadata = run::Metadata::new(context.clone(), Source::Direct, RunSource::UserSubmit);
context.route.user_id = Some("B".into());
let writer = AnalyticsWriter::start(config.into(), context.editor_session_id.clone());
(metadata, context, writer)
}
#[cfg(test)]
pub(super) fn drain_analytics_test_writer(
config: &Path,
context: &crate::analytics::contract::Context,
writer: &crate::analytics::store::AnalyticsWriter,
) -> Vec<Value> {
use crate::analytics::contract::{EntrySource, EventData, SessionStart, Source};
let marker = context
.capture(
EventData::EditorSessionStart(SessionStart {
entry_source: EntrySource::DirectLaunch,
first_project_id: None,
}),
None,
Source::Editor,
None,
)
.unwrap();
let marker_id = marker.event_id.clone();
assert!(writer.try_record(context.route.clone(), marker, marker_id.clone()));
assert!(writer.flush());
let batches = config
.join("analytics/instances")
.join(&context.editor_session_id)
.join("batches");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
let events: Vec<Value> = fs::read_dir(&batches)
.into_iter()
.flatten()
.flatten()
.filter(|entry| !entry.file_name().to_string_lossy().starts_with('.'))
.filter_map(|entry| fs::read_to_string(entry.path().join("events.jsonl")).ok())
.flat_map(|contents| {
contents
.lines()
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
.collect::<Vec<_>>()
})
.collect();
if events.iter().any(|event| event["event_id"] == marker_id) {
return events;
}
assert!(
std::time::Instant::now() < deadline,
"analytics FIFO sentinel timed out"
);
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[tokio::test] #[tokio::test]
@@ -4262,6 +4355,293 @@ mod tests {
assert_eq!(importability.get("assets/vector.svg"), Some(&true)); assert_eq!(importability.get("assets/vector.svg"), Some(&true));
} }
#[tokio::test]
async fn analytics_real_file_write_preserves_original_identity_and_failed_run_revision() {
use crate::analytics::{
contract::{ErrorCode, RunEndReason},
run,
};
let temporary = tempfile::tempdir().unwrap();
let root = temporary.path().join("project");
let config = temporary.path().join("config");
let (metadata, context, writer) = analytics_test_writer(&config);
let original_capture = Some((metadata.context.clone(), writer.clone()));
let mut lifecycle = crate::analytics::gui::LifecycleFixture::start(
metadata.context.clone(),
writer.clone(),
);
lifecycle.create_and_open(&root, "direct-analytics");
let session = super::super::direct_execution::open_with_analytics_at(
&temporary.path().join("host"),
&root,
"analytics-write",
&format!("{:x}", Sha256::digest(b"request")),
false,
&Default::default(),
Some(metadata.clone()),
)
.unwrap();
session.set_analytics_capture(Some((context.clone(), writer.clone())));
super::super::direct_delivery::register_contract(
&root,
&session,
&json!({
"scope": "核对当前项目宿主写入的成果采集",
"changeKind": "project",
"requirements": [{"id": "analytics-output", "kind": "artifact", "path": "game/index.html"}]
}),
)
.await
.expect("freeze a validated delivery contract before writing");
let project_id = session.snapshot().unwrap().project_id;
run::accepted(&writer, &root, &project_id, &metadata);
crate::analytics::goal::accepted(
original_capture.clone(),
&root,
&project_id,
crate::analytics::contract::Source::Direct,
);
let lease = session
.admit(super::super::direct_execution::EffectKind::Write, None)
.unwrap();
let permit = lease.write_permit().unwrap();
let arguments = json!({"path":"game/index.html", "content":"<!doctype html><html><body>真实预览</body></html>"});
let changed = bridge_write_file_with_permit(&root, &arguments, Some(&permit));
assert_eq!(changed["isError"], false);
let payload: Value =
serde_json::from_str(changed["content"][0]["text"].as_str().unwrap()).unwrap();
let revision = payload["revision"].as_u64().unwrap().to_string();
assert_eq!(session.analytics_output_revision(), Some(revision.clone()));
assert_eq!(
bridge_write_file_with_permit(&root, &arguments, Some(&permit))["isError"],
false
);
assert_eq!(
bridge_write_file_with_permit(
&root,
&json!({"path":"../bad.js","content":"bad"}),
Some(&permit)
)["isError"],
true
);
assert_eq!(session.analytics_output_revision(), Some(revision.clone()));
lease.finish(true, true, None).unwrap();
let attempt = uuid::Uuid::new_v4().to_string();
run::direct_finished(
Some((context.clone(), writer.clone())),
&root,
&project_id,
&metadata,
Some(&attempt),
run::Outcome {
turn_id: Some("analytics-write".into()),
end_reason: RunEndReason::Failed,
error_code: Some(ErrorCode::RuntimeErrorUnclassified),
duration_ms: None,
output_change_detected: Some(true),
revision_id: session.analytics_output_revision(),
},
);
run::settle(Some((context.clone(), writer.clone())), &attempt, false);
// 默认项目使用 npm:预览服务读取真实构建目录,夹具提供构建入口,不调用构建器。
let served_root = crate::project_game_root(&root);
fs::create_dir_all(&served_root).unwrap();
fs::write(
served_root.join("index.html"),
"<!doctype html><html><body>真实预览构建</body></html>",
)
.unwrap();
let (preview_lease, observation) = crate::analytics::preview::prepare(
&root,
original_capture.clone(),
crate::analytics::contract::Source::Editor,
crate::analytics::contract::PreviewSource::User,
)
.unwrap();
let (preview, stop) = crate::start_local_game_preview_for_project(&root).unwrap();
observation.observe(preview.port).await;
let preview_revision = crate::read_game_creator_agent_runtime_project_revision(&root)
.unwrap()
.revision
.to_string();
drop(preview_lease);
let _ = stop.send(());
let checkpoint = crate::commands::checkpoint_with_capture_for_test(
root.to_string_lossy().into_owned(),
original_capture,
)
.unwrap();
lifecycle.exit();
let events = drain_analytics_test_writer(&config, &context, &writer);
let ids: std::collections::HashSet<_> = events
.iter()
.map(|event| event["event_id"].as_str().unwrap())
.collect();
assert_eq!(ids.len(), events.len());
let chain: Vec<_> = events
.iter()
.filter(|event| event["user_id"] == "A")
.collect();
for name in [
"editor_session_start",
"editor_focus_start",
"project_create_success",
"project_open",
"creative_task_submit",
"project_revision_created",
"agent_run_failed",
"preview_ready",
"project_save",
"editor_focus_end",
"editor_session_end",
] {
assert_eq!(
chain
.iter()
.filter(|event| event["event_name"] == name)
.count(),
1,
"{name}"
);
}
for event in &chain {
assert_eq!(
event["editor_session_id"],
metadata.context.editor_session_id
);
if !event["project_id"].is_null() {
assert_eq!(event["project_id"], project_id);
}
if !event["creative_task_id"].is_null() {
assert_eq!(event["creative_task_id"], project_id);
}
if !event["agent_run_id"].is_null() {
assert_eq!(event["agent_run_id"], metadata.run_id);
}
let name = event["event_name"].as_str().unwrap();
if matches!(
name,
"project_create_success"
| "project_open"
| "creative_task_submit"
| "project_revision_created"
| "agent_run_failed"
| "preview_ready"
| "project_save"
) {
assert_eq!(
event["project_id"], project_id,
"{name} must identify its project"
);
}
if matches!(
name,
"creative_task_submit"
| "project_revision_created"
| "agent_run_failed"
| "preview_ready"
| "project_save"
) {
assert_eq!(
event["creative_task_id"], project_id,
"{name} must identify its goal"
);
}
if name == "agent_run_failed" {
assert_eq!(event["agent_run_id"], metadata.run_id);
assert_eq!(event["agent_turn_id"], "analytics-write");
}
}
let save = chain
.iter()
.find(|event| event["event_name"] == "project_save")
.unwrap();
assert_eq!(save["properties"]["save_source"], "checkpoint");
assert!(Path::new(&checkpoint.checkpoint_path).is_dir());
let mut digest = Sha256::new();
digest.update(serde_json::to_vec(&metadata.context.route).unwrap());
digest.update([0]);
digest.update(format!("{project_id}:{}:project_save", checkpoint.checkpoint_id).as_bytes());
let checkpoint_fact = format!("{:x}", digest.finalize());
let batches = config
.join("analytics/instances")
.join(&context.editor_session_id)
.join("batches");
let checkpoint_fact_matches = fs::read_dir(batches)
.unwrap()
.flatten()
.filter_map(|entry| fs::read(entry.path().join("meta.json")).ok())
.filter_map(|bytes| serde_json::from_slice::<Value>(&bytes).ok())
.filter(|batch| batch["facts"][&checkpoint_fact] == save["event_id"])
.count();
assert_eq!(
checkpoint_fact_matches, 1,
"save fact must use the actual checkpoint ID"
);
let ready = chain
.iter()
.find(|event| event["event_name"] == "preview_ready")
.unwrap();
assert_eq!(ready["properties"]["preview_version"], preview_revision);
let focus_start = chain
.iter()
.find(|event| event["event_name"] == "editor_focus_start")
.unwrap();
let focus_end = chain
.iter()
.find(|event| event["event_name"] == "editor_focus_end")
.unwrap();
assert_eq!(
focus_start["properties"]["focus_interval_id"],
focus_end["properties"]["focus_interval_id"]
);
let revisions: Vec<_> = events
.iter()
.filter(|event| event["event_name"] == "project_revision_created")
.collect();
assert_eq!(revisions.len(), 1);
assert_eq!(revisions[0]["user_id"], "A");
assert_eq!(revisions[0]["properties"]["revision_id"], revision);
let failed: Vec<_> = events
.iter()
.filter(|event| event["event_name"] == "agent_run_failed")
.collect();
assert_eq!(failed.len(), 1);
assert_eq!(failed[0]["user_id"], "A");
assert_eq!(failed[0]["properties"]["revision_id"], revision);
assert_eq!(failed[0]["properties"]["output_change_detected"], true);
}
#[test]
fn analytics_file_comparison_requires_known_bounded_content() {
let temporary = tempfile::tempdir().unwrap();
let root = temporary.path();
assert_eq!(
bridge_file_content_changed(root, "new.js", b"new"),
Some(true)
);
fs::write(root.join("new.js"), b"new").unwrap();
assert_eq!(
bridge_file_content_changed(root, "new.js", b"new"),
Some(false)
);
assert_eq!(
bridge_file_content_changed(root, "new.js", b"changed"),
Some(true)
);
fs::create_dir(root.join("directory.js")).unwrap();
assert_eq!(
bridge_file_content_changed(root, "directory.js", b"new"),
None
);
fs::write(
root.join("large.js"),
vec![0; DIRECT_TOOL_BRIDGE_MAX_WRITE_CONTENT_BYTES + 1],
)
.unwrap();
assert_eq!(bridge_file_content_changed(root, "large.js", b"new"), None);
}
#[test] #[test]
fn bridge_write_file_writes_project_relative_text_without_runtime_tasks() { fn bridge_write_file_writes_project_relative_text_without_runtime_tasks() {
let temporary = tempfile::tempdir().expect("create direct write root"); let temporary = tempfile::tempdir().expect("create direct write root");
@@ -453,12 +453,15 @@ async fn run_browser_with_budget(
sequence, 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, root,
evidence_root, evidence_root,
scenario, scenario,
false, false,
reservation.as_ref().map(|r| r.session.cancel_flag()), reservation.as_ref().map(|r| r.session.cancel_flag()),
reservation
.as_ref()
.and_then(|r| r.session.analytics_capture()),
) )
.await; .await;
let (result, passed) = match evidence { let (result, passed) = match evidence {
@@ -126,6 +126,9 @@ pub(crate) struct DesignTurn {
pub(crate) attempt: u32, pub(crate) attempt: u32,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) model_selection: Option<DesignModelSelection>, pub(crate) model_selection: Option<DesignModelSelection>,
/// 仅持久化埋点关联,旧回合缺失时不补历史执行。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) analytics: Option<crate::analytics::run::Metadata>,
} }
/// 仅保存恢复所需的用户选择,不包含连接配置或凭据。 /// 仅保存恢复所需的用户选择,不包含连接配置或凭据。
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,277 @@
use super::*;
use serde_json::json;
fn context() -> Context {
Context {
route: Route::from_identity(Some("123".into()), Some("https://example.com/api")),
editor_session_id: Uuid::new_v4().to_string(),
client_version: "0.1.67".into(),
}
}
fn run() -> RunFinished {
RunFinished {
agent_type: AgentType::GameAgent,
run_source: RunSource::UserSubmit,
duration_ms: None,
retry_index: 0,
output_change_detected: None,
revision_id: None,
end_reason: RunEndReason::Finished,
}
}
fn samples() -> Vec<Event> {
let mut failed = run();
failed.end_reason = RunEndReason::Failed;
let values = vec![
(
EventData::EditorSessionStart(SessionStart {
entry_source: EntrySource::DirectLaunch,
first_project_id: None,
}),
None,
Source::Editor,
),
(
EventData::EditorSessionEnd(SessionEnd {
end_reason: SessionEndReason::UserExit,
session_duration_ms: Some(10),
last_project_id: None,
}),
None,
Source::Editor,
),
(
EventData::EditorFocusStart(FocusStart {
focus_interval_id: Uuid::new_v4().to_string(),
focus_reason: FocusReason::InitialFocus,
active_project_id: None,
}),
None,
Source::Editor,
),
(
EventData::EditorFocusEnd(FocusEnd {
focus_interval_id: Uuid::new_v4().to_string(),
blur_reason: BlurReason::WindowBlur,
focus_duration_ms: None,
active_project_id: None,
}),
None,
Source::Editor,
),
(
EventData::ProjectCreateSuccess(ProjectCreated {
creation_source: CreationSource::HomeGame,
project_template_id: None,
}),
Some("project".into()),
Source::Editor,
),
(
EventData::ProjectOpen(ProjectOpened {
open_source: OpenSource::Recent,
is_first_open: None,
}),
Some("project".into()),
Source::Editor,
),
(
EventData::CreativeTaskSubmit(EmptyProperties {}),
Some("project".into()),
Source::Direct,
),
(
EventData::AgentRunCompleted(run()),
Some("project".into()),
Source::Direct,
),
(
EventData::AgentRunFailed(failed),
Some("project".into()),
Source::Direct,
),
(
EventData::ProjectRevisionCreated(RevisionCreated {
revision_id: "42".into(),
revision_source: RevisionSource::UiEditor,
change_kind: ChangeKind::Ui,
files_changed_count: Some(1),
}),
Some("project".into()),
Source::UiEditor,
),
(
EventData::PreviewReady(PreviewReady {
preview_source: PreviewSource::User,
preview_version: "42".into(),
ready_duration_ms: None,
}),
Some("project".into()),
Source::Editor,
),
(
EventData::ProjectSave(ProjectSaved {
save_source: SaveSource::Checkpoint,
revision_id: Some("42".into()),
}),
Some("project".into()),
Source::Manual,
),
];
values
.into_iter()
.map(|(data, project, source)| {
let identity = matches!(
data,
EventData::AgentRunCompleted(_) | EventData::AgentRunFailed(_)
)
.then(|| RunIdentity {
run_id: Uuid::new_v4().to_string(),
turn_id: Some("real-turn".into()),
error_code: matches!(data, EventData::AgentRunFailed(_))
.then_some(ErrorCode::RuntimeErrorUnclassified),
});
context().capture(data, project, source, identity).unwrap()
})
.collect()
}
#[test]
fn all_events_round_trip_with_explicit_nullable_envelopes() {
let events = samples();
assert_eq!(events.len(), 12);
for event in events {
let value = serde_json::to_value(&event).unwrap();
assert_eq!(value.as_object().unwrap().len(), 15);
let decoded: Event = serde_json::from_value(value).unwrap();
decoded.validate().unwrap();
assert_eq!(decoded.event_id, event.event_id);
assert_eq!(decoded.event_time, event.event_time);
}
}
#[test]
fn missing_top_level_fields_and_extra_fields_are_rejected() {
for event in samples() {
let value = serde_json::to_value(event).unwrap();
for field in value.as_object().unwrap().keys() {
let mut missing = value.clone();
missing.as_object_mut().unwrap().remove(field);
assert!(
serde_json::from_value::<Event>(missing).is_err(),
"missing {field}"
);
}
let mut extra = value;
extra["prompt"] = json!("must not be accepted");
assert!(serde_json::from_value::<Event>(extra).is_err());
}
}
#[test]
fn event_properties_are_closed_and_nullable_fields_are_required() {
for event in samples() {
let mut extra = event.clone();
extra.properties["access_token"] = json!("never collected");
assert!(extra.validate().is_err());
for (field, value) in event.properties.as_object().unwrap() {
if !value.is_null() {
continue;
}
let mut missing = event.clone();
missing.properties.as_object_mut().unwrap().remove(field);
assert!(
missing.validate().is_err(),
"{} missing {field}",
event.event_name
);
}
}
let mut nested = samples().pop().unwrap();
nested.properties["pending_approval"] =
json!({"request_id":"a", "phase":"concept", "question":"private"});
assert!(nested.validate().is_err());
nested.properties["pending_approval"] = Value::Null;
nested.properties["pending_clarification"] = json!({"request_id":"a", "options":[]});
assert!(nested.validate().is_err());
}
#[test]
fn inconsistent_identity_status_and_numbers_are_rejected() {
let event = samples()
.into_iter()
.find(|e| e.event_name == "agent_run_completed")
.unwrap();
let mutate: Vec<(&str, Value)> = vec![
("creative_task_id", json!("other-project")),
("project_id", Value::Null),
("agent_run_id", json!("project")),
("status", json!("failed")),
("source", json!("design_agent")),
("error_code", json!("runtime_failed")),
("schema_version", json!(2)),
("event_time", json!("2026-09-21T00:00:00Z")),
("event_id", json!("not-a-uuid")),
("user_id", json!("")),
];
for (key, value) in mutate {
let mut raw = serde_json::to_value(&event).unwrap();
raw[key] = value;
let invalid: Event = serde_json::from_value(raw).unwrap();
assert!(invalid.validate().is_err(), "{key}");
}
for (key, value) in [
("duration_ms", json!(-1)),
("retry_index", json!(MAX_SAFE_INTEGER + 1)),
("duration_ms", json!(1.5)),
] {
let mut invalid = event.clone();
invalid.properties[key] = value;
assert!(invalid.validate().is_err());
}
}
#[test]
fn frozen_context_and_origin_do_not_inherit_new_account() {
let a = context();
let mut b = a.clone();
b.route = Route::from_identity(
Some("456".into()),
Some("https://other.example.com/api?token=private"),
);
let event = a
.capture(
EventData::CreativeTaskSubmit(EmptyProperties {}),
Some("p".into()),
Source::Direct,
None,
)
.unwrap();
assert_eq!(event.user_id.as_deref(), Some("123"));
assert_eq!(
a.route.destination_origin.as_deref(),
Some("https://example.com")
);
assert_eq!(
b.route.destination_origin.as_deref(),
Some("https://other.example.com")
);
assert_eq!(
Route::from_identity(None, Some("file:///private")).destination_origin,
None
);
assert_eq!(
Route::from_identity(None, Some("https://user:secret@example.com")).destination_origin,
None
);
assert!(
Route {
user_id: None,
destination_origin: Some("https://example.com/api".into())
}
.validate()
== false
);
}
@@ -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<Self> {
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);
}
}
}
@@ -0,0 +1,166 @@
//! 新建项目的目标采集资格。只在后台持锁读改写,不等待业务线程。
use super::contract::{Context, EmptyProperties, Event, EventData, Route, Source};
use super::store::AnalyticsWriter;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
const MARKER: &str = ".agent/analytics-goal.json";
const MAX_MARKER_BYTES: usize = 4096;
#[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: &AnalyticsWriter, root: &Path, project_id: &str) {
writer.try_goal(Request::Created {
root: root.into(),
project_id: project_id.into(),
});
}
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<Option<(Route, Event)>, 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 {
return Ok(None);
}
let path = crate::project::resolve_local_project_path(root, MARKER)?;
let primary = std::fs::symlink_metadata(&path);
match &request {
Request::Created { .. } => {
// 缺失主文件但存在恢复副本仍属未知,不能重新授予资格。
let backup = crate::agent::agent_runtime_json_sidecar_backup_path(&path);
if !matches!(primary, Err(ref e) if e.kind() == std::io::ErrorKind::NotFound)
|| !matches!(std::fs::symlink_metadata(backup), Err(ref e) if e.kind() == std::io::ErrorKind::NotFound)
{
return Ok(None);
}
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,
)?;
Ok(None)
}
Request::Accepted { .. } => {
// 明确要求主文件存在,不启用 sidecar 的 previous 自动回退。
let Ok(metadata) = primary else {
return Ok(None);
};
if !metadata.is_file() || metadata.file_type().is_symlink() {
return Ok(None);
}
let Some(mut marker): Option<Marker> =
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!(),
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -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<Event> {
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::<Event>(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
);
}
@@ -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;

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