Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/main.rs
T
kdletters f1b282f3cd 合并 master 并接入 DirectProject 新聊天架构
- 合并 origin/master(304 个提交:DirectProject 聊天容器重构、Project Supervisor 退役、策划附件导入、CI 隔离编译缓存等)。
- 接受 master 对 ProjectSupervisorView / SupervisorChatOnlyView 的退役与预览快捷测试收敛;发布入口改由 DirectProject 聊天头承载。
- DirectProjectChatHeader 新增「发布到游戏广场」入口(无回调不渲染、回合忙态禁用),DirectProjectChatView 透传 onRequestGamePublish。
- App.tsx 继续由工作台壳持有试玩包导出与 GameDistributionPublishPanel,沿用 project.export_package 权限确认队列;check-config 把该命令从 native-only 清单移回 App invoke。
- 后台游戏审核 API / 类型 / 路由测试与 master 新增的 AGC 模板管理按双方保留合并,并修掉拼接造成的接口与用例闭合缺陷。
- 修正 master 自带的 viteProxyConfig 断言:/api/creation-entry 属退役路由,测试改为断言不进入代理。
- 记录合并踩坑:语法结构内部的冲突不能简单按「双方保留」拼接,必须按某一侧骨架重建并跑 tsc 与单文件测试。
- 验证:全量 vitest 393 文件 / 4374 用例通过,root / AGC / admin-web 三端 typecheck,cargo check 与游戏分发 Rust 测试,encoding、doc-index、rustfmt、SpacetimeDB schema guard。
2026-09-22 16:45:29 +08:00

2921 lines
101 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#![cfg_attr(all(not(dev), target_os = "windows"), windows_subsystem = "windows")]
#[cfg(test)]
#[path = "../build_support/godot_bundle.rs"]
mod godot_bundle;
use std::collections::BTreeMap;
use std::fs;
use std::fs::{File, OpenOptions};
use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::AtomicBool;
use std::sync::{mpsc, Arc, Mutex, OnceLock};
use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
// crate 根的 trait 导入会被 `use super::*` 的子模块继承(template_library 的流式下载依赖
// `StreamExt`,通知与 Agent 事件依赖 `Emitter`),不要因为根模块自身不再直接用到就删掉。
use futures::StreamExt;
use platform_agent::{
build_game_creation_seed_task_graph, plan_game_creation_agent_pass,
route_game_creation_repair_issues,
};
use platform_llm::{
LlmApiKind, LlmClient, LlmConfig, LlmMessage, LlmMessageContentPart, LlmProvider,
LlmRunRequest, DEFAULT_RETRY_BACKOFF_MS,
};
use reqwest::header;
use serde::{Deserialize, Serialize};
use sha2::Digest;
use shared_contracts::error_reports::ErrorReportLogInput;
use shared_contracts::game_creation_app::{
game_creation_app_asset_category_for_kind, game_creation_app_asset_category_from_str,
game_creation_app_asset_effective_category, new_game_creation_app_manifest,
new_game_creation_app_seed_tasks, normalize_game_creation_app_asset_tags,
validate_game_iteration_versions, GameCreationAgentArtifactTrace,
GameCreationAgentCapabilityDescriptor, GameCreationAgentPassPlanTrace,
GameCreationAgentRepairRouteTrace, GameCreationAgentRunStep,
GameCreationAgentRunTaskGraphTrace, GameCreationAgentRunTrace, GameCreationAgentToolCallTrace,
GameCreationAppAgentGroup, GameCreationAppAssetKind, GameCreationAppAssetManifestEntry,
GameCreationAppAssetSource, GameCreationAppAssetSourceKind, GameCreationAppCommandRunState,
GameCreationAppCommandRunStatus, GameCreationAppLimitedRunCommandDescriptor,
GameCreationAppManifest, GameCreationAppPermission, GameCreationAppPreviewState,
GameCreationAppPreviewStatus, GameCreationAppTaskState, GameCreationAppTaskStatus,
GameIterationVersion, GameIterationVersionCreatedReason, GameIterationVersionResourceBinding,
ProjectResourceCanvasLayout, ProjectResourceCanvasLayoutMode, ProjectResourceCanvasPosition,
UpdateProjectResourceCanvasLayoutResult, UpdateProjectResourceCanvasLayoutStatus,
GAME_CREATION_AGENT_CAPABILITIES, GAME_CREATION_AGENT_RUN_SCHEMA_VERSION,
GAME_CREATION_AGENT_TOOL_CALL_MAX, GAME_CREATION_APP_COMMANDS,
GAME_CREATION_APP_LIMITED_RUN_COMMANDS, GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION,
};
// `Emitter` 同时被 `use super::*` 的子模块依赖(通知、Agent 事件等都从 crate 根取该 trait),
// 不要因为根模块自身不再直接 `.emit(..)` 就删掉它。
use environment_check::preflight_web_game_creation;
use tauri::{Emitter, Manager};
use tauri_plugin_dialog::DialogExt;
use tauri_plugin_opener::OpenerExt;
/// 更新完成后的进程重启:Windows 由 NSIS 安装程序代为重启,macOS / Linux 由客户端在安装后调用。
#[tauri::command]
fn restart_agc_app(app: tauri::AppHandle) {
app.restart();
}
/// 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);
}};
}
/// 把 `shared-contracts` 的「非 canonical 资源 kind」留痕接到壳层日志上。
///
/// 解析边界(含 manifest 反序列化、画板 assetKind、平台 assetKind)认不出 canonical 值时,
/// 会把**原始输入串**与调用上下文交回来;这里落到 `app_log!` → AppData 日志里,
/// 以便回查非 canonical kind 的写入边界。kind 本身只接受严格值。
///
/// 按 `(原始串, 上下文)` 去重:manifest 读取极频繁,每次都落一行会把其它诊断刷掉;
/// 去重后"有哪些非 canonical 值、分别从哪个边界进来"仍然完整。需要具体资产身份时看
/// `log_non_canonical_manifest_asset_kinds`(按资产去重,带 assetId / localPath)。
fn register_non_canonical_asset_kind_reporter() {
static REPORTED: std::sync::OnceLock<std::sync::Mutex<std::collections::HashSet<String>>> =
std::sync::OnceLock::new();
shared_contracts::game_creation_app::set_non_canonical_asset_kind_reporter(
|raw_kind: &str, context: &str| {
let key = format!("{context}\u{1}{raw_kind}");
let first_seen = REPORTED
.get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()))
.lock()
.map(|mut reported| reported.insert(key))
.unwrap_or(true);
if !first_seen {
return;
}
app_log!(
"GameCreationApp 资源 kind 不是 canonical 值,已按严格口径收口为 unknownrawKind={raw_kind} context={context}"
);
},
);
}
// 调试落盘模块(保存 LLM 原始输出 / 失败输入,排查截断、空返回等)放在 debug_drafts.rs。
// 用 #[cfg] 编译期门控:仅开发(debug)且非测试构建编入;生产 release 与 cargo test 下整体剔除。
include!(concat!(env!("OUT_DIR"), "/agent_runtime_prompt_bundle.rs"));
mod agent;
mod agent_native_tools;
mod asset_generation_tasks;
mod assets;
mod browser;
mod builtin_plugins;
mod cli;
mod client_extensions;
mod collaboration;
mod command_exec;
mod command_output;
mod command_sandbox;
mod command_sandbox_trampoline;
mod commands;
mod config;
mod context_compaction;
mod context_menu;
#[cfg(all(debug_assertions, not(test)))]
mod debug;
mod delegation;
mod editor_adapter;
mod editor_adapters;
mod environment_check;
pub mod error_report;
mod git_inspect;
mod goal;
mod http_client;
mod image_inspect;
mod isolated_agent;
mod patchset;
mod platform_session;
mod plugin_host;
mod preview;
mod process_session;
mod process_session_bridge;
mod project;
mod project_snapshot;
mod provider_handoff;
mod provider_retry;
mod repository_context;
mod resource_inspect;
mod resource_preview_scheduler;
mod runner;
mod swarm_cli;
mod template_library;
mod tool_plan_handoff;
mod user_input;
mod windows;
use agent::design_tools::*;
use agent::*;
use agent_native_tools::*;
use asset_generation_tasks::*;
use assets::*;
use browser::*;
use cli::*;
use client_extensions::*;
use collaboration::*;
use command_exec::*;
use command_output::*;
use command_sandbox::*;
use commands::*;
use config::*;
use context_compaction::*;
use delegation::*;
use error_report::*;
use git_inspect::*;
use goal::*;
use image_inspect::*;
use isolated_agent::*;
use patchset::*;
use platform_session::*;
use plugin_host::{
call_agc_plugin, list_agc_extensions, list_agc_plugins, read_agc_plugin_panel,
refresh_agc_plugins, reload_agc_plugin, set_agc_plugin_enabled, set_agc_plugin_project_path,
start_agc_plugin, stop_agc_plugin, PluginHost,
};
use preview::*;
use process_session::*;
use project::*;
use project_snapshot::*;
use repository_context::*;
use resource_inspect::*;
use resource_preview_scheduler::*;
use runner::*;
use swarm_cli::*;
use template_library::*;
use user_input::*;
use windows::*;
#[tauri::command]
async fn suggest_ui_design_semantic(
project_path: String,
state: ui_editor::state::State,
) -> Result<Vec<ui_editor::commands::UIDesignSuggestionTreeNode>, String> {
ui_editor::commands::suggest_ui_design_semantic_impl(project_path, state).await
}
#[tauri::command]
async fn recognize_ui(
project_path: String,
state: ui_editor::state::State,
) -> Result<ui_editor::commands::RecognitionDTO, String> {
ui_editor::commands::recognize_ui_impl(project_path, state).await
}
#[tauri::command]
async fn separate_ui(
project_path: String,
asset_id: String,
state: ui_editor::state::State,
) -> Result<ui_editor::commands::SeparationDTO, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "asset.register")?;
ui_editor::commands::separate_ui_impl(project_path, asset_id, state).await
}
#[tauri::command]
fn inspect_separation_recovery(
project_path: String,
asset_id: String,
) -> Result<ui_editor::commands::SeparationRecoveryDTO, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "asset.list")?;
ui_editor::commands::separation::inspect_separation_recovery(root, &asset_id)
}
#[tauri::command]
fn finalize_separation(project_path: String, asset_id: String) -> Result<(), String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "asset.register")?;
ui_editor::commands::separation::finalize_separation(root, &asset_id)
}
#[tauri::command]
fn discard_separation_recovery(project_path: String, asset_id: String) -> Result<(), String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "asset.register")?;
ui_editor::commands::separation::discard_separation_recovery(root, &asset_id)
}
#[tauri::command]
async fn merge_ui(state: ui_editor::state::State) -> Result<ui_editor::commands::MergeDTO, String> {
ui_editor::commands::merge_ui_impl(state).await
}
#[tauri::command]
async fn bind_components(
project_path: String,
state: ui_editor::state::State,
sprite_ids: Vec<String>,
) -> Result<ui_editor::commands::BindingDTO, String> {
ui_editor::commands::bind_components_impl(project_path, state, sprite_ids).await
}
#[tauri::command]
fn load_ui_design_state(
input: ui_editor::persistence::LoadUiDesignStateInput,
) -> Result<ui_editor::persistence::UiDesignStateSnapshot, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "asset.list")?;
ui_editor::persistence::load_ui_design_state_at(input)
}
#[tauri::command]
fn save_ui_design_state(
input: ui_editor::persistence::SaveUiDesignStateInput,
) -> Result<ui_editor::persistence::SaveUiDesignStateResult, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "asset.register")?;
ui_editor::persistence::save_ui_design_state_at(input)
}
#[tauri::command]
fn generate_ui_design_code(
input: ui_editor::persistence::GenerateUiDesignCodeInput,
) -> Result<ui_editor::persistence::GenerateUiDesignCodeResult, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "file.write")?;
ui_editor::persistence::generate_ui_design_code_at(input)
}
#[tauri::command]
fn ensure_ui_design_resource_for_prototype(
input: ui_editor::resource_bridge::EnsureUiDesignResourceForPrototypeInput,
) -> Result<ui_editor::resource_bridge::EnsureUiDesignResourceForPrototypeResult, String> {
ui_editor::resource_bridge::ensure_ui_design_resource_for_prototype(input)
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct InitLocalProjectResult {
project_path: String,
manifest_path: String,
manifest: GameCreationAppManifest,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalProjectDirectoryStatus {
project_path: String,
exists: bool,
is_directory: bool,
is_game_creator_project: bool,
is_godot_project: bool,
godot_project_root: Option<String>,
is_cocos_project: bool,
cocos_project_root: Option<String>,
is_unity_project: bool,
unity_project_root: Option<String>,
project_name: Option<String>,
modified_at: Option<u64>,
manifest_error: Option<String>,
recent_run_status: Option<String>,
recent_run_stop_reason: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalPreviewResult {
url: String,
port: u16,
root: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalPreviewStatus {
status: String,
url: Option<String>,
port: Option<u16>,
root: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalGameProjectRevisionStatus {
revision: u64,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct GenerateLocalGameDraftResult {
project_path: String,
game_index_path: String,
design_path: String,
short_memory_path: String,
long_memory_path: String,
manifest: GameCreationAppManifest,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorChatAgentReply {
reply_text: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorRoleAgentChatStreamEvent {
project_path: String,
agent_id: String,
run_id: String,
status: String,
delta_text: String,
accumulated_text: String,
finish_reason: Option<String>,
session_id: Option<String>,
runtime_status: Option<String>,
runtime_phase: Option<String>,
runtime_summary: Option<String>,
runtime_state: Option<AgentRuntimeState>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentRuntimeState {
#[serde(default)]
schema_version: String,
#[serde(default)]
agent_id: String,
#[serde(default)]
task_id: String,
#[serde(default)]
session_id: String,
#[serde(default)]
run_id: String,
#[serde(default)]
source: String,
#[serde(default = "default_agent_runtime_run_profile")]
run_profile: String,
#[serde(default)]
run_profile_binding_fingerprint: String,
#[serde(default)]
parent_agent_id: Option<String>,
#[serde(default)]
parent_run_id: Option<String>,
#[serde(default)]
delegation_id: Option<String>,
#[serde(default)]
status: String,
#[serde(default)]
phase: String,
#[serde(default)]
current_task: String,
#[serde(default)]
current_goal: String,
#[serde(default)]
goal_id: Option<String>,
#[serde(default)]
goal_revision: u64,
#[serde(default)]
goal_status: Option<String>,
#[serde(default)]
goal_outcome: Option<String>,
#[serde(default)]
goal_constraints: Vec<String>,
#[serde(default)]
goal_verification: Vec<String>,
#[serde(default)]
current_action: String,
#[serde(default)]
waiting_on: String,
#[serde(default)]
next_step: String,
#[serde(default)]
loop_iteration: u32,
/// Consecutive rounds where this run produced no action and no structured
/// plan step advance. Runtime-owned durable state so a runner restart
/// cannot launder an explanation-only planning loop back into an unbounded
/// Provider spend.
#[serde(default)]
plan_update_idle_rounds: u32,
/// Consecutive final replies this run had refused by a completion blocker.
/// Runtime-owned durable state for the same reason as the two counters
/// above: a blocker the model cannot satisfy is a livelock, and a runner
/// restart must not launder it back into unbounded Provider spend.
#[serde(default)]
stale_finalization_rounds: u32,
#[serde(default)]
max_loop_iterations: u32,
#[serde(default)]
tool_action_budget: u32,
#[serde(default)]
plan_revision: u64,
#[serde(default)]
plan_explanation: String,
#[serde(default)]
plan: Vec<String>,
#[serde(default)]
plan_steps: Vec<AgentRuntimePlanStep>,
#[serde(default)]
active_plan_step_index: Option<u32>,
#[serde(default)]
observations: Vec<String>,
#[serde(default)]
recent_tool_calls: Vec<AgentRuntimeToolCallRecord>,
#[serde(default)]
pending_tool_action: Option<AgentRuntimePendingToolActionSummary>,
#[serde(default)]
task_queue: AgentRuntimeTaskQueueSummary,
#[serde(default)]
allowed_tools: Vec<String>,
#[serde(default)]
tool_policy: AgentRuntimeToolPolicySnapshot,
#[serde(default)]
applied_steer_cursor: u64,
#[serde(default)]
applied_steer_refs: Vec<AgentRuntimeSteerRef>,
#[serde(default)]
queued_steer_count: u32,
#[serde(default)]
context_usage: AgentRuntimeContextUsage,
#[serde(default)]
last_response: Option<String>,
#[serde(default)]
error: Option<String>,
#[serde(default)]
started_at: u64,
#[serde(default)]
updated_at: u64,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentRuntimeContextUsage {
#[serde(default)]
estimated_input_tokens: u64,
#[serde(default)]
auto_compact_token_limit: u64,
#[serde(default)]
last_prompt_tokens: Option<u64>,
#[serde(default)]
last_completion_tokens: Option<u64>,
#[serde(default)]
last_total_tokens: Option<u64>,
#[serde(default)]
compaction_revision: u64,
#[serde(default)]
compaction_count: u64,
#[serde(default)]
last_compaction_trigger: Option<String>,
#[serde(default)]
last_compacted_at: Option<u64>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentRuntimeSteerRef {
#[serde(default)]
steer_id: String,
#[serde(default)]
sequence: u64,
#[serde(default)]
message_id: String,
#[serde(default)]
instruction_sha256: String,
#[serde(default)]
content_chars: u32,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentRuntimeToolPolicySnapshot {
#[serde(default = "default_agent_runtime_run_profile")]
run_profile: String,
#[serde(default)]
run_profile_binding_fingerprint: String,
#[serde(default)]
allowed_tools: Vec<String>,
#[serde(default)]
auto_tools: Vec<String>,
#[serde(default)]
confirm_tools: Vec<String>,
#[serde(default)]
denied_tools: Vec<String>,
#[serde(default)]
updated_at: u64,
}
impl Default for AgentRuntimeToolPolicySnapshot {
fn default() -> Self {
Self {
run_profile: default_agent_runtime_run_profile(),
run_profile_binding_fingerprint: String::new(),
allowed_tools: Vec::new(),
auto_tools: Vec::new(),
confirm_tools: Vec::new(),
denied_tools: Vec::new(),
updated_at: 0,
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentRuntimeToolCallRecord {
#[serde(default)]
action_id: Option<String>,
#[serde(default)]
tool: String,
#[serde(default)]
status: String,
#[serde(default)]
action_fingerprint: Option<String>,
#[serde(default)]
input_summary: Option<String>,
#[serde(default)]
reason: Option<String>,
#[serde(default)]
summary: String,
#[serde(default)]
detail: Option<String>,
#[serde(default)]
updated_at: u64,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentRuntimePendingToolActionSummary {
#[serde(default)]
action_id: String,
#[serde(default)]
action_fingerprint: String,
#[serde(default)]
tool: String,
#[serde(default)]
input_summary: Option<String>,
#[serde(default)]
reason: Option<String>,
#[serde(default)]
requested_at: u64,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentRuntimePlanStep {
#[serde(default)]
index: u32,
#[serde(default)]
title: String,
#[serde(default)]
status: String,
#[serde(default)]
detail: Option<String>,
#[serde(default)]
updated_at: u64,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentRuntimeTaskQueueSummary {
#[serde(default)]
total: u32,
#[serde(default)]
pending: u32,
#[serde(default)]
running: u32,
#[serde(default)]
waiting_for_confirmation: u32,
#[serde(default)]
waiting_for_user_input: u32,
#[serde(default)]
paused: u32,
#[serde(default)]
cancelled: u32,
#[serde(default)]
completed: u32,
#[serde(default)]
failed: u32,
#[serde(default)]
latest_run_id: Option<String>,
#[serde(default)]
updated_at: u64,
}
impl Default for AgentRuntimeTaskQueueSummary {
fn default() -> Self {
Self {
total: 0,
pending: 0,
running: 0,
waiting_for_confirmation: 0,
waiting_for_user_input: 0,
paused: 0,
cancelled: 0,
completed: 0,
failed: 0,
latest_run_id: None,
updated_at: 0,
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentRuntimeEvent {
#[serde(default)]
schema_version: String,
#[serde(default)]
agent_id: String,
#[serde(default)]
task_id: String,
#[serde(default)]
session_id: String,
#[serde(default)]
run_id: String,
#[serde(default)]
source: String,
#[serde(default)]
event_type: String,
#[serde(default)]
event_id: String,
#[serde(default)]
action_id: Option<String>,
#[serde(default)]
status: String,
#[serde(default)]
phase: String,
#[serde(default)]
summary: String,
#[serde(default)]
public_text: Option<String>,
#[serde(default)]
detail: Option<String>,
#[serde(default)]
updated_at: u64,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct AgentRuntimeResponseStream {
schema_version: String,
agent_id: String,
task_id: String,
session_id: String,
run_id: String,
request_kind: String,
request_slot: String,
applied_steer_cursor: u64,
response_revision: u64,
sequence: u64,
status: String,
accumulated_text: String,
finish_reason: Option<String>,
started_at: u64,
updated_at: u64,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentRuntimeTaskRecord {
#[serde(default)]
schema_version: String,
#[serde(default)]
agent_id: String,
#[serde(default)]
task_id: String,
#[serde(default)]
session_id: String,
#[serde(default)]
run_id: String,
#[serde(default)]
source: String,
#[serde(default = "default_agent_runtime_run_profile")]
run_profile: String,
#[serde(default)]
run_profile_binding_fingerprint: String,
#[serde(default)]
parent_agent_id: Option<String>,
#[serde(default)]
parent_run_id: Option<String>,
#[serde(default)]
delegation_id: Option<String>,
#[serde(default)]
goal_id: Option<String>,
#[serde(default)]
goal_revision: u64,
#[serde(default)]
goal_status: Option<String>,
#[serde(default)]
task: String,
#[serde(default)]
status: String,
#[serde(default)]
phase: String,
#[serde(default)]
current_action: String,
#[serde(default)]
terminal_detail: Option<String>,
#[serde(default)]
error: Option<String>,
#[serde(default)]
updated_at: u64,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct AgentGoalRecord {
schema_version: String,
project_id: String,
goal_id: String,
agent_id: String,
session_id: String,
run_id: String,
revision: u64,
status: String,
outcome: String,
constraints: Vec<String>,
verification: Vec<String>,
#[serde(default)]
completion_evidence: Vec<String>,
#[serde(default)]
response_fingerprint: Option<String>,
created_at: u64,
#[serde(default)]
pause_requested_at: Option<u64>,
#[serde(default)]
paused_at: Option<u64>,
#[serde(default)]
completed_at: Option<u64>,
#[serde(default)]
cleared_at: Option<u64>,
#[serde(default)]
error: Option<String>,
updated_at: u64,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentGoalMutationResult {
goal: AgentGoalRecord,
runtime: AgentRuntimeResult,
provider_interrupted: bool,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentRuntimeResult {
state: AgentRuntimeState,
#[serde(skip_serializing_if = "Option::is_none")]
accepted_run_id: Option<String>,
session_path: String,
event_path: String,
task_path: String,
task_queue: AgentRuntimeTaskQueueSummary,
recent_events: Vec<AgentRuntimeEvent>,
recent_tasks: Vec<AgentRuntimeTaskRecord>,
response_stream: Option<AgentRuntimeResponseStream>,
user_input_request: Option<AgentRuntimeUserInputRequestView>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentRuntimeSteerResult {
runtime: AgentRuntimeResult,
steer_id: String,
sequence: u64,
status: String,
provider_interrupted: bool,
#[serde(skip_serializing_if = "Option::is_none")]
assistant_reply: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
interrupt_decision: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
decision_reason: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorAgentRuntimeUpdateEvent {
project_path: String,
agent_id: String,
run_id: String,
status: String,
phase: String,
manifest_invalidated: bool,
runtime: AgentRuntimeResult,
}
#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorManifestInvalidatedEvent {
project_path: String,
agent_id: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorManifestInvalidationRelayEnvelope {
token: String,
event: GameCreatorManifestInvalidatedEvent,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct GameCreatorManifestInvalidationEventSink {
port: u16,
token: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorAgentProgressEvent {
project_path: String,
stage: String,
message: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorDirectTurnUpdateEvent {
project_path: String,
turn_id: String,
sequence: u64,
status: String,
activity: Option<String>,
accumulated_text: Option<String>,
/// 本回合内发生变化的结构化工具调用集合(只有变化时才带,老事件没有这个字段)。
/// `skip_serializing_if`:字段缺席时前端拿到 `undefined`,行为与改造前一致。
#[serde(skip_serializing_if = "Option::is_none")]
tool_calls: Option<Vec<crate::DirectToolCall>>,
/// 本回合当前累计的思考过程(流式整段替换);拿不到时字段缺席。
#[serde(skip_serializing_if = "Option::is_none")]
reasoning_text: Option<String>,
/// 本回合**顺序真相**里本次发生变化的那几条(文本段 / 工具位置标记)。
/// `skip_serializing_if`:字段缺席时前端拿到 `undefined`,行为与改造前一致。
#[serde(skip_serializing_if = "Option::is_none")]
stream_items: Option<Vec<crate::DirectTurnStreamItem>>,
updated_at: u64,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorLlmConfigStatus {
agent_mode: String,
configured: bool,
account_credential_state: String,
official_route_locked: bool,
reasoning_effort: String,
stream: bool,
web_search_enabled: bool,
context_window_tokens: u64,
auto_compact_token_limit: u64,
tool_output_token_limit: u64,
request_timeout_ms: u64,
max_retries: u32,
retry_backoff_ms: u64,
error: Option<String>,
agents: Vec<GameCreatorAgentLlmConfigStatus>,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorAgentLlmConfigStatus {
agent_mode: String,
agent_id: String,
label: String,
configured: bool,
account_credential_state: String,
official_route_locked: bool,
reasoning_effort: String,
stream: bool,
web_search_enabled: bool,
context_window_tokens: u64,
auto_compact_token_limit: u64,
tool_output_token_limit: u64,
request_timeout_ms: u64,
max_retries: u32,
retry_backoff_ms: u64,
error: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorAppConfigFile {
#[serde(default, skip_serializing_if = "Option::is_none")]
validation: Option<agent::DirectValidationConfig>,
#[serde(skip_serializing_if = "Option::is_none")]
schema_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
agent_mode: Option<String>,
llm: Option<GameCreatorLlmConfigFile>,
agent_llm: Option<BTreeMap<String, GameCreatorLlmConfigFile>>,
editor_api: Option<GameCreatorEditorApiConfigFile>,
#[serde(default, skip_serializing_if = "Option::is_none")]
selected_model_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
selected_model_is_default: Option<bool>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorLlmConfigFile {
#[serde(skip_serializing_if = "Option::is_none")]
custom_enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
visible_models: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
api_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
base_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
api_kind: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
reasoning_effort: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
stream: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
web_search_enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
context_window_tokens: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
auto_compact_token_limit: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_output_token_limit: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
request_timeout_ms: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
max_retries: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
retry_backoff_ms: Option<u64>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorEditorApiConfigFile {
base_url: Option<String>,
api_key: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorAppConfig {
#[serde(default)]
validation: agent::DirectValidationConfig,
#[serde(default = "default_game_creator_app_config_schema_version")]
schema_version: String,
#[serde(default = "default_game_creator_agent_mode")]
agent_mode: String,
llm: GameCreatorLlmConfig,
#[serde(default)]
agent_llm: BTreeMap<String, GameCreatorLlmConfigFile>,
editor_api: GameCreatorEditorApiConfig,
#[serde(default)]
selected_model_id: String,
#[serde(default)]
selected_model_is_default: bool,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorLlmConfig {
#[serde(default)]
custom_enabled: bool,
#[serde(default)]
visible_models: Vec<String>,
api_key: String,
base_url: String,
model: String,
api_kind: String,
reasoning_effort: String,
stream: bool,
web_search_enabled: bool,
#[serde(default = "default_game_creator_llm_context_window_tokens")]
context_window_tokens: u64,
#[serde(default = "default_game_creator_llm_auto_compact_token_limit")]
auto_compact_token_limit: u64,
#[serde(default = "default_game_creator_llm_tool_output_token_limit")]
tool_output_token_limit: u64,
request_timeout_ms: u64,
max_retries: u32,
retry_backoff_ms: u64,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorEditorApiConfig {
base_url: String,
api_key: String,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct GameCreatorAppConfigView {
path: String,
config: GameCreatorAppConfig,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentRuntimeContextCompactionResult {
agent_id: String,
session_id: String,
run_id: Option<String>,
trigger: String,
revision: u64,
estimated_tokens_before: u64,
estimated_tokens_after: u64,
prompt_tokens: Option<u64>,
completion_tokens: Option<u64>,
total_tokens: Option<u64>,
covered_agent_messages: u64,
covered_project_messages: u64,
covered_observations: u64,
reused: bool,
compacted_at: u64,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentRunControlResult {
run_id: String,
status: String,
lifecycle_status: String,
next_step: String,
message: String,
activity_path: String,
output_path: String,
context_bundle_path: String,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct UploadLocalAssetResult {
id: String,
local_path: String,
absolute_path: String,
manifest_path: String,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct CreateUiDesignResourceResult {
asset: UploadLocalAssetResult,
manifest: GameCreationAppManifest,
committed_project_revision: u64,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct ImportCanvasExportResult {
import_root: String,
metadata_path: String,
imported_count: usize,
assets: Vec<UploadLocalAssetResult>,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct SyncCanvasProjectAssetsResult {
canvas_project_id: String,
import_root: String,
imported_count: usize,
assets: Vec<UploadLocalAssetResult>,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalImportResult {
assets: Vec<ImportedAsset>,
}
type RemoteImportResult = LocalImportResult;
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct ImportedAsset {
id: String,
local_path: String,
asset_kind: Option<String>,
}
#[derive(Debug, Eq, PartialEq)]
struct GeneratedPlatformArtAssetSlice {
name: String,
width: u32,
height: u32,
local_path: String,
resource_id: Option<String>,
asset_object_id: Option<String>,
content_sha256: String,
pixel_sha256: String,
}
#[derive(Debug, Eq, PartialEq)]
struct GeneratedPlatformArtAsset {
asset: UploadLocalAssetResult,
slices: Vec<GeneratedPlatformArtAssetSlice>,
slice_mode: Option<String>,
grid_x: Option<u32>,
grid_y: Option<u32>,
resource_id: Option<String>,
asset_object_id: Option<String>,
task_id: Option<String>,
model: Option<String>,
warning: Option<String>,
slice_warning: Option<String>,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct OpenCanvasProjectResult {
url: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CanvasExportMetadata {
project_title: String,
exported_at: String,
layers: Vec<CanvasExportLayerMetadata>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CanvasExportLayerMetadata {
title: String,
file: Option<String>,
visible: CanvasExportVisibleMetadata,
export_error: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct CanvasExportVisibleMetadata {
#[serde(rename = "type")]
layer_type: String,
model: String,
task: String,
object: String,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalGameMemoryResult {
scope: String,
path: String,
content: String,
exists: bool,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalAgentMemoryResult {
task_id: String,
path: String,
content: String,
exists: bool,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LimitedLocalCommandResult {
command_id: String,
status: String,
output: String,
log_path: String,
updated_at: u64,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalProjectFileEntry {
path: String,
kind: String,
size: u64,
modified_at: u64,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct ListLocalProjectFilesResult {
project_path: String,
files: Vec<LocalProjectFileEntry>,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalProjectFileResult {
path: String,
absolute_path: String,
content: String,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalProjectFileMutationResult {
path: String,
absolute_path: String,
deleted: bool,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalConversationMessage {
role: String,
content: String,
agent_id: Option<String>,
}
#[derive(Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalConversationMessageRecord {
schema_version: String,
role: String,
content: String,
agent_id: Option<String>,
message_id: Option<String>,
updated_at: u64,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalConversationResult {
path: String,
agent_id: Option<String>,
session_id: Option<String>,
messages: Vec<LocalConversationMessageRecord>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentConversationSessionRecord {
session_id: String,
title: String,
created_at: u64,
updated_at: u64,
archived_at: Option<u64>,
message_count: u64,
legacy: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
forked_from_session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
forked_message_count: Option<u64>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct AgentConversationSessionListResult {
path: String,
agent_id: String,
active_session_id: String,
sessions: Vec<AgentConversationSessionRecord>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct ProjectPermissionPolicy {
denied_commands: Vec<String>,
confirm_commands: Vec<String>,
#[serde(default)]
agent_policies: BTreeMap<String, ProjectAgentPermissionPolicy>,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct ProjectAgentPermissionPolicy {
#[serde(default)]
denied_commands: Vec<String>,
#[serde(default)]
confirm_commands: Vec<String>,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct ProjectPermissionPolicyView {
path: String,
policy: ProjectPermissionPolicy,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalProjectIndexedFile {
path: String,
size: u64,
checksum: String,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalProjectIndexResult {
project_path: String,
index_path: String,
file_count: usize,
total_bytes: u64,
files: Vec<LocalProjectIndexedFile>,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalProjectCheckpointResult {
checkpoint_id: String,
checkpoint_path: String,
file_count: usize,
total_bytes: u64,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalProjectExportPackageResult {
package_path: String,
package_relative_path: String,
file_count: usize,
total_bytes: u64,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalProjectExportPackageSummary {
package_path: String,
package_relative_path: String,
total_bytes: u64,
modified_at: u64,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalProjectExportPackagesResult {
project_path: String,
packages: Vec<LocalProjectExportPackageSummary>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalProjectDiffEntry {
path: String,
status: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalProjectDiffResult {
checkpoint_id: String,
added: Vec<LocalProjectDiffEntry>,
changed: Vec<LocalProjectDiffEntry>,
deleted: Vec<LocalProjectDiffEntry>,
}
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
struct LocalProjectRestoreResult {
checkpoint_id: String,
restored_count: usize,
deleted_count: usize,
}
const DEFAULT_GAME_INDEX_HTML: &str = r#"<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Genarrative Game Draft</title>
</head>
<body><main id="game"></main><script type="module" src="/game.js"></script></body>
</html>
"#;
const DEFAULT_GAME_STYLE_CSS: &str = "body { margin: 0; display: grid; min-height: 100vh; place-items: center; background: #101827; color: #d9e7ff; font: 16px system-ui, sans-serif; }\nmain { width: min(720px, calc(100vw - 32px)); }\n";
const DEFAULT_GAME_SCRIPT_JS: &str = "import Phaser from 'phaser';\nimport './style.css';\n\nclass PlaceholderScene extends Phaser.Scene {\n create() { this.add.text(24, 24, '还没有生成游戏。回到聊天输入创意并确认生成后,这里会写入可试玩原型。'); }\n}\n\nnew Phaser.Game({ type: Phaser.AUTO, width: 720, height: 420, parent: 'game', scene: PlaceholderScene });\n";
const DEFAULT_EDITOR_BASE_URL: &str = "http://127.0.0.1:3000";
const GAME_CREATOR_CONFIG_FILE_NAME: &str = "game-creator.config.json";
const GAME_CREATOR_LOCAL_CONFIG_FILE_NAME: &str = "game-creator.config.local.json";
const GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER: &str = "codex_app_server";
const GAME_CREATOR_AGENT_MODE_CODEX_CLI: &str = "codex_cli";
const GAME_CREATOR_AGENT_MODE_PROVIDER: &str = "provider";
const GAME_CREATOR_APP_CONFIG_SCHEMA_VERSION: &str = "game-creator-config.v2";
const DEFAULT_GAME_CREATOR_LLM_BASE_URL: &str = "https://dev.genarrative.world/gpt/v1";
const DEFAULT_GAME_CREATOR_LLM_MODEL: &str = "gpt-6-astra";
const DEFAULT_GAME_CREATOR_LLM_API_KIND: &str = "openai_responses";
const DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT: &str = "high";
const DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS: u64 = 128_000;
const DEFAULT_GAME_CREATOR_LLM_AUTO_COMPACT_TOKEN_LIMIT: u64 = 64_000;
const DEFAULT_GAME_CREATOR_LLM_TOOL_OUTPUT_TOKEN_LIMIT: u64 = 12_000;
const DEFAULT_GAME_CREATOR_LLM_MAX_RETRIES: u32 = 10;
fn default_game_creator_agent_mode() -> String {
GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER.to_string()
}
fn default_game_creator_app_config_schema_version() -> String {
GAME_CREATOR_APP_CONFIG_SCHEMA_VERSION.to_string()
}
fn default_game_creator_llm_context_window_tokens() -> u64 {
DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS
}
fn default_game_creator_llm_auto_compact_token_limit() -> u64 {
DEFAULT_GAME_CREATOR_LLM_AUTO_COMPACT_TOKEN_LIMIT
}
fn default_game_creator_llm_tool_output_token_limit() -> u64 {
DEFAULT_GAME_CREATOR_LLM_TOOL_OUTPUT_TOKEN_LIMIT
}
const DEFAULT_CANVAS_SYNC_API_BASE_URL: &str = "https://dev.genarrative.world";
const DEFAULT_GAME_CREATOR_APP_CONFIG_JSON: &str = include_str!("../../game-creator.config.json");
const GAME_CREATOR_LLM_MAX_OUTPUT_TOKENS: u32 = 320000;
const GAME_CREATOR_CHAT_AGENT_MAX_OUTPUT_TOKENS: u32 = 1800;
const GAME_CREATOR_PLANNER_MAX_OUTPUT_TOKENS: u32 = 900;
const GAME_CREATOR_ROLE_AGENT_MAX_OUTPUT_TOKENS: u32 = 1200;
const GAME_CREATOR_REQUIRED_LLM_AGENT_IDS: [&str; 3] = [
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
"planner",
"generator",
];
const MIN_GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS: u64 = 1_000;
const GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS: u64 = 180_000;
const GAME_CREATOR_AGENT_LOOP_MAX_PASSES: u8 = 3;
const GAME_CREATOR_AGENT_TOOL_CALL_MAX: u16 = GAME_CREATION_AGENT_TOOL_CALL_MAX;
const GAME_CREATOR_AGENT_RUN_HISTORY_MAX_COUNT: usize = 100;
const GAME_CREATOR_AGENT_DB_SCHEMA_VERSION: &str = "game-creator-agent-db.v1";
const PROJECT_BLACKBOARD_MEMORY_PATH: &str = "memory/blackboard.md";
const PROJECT_PERMISSION_POLICY_PATH: &str = ".agent/policy.json";
const PROJECT_INDEX_PATH: &str = ".agent/project.index.json";
const PROJECT_WRITE_LOCK_PATH: &str = ".agent/project.lock";
const LOCAL_CONVERSATION_SCHEMA_VERSION: &str = "game-creator-conversation.v1";
const AGENT_CONVERSATION_SESSION_SCHEMA_VERSION: &str = "game-creator-agent-sessions.v1";
const AGENT_RUNTIME_SCHEMA_VERSION: &str = "game-creator-agent-runtime.v1";
const AGENT_RUNTIME_RUN_PROFILE_STANDARD: &str = "standard";
const AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD: &str = "autonomous-game-build";
const AGENT_RUNTIME_AUTONOMOUS_SOURCE_FIELD_MAX_CHARS: usize = 8_000;
const AGENT_RUNTIME_AUTONOMOUS_SOURCE_TOTAL_MAX_CHARS: usize = 10_000;
const AGENT_RUNTIME_RECENT_EVENT_LIMIT: usize = 20;
const AGENT_RUNTIME_RECENT_TASK_LIMIT: usize = 12;
const GAME_CREATOR_CONVERSATION_CONTEXT_MAX_MESSAGES: usize = 12;
fn default_agent_runtime_run_profile() -> String {
AGENT_RUNTIME_RUN_PROFILE_STANDARD.to_string()
}
const MAX_CANVAS_EXPORT_FILES: usize = 500;
const MAX_CANVAS_EXPORT_BYTES: u64 = 512 * 1024 * 1024;
const MAX_PROJECT_EXPORT_PACKAGE_FILES: usize = 1200;
const MAX_PROJECT_EXPORT_PACKAGE_BYTES: u64 = 512 * 1024 * 1024;
const GAME_CREATOR_AGENT_ARTIFACT_PATHS: [&str; 15] = [
".agent/agent.db",
".agent/spec.md",
".agent/findings.md",
".agent/logs/agent.log",
".agent/logs/command.log",
".agent/logs/preview.log",
"memory/session.md",
"memory/project.md",
PROJECT_BLACKBOARD_MEMORY_PATH,
"game/game_design.md",
"game/balance.json",
"assets/manifest.art.json",
"assets/manifest.audio.json",
"exports/README.md",
"game/index.html",
];
static GAME_CREATOR_RUNTIME_CONFIG_DIR: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
impl Default for GameCreatorAppConfig {
fn default() -> Self {
let mut llm = GameCreatorLlmConfig::default();
// DirectProject is the shipped product route, so the application-level
// default enables the controlled AGC search tool. The bare
// GameCreatorLlmConfig default remains conservative for legacy callers.
llm.web_search_enabled = true;
Self {
validation: agent::DirectValidationConfig::default(),
schema_version: default_game_creator_app_config_schema_version(),
agent_mode: default_game_creator_agent_mode(),
llm,
agent_llm: BTreeMap::new(),
editor_api: GameCreatorEditorApiConfig::default(),
selected_model_id: String::new(),
selected_model_is_default: false,
}
}
}
impl Default for GameCreatorLlmConfig {
fn default() -> Self {
Self {
custom_enabled: false,
visible_models: Vec::new(),
api_key: String::new(),
base_url: DEFAULT_GAME_CREATOR_LLM_BASE_URL.to_string(),
model: DEFAULT_GAME_CREATOR_LLM_MODEL.to_string(),
api_kind: DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string(),
reasoning_effort: DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT.to_string(),
stream: true,
web_search_enabled: false,
context_window_tokens: DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS,
auto_compact_token_limit: DEFAULT_GAME_CREATOR_LLM_AUTO_COMPACT_TOKEN_LIMIT,
tool_output_token_limit: DEFAULT_GAME_CREATOR_LLM_TOOL_OUTPUT_TOKEN_LIMIT,
request_timeout_ms: GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS,
max_retries: DEFAULT_GAME_CREATOR_LLM_MAX_RETRIES,
retry_backoff_ms: DEFAULT_RETRY_BACKOFF_MS,
}
}
}
impl Default for GameCreatorEditorApiConfig {
fn default() -> Self {
Self {
base_url: DEFAULT_CANVAS_SYNC_API_BASE_URL.to_string(),
api_key: String::new(),
}
}
}
#[derive(Clone, Copy, Debug)]
struct AgentRoleDefinition {
id: &'static str,
role: &'static str,
task_id: &'static str,
tool_id: &'static str,
brief_path_name: &'static str,
}
#[derive(Clone, Copy, Debug)]
struct AgentGroupDefinition {
id: &'static str,
label: &'static str,
role: &'static str,
brief_path_name: &'static str,
roles: &'static [AgentRoleDefinition],
}
const GAME_CREATOR_LEGACY_CHAT_AGENT_CONFIG_ID: &str = "chat";
struct GameCreatorLlmAgentStatusDefinition {
agent_id: String,
label: String,
}
fn game_creator_llm_agent_status_definitions() -> Vec<GameCreatorLlmAgentStatusDefinition> {
let mut agents = vec![
GameCreatorLlmAgentStatusDefinition {
agent_id: GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID.to_string(),
label: format!("{} Agent", PROJECT_SUPERVISOR_AGENT_DEFINITION.label),
},
GameCreatorLlmAgentStatusDefinition {
agent_id: "planner".to_string(),
label: "Planner".to_string(),
},
GameCreatorLlmAgentStatusDefinition {
agent_id: "orchestrator".to_string(),
label: "Orchestrator".to_string(),
},
GameCreatorLlmAgentStatusDefinition {
agent_id: "generator".to_string(),
label: "Generator".to_string(),
},
GameCreatorLlmAgentStatusDefinition {
agent_id: "evaluator".to_string(),
label: "Evaluator".to_string(),
},
];
for group in GAME_CREATOR_AGENT_GROUP_DEFINITIONS {
for role in group.roles {
if agents.iter().any(|agent| agent.agent_id == role.task_id) {
continue;
}
agents.push(GameCreatorLlmAgentStatusDefinition {
agent_id: role.task_id.to_string(),
label: format!("{} / {}", group.label, role.role),
});
}
}
agents
}
#[derive(Clone, Debug)]
struct AgentPassArtifactPaths {
draft_json: String,
design_markdown: String,
balance_json: String,
art_manifest_json: String,
audio_manifest_json: String,
publish_readme: String,
game_html: String,
handoff_markdown: String,
}
#[derive(Clone, Debug)]
struct AgentRoleBrief {
group_definition: AgentGroupDefinition,
role_definition: AgentRoleDefinition,
markdown: String,
relative_path: String,
memory_relative_path: String,
status: String,
tool_id: String,
summary: String,
}
#[derive(Clone, Debug)]
struct AgentGroupBrief {
definition: AgentGroupDefinition,
markdown: String,
relative_path: String,
role_briefs: Vec<AgentRoleBrief>,
}
#[derive(Clone, Debug)]
struct AgentPassAgenda {
relative_path: String,
task_graph_relative_path: String,
active_task_ids: Vec<String>,
carried_task_ids: Vec<String>,
dependency_waves: Vec<Vec<String>>,
repair_focus: Vec<String>,
repair_routes: Vec<GameCreationAgentRepairRouteTrace>,
summary: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct LlmGameDraft {
title: String,
design_markdown: String,
balance: serde_json::Value,
art_manifest: serde_json::Value,
audio_manifest: serde_json::Value,
publish_readme: String,
handoffs: Vec<LlmAgentHandoff>,
game_html: String,
handoff_summary: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct LlmAgentHandoff {
group: String,
role: String,
summary: String,
outputs: Vec<String>,
next: String,
}
const DIAGNOSTIC_LOG_MAX_BYTES: u64 = 256 * 1024;
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)
.unwrap_or_default()
.as_secs()
}
fn append_bounded_diagnostic_line_with_limit(
path: &Path,
line: &str,
max_bytes: u64,
) -> std::io::Result<()> {
let _guard = DIAGNOSTIC_LOG_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut file = open_secure_diagnostic_log(path)?;
if file.metadata()?.len() >= max_bytes {
file.seek(SeekFrom::Start(0)).map_err(|error| {
std::io::Error::new(error.kind(), format!("seek current log: {error}"))
})?;
let mut previous_content = Vec::new();
std::io::Read::by_ref(&mut file)
.take(max_bytes.saturating_add(1))
.read_to_end(&mut previous_content)
.map_err(|error| {
std::io::Error::new(error.kind(), format!("read current log: {error}"))
})?;
let previous_path = path.with_extension("previous.log");
let mut previous = open_secure_diagnostic_log(&previous_path)?;
previous.set_len(0).map_err(|error| {
std::io::Error::new(error.kind(), format!("truncate previous log: {error}"))
})?;
previous.write_all(&previous_content).map_err(|error| {
std::io::Error::new(error.kind(), format!("write previous log: {error}"))
})?;
previous.flush().map_err(|error| {
std::io::Error::new(error.kind(), format!("flush previous log: {error}"))
})?;
file.set_len(0).map_err(|error| {
std::io::Error::new(error.kind(), format!("truncate current log: {error}"))
})?;
}
file.seek(SeekFrom::End(0))
.map_err(|error| std::io::Error::new(error.kind(), format!("seek log end: {error}")))?;
writeln!(file, "{} {line}", diagnostic_timestamp())?;
file.flush()
}
fn open_secure_diagnostic_log(path: &Path) -> std::io::Result<File> {
match fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"diagnostic log must be a regular file",
));
}
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error),
}
let mut options = OpenOptions::new();
options.read(true).write(true).create(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
}
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
}
let file = options.open(path)?;
let metadata = file.metadata()?;
if !metadata.is_file() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"diagnostic log must be a regular file",
));
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
if metadata.nlink() != 1 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"diagnostic log must not be a hardlink",
));
}
}
#[cfg(windows)]
crate::runner::validate_windows_regular_file_handle(&file, "diagnostic log")
.map_err(std::io::Error::other)?;
Ok(file)
}
pub(crate) fn append_bounded_diagnostic_line(path: &Path, line: &str) -> std::io::Result<()> {
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());
let mut cursor = 0;
while cursor < bytes.len() {
let previous_allows_drive_path = cursor == 0 || !bytes[cursor - 1].is_ascii_alphanumeric();
let is_drive_path = previous_allows_drive_path
&& cursor + 2 < bytes.len()
&& bytes[cursor].is_ascii_alphabetic()
&& bytes[cursor + 1] == b':'
&& matches!(bytes[cursor + 2], b'\\' | b'/');
if !is_drive_path {
let ch = value[cursor..]
.chars()
.next()
.expect("valid character boundary");
output.push(ch);
cursor += ch.len_utf8();
continue;
}
output.push_str("<absolute-path>");
cursor += 3;
while cursor < bytes.len()
&& !bytes[cursor].is_ascii_whitespace()
&& !matches!(bytes[cursor], b'\"' | b'\'' | b',' | b';')
{
cursor += 1;
}
}
output
}
fn redact_unix_absolute_paths(value: &str) -> String {
let chars = value.chars().collect::<Vec<_>>();
let mut output = String::with_capacity(value.len());
let mut cursor = 0;
while cursor < chars.len() {
let previous_allows_path = cursor == 0
|| chars[cursor - 1].is_whitespace()
|| matches!(chars[cursor - 1], '=' | '(' | ':' | '');
let is_url_separator = chars.get(cursor + 1) == Some(&'/');
if chars[cursor] != '/' || !previous_allows_path || is_url_separator {
output.push(chars[cursor]);
cursor += 1;
continue;
}
output.push_str("<absolute-path>");
cursor += 1;
while cursor < chars.len()
&& !chars[cursor].is_whitespace()
&& !matches!(chars[cursor], '"' | '\'' | ',' | ';')
{
cursor += 1;
}
}
output
}
pub(crate) fn sanitize_diagnostic_message(value: &str, private_root: Option<&Path>) -> String {
let mut sanitized = value.replace(['\r', '\n'], " ");
if let Some(root) = private_root {
let root = root.to_string_lossy();
if !root.is_empty() {
sanitized = sanitized.replace(root.as_ref(), "<appdata>");
}
}
let lowercase = sanitized.to_ascii_lowercase();
if [
"authorization",
"bearer ",
"api_key",
"apikey",
"api key",
"x-api-key",
"token=",
"token:",
"credential",
]
.iter()
.any(|marker| lowercase.contains(marker))
{
return "<sensitive diagnostic details redacted>".to_string();
}
sanitized = redact_unix_absolute_paths(&redact_windows_absolute_paths(&sanitized));
sanitized.chars().take(2_048).collect()
}
/// 启动阶段的致命失败必须让用户看得见:release 双击启动时 stderr 不可见,只写日志
/// 等于什么都没发生。Windows 用系统消息框,其它平台退化为 stderr。日志路径尚未
/// 确定时仍然要提示,只是不给路径。
#[cfg(windows)]
fn show_startup_error_dialog(log_path: Option<&Path>) {
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::UI::WindowsAndMessaging::{
MessageBoxW, MB_ICONERROR, MB_OK, MB_SETFOREGROUND,
};
if STARTUP_ERROR_DIALOG_SHOWN.swap(true, std::sync::atomic::Ordering::AcqRel) {
return;
}
let title = std::ffi::OsStr::new("Genarrative AI Game Creator")
.encode_wide()
.chain(Some(0))
.collect::<Vec<_>>();
let message_text = match log_path {
Some(log_path) => format!(
"应用启动失败,请把下面的诊断日志发给开发人员:\n{}",
log_path.display()
),
None => {
"应用启动失败,诊断日志路径尚未确定;请把这条提示和复现步骤发给开发人员。".to_string()
}
};
let message = std::ffi::OsStr::new(&message_text)
.encode_wide()
.chain(Some(0))
.collect::<Vec<_>>();
// SAFETY: both UTF-16 buffers are NUL-terminated and live for the duration of the call.
unsafe {
MessageBoxW(
std::ptr::null_mut(),
message.as_ptr(),
title.as_ptr(),
MB_OK | MB_ICONERROR | MB_SETFOREGROUND,
);
}
}
#[cfg(not(windows))]
fn show_startup_error_dialog(log_path: Option<&Path>) {
if STARTUP_ERROR_DIALOG_SHOWN.swap(true, std::sync::atomic::Ordering::AcqRel) {
return;
}
match log_path {
Some(log_path) => eprintln!(
"Genarrative AI Game Creator startup failed; see {}",
log_path.display()
),
None => eprintln!(
"Genarrative AI Game Creator startup failed before the diagnostics log path was known"
),
}
}
/// 客户端产品名跟随构建期渠道身份:默认渠道是「陶泥儿」,其它渠道带渠道后缀
/// (例如「陶泥儿 Release」)。同机并存的渠道客户端因此在窗口标题、任务栏与
/// Alt-Tab 里可区分;默认渠道结果不变。
pub(crate) fn game_creator_product_name(app: &tauri::AppHandle) -> String {
app.package_info().name.clone()
}
/// 配置目录就绪前的启动日志路径:优先用已经生效的配置目录(例如 `--config-dir`
/// 已经设置好的目录),否则退到平台配置根。两者都不可用时返回 `None`,此时
/// `StartupLogSlot::fail` 仍然必须给出用户可见提示。
fn early_startup_log_path(identifier: &str) -> Option<PathBuf> {
let configured_dir = game_creator_runtime_config_dir();
resolve_early_startup_log_path(configured_dir.as_deref(), identifier)
}
fn resolve_early_startup_log_path(
configured_dir: Option<&Path>,
identifier: &str,
) -> Option<PathBuf> {
match configured_dir {
Some(directory) => Some(directory.join("diagnostics/startup.log")),
None => {
platform_config_root().map(|root| root.join(identifier).join("diagnostics/startup.log"))
}
}
}
#[cfg(windows)]
fn platform_config_root() -> Option<PathBuf> {
std::env::var_os("APPDATA").map(PathBuf::from)
}
#[cfg(target_os = "macos")]
fn platform_config_root() -> Option<PathBuf> {
std::env::var_os("HOME").map(|home| PathBuf::from(home).join("Library/Application Support"))
}
#[cfg(not(any(windows, target_os = "macos")))]
fn platform_config_root() -> Option<PathBuf> {
std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))
}
/// 启动诊断日志槽位。`configure_game_creator_runtime_config_dir` 之前只能退回按
/// 标识符推导的 APPDATA 路径,成功后再切换到真实配置目录,保证早期失败也有落点。
#[derive(Debug, Default)]
struct StartupLogSlot(Mutex<Option<PathBuf>>);
impl StartupLogSlot {
fn new(path: Option<PathBuf>) -> Self {
Self(Mutex::new(path))
}
fn set(&self, path: PathBuf) {
match self.0.lock() {
Ok(mut guard) => *guard = Some(path),
Err(poisoned) => *poisoned.into_inner() = Some(path),
}
}
fn path(&self) -> Option<PathBuf> {
match self.0.lock() {
Ok(guard) => guard.clone(),
Err(poisoned) => poisoned.into_inner().clone(),
}
}
fn append(&self, line: &str) {
if let Some(path) = self.path() {
let _ = append_bounded_diagnostic_line(&path, line);
}
}
/// 启动阶段的致命失败:先落盘,再给出用户可见提示。日志路径未知时仍然要
/// 提示,否则早期失败依旧表现为“双击没反应”。
fn fail(&self, line: &str) {
self.append(line);
show_startup_error_dialog(self.path().as_deref());
}
}
#[derive(Clone, Debug)]
struct GameCreatorAgentLoopResult {
run_id: String,
draft: LlmGameDraft,
spec_markdown: String,
findings_markdown: String,
passes: u8,
steps: Vec<GameCreationAgentRunStep>,
}
fn game_creator_gui_run_event_requests_runner_shutdown(event: &tauri::RunEvent) -> bool {
matches!(event, tauri::RunEvent::Exit)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum GameCreatorGuiRunnerShutdownOutcome {
NotRequested,
Requested,
/// 仍有其它界面窗口持有参与锁,Runner 必须保留给它们。
Retained,
Failed(GameCreatorGuiRunnerShutdownFailure),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum GameCreatorGuiRunnerShutdownFailure {
EndpointUnavailable,
RunnerUnresponsive,
ProcessIdentity,
PlatformUnsupported,
LockTimeout,
Other,
}
impl GameCreatorGuiRunnerShutdownFailure {
fn code(self) -> &'static str {
match self {
Self::EndpointUnavailable => "endpoint_unavailable",
Self::RunnerUnresponsive => "runner_unresponsive",
Self::ProcessIdentity => "process_identity",
Self::PlatformUnsupported => "platform_unsupported",
Self::LockTimeout => "lock_timeout",
Self::Other => "other",
}
}
}
fn classify_game_creator_gui_runner_shutdown_error(
error: &str,
) -> GameCreatorGuiRunnerShutdownFailure {
if error.contains("进程启动身份")
|| error.contains("pid 已")
|| error.contains("pidfd")
|| error.contains("进程句柄")
{
GameCreatorGuiRunnerShutdownFailure::ProcessIdentity
} else if error.contains("当前平台不支持") || error.contains("macOS 不提供") {
GameCreatorGuiRunnerShutdownFailure::PlatformUnsupported
} else if error.contains("实例锁") || error.contains("参与锁") || error.contains("owner 锁")
{
GameCreatorGuiRunnerShutdownFailure::LockTimeout
} else if error.contains("endpoint") {
GameCreatorGuiRunnerShutdownFailure::EndpointUnavailable
} else if error.contains("响应") || error.contains("连接 Agent Runner") {
GameCreatorGuiRunnerShutdownFailure::RunnerUnresponsive
} else {
GameCreatorGuiRunnerShutdownFailure::Other
}
}
fn resolve_game_creator_gui_runner_shutdown<F>(
event: &tauri::RunEvent,
shutdown: F,
) -> GameCreatorGuiRunnerShutdownOutcome
where
F: FnOnce() -> Result<bool, String>,
{
if !game_creator_gui_run_event_requests_runner_shutdown(event) {
return GameCreatorGuiRunnerShutdownOutcome::NotRequested;
}
match shutdown() {
Ok(true) => GameCreatorGuiRunnerShutdownOutcome::Requested,
Ok(false) => GameCreatorGuiRunnerShutdownOutcome::Retained,
Err(error) => GameCreatorGuiRunnerShutdownOutcome::Failed(
classify_game_creator_gui_runner_shutdown_error(&error),
),
}
}
fn handle_game_creator_gui_run_event(event: &tauri::RunEvent) {
if matches!(event, tauri::RunEvent::Exit) {
// 窗口关闭时已经按项目触发过一次快照同步;退出路径只负责在有界预算内
// 等在途同步收尾,不重复发起(此刻窗口已销毁,重新枚举项目只会是空集)。
wait_for_project_snapshot_syncs_on_exit();
// 退出时统一收尾本地预览:进程内监听线程随进程消失,但 `.agent/manifest.json`
// 里的 preview 记录会留在 running 上,下次进项目就照着它渲染打不开的运行界面。
if let Err(error) =
preview::stop_local_game_preview_on_exit(&game_creator_preview_registry())
{
app_log!("preview.gui_exit.stop_failed: {error}");
}
if let Err(error) = agent::shutdown_game_creator_codex_app_servers() {
app_log!("agent.direct_codex.gui_exit.shutdown_failed: {error}");
}
}
match resolve_game_creator_gui_runner_shutdown(
event,
shutdown_external_agent_runner_for_gui_exit,
) {
GameCreatorGuiRunnerShutdownOutcome::NotRequested => {}
GameCreatorGuiRunnerShutdownOutcome::Requested => {
app_log!("agent.runner.gui_exit.shutdown_requested")
}
GameCreatorGuiRunnerShutdownOutcome::Retained => {
app_log!("agent.runner.gui_exit.retained_for_other_windows")
}
GameCreatorGuiRunnerShutdownOutcome::Failed(failure) => {
app_log!("agent.runner.gui_exit.shutdown_failed.{}", failure.code())
}
}
}
/// Tauri 的全局异步 runtime 默认由 `TokioRuntime::new()` 建出来,worker 线程吃
/// tokio 默认栈。Runtime 的 agent turn 调用链深到本仓库另一处专门给自己的后台
/// 线程配了 AGENT_RUNTIME_BACKGROUND_WORKER_STACK_BYTES;但凡经 async_runtime::spawn
/// 派发的活(例如静态委派的父 run 唤醒)都落在这些默认栈的 worker 上,同一段代码
/// 在那里直接 `thread 'tokio-rt-worker' has overflowed its stack` 把整个进程 abort
/// 掉——现场表现是父 run 认领委派回执那一刻 Agent Runner 无声消失,调用方只看到
/// 连接超时。这里在任何异步派发之前把全局 runtime 换成同样栈尺寸的实例。
///
/// `async_runtime::set` 只接受 handle 且要求底层 Runtime 常驻,所以这里刻意泄漏。
fn build_agent_runtime_async_runtime() -> Result<tokio::runtime::Runtime, String> {
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.thread_stack_size(crate::agent::AGENT_RUNTIME_BACKGROUND_WORKER_STACK_BYTES)
.build()
.map_err(|error| format!("创建全局异步 runtime 失败:{error}"))
}
#[cfg(not(test))]
fn install_agent_runtime_async_runtime_with_deep_stack() {
let runtime = match build_agent_runtime_async_runtime() {
Ok(runtime) => runtime,
Err(error) => {
app_log!("agent.runner.failed: {error}");
std::process::exit(1);
}
};
tauri::async_runtime::set(runtime.handle().clone());
// 进程生命周期内必须持有,否则 handle 立即失效。
Box::leak(Box::new(runtime));
}
#[cfg(windows)]
fn run_windows_acl_repair_if_requested(args: &[String]) -> Option<i32> {
let [command, path, target_user_sid_flag, target_user_sid, authorization_flag, nonce, scope_flag, scope_value] =
args
else {
if args.first().map(String::as_str) == Some("--repair-private-acl") {
eprintln!(
"用法:--repair-private-acl <路径> --target-user-sid <SID> --authorization <票据> --scope <managed|user-selected>"
);
return Some(1);
}
return None;
};
if command != "--repair-private-acl"
|| target_user_sid_flag != "--target-user-sid"
|| authorization_flag != "--authorization"
|| scope_flag != "--scope"
{
eprintln!(
"用法:--repair-private-acl <路径> --target-user-sid <SID> --authorization <票据> --scope <managed|user-selected>"
);
return Some(1);
}
let path = std::path::PathBuf::from(path);
let scope = match config::parse_windows_acl_repair_scope(scope_value) {
Ok(scope) => scope,
Err(error) => {
eprintln!("{error}");
return Some(1);
}
};
let result =
config::consume_windows_acl_repair_authorization(&path, target_user_sid, nonce, scope)
.and_then(|()| {
config::repair_game_creator_private_acl_for_user_sid(&path, target_user_sid)
});
match result {
Ok(()) => Some(0),
Err(error) => {
eprintln!("AGC ACL 提权修复失败:{error}");
Some(1)
}
}
}
#[cfg(test)]
mod async_runtime_stack_tests {
/// 每帧固定占 16 KiB,用 black_box 挡住优化,让递归深度直接换算成栈用量。
fn consume_stack(depth: usize) -> u64 {
let mut frame = [0_u8; 16 * 1024];
frame[depth % frame.len()] = depth as u8;
let sum = std::hint::black_box(&frame)
.iter()
.map(|byte| u64::from(*byte))
.sum::<u64>();
if depth == 0 {
sum
} else {
sum + consume_stack(depth - 1)
}
}
/// 全局异步 runtime 的 worker 必须和 Runtime 自己的后台线程用同一份栈预算。
/// 掉了 thread_stack_size 时这条不是断言失败而是整个测试进程被 abort——这正是
/// 线上的失效形态:Agent Runner 在父 run 认领委派回执时无声消失。
#[test]
fn async_runtime_workers_hold_a_call_chain_that_overflows_the_default_stack() {
const FRAMES: usize = 192; // 192 × 16 KiB = 3 MiB,超出 tokio 默认栈,远低于 16 MiB
let runtime = super::build_agent_runtime_async_runtime().expect("build async runtime");
let handled =
runtime.block_on(async { tokio::spawn(async { consume_stack(FRAMES - 1) }).await });
assert!(handled.is_ok(), "{handled:?}");
}
}
#[cfg(not(test))]
fn main() {
install_agent_runtime_async_runtime_with_deep_stack();
register_non_canonical_asset_kind_reporter();
let mut args = std::env::args().skip(1).collect::<Vec<_>>();
if let Some(exit_code) = run_direct_tools_mcp_if_requested(&args) {
std::process::exit(exit_code);
}
#[cfg(windows)]
if let Some(exit_code) = run_windows_acl_repair_if_requested(&args) {
std::process::exit(exit_code);
}
#[cfg(target_os = "linux")]
if command_sandbox_trampoline::is_trampoline_mode(&args) {
match command_sandbox_trampoline::run_trampoline() {
Ok(exit_code) => std::process::exit(exit_code),
Err(_) => std::process::exit(125),
}
}
#[cfg(target_os = "linux")]
if command_sandbox_trampoline::is_process_session_trampoline_mode(&args) {
match command_sandbox_trampoline::run_process_session_trampoline() {
Ok(exit_code) => std::process::exit(exit_code),
Err(_) => std::process::exit(125),
}
}
#[cfg(target_os = "linux")]
if is_process_session_child_mode(&args) {
match run_process_session_child(&args) {
Ok(exit_code) => std::process::exit(exit_code),
Err(_) => std::process::exit(125),
}
}
let runtime_config_dir = match take_cli_runtime_config_dir(&mut args) {
Ok(config_dir) => config_dir,
Err(error) => {
app_log!("{error}");
std::process::exit(1);
}
};
if args.first().map(String::as_str) == Some("--agent-runner") {
let gui_owner_required = match args.as_slice() {
[_] => false,
[_, option] if option == "--gui-owner-required" => true,
_ => {
app_log!(
"用法:--agent-runner [--gui-owner-required] --config-dir <AppData 绝对路径>"
);
std::process::exit(1);
}
};
let Some(config_dir) = runtime_config_dir else {
app_log!("Agent Runner 必须显式传入 --config-dir <AppData 绝对路径>");
std::process::exit(1);
};
if let Err(error) = load_platform_session_fixture_from_env(&config_dir) {
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) {
app_log!("agent.runner.failed: {error}");
std::process::exit(1);
}
return;
}
match parse_cli_command(&args) {
Ok(Some(mut command)) => {
let config_dir =
match prepare_cli_command_paths(&mut command, runtime_config_dir.as_deref()) {
Ok(config_dir) => config_dir,
Err(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) {
app_log!("agent.runner.failed: {error}");
std::process::exit(1);
}
if command.requires_external_agent_runner() || command.is_read_only_status() {
let configured = if command.is_read_only_status() {
configure_external_agent_runner_read_only(&config_dir)
} else {
configure_external_agent_runner(&config_dir)
};
if let Err(error) = configured {
app_log!("agent.runner.failed: {error}");
std::process::exit(1);
}
}
set_game_creator_runtime_config_dir(config_dir);
}
if command.requires_started_external_agent_runner() {
if let Err(error) = ensure_external_agent_runner_started() {
app_log!("agent.runner.failed: {error}");
std::process::exit(1);
}
}
if let Err(error) = run_cli_command(command) {
app_log!("agent.run.failed: {error}");
std::process::exit(1);
}
return;
}
Ok(None) => {}
Err(error) => {
app_log!("{error}");
std::process::exit(1);
}
}
if let Some(config_dir) = runtime_config_dir {
set_game_creator_runtime_config_dir(config_dir);
}
let mut tauri_context = tauri::generate_context!();
// 配置目录确定之前先推导启动日志路径:优先用已经生效的配置目录(例如
// `--config-dir`),否则退到平台配置根,保证
// `configure_game_creator_runtime_config_dir` 自身失败也有落点。
let startup_log = Arc::new(StartupLogSlot::new(early_startup_log_path(
tauri_context.config().identifier.as_str(),
)));
let setup_log = Arc::clone(&startup_log);
let app = tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(context_menu::init())
.on_window_event(|window, event| handle_project_snapshot_window_event(window, event))
.manage(game_creator_preview_registry())
.manage(ProjectResourcePreviewReadManager::default())
.manage(PluginHost::default())
.setup(move |app| {
error_report::initialize_notifications(app.handle());
setup_log.append("startup.setup.begin");
setup_log.append("startup.appdata.configure.begin");
configure_game_creator_runtime_config_dir(app.handle()).inspect_err(|error| {
// 日志路径未知也必须记录并提示:不能因为拿不到路径就静默失败。
let config_dir = game_creator_runtime_config_dir();
let details =
sanitize_diagnostic_message(&error.to_string(), config_dir.as_deref());
setup_log.fail(&format!(
"startup.appdata.configure.failed details={details}"
));
})?;
if let Some(directory) = game_creator_runtime_config_dir() {
setup_log.set(directory.join("diagnostics/startup.log"));
}
setup_log.append("startup.appdata.configure.complete");
let config_dir = game_creator_runtime_config_dir().ok_or_else(|| {
let error = std::io::Error::new(
std::io::ErrorKind::NotFound,
"客户端 AppData 配置目录未初始化",
);
setup_log.fail("startup.appdata.resolve.failed details=config-dir-uninitialized");
error
})?;
// 主窗口标题与产品名保持一致:配置里的标题来自基线配置,渠道后缀只
// 由构建期身份决定,因此必须在这里按产品名覆盖。
match app.get_webview_window("client") {
Some(window) => {
if let Err(error) = window.set_title(&game_creator_product_name(app.handle())) {
app_log!("startup.window-title.failed: {error}");
}
}
None => app_log!("startup.window-title.failed: 缺少 client 主窗口"),
}
spawn_project_snapshot_scheduler(app.handle().clone());
if let Err(error) = builtin_plugins::initialize(&config_dir) {
app_log!("startup.builtin-plugins.initialize.failed: {error}");
}
if let Err(error) = app.state::<PluginHost>().initialize(&config_dir) {
app_log!("startup.plugin-host.initialize.failed: {error}");
}
if let Some(workspace) = plugin_host::resolve_plugin_workspace(app.handle()) {
if let Err(error) = app.state::<PluginHost>().set_plugin_workspace(workspace) {
app_log!("startup.plugin-host.workspace.failed: {error}");
}
}
if let Err(error) = editor_adapters::register_linked_editor_adapters(
app.handle(),
app.state::<PluginHost>().inner(),
) {
app_log!("startup.plugin-host.adapter.failed: {error}");
}
load_platform_session_fixture_from_env(&config_dir).map_err(|error| {
std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
format!("加载平台登录态测试 fixture 失败:{error}"),
)
})?;
setup_log.append("startup.runner.configure.begin");
configure_external_agent_runner(&config_dir)
.inspect_err(|error| {
let details = sanitize_diagnostic_message(error, Some(config_dir.as_path()));
setup_log.fail(&format!(
"startup.runner.configure.failed details={details}"
));
})
.map_err(|error| {
std::io::Error::new(
std::io::ErrorKind::Other,
format!("配置 Agent Runner 失败:{error}"),
)
})?;
setup_log.append("startup.runner.configure.complete");
hold_external_agent_runner_gui_participant_lock(&config_dir)
.inspect_err(|error| {
let details = sanitize_diagnostic_message(error, Some(config_dir.as_path()));
setup_log.fail(&format!(
"startup.runner.participant-lock.failed details={details}"
));
})
.map_err(|error| {
std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
format!("建立 AGC 界面参与锁失败:{error}"),
)
})?;
setup_log.append("startup.runner.start.begin");
set_game_creator_agent_runtime_update_app_handle(app.handle().clone());
set_direct_thread_manager_app_handle(app.handle().clone());
let manifest_event_sink =
start_game_creator_manifest_invalidation_event_sink(app.handle().clone())?;
attach_external_agent_runner_gui_owner(&manifest_event_sink)
.inspect_err(|error| {
let details = sanitize_diagnostic_message(error, Some(config_dir.as_path()));
setup_log.fail(&format!(
"startup.runner.attach-owner.failed details={details}"
));
})
.map_err(|error| {
std::io::Error::new(
std::io::ErrorKind::Other,
format!("绑定 Agent Runner GUI owner 失败:{error}"),
)
})?;
setup_log.append("startup.runner.start.complete");
setup_log.append("startup.setup.complete");
Ok(())
})
.invoke_handler(tauri::generate_handler![
start_game_creator_external_mcp,
stop_game_creator_external_mcp,
create_automatic_local_game_project,
create_automatic_local_game_project_from_template,
init_local_game_project,
fetch_game_template_library,
get_game_template_library_access,
download_game_template,
import_local_godot_project,
import_local_cocos_project,
import_local_unity_project,
is_local_project_directory_non_empty,
inspect_local_project_directory,
pick_local_project_directory,
rename_local_game_project,
suggest_automatic_project_name,
polish_local_project_prompt,
pick_client_extension_file,
pick_client_extension_directory,
list_client_extensions,
list_agc_skill_catalog,
import_client_extension,
set_client_extension_enabled,
rename_client_extension,
remove_client_extension,
list_agc_plugins,
list_agc_extensions,
refresh_agc_plugins,
start_agc_plugin,
stop_agc_plugin,
reload_agc_plugin,
set_agc_plugin_enabled,
call_agc_plugin,
read_agc_plugin_panel,
set_agc_plugin_project_path,
open_local_project_directory,
control_agent_run,
generate_local_game_draft,
chat_with_game_creator_agent,
chat_with_game_creator_role_agent,
chat_with_game_creator_role_agent_stream,
chat_with_game_creator_direct_codex,
preflight_web_game_creation,
cancel_direct_codex_turn,
select_game_creator_reasoning_effort,
hydrate_design_agent_session,
reset_design_agent_session,
get_design_agent_runtime_mode,
is_design_agent_debug_enabled,
set_design_agent_runtime_mode,
debug_fast_forward_design_session,
continue_design_agent_session,
decide_design_phase,
import_design_workspace_file,
list_design_workspace,
read_design_workspace_file,
start_game_creator_agent_runtime_task,
start_game_creator_supervisor_runtime_task,
compact_game_creator_agent_runtime_context,
read_game_creator_agent_goal,
start_game_creator_agent_goal,
edit_game_creator_agent_goal,
pause_game_creator_agent_goal,
resume_game_creator_agent_goal,
clear_game_creator_agent_goal,
steer_game_creator_agent_runtime_task,
cancel_game_creator_agent_runtime_task,
retry_game_creator_agent_runtime_task,
confirm_retry_game_creator_agent_runtime_task,
confirm_game_creator_agent_runtime_task,
reject_game_creator_agent_runtime_task,
answer_game_creator_agent_runtime_user_input,
read_game_creator_agent_runtime,
read_game_creator_agent_runtimes,
resume_game_creator_agent_runtime_tasks,
confirm_resume_game_creator_agent_runtime_tasks,
schedule_game_creator_agent_ready_tasks,
check_game_creator_llm_config,
read_platform_account_session_state,
install_platform_account_session,
clear_platform_account_session,
read_game_creator_app_config,
write_game_creator_app_config,
select_game_creator_model,
discover_game_creator_llm_models,
upload_local_asset,
register_local_asset,
create_ui_design_resource,
update_local_project_resource_classification,
add_local_project_resource_tags,
derive_local_project_resource,
list_pending_local_project_resource_edits,
resume_local_project_resource_edit,
request_local_project_resource_edit_service_identity_confirmation,
confirm_local_project_resource_edit_service_identity,
archive_failed_local_project_resource_edit,
normalize_local_project_raster_resource,
import_canvas_asset,
import_canvas_export,
sync_canvas_project_assets,
import_ui_editor_assets,
prepare_ui_editor_project_fonts,
read_ui_editor_font_bytes,
check_ui_editor_font_glyph_coverage,
suggest_ui_design_semantic,
recognize_ui,
separate_ui,
inspect_separation_recovery,
finalize_separation,
discard_separation_recovery,
merge_ui,
bind_components,
load_ui_design_state,
save_ui_design_state,
generate_ui_design_code,
ensure_ui_design_resource_for_prototype,
generate_platform_art_asset,
generate_local_project_asset,
start_local_project_asset_generation,
list_local_project_asset_generations,
open_canvas_project,
get_game_creation_agent_capabilities,
get_limited_local_commands,
run_limited_local_command,
append_local_permission_log,
list_local_project_files,
import_local_project_image_assets,
read_local_project_file,
read_local_project_image_preview,
save_local_project_asset_file,
read_local_project_text_preview,
read_local_project_structured_preview,
read_local_project_media_preview,
cancel_local_project_resource_preview_scope,
write_local_project_file,
read_local_game_memory,
read_local_agent_memory,
write_local_agent_memory,
write_local_game_memory,
list_game_creator_agent_sessions,
create_game_creator_agent_session,
fork_game_creator_agent_session,
set_active_game_creator_agent_session,
archive_game_creator_agent_session,
read_local_conversation,
read_direct_project_conversation,
read_agent_runtime_error_detail,
list_game_creator_direct_active_turns,
subscribe_direct_project_thread,
consume_direct_project_thread,
read_direct_project_history_slice,
append_local_conversation_message,
append_direct_project_conversation_message,
build_local_project_index,
create_local_project_checkpoint,
export_local_project_package,
read_local_project_export_package,
list_local_project_export_packages,
diff_local_project_checkpoint,
restore_local_project_checkpoint,
read_project_permission_policy,
write_project_permission_policy,
open_game_creator_workspace_window,
open_game_creator_launcher_window,
start_local_game_preview,
activate_local_game_preview,
stop_local_game_preview,
stop_local_game_preview_if_matches,
get_local_game_preview_status,
read_local_project_resource_canvas_layout,
read_local_project_resource_graph,
update_local_project_resource_canvas_layout,
delete_local_project_asset,
read_local_project_asset_references,
rename_local_project_asset,
read_local_project_version_resource_replacement_candidates,
replace_local_project_version_resource,
get_local_game_project_revision,
get_local_game_manifest,
restart_agc_app,
append_application_log,
read_diagnostic_logs,
report_client_error,
get_pending_error_reports,
ack_error_reports,
sync_local_project_snapshot,
read_local_project_snapshot_state,
set_active_project_snapshot_workspace,
])
.build(tauri_context);
let app = match app {
Ok(app) => {
startup_log.append("startup.build.complete");
app
}
Err(error) => {
if let Some(path) = startup_log.path() {
let details = sanitize_diagnostic_message(&error.to_string(), path.parent());
startup_log.fail(&format!("startup.build.failed details={details}"));
}
app_log!("failed to build Genarrative AI Game Creator shell: {error}");
std::process::exit(1);
}
};
app.run(move |_app_handle, event| handle_game_creator_gui_run_event(&event));
}
#[cfg(test)]
mod diagnostic_log_tests {
use super::*;
#[test]
fn bounded_diagnostic_log_rotates_and_keeps_only_one_previous_file() {
let directory = tempfile::tempdir().expect("create diagnostics directory");
let path = directory.path().join("startup.log");
let first_record = "x".repeat(128);
append_bounded_diagnostic_line_with_limit(&path, &first_record, 64)
.expect("write first record");
append_bounded_diagnostic_line_with_limit(&path, "second-record", 64)
.expect("rotate diagnostic log");
let current = fs::read_to_string(&path).expect("read current diagnostic log");
let previous = fs::read_to_string(path.with_extension("previous.log"))
.expect("read previous diagnostic log");
assert!(current.contains("second-record"));
assert!(previous.contains(&"x".repeat(32)));
}
#[test]
fn startup_log_slot_keeps_early_failures_after_the_real_config_dir_is_known() {
let directory = tempfile::tempdir().expect("create diagnostics directory");
let path = directory.path().join("startup.log");
let slot = StartupLogSlot::new(None);
// 配置目录未知时不能凭空造出日志文件。
slot.append("startup.setup.begin");
assert!(!path.exists());
slot.set(path.clone());
slot.append("startup.setup.begin");
slot.append("startup.appdata.configure.complete");
let content = fs::read_to_string(&path).expect("read startup log");
assert!(content.contains("startup.setup.begin"));
assert!(content.contains("startup.appdata.configure.complete"));
}
#[test]
fn early_startup_log_path_prefers_the_already_applied_config_dir() {
let directory = tempfile::tempdir().expect("create config directory");
assert_eq!(
resolve_early_startup_log_path(Some(directory.path()), "world.genarrative.test"),
Some(directory.path().join("diagnostics/startup.log"))
);
// 配置目录尚未生效时才退到平台配置根,且仍要按标识符分层。
if let Some(fallback) = resolve_early_startup_log_path(None, "world.genarrative.test") {
assert!(fallback.ends_with("world.genarrative.test/diagnostics/startup.log"));
}
}
#[test]
fn startup_log_slot_fail_without_path_still_reports_instead_of_going_silent() {
let directory = tempfile::tempdir().expect("create diagnostics directory");
let slot = StartupLogSlot::new(None);
// 路径未知时 fail 不能静默:它必须仍然走到用户可见提示,同时不造日志文件。
slot.fail("startup.runner.owner-lock.failed details=test");
assert_eq!(fs::read_dir(directory.path()).expect("read dir").count(), 0);
let path = directory.path().join("startup.log");
let slot = StartupLogSlot::new(Some(path.clone()));
slot.fail("startup.runner.owner-lock.failed details=test");
let content = fs::read_to_string(&path).expect("read startup log");
assert!(content.contains("startup.runner.owner-lock.failed details=test"));
}
#[test]
fn diagnostic_message_redacts_sensitive_values_and_absolute_paths() {
assert_eq!(
sanitize_diagnostic_message("Authorization: Bearer secret", None),
"<sensitive diagnostic details redacted>"
);
assert_eq!(
sanitize_diagnostic_message(r"failed at C:\private\project\game.json", None),
"failed at <absolute-path>"
);
assert_eq!(
sanitize_diagnostic_message("failed at /home/example/private/game.json", None),
"failed at <absolute-path>"
);
}
#[test]
fn diagnostic_log_rejects_hardlink_targets_including_rotation_backup() {
let directory = tempfile::tempdir().expect("create diagnostics directory");
let outside = directory.path().join("outside.txt");
fs::write(&outside, "outside-unchanged").expect("write outside target");
let path = directory.path().join("startup.log");
fs::hard_link(&outside, &path).expect("create diagnostic hardlink");
assert!(append_bounded_diagnostic_line(&path, "must-not-write").is_err());
assert_eq!(
fs::read_to_string(&outside).expect("read outside target"),
"outside-unchanged"
);
fs::remove_file(&path).expect("remove diagnostic hardlink");
fs::write(&path, "rotate-me").expect("write diagnostic file");
let previous = path.with_extension("previous.log");
fs::hard_link(&outside, &previous).expect("create previous hardlink");
assert!(append_bounded_diagnostic_line_with_limit(&path, "blocked", 1).is_err());
assert_eq!(
fs::read_to_string(&outside).expect("read outside target after rotation"),
"outside-unchanged"
);
}
#[cfg(unix)]
#[test]
fn diagnostic_log_rejects_symlink_targets() {
use std::os::unix::fs::symlink;
let directory = tempfile::tempdir().expect("create diagnostics directory");
let outside = directory.path().join("outside.txt");
fs::write(&outside, "outside-unchanged").expect("write outside target");
let path = directory.path().join("startup.log");
symlink(&outside, &path).expect("create diagnostic symlink");
assert!(append_bounded_diagnostic_line(&path, "must-not-write").is_err());
assert_eq!(
fs::read_to_string(&outside).expect("read outside target"),
"outside-unchanged"
);
}
#[cfg(windows)]
#[test]
fn diagnostic_log_rejects_windows_symlink_or_reparse_targets_when_supported() {
use std::os::windows::fs::symlink_file;
let directory = tempfile::tempdir().expect("create diagnostics directory");
let outside = directory.path().join("outside.txt");
fs::write(&outside, "outside-unchanged").expect("write outside target");
let path = directory.path().join("startup.log");
if symlink_file(&outside, &path).is_err() {
return;
}
assert!(append_bounded_diagnostic_line(&path, "must-not-write").is_err());
assert_eq!(
fs::read_to_string(&outside).expect("read outside target"),
"outside-unchanged"
);
}
}
#[cfg(test)]
mod tests;
pub mod ui_editor;