Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a0704e4da | |||
| 9d060de185 | |||
| 8d83b14ae5 | |||
| f983663caf | |||
| 9f225b7a41 | |||
| 5087b600a2 | |||
| 8fd85d0426 | |||
| b840973d82 | |||
| ca82e6b745 | |||
| 3e657e6a6a |
@@ -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();
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user