Merge branch 'master' into codex/issue-257-bgfilter-agc-backend
Project CI / Repository checks (pull_request) Successful in 2m47s
Project CI / Frontend tests (pull_request) Successful in 3m27s
Project CI / Backend tests (pull_request) Successful in 6m46s
Project CI / Native shell tests (pull_request) Successful in 18m35s

This commit is contained in:
2026-09-03 10:15:08 +08:00
89 changed files with 4746 additions and 229 deletions
+116 -34
View File
@@ -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;
+32
View File
@@ -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;
+7
View File
@@ -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}
+1
View File
@@ -38,6 +38,7 @@ const routeIcons = {
tables: Database,
debug: Bug,
tracking: Table2,
'error-reports': Bug,
'gray-release': GitBranch,
redeem: TicketPercent,
invite: TicketCheck,
+2
View File
@@ -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>
);
}
+20
View File
@@ -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
View File
@@ -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: &regex::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