Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/main.rs
T
k88936 774f95be94
Project CI / AI game creator shell Rust crates (push) Successful in 1m35s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m17s
Project CI / Backend tests (push) Successful in 4m8s
Project CI / Frontend tests (push) Successful in 1m49s
Project CI / Native shell tests (push) Successful in 6m21s
Project CI / Repository checks (push) Successful in 2m24s
Project CI / AI game creator shell web tests (push) Successful in 1m43s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 10m25s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 11m50s
Merge pull request '退役 DirectProject 工具调用与回合流账本' (#560) from refactor/remove-toolcall.jsonl-legacy into master
Reviewed-on: #560
2026-10-01 16:13:14 +08:00

2157 lines
69 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;
// 复用随包资源声明的既有单测(校验通过/拒绝用例),生产运行时只经 codex_bundle 使用布局。
#[cfg(test)]
#[path = "../build_support/package_layout.rs"]
mod package_layout;
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::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::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,
GameCreationAgentPassPlanTrace, GameCreationAgentRepairRouteTrace, GameCreationAgentRunStep,
GameCreationAgentRunTaskGraphTrace, GameCreationAgentRunTrace, GameCreationAgentToolCallTrace,
GameCreationAppAgentGroup, GameCreationAppAssetKind, GameCreationAppAssetManifestEntry,
GameCreationAppAssetSource, GameCreationAppAssetSourceKind, GameCreationAppCommandRunState,
GameCreationAppCommandRunStatus, GameCreationAppManifest, GameCreationAppPermission,
GameCreationAppPreviewState, GameCreationAppPreviewStatus, GameCreationAppTaskState,
GameCreationAppTaskStatus, GameIterationVersion, GameIterationVersionCreatedReason,
GameIterationVersionResourceBinding, ProjectResourceCanvasLayout,
ProjectResourceCanvasLayoutMode, ProjectResourceCanvasPosition,
UpdateProjectResourceCanvasLayoutResult, UpdateProjectResourceCanvasLayoutStatus,
GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, GAME_CREATION_AGENT_TOOL_CALL_MAX,
GAME_CREATION_APP_COMMANDS, GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION,
};
// `Emitter` 同时被 `use super::*` 的子模块依赖(通知、Agent 事件等都从 crate 根取该 trait),
// 不要因为根模块自身不再直接 `.emit(..)` 就删掉它。
use tauri::Emitter;
#[cfg(not(test))]
use tauri::Manager;
/// 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 下整体剔除。
include!(concat!(env!("OUT_DIR"), "/agent_runtime_prompt_bundle.rs"));
mod account_api;
mod acl_repair_gate;
mod agent;
mod agent_native_tools;
mod analytics;
mod asset_generation_tasks;
mod assets;
mod auth_session;
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;
// 桌面与独立进程启动接线不参与单元测试;共享实现仍在各自模块编译。
#[cfg(not(test))]
mod desktop;
#[cfg(not(test))]
pub(crate) use desktop::game_creator_product_name;
mod editor_adapter;
mod editor_adapters;
mod environment_check;
pub mod error_report;
mod game_distribution_publish;
mod game_package_upload;
mod git_inspect;
mod goal;
mod http_client;
mod image_inspect;
mod isolated_agent;
mod llm_catalog;
mod patchset;
mod platform_asset_upload;
mod platform_maintenance;
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 template_library;
mod tool_plan_handoff;
mod user_input;
mod windows;
mod workspace_preferences;
use agent::*;
use agent_native_tools::*;
use assets::*;
use auth_session::*;
use browser::*;
use cli::*;
use collaboration::*;
use command_exec::*;
use command_output::*;
use command_sandbox::*;
use commands::*;
use config::*;
use context_compaction::*;
use delegation::*;
use git_inspect::*;
use goal::*;
use image_inspect::*;
use isolated_agent::*;
use llm_catalog::load_game_creator_llm_models;
use patchset::*;
use platform_asset_upload::upload_platform_media_asset;
use platform_session::*;
use preview::*;
use process_session::*;
use project::*;
#[cfg(not(test))]
use project_snapshot::*;
use repository_context::*;
use resource_inspect::*;
use resource_preview_scheduler::*;
use runner::*;
use user_input::*;
use windows::*;
fn save_ui_design_state_with_capture(
input: ui_editor::persistence::SaveUiDesignStateInput,
capture: Option<(
analytics::contract::Context,
analytics::store::AnalyticsWriter,
)>,
) -> Result<ui_editor::persistence::SaveUiDesignStateResult, String> {
let project_id = input.expected_project_id.clone();
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "asset.register")?;
let result = ui_editor::persistence::save_ui_design_state_at(input)?;
if let ui_editor::persistence::SaveUiDesignStateResult::Saved {
committed_project_revision,
..
} = &result
{
analytics::project::revision(
capture,
&project_id,
analytics::contract::Source::UiEditor,
analytics::contract::RevisionCreated {
revision_id: committed_project_revision.to_string(),
revision_source: analytics::contract::RevisionSource::UiEditor,
change_kind: analytics::contract::ChangeKind::Ui,
files_changed_count: None,
},
);
}
Ok(result)
}
#[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, 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,
}
#[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(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 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, 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_CLAUDE_CODE_CLI: &str = "claude_code_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";
// 默认模型不再写死具体上游模型名:正式构建锁定官方路由,
// 未选择平台目录模型时该占位标识表示“跟随平台默认”(与 config.rs 同源)。
const DEFAULT_GAME_CREATOR_LLM_MODEL: &str = OFFICIAL_LLM_ROUTER_DEFAULT_MODEL;
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_ERROR_DIALOG_SHOWN: AtomicBool = AtomicBool::new(false);
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"
),
}
}
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,
}
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),
),
}
}
/// 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(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() {
desktop::run();
}
#[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;