Compare commits

..

14 Commits

Author SHA1 Message Date
lhk229 11c8cbdf67 Merge pull request '修复 AGC Direct 写通道项目锁零等待与持锁方不可诊断' (#320) from fix/issue-318-direct-write-lock-wait into master
Project CI / Repository checks (push) Successful in 3m7s
Project CI / Frontend tests (push) Successful in 3m41s
Project CI / Backend tests (push) Successful in 5m48s
Project CI / Native shell tests (push) Successful in 17m34s
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/320
2026-09-10 21:43:37 +08:00
suzmii ff42fff61c 按评审意见把写锁等待挪出 runtime worker 并收紧 wait_exhausted 记账
Project CI / Repository checks (pull_request) Successful in 3m18s
Project CI / Frontend tests (pull_request) Successful in 4m3s
Project CI / Backend tests (pull_request) Successful in 7m25s
Project CI / Native shell tests (pull_request) Successful in 19m35s
- agc_write_file 写路径改经 bridge_write_file_in_blocking_pool 走 tokio::task::spawn_blocking:有界等待是同步轮询(最多约 10 秒),直接在 async handler 里跑会占住 tokio worker,争用窗口内同一轮并行写多个文件时会波及共享同一 runtime 的只读端点与 UI 命令
- 新增用例用默认 current_thread runtime 加心跳任务锁住该性质;把 handler 临时改回同步直调时该用例按预期失败,确认有区分度
- project.write_lock.wait_exhausted 与终态改判一起改为只在真的等过(max_attempts > 1)时发生:hydrate 的单次试探不再写 waitedMs 近似 0 的“耗尽”日志
- 技术方案、decision-log、pitfalls 同步这两条,并补“同步有界等待不能直接跑在 async handler 里”的排障经验
2026-09-10 21:38:48 +08:00
lhk229 e8c5d2e247 Merge branch 'master' into fix/issue-318-direct-write-lock-wait
Project CI / Repository checks (pull_request) Successful in 2m58s
Project CI / Frontend tests (pull_request) Successful in 3m45s
Project CI / Backend tests (pull_request) Successful in 7m5s
Project CI / Native shell tests (pull_request) Successful in 17m13s
2026-09-10 21:13:23 +08:00
lhk229 ab86efa421 Merge pull request '停止追踪误提交的本地文档' (#323) from rm/local-docs into master
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Reviewed-on: https://git.genarrative.world/git/GenarrativeAI/Genarrative/pulls/323
2026-09-10 21:12:38 +08:00
lhk229 a12d18c1cd 停止追踪误提交的本地文档
Project CI / Repository checks (pull_request) Successful in 2m30s
Project CI / Frontend tests (pull_request) Successful in 3m19s
Project CI / Backend tests (pull_request) Successful in 6m6s
Project CI / Native shell tests (pull_request) Successful in 18m2s
从仓库索引移除 local-docs 下的 11 份本地过程文档
保留当前工作区本地文件,不新增或修改忽略规则
2026-09-10 13:00:26 +00:00
suzmii 71e9ad3133 修复锁失败终态判据漏传平台导致 CI 把权限改判成争用
Project CI / Repository checks (pull_request) Successful in 6m17s
Project CI / Frontend tests (pull_request) Successful in 8m23s
Project CI / Backend tests (pull_request) Successful in 12m7s
Project CI / Native shell tests (pull_request) Successful in 25m6s
- project_write_lock_permission_is_ambiguous 改为按平台加原始错误码判定(Windows 的 ACCESS_DENIED(5));只看 ErrorKind 在 Linux 上会得出相反结论:errno 5 在 Windows 是 ACCESS_DENIED、在 Linux 是 EIO
- ProjectWriteLockFailure::Retryable 携带 platform,终态改判与争用文案都用同一次分类的平台,不再依赖宿主 errno 语义
- 终态投影用例显式用 Windows 平台构造失败并补 Unix 反例,两个平台上结论一致;CI 首次推送正是在此失败(2358 passed / 1 failed,left contention / right permission_denied)
- pitfalls 补充“判据的每一环都要带平台”的排障经验
2026-09-10 20:46:10 +08:00
suzmii 06f738dc99 将项目写锁从 project/filesystem.rs 纯搬移到 project/write_lock.rs
Project CI / Repository checks (pull_request) Successful in 2m37s
Project CI / Frontend tests (pull_request) Successful in 3m30s
Project CI / Backend tests (pull_request) Successful in 6m36s
Project CI / Native shell tests (pull_request) Failing after 13m57s
- 新建 project/write_lock.rs:取锁、等待分类、持锁方诊断、残留回收与 4 条锁用例整体搬移,逻辑不变
- project/filesystem.rs 只保留项目文件 IO(1533 → 680 行),锁相关常量、结构、进程判据与用例全部移出
- project.rs 注册 mod write_lock 并 pub(crate) use write_lock::*,crate::project:: 与 crate:: 既有路径不变
- windows_metadata_is_reparse_point 提为 pub(crate),供 write_lock 复用同一条 reparse point 判据
- agent_db.rs 与 checkpoint.rs 的 PROJECT_FILE_FLAG_OPEN_REPARSE_POINT 导入路径改为 super::write_lock
- 同步修正技术方案、Fast GDD 技术方案、decision-log、pitfalls 中指向锁实现的文件路径,并把“拆锁”从后续事项改为已完成
2026-09-10 20:12:28 +08:00
suzmii 8c639e5d13 修复项目写锁重试判据用一次元数据观察误判瞬时争用
- 项目写锁分类改为只按错误码判定重试性,不再用 path.exists() 决定“要不要等”:真机 6 万次建锁/删锁竞争实测 396-538 例命中“ACCESS_DENIED(5) + 目标不可见”,旧判据会让等待层立刻失败关闭,把毫秒级竞争换成更误导的 ACL 文案
- 新增 ProjectWriteLockFailure(Retryable / Terminal)与 acquire_project_write_lock_failure,有界等待改按类型分流,acquire_project_write_lock 退化为它的文案包装
- Windows 的 ACCESS_DENIED(5) 终态改判移到等待预算耗尽之后:只有真的等过预算且目标此刻仍不存在时才投影成权限拒绝,单次试探保持争用语义
- 锁分类判据改为平台参数传入(project_write_lock_open_failure_for),Linux CI 可覆盖 Windows 分支;替换原先只在 Windows 本地执行的分类用例
- 零等待入口在错误码不可区分时补一句“可能是删除挂起、删除拆链窗口或权限 / ACL 拒绝”,争用前缀逐字不变,调用方既有重试语义不受影响
- PROJECT_WRITE_LOCK_CONTENTION_PREFIX 收口 provider_recovery.rs、planning_session_v2.rs、direct_runtime.rs 三处手写文案
- project.write_lock.wait_exhausted 日志补 projection= 分类,attempts 记为实际尝试次数
- 同步更新技术方案、decision-log、pitfalls,并修正 ownerIsSelf 只比 PID 的表述
2026-09-10 20:07:28 +08:00
suzmii 59cab3d9fd Merge remote-tracking branch 'origin/master' into fix/issue-318-write-lock-classification 2026-09-10 19:58:05 +08:00
lhk229 89447ed432 按评审意见强化回退模板读取通道回归测试
Project CI / Repository checks (pull_request) Successful in 2m47s
Project CI / Frontend tests (pull_request) Successful in 3m44s
Project CI / Backend tests (pull_request) Successful in 7m1s
Project CI / Native shell tests (pull_request) Successful in 18m11s
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
- tests/configuration.rs 回退模板测试改用与模板无关的 runtime dir,分类断言覆盖按路径归属而非 runtime dir 为 None 的恒真分支,并补充 runtime dir 内路径的 managed 正向断言
- tests/mod.rs 移除不再使用的 clear_test_runtime_config_dir 辅助
2026-09-10 09:46:48 +00:00
lhk229 3ebfcc0c2f 修正新增配置读取回归测试的 Rust 格式
Project CI / Repository checks (pull_request) Successful in 2m16s
Project CI / Frontend tests (pull_request) Successful in 3m8s
Project CI / Backend tests (pull_request) Successful in 7m2s
Project CI / Native shell tests (pull_request) Successful in 18m27s
- tests/configuration.rs 按 cargo fmt 调整两个 assert! 宏换行
2026-09-10 09:03:19 +00:00
lhk229 9b5d1fe107 修复仓库回退配置模板被读取通道私有化 ACL 锁定
Project CI / Frontend tests (pull_request) Successful in 3m30s
Project CI / Backend tests (pull_request) Successful in 6m27s
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
- config.rs 新增 game_creator_config_path_is_runtime_managed 判定,read_game_creator_config_file 按路径归属分流:AppData 托管目录内的真实凭据维持私有加固读取,仓库旁边的回退模板与 local 覆盖改用 open_project_snapshot_regular_file 非变异快照通道
- 根因:开发 CLI 无 AppHandle 时回退读取 worktree 内 git 跟踪模板,私有读通道在 Windows 上无条件收紧 DACL 为仅当前进程用户,导致其他账号与 cargo include_str! 全部 Access Denied
- tests/mod.rs 新增 clear_test_runtime_config_dir 辅助
- tests/configuration.rs 新增两个回归测试锁定快照 / 私有两条读取通道的分流
- pitfalls.md 记录该排障经验与读取通道副作用白名单教训
2026-09-10 08:57:31 +00:00
suzmii 4ecab19429 修正 CI 权限用例在 root 容器下的前提并补平台无关的分类判据
Project CI / Repository checks (pull_request) Successful in 3m5s
Project CI / Frontend tests (pull_request) Successful in 4m2s
Project CI / Backend tests (pull_request) Successful in 6m51s
Project CI / Native shell tests (pull_request) Successful in 19m16s
- project_write_lock_does_not_project_permission_denial_as_contention 不再用 expect_err 断言“只读目录必须挡住取锁”:CI 容器以 root 运行,0o500 不生效,取锁会正常成功;此时跳过端到端前提
- 新增平台无关用例 project_write_lock_classifies_by_whether_the_target_exists:目标存在才是争用、目标不存在却创建失败是权限拒绝、NotFound 归其它
- 让权限分类判据在不依赖 ACL 环境的条件下也有回归护栏,避免只靠会被 root 绕过的端到端用例
2026-09-10 16:52:30 +08:00
suzmii 81c2389132 修复 AGC Direct 写通道项目锁零等待与持锁方不可诊断
Project CI / Repository checks (pull_request) Successful in 3m9s
Project CI / Frontend tests (pull_request) Successful in 4m7s
Project CI / Backend tests (pull_request) Successful in 6m54s
Project CI / Native shell tests (pull_request) Failing after 14m11s
- Direct 写路径(agc_write_file)改用统一的有界等待,与 file.write / file.patch / file.delete 同语义,同一轮并行写多个文件按同一把锁串行,不再在 24-42ms 内把重叠判成"项目正在被其他写操作占用"
- create_new 失败拆成争用 / 权限拒绝 / 其它三类:锁文件不存在却仍创建失败不再投影成争用;sharing violation(32) 与 lock violation(33) 恒定归争用
- 争用错误与等待日志带上持锁方身份(commandId / pid / createdAt / ownerIsSelf),锁文件处于删除挂起或未写完时显式表达成"身份不可读"
- ProjectWriteLockSnapshot 补 commandId 与 describe_holder(),沿用既有快照 + 字节 CAS 回收机制,不新增第二套回收判据
- 有界等待预算耗尽记 project.write_lock.wait_exhausted(含等待毫秒数),权限拒绝记 project.write_lock.permission_denied,争用不在零等待入口里逐次记账
- 新增同进程重叠写等待、同轮并行写、活外部进程持锁带身份、权限拒绝分类、ACL 拒绝不投影成争用五条回归用例
- 同步 decision-log、pitfalls 与技术方案文档
2026-09-10 16:30:22 +08:00
159 changed files with 33975 additions and 25032 deletions
@@ -1,7 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main",
"description": "AI 游戏创作主窗口允许读取系统剪贴板图片,用于粘贴素材附件;允许弹出原生打开/保存对话框用于素材上传与导出。",
"description": "AI 游戏创作主窗口允许读取系统剪贴板图片,用于粘贴素材附件。",
"windows": ["client"],
"permissions": [
"clipboard-manager:allow-read-image",
@@ -21,7 +21,6 @@
]
},
"opener:default",
"dialog:allow-open",
"dialog:allow-save"
"dialog:allow-open"
]
}
@@ -1 +0,0 @@
你是游戏创作需求的润色助手。把用户给创作 Agent 的一段需求改写成更清晰、可执行的中文需求。要求:保留用户的原始意图、玩法、美术方向、数值与限制条件,不得新增或删除需求点,不得替用户做决定,不得写成方案书或任务清单。只输出润色后的需求正文,不要解释、不要引言、不要 Markdown 标记、不要引号包裹、不要重复用户原文;无法润色时原样输出用户输入。
@@ -16,7 +16,6 @@ mod direct_codex_attachments;
mod direct_codex_audit;
mod direct_project_history;
mod direct_project_turn_history;
mod direct_codex_references;
mod direct_runtime;
mod direct_tool_bridge;
mod direct_tools_mcp;
@@ -43,7 +42,6 @@ pub(crate) use direct_codex_attachments::*;
pub(crate) use direct_codex_audit::*;
pub(crate) use direct_project_history::*;
pub(crate) use direct_project_turn_history::*;
pub(crate) use direct_codex_references::*;
pub(crate) use direct_runtime::*;
pub(crate) use direct_tool_bridge::*;
pub(crate) use direct_tools_mcp::*;
@@ -1,328 +0,0 @@
use super::*;
pub(crate) const MAX_DIRECT_CODEX_REFERENCES: usize = 32;
const MAX_DIRECT_CODEX_REFERENCE_ID_CHARS: usize = 200;
const MAX_DIRECT_CODEX_REFERENCE_LABEL_CHARS: usize = 160;
const MAX_DIRECT_CODEX_REFERENCE_SOURCE_CHARS: usize = 32;
const MAX_DIRECT_CODEX_REFERENCE_ELEMENT_CHARS: usize = 80;
const MAX_DIRECT_CODEX_REFERENCE_TEXT_CHARS: usize = 240;
const DIRECT_CODEX_REFERENCE_HEADER: &str =
"[本轮用户引用素材:以下均为当前项目已确认的安全引用。请使用稳定资源 ID 和项目相对路径读取,不要读取或输出其它路径。]";
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase", tag = "type")]
pub(crate) enum DirectCodexTurnReference {
#[serde(rename = "resource")]
Resource(DirectCodexResourceReference),
#[serde(rename = "runtime-region")]
RuntimeRegion(DirectCodexRuntimeRegionReference),
}
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DirectCodexResourceReference {
pub(crate) resource_id: String,
#[serde(default)]
pub(crate) label: Option<String>,
#[serde(default)]
pub(crate) source: Option<String>,
}
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DirectCodexRuntimeRegionReference {
#[serde(default)]
pub(crate) label: Option<String>,
#[serde(default)]
pub(crate) run_id: Option<String>,
#[serde(default)]
pub(crate) version_id: Option<String>,
#[serde(default)]
pub(crate) element_tag: Option<String>,
#[serde(default)]
pub(crate) element_role: Option<String>,
#[serde(default)]
pub(crate) text: Option<String>,
#[serde(default)]
pub(crate) width: Option<f64>,
#[serde(default)]
pub(crate) height: Option<f64>,
#[serde(default)]
pub(crate) resource_ids: Vec<String>,
}
fn sanitize_reference_text(value: &str, max_chars: usize) -> Option<String> {
let sanitized = value
.trim()
.chars()
.filter(|character| !character.is_control())
.take(max_chars)
.collect::<String>();
if sanitized.is_empty() {
None
} else {
Some(sanitized)
}
}
fn sanitize_reference_label(value: Option<&str>) -> Option<String> {
value.and_then(|value| sanitize_reference_text(value, MAX_DIRECT_CODEX_REFERENCE_LABEL_CHARS))
}
fn sanitize_reference_source(value: Option<&str>) -> Option<String> {
let value = sanitize_reference_text(value?, MAX_DIRECT_CODEX_REFERENCE_SOURCE_CHARS)?;
if value
.chars()
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.'))
{
Some(value)
} else {
None
}
}
fn sanitize_reference_element(value: Option<&str>) -> Option<String> {
let value = sanitize_reference_text(value?, MAX_DIRECT_CODEX_REFERENCE_ELEMENT_CHARS)?;
if value
.chars()
.all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.'))
{
Some(value)
} else {
None
}
}
fn sanitize_reference_dimension(value: Option<f64>) -> Option<u32> {
value
.filter(|value| value.is_finite() && *value > 0.0)
.map(|value| value.round().clamp(1.0, 100_000.0) as u32)
}
fn asset_display_label(asset: &GameCreationAppAssetManifestEntry) -> String {
let basename = asset
.local_path
.rsplit(['/', '\\'])
.next()
.unwrap_or(&asset.id);
let without_extension = basename
.rsplit_once('.')
.map(|(name, _)| name)
.unwrap_or(basename)
.trim();
if without_extension.is_empty() {
asset.id.clone()
} else {
without_extension
.chars()
.filter(|character| !character.is_control())
.take(MAX_DIRECT_CODEX_REFERENCE_LABEL_CHARS)
.collect()
}
}
fn validate_resource_reference_id(value: &str) -> Result<String, String> {
let resource_id = value.trim();
if resource_id.is_empty()
|| resource_id.chars().count() > MAX_DIRECT_CODEX_REFERENCE_ID_CHARS
|| resource_id.chars().any(char::is_control)
{
return Err("引用的素材 ID 无效,请移除后重新选择".to_string());
}
Ok(resource_id.to_string())
}
fn render_resource_reference_line(
manifest: &GameCreationAppManifest,
reference: &DirectCodexResourceReference,
) -> Result<String, String> {
let resource_id = validate_resource_reference_id(&reference.resource_id)?;
let asset = manifest
.assets
.iter()
.find(|asset| asset.id == resource_id)
.ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?;
let local_path = sanitize_attachment_local_path(&asset.local_path)
.ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?;
let label = sanitize_reference_label(reference.label.as_deref())
.unwrap_or_else(|| asset_display_label(asset));
let source = sanitize_reference_source(reference.source.as_deref())
.unwrap_or_else(|| "unknown".to_string());
Ok(format!(
"- 素材 ID{resource_id};名称:{label};类型:{};媒体类型:{};项目路径:{local_path};来源:{source}",
asset.kind, asset.media_type
))
}
fn render_runtime_region_reference_line(
manifest: &GameCreationAppManifest,
reference: &DirectCodexRuntimeRegionReference,
) -> Result<String, String> {
let label = sanitize_reference_label(reference.label.as_deref())
.unwrap_or_else(|| "运行画面区域".to_string());
let run_id = sanitize_reference_source(reference.run_id.as_deref());
let version_id = sanitize_reference_source(reference.version_id.as_deref());
let element_tag = sanitize_reference_element(reference.element_tag.as_deref());
let element_role = sanitize_reference_element(reference.element_role.as_deref());
let text = reference
.text
.as_deref()
.and_then(|value| sanitize_reference_text(value, MAX_DIRECT_CODEX_REFERENCE_TEXT_CHARS));
let width = sanitize_reference_dimension(reference.width);
let height = sanitize_reference_dimension(reference.height);
let mut related_resource_ids = Vec::new();
for resource_id in &reference.resource_ids {
let resource_id = validate_resource_reference_id(resource_id)?;
if !manifest.assets.iter().any(|asset| asset.id == resource_id) {
return Err("运行画面引用的素材已变化,请重新点选".to_string());
}
related_resource_ids.push(resource_id);
}
let mut parts = vec![format!("名称:{label}")];
if let Some(run_id) = run_id {
parts.push(format!("运行标识:{run_id}"));
}
if let Some(version_id) = version_id {
parts.push(format!("版本标识:{version_id}"));
}
if let Some(element_tag) = element_tag {
parts.push(format!("元素:{element_tag}"));
}
if let Some(element_role) = element_role {
parts.push(format!("角色:{element_role}"));
}
if let Some(text) = text {
parts.push(format!("文本摘要:{text}"));
}
if let (Some(width), Some(height)) = (width, height) {
parts.push(format!("尺寸:{width}x{height}"));
}
if !related_resource_ids.is_empty() {
parts.push(format!("关联素材 ID{}", related_resource_ids.join(",")));
}
Ok(format!("- 运行画面区域:{}", parts.join("")))
}
pub(crate) fn render_direct_codex_references_section(
root: &Path,
references: &[DirectCodexTurnReference],
) -> Result<Option<String>, String> {
if references.is_empty() {
return Ok(None);
}
if references.len() > MAX_DIRECT_CODEX_REFERENCES {
return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材"));
}
let manifest = read_manifest_for_project(root)?;
let mut lines = Vec::with_capacity(references.len());
for reference in references {
lines.push(match reference {
DirectCodexTurnReference::Resource(reference) => {
render_resource_reference_line(&manifest, reference)?
}
DirectCodexTurnReference::RuntimeRegion(reference) => {
render_runtime_region_reference_line(&manifest, reference)?
}
});
}
Ok(Some(
std::iter::once(DIRECT_CODEX_REFERENCE_HEADER.to_string())
.chain(lines)
.collect::<Vec<_>>()
.join("\n"),
))
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture_project() -> tempfile::TempDir {
let directory = tempfile::tempdir().expect("temp project");
let root = directory.path();
std::fs::create_dir_all(root.join(".agent")).expect("create agent dir");
let mut manifest = new_game_creation_app_manifest("project-1", "测试项目");
manifest.assets.push(GameCreationAppAssetManifestEntry {
id: "asset-hero".to_string(),
kind: "character".to_string(),
media_type: "image/png".to_string(),
local_path: "assets/hero.png".to_string(),
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Uploaded,
canvas_project_id: None,
resource_id: None,
asset_object_id: None,
task_id: None,
prompt: None,
model: None,
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
},
image_sequence_frames: None,
image_sequence_duration_ms: None,
category: game_creation_app_asset_category_for_kind("character"),
tags: Vec::new(),
});
write_manifest(&root.join(".agent/manifest.json"), &manifest).expect("write manifest");
directory
}
#[test]
fn resource_reference_uses_manifest_identity_and_never_accepts_client_paths() {
let project = fixture_project();
let reference: DirectCodexTurnReference = serde_json::from_str(
r#"{"type":"resource","resourceId":"asset-hero","label":"主角","source":"asset-picker","localPath":"C:\\secret.png"}"#,
)
.expect("reference json");
let section = render_direct_codex_references_section(
project.path(),
std::slice::from_ref(&reference),
)
.expect("render")
.expect("section");
assert!(section.contains("素材 IDasset-hero"));
assert!(section.contains("名称:主角"));
assert!(section.contains("项目路径:assets/hero.png"));
assert!(!section.contains("C:\\secret.png"));
}
#[test]
fn deleted_resource_fails_closed() {
let project = fixture_project();
let reference: DirectCodexTurnReference = serde_json::from_str(
r#"{"type":"resource","resourceId":"asset-missing","label":"不存在"}"#,
)
.expect("reference json");
let error = render_direct_codex_references_section(
project.path(),
std::slice::from_ref(&reference),
)
.expect_err("missing resource");
assert_eq!(error, "引用的素材已不存在,请移除后重新选择");
}
#[test]
fn runtime_region_keeps_only_safe_summary_and_existing_resource_ids() {
let project = fixture_project();
let reference: DirectCodexTurnReference = serde_json::from_str(
r#"{"type":"runtime-region","label":"开始按钮","runId":"run-1","elementTag":"button","elementRole":"button","text":"开始游戏","width":120.4,"height":40.2,"resourceIds":["asset-hero"],"html":"<button onclick=secret>"}"#,
)
.expect("reference json");
let section = render_direct_codex_references_section(
project.path(),
std::slice::from_ref(&reference),
)
.expect("render")
.expect("section");
assert!(section.contains("运行画面区域:名称:开始按钮"));
assert!(section.contains("文本摘要:开始游戏"));
assert!(section.contains("尺寸:120x40"));
assert!(section.contains("关联素材 IDasset-hero"));
assert!(!section.contains("onclick"));
assert!(!section.contains("secret"));
}
}
@@ -1776,7 +1776,7 @@ fn direct_codex_failure_recovery_hint(stage: DirectCodexFailureStage, error: &st
if normalized.contains("permission-denied") || normalized.contains("http 403") {
return "当前陶泥儿账号可能没有访问该资源的权限,请检查账号后重试";
}
if error.contains("项目正在被其他写操作占用") {
if error.contains(crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX) {
return "当前项目仍有写入正在结束,请稍后再次发送该需求";
}
if error.contains("身份不唯一")
@@ -4414,7 +4414,6 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
creation_type: Option<String>,
client_turn_id: Option<String>,
attachments: Option<Vec<DirectCodexTurnAttachment>>,
references: Option<Vec<DirectCodexTurnReference>>,
) -> Result<String, String> {
let root = Path::new(project_path.trim());
let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?;
@@ -4429,33 +4428,16 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
&prompt,
attachments.as_deref().unwrap_or_default(),
);
let attachments = attachments.unwrap_or_default();
let references = references.unwrap_or_default();
let mut user_prompt = match render_direct_codex_user_prompt(&prompt, &attachments) {
let user_prompt = match render_direct_codex_user_prompt(
&prompt,
attachments.as_deref().unwrap_or_default(),
) {
Ok(prompt) => prompt,
Err(_) if !references.is_empty() && prompt.trim().is_empty() => String::new(),
Err(error) => {
audit.finish(false);
return Err(error);
}
};
if let Some(reference_section) = match render_direct_codex_references_section(root, &references)
{
Ok(section) => section,
Err(error) => {
audit.finish(false);
return Err(error);
}
} {
if !user_prompt.trim().is_empty() {
user_prompt.push_str("\n\n");
}
user_prompt.push_str(&reference_section);
}
if user_prompt.trim().is_empty() {
audit.finish(false);
return Err("聊天内容不能为空".to_string());
}
let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter(
root,
&user_prompt,
@@ -1161,8 +1161,6 @@ fn bridge_registered_resource(
"localPath": asset.local_path,
"kind": asset.kind,
"mediaType": asset.media_type,
"category": asset.category,
"tags": asset.tags,
"canvasProjectId": asset.source.canvas_project_id,
"resourceId": asset.source.resource_id,
"assetObjectId": asset.source.asset_object_id,
@@ -1475,7 +1473,14 @@ fn bridge_write_file(root: &Path, arguments: &Value) -> Value {
return Err("工具参数 content 不能包含 NUL".to_string());
}
reject_command_output_wrapper(content)?;
let _lock = acquire_project_write_lock(root, "direct-codex.file.write")?;
// Direct 写入原本用零等待取锁:任何重叠都在 24-42ms 内直接被判成"别人正在写",
// 而 `file.write / file.patch / file.delete` 等写入口用的是约 10 秒有界等待。
// 这是用户直接触发、失败即整轮无法落盘的项目写入通道,必须和其它写入口同语义:
// 短暂重叠排队等成功,只有预算耗尽才报出带持锁方身份的错误。
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
root,
"direct-codex.file.write",
)?;
let written = write_local_project_file_at(root, &path, content)?;
let revision = advance_agent_runtime_project_revision_locked(root)?;
Ok::<_, String>(json!({
@@ -1495,6 +1500,27 @@ fn bridge_write_file(root: &Path, arguments: &Value) -> Value {
}
}
/// 项目写锁的有界等待是同步轮询(2_000 × 5ms,最多约 10 秒)。handler 是 async
/// 直接在 handler 里走完整条写路径会占住一个 tokio worker:争用窗口内同一轮并行写多个
/// 文件时会有多个 worker 被占,而这条 bridge 与只读端点、UI 命令共享同一个 runtime,
/// 正是 Issue #318 现场"只读工具全部正常"这条诊断特征会被破坏的情形。
/// 因此整条写路径挪进阻塞线程池,等待语义与错误文案都不变。
async fn bridge_write_file_in_blocking_pool(root: PathBuf, arguments: Value) -> Value {
let task_root = root.clone();
match tokio::task::spawn_blocking(move || bridge_write_file(&task_root, &arguments)).await {
Ok(result) => result,
Err(error) => bridge_tool_result(
redact_agent_runtime_error(
&root,
&format!("agc_write_file 阻塞任务未返回:{error}"),
480,
),
Vec::new(),
true,
),
}
}
fn bridge_safe_account_asset_projection(asset: &Value) -> Option<Value> {
let asset_id = asset.get("assetId").and_then(Value::as_str)?;
if asset_id.trim().is_empty() {
@@ -2309,7 +2335,9 @@ async fn handle_direct_tool_bridge(
bridge_list_registered_assets(&state.root, &request.arguments)
}
"agc_list_project_files" => bridge_list_project_files(&state.root, &request.arguments),
"agc_write_file" => bridge_write_file(&state.root, &request.arguments),
"agc_write_file" => {
bridge_write_file_in_blocking_pool(state.root.clone(), request.arguments).await
}
"agc_list_account_assets" => bridge_list_account_assets(&state, &request.arguments).await,
"agc_import_account_assets" => {
bridge_import_account_assets(&state, &request.arguments).await
@@ -2714,6 +2742,185 @@ mod tests {
);
}
/// Issue #318 第 1 条验收:同一轮里并行的多个文件写必须排队成功,
/// 而不是互相报"项目正在被其他写操作占用"。
#[test]
fn bridge_write_file_serializes_parallel_writes_in_one_round() {
let temporary = tempfile::tempdir().expect("create parallel direct write root");
init_local_game_project_at(temporary.path(), "direct-parallel", "Direct 并行写入测试")
.expect("initialize parallel direct write root");
let root = temporary.path().to_path_buf();
let paths = (0..4)
.map(|index| format!("game/parallel-{index}.js"))
.collect::<Vec<_>>();
let results = std::thread::scope(|scope| {
let handles = paths
.iter()
.map(|path| {
let root = root.clone();
let path = path.clone();
scope.spawn(move || {
let result = bridge_write_file(
&root,
&json!({ "path": path, "content": format!("// {path}\n") }),
);
(path, result)
})
})
.collect::<Vec<_>>();
handles
.into_iter()
.map(|handle| handle.join().expect("parallel direct write must not panic"))
.collect::<Vec<_>>()
});
for (path, result) in &results {
assert_eq!(
result.get("isError").and_then(Value::as_bool),
Some(false),
"parallel direct write of {path} must succeed: {result}"
);
assert_eq!(
fs::read_to_string(root.join(path)).expect("read parallel direct write"),
format!("// {path}\n")
);
}
}
/// Issue #318 第 1 条验收:App 自己另一条写通道正在写该项目时,
/// Direct 写入必须等待后成功,而不是在 24-42ms 内被判成"别人正在写"。
#[test]
fn bridge_write_file_waits_for_a_short_same_process_project_writer() {
let temporary = tempfile::tempdir().expect("create contended direct write root");
init_local_game_project_at(temporary.path(), "direct-contended", "Direct 写入等待测试")
.expect("initialize contended direct write root");
let root = temporary.path().to_path_buf();
let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
let holder_barrier = std::sync::Arc::clone(&barrier);
let holder_root = root.clone();
let holder = std::thread::spawn(move || {
let lock = acquire_project_write_lock(&holder_root, "concurrent-writer")
.expect("acquire a short-lived project writer");
holder_barrier.wait();
std::thread::sleep(std::time::Duration::from_millis(300));
drop(lock);
});
barrier.wait();
let result = bridge_write_file(
&root,
&json!({ "path": "game/waited.js", "content": "// waited\n" }),
);
holder.join().expect("join the short-lived project writer");
assert_eq!(
result.get("isError").and_then(Value::as_bool),
Some(false),
"the direct write must wait out a short same-process writer: {result}"
);
assert_eq!(
fs::read_to_string(root.join("game/waited.js")).expect("read waited direct write"),
"// waited\n"
);
}
/// 有界等待是同步轮询(最多约 10 秒),而 handler 是 async:等待必须挪到阻塞线程池,
/// 否则会占住 runtime worker。本用例用默认的 current_thread runtime——handler 一旦同步
/// 阻塞,同一 runtime 上的心跳任务就完全停摆,因此在写入等待期间检查心跳即可区分。
#[tokio::test]
async fn bridge_write_file_waits_on_the_blocking_pool_instead_of_a_runtime_worker() {
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
let temporary = tempfile::tempdir().expect("create blocking pool write root");
init_local_game_project_at(temporary.path(), "direct-pool", "Direct 阻塞池测试")
.expect("initialize blocking pool write root");
let state = direct_tool_bridge_state(temporary.path().to_path_buf());
// 另一条写通道由 OS 线程持有项目写锁,不受本 runtime 影响。
let holder_root = temporary.path().to_path_buf();
let lock = acquire_project_write_lock(&holder_root, "concurrent-writer")
.expect("acquire the concurrent project writer");
let holder = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(250));
drop(lock);
});
// 心跳任务:只有 handler 让出 worker,它才可能在写入等待期间推进。
let heartbeat = Arc::new(AtomicBool::new(false));
let heartbeat_writer = Arc::clone(&heartbeat);
tokio::spawn(async move {
tokio::time::sleep(Duration::from_millis(50)).await;
heartbeat_writer.store(true, Ordering::SeqCst);
});
let response = handle_direct_tool_bridge(
axum::extract::State(state),
axum::Json(DirectToolBridgeRequest {
tool: "agc_write_file".to_string(),
arguments: json!({ "path": "game/blocking-pool.js", "content": "// pooled\n" }),
}),
)
.await
.0;
holder.join().expect("join the concurrent project writer");
assert!(
heartbeat.load(Ordering::SeqCst),
"写入等待期间同一 runtime 的心跳任务停摆了:等待必须走阻塞线程池,不能占住 worker"
);
assert_eq!(
response.get("isError").and_then(Value::as_bool),
Some(false),
"the pooled direct write must still wait out the holder: {response}"
);
assert_eq!(
fs::read_to_string(temporary.path().join("game/blocking-pool.js"))
.expect("read pooled direct write"),
"// pooled\n"
);
}
/// Issue #318 第 3 条验收:权限拒绝不得被投影成"被其他写操作占用"。
#[cfg(unix)]
#[test]
fn bridge_write_file_does_not_project_permission_denial_as_contention() {
use std::os::unix::fs::PermissionsExt;
let temporary = tempfile::tempdir().expect("create acl direct write root");
init_local_game_project_at(temporary.path(), "direct-acl", "Direct ACL 测试")
.expect("initialize acl direct write root");
let agent_directory = temporary.path().join(".agent");
let original = fs::metadata(&agent_directory)
.expect("read control directory metadata")
.permissions();
fs::set_permissions(&agent_directory, fs::Permissions::from_mode(0o500))
.expect("drop write permission on the control directory");
let result = bridge_write_file(
temporary.path(),
&json!({ "path": "game/acl-denied.js", "content": "// denied\n" }),
);
fs::set_permissions(&agent_directory, original)
.expect("restore control directory permission");
if result.get("isError").and_then(Value::as_bool) != Some(true) {
// 以 root 运行(或文件系统忽略权限位)时 0o500 不构成拒绝,本用例不成立。
return;
}
let text = result
.pointer("/content/0/text")
.and_then(Value::as_str)
.unwrap_or_default();
assert!(
!text.contains("项目正在被其他写操作占用"),
"a permission denial must not be projected as lock contention: {text}"
);
assert!(!temporary.path().join("game/acl-denied.js").exists());
}
#[test]
fn resource_request_uuid_is_stable_v4_and_domain_separated() {
let operation = direct_resource_request_uuid("turn-1", "operation", "abc");
@@ -2783,8 +2990,6 @@ mod tests {
local_path: "assets/art-spritesheet-slices/player.png".to_string(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
category: game_creation_app_asset_category_for_kind("art-spritesheet-slice"),
tags: Vec::new(),
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
canvas_project_id: Some("canvas-1".to_string()),
@@ -2848,8 +3053,6 @@ mod tests {
},
]),
image_sequence_duration_ms: Some(4_000),
category: game_creation_app_asset_category_for_kind("character-animation"),
tags: Vec::new(),
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
canvas_project_id: Some("canvas-1".to_string()),
@@ -2869,8 +3072,6 @@ mod tests {
projection["imageSequenceFrames"][0]["objectKey"],
"animations/frame-01.png"
);
assert_eq!(projection["category"], "character");
assert_eq!(projection["tags"], json!([]));
let serialized = projection.to_string();
assert!(!serialized.contains("signed.invalid"));
assert!(!serialized.contains("private prompt"));
@@ -2878,42 +3079,6 @@ mod tests {
assert!(!serialized.contains("private.invalid"));
}
#[test]
fn bridge_registered_resource_projects_manifest_classification_verbatim() {
let asset = GameCreationAppAssetManifestEntry {
id: "spec-1".to_string(),
kind: "spec".to_string(),
media_type: "application/json".to_string(),
local_path: "assets/specs/hero.json".to_string(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
category:
shared_contracts::game_creation_app::GameCreationAppAssetCategory::UiInteraction,
tags: vec!["主角".to_string(), "第一版".to_string()],
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Generated,
canvas_project_id: None,
resource_id: None,
asset_object_id: None,
task_id: None,
prompt: Some("private prompt".to_string()),
model: Some("private model".to_string()),
generation_route: None,
generation_kind: None,
reference_resource_ids: Vec::new(),
},
};
let projection = bridge_registered_resource(&asset, false);
assert_eq!(projection["kind"], "spec");
assert_eq!(projection["category"], "ui-interaction");
assert_eq!(projection["tags"], json!(["主角", "第一版"]));
assert_eq!(projection["imageSequenceFrames"], Value::Null);
let serialized = projection.to_string();
assert!(!serialized.contains("private prompt"));
assert!(!serialized.contains("private model"));
}
#[test]
fn bridge_success_warnings_are_redacted_before_serialization() {
let root = tempfile::tempdir().expect("warning redaction root");
@@ -256,7 +256,7 @@ fn direct_tools_mcp_specs_for(controlled_web_search: bool) -> Value {
}),
json!({
"name": "agc_list_registered_assets",
"description": "查询当前项目由客户端权威 manifest 登记的资源与未完成资源 operation。结果有界且只包含项目相对路径、稳定资源身份、功能分类与自定义标签、序列帧身份和恢复状态,不返回 prompt、模型、签名 URL、宿主路径或凭据。",
"description": "查询当前项目由客户端权威 manifest 登记的资源与未完成资源 operation。结果有界且只包含项目相对路径、稳定资源身份、序列帧身份和恢复状态,不返回 prompt、模型、签名 URL、宿主路径或凭据。",
"inputSchema": {
"type": "object",
"properties": {
@@ -6786,8 +6786,6 @@ fn register_platform_art_slice_manifest_entries_at(
local_path: registration.local_path.clone(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
category: game_creation_app_asset_category_for_kind("art-spritesheet-slice"),
tags: Vec::new(),
source,
});
ids.push(id);
@@ -1822,27 +1822,45 @@ const AGENT_RUNTIME_PROJECT_WRITE_LOCK_SHORT_WAIT_ATTEMPTS: usize = 200;
/// Take the project write lock, riding out transient contention for at most
/// `max_attempts` polls.
///
/// `项目正在被其他写操作占用:` is the one lock error that means
/// "nothing is broken, the current holder is mid-write" — every other variant
/// (a torn lock file, a denied path) is returned immediately. Callers pick the
/// budget from what a lost race costs them: a one-shot user intent waits out the
/// full window, a poll that will run again shortly waits far less.
/// 能不能等由 `ProjectWriteLockFailure` 的**类型**决定,不解析错误文案:只有可重试的
/// 取锁失败才在这里等,权限拒绝和坏路径立刻返回。判据曾经是
/// `项目正在被其他写操作占用:` 这个前缀,那等于把"要不要等"绑在中文文案上——
/// 改一次文案就悄悄改掉一次重试语义。Callers pick the budget from what a lost race
/// costs them: a one-shot user intent waits out the full window, a poll that will run
/// again shortly waits far less.
fn acquire_game_creator_agent_runtime_project_write_lock_within(
root: &Path,
command_id: &str,
max_attempts: usize,
) -> Result<ProjectWriteLock, String> {
let max_attempts = max_attempts.max(1);
let started_at = std::time::Instant::now();
for attempt in 0..max_attempts {
match acquire_project_write_lock(root, command_id) {
Err(error)
if error.starts_with("项目正在被其他写操作占用:")
&& attempt + 1 < max_attempts =>
{
std::thread::sleep(AGENT_RUNTIME_PROJECT_WRITE_LOCK_RETRY_INTERVAL);
let failure = match acquire_project_write_lock_failure(root, command_id) {
Ok(lock) => return Ok(lock),
Err(failure) if failure.is_retryable() => failure,
Err(failure) => return Err(failure.message()),
};
if attempt + 1 == max_attempts {
// 等待预算耗尽才记一条:争用本身可能重试上千次,逐次记账会淹掉日志。
// 这条记录回答的正是 Issue #318 现场缺的问题——"谁在持锁、是不是自己人",
// 以及等满预算之后这到底是争用还是权限拒绝(`projection=`)。
// 单次试探(max_attempts == 1,例如 hydrate 的 try_acquire_*)根本没有等待:
// 既不写 `wait_exhausted`waitedMs≈0 会让"耗尽"这个词失去意义,而 hydrate
// 每次状态变化都会撞一次锁,会把它变成噪声),也不做终态改判。
let waited = max_attempts > 1;
let (projection, message) = failure.exhausted_projection(waited);
if waited {
app_log!(
"project.write_lock.wait_exhausted commandId={command_id} attempts={} waitedMs={} projection={projection} holder={}",
attempt + 1,
started_at.elapsed().as_millis(),
crate::project::project_write_lock_contention_diagnostic(root)
);
}
result => return result,
return Err(message);
}
std::thread::sleep(AGENT_RUNTIME_PROJECT_WRITE_LOCK_RETRY_INTERVAL);
}
unreachable!("project write lock retry loop always returns")
}
@@ -24,7 +24,7 @@ enum AutonomousManifestParentWakeReconciliationOutcome {
pub(crate) fn autonomous_manifest_parent_wake_error_is_transient(error: &str) -> bool {
let normalized = error.to_ascii_lowercase();
error.starts_with("项目正在被其他写操作占用:")
error.starts_with(crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX)
|| error.contains("另一个程序正在使用此文件")
|| normalized.contains("sharing violation")
|| normalized.contains("lock violation")
@@ -1495,7 +1495,7 @@ pub(crate) fn hydrate_planning_session_v2(
"planning.v2.hydrate",
) {
Ok(lock) => lock,
Err(error) if error.starts_with("项目正在被其他写操作占用:") => {
Err(error) if error.starts_with(crate::project::PROJECT_WRITE_LOCK_CONTENTION_PREFIX) => {
return Ok(None)
}
Err(error) => return Err(error),
@@ -1768,8 +1768,6 @@ pub(crate) fn register_local_asset_entry(
local_path: normalized_path.clone(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
category: game_creation_app_asset_category_for_kind(kind),
tags: Vec::new(),
source,
});
Ok((id, "asset.register"))
@@ -1795,181 +1793,6 @@ pub(crate) fn register_local_asset_entry(
})
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct ReadLocalProjectAssetReferencesInput {
pub project_path: String,
pub asset_id: String,
}
/// 引用了某个素材的项目版本摘要。删除弹窗据此列出「被哪些版本使用」。
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct LocalProjectAssetReferenceVersion {
pub version_id: String,
pub project_revision: u64,
pub created_at: u64,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ReadLocalProjectAssetReferencesResult {
pub asset_id: String,
pub versions: Vec<LocalProjectAssetReferenceVersion>,
}
/// 版本数组里 `resourceBindings` 指向该素材的版本,顺序与版本数组一致。
///
/// 版本绑定即资源记录:`resourceId` 指向 manifest 资产 ID,命中即表示该版本用了这个素材。
fn asset_referencing_versions(
manifest: &GameCreationAppManifest,
asset_id: &str,
) -> Vec<LocalProjectAssetReferenceVersion> {
manifest
.versions
.iter()
.filter(|version| {
version
.resource_bindings
.iter()
.any(|binding| binding.resource_id == asset_id)
})
.map(|version| LocalProjectAssetReferenceVersion {
version_id: version.version_id.clone(),
project_revision: version.project_revision,
created_at: version.created_at,
})
.collect()
}
fn normalized_asset_id(asset_id: &str) -> Result<&str, String> {
let asset_id = asset_id.trim();
if asset_id.is_empty() {
return Err("素材 assetId 不能为空".to_string());
}
Ok(asset_id)
}
pub(crate) fn read_manifest_asset_references_at(
root: &Path,
asset_id: &str,
) -> Result<ReadLocalProjectAssetReferencesResult, String> {
let asset_id = normalized_asset_id(asset_id)?;
let manifest = read_existing_manifest_for_project(root)?;
Ok(ReadLocalProjectAssetReferencesResult {
asset_id: asset_id.to_string(),
versions: asset_referencing_versions(&manifest, asset_id),
})
}
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct DeleteLocalProjectAssetInput {
pub project_path: String,
pub expected_project_id: String,
pub expected_project_revision: u64,
pub asset_id: String,
/// 用户是否勾选「把相关游戏版本一并删除」;默认不勾,只删素材登记。
pub delete_referenced_versions: bool,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DeleteLocalProjectAssetResult {
pub asset_id: String,
pub local_path: String,
pub committed_project_revision: u64,
/// 只删除 manifest 登记,磁盘文件保留;调用方据此提示用户。
pub file_retained: bool,
}
/// 删除一个素材登记,并按用户确认决定是否连带删除引用它的游戏版本。
///
/// 语义:
/// - 未被任何版本引用 → 只摘掉登记;
/// - 被引用且 `delete_referenced_versions` 为 `false` → 只摘掉登记,引用它的版本原样保留为
/// 悬空绑定,不做二次拦截;界面不为这些版本合成资源卡;
/// - 被引用且为 `true` → 素材与所有引用它的版本在**同一次 manifest 写入**里一起删除;
/// - 素材不可变,**不删除磁盘文件**,只摘掉 manifest 登记;
/// - 与资源分类更新同口径:持项目写锁后按 `expectedProjectId` / 当前 revision 做 CAS
/// 失败时 manifest 与 revision 都不变。
pub(crate) fn delete_manifest_asset_at(
root: &Path,
expected_project_id: &str,
expected_project_revision: u64,
asset_id: &str,
delete_referenced_versions: bool,
) -> Result<DeleteLocalProjectAssetResult, String> {
if expected_project_revision
> shared_contracts::game_creation_app::GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION
{
return Err("expectedProjectRevision 超出 JavaScript 安全整数范围".to_string());
}
let expected_project_id = expected_project_id.trim();
if expected_project_id.is_empty() {
return Err("素材删除 expectedProjectId 不能为空".to_string());
}
let asset_id = normalized_asset_id(asset_id)?;
if read_existing_manifest_for_project(root)?.project_id != expected_project_id {
return Err("project-identity-conflict".to_string());
}
let _lock = acquire_project_write_lock(root, "asset.register")?;
if read_existing_manifest_for_project(root)?.project_id != expected_project_id {
return Err("project-identity-conflict".to_string());
}
if read_game_creator_agent_runtime_project_revision(root)?.revision != expected_project_revision
{
return Err("project-revision-conflict".to_string());
}
let mut local_path = String::new();
mutate_manifest_at_allowing_version_removals(
root,
&|manifest| {
if !delete_referenced_versions {
return Vec::new();
}
asset_referencing_versions(manifest, asset_id)
.into_iter()
.map(|version| version.version_id)
.collect()
},
|manifest| {
let Some(index) = manifest
.assets
.iter()
.position(|asset| asset.id == asset_id)
else {
return Err(format!("项目资源不存在:{asset_id}"));
};
local_path = manifest.assets[index].local_path.clone();
manifest.assets.remove(index);
if delete_referenced_versions {
let referenced_version_ids: Vec<String> =
asset_referencing_versions(manifest, asset_id)
.into_iter()
.map(|version| version.version_id)
.collect();
manifest
.versions
.retain(|version| !referenced_version_ids.contains(&version.version_id));
}
Ok(())
},
)?;
let committed_project_revision = advance_agent_runtime_project_revision_locked(root)
.map_err(|error| format!("素材登记已删除,但项目 revision 未能推进:{error}"))?;
Ok(DeleteLocalProjectAssetResult {
asset_id: asset_id.to_string(),
local_path,
committed_project_revision,
file_retained: true,
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -18,13 +18,6 @@ const AUTOMATIC_PROJECT_NAME_MAX_PROMPT_CHARS: usize = 8_000;
const AUTOMATIC_PROJECT_NAME_MAX_OUTPUT_TOKENS: u32 = 64;
const AUTOMATIC_PROJECT_NAME_SYSTEM_PROMPT: &str =
include_str!("../prompts/automatic-project-name.md");
// 聊天输入区的 AI 润色复用同一条短文本生成通道:只做一次单轮改写,
// 计费由平台 LLM 路由(/api/llm/chat/completions、/api/llm/responses)侧完成。
const LOCAL_PROJECT_PROMPT_POLISH_MAX_PROMPT_CHARS: usize = 4_000;
const LOCAL_PROJECT_PROMPT_POLISH_MAX_CONTEXT_CHARS: usize = 1_000;
const LOCAL_PROJECT_PROMPT_POLISH_MAX_OUTPUT_TOKENS: u32 = 2_048;
const LOCAL_PROJECT_PROMPT_POLISH_SYSTEM_PROMPT: &str =
include_str!("../prompts/local-project-prompt-polish.md");
fn is_chinese_project_name_character(value: char) -> bool {
matches!(
@@ -86,122 +79,6 @@ async fn request_automatic_project_name(prompt: &str) -> Result<Option<String>,
Ok(normalize_suggested_project_name(response.text.trim()))
}
pub(crate) fn build_local_project_prompt_polish_prompt(
prompt: &str,
context: Option<&str>,
) -> Result<String, String> {
let prompt = prompt.trim();
if prompt.is_empty() {
return Err("待润色的内容为空,无法润色".to_string());
}
let mut user_prompt: String = prompt
.chars()
.take(LOCAL_PROJECT_PROMPT_POLISH_MAX_PROMPT_CHARS)
.collect();
if let Some(context) = context.map(str::trim).filter(|value| !value.is_empty()) {
let context: String = context
.chars()
.take(LOCAL_PROJECT_PROMPT_POLISH_MAX_CONTEXT_CHARS)
.collect();
user_prompt.push_str("\n\n当前项目上下文:\n");
user_prompt.push_str(&context);
}
Ok(user_prompt)
}
/// 只接受非空文本:空回复或纯空白一律视为润色失败,由调用方保留原文。
fn normalize_polished_prompt(value: &str) -> Option<String> {
let value = value.trim();
if value.is_empty() {
None
} else {
Some(value.to_string())
}
}
async fn request_local_project_prompt_polish(
prompt: &str,
context: Option<&str>,
) -> Result<String, String> {
let user_prompt = build_local_project_prompt_polish_prompt(prompt, context)?;
let app_config = load_game_creator_app_config()?;
if app_config.agent_mode == GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER {
let reply = crate::agent::direct_game_creator_home_codex_chat(
LOCAL_PROJECT_PROMPT_POLISH_SYSTEM_PROMPT.trim().to_string(),
user_prompt,
)
.await?;
return normalize_polished_prompt(&reply)
.ok_or_else(|| "AI 润色没有返回可用文本".to_string());
}
let mut llm = app_config.llm.clone();
llm.max_retries = 0;
let client = build_game_creator_llm_client_from_llm_config(&llm, "llm")?;
let request = LlmRunRequest::single_turn(
LOCAL_PROJECT_PROMPT_POLISH_SYSTEM_PROMPT.trim(),
user_prompt,
)
.with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?)
.with_model(llm.model.clone())
.with_request_timeout_ms(llm.request_timeout_ms)
.with_max_output_tokens(LOCAL_PROJECT_PROMPT_POLISH_MAX_OUTPUT_TOKENS)
.with_web_search(false);
let response = client
.run(request)
.await
.map_err(|error| format!("AI 润色失败:{error}"))?;
normalize_polished_prompt(&response.text).ok_or_else(|| "AI 润色没有返回可用文本".to_string())
}
#[cfg(test)]
mod local_project_prompt_polish_tests {
use super::*;
#[test]
fn rejects_empty_prompt() {
assert!(build_local_project_prompt_polish_prompt(" ", None).is_err());
}
#[test]
fn appends_optional_project_context() {
let prompt =
build_local_project_prompt_polish_prompt("做个跳跃游戏", Some(" 像素风 ")).unwrap();
assert_eq!(prompt, "做个跳跃游戏\n\n当前项目上下文:\n像素风");
let without_context =
build_local_project_prompt_polish_prompt("做个跳跃游戏", None).unwrap();
assert_eq!(without_context, "做个跳跃游戏");
let blank_context =
build_local_project_prompt_polish_prompt("做个跳跃游戏", Some(" ")).unwrap();
assert_eq!(blank_context, "做个跳跃游戏");
}
#[test]
fn truncates_prompt_and_context_to_their_limits() {
let long_prompt = "".repeat(LOCAL_PROJECT_PROMPT_POLISH_MAX_PROMPT_CHARS + 32);
let long_context = "".repeat(LOCAL_PROJECT_PROMPT_POLISH_MAX_CONTEXT_CHARS + 32);
let prompt =
build_local_project_prompt_polish_prompt(&long_prompt, Some(&long_context)).unwrap();
let (body, context) = prompt.split_once("\n\n当前项目上下文:\n").unwrap();
assert_eq!(
body.chars().count(),
LOCAL_PROJECT_PROMPT_POLISH_MAX_PROMPT_CHARS
);
assert_eq!(
context.chars().count(),
LOCAL_PROJECT_PROMPT_POLISH_MAX_CONTEXT_CHARS
);
}
#[test]
fn empty_model_reply_is_not_accepted_as_polished_text() {
assert_eq!(normalize_polished_prompt(" \n "), None);
assert_eq!(
normalize_polished_prompt(" 做个像素风跳跃游戏 \n").as_deref(),
Some("做个像素风跳跃游戏")
);
}
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AssetImportRequirements {
@@ -971,45 +848,242 @@ pub(crate) fn update_local_project_resource_canvas_layout(
)
}
/// 读取引用了某个素材的项目版本,供删除弹窗提示「被哪些版本使用」。
#[tauri::command]
pub(crate) fn read_local_project_asset_references(
input: ReadLocalProjectAssetReferencesInput,
) -> Result<ReadLocalProjectAssetReferencesResult, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "asset.list")?;
read_manifest_asset_references_at(root, &input.asset_id)
pub(crate) fn create_local_project_asset_canvas_draft(
input: CreateAssetCanvasDraftInput,
) -> Result<CreateAssetCanvasDraftResult, String> {
let root = validated_local_project_directory_path(input.project_path.trim())?;
create_asset_canvas_draft_at(&root, &input)
}
/// 删除一个素材登记,并按用户确认决定是否连带删除引用它的游戏版本。
///
/// 素材不可变:只摘掉 manifest 登记,磁盘文件保留。
#[tauri::command]
pub(crate) fn delete_local_project_asset(
input: DeleteLocalProjectAssetInput,
) -> Result<DeleteLocalProjectAssetResult, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "asset.register")?;
delete_manifest_asset_at(
root,
&input.expected_project_id,
input.expected_project_revision,
&input.asset_id,
input.delete_referenced_versions,
)
pub(crate) fn read_local_project_asset_canvas_draft(
input: ReadAssetCanvasDraftInput,
) -> Result<ReadAssetCanvasDraftResult, String> {
let root = validated_local_project_directory_path(input.project_path.trim())?;
read_asset_canvas_draft_at(&root, &input)
}
/// 重命名一个已登记素材:磁盘文件改名 + 更新 manifest 的 `localPath`,资产 `id` 不变。
///
/// 只允许在资产当前所在目录内改名,扩展名必须一致,同目录不得已有同名文件;manifest 写失败
/// 时把文件改回原名,不留半成品。
#[tauri::command]
pub(crate) fn rename_local_project_asset(
input: RenameLocalProjectAssetInput,
) -> Result<RenameLocalProjectAssetResult, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "asset.register")?;
rename_local_project_asset_at(root, &input.asset_id, &input.new_file_name, None)
pub(crate) fn discover_local_project_asset_canvas_draft(
input: DiscoverAssetCanvasDraftInput,
) -> Result<DiscoverAssetCanvasDraftResult, String> {
let root = validated_local_project_directory_path(input.project_path.trim())?;
discover_asset_canvas_draft_at(&root, &input)
}
#[tauri::command]
pub(crate) fn update_local_project_asset_canvas_draft(
input: UpdateAssetCanvasDraftInput,
) -> Result<UpdateAssetCanvasDraftResult, String> {
let root = validated_local_project_directory_path(input.project_path.trim())?;
update_asset_canvas_draft_at(&root, &input)
}
#[tauri::command]
pub(crate) fn acknowledge_local_project_asset_canvas_candidate_layers(
input: AcknowledgeAssetCanvasCandidateLayersInput,
) -> Result<AcknowledgeAssetCanvasCandidateLayersResult, String> {
let root = validated_local_project_directory_path(input.project_path.trim())?;
acknowledge_candidate_layers_at(&root, &input)
}
#[tauri::command]
pub(crate) async fn import_local_project_asset_canvas_images(
app: tauri::AppHandle,
input: ImportAssetCanvasImagesInput,
) -> Result<ImportAssetCanvasImagesResult, String> {
let root = validated_local_project_directory_path(input.project_path.trim())?;
let (sender, receiver) = tokio::sync::oneshot::channel();
let mut dialog = app
.dialog()
.file()
.set_title("导入图片")
.add_filter("图片", &["png", "jpg", "jpeg", "webp"]);
if let Some(window) = app.get_webview_window("client") {
dialog = dialog.set_parent(&window);
}
dialog.pick_files(move |paths| {
let _ = sender.send(paths);
});
let Some(paths) = receiver
.await
.map_err(|_| "图片文件选择器意外关闭".to_string())?
else {
return Ok(ImportAssetCanvasImagesResult::cancelled());
};
if paths.is_empty() {
return Ok(ImportAssetCanvasImagesResult::cancelled());
}
let paths = paths
.into_iter()
.map(|path| {
path.into_path()
.map_err(|error| format!("读取导入图片路径失败:{error}"))
})
.collect::<Result<Vec<_>, _>>()?;
tokio::task::spawn_blocking(move || import_asset_canvas_images_at(&root, &input, &paths))
.await
.map_err(|error| format!("图片导入任务异常结束:{error}"))?
}
#[tauri::command]
pub(crate) fn store_local_project_asset_canvas_media(
input: StoreAssetCanvasMediaInput,
) -> Result<StoreAssetCanvasMediaResult, String> {
let root = validated_local_project_directory_path(input.project_path.trim())?;
store_asset_canvas_media_at(&root, &input)
}
#[tauri::command]
pub(crate) fn stage_local_project_asset_canvas_image(
input: StageAssetCanvasImageInput,
) -> Result<StageAssetCanvasImageResult, String> {
let root = validated_local_project_directory_path(input.project_path.trim())?;
stage_asset_canvas_image_at(&root, &input)
}
#[tauri::command]
pub(crate) async fn archive_failed_local_project_asset_canvas_generation(
input: ArchiveAssetCanvasGenerationInput,
) -> Result<ArchiveAssetCanvasGenerationResult, String> {
let root = validated_local_project_directory_path(input.project_path.trim())?;
archive_failed_asset_canvas_generation_at(&root, &input).await
}
#[tauri::command]
pub(crate) async fn generate_local_project_asset_canvas_image(
app: tauri::AppHandle,
input: GenerateAssetCanvasImageInput,
) -> Result<GenerateAssetCanvasImageResult, String> {
let root = validated_local_project_directory_path(input.project_path.trim())?;
let progress_app = app.clone();
let execution = generate_asset_canvas_image_at(&root, &input, move |payload| {
let _ = progress_app.emit(ASSET_CANVAS_GENERATION_PROGRESS_EVENT, payload);
})
.await?;
if let Some(event) = execution.event.as_ref() {
publish_asset_canvas_event_after_commit_at(&root, event, |payload| {
app.emit(
"game-creator-local-asset-committed",
asset_canvas_committed_public_event(payload),
)
.map_err(|error| error.to_string())
});
}
Ok(execution.result)
}
#[tauri::command]
pub(crate) async fn finalize_local_project_asset_canvas_generation_failure(
input: FinalizeAssetCanvasGenerationFailureInput,
) -> Result<FinalizeAssetCanvasGenerationFailureResult, String> {
let root = validated_local_project_directory_path(input.project_path.trim())?;
finalize_asset_canvas_generation_failure_at(&root, &input).await
}
#[tauri::command]
pub(crate) async fn recover_local_project_asset_canvas_generations(
app: tauri::AppHandle,
input: RecoverAssetCanvasGenerationsInput,
) -> Result<RecoverAssetCanvasGenerationsResult, String> {
let root = validated_local_project_directory_path(input.project_path.trim())?;
let progress_app = app.clone();
let execution = recover_asset_canvas_generations_at(&root, &input, move |payload| {
let _ = progress_app.emit(ASSET_CANVAS_GENERATION_PROGRESS_EVENT, payload);
})
.await?;
for event in &execution.events {
publish_asset_canvas_event_after_commit_at(&root, event, |payload| {
app.emit(
"game-creator-local-asset-committed",
asset_canvas_committed_public_event(payload),
)
.map_err(|error| error.to_string())
});
}
Ok(execution.result)
}
#[tauri::command]
pub(crate) async fn confirm_local_project_asset_canvas_generation_service_identity(
input: ConfirmAssetCanvasGenerationServiceIdentityInput,
) -> Result<ConfirmAssetCanvasGenerationServiceIdentityResult, String> {
let root = validated_local_project_directory_path(input.project_path.trim())?;
confirm_asset_canvas_generation_service_identity_at(&root, &input).await
}
#[tauri::command]
pub(crate) fn read_local_project_asset_canvas_media(
input: ReadAssetCanvasMediaInput,
) -> Result<ReadAssetCanvasMediaResult, String> {
let root = validated_local_project_directory_path(input.project_path.trim())?;
read_asset_canvas_media_at(&root, &input)
}
#[tauri::command]
pub(crate) fn discard_local_project_asset_canvas_draft(
input: DiscardAssetCanvasDraftInput,
) -> Result<DiscardAssetCanvasDraftResult, String> {
let root = validated_local_project_directory_path(input.project_path.trim())?;
discard_asset_canvas_draft_at(&root, &input)
}
#[tauri::command]
pub(crate) fn commit_local_project_asset(
app: tauri::AppHandle,
input: CommitAssetCanvasInput,
) -> Result<CommitAssetCanvasResult, String> {
let root = validated_local_project_directory_path(input.project_path.trim())?;
let execution = commit_asset_canvas_at(&root, &input)?;
if let Some(event) = execution.event.as_ref() {
publish_asset_canvas_event_after_commit_at(&root, event, |payload| {
app.emit(
"game-creator-local-asset-committed",
asset_canvas_committed_public_event(payload),
)
.map_err(|error| error.to_string())
});
}
Ok(execution.result)
}
#[tauri::command]
pub(crate) fn commit_local_project_asset_canvas_candidate(
app: tauri::AppHandle,
input: CommitAssetCanvasCandidateInput,
) -> Result<CommitAssetCanvasResult, String> {
let root = validated_local_project_directory_path(input.project_path.trim())?;
let execution = commit_asset_canvas_candidate_at(&root, &input)?;
if let Some(event) = execution.event.as_ref() {
publish_asset_canvas_event_after_commit_at(&root, event, |payload| {
app.emit(
"game-creator-local-asset-committed",
asset_canvas_committed_public_event(payload),
)
.map_err(|error| error.to_string())
});
}
Ok(execution.result)
}
#[tauri::command]
pub(crate) fn recover_local_project_asset_canvas_transactions(
app: tauri::AppHandle,
input: RecoverAssetCanvasTransactionsInput,
) -> Result<RecoverAssetCanvasTransactionsResult, String> {
let root = validated_local_project_directory_path(input.project_path.trim())?;
let execution = recover_asset_canvas_transactions_at(&root, &input.expected_project_id)?;
for event in &execution.events {
publish_asset_canvas_event_after_commit_at(&root, event, |payload| {
app.emit(
"game-creator-local-asset-committed",
asset_canvas_committed_public_event(payload),
)
.map_err(|error| error.to_string())
});
}
Ok(execution.result)
}
#[tauri::command]
@@ -1791,16 +1865,6 @@ pub(crate) async fn suggest_automatic_project_name(
.map_err(|error| redact_agent_runtime_error(Path::new("."), &error, 320))
}
#[tauri::command]
pub(crate) async fn polish_local_project_prompt(
prompt: String,
context: Option<String>,
) -> Result<String, String> {
request_local_project_prompt_polish(prompt.trim(), context.as_deref())
.await
.map_err(|error| redact_agent_runtime_error(Path::new("."), &error, 320))
}
#[tauri::command]
pub(crate) fn read_platform_account_session_generation() -> u64 {
current_platform_session_generation()
@@ -2102,22 +2166,6 @@ pub(crate) fn create_ui_design_resource(
})
}
#[tauri::command]
pub(crate) fn update_local_project_resource_classification(
input: UpdateLocalProjectResourceClassificationInput,
) -> Result<UpdateLocalProjectResourceClassificationResult, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "asset.register")?;
update_manifest_asset_classification_at(
root,
&input.expected_project_id,
input.expected_project_revision,
&input.asset_id,
&input.category,
input.tags,
)
}
#[tauri::command]
pub(crate) async fn derive_local_project_resource(
input: DeriveLocalProjectResourceInput,
@@ -2743,8 +2791,6 @@ mod ui_editor_font_tests {
local_path: relative_path.to_string(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
category: game_creation_app_asset_category_for_kind("font"),
tags: Vec::new(),
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Uploaded,
canvas_project_id: None,
@@ -4332,12 +4378,6 @@ pub(crate) fn read_local_project_file(
read_local_project_file_at(root, &normalized_path)
}
#[tauri::command]
pub(crate) fn save_local_project_asset_file(
input: SaveLocalProjectAssetFileInput,
) -> Result<SaveLocalProjectAssetFileResult, String> {
save_local_project_asset_file_at(input)
}
#[tauri::command]
pub(crate) async fn read_local_project_image_preview(
preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>,
@@ -3640,7 +3640,38 @@ pub(crate) fn merge_game_creator_config_file(
Ok(())
}
fn read_game_creator_config_file(path: &Path) -> Result<Option<String>, String> {
/// Only files inside the managed runtime config directory (real credentials)
/// may use the private-read channel: it hardens the owner/DACL on every read.
/// Repository-adjacent fallback templates and overlay files are shared inputs
/// that can be git-tracked; privatizing one on read silently locks the
/// worktree template to whichever account happened to run the dev CLI, so
/// they must go through the non-mutating snapshot channel instead.
pub(crate) fn game_creator_config_path_is_runtime_managed(path: &Path) -> bool {
game_creator_runtime_config_dir().is_some_and(|directory| path.starts_with(directory))
}
fn read_game_creator_snapshot_file_to_string(
path: &Path,
label: &str,
max_bytes: u64,
) -> Result<String, String> {
let (mut file, metadata) = open_project_snapshot_regular_file(path, label)?;
if metadata.len() > max_bytes {
return Err(format!("{label}过大,已拒绝读取:{}", path.display()));
}
let mut content = String::with_capacity(metadata.len() as usize);
file.read_to_string(&mut content)
.map_err(|error| format!("读取{label}失败:{}: {error}", path.display()))?;
let final_metadata = file
.metadata()
.map_err(|error| format!("复核{label}失败:{}: {error}", path.display()))?;
if final_metadata.len() != metadata.len() {
return Err(format!("{label}读取期间文件发生漂移:{}", path.display()));
}
Ok(content)
}
pub(crate) fn read_game_creator_config_file(path: &Path) -> Result<Option<String>, String> {
let backup_path = game_creator_config_backup_path(path);
let path_exists = validate_game_creator_config_file_entry(path)?;
let read_path = if path_exists {
@@ -3650,7 +3681,11 @@ fn read_game_creator_config_file(path: &Path) -> Result<Option<String>, String>
} else {
return Ok(None);
};
let content = read_game_creator_private_file_to_string(read_path, "客户端配置", 256 * 1024)?;
let content = if game_creator_config_path_is_runtime_managed(read_path) {
read_game_creator_private_file_to_string(read_path, "客户端配置", 256 * 1024)?
} else {
read_game_creator_snapshot_file_to_string(read_path, "客户端配置", 256 * 1024)?
};
Ok(Some(content))
}
@@ -26,11 +26,10 @@ use serde::{Deserialize, Serialize};
use sha2::Digest;
use shared_contracts::error_reports::ErrorReportLogInput;
use shared_contracts::game_creation_app::{
game_creation_app_asset_category_for_kind, game_creation_app_asset_category_from_str,
new_game_creation_app_manifest, new_game_creation_app_seed_tasks,
normalize_game_creation_app_asset_tags, validate_game_iteration_versions,
GameCreationAgentArtifactTrace, GameCreationAgentCapabilityDescriptor,
GameCreationAgentPassPlanTrace, GameCreationAgentRepairRouteTrace, GameCreationAgentRunStep,
validate_game_iteration_versions, GameCreationAgentArtifactTrace,
GameCreationAgentCapabilityDescriptor, GameCreationAgentPassPlanTrace,
GameCreationAgentRepairRouteTrace, GameCreationAgentRunStep,
GameCreationAgentRunTaskGraphTrace, GameCreationAgentRunTrace, GameCreationAgentToolCallTrace,
GameCreationAppAgentGroup, GameCreationAppAssetManifestEntry, GameCreationAppAssetSource,
GameCreationAppAssetSourceKind, GameCreationAppCommandRunState,
@@ -2563,7 +2562,6 @@ fn main() {
pick_local_project_directory,
rename_local_game_project,
suggest_automatic_project_name,
polish_local_project_prompt,
pick_local_file,
pick_client_extension_file,
pick_client_extension_directory,
@@ -2615,7 +2613,6 @@ fn main() {
upload_local_asset,
register_local_asset,
create_ui_design_resource,
update_local_project_resource_classification,
derive_local_project_resource,
list_pending_local_project_resource_edits,
resume_local_project_resource_edit,
@@ -2648,7 +2645,6 @@ fn main() {
import_local_project_image_assets,
read_local_project_file,
read_local_project_image_preview,
save_local_project_asset_file,
read_local_project_text_preview,
read_local_project_media_preview,
cancel_local_project_resource_preview_scope,
@@ -2687,9 +2683,24 @@ fn main() {
read_local_project_resource_canvas_layout,
read_local_project_resource_graph,
update_local_project_resource_canvas_layout,
delete_local_project_asset,
read_local_project_asset_references,
rename_local_project_asset,
create_local_project_asset_canvas_draft,
read_local_project_asset_canvas_draft,
discover_local_project_asset_canvas_draft,
update_local_project_asset_canvas_draft,
acknowledge_local_project_asset_canvas_candidate_layers,
import_local_project_asset_canvas_images,
store_local_project_asset_canvas_media,
stage_local_project_asset_canvas_image,
finalize_local_project_asset_canvas_generation_failure,
archive_failed_local_project_asset_canvas_generation,
generate_local_project_asset_canvas_image,
recover_local_project_asset_canvas_generations,
confirm_local_project_asset_canvas_generation_service_identity,
read_local_project_asset_canvas_media,
discard_local_project_asset_canvas_draft,
recover_local_project_asset_canvas_transactions,
commit_local_project_asset,
commit_local_project_asset_canvas_candidate,
get_local_game_project_revision,
get_local_game_manifest,
download_agc_update,
@@ -488,113 +488,6 @@ const PREVIEW_FIT_BRIDGE_SCRIPT: &str = r#"(() => {
if (!document.hidden) schedule();
}, 500);
};
const inspectMessageType = 'genarrative.local-preview-inspect.v1';
const inspectState = { enabled: false, overlay: null, current: null };
const inspectOverlay = () => {
if (inspectState.overlay) return inspectState.overlay;
const overlay = document.createElement('div');
overlay.dataset.genarrativePreviewInspect = 'true';
overlay.style.cssText = 'position:fixed;z-index:2147483647;pointer-events:none;border:2px solid #f97316;background:rgba(249,115,22,0.14);box-shadow:inset 0 0 0 1px rgba(255,255,255,0.85);';
(document.body || document.documentElement).appendChild(overlay);
inspectState.overlay = overlay;
return overlay;
};
const clearInspectOverlay = () => {
inspectState.overlay?.remove();
inspectState.overlay = null;
inspectState.current = null;
};
const inspectElementSummary = (element) => {
const rect = element.getBoundingClientRect();
const tag = element.tagName ? element.tagName.toLowerCase() : 'div';
const role = (element.getAttribute && element.getAttribute('role')) || '';
const text = (element.innerText || element.textContent || '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 120);
const resourceIds = [];
if (element.getAttribute) {
for (const name of ['data-resource-id', 'data-asset-id', 'data-genarrative-resource-id']) {
const value = element.getAttribute(name);
if (value && !resourceIds.includes(value)) resourceIds.push(value);
}
}
const media = element.querySelector ? element.querySelector('img,video,audio') : null;
const rawSource =
(element.getAttribute && element.getAttribute('src')) ||
(media && media.getAttribute && media.getAttribute('src')) ||
'';
let sourcePath = '';
if (rawSource) {
try {
sourcePath = new URL(rawSource, window.location.href).pathname;
} catch {
sourcePath = '';
}
}
return {
label: text || tag,
elementTag: tag,
elementRole: role.slice(0, 80),
text,
width: Math.max(1, Math.round(rect.width)),
height: Math.max(1, Math.round(rect.height)),
resourceIds,
sourcePath,
};
};
const postInspect = (action, selection) => {
window.parent.postMessage({ type: inspectMessageType, action, selection }, '*');
};
const stopInspect = (cancelled) => {
if (!inspectState.enabled) return;
inspectState.enabled = false;
document.removeEventListener('mousemove', handleInspectMove, true);
document.removeEventListener('click', handleInspectClick, true);
document.removeEventListener('keydown', handleInspectKeyDown, true);
clearInspectOverlay();
postInspect(cancelled ? 'cancelled' : 'disabled');
};
const handleInspectMove = (event) => {
if (!inspectState.enabled) return;
const target = event.target;
if (!(target instanceof Element)) return;
inspectState.current = target;
const rect = target.getBoundingClientRect();
const overlay = inspectOverlay();
overlay.style.left = `${rect.left}px`;
overlay.style.top = `${rect.top}px`;
overlay.style.width = `${rect.width}px`;
overlay.style.height = `${rect.height}px`;
};
const handleInspectClick = (event) => {
if (!inspectState.enabled) return;
event.preventDefault();
event.stopPropagation();
const target = event.target;
if (target instanceof Element) postInspect('selected', inspectElementSummary(target));
stopInspect(false);
};
const handleInspectKeyDown = (event) => {
if (!inspectState.enabled || event.key !== 'Escape') return;
event.preventDefault();
stopInspect(true);
};
const startInspect = () => {
if (inspectState.enabled) return;
inspectState.enabled = true;
document.addEventListener('mousemove', handleInspectMove, true);
document.addEventListener('click', handleInspectClick, true);
document.addEventListener('keydown', handleInspectKeyDown, true);
postInspect('enabled');
};
window.addEventListener('message', (event) => {
if (event.source !== window.parent) return;
const data = event.data;
if (!data || data.type !== inspectMessageType) return;
if (data.action === 'enable') startInspect();
else if (data.action === 'disable') stopInspect(false);
});
if (ready) activate();
else window.addEventListener('load', activate, { once: true });
})();"#;
@@ -4,8 +4,7 @@ use similar::TextDiff;
use std::io::{Seek, SeekFrom};
mod agent_db;
mod asset_export;
mod asset_rename;
mod asset_canvas;
mod checkpoint;
mod conversation;
mod export;
@@ -17,10 +16,10 @@ mod resource_dependency_graph;
mod resource_editor;
mod resource_layout;
mod verification;
mod write_lock;
pub(crate) use agent_db::*;
pub(crate) use asset_export::*;
pub(crate) use asset_rename::*;
pub(crate) use asset_canvas::*;
pub(crate) use checkpoint::*;
pub(crate) use conversation::*;
pub(crate) use export::*;
@@ -32,3 +31,4 @@ pub(crate) use resource_dependency_graph::*;
pub(crate) use resource_editor::*;
pub(crate) use resource_layout::*;
pub(crate) use verification::*;
pub(crate) use write_lock::*;
@@ -2,7 +2,7 @@ use super::*;
use std::collections::BTreeSet;
#[cfg(windows)]
use super::filesystem::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT;
use super::write_lock::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT;
const AGENT_DB_MAX_RECORD_BYTES: usize = 1024 * 1024;
const AGENT_DB_ACTION_RECEIPT_RECORD_TYPE: &str = "agent.runtime.action_receipt";
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,255 +0,0 @@
use super::*;
use std::fs::File;
use std::io::{Read, Write};
/// 显式保存:把项目内已登记的素材文件复制到用户选定的目标路径。
///
/// 这里刻意不做"浏览器下载"——AGC 是 Tauri/WebView2 宿主,未注册 `on_download`
/// 时 `<a download>` 能否落盘不可靠,所以保存路径由原生对话框给出,复制由 Rust 完成。
const ASSET_EXPORT_COPY_CHUNK_BYTES: usize = 64 * 1024;
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct SaveLocalProjectAssetFileInput {
pub(crate) project_path: String,
pub(crate) relative_path: String,
pub(crate) destination_path: String,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct SaveLocalProjectAssetFileResult {
pub(crate) destination_path: String,
pub(crate) byte_len: u64,
}
fn resolve_export_source_file(
root: &Path,
relative_path: &str,
) -> Result<PathBuf, String> {
let normalized = normalize_relative_path(relative_path.trim())?;
if normalized.is_empty() {
return Err("待保存的素材路径不能为空".to_string());
}
let source = root.join(&normalized);
// 只允许保存项目根内真实存在的普通文件:符号链接与目录都要拒绝。
let metadata = std::fs::symlink_metadata(&source)
.map_err(|error| format!("素材文件不存在或不可读:{error}"))?;
if metadata.file_type().is_symlink() {
return Err("素材路径不能是符号链接".to_string());
}
if !metadata.is_file() {
return Err("素材路径必须是普通文件".to_string());
}
Ok(source)
}
fn resolve_export_destination_file(destination_path: &str) -> Result<PathBuf, String> {
let trimmed = destination_path.trim();
if trimmed.is_empty() {
return Err("保存目标路径不能为空".to_string());
}
let destination = PathBuf::from(trimmed);
if destination.file_name().is_none() {
return Err("保存目标必须是文件路径".to_string());
}
let parent = destination
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.ok_or_else(|| "保存目标缺少父目录".to_string())?;
if !parent.is_dir() {
return Err("保存目标的父目录不存在".to_string());
}
if destination.is_dir() {
return Err("保存目标不能是目录".to_string());
}
Ok(destination)
}
pub(crate) fn save_local_project_asset_file_at(
input: SaveLocalProjectAssetFileInput,
) -> Result<SaveLocalProjectAssetFileResult, String> {
let root = Path::new(input.project_path.trim());
validate_project_root(root)?;
enforce_project_auto_permission_policy(root, "file.read")?;
let source = resolve_export_source_file(root, &input.relative_path)?;
let destination = resolve_export_destination_file(&input.destination_path)?;
let mut reader =
File::open(&source).map_err(|error| format!("打开素材文件失败:{error}"))?;
let mut writer = File::create(&destination)
.map_err(|error| format!("创建保存目标失败:{error}"))?;
let mut buffer = vec![0_u8; ASSET_EXPORT_COPY_CHUNK_BYTES];
let mut byte_len = 0_u64;
loop {
let read = reader
.read(&mut buffer)
.map_err(|error| format!("读取素材文件失败:{error}"))?;
if read == 0 {
break;
}
writer
.write_all(&buffer[..read])
.map_err(|error| format!("写入保存目标失败:{error}"))?;
byte_len += read as u64;
}
writer
.flush()
.map_err(|error| format!("刷新保存目标失败:{error}"))?;
Ok(SaveLocalProjectAssetFileResult {
destination_path: destination.to_string_lossy().into_owned(),
byte_len,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT_ASSET_EXPORT_TEST_ID: AtomicU64 = AtomicU64::new(0);
fn unique_asset_export_directory(prefix: &str) -> PathBuf {
std::env::temp_dir().join(format!(
"genarrative-asset-export-{prefix}-{}-{}",
std::process::id(),
NEXT_ASSET_EXPORT_TEST_ID.fetch_add(1, Ordering::Relaxed)
))
}
fn create_export_project() -> (PathBuf, PathBuf) {
let root = unique_asset_export_directory("project");
let assets = root.join("assets");
std::fs::create_dir_all(&assets).expect("create project assets directory");
(root, assets)
}
#[test]
fn copies_registered_asset_to_chosen_destination() {
let (root, assets) = create_export_project();
let source_bytes = b"genarrative-asset-export-payload".to_vec();
std::fs::write(assets.join("hero.png"), &source_bytes).expect("write source asset");
let destination_directory = unique_asset_export_directory("destination");
std::fs::create_dir_all(&destination_directory).expect("create destination directory");
let destination = destination_directory.join("hero-copy.png");
let result = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput {
project_path: root.to_string_lossy().into_owned(),
relative_path: "assets/hero.png".to_string(),
destination_path: destination.to_string_lossy().into_owned(),
})
.expect("save asset file");
assert_eq!(result.byte_len, source_bytes.len() as u64);
assert_eq!(result.destination_path, destination.to_string_lossy());
assert_eq!(
std::fs::read(&destination).expect("read copied asset"),
source_bytes
);
// 源素材保持不变:保存只是复制,不改动项目内文件。
assert_eq!(
std::fs::read(assets.join("hero.png")).expect("read source asset"),
source_bytes
);
let _ = std::fs::remove_dir_all(&root);
let _ = std::fs::remove_dir_all(&destination_directory);
}
#[test]
fn rejects_missing_source_asset() {
let (root, _assets) = create_export_project();
let destination_directory = unique_asset_export_directory("destination");
std::fs::create_dir_all(&destination_directory).expect("create destination directory");
let error = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput {
project_path: root.to_string_lossy().into_owned(),
relative_path: "assets/missing.png".to_string(),
destination_path: destination_directory
.join("missing-copy.png")
.to_string_lossy()
.into_owned(),
})
.expect_err("missing source must fail");
assert!(error.contains("素材文件不存在或不可读"), "{error}");
let _ = std::fs::remove_dir_all(&root);
let _ = std::fs::remove_dir_all(&destination_directory);
}
#[test]
fn rejects_directory_as_source_asset() {
let (root, assets) = create_export_project();
std::fs::create_dir_all(assets.join("nested")).expect("create nested directory");
let destination_directory = unique_asset_export_directory("destination");
std::fs::create_dir_all(&destination_directory).expect("create destination directory");
let error = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput {
project_path: root.to_string_lossy().into_owned(),
relative_path: "assets/nested".to_string(),
destination_path: destination_directory
.join("nested-copy")
.to_string_lossy()
.into_owned(),
})
.expect_err("directory source must fail");
assert!(error.contains("必须是普通文件"), "{error}");
let _ = std::fs::remove_dir_all(&root);
let _ = std::fs::remove_dir_all(&destination_directory);
}
#[test]
fn rejects_blank_destination_and_missing_parent_directory() {
let (root, assets) = create_export_project();
std::fs::write(assets.join("hero.png"), b"payload").expect("write source asset");
let blank = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput {
project_path: root.to_string_lossy().into_owned(),
relative_path: "assets/hero.png".to_string(),
destination_path: " ".to_string(),
})
.expect_err("blank destination must fail");
assert!(blank.contains("保存目标路径不能为空"), "{blank}");
let missing_parent_directory = unique_asset_export_directory("absent");
let missing_parent = save_local_project_asset_file_at(
SaveLocalProjectAssetFileInput {
project_path: root.to_string_lossy().into_owned(),
relative_path: "assets/hero.png".to_string(),
destination_path: missing_parent_directory
.join("hero-copy.png")
.to_string_lossy()
.into_owned(),
},
)
.expect_err("missing parent directory must fail");
assert!(
missing_parent.contains("保存目标的父目录不存在"),
"{missing_parent}"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn rejects_destination_that_is_an_existing_directory() {
let (root, assets) = create_export_project();
std::fs::write(assets.join("hero.png"), b"payload").expect("write source asset");
let destination_directory = unique_asset_export_directory("destination");
std::fs::create_dir_all(&destination_directory).expect("create destination directory");
let error = save_local_project_asset_file_at(SaveLocalProjectAssetFileInput {
project_path: root.to_string_lossy().into_owned(),
relative_path: "assets/hero.png".to_string(),
destination_path: destination_directory.to_string_lossy().into_owned(),
})
.expect_err("directory destination must fail");
assert!(error.contains("保存目标不能是目录"), "{error}");
let _ = std::fs::remove_dir_all(&root);
let _ = std::fs::remove_dir_all(&destination_directory);
}
}
@@ -1,256 +0,0 @@
use super::*;
use super::filesystem::validate_portable_project_path_component;
/// 素材重命名的入参。
///
/// 只收"新文件名"而不是新旧两个全路径:改名被限制在资产当前所在目录内,目录由 manifest
/// 里的 `localPath` 决定,调用方无法指定目标目录。
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct RenameLocalProjectAssetInput {
pub(crate) project_path: String,
pub(crate) asset_id: String,
pub(crate) new_file_name: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct RenameLocalProjectAssetResult {
pub(crate) asset: GameCreationAppAssetManifestEntry,
pub(crate) previous_local_path: String,
pub(crate) committed_project_revision: u64,
}
/// 故障注入点:让测试在不依赖只读路径的前提下验证"manifest 写失败必须回滚文件改名"。
/// 生产路径始终传 `None`。
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum RenameLocalProjectAssetFaultStage {
/// 文件已改名、manifest 尚未写入时失败。
ManifestWrite,
}
/// 校验调用方给出的新文件名,返回 trim 后的名字。
///
/// 判据(按顺序):
/// - 非空;
/// - 不含路径分隔符(`/`、`\`)——这同时保证"不跨目录",改名只落在资产当前所在目录内;
/// - 不含 `..`——分隔符已被拒绝后,`..` 仍可能以"整个名字就是 `..`"的形式改变层级,一律拒绝;
/// - 通过项目路径组件的可移植性校验(控制字符、Windows 保留字符、结尾点或空格、保留设备名),
/// 与项目内其它写入路径保持同一套路径安全口径。
fn normalize_asset_file_name(value: &str) -> Result<String, String> {
let name = value.trim();
if name.is_empty() {
return Err("新文件名不能为空".to_string());
}
if name.contains('/') || name.contains('\\') {
return Err("新文件名不能包含路径分隔符".to_string());
}
if name.contains("..") {
return Err("新文件名不能包含 ..".to_string());
}
validate_portable_project_path_component(name)
.map_err(|error| format!("新文件名非法:{error}"))?;
Ok(name.to_string())
}
/// 取小写扩展名。`.gitignore` 这类隐藏文件按"无扩展名"处理。
fn asset_file_extension(file_name: &str) -> Option<String> {
Path::new(file_name)
.extension()
.and_then(std::ffi::OsStr::to_str)
.map(str::to_ascii_lowercase)
}
/// 一个帧的 `imageSrc` 是否就是被改名的那份本地文件。
///
/// 判据:既不是绝对路径、也不是带 scheme 的远程地址、也不是反斜杠路径;去掉目录后文件名与旧名
/// 一致(大小写不敏感,和扩展名比较同口径)。只认真正指向该文件的帧,同目录但指向其它文件的帧
/// 保持原样,避免改名连带改坏别的引用。
fn asset_local_frame_matches(image_src: &str, directory: &str, previous_file_name: &str) -> bool {
let image_src = image_src.trim();
if image_src.is_empty()
|| image_src.starts_with('/')
|| image_src.contains('\\')
|| image_src.contains("://")
|| Path::new(image_src).is_absolute()
{
return false;
}
let (frame_directory, frame_file_name) = match image_src.rsplit_once('/') {
Some((directory, file_name)) => (directory, file_name),
None => ("", image_src),
};
frame_directory == directory && frame_file_name.eq_ignore_ascii_case(previous_file_name)
}
/// 把 manifest 条目里指向被改名文件的序列帧对齐到新 `localPath`。
fn align_image_sequence_frames(
asset: &mut GameCreationAppAssetManifestEntry,
directory: &str,
previous_file_name: &str,
next_local_path: &str,
) {
let Some(frames) = asset.image_sequence_frames.as_mut() else {
return;
};
for frame in frames.iter_mut() {
if !asset_local_frame_matches(&frame.image_src, directory, previous_file_name) {
continue;
}
frame.image_src = next_local_path.to_string();
}
}
/// manifest 写失败后的回滚:把文件改回原名,并把回滚失败单独报出来。
fn rollback_asset_file_rename(
current_absolute: &Path,
next_absolute: &Path,
error: String,
) -> String {
match fs::rename(next_absolute, current_absolute) {
Ok(()) => error,
Err(rollback_error) => format!(
"{error}reconciliation-required: 素材文件未能改回原名:{} -> {}: {rollback_error}",
next_absolute.display(),
current_absolute.display()
),
}
}
/// 重命名一个已登记素材:**文件改名 + 更新 manifest 的 `localPath`,资产 `id` 不变**。
///
/// manifest 资产条目没有 `name` 字段,资源显示名来自 `fileName(localPath)`,因此重命名的语义
/// 只能是"改文件名 + 改 `localPath`"。
///
/// 事务顺序(全程持既有项目写锁,期间不会出现第二个项目写者;manifest 写入仍走既有边界:
/// 版本数组不可变校验 + 安装后回读一致性校验):
/// 1. 读 manifest 定位资产,取旧 `localPath`
/// 2. 校验新文件名、扩展名一致、目标不冲突,然后 `rename` 磁盘文件;
/// 3. 更新 manifest 的 `localPath``id` / `kind` / `mediaType` / `source` / `category` / `tags`
/// 全部不变),并把 `imageSequenceFrames` 里指向该文件的帧对齐到新路径;
/// 4. 写 manifest
/// 5. 第 4 步失败时把文件改回原名,不留"文件已改名但 manifest 还是旧路径"的半成品;
/// 改回原名也失败时,两个错误都报出来并标记 reconciliation-required。
pub(crate) fn rename_local_project_asset_at(
root: &Path,
asset_id: &str,
new_file_name: &str,
fault: Option<RenameLocalProjectAssetFaultStage>,
) -> Result<RenameLocalProjectAssetResult, String> {
let asset_id = asset_id.trim();
if asset_id.is_empty() {
return Err("素材重命名 assetId 不能为空".to_string());
}
let new_file_name = normalize_asset_file_name(new_file_name)?;
let _lock = acquire_project_write_lock(root, "asset.register")?;
let manifest_path = root.join(".agent/manifest.json");
let mut manifest = read_existing_manifest_for_project(root)?;
let index = manifest
.assets
.iter()
.position(|asset| asset.id == asset_id)
.ok_or_else(|| format!("项目资源不存在:{asset_id}"))?;
let previous_local_path = manifest.assets[index].local_path.clone();
let (directory, current_file_name) = match previous_local_path.rsplit_once('/') {
Some((directory, file_name)) => (directory.to_string(), file_name.to_string()),
None => (String::new(), previous_local_path.clone()),
};
// 同名重命名是空操作:磁盘与 manifest 都不动,直接回报当前状态。
if current_file_name == new_file_name {
return Ok(RenameLocalProjectAssetResult {
asset: manifest.assets[index].clone(),
previous_local_path,
committed_project_revision: read_game_creator_agent_runtime_project_revision(root)?
.revision,
});
}
let current_extension = asset_file_extension(&current_file_name);
let next_extension = asset_file_extension(&new_file_name);
if current_extension != next_extension {
return Err(format!(
"新文件名扩展名必须与原文件一致,原扩展名:{}",
current_extension
.map(|extension| format!(".{extension}"))
.unwrap_or_else(|| "".to_string())
));
}
let next_local_path = normalize_relative_path(&if directory.is_empty() {
new_file_name.clone()
} else {
format!("{directory}/{new_file_name}")
})?;
let current_absolute = resolve_local_project_path(root, &previous_local_path)?;
let next_absolute = resolve_local_project_path(root, &next_local_path)?;
// 被改名的必须是真的普通文件:登记与磁盘不一致时先报错,不写出指向"新名字"的悬空登记。
match fs::symlink_metadata(&current_absolute) {
Ok(metadata) if metadata.is_file() && !metadata.file_type().is_symlink() => {}
Ok(_) => return Err(format!("素材路径必须是普通文件:{previous_local_path}")),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Err(format!("素材文件不存在:{previous_local_path}"));
}
Err(error) => {
return Err(format!(
"读取素材元数据失败:{}: {error}",
current_absolute.display()
));
}
}
// 同目录内不得已存在同名文件:先检查再改名,避免 `rename` 覆盖既有文件。
// 只差文件名大小写的改名在大小写不敏感的文件系统上会命中同一条检查,按"同名冲突"拒绝。
match fs::symlink_metadata(&next_absolute) {
Ok(_) => return Err(format!("同目录已存在同名文件:{new_file_name}")),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => {
return Err(format!(
"读取目标素材元数据失败:{}: {error}",
next_absolute.display()
));
}
}
fs::rename(&current_absolute, &next_absolute).map_err(|error| {
format!(
"素材文件改名失败:{} -> {}: {error}",
current_absolute.display(),
next_absolute.display()
)
})?;
manifest.assets[index].local_path = next_local_path.clone();
align_image_sequence_frames(
&mut manifest.assets[index],
&directory,
&current_file_name,
&next_local_path,
);
let asset = manifest.assets[index].clone();
// 文件已改名:从这里开始的任何失败都必须把文件改回原名。
let write_error = match fault {
Some(RenameLocalProjectAssetFaultStage::ManifestWrite) => {
Some("fault-injected:rename-asset-manifest-write".to_string())
}
None => write_manifest(&manifest_path, &manifest).err(),
};
if let Some(error) = write_error {
return Err(rollback_asset_file_rename(
&current_absolute,
&next_absolute,
error,
));
}
let committed_project_revision = advance_agent_runtime_project_revision_locked(root)
.map_err(|error| format!("素材已改名,但项目 revision 未能推进:{error}"))?;
Ok(RenameLocalProjectAssetResult {
asset,
previous_local_path,
committed_project_revision,
})
}
@@ -3,7 +3,7 @@ use super::*;
use super::filesystem::validate_portable_project_path_component;
#[cfg(windows)]
use super::filesystem::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT;
use super::write_lock::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT;
#[cfg(windows)]
fn windows_regular_file_handle_identity(file: &File, label: &str) -> Result<(u32, u64), String> {
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More