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
Reviewed-on: #560
2157 lines
69 KiB
Rust
2157 lines
69 KiB
Rust
#![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;
|