Merge branch 'master' into codex/issue-257-bgfilter-agc-backend
This commit is contained in:
@@ -23,6 +23,9 @@ import type {
|
||||
AdminEditorShowcaseListQuery,
|
||||
AdminEditorShowcaseListResponse,
|
||||
AdminEditorShowcaseReviewRequest,
|
||||
AdminErrorReportDetail,
|
||||
AdminErrorReportEntry,
|
||||
AdminErrorReportListResponse,
|
||||
AdminFeatureGateConfigResponse,
|
||||
AdminLoginResponse,
|
||||
AdminMeResponse,
|
||||
@@ -259,7 +262,16 @@ export function listAdminTrackingEvents(
|
||||
query: AdminTrackingEventListQuery = {},
|
||||
) {
|
||||
return request<AdminTrackingEventListResponse>(
|
||||
`/admin/api/tracking/events${buildQueryString(query)}`,
|
||||
`/admin/api/tracking/events${buildQueryString((params) => {
|
||||
appendQueryParam(params, 'eventKey', query.eventKey);
|
||||
appendQueryParam(params, 'userId', query.userId);
|
||||
appendQueryParam(params, 'scopeKind', query.scopeKind);
|
||||
appendQueryParam(params, 'scopeId', query.scopeId);
|
||||
appendQueryParam(params, 'startDate', query.startDate);
|
||||
appendQueryParam(params, 'endDate', query.endDate);
|
||||
appendNumericQueryParam(params, 'limit', query.limit);
|
||||
if (query.exportAll) params.set('exportAll', 'true');
|
||||
})}`,
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
@@ -271,6 +283,60 @@ export function listAdminTrackingEventKeys(token: string) {
|
||||
);
|
||||
}
|
||||
|
||||
export function listAdminErrorReports(
|
||||
token: string,
|
||||
query: {
|
||||
status?: string;
|
||||
fingerprint?: string;
|
||||
source?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
} = {},
|
||||
) {
|
||||
return request<AdminErrorReportListResponse>(
|
||||
`/admin/api/error-reports${buildErrorReportQueryString(query)}`,
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
export function getAdminErrorReport(token: string, batchId: string) {
|
||||
return request<AdminErrorReportDetail>(
|
||||
`/admin/api/error-reports/${encodeURIComponent(batchId)}`,
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
export function updateAdminErrorReport(
|
||||
token: string,
|
||||
batchId: string,
|
||||
payload: { status: string; note?: string },
|
||||
) {
|
||||
return request<AdminErrorReportEntry>(
|
||||
`/admin/api/error-reports/${encodeURIComponent(batchId)}`,
|
||||
{ method: 'PATCH', token, body: payload },
|
||||
);
|
||||
}
|
||||
|
||||
export function getAdminErrorReportDownloadUrl(batchId: string) {
|
||||
return `/admin/api/error-reports/${encodeURIComponent(batchId)}/download`;
|
||||
}
|
||||
|
||||
export async function downloadAdminErrorReport(token: string, batchId: string) {
|
||||
const response = await fetch(
|
||||
buildRequestUrl(getAdminErrorReportDownloadUrl(batchId)),
|
||||
{
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new AdminApiError({
|
||||
message: `下载失败(${response.status})`,
|
||||
status: response.status,
|
||||
});
|
||||
}
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
export function getAdminFeatureGateConfig(token: string) {
|
||||
return request<AdminFeatureGateConfigResponse>('/admin/api/feature-gates', {
|
||||
token,
|
||||
@@ -788,43 +854,44 @@ async function postAdminDirectUploadFile(
|
||||
}
|
||||
}
|
||||
|
||||
function buildQueryString(query: AdminTrackingEventListQuery) {
|
||||
function buildQueryString(append: (params: URLSearchParams) => void) {
|
||||
const params = new URLSearchParams();
|
||||
appendQueryParam(params, 'eventKey', query.eventKey);
|
||||
appendQueryParam(params, 'userId', query.userId);
|
||||
appendQueryParam(params, 'scopeKind', query.scopeKind);
|
||||
appendQueryParam(params, 'scopeId', query.scopeId);
|
||||
appendQueryParam(params, 'startDate', query.startDate);
|
||||
appendQueryParam(params, 'endDate', query.endDate);
|
||||
if (typeof query.limit === 'number' && Number.isFinite(query.limit)) {
|
||||
params.set('limit', String(query.limit));
|
||||
}
|
||||
if (query.exportAll) {
|
||||
params.set('exportAll', 'true');
|
||||
}
|
||||
const queryString = params.toString();
|
||||
return queryString ? `?${queryString}` : '';
|
||||
append(params);
|
||||
return formatQueryString(params);
|
||||
}
|
||||
|
||||
function buildErrorReportQueryString(query: {
|
||||
status?: string;
|
||||
fingerprint?: string;
|
||||
source?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) {
|
||||
return buildQueryString((params) => {
|
||||
appendQueryParam(params, 'status', query.status);
|
||||
appendQueryParam(params, 'fingerprint', query.fingerprint);
|
||||
appendQueryParam(params, 'source', query.source);
|
||||
appendNumericQueryParam(params, 'limit', query.limit);
|
||||
appendNumericQueryParam(params, 'offset', query.offset);
|
||||
});
|
||||
}
|
||||
|
||||
function buildAdminRechargeOrderListQuery(query: AdminRechargeOrderListQuery) {
|
||||
const params = new URLSearchParams();
|
||||
appendQueryParam(params, 'orderId', query.orderId);
|
||||
appendQueryParam(
|
||||
params,
|
||||
'providerTransactionId',
|
||||
query.providerTransactionId,
|
||||
);
|
||||
appendQueryParam(params, 'userId', query.userId);
|
||||
appendQueryParam(params, 'publicUserCode', query.publicUserCode);
|
||||
appendQueryParam(params, 'paymentChannel', query.paymentChannel);
|
||||
appendQueryParam(params, 'status', query.status);
|
||||
appendQueryParam(params, 'createdAfter', query.createdAfter);
|
||||
appendQueryParam(params, 'createdBefore', query.createdBefore);
|
||||
if (typeof query.limit === 'number' && Number.isFinite(query.limit)) {
|
||||
params.set('limit', String(query.limit));
|
||||
}
|
||||
const queryString = params.toString();
|
||||
return queryString ? `?${queryString}` : '';
|
||||
return buildQueryString((params) => {
|
||||
appendQueryParam(params, 'orderId', query.orderId);
|
||||
appendQueryParam(
|
||||
params,
|
||||
'providerTransactionId',
|
||||
query.providerTransactionId,
|
||||
);
|
||||
appendQueryParam(params, 'userId', query.userId);
|
||||
appendQueryParam(params, 'publicUserCode', query.publicUserCode);
|
||||
appendQueryParam(params, 'paymentChannel', query.paymentChannel);
|
||||
appendQueryParam(params, 'status', query.status);
|
||||
appendQueryParam(params, 'createdAfter', query.createdAfter);
|
||||
appendQueryParam(params, 'createdBefore', query.createdBefore);
|
||||
appendNumericQueryParam(params, 'limit', query.limit);
|
||||
});
|
||||
}
|
||||
|
||||
function buildAdminUserDetailQuery(query: AdminUserDetailQuery) {
|
||||
@@ -900,6 +967,21 @@ function appendQueryParam(
|
||||
}
|
||||
}
|
||||
|
||||
function appendNumericQueryParam(
|
||||
params: URLSearchParams,
|
||||
key: string,
|
||||
value: number | null | undefined,
|
||||
) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
params.set(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
function formatQueryString(params: URLSearchParams) {
|
||||
const queryString = params.toString();
|
||||
return queryString ? `?${queryString}` : '';
|
||||
}
|
||||
|
||||
function parseJsonResponse(responseText: string): unknown {
|
||||
if (!responseText.trim()) {
|
||||
return null;
|
||||
|
||||
@@ -96,6 +96,38 @@ export interface AdminMeResponse {
|
||||
admin: AdminSessionPayload;
|
||||
}
|
||||
|
||||
export interface AdminErrorReportEntry {
|
||||
batchId: string;
|
||||
eventCount: number;
|
||||
logCount: number;
|
||||
fingerprint?: string;
|
||||
source?: string;
|
||||
status: 'new' | 'in-progress' | 'resolved';
|
||||
userId: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
userDescription?: string;
|
||||
attachmentSizeBytes: number;
|
||||
submissionId?: string;
|
||||
ossObjectKey?: string;
|
||||
archiveSha256?: string;
|
||||
uploadStatus?: 'uploading' | 'ready' | 'failed';
|
||||
}
|
||||
|
||||
export interface AdminErrorReportListResponse {
|
||||
reports: AdminErrorReportEntry[];
|
||||
total: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export interface AdminErrorReportDetail extends AdminErrorReportEntry {
|
||||
note?: string;
|
||||
events: Array<Record<string, unknown>>;
|
||||
logNames: string[];
|
||||
}
|
||||
|
||||
export interface AdminOverviewResponse {
|
||||
service: AdminServiceOverviewPayload;
|
||||
database: AdminDatabaseOverviewPayload;
|
||||
|
||||
@@ -24,6 +24,7 @@ import { AdminDebugHttpPage } from '../pages/AdminDebugHttpPage';
|
||||
import { AdminEditorAssetQueryPage } from '../pages/AdminEditorAssetQueryPage';
|
||||
import { AdminEditorGenerationPricingPage } from '../pages/AdminEditorGenerationPricingPage';
|
||||
import { AdminEditorShowcaseReviewPage } from '../pages/AdminEditorShowcaseReviewPage';
|
||||
import { AdminErrorReportsPage } from '../pages/AdminErrorReportsPage';
|
||||
import { AdminGrayReleaseConfigPage } from '../pages/AdminGrayReleaseConfigPage';
|
||||
import { AdminInviteCodePage } from '../pages/AdminInviteCodePage';
|
||||
import { AdminLoginPage } from '../pages/AdminLoginPage';
|
||||
@@ -228,6 +229,12 @@ export function AdminApp() {
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{activeRouteId === 'error-reports' ? (
|
||||
<AdminErrorReportsPage
|
||||
token={token}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
/>
|
||||
) : null}
|
||||
{activeRouteId === 'gray-release' ? (
|
||||
<AdminGrayReleaseConfigPage
|
||||
token={token}
|
||||
|
||||
@@ -38,6 +38,7 @@ const routeIcons = {
|
||||
tables: Database,
|
||||
debug: Bug,
|
||||
tracking: Table2,
|
||||
'error-reports': Bug,
|
||||
'gray-release': GitBranch,
|
||||
redeem: TicketPercent,
|
||||
invite: TicketCheck,
|
||||
|
||||
@@ -5,6 +5,7 @@ export type AdminRouteId =
|
||||
| 'tables'
|
||||
| 'debug'
|
||||
| 'tracking'
|
||||
| 'error-reports'
|
||||
| 'gray-release'
|
||||
| 'redeem'
|
||||
| 'invite'
|
||||
@@ -33,6 +34,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
|
||||
{ id: 'tables', label: '表查询', hash: '#tables' },
|
||||
{ id: 'debug', label: 'API 调试', hash: '#debug' },
|
||||
{ id: 'tracking', label: '埋点数据', hash: '#tracking' },
|
||||
{ id: 'error-reports', label: '错误报告', hash: '#error-reports' },
|
||||
{ id: 'gray-release', label: '灰度发布', hash: '#gray-release' },
|
||||
{ id: 'redeem', label: '兑换码', hash: '#redeem' },
|
||||
{ id: 'invite', label: '邀请码', hash: '#invite' },
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
downloadAdminErrorReport,
|
||||
formatAdminApiError,
|
||||
getAdminErrorReport,
|
||||
isAdminApiError,
|
||||
listAdminErrorReports,
|
||||
updateAdminErrorReport,
|
||||
} from '../api/adminApiClient';
|
||||
import type {
|
||||
AdminErrorReportDetail,
|
||||
AdminErrorReportEntry,
|
||||
} from '../api/adminApiTypes';
|
||||
|
||||
type Props = { token: string; onUnauthorized: (message?: string) => void };
|
||||
|
||||
const ADMIN_ERROR_REPORT_STATUSES = ['new', 'in-progress', 'resolved'] as const;
|
||||
const ERROR_REPORT_PAGE_SIZE = 50;
|
||||
|
||||
export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
|
||||
const [reports, setReports] = useState<AdminErrorReportEntry[]>([]);
|
||||
const [selected, setSelected] = useState<AdminErrorReportDetail | null>(null);
|
||||
const [status, setStatus] = useState('');
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [filterStatus, setFilterStatus] = useState('');
|
||||
const [pageOffset, setPageOffset] = useState(0);
|
||||
const [pageInfo, setPageInfo] = useState({ total: 0, hasMore: false });
|
||||
const openReportRequestId = useRef(0);
|
||||
const loadRequestId = useRef(0);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const requestId = ++loadRequestId.current;
|
||||
setStatus('');
|
||||
try {
|
||||
const response = await listAdminErrorReports(token, {
|
||||
status: filterStatus || undefined,
|
||||
limit: ERROR_REPORT_PAGE_SIZE,
|
||||
offset: pageOffset,
|
||||
});
|
||||
if (requestId === loadRequestId.current) {
|
||||
const lastValidOffset = response.total
|
||||
? Math.floor((response.total - 1) / ERROR_REPORT_PAGE_SIZE) *
|
||||
ERROR_REPORT_PAGE_SIZE
|
||||
: 0;
|
||||
if (pageOffset > lastValidOffset) {
|
||||
setPageOffset(lastValidOffset);
|
||||
return;
|
||||
}
|
||||
setReports(response.reports);
|
||||
setPageInfo({ total: response.total, hasMore: response.hasMore });
|
||||
}
|
||||
} catch (error) {
|
||||
if (requestId !== loadRequestId.current) return;
|
||||
if (isAdminApiError(error) && error.status === 401)
|
||||
return onUnauthorized();
|
||||
setStatus(formatAdminApiError(error));
|
||||
}
|
||||
}, [filterStatus, onUnauthorized, pageOffset, token]);
|
||||
|
||||
const loadRef = useRef(load);
|
||||
useEffect(() => {
|
||||
loadRef.current = load;
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
async function openReport(batchId: string) {
|
||||
const requestId = ++openReportRequestId.current;
|
||||
setStatus('');
|
||||
try {
|
||||
const detail = await getAdminErrorReport(token, batchId);
|
||||
if (requestId === openReportRequestId.current) setSelected(detail);
|
||||
} catch (error) {
|
||||
if (requestId !== openReportRequestId.current) return;
|
||||
if (isAdminApiError(error) && error.status === 401)
|
||||
return onUnauthorized();
|
||||
setStatus(formatAdminApiError(error));
|
||||
}
|
||||
}
|
||||
|
||||
async function saveStatus(nextStatus: string) {
|
||||
if (!selected || busy) return;
|
||||
setBusy(true);
|
||||
setStatus('');
|
||||
try {
|
||||
const updated = await updateAdminErrorReport(token, selected.batchId, {
|
||||
status: nextStatus,
|
||||
note: selected.note,
|
||||
});
|
||||
setSelected((current) => (current ? { ...current, ...updated } : current));
|
||||
await loadRef.current();
|
||||
} catch (error) {
|
||||
if (isAdminApiError(error) && error.status === 401) onUnauthorized();
|
||||
else setStatus(formatAdminApiError(error));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function download(batchId: string) {
|
||||
if (downloading) return;
|
||||
setDownloading(true);
|
||||
setStatus('');
|
||||
try {
|
||||
const blob = await downloadAdminErrorReport(token, batchId);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = `${batchId}.zip`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 10_000);
|
||||
} catch (error) {
|
||||
if (isAdminApiError(error) && error.status === 401) onUnauthorized();
|
||||
else setStatus(formatAdminApiError(error));
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="admin-panel">
|
||||
<div className="admin-panel-header">
|
||||
<div>
|
||||
<h1>错误报告</h1>
|
||||
<p>查看用户提交的脱敏诊断包。</p>
|
||||
</div>
|
||||
<label>
|
||||
状态{' '}
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(event) => {
|
||||
setFilterStatus(event.target.value);
|
||||
setPageOffset(0);
|
||||
}}
|
||||
>
|
||||
<option value="">全部</option>
|
||||
{ADMIN_ERROR_REPORT_STATUSES.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{status ? (
|
||||
<p className="admin-alert" role="status">
|
||||
{status}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>报告</th>
|
||||
<th>事件</th>
|
||||
<th>来源</th>
|
||||
<th>状态</th>
|
||||
<th>用户</th>
|
||||
<th>时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{reports.map((report) => (
|
||||
<tr
|
||||
key={report.batchId}
|
||||
onClick={() => void openReport(report.batchId)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
void openReport(report.batchId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<td>{report.batchId}</td>
|
||||
<td>{report.eventCount}</td>
|
||||
<td>{report.source ?? '-'}</td>
|
||||
<td>{report.status}</td>
|
||||
<td>{report.userId}</td>
|
||||
<td>{new Date(report.createdAt).toLocaleString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="admin-detail-modal__actions" aria-label="错误报告分页">
|
||||
<span>
|
||||
{pageInfo.total === 0
|
||||
? '暂无报告'
|
||||
: `第 ${pageOffset + 1}-${Math.min(pageOffset + reports.length, pageInfo.total)} 条,共 ${pageInfo.total} 条`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setPageOffset((offset) =>
|
||||
Math.max(0, offset - ERROR_REPORT_PAGE_SIZE),
|
||||
)
|
||||
}
|
||||
disabled={pageOffset === 0}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setPageOffset((offset) => offset + ERROR_REPORT_PAGE_SIZE)
|
||||
}
|
||||
disabled={!pageInfo.hasMore}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
{selected ? (
|
||||
<div
|
||||
className="admin-detail-modal"
|
||||
role="dialog"
|
||||
aria-label="错误报告详情"
|
||||
>
|
||||
<div className="admin-detail-modal__panel">
|
||||
<header>
|
||||
<h2>{selected.batchId}</h2>
|
||||
<button type="button" onClick={() => setSelected(null)}>
|
||||
关闭
|
||||
</button>
|
||||
</header>
|
||||
<p>
|
||||
用户:{selected.userId} · 事件:{selected.eventCount} · 日志:
|
||||
{selected.logCount}
|
||||
</p>
|
||||
<pre>{JSON.stringify(selected.events.slice(0, 20), null, 2)}</pre>
|
||||
{selected.userDescription ? (
|
||||
<p>用户描述:{selected.userDescription}</p>
|
||||
) : null}
|
||||
<label className="admin-detail-modal__note">
|
||||
处理备注
|
||||
<textarea
|
||||
value={selected.note ?? ''}
|
||||
onChange={(event) =>
|
||||
setSelected((current) =>
|
||||
current ? { ...current, note: event.target.value } : current,
|
||||
)
|
||||
}
|
||||
maxLength={2000}
|
||||
rows={4}
|
||||
placeholder="记录处理结论或后续跟进事项"
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
<div className="admin-detail-modal__actions">
|
||||
<select
|
||||
value={selected.status}
|
||||
onChange={(event) => void saveStatus(event.target.value)}
|
||||
disabled={busy}
|
||||
>
|
||||
{ADMIN_ERROR_REPORT_STATUSES.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveStatus(selected.status)}
|
||||
disabled={busy}
|
||||
>
|
||||
保存备注
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void download(selected.batchId)}
|
||||
disabled={downloading}
|
||||
>
|
||||
{downloading ? '下载中…' : '下载诊断包'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -3075,3 +3075,23 @@ button:disabled {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
.admin-detail-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 20px;
|
||||
background: rgb(15 23 42 / 35%);
|
||||
}
|
||||
.admin-detail-modal__panel {
|
||||
width: min(860px, 100%);
|
||||
max-height: 90vh;
|
||||
overflow: auto;
|
||||
border-radius: 14px;
|
||||
padding: 20px;
|
||||
background: var(--admin-surface, #fff);
|
||||
}
|
||||
.admin-detail-modal__panel header,
|
||||
.admin-detail-modal__actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.admin-detail-modal__panel pre { max-height: 360px; overflow: auto; white-space: pre-wrap; background: #f8fafc; padding: 12px; border-radius: 8px; }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { test } from 'node:test';
|
||||
|
||||
import {
|
||||
@@ -46,3 +47,14 @@ test('next release version follows the higher local or OSS version', () => {
|
||||
assert.equal(nextPatchVersion('0.1.18', '0.1.15'), '0.1.19');
|
||||
assert.equal(nextPatchVersion('0.1.12', null), '0.1.13');
|
||||
});
|
||||
|
||||
test('release upload forces overwrite for versioned artifact and latest pointer', () => {
|
||||
const source = readFileSync(
|
||||
new URL('./release-upload.mjs', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.equal(
|
||||
(source.match(/runOssutil\(\['cp', '--force'/gu) ?? []).length,
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -320,6 +320,7 @@ const APP_INVOKE_BARE_CALL_NAMES = new Set([
|
||||
'directInvoke',
|
||||
'invokeInput',
|
||||
'invokeAuthenticatedInput',
|
||||
'invokeDiagnostic',
|
||||
]);
|
||||
|
||||
function parseAppInvokeCommandNames(source, fileName = 'fixture.tsx') {
|
||||
@@ -352,7 +353,10 @@ function parseAppInvokeCommandNames(source, fileName = 'fixture.tsx') {
|
||||
const isObjectInvoke =
|
||||
ts.isPropertyAccessExpression(expression) &&
|
||||
expression.name.text === 'invoke';
|
||||
const commandArgument = node.arguments[0];
|
||||
const commandArgument =
|
||||
ts.isIdentifier(expression) && expression.text === 'invokeDiagnostic'
|
||||
? node.arguments[1]
|
||||
: node.arguments[0];
|
||||
if (
|
||||
(isBareCall || isObjectInvoke) &&
|
||||
commandArgument !== undefined &&
|
||||
|
||||
@@ -40,7 +40,9 @@ await prepareReleaseVersion();
|
||||
runTauriBuild([]);
|
||||
const { artifact, manifestPath, manifest } = generateUpdateManifest();
|
||||
const artifactKey = `agc/${manifest.version}/${path.basename(artifact)}`;
|
||||
runOssutil(['cp', artifact, `oss://${bucket}/${artifactKey}`]);
|
||||
runOssutil(['cp', manifestPath, `oss://${bucket}/agc/latest.json`]);
|
||||
// Jenkins/ossutil 默认会在目标对象已存在时交互询问并按默认值跳过;
|
||||
// 发布清单是固定的 latest 指针,必须显式覆盖,否则流水线会误报成功但远端仍保留旧版本。
|
||||
runOssutil(['cp', '--force', artifact, `oss://${bucket}/${artifactKey}`]);
|
||||
runOssutil(['cp', '--force', manifestPath, `oss://${bucket}/agc/latest.json`]);
|
||||
console.log(`[ai-game-creator-shell] 已上传 oss://${bucket}/${artifactKey}`);
|
||||
console.log(`[ai-game-creator-shell] 已上传 oss://${bucket}/agc/latest.json`);
|
||||
|
||||
+1
@@ -1727,6 +1727,7 @@ dependencies = [
|
||||
"platform-agent",
|
||||
"platform-llm",
|
||||
"portable-pty",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
"schemars 1.2.1",
|
||||
"serde",
|
||||
|
||||
@@ -44,6 +44,7 @@ platform-agent = { path = "../../../server-rs/crates/platform-agent" }
|
||||
portable-pty = "0.9"
|
||||
percent-encoding = "2"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "native-tls", "stream"] }
|
||||
regex = "1"
|
||||
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false }
|
||||
tauri = { version = "2.11.2", features = [] }
|
||||
tauri-plugin-dialog = "2.7.1"
|
||||
|
||||
@@ -1669,7 +1669,7 @@ impl CodexAppServerConnection {
|
||||
None
|
||||
};
|
||||
if std::env::var_os("GENARRATIVE_AGC_DIRECT_DEBUG").is_some() {
|
||||
eprintln!(
|
||||
app_log!(
|
||||
"agent.direct_codex.provider_proxy configured={}",
|
||||
provider_proxy.is_some()
|
||||
);
|
||||
@@ -1803,7 +1803,7 @@ impl CodexAppServerConnection {
|
||||
.await
|
||||
.map_err(platform_llm::LlmError::Transport)?;
|
||||
if let Some(reason) = remote_control_disable_reason {
|
||||
eprintln!("agent.codex_app_server.remote_control disabled reason={reason}");
|
||||
app_log!("agent.codex_app_server.remote_control disabled reason={reason}");
|
||||
}
|
||||
if let Some(skill_roots) = connection.inner._skill_roots.as_ref() {
|
||||
connection
|
||||
@@ -2954,7 +2954,7 @@ async fn fail_game_creator_codex_app_server_connection(
|
||||
.unwrap_or_else(|| "unknown".to_string());
|
||||
let stderr = inner.stderr_summary.lock().await.diagnostic();
|
||||
let diagnostic = format!("{error};exitStatus={exit_status};{stderr}");
|
||||
eprintln!("agent.runner.failed: Codex app-server 连接终止:{diagnostic}");
|
||||
app_log!("agent.runner.failed: Codex app-server 连接终止:{diagnostic}");
|
||||
for (_, pending) in inner.pending.lock().await.drain() {
|
||||
let _ = pending.sender.send(Err(diagnostic.clone()));
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ async fn proxy_codex_provider_request(
|
||||
}
|
||||
let direct_debug = std::env::var_os("GENARRATIVE_AGC_DIRECT_DEBUG").is_some();
|
||||
if direct_debug {
|
||||
eprintln!(
|
||||
app_log!(
|
||||
"agent.direct_codex.provider_proxy.request method={} path={}",
|
||||
request.method(),
|
||||
request.uri().path(),
|
||||
@@ -174,7 +174,7 @@ async fn proxy_codex_provider_request(
|
||||
}
|
||||
}
|
||||
if direct_debug {
|
||||
eprintln!(
|
||||
app_log!(
|
||||
"agent.direct_codex.provider_proxy.response status={} strippedCodexHeaders={}",
|
||||
status.as_u16(),
|
||||
stripped_limit_headers,
|
||||
|
||||
@@ -462,7 +462,7 @@ pub(crate) async fn request_generator_game_draft_with_client(
|
||||
Ok(response) => break response,
|
||||
Err(platform_llm::LlmError::EmptyResponse) if empty_retries < MAX_EMPTY_RETRIES => {
|
||||
empty_retries += 1;
|
||||
eprintln!(
|
||||
app_log!(
|
||||
"llm.chat.generator.empty-response 重试 {empty_retries}/{MAX_EMPTY_RETRIES}(上游返回空 content,原样重发)"
|
||||
);
|
||||
// 同步推送到 App 进度面板,便于在界面上看到重试(无需盯命令行)。
|
||||
|
||||
@@ -141,7 +141,7 @@ pub(crate) fn schedule_waiting_autonomous_manifest_parent_wake_after_lane_releas
|
||||
"error": error,
|
||||
}),
|
||||
);
|
||||
eprintln!("项目任务图自动唤醒状态持久化失败:{error}");
|
||||
app_log!("项目任务图自动唤醒状态持久化失败:{error}");
|
||||
}
|
||||
if !singleflight.finish_pass() {
|
||||
return;
|
||||
|
||||
@@ -1410,6 +1410,7 @@ pub(crate) fn fail_game_creator_agent_runtime_turn_at(
|
||||
state.next_step = "等待开发者处理失败".to_string();
|
||||
let public_error = redact_agent_runtime_error(root, error, 500);
|
||||
state.error = Some(public_error.clone());
|
||||
let _ = crate::error_report::report_agent_runtime_error(&state.agent_id, &public_error);
|
||||
// The public terminal message is deliberately committed before the
|
||||
// remaining Runtime projections. Even if a task/event/state write is the
|
||||
// failing subsystem, the user still receives one stable failure outcome.
|
||||
@@ -1469,6 +1470,7 @@ pub(crate) fn fail_game_creator_agent_runtime_budget_at(
|
||||
state.next_step = "调整任务范围后重试".to_string();
|
||||
let public_error = redact_agent_runtime_error(root, error, 500);
|
||||
state.error = Some(public_error.clone());
|
||||
let _ = crate::error_report::report_agent_runtime_error(&state.agent_id, &public_error);
|
||||
let public_status_result =
|
||||
append_game_creator_agent_runtime_terminal_public_message_at(root, &state, &public_error);
|
||||
write_non_terminal_isolated_child_cancel_tombstones_for_parent_at(
|
||||
@@ -3783,6 +3785,7 @@ pub(crate) fn fail_game_creator_agent_runtime_public_start_status_at(
|
||||
error: &str,
|
||||
) -> Result<(), String> {
|
||||
let error = redact_agent_runtime_project_paths(root, error, 500);
|
||||
let _ = crate::error_report::report_agent_runtime_error(&record.agent_id, &error);
|
||||
let failed_task = AgentRuntimeTaskRecord {
|
||||
status: "failed".to_string(),
|
||||
phase: "public-status-write-failed".to_string(),
|
||||
|
||||
@@ -1714,7 +1714,7 @@ pub(crate) fn decide_game_creator_plan_gdd(
|
||||
enforce_project_permission_policy(&root, "conversation.write")?;
|
||||
enforce_project_permission_policy(&root, "agent.run_status")?;
|
||||
enforce_project_permission_policy(&root, "agent.resume")?;
|
||||
let mut result = decide_plan_gdd_at(
|
||||
let mut result = match decide_plan_gdd_at(
|
||||
&root,
|
||||
&DecidePlanGddInputV1 {
|
||||
gdd_id,
|
||||
@@ -1726,10 +1726,22 @@ pub(crate) fn decide_game_creator_plan_gdd(
|
||||
action,
|
||||
comment,
|
||||
},
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
) {
|
||||
Ok(result) => result,
|
||||
Err(error) => {
|
||||
return Err(error.to_string());
|
||||
}
|
||||
};
|
||||
if !result.recovery_pending {
|
||||
if wake_pending_game_creator_agent_background_tasks_at(&root).is_err() {
|
||||
if let Err(error) = wake_pending_game_creator_agent_background_tasks_at(&root) {
|
||||
let detail = error.to_string();
|
||||
crate::error_report::report_diagnostic_error(
|
||||
"agent",
|
||||
&detail,
|
||||
None,
|
||||
Some("wake_pending_game_creator_agent_background_tasks"),
|
||||
None,
|
||||
);
|
||||
// The receipt is already the user-decision linearization point;
|
||||
// surface a recoverable projection state instead of turning a
|
||||
// durable approval into a false command failure.
|
||||
|
||||
@@ -39,7 +39,7 @@ fn repo_root() -> Option<PathBuf> {
|
||||
// 便于排查 “EOF while parsing a string” 这类输出截断问题。尽力而为,不阻断主流程。
|
||||
pub(crate) fn persist_snapshot(raw_content: &str) {
|
||||
let Some(repo_root) = repo_root() else {
|
||||
eprintln!("llm.draft.snapshot.skip: 未能定位仓库根目录");
|
||||
app_log!("llm.draft.snapshot.skip: 未能定位仓库根目录");
|
||||
return;
|
||||
};
|
||||
let dir = repo_root
|
||||
@@ -47,12 +47,12 @@ pub(crate) fn persist_snapshot(raw_content: &str) {
|
||||
.join("ai-game-creator-shell")
|
||||
.join(".llm-drafts");
|
||||
if let Err(error) = fs::create_dir_all(&dir) {
|
||||
eprintln!("llm.draft.snapshot.dir.failed: {}: {error}", dir.display());
|
||||
app_log!("llm.draft.snapshot.dir.failed: {}: {error}", dir.display());
|
||||
return;
|
||||
}
|
||||
let path = dir.join(format!("draft-{}.txt", unix_millis()));
|
||||
if let Err(error) = fs::write(&path, raw_content) {
|
||||
eprintln!(
|
||||
app_log!(
|
||||
"llm.draft.snapshot.write.failed: {}: {error}",
|
||||
path.display()
|
||||
);
|
||||
@@ -61,14 +61,14 @@ pub(crate) fn persist_snapshot(raw_content: &str) {
|
||||
// 同步更新 latest.txt,方便直接打开最近一次草案。
|
||||
let latest = dir.join("latest.txt");
|
||||
let _ = fs::write(&latest, raw_content);
|
||||
eprintln!("llm.draft.snapshot.saved: {}", path.display());
|
||||
app_log!("llm.draft.snapshot.saved: {}", path.display());
|
||||
}
|
||||
|
||||
// LLM 调用失败(例如返回内容为空)时,把本次发送给模型的输入(system + 完整 user prompt)
|
||||
// 连同错误信息一起落盘到 .llm-drafts/,便于按同样输入复现与定位。尽力而为,不阻断主流程。
|
||||
pub(crate) fn persist_error_input(system_prompt: &str, user_prompt: &str, error: &str) {
|
||||
let Some(repo_root) = repo_root() else {
|
||||
eprintln!("llm.draft.error-input.skip: 未能定位仓库根目录");
|
||||
app_log!("llm.draft.error-input.skip: 未能定位仓库根目录");
|
||||
return;
|
||||
};
|
||||
let dir = repo_root
|
||||
@@ -76,7 +76,7 @@ pub(crate) fn persist_error_input(system_prompt: &str, user_prompt: &str, error:
|
||||
.join("ai-game-creator-shell")
|
||||
.join(".llm-drafts");
|
||||
if let Err(io_error) = fs::create_dir_all(&dir) {
|
||||
eprintln!(
|
||||
app_log!(
|
||||
"llm.draft.error-input.dir.failed: {}: {io_error}",
|
||||
dir.display()
|
||||
);
|
||||
@@ -87,7 +87,7 @@ pub(crate) fn persist_error_input(system_prompt: &str, user_prompt: &str, error:
|
||||
);
|
||||
let path = dir.join(format!("error-input-{}.txt", unix_millis()));
|
||||
if let Err(io_error) = fs::write(&path, &body) {
|
||||
eprintln!(
|
||||
app_log!(
|
||||
"llm.draft.error-input.write.failed: {}: {io_error}",
|
||||
path.display()
|
||||
);
|
||||
@@ -96,5 +96,5 @@ pub(crate) fn persist_error_input(system_prompt: &str, user_prompt: &str, error:
|
||||
// 同步更新 latest-error-input.txt,方便直接打开最近一次失败输入。
|
||||
let latest = dir.join("latest-error-input.txt");
|
||||
let _ = fs::write(&latest, &body);
|
||||
eprintln!("llm.draft.error-input.saved: {}", path.display());
|
||||
app_log!("llm.draft.error-input.saved: {}", path.display());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
use tauri::command;
|
||||
|
||||
use super::queue::{ack, report_diagnostic_error, snapshot, ErrorReportEvent};
|
||||
|
||||
#[command]
|
||||
pub fn report_client_error(
|
||||
source: String,
|
||||
message: String,
|
||||
stack: Option<String>,
|
||||
action: Option<String>,
|
||||
page: Option<String>,
|
||||
) -> Result<ErrorReportEvent, String> {
|
||||
report_diagnostic_error(
|
||||
&source,
|
||||
&message,
|
||||
stack.as_deref(),
|
||||
action.as_deref(),
|
||||
page.as_deref(),
|
||||
)
|
||||
.ok_or_else(|| "错误消息不能为空".to_string())
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub fn get_pending_error_reports() -> Vec<ErrorReportEvent> {
|
||||
snapshot()
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub fn ack_error_reports(event_ids: Vec<String>) -> Result<(), String> {
|
||||
ack(&event_ids);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
mod commands;
|
||||
mod notifications;
|
||||
mod queue;
|
||||
mod sanitize;
|
||||
|
||||
pub use commands::{ack_error_reports, get_pending_error_reports, report_client_error};
|
||||
pub use notifications::initialize_notifications;
|
||||
pub use queue::{report_agent_runtime_error, report_diagnostic_error};
|
||||
@@ -0,0 +1,55 @@
|
||||
use std::sync::{
|
||||
atomic::{AtomicU64, Ordering},
|
||||
Mutex, OnceLock,
|
||||
};
|
||||
use std::time::Duration;
|
||||
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
use super::queue::snapshot_with_generation;
|
||||
|
||||
static APP_HANDLE: OnceLock<AppHandle> = OnceLock::new();
|
||||
static TIMER_ACTIVE: OnceLock<Mutex<bool>> = OnceLock::new();
|
||||
static LAST_EMITTED_GENERATION: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
pub fn initialize_notifications(app: &AppHandle) {
|
||||
let _ = APP_HANDLE.set(app.clone());
|
||||
let _ = TIMER_ACTIVE.set(Mutex::new(false));
|
||||
}
|
||||
|
||||
pub(crate) fn schedule_notification() {
|
||||
let Some(handle) = APP_HANDLE.get().cloned() else {
|
||||
return;
|
||||
};
|
||||
let active = TIMER_ACTIVE.get_or_init(|| Mutex::new(false));
|
||||
{
|
||||
let mut value = active
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if *value {
|
||||
return;
|
||||
}
|
||||
*value = true;
|
||||
}
|
||||
tauri::async_runtime::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
let (events, current_generation) = snapshot_with_generation();
|
||||
LAST_EMITTED_GENERATION.store(current_generation, Ordering::Release);
|
||||
if !events.is_empty() {
|
||||
let _ = handle.emit(
|
||||
"error-report-updated",
|
||||
serde_json::json!({
|
||||
"generation": current_generation,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if let Some(active) = TIMER_ACTIVE.get() {
|
||||
*active
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner()) = false;
|
||||
}
|
||||
if snapshot_with_generation().1 > LAST_EMITTED_GENERATION.load(Ordering::Acquire) {
|
||||
schedule_notification();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::notifications::schedule_notification;
|
||||
use super::sanitize::{fingerprint, sanitize};
|
||||
|
||||
const MAX_EVENTS: usize = 100;
|
||||
const MAX_SOURCE_CHARS: usize = 128;
|
||||
const MAX_MESSAGE_CHARS: usize = 512;
|
||||
const MAX_STACK_CHARS: usize = 8_000;
|
||||
const MAX_ACTION_CHARS: usize = 128;
|
||||
const MAX_PAGE_CHARS: usize = 128;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ErrorReportEvent {
|
||||
pub event_id: String,
|
||||
pub fingerprint: String,
|
||||
pub source: String,
|
||||
pub message: String,
|
||||
pub stack: Option<String>,
|
||||
pub occurred_at: String,
|
||||
pub last_occurred_at: String,
|
||||
pub count: u32,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Queue {
|
||||
events: HashMap<String, ErrorReportEvent>,
|
||||
order: VecDeque<String>,
|
||||
sequence: u64,
|
||||
}
|
||||
|
||||
static QUEUE: OnceLock<Mutex<Queue>> = OnceLock::new();
|
||||
|
||||
fn queue() -> &'static Mutex<Queue> {
|
||||
QUEUE.get_or_init(|| Mutex::new(Queue::default()))
|
||||
}
|
||||
|
||||
fn now() -> String {
|
||||
let seconds = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
format!("{seconds}")
|
||||
}
|
||||
|
||||
fn stable_codex_app_server_error_message(message: &str) -> Option<String> {
|
||||
for marker in ["kind=codex-app-server-", "codex-app-server-error:"] {
|
||||
let Some(start) = message.find(marker) else {
|
||||
continue;
|
||||
};
|
||||
let kind_start = start + marker.len();
|
||||
let kind = message[kind_start..]
|
||||
.chars()
|
||||
.take_while(|character| character.is_ascii_lowercase() || *character == '-')
|
||||
.collect::<String>();
|
||||
if !kind.is_empty() {
|
||||
return Some(format!("codex-app-server-error:{kind}"));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn report_agent_runtime_error(agent_id: &str, error: &str) -> Option<ErrorReportEvent> {
|
||||
report_diagnostic_error(
|
||||
"agent-runtime",
|
||||
error,
|
||||
None,
|
||||
Some("agent-runtime"),
|
||||
Some(agent_id),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn report_diagnostic_error(
|
||||
source: &str,
|
||||
message: &str,
|
||||
stack: Option<&str>,
|
||||
action: Option<&str>,
|
||||
page: Option<&str>,
|
||||
) -> Option<ErrorReportEvent> {
|
||||
let raw_call_site = stack.and_then(|value| {
|
||||
value.lines().find_map(|line| {
|
||||
let trimmed = line.trim();
|
||||
trimmed.strip_prefix("at ").map(|site| {
|
||||
site.trim_end_matches(|character: char| {
|
||||
character.is_ascii_digit()
|
||||
|| character == ':'
|
||||
|| character == ')'
|
||||
|| character == '('
|
||||
})
|
||||
.trim()
|
||||
.to_string()
|
||||
})
|
||||
})
|
||||
});
|
||||
let source = sanitize(source, MAX_SOURCE_CHARS);
|
||||
let action = action.map(|value| sanitize(value, MAX_ACTION_CHARS));
|
||||
let page = page.map(|value| sanitize(value, MAX_PAGE_CHARS));
|
||||
let sanitized_message = sanitize(message, MAX_MESSAGE_CHARS);
|
||||
let message =
|
||||
stable_codex_app_server_error_message(&sanitized_message).unwrap_or(sanitized_message);
|
||||
let stack = stack.map(|value| sanitize(value, MAX_STACK_CHARS));
|
||||
if message.trim().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let fingerprint = fingerprint(&[
|
||||
&source,
|
||||
action.as_deref().unwrap_or_default(),
|
||||
page.as_deref().unwrap_or_default(),
|
||||
&message,
|
||||
raw_call_site.as_deref().unwrap_or_default(),
|
||||
]);
|
||||
let timestamp = now();
|
||||
let (should_schedule, event) = {
|
||||
let mut state = queue()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if let Some(existing) = state.events.get_mut(&fingerprint) {
|
||||
existing.count = existing.count.saturating_add(1);
|
||||
existing.last_occurred_at = timestamp;
|
||||
(false, existing.clone())
|
||||
} else {
|
||||
state.sequence = state.sequence.saturating_add(1);
|
||||
let event_id = format!("rust-error-{}", state.sequence);
|
||||
if state.order.len() >= MAX_EVENTS {
|
||||
if let Some(oldest) = state.order.pop_front() {
|
||||
state.events.remove(&oldest);
|
||||
}
|
||||
}
|
||||
state.order.push_back(fingerprint.clone());
|
||||
let event = ErrorReportEvent {
|
||||
event_id,
|
||||
fingerprint,
|
||||
source,
|
||||
message,
|
||||
stack,
|
||||
occurred_at: timestamp.clone(),
|
||||
last_occurred_at: timestamp,
|
||||
count: 1,
|
||||
};
|
||||
state
|
||||
.events
|
||||
.insert(event.fingerprint.clone(), event.clone());
|
||||
(true, event)
|
||||
}
|
||||
};
|
||||
if should_schedule {
|
||||
schedule_notification();
|
||||
}
|
||||
Some(event)
|
||||
}
|
||||
|
||||
pub(crate) fn snapshot() -> Vec<ErrorReportEvent> {
|
||||
snapshot_with_generation().0
|
||||
}
|
||||
|
||||
pub(crate) fn snapshot_with_generation() -> (Vec<ErrorReportEvent>, u64) {
|
||||
let state = queue()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let events = state
|
||||
.order
|
||||
.iter()
|
||||
.filter_map(|fingerprint| state.events.get(fingerprint).cloned())
|
||||
.collect();
|
||||
(events, state.sequence)
|
||||
}
|
||||
|
||||
pub(crate) fn ack(event_ids: &[String]) {
|
||||
let mut state = queue()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
for event_id in event_ids {
|
||||
let Some(fingerprint) = state.events.iter().find_map(|(fingerprint, event)| {
|
||||
(event.event_id == *event_id).then_some(fingerprint.clone())
|
||||
}) else {
|
||||
continue;
|
||||
};
|
||||
state.events.remove(&fingerprint);
|
||||
state.order.retain(|item| item != &fingerprint);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn generation() -> u64 {
|
||||
let state = queue()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
state.sequence
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn reset_for_tests() {
|
||||
let mut state = queue()
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
*state = Queue::default();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{report_agent_runtime_error, report_diagnostic_error, reset_for_tests, snapshot};
|
||||
|
||||
#[test]
|
||||
fn merges_events_by_call_site_without_storing_second_queue() {
|
||||
reset_for_tests();
|
||||
let first = report_diagnostic_error(
|
||||
"test",
|
||||
"boom",
|
||||
Some("Error: boom\n at app.ts:10:2"),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("first event");
|
||||
let second = report_diagnostic_error(
|
||||
"test",
|
||||
"boom",
|
||||
Some("Error: boom\n at app.ts:99:7"),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("merged event");
|
||||
assert_eq!(first.event_id, second.event_id);
|
||||
assert_eq!(snapshot()[0].count, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_empty_messages() {
|
||||
reset_for_tests();
|
||||
assert!(report_diagnostic_error("test", "\n", None, None, None).is_none());
|
||||
assert!(snapshot().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonicalizes_codex_app_server_public_summaries_and_deduplicates_frontend_capture() {
|
||||
reset_for_tests();
|
||||
let runtime = report_agent_runtime_error(
|
||||
"project-supervisor",
|
||||
"runtime 调用 LLM 失败:kind=codex-app-server-other fingerprint=deadbeef chars=42",
|
||||
)
|
||||
.expect("runtime event");
|
||||
let frontend = report_diagnostic_error(
|
||||
"agent-runtime",
|
||||
"codex-app-server-error:other",
|
||||
None,
|
||||
Some("agent-runtime"),
|
||||
Some("project-supervisor"),
|
||||
)
|
||||
.expect("frontend event");
|
||||
assert_eq!(runtime.event_id, frontend.event_id);
|
||||
assert_eq!(snapshot()[0].count, 2);
|
||||
assert_eq!(snapshot()[0].message, "codex-app-server-error:other");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
static AUTHORIZATION_PATTERN: LazyLock<regex::Regex> = LazyLock::new(|| {
|
||||
regex::Regex::new(r"(?i)authorization\s*:\s*(?:bearer\s+)?\S+")
|
||||
.expect("valid diagnostic sanitization pattern")
|
||||
});
|
||||
static BEARER_PATTERN: LazyLock<regex::Regex> = LazyLock::new(|| {
|
||||
regex::Regex::new(r"(?i)bearer\s+\S+").expect("valid diagnostic sanitization pattern")
|
||||
});
|
||||
static TOKEN_PATTERN: LazyLock<regex::Regex> = LazyLock::new(|| {
|
||||
regex::Regex::new(r"(?i)(?:api[_-]?key|token)\s*[=:]\s*\S+")
|
||||
.expect("valid diagnostic sanitization pattern")
|
||||
});
|
||||
static URL_PATTERN: LazyLock<regex::Regex> = LazyLock::new(|| {
|
||||
regex::Regex::new(r"(?i)https?://\S+").expect("valid diagnostic sanitization pattern")
|
||||
});
|
||||
static PATH_PATTERN: LazyLock<regex::Regex> = LazyLock::new(|| {
|
||||
regex::Regex::new(r"(?i)[A-Z]:[\\/][^\s]+|/(?:Users|home|private|tmp)/[^\s]+")
|
||||
.expect("valid diagnostic sanitization pattern")
|
||||
});
|
||||
static HEX_ID_PATTERN: LazyLock<regex::Regex> = LazyLock::new(|| {
|
||||
regex::Regex::new(r"(?i)\b[0-9a-f]{8,}\b").expect("valid diagnostic sanitization pattern")
|
||||
});
|
||||
|
||||
fn replace_pattern(value: String, pattern: ®ex::Regex, replacement: &str) -> String {
|
||||
pattern.replace_all(&value, replacement).into_owned()
|
||||
}
|
||||
|
||||
pub(crate) fn sanitize(value: &str, max_chars: usize) -> String {
|
||||
let normalized = value
|
||||
.to_string()
|
||||
.pipe(|value| replace_pattern(value, &AUTHORIZATION_PATTERN, "authorization: [REDACTED]"))
|
||||
.pipe(|value| replace_pattern(value, &BEARER_PATTERN, "Bearer [REDACTED]"))
|
||||
.pipe(|value| replace_pattern(value, &TOKEN_PATTERN, "[REDACTED]"))
|
||||
.pipe(|value| replace_pattern(value, &URL_PATTERN, "<url>"))
|
||||
.pipe(|value| replace_pattern(value, &PATH_PATTERN, "<path>"))
|
||||
.pipe(|value| replace_pattern(value, &HEX_ID_PATTERN, "<id>"));
|
||||
normalized
|
||||
.replace(['\r', '\n'], " ")
|
||||
.chars()
|
||||
.filter(|character| !character.is_control() || *character == '\t')
|
||||
.collect::<String>()
|
||||
.chars()
|
||||
.take(max_chars)
|
||||
.collect()
|
||||
}
|
||||
|
||||
trait Pipe: Sized {
|
||||
fn pipe<T>(self, function: impl FnOnce(Self) -> T) -> T {
|
||||
function(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Pipe for T {}
|
||||
|
||||
pub(crate) fn fingerprint(parts: &[&str]) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
for part in parts {
|
||||
hasher.update(part.as_bytes());
|
||||
hasher.update([0]);
|
||||
}
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::sanitize;
|
||||
|
||||
#[test]
|
||||
fn redacts_credentials_urls_paths_and_ids() {
|
||||
let value = "authorization: Bearer secret token=abc https://example.test/a /home/alice/project deadbeef12";
|
||||
let sanitized = sanitize(value, 512);
|
||||
assert!(!sanitized.contains("secret"));
|
||||
assert!(!sanitized.contains("example.test"));
|
||||
assert!(!sanitized.contains("/home/alice"));
|
||||
assert!(!sanitized.contains("deadbeef12"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacts_case_insensitive_posix_paths() {
|
||||
let sanitized = sanitize("/users/alice/project", 512);
|
||||
assert!(!sanitized.contains("/users/alice"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removes_newlines_and_bounds_length() {
|
||||
assert_eq!(sanitize("a\nb\u{0000}c", 3), "a b");
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ use platform_llm::{
|
||||
use reqwest::header;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Digest;
|
||||
use shared_contracts::error_reports::ErrorReportLogInput;
|
||||
use shared_contracts::game_creation_app::{
|
||||
new_game_creation_app_manifest, new_game_creation_app_seed_tasks,
|
||||
validate_game_iteration_versions, GameCreationAgentArtifactTrace,
|
||||
@@ -227,6 +228,16 @@ async fn download_agc_update(
|
||||
Ok(target.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// Rust 侧普通文本日志:保留 stderr 输出,同时将同一行持久化到 AppData。
|
||||
/// 诊断包只在用户主动提交时读取这些 raw log;结构化错误事件仍只留在内存。
|
||||
macro_rules! app_log {
|
||||
($($arg:tt)*) => {{
|
||||
let message = format!($($arg)*);
|
||||
let _ = $crate::append_application_log_line(&format!("RUST {}: {}", module_path!(), message));
|
||||
std::eprintln!("{}", message);
|
||||
}};
|
||||
}
|
||||
|
||||
// 调试落盘模块(保存 LLM 原始输出 / 失败输入,排查截断、空返回等)放在 debug_drafts.rs。
|
||||
// 用 #[cfg] 编译期门控:仅开发(debug)且非测试构建编入;生产 release 与 cargo test 下整体剔除。
|
||||
mod agent;
|
||||
@@ -247,6 +258,7 @@ mod context_menu;
|
||||
#[cfg(all(debug_assertions, not(test)))]
|
||||
mod debug;
|
||||
mod delegation;
|
||||
pub mod error_report;
|
||||
mod git_inspect;
|
||||
mod goal;
|
||||
mod http_client;
|
||||
@@ -283,6 +295,7 @@ use commands::*;
|
||||
use config::*;
|
||||
use context_compaction::*;
|
||||
use delegation::*;
|
||||
use error_report::*;
|
||||
use git_inspect::*;
|
||||
use goal::*;
|
||||
use image_inspect::*;
|
||||
@@ -1759,6 +1772,47 @@ static DIAGNOSTIC_LOG_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
static STARTUP_PANIC_LOG_PATH: OnceLock<PathBuf> = OnceLock::new();
|
||||
static STARTUP_ERROR_DIALOG_SHOWN: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
#[tauri::command]
|
||||
fn append_application_log(level: String, source: String, message: String) -> Result<(), String> {
|
||||
let config_dir = game_creator_runtime_config_dir()
|
||||
.ok_or_else(|| "客户端 AppData 配置目录未初始化".to_string())?;
|
||||
let level = sanitize_diagnostic_message(&level, Some(&config_dir));
|
||||
let source = sanitize_diagnostic_message(&source, Some(&config_dir));
|
||||
let message = sanitize_diagnostic_message(&message, Some(&config_dir));
|
||||
let line = format!("WEBVIEW {level} {source}: {message}");
|
||||
append_application_log_line(&line).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn read_diagnostic_logs() -> Result<Vec<ErrorReportLogInput>, String> {
|
||||
let Some(config_dir) = game_creator_runtime_config_dir() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let _guard = DIAGNOSTIC_LOG_LOCK
|
||||
.get_or_init(|| Mutex::new(()))
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let directory = config_dir.join("diagnostics");
|
||||
let mut files = Vec::new();
|
||||
for name in ["application.log", "application.previous.log", "startup.log"] {
|
||||
let path = directory.join(name);
|
||||
let Ok(metadata) = fs::symlink_metadata(&path) else {
|
||||
continue;
|
||||
};
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||
continue;
|
||||
}
|
||||
let Ok(content) = fs::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
files.push(ErrorReportLogInput {
|
||||
name: name.to_string(),
|
||||
content,
|
||||
});
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
fn diagnostic_timestamp() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
@@ -1864,6 +1918,14 @@ pub(crate) fn append_bounded_diagnostic_line(path: &Path, line: &str) -> std::io
|
||||
append_bounded_diagnostic_line_with_limit(path, line, DIAGNOSTIC_LOG_MAX_BYTES)
|
||||
}
|
||||
|
||||
pub(crate) fn append_application_log_line(line: &str) -> std::io::Result<()> {
|
||||
let Some(config_dir) = game_creator_runtime_config_dir() else {
|
||||
return Ok(());
|
||||
};
|
||||
let sanitized = sanitize_diagnostic_message(line, Some(&config_dir));
|
||||
append_bounded_diagnostic_line(&config_dir.join("diagnostics/application.log"), &sanitized)
|
||||
}
|
||||
|
||||
fn redact_windows_absolute_paths(value: &str) -> String {
|
||||
let bytes = value.as_bytes();
|
||||
let mut output = String::with_capacity(value.len());
|
||||
@@ -1952,7 +2014,7 @@ pub(crate) fn sanitize_diagnostic_message(value: &str, private_root: Option<&Pat
|
||||
}
|
||||
|
||||
fn show_startup_error_dialog(log_path: &Path) {
|
||||
eprintln!("Genarrative startup failed; see {}", log_path.display());
|
||||
app_log!("Genarrative startup failed; see {}", log_path.display());
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -2042,16 +2104,16 @@ where
|
||||
fn handle_game_creator_gui_run_event(event: &tauri::RunEvent) {
|
||||
if matches!(event, tauri::RunEvent::Exit) {
|
||||
if let Err(error) = agent::shutdown_game_creator_codex_app_servers() {
|
||||
eprintln!("agent.direct_codex.gui_exit.shutdown_failed: {error}");
|
||||
app_log!("agent.direct_codex.gui_exit.shutdown_failed: {error}");
|
||||
}
|
||||
}
|
||||
match resolve_game_creator_gui_runner_shutdown(event, shutdown_external_agent_runner) {
|
||||
GameCreatorGuiRunnerShutdownOutcome::NotRequested => {}
|
||||
GameCreatorGuiRunnerShutdownOutcome::Requested => {
|
||||
eprintln!("agent.runner.gui_exit.shutdown_requested")
|
||||
app_log!("agent.runner.gui_exit.shutdown_requested")
|
||||
}
|
||||
GameCreatorGuiRunnerShutdownOutcome::Failed(failure) => {
|
||||
eprintln!("agent.runner.gui_exit.shutdown_failed.{}", failure.code())
|
||||
app_log!("agent.runner.gui_exit.shutdown_failed.{}", failure.code())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2078,7 +2140,7 @@ fn install_agent_runtime_async_runtime_with_deep_stack() {
|
||||
let runtime = match build_agent_runtime_async_runtime() {
|
||||
Ok(runtime) => runtime,
|
||||
Err(error) => {
|
||||
eprintln!("agent.runner.failed: {error}");
|
||||
app_log!("agent.runner.failed: {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
@@ -2197,7 +2259,7 @@ fn main() {
|
||||
let runtime_config_dir = match take_cli_runtime_config_dir(&mut args) {
|
||||
Ok(config_dir) => config_dir,
|
||||
Err(error) => {
|
||||
eprintln!("{error}");
|
||||
app_log!("{error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
@@ -2206,23 +2268,23 @@ fn main() {
|
||||
[_] => false,
|
||||
[_, option] if option == "--gui-owner-required" => true,
|
||||
_ => {
|
||||
eprintln!(
|
||||
app_log!(
|
||||
"用法:--agent-runner [--gui-owner-required] --config-dir <AppData 绝对路径>"
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
let Some(config_dir) = runtime_config_dir else {
|
||||
eprintln!("Agent Runner 必须显式传入 --config-dir <AppData 绝对路径>");
|
||||
app_log!("Agent Runner 必须显式传入 --config-dir <AppData 绝对路径>");
|
||||
std::process::exit(1);
|
||||
};
|
||||
if let Err(error) = load_platform_session_fixture_from_env(&config_dir) {
|
||||
eprintln!("agent.runner.failed: {error}");
|
||||
app_log!("agent.runner.failed: {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
set_game_creator_runtime_config_dir(config_dir.clone());
|
||||
if let Err(error) = run_external_agent_runner_server(config_dir, gui_owner_required) {
|
||||
eprintln!("agent.runner.failed: {error}");
|
||||
app_log!("agent.runner.failed: {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
return;
|
||||
@@ -2233,13 +2295,13 @@ fn main() {
|
||||
match prepare_cli_command_paths(&mut command, runtime_config_dir.as_deref()) {
|
||||
Ok(config_dir) => config_dir,
|
||||
Err(error) => {
|
||||
eprintln!("agent.runner.failed: {error}");
|
||||
app_log!("agent.runner.failed: {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
if let Some(config_dir) = config_dir {
|
||||
if let Err(error) = load_platform_session_fixture_from_env(&config_dir) {
|
||||
eprintln!("agent.runner.failed: {error}");
|
||||
app_log!("agent.runner.failed: {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
if command.requires_external_agent_runner() {
|
||||
@@ -2249,7 +2311,7 @@ fn main() {
|
||||
configure_external_agent_runner(&config_dir)
|
||||
};
|
||||
if let Err(error) = configured {
|
||||
eprintln!("agent.runner.failed: {error}");
|
||||
app_log!("agent.runner.failed: {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -2257,19 +2319,19 @@ fn main() {
|
||||
}
|
||||
if command.requires_started_external_agent_runner() {
|
||||
if let Err(error) = ensure_external_agent_runner_started() {
|
||||
eprintln!("agent.runner.failed: {error}");
|
||||
app_log!("agent.runner.failed: {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
if let Err(error) = run_cli_command(command) {
|
||||
eprintln!("agent.run.failed: {error}");
|
||||
app_log!("agent.run.failed: {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
eprintln!("{error}");
|
||||
app_log!("{error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -2290,6 +2352,7 @@ fn main() {
|
||||
.manage(game_creator_preview_registry())
|
||||
.manage(ProjectResourcePreviewReadManager::default())
|
||||
.setup(move |app| {
|
||||
error_report::initialize_notifications(app.handle());
|
||||
if let Some(path) = setup_log.as_deref() {
|
||||
let _ = append_bounded_diagnostic_line(path, "startup.setup.begin");
|
||||
let _ = append_bounded_diagnostic_line(path, "startup.appdata.configure.begin");
|
||||
@@ -2304,7 +2367,9 @@ fn main() {
|
||||
show_startup_error_dialog(path);
|
||||
}
|
||||
})?;
|
||||
if let Some(path) = setup_log.as_deref() {
|
||||
let startup_log = game_creator_runtime_config_dir()
|
||||
.map(|directory| directory.join("diagnostics/startup.log"));
|
||||
if let Some(path) = startup_log.as_deref() {
|
||||
let _ = append_bounded_diagnostic_line(path, "startup.appdata.configure.complete");
|
||||
}
|
||||
let config_dir = game_creator_runtime_config_dir().ok_or_else(|| {
|
||||
@@ -2547,6 +2612,11 @@ fn main() {
|
||||
get_local_game_project_revision,
|
||||
get_local_game_manifest,
|
||||
download_agc_update,
|
||||
append_application_log,
|
||||
read_diagnostic_logs,
|
||||
report_client_error,
|
||||
get_pending_error_reports,
|
||||
ack_error_reports
|
||||
])
|
||||
.build(tauri_context);
|
||||
let app = match app {
|
||||
@@ -2565,7 +2635,7 @@ fn main() {
|
||||
);
|
||||
show_startup_error_dialog(path);
|
||||
}
|
||||
eprintln!("failed to build Genarrative AI Game Creator shell: {error}");
|
||||
app_log!("failed to build Genarrative AI Game Creator shell: {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user