diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs index 7e17273aa..b19cea107 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs @@ -78,12 +78,6 @@ pub(super) fn game_creator_agent_final_reply_error_allows_fallback(error: &str) matches!(kind.as_str(), "empty-response" | "deserialize") } -/// 这些错误只描述本次 Provider input 或候选 GDD;真正的 session CAS 冲突不在 -/// 此列——那说明 durable session 已被推进或损坏,必须 reconcile。 -fn plan_submit_error_is_business_rejection(error: &PlanningStorageError) -> bool { - matches!(error.code(), "PLAN_INVALID_REQUEST" | "PLAN_SIZE_LIMIT") -} - const AGENT_RUNTIME_PLAN_UPDATE_IDLE_LIMIT: u32 = 4; /// 最终回复被收束门禁拦下后 run 会原地续跑重试。多数 blocker 是模型自己能解的 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs index 754a2eb8e..2c4e811b3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs @@ -7,7 +7,6 @@ mod design_session; mod finalization; mod json_sidecar; mod models; -mod planning_gdd_model; mod provider_control; mod provider_retry; mod real_e2e_checkpoint; @@ -22,7 +21,6 @@ pub(crate) use design_session::*; pub(in crate::agent) use finalization::*; pub(in crate::agent) use json_sidecar::*; pub(in crate::agent) use models::*; -pub(crate) use planning_gdd_model::*; pub(in crate::agent) use provider_control::*; pub(in crate::agent) use provider_retry::*; pub(in crate::agent) use real_e2e_checkpoint::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_gdd_model.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_gdd_model.rs deleted file mode 100644 index 4ca7168cd..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_gdd_model.rs +++ /dev/null @@ -1,1082 +0,0 @@ -//! 策划 GDD 的内容模型与落盘工具:GDD 业务结构、字段校验、类型化指纹和 -//! `game/fast_gdd.md` 原子写。立项策划 V2 会话与 Fast GDD 交付物共用这一层。 - -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use std::fmt; -use std::fs::{self, File, OpenOptions}; -use std::io::{Read, Seek, SeekFrom, Write}; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::{SystemTime, UNIX_EPOCH}; -use uuid::{Uuid, Variant}; - -use crate::project::{normalize_relative_path, resolve_local_project_path}; - -pub(crate) const PLAN_INDEX_MAX_BYTES: usize = 256 * 1024; -pub(crate) const PLAN_FAST_GDD_PATH: &str = "game/fast_gdd.md"; -pub(crate) const PLAN_FAST_GDD_MAX_BYTES: usize = 128 * 1024; -/// 单条决定 answerSummary 的上限(`initial-request` 除外)。 -pub(crate) const PLAN_DECISION_ANSWER_SUMMARY_MAX_CHARS: usize = 800; - -/// `initial-request` 那条决定的 answerSummary 上限,也就是立项策划入口原始需求的上限。 -/// -/// 上游用 `sanitize_agent_runtime_text(task, AGENT_RUNTIME_TASK_MAX_CHARS)` 归一根 -/// task:4000 个 Unicode scalar 封顶,超长时再补一个省略号,真实上界因此是 4001。 -/// 这里早期写死 400,于是 401~4001 字的开场需求会让根 run、Goal Contract 与首跳 -/// 委派全部正常建立,直到策划子 Agent 的 task-start 才在 session 投影上硬失败 -/// (`phase=planning-session-projection-failed`);此时 session 从未创建,用同一根 -/// task 重试必然复现,用户只能重开一条链。直接绑定到上游常量,两边不会再漂开。 -pub(crate) const PLAN_INITIAL_REQUEST_MAX_CHARS: usize = - crate::agent::runtime_driver::AGENT_RUNTIME_TASK_MAX_CHARS + 1; - -/// `initial-request` 承载的是用户原话,长度上限与其余决定不同。 -fn decision_answer_summary_max_chars(decision_id: &str) -> usize { - if decision_id == "initial-request" { - PLAN_INITIAL_REQUEST_MAX_CHARS - } else { - PLAN_DECISION_ANSWER_SUMMARY_MAX_CHARS - } -} -static PLANNING_TEMP_NONCE: AtomicU64 = AtomicU64::new(1); - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct PlanningStorageError { - code: &'static str, - detail: String, -} - -impl PlanningStorageError { - pub(crate) fn new(code: &'static str, detail: impl Into) -> Self { - Self { - code, - detail: detail.into(), - } - } - - pub(crate) fn code(&self) -> &'static str { - self.code - } -} - -impl fmt::Display for PlanningStorageError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(formatter, "{}: {}", self.code, self.detail) - } -} - -impl std::error::Error for PlanningStorageError {} - -pub(crate) type FingerprintError = PlanningStorageError; - -fn invalid(detail: impl Into) -> PlanningStorageError { - PlanningStorageError::new("PLAN_INVALID_SCHEMA", detail) -} - -fn io_error(label: &str, error: impl fmt::Display) -> PlanningStorageError { - PlanningStorageError::new("PLAN_STORAGE_IO", format!("{label}: {error}")) -} - -fn canonical_bytes(value: &T) -> Result, PlanningStorageError> { - serde_json::to_vec(value) - .map_err(|error| PlanningStorageError::new("PLAN_SERIALIZE_FAILED", error.to_string())) -} - -#[derive(Serialize)] -struct FingerprintEnvelope<'a, T: Serialize + ?Sized> { - domain: &'static str, - value: &'a T, -} - -/// Typed planning fingerprint. The envelope and the serialized value are -/// deliberately struct-shaped; map/string concatenation fingerprints are not -/// interchangeable with this contract. -pub(crate) fn typed_serde_fingerprint( - domain: &'static str, - value: &T, -) -> Result { - if domain.trim().is_empty() { - return Err(PlanningStorageError::new( - "PLAN_INVALID_FINGERPRINT_DOMAIN", - "fingerprint domain 不能为空", - )); - } - let bytes = typed_serde_canonical_bytes(domain, value)?; - Ok(format!("sha256-serde-json-v2:{:x}", Sha256::digest(bytes))) -} - -pub(crate) fn typed_serde_canonical_bytes( - domain: &'static str, - value: &T, -) -> Result, FingerprintError> { - if domain.trim().is_empty() { - return Err(PlanningStorageError::new( - "PLAN_INVALID_FINGERPRINT_DOMAIN", - "fingerprint domain 不能为空", - )); - } - let envelope = FingerprintEnvelope { domain, value }; - canonical_bytes(&envelope) -} -fn is_hex(value: &str, length: usize) -> bool { - value.len() == length && value.bytes().all(|byte| byte.is_ascii_hexdigit()) -} - -fn is_lower_hex(value: &str, length: usize) -> bool { - is_hex(value, length) && value.bytes().all(|byte| !byte.is_ascii_uppercase()) -} - -pub(crate) fn is_typed_fingerprint(value: &str) -> bool { - value - .strip_prefix("sha256-serde-json-v2:") - .is_some_and(|digest| is_lower_hex(digest, 64)) -} - -pub(crate) fn validate_text( - value: &str, - label: &str, - min: usize, - max: usize, -) -> Result<(), PlanningStorageError> { - if value.chars().count() < min || value.chars().count() > max { - return Err(invalid(format!( - "{label} 必须为 {min}..={max} 个 Unicode scalar" - ))); - } - if value != value.trim() { - return Err(invalid(format!("{label} 必须已完成首尾空白规范化"))); - } - if value.contains('\r') - || value.chars().any(|character| { - character == '\0' - || character == '\u{7f}' - || (character.is_control() && character != '\n' && character != '\t') - }) - { - return Err(invalid(format!("{label} 包含不允许的控制字符"))); - } - Ok(()) -} -pub(crate) fn validate_opaque_id( - value: &str, - label: &str, - allow_empty: bool, -) -> Result<(), PlanningStorageError> { - if value.is_empty() && allow_empty { - return Ok(()); - } - let mut chars = value.chars(); - let Some(first) = chars.next() else { - return Err(invalid(format!("{label} 不能为空"))); - }; - if value.chars().count() > 128 - || !first.is_ascii_alphanumeric() - || !chars.all(|character| { - character.is_ascii_alphanumeric() || matches!(character, '.' | '_' | ':' | '-') - }) - { - return Err(invalid(format!("{label} 不是合法 opaque ID"))); - } - Ok(()) -} - -pub(crate) fn validate_uuid_prefixed( - value: &str, - prefix: &str, - label: &str, -) -> Result<(), PlanningStorageError> { - let Some(uuid) = value.strip_prefix(prefix) else { - return Err(invalid(format!("{label} 必须以 {prefix} 开头"))); - }; - if uuid.len() != 36 - || !uuid.bytes().enumerate().all(|(index, byte)| { - matches!(index, 8 | 13 | 18 | 23) - .then_some(byte == b'-') - .unwrap_or_else(|| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) - }) - || Uuid::parse_str(uuid).ok().is_none_or(|parsed| { - parsed.hyphenated().to_string() != uuid || parsed.get_variant() != Variant::RFC4122 - }) - { - return Err(invalid(format!("{label} 不是小写 RFC 4122 UUID"))); - } - Ok(()) -} - -pub(crate) fn validate_action_id(value: &str, label: &str) -> Result<(), PlanningStorageError> { - let Some(digest) = value.strip_prefix("action-") else { - return Err(invalid(format!("{label} 必须以 action- 开头"))); - }; - if !is_lower_hex(digest, 24) { - return Err(invalid(format!("{label} 不是合法 actionId"))); - } - Ok(()) -} -pub(crate) fn validate_timestamp(value: &str, label: &str) -> Result<(), PlanningStorageError> { - validate_text(value, label, 24, 24)?; - if value.len() != 24 || !value.is_ascii() { - return Err(invalid(format!("{label} 必须是 ASCII UTC 毫秒时间"))); - } - let bytes = value.as_bytes(); - let punctuation = [ - (4, b'-'), - (7, b'-'), - (10, b'T'), - (13, b':'), - (16, b':'), - (19, b'.'), - (23, b'Z'), - ]; - if punctuation - .iter() - .any(|(index, expected)| bytes[*index] != *expected) - || bytes.iter().enumerate().any(|(index, byte)| { - !punctuation - .iter() - .any(|(punctuation_index, _)| *punctuation_index == index) - && !byte.is_ascii_digit() - }) - { - return Err(invalid(format!("{label} 必须是 UTC 毫秒时间"))); - } - let year = value[0..4] - .parse::() - .map_err(|_| invalid(format!("{label} 年份非法")))?; - let month = value[5..7] - .parse::() - .map_err(|_| invalid(format!("{label} 月份非法")))?; - let day = value[8..10] - .parse::() - .map_err(|_| invalid(format!("{label} 日期非法")))?; - let hour = value[11..13] - .parse::() - .map_err(|_| invalid(format!("{label} 小时非法")))?; - let minute = value[14..16] - .parse::() - .map_err(|_| invalid(format!("{label} 分钟非法")))?; - let second = value[17..19] - .parse::() - .map_err(|_| invalid(format!("{label} 秒非法")))?; - let millis = value[20..23] - .parse::() - .map_err(|_| invalid(format!("{label} 毫秒非法")))?; - if !(1..=12).contains(&month) || hour > 23 || minute > 59 || second > 59 || millis > 999 { - return Err(invalid(format!("{label} 的 UTC 日期时间分量越界"))); - } - let leap_year = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); - let days_in_month = match month { - 2 if leap_year => 29, - 2 => 28, - 4 | 6 | 9 | 11 => 30, - _ => 31, - }; - if day == 0 || day > days_in_month { - return Err(invalid(format!("{label} 的日期分量越界"))); - } - Ok(()) -} -fn validate_decision_state(value: &str) -> Result<(), PlanningStorageError> { - if matches!(value, "confirmed" | "default_pending" | "prototype_pending") { - Ok(()) - } else { - Err(invalid(format!("未知 decision state:{value}"))) - } -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanGddGame { - pub(crate) title: String, - pub(crate) genre: PlanGenre, - pub(crate) art_style: PlanArtStyle, - pub(crate) one_liner: String, - pub(crate) pillars: Vec, - pub(crate) core_loop: Vec, - pub(crate) target_users: PlanTargetUsers, - pub(crate) platform_facts: PlanPlatformFacts, - pub(crate) mvp_systems: Vec, - pub(crate) out_of_scope: Vec, - pub(crate) creator_tips: PlanCreatorTips, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanGenre { - pub(crate) primary: String, - pub(crate) fusion: Option, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanArtStyle { - pub(crate) visual_type: String, - pub(crate) keywords: Vec, - pub(crate) mood_and_color: String, - pub(crate) mvp_art_boundary: String, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanPillar { - pub(crate) name: String, - pub(crate) player_feel: String, - pub(crate) mechanism: String, - pub(crate) decision_state: String, - pub(crate) basis: Option<()>, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanTargetUsers { - pub(crate) core_users: String, - pub(crate) preferences: String, - pub(crate) session_length: String, - pub(crate) reference_games: Vec, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanPlatformFacts { - pub(crate) runtime: String, - pub(crate) viewports: Vec, - pub(crate) inputs: Vec, - pub(crate) preview: String, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanMvpSystem { - pub(crate) system: String, - pub(crate) minimal_function: String, - pub(crate) why_required: String, - pub(crate) verify_method: String, - pub(crate) decision_state: String, - pub(crate) basis: Option<()>, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanCreatorTips { - pub(crate) do_first: String, - pub(crate) defer_for_now: String, - pub(crate) how_to_verify: String, - pub(crate) expand_when: String, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanDecision { - pub(crate) id: String, - pub(crate) topic: String, - pub(crate) state: String, - pub(crate) answer_source: String, - pub(crate) round: u32, - pub(crate) answer_summary: String, - pub(crate) basis: Option<()>, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanPrototypeValidationItem { - pub(crate) id: String, - pub(crate) question: String, - pub(crate) micro_prototype: String, - pub(crate) observation: String, - pub(crate) pass_criterion: String, -} -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanSubmitGame { - pub(crate) title: String, - pub(crate) genre: PlanGenre, - pub(crate) art_style: PlanArtStyle, - pub(crate) one_liner: String, - pub(crate) pillars: Vec, - pub(crate) core_loop: Vec, - pub(crate) target_users: PlanTargetUsers, - pub(crate) mvp_systems: Vec, - pub(crate) out_of_scope: Vec, - pub(crate) creator_tips: PlanCreatorTips, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanSubmitPillar { - pub(crate) name: String, - pub(crate) player_feel: String, - pub(crate) mechanism: String, - pub(crate) decision_state: String, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanSubmitMvpSystem { - pub(crate) system: String, - pub(crate) minimal_function: String, - pub(crate) why_required: String, - pub(crate) verify_method: String, - pub(crate) decision_state: String, -} - -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub(crate) struct PlanSubmitDecision { - pub(crate) id: String, - pub(crate) topic: String, - pub(crate) state: String, - pub(crate) answer_source: String, - pub(crate) round: u32, - pub(crate) answer_summary: String, -} -fn validate_plan_platform_facts(value: &PlanPlatformFacts) -> Result<(), PlanningStorageError> { - if value.runtime != "self-contained-web" - || value.viewports != ["desktop", "mobile"] - || value.inputs != ["keyboard", "touch"] - || value.preview != "local-http" - { - return Err(invalid("platformFacts 必须是 Runtime 固定的平台事实")); - } - Ok(()) -} - -pub(crate) fn fixed_plan_platform_facts() -> PlanPlatformFacts { - PlanPlatformFacts { - runtime: "self-contained-web".to_string(), - viewports: vec!["desktop".to_string(), "mobile".to_string()], - inputs: vec!["keyboard".to_string(), "touch".to_string()], - preview: "local-http".to_string(), - } -} - -pub(crate) fn validate_plan_game(game: &PlanGddGame) -> Result<(), PlanningStorageError> { - validate_text(&game.title, "game.title", 1, 80)?; - validate_text(&game.genre.primary, "game.genre.primary", 1, 80)?; - if let Some(fusion) = &game.genre.fusion { - validate_text(fusion, "game.genre.fusion", 1, 80)?; - } - validate_text( - &game.art_style.visual_type, - "game.artStyle.visualType", - 1, - 80, - )?; - if !(1..=6).contains(&game.art_style.keywords.len()) { - return Err(invalid("game.artStyle.keywords 必须有 1~6 项")); - } - for (index, keyword) in game.art_style.keywords.iter().enumerate() { - validate_text(keyword, &format!("game.artStyle.keywords[{index}]"), 1, 64)?; - } - validate_text( - &game.art_style.mood_and_color, - "game.artStyle.moodAndColor", - 1, - 1000, - )?; - validate_text( - &game.art_style.mvp_art_boundary, - "game.artStyle.mvpArtBoundary", - 1, - 1200, - )?; - // 设计约定:Runtime 接受范围为 10~160,模型 schema 提示范围收紧为 25~90; - // 两者有意不对称,用于避免过短概念同时保留对已有/人工 GDD 的兼容,不是缺陷或 bug。 - validate_text(&game.one_liner, "game.oneLiner", 10, 160)?; - - if !(1..=6).contains(&game.pillars.len()) { - return Err(invalid("game.pillars 必须有 1~6 条")); - } - for (index, pillar) in game.pillars.iter().enumerate() { - validate_text(&pillar.name, &format!("game.pillars[{index}].name"), 1, 80)?; - validate_text( - &pillar.player_feel, - &format!("game.pillars[{index}].playerFeel"), - 1, - 400, - )?; - validate_text( - &pillar.mechanism, - &format!("game.pillars[{index}].mechanism"), - 1, - 400, - )?; - validate_decision_state(&pillar.decision_state)?; - if pillar.basis.is_some() { - return Err(invalid("v1 的 pillar.basis 必须为 null")); - } - } - - if !(1..=8).contains(&game.core_loop.len()) { - return Err(invalid("game.coreLoop 必须有 1~8 步")); - } - for (index, step) in game.core_loop.iter().enumerate() { - validate_text(step, &format!("game.coreLoop[{index}]"), 1, 400)?; - } - - validate_text( - &game.target_users.core_users, - "game.targetUsers.coreUsers", - 1, - 400, - )?; - validate_text( - &game.target_users.preferences, - "game.targetUsers.preferences", - 1, - 400, - )?; - validate_text( - &game.target_users.session_length, - "game.targetUsers.sessionLength", - 1, - 400, - )?; - if game.target_users.reference_games.len() > 8 { - return Err(invalid("game.targetUsers.referenceGames 最多 8 项")); - } - for (index, reference) in game.target_users.reference_games.iter().enumerate() { - validate_text( - reference, - &format!("game.targetUsers.referenceGames[{index}]"), - 1, - 80, - )?; - } - validate_plan_platform_facts(&game.platform_facts)?; - - if !(1..=8).contains(&game.mvp_systems.len()) { - return Err(invalid("game.mvpSystems 必须有 1~8 项")); - } - for (index, system) in game.mvp_systems.iter().enumerate() { - validate_text( - &system.system, - &format!("game.mvpSystems[{index}].system"), - 1, - 80, - )?; - validate_text( - &system.minimal_function, - &format!("game.mvpSystems[{index}].minimalFunction"), - 1, - 400, - )?; - validate_text( - &system.why_required, - &format!("game.mvpSystems[{index}].whyRequired"), - 1, - 400, - )?; - validate_text( - &system.verify_method, - &format!("game.mvpSystems[{index}].verifyMethod"), - 1, - 400, - )?; - validate_decision_state(&system.decision_state)?; - if system.basis.is_some() { - return Err(invalid("v1 的 mvpSystem.basis 必须为 null")); - } - } - if game.out_of_scope.len() > 12 { - return Err(invalid("game.outOfScope 必须有 0~12 项")); - } - for (index, item) in game.out_of_scope.iter().enumerate() { - validate_text(item, &format!("game.outOfScope[{index}]"), 1, 80)?; - } - validate_text( - &game.creator_tips.do_first, - "game.creatorTips.doFirst", - 1, - 1000, - )?; - validate_text( - &game.creator_tips.defer_for_now, - "game.creatorTips.deferForNow", - 1, - 1000, - )?; - validate_text( - &game.creator_tips.how_to_verify, - "game.creatorTips.howToVerify", - 1, - 1000, - )?; - validate_text( - &game.creator_tips.expand_when, - "game.creatorTips.expandWhen", - 1, - 1000, - )?; - Ok(()) -} -/// Resolve a planning path and classify a linked path component as untrusted -/// rather than merely invalid. The generic resolver folds "component is a -/// symlink" into the same opaque string as every other path error, so mapping -/// its failure wholesale to `PLAN_INVALID_PATH` misreports an untrusted target; -/// `ensure_planning_parent` and `verify_regular_planning_file` already classify -/// links as `PLAN_UNTRUSTED_PATH`, and this keeps the whole module consistent. -fn resolve_planning_path( - root: &Path, - relative_path: &str, -) -> Result { - let normalized = normalize_relative_path(relative_path) - .map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error))?; - let mut path = root.to_path_buf(); - for part in normalized.split('/') { - path.push(part); - // 只判定链接/重解析点;缺失组件与真实 IO 错误交给通用解析器,保持既有分类。 - if fs::symlink_metadata(&path) - .is_ok_and(|metadata| planning_metadata_is_link_or_reparse(&metadata)) - { - return Err(PlanningStorageError::new( - "PLAN_UNTRUSTED_PATH", - format!("规划路径组件不能是链接或重解析点:{}", path.display()), - )); - } - } - resolve_local_project_path(root, relative_path) - .map_err(|error| PlanningStorageError::new("PLAN_INVALID_PATH", error)) -} - -fn planning_metadata_is_link_or_reparse(metadata: &fs::Metadata) -> bool { - if metadata.file_type().is_symlink() { - return true; - } - #[cfg(windows)] - { - use std::os::windows::fs::MetadataExt; - const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400; - return metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0; - } - #[cfg(not(windows))] - { - false - } -} -#[cfg(unix)] -fn open_planning_parent_directory(parent: &Path) -> Result { - use std::os::unix::fs::OpenOptionsExt; - OpenOptions::new() - .read(true) - .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC) - .open(parent) - .map_err(|error| io_error("打开 planning 父目录失败", error)) -} - -#[cfg(windows)] -fn open_planning_parent_directory(parent: &Path) -> Result { - use std::os::windows::fs::OpenOptionsExt; - let directory = OpenOptions::new() - .read(true) - // FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS; the - // latter is required for opening a directory handle on Windows. - .custom_flags(0x0020_0000 | 0x0200_0000) - .open(parent) - .map_err(|error| io_error("打开 planning 父目录失败", error))?; - let metadata = directory - .metadata() - .map_err(|error| io_error("读取 planning 父目录句柄失败", error))?; - if planning_metadata_is_link_or_reparse(&metadata) || !metadata.is_dir() { - return Err(PlanningStorageError::new( - "PLAN_UNTRUSTED_PATH", - format!("planning 父目录句柄不是可信普通目录:{}", parent.display()), - )); - } - Ok(directory) -} - -#[cfg(not(any(unix, windows)))] -fn open_planning_parent_directory(parent: &Path) -> Result { - File::open(parent).map_err(|error| io_error("打开 planning 父目录失败", error)) -} - -fn verify_regular_planning_file( - path: &Path, - label: &str, -) -> Result { - let metadata = fs::symlink_metadata(path) - .map_err(|error| io_error(&format!("读取 {label} 元数据失败"), error))?; - if planning_metadata_is_link_or_reparse(&metadata) || !metadata.is_file() { - return Err(PlanningStorageError::new( - "PLAN_UNTRUSTED_PATH", - format!("{label} 必须是可信普通文件:{}", path.display()), - )); - } - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - if metadata.nlink() != 1 { - return Err(PlanningStorageError::new( - "PLAN_UNTRUSTED_PATH", - format!("{label} 不能是硬链接文件:{}", path.display()), - )); - } - } - #[cfg(windows)] - { - use std::os::windows::fs::OpenOptionsExt; - const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; - let file = OpenOptions::new() - .read(true) - .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT) - .open(path) - .map_err(|error| io_error(&format!("打开 {label} 句柄失败"), error))?; - crate::runner::validate_windows_regular_file_handle(&file, label) - .map_err(|error| PlanningStorageError::new("PLAN_UNTRUSTED_PATH", error))?; - } - Ok(metadata) -} - -fn read_regular_planning_file(path: &Path, label: &str) -> Result, PlanningStorageError> { - let metadata = verify_regular_planning_file(path, label)?; - if metadata.len() > PLAN_INDEX_MAX_BYTES as u64 { - return Err(PlanningStorageError::new( - "PLAN_SIZE_LIMIT", - format!("{label} 超过 {} 字节读取上限", PLAN_INDEX_MAX_BYTES), - )); - } - let mut options = OpenOptions::new(); - options.read(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC); - } - #[cfg(windows)] - { - use std::os::windows::fs::OpenOptionsExt; - const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000; - // The final open must inspect the directory entry itself. Without - // OPEN_REPARSE_POINT a junction/symlink can be followed before the - // handle validator sees the target's (apparently regular) attributes. - options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT); - } - let mut file = options - .open(path) - .map_err(|error| io_error(&format!("打开 {label} 失败"), error))?; - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - let opened_metadata = file - .metadata() - .map_err(|error| io_error(&format!("复核 {label} 句柄元数据失败"), error))?; - if opened_metadata.dev() != metadata.dev() - || opened_metadata.ino() != metadata.ino() - || opened_metadata.nlink() != 1 - { - return Err(PlanningStorageError::new( - "PLAN_RECONCILIATION_REQUIRED", - format!("打开 {label} 时文件身份发生漂移"), - )); - } - } - #[cfg(windows)] - crate::runner::validate_windows_regular_file_handle(&file, label) - .map_err(|error| PlanningStorageError::new("PLAN_UNTRUSTED_PATH", error))?; - let mut bytes = Vec::with_capacity(metadata.len().min(PLAN_INDEX_MAX_BYTES as u64) as usize); - (&mut file) - .take((PLAN_INDEX_MAX_BYTES as u64).saturating_add(1)) - .read_to_end(&mut bytes) - .map_err(|error| io_error(&format!("读取 {label} 失败"), error))?; - if bytes.len() > PLAN_INDEX_MAX_BYTES { - return Err(PlanningStorageError::new( - "PLAN_SIZE_LIMIT", - format!("{label} 超过 {} 字节读取上限", PLAN_INDEX_MAX_BYTES), - )); - } - let final_metadata = file - .metadata() - .map_err(|error| io_error(&format!("复核 {label} 元数据失败"), error))?; - if final_metadata.len() != bytes.len() as u64 || final_metadata.len() != metadata.len() { - return Err(PlanningStorageError::new( - "PLAN_RECONCILIATION_REQUIRED", - format!("读取 {label} 时文件发生漂移"), - )); - } - Ok(bytes) -} - -fn sync_planning_parent(parent: &Path) -> Result<(), PlanningStorageError> { - #[cfg(unix)] - File::open(parent) - .and_then(|directory| directory.sync_all()) - .map_err(|error| io_error("同步 planning 父目录失败", error))?; - #[cfg(windows)] - { - let directory = open_planning_parent_directory(parent)?; - if let Err(error) = directory.sync_all() { - // MoveFileExW(MOVEFILE_WRITE_THROUGH) already flushes the file - // publication on Windows. Some Windows filesystems reject - // FlushFileBuffers on a directory handle with ACCESS_DENIED or - // INVALID_FUNCTION; keep the stronger native flush as the - // fallback rather than making every durable write unusable there. - if !matches!(error.raw_os_error(), Some(1 | 5 | 6)) { - return Err(io_error("同步 planning 父目录失败", error)); - } - } - } - #[cfg(not(any(unix, windows)))] - let _ = parent; - Ok(()) -} - -fn temp_planning_path(parent: &Path, target: &Path) -> PathBuf { - let nonce = PLANNING_TEMP_NONCE.fetch_add(1, Ordering::Relaxed); - let random = Uuid::new_v4(); - let file_name = target - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("planning.json"); - parent.join(format!( - ".{file_name}.tmp-{}-{nonce}-{random}", - std::process::id() - )) -} - -fn write_sync_new_file(path: &Path, bytes: &[u8], label: &str) -> Result<(), PlanningStorageError> { - if bytes.len() > PLAN_INDEX_MAX_BYTES { - return Err(PlanningStorageError::new( - "PLAN_SIZE_LIMIT", - format!("{label} 临时内容超过 {} 字节上限", PLAN_INDEX_MAX_BYTES), - )); - } - let result = (|| { - let mut options = OpenOptions::new(); - options.read(true).write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - options - .mode(0o600) - .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC); - } - let mut file = options - .open(path) - .map_err(|error| io_error(&format!("创建 {label} 临时文件失败"), error))?; - #[cfg(windows)] - crate::runner::validate_windows_regular_file_handle(&file, label) - .map_err(|error| PlanningStorageError::new("PLAN_UNTRUSTED_PATH", error))?; - file.write_all(bytes) - .and_then(|_| file.sync_all()) - .map_err(|error| io_error(&format!("写入 {label} 临时文件失败"), error))?; - #[cfg(unix)] - { - use std::os::unix::fs::MetadataExt; - let metadata = file - .metadata() - .map_err(|error| io_error(&format!("复核 {label} 临时文件句柄失败"), error))?; - if !metadata.is_file() || metadata.nlink() != 1 { - return Err(PlanningStorageError::new( - "PLAN_UNTRUSTED_PATH", - format!("{label} 临时文件句柄不是唯一普通文件"), - )); - } - } - file.seek(SeekFrom::Start(0)) - .map_err(|error| io_error(&format!("定位 {label} 临时文件回读位置失败"), error))?; - let mut check = Vec::new(); - file.read_to_end(&mut check) - .map_err(|error| io_error(&format!("回读 {label} 临时文件失败"), error))?; - if check != bytes { - return Err(PlanningStorageError::new( - "PLAN_RECONCILIATION_REQUIRED", - format!("{label} 临时文件回读不一致"), - )); - } - Ok(()) - })(); - if result.is_err() { - let _ = fs::remove_file(path); - } - result -} - -pub(crate) fn write_plan_fast_gdd_markdown_atomic_locked( - root: &Path, - markdown: &str, -) -> Result<(), PlanningStorageError> { - let bytes = markdown.as_bytes(); - if bytes.is_empty() { - return Err(invalid("Fast GDD Markdown 不能为空")); - } - if bytes.len() > PLAN_FAST_GDD_MAX_BYTES { - return Err(PlanningStorageError::new( - "PLAN_SIZE_LIMIT", - format!( - "Fast GDD Markdown 超过 {} 字节上限", - PLAN_FAST_GDD_MAX_BYTES - ), - )); - } - if bytes.contains(&0) || !std::str::from_utf8(bytes).is_ok() { - return Err(invalid("Fast GDD Markdown 必须是无 NUL 的 UTF-8 文本")); - } - - let target = resolve_planning_path(root, PLAN_FAST_GDD_PATH)?; - let parent = target - .parent() - .ok_or_else(|| PlanningStorageError::new("PLAN_INVALID_PATH", "Fast GDD 缺少父目录"))?; - - // The projection lives outside `.agent/planning`, so it cannot use the - // planning-only parent helper. Build the relative `game/` directory one - // component at a time and reject links/reparse points at every step. - let root_metadata = - fs::symlink_metadata(root).map_err(|error| io_error("读取项目根目录失败", error))?; - if planning_metadata_is_link_or_reparse(&root_metadata) || !root_metadata.is_dir() { - return Err(PlanningStorageError::new( - "PLAN_UNTRUSTED_PATH", - "项目根目录必须是可信普通目录", - )); - } - let mut cursor = root.to_path_buf(); - let relative_parent = parent - .strip_prefix(root) - .map_err(|_| PlanningStorageError::new("PLAN_INVALID_PATH", "Fast GDD 父目录越出项目根"))?; - for component in relative_parent.components() { - use std::path::Component; - let Component::Normal(component) = component else { - return Err(PlanningStorageError::new( - "PLAN_INVALID_PATH", - "Fast GDD 父目录组件非法", - )); - }; - cursor.push(component); - match fs::symlink_metadata(&cursor) { - Ok(metadata) => { - if planning_metadata_is_link_or_reparse(&metadata) || !metadata.is_dir() { - return Err(PlanningStorageError::new( - "PLAN_UNTRUSTED_PATH", - "Fast GDD 父目录必须是可信普通目录", - )); - } - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - fs::create_dir(&cursor) - .map_err(|error| io_error("创建 Fast GDD 父目录失败", error))?; - let metadata = fs::symlink_metadata(&cursor) - .map_err(|error| io_error("复核 Fast GDD 父目录失败", error))?; - if planning_metadata_is_link_or_reparse(&metadata) || !metadata.is_dir() { - return Err(PlanningStorageError::new( - "PLAN_UNTRUSTED_PATH", - "新建 Fast GDD 父目录不是可信普通目录", - )); - } - } - Err(error) => return Err(io_error("读取 Fast GDD 父目录失败", error)), - } - } - - match fs::symlink_metadata(&target) { - Ok(_) => { - verify_regular_planning_file(&target, "现有 Fast GDD Markdown")?; - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => return Err(io_error("读取现有 Fast GDD Markdown 失败", error)), - } - - let temporary = temp_planning_path(parent, &target); - let result = (|| { - write_sync_new_file(&temporary, bytes, "Fast GDD Markdown")?; - verify_replace_target_is_safe(&target, "Fast GDD Markdown")?; - replace_planning_file_atomically(&temporary, &target, "Fast GDD Markdown")?; - let published = read_regular_planning_file(&target, "已发布 Fast GDD Markdown")?; - if published != bytes { - return Err(PlanningStorageError::new( - "PLAN_RECONCILIATION_REQUIRED", - "Fast GDD Markdown 发布后内容不一致", - )); - } - sync_planning_parent(parent) - })(); - if temporary.exists() { - let cleanup = fs::remove_file(&temporary) - .map_err(|error| io_error("清理 Fast GDD 临时文件失败", error)); - if result.is_ok() { - cleanup?; - } - } - result -} - -fn replace_planning_file_atomically( - temporary: &Path, - target: &Path, - label: &str, -) -> Result<(), PlanningStorageError> { - #[cfg(not(windows))] - { - fs::rename(temporary, target) - .map_err(|error| io_error(&format!("原子替换 {label} 失败"), error))?; - } - #[cfg(windows)] - { - use std::os::windows::ffi::OsStrExt; - use windows_sys::Win32::Storage::FileSystem::{ - MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH, - }; - let from = temporary - .as_os_str() - .encode_wide() - .chain(std::iter::once(0)) - .collect::>(); - let to = target - .as_os_str() - .encode_wide() - .chain(std::iter::once(0)) - .collect::>(); - let result = unsafe { - MoveFileExW( - from.as_ptr(), - to.as_ptr(), - MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH, - ) - }; - if result == 0 { - return Err(io_error( - &format!("原子替换 {label} 失败"), - std::io::Error::last_os_error(), - )); - } - } - Ok(()) -} - -fn verify_replace_target_is_safe(target: &Path, label: &str) -> Result<(), PlanningStorageError> { - match fs::symlink_metadata(target) { - Ok(_) => { - verify_regular_planning_file(target, &format!("现有 {label}"))?; - Ok(()) - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(io_error(&format!("读取现有 {label} 目标失败"), error)), - } -} - -/// Return the fixed UTC millisecond timestamp used by Runtime-owned planning -/// projections. Keeping this helper here makes tests able to inject a fixed -/// timestamp while production callers can use the same formatting contract. -pub(crate) fn current_plan_timestamp_utc() -> String { - let millis = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis(); - let seconds = millis / 1_000; - let millis_part = millis % 1_000; - let days = seconds / 86_400; - let day_seconds = seconds % 86_400; - let hour = day_seconds / 3_600; - let minute = (day_seconds % 3_600) / 60; - let second = day_seconds % 60; - - // Civil-from-days, Gregorian calendar (Howard Hinnant algorithm). - let z = days as i64 + 719_468; - let era = if z >= 0 { z } else { z - 146_096 } / 146_097; - let doe = z - era * 146_097; - let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; - let year = yoe + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let day = doy - (153 * mp + 2) / 5 + 1; - let month = mp + if mp < 10 { 3 } else { -9 }; - let year = year + i64::from(month <= 2); - format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{millis_part:03}Z") -}