532c6d51fd
## 背景 本合并请求整合 Game Agent 资源管理的栏目分页自由画布,以及图片持续精修、候选生成和本地恢复事务。 当前远端比较基线: - base:`master` @ `b00a3f80576949567bf3320f0c6c92b4ed0a649d` - head:`codex/game-agent-resource-section-pages` @ `5795d2f9b8797c342dcf2920ccefec2a3ff3f472` - 相对 base:28 commits、33 files(`+3746 / -1333`) ## 主要改动 ### 资源管理分页自由画布 - 固定展示“设计文档 → 美术资源 → 音乐音效 → 游戏代码 → 项目版本”五个栏目;空栏目仍可从悬浮 Dock 打开空画布。 - `按依赖` 与 `按类型` 共用栏目分页画布;每个“排序模式 + 栏目”组合独立保存 viewport。 - 普通滚轮使用有界意图队列切换栏目;Ctrl/Meta + 滚轮以指针为锚点缩放;空白拖拽可沿 x/y 无限平移,不以资源 extent 作为导航边界。 - 资源卡支持拖拽布局与一次 CAS 持久化;切页、排序切换和卸载会统一取消拖拽及 pointer capture。 - 资源详情为非模态独立卡片:打开、切换或关闭详情不卸载背景画布、资源卡或依赖连线,也不重置 viewport、搜索或排序状态。 - 顶部已移除“生成视频 / 生成音效 / 生成背景音乐 / 新增 UI 设计”的手动入口;保留播放、未完成编辑恢复、排序和画布复位,以及资源详情中的既有编辑动作。 ### 图片持续精修与生成事务 - 图片资源可进入唯一活动精修草稿,支持原生批量导入、图片下方快速编辑卡、候选图层、任务侧栏和“设为最终图”。 - 生成成功只合并候选图层和 generation 权威事实,不以全量 hydrate 覆盖生成期间的本地编辑。 - 候选媒体、图层和公开 generation 记录写入并回读成功后,才推进私有 `candidate-ready`。 - 候选确认接入 Surface 保存 FIFO 与重新打开 hydrate;revision-sensitive 操作等待确认屏障。 - 失败归档和正式图提交采用可恢复中间态,避免中断后出现幽灵任务、重复 revision 或不一致的正式资产。 ### 合同与文档 - `acknowledgeCandidateLayers`、`importLocalImages`、`archiveFailedGeneration` 为必选 Host Port;不支持的宿主返回结构化 `unsupported-capability`。 - 同步更新 TypeScript / Rust 合同、Tauri command 可达性、PRD、技术方案与项目共享记忆。 - 明确资源栏目无限画布合同:普通平移不夹取;资源 extent、图片测量、布局变更和 resize 不得重置用户 viewport;仅首次可测量布局与显式复位按真实卡片包围盒适配内容。 ## 验证 CI run [#1290](https://git.genarrative.world/git/GenarrativeAI/Genarrative/actions/runs/1290) 针对当前 head 已完成且四项均成功: - [x] Repository checks - [x] Frontend tests(235 test files、3166 tests passed;App Surface 410 tests) - [x] Backend tests - [x] Native shell tests 另有本地定向检查: - [x] `npm run agc:typecheck` - [x] 资源画布、App Surface、Asset Canvas 与资源实时集成定向回归 - [x] Rust 候选确认、候选中断恢复、失败归档与恢复定向测试 - [x] `cargo fmt --check` - [x] `npm run check:encoding` - [x] `git diff --check` ## 评审 provenance - review #247、#248、#252 都针对旧 head,当前在 Gitea 中均标记为 stale;不将其写作“已处理 #247”或当前 head 的评审结论。 - review #253 针对旧 `5456a1d…` head,提出 PRD 退役入口、共享 pitfalls 的 extent 旧合同和 PR 正文事实更新三项 P2;对应文档已在 `5795d2f…` head 修复并推送。 - 早期由 PR #176 引入的候选持久化、归档、Host Port 与快速编辑卡问题,当前已在 base/head 中具备对应修复,不作为本轮重复阻断项。 - 当前 head 仍需新的、针对最新提交的审批;历史 stale review 不能替代当前 head approval。 ## 影响范围 - 修改 AI 游戏创作 App 的资源管理、图片精修、Tauri 本地持久化与恢复语义,以及共享合同和权威文档。 - 会调整本地资源布局、精修 draft 和私有 generation ledger 的状态机。 - 不修改 SpacetimeDB schema,不新增或变更 `/api/external/v1` 路由。 --------- Co-authored-by: kdletters <kdletters@qq.com> Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/181 Co-authored-by: suzmii <suzmii@qq.com> Co-committed-by: suzmii <suzmii@qq.com>
1043 lines
41 KiB
Rust
1043 lines
41 KiB
Rust
use super::*;
|
||
use shared_contracts::game_creation_app::{
|
||
validate_project_resource_canvas_layout_revision,
|
||
GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION,
|
||
};
|
||
use std::collections::HashSet;
|
||
use std::fs::File;
|
||
use std::sync::{Mutex, OnceLock};
|
||
|
||
const RESOURCE_LAYOUT_DIRECTORY: &str = ".agent/workbench/resource-layouts";
|
||
const RESOURCE_LAYOUT_LOCK_PATH: &str = ".agent/workbench/resource-layouts/.layout.lock";
|
||
const RESOURCE_LAYOUT_MAX_BYTES: usize = 2 * 1024 * 1024;
|
||
const RESOURCE_LAYOUT_MAX_POSITIONS: usize = 4096;
|
||
const RESOURCE_LAYOUT_MAX_RESOURCE_ID_CHARS: usize = 512;
|
||
const RESOURCE_LAYOUT_MIN_COORDINATE: i32 = -1_000_000;
|
||
const RESOURCE_LAYOUT_MAX_COORDINATE: i32 = 1_000_000;
|
||
const RESOURCE_LAYOUT_LOCK_WAIT_ATTEMPTS: usize = 100;
|
||
const RESOURCE_LAYOUT_LOCK_WAIT_MILLIS: u64 = 10;
|
||
|
||
static RESOURCE_LAYOUT_LOCK_OPEN_GUARD: OnceLock<Mutex<()>> = OnceLock::new();
|
||
|
||
#[derive(Debug)]
|
||
struct ResourceLayoutWriteLock {
|
||
_file: File,
|
||
}
|
||
|
||
fn resource_layout_mode_name(mode: ProjectResourceCanvasLayoutMode) -> &'static str {
|
||
match mode {
|
||
ProjectResourceCanvasLayoutMode::Dependency => "dependency",
|
||
ProjectResourceCanvasLayoutMode::Type => "type",
|
||
}
|
||
}
|
||
|
||
fn resource_layout_relative_path(mode: ProjectResourceCanvasLayoutMode) -> String {
|
||
format!(
|
||
"{RESOURCE_LAYOUT_DIRECTORY}/{}.json",
|
||
resource_layout_mode_name(mode)
|
||
)
|
||
}
|
||
|
||
fn acquire_resource_layout_write_lock(root: &Path) -> Result<ResourceLayoutWriteLock, String> {
|
||
for attempt in 0..RESOURCE_LAYOUT_LOCK_WAIT_ATTEMPTS {
|
||
if let Some(file) = try_open_resource_layout_write_lock_file(root)? {
|
||
return Ok(ResourceLayoutWriteLock { _file: file });
|
||
}
|
||
if attempt + 1 < RESOURCE_LAYOUT_LOCK_WAIT_ATTEMPTS {
|
||
std::thread::sleep(Duration::from_millis(RESOURCE_LAYOUT_LOCK_WAIT_MILLIS));
|
||
}
|
||
}
|
||
Err("资源布局正在被其他窗口保存,请稍后重试".to_string())
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
fn try_open_resource_layout_write_lock_file(root: &Path) -> Result<Option<File>, String> {
|
||
use std::ffi::CString;
|
||
use std::os::fd::{AsRawFd, FromRawFd};
|
||
use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt};
|
||
|
||
let _open_guard = RESOURCE_LAYOUT_LOCK_OPEN_GUARD
|
||
.get_or_init(|| Mutex::new(()))
|
||
.lock()
|
||
.map_err(|_| "资源布局锁安全打开门禁已损坏".to_string())?;
|
||
validate_project_root(root)?;
|
||
let relative_path = normalize_relative_path(RESOURCE_LAYOUT_LOCK_PATH)?;
|
||
let path = root.join(&relative_path);
|
||
let mut components = relative_path.split('/').collect::<Vec<_>>();
|
||
let file_name = components
|
||
.pop()
|
||
.ok_or_else(|| "资源布局锁路径缺少文件名".to_string())?;
|
||
let mut directory = fs::OpenOptions::new()
|
||
.read(true)
|
||
.custom_flags(libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW)
|
||
.open(root)
|
||
.map_err(|error| format!("安全打开项目目录失败:{}: {error}", root.display()))?;
|
||
for component in components {
|
||
let component =
|
||
CString::new(component).map_err(|_| "资源布局锁目录包含 NUL".to_string())?;
|
||
// SAFETY: `directory` is a live directory fd and `component` is NUL terminated.
|
||
let created = unsafe { libc::mkdirat(directory.as_raw_fd(), component.as_ptr(), 0o700) };
|
||
if created != 0 {
|
||
let error = std::io::Error::last_os_error();
|
||
if error.kind() != std::io::ErrorKind::AlreadyExists {
|
||
return Err(format!(
|
||
"创建资源布局锁目录失败:{}: {error}",
|
||
path.display()
|
||
));
|
||
}
|
||
}
|
||
// SAFETY: `directory` and `component` remain valid for the duration of openat.
|
||
let fd = unsafe {
|
||
libc::openat(
|
||
directory.as_raw_fd(),
|
||
component.as_ptr(),
|
||
libc::O_RDONLY | libc::O_CLOEXEC | libc::O_DIRECTORY | libc::O_NOFOLLOW,
|
||
)
|
||
};
|
||
if fd < 0 {
|
||
return Err(format!(
|
||
"安全打开资源布局锁目录失败:{}: {}",
|
||
path.display(),
|
||
std::io::Error::last_os_error()
|
||
));
|
||
}
|
||
// SAFETY: openat returned a new owned fd.
|
||
directory = unsafe { File::from_raw_fd(fd) };
|
||
}
|
||
let file_name = CString::new(file_name).map_err(|_| "资源布局锁文件名包含 NUL".to_string())?;
|
||
// SAFETY: `directory` is a live directory fd and `file_name` is NUL terminated.
|
||
let fd = unsafe {
|
||
libc::openat(
|
||
directory.as_raw_fd(),
|
||
file_name.as_ptr(),
|
||
libc::O_RDWR | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW,
|
||
0o600,
|
||
)
|
||
};
|
||
if fd < 0 {
|
||
return Err(format!(
|
||
"安全打开资源布局锁失败:{}: {}",
|
||
path.display(),
|
||
std::io::Error::last_os_error()
|
||
));
|
||
}
|
||
// SAFETY: openat returned a new owned fd.
|
||
let file = unsafe { File::from_raw_fd(fd) };
|
||
let metadata = file
|
||
.metadata()
|
||
.map_err(|error| format!("读取资源布局锁句柄元数据失败:{}: {error}", path.display()))?;
|
||
// SAFETY: geteuid takes no arguments and has no memory safety preconditions.
|
||
let effective_user_id = unsafe { libc::geteuid() };
|
||
if !metadata.is_file() || metadata.uid() != effective_user_id || metadata.nlink() != 1 {
|
||
return Err(format!(
|
||
"资源布局锁必须是当前用户持有的无硬链接普通文件:{}",
|
||
path.display()
|
||
));
|
||
}
|
||
file.set_permissions(fs::Permissions::from_mode(0o600))
|
||
.map_err(|error| format!("收紧资源布局锁权限失败:{}: {error}", path.display()))?;
|
||
let path_metadata = fs::symlink_metadata(&path)
|
||
.map_err(|error| format!("复核资源布局锁路径失败:{}: {error}", path.display()))?;
|
||
if path_metadata.file_type().is_symlink()
|
||
|| path_metadata.dev() != metadata.dev()
|
||
|| path_metadata.ino() != metadata.ino()
|
||
{
|
||
return Err(format!(
|
||
"资源布局锁路径在安全打开期间发生替换:{}",
|
||
path.display()
|
||
));
|
||
}
|
||
let verified = file
|
||
.metadata()
|
||
.map_err(|error| format!("复核资源布局锁句柄失败:{}: {error}", path.display()))?;
|
||
if verified.uid() != effective_user_id
|
||
|| verified.nlink() != 1
|
||
|| verified.permissions().mode() & 0o777 != 0o600
|
||
{
|
||
return Err(format!(
|
||
"资源布局锁必须由当前用户持有且权限为 0600:{}",
|
||
path.display()
|
||
));
|
||
}
|
||
// SAFETY: flock observes only the live fd owned by `file`; dropping it releases the lock.
|
||
if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 {
|
||
return Ok(Some(file));
|
||
}
|
||
let error = std::io::Error::last_os_error();
|
||
if error.kind() == std::io::ErrorKind::WouldBlock {
|
||
Ok(None)
|
||
} else {
|
||
Err(format!(
|
||
"获取资源布局系统文件锁失败:{}: {error}",
|
||
path.display()
|
||
))
|
||
}
|
||
}
|
||
|
||
#[cfg(windows)]
|
||
fn try_open_resource_layout_write_lock_file(root: &Path) -> Result<Option<File>, String> {
|
||
use std::os::windows::fs::{MetadataExt, OpenOptionsExt};
|
||
|
||
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x0000_0400;
|
||
const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
|
||
const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
|
||
const FILE_SHARE_READ_WRITE: u32 = 0x0000_0003;
|
||
|
||
let _open_guard = RESOURCE_LAYOUT_LOCK_OPEN_GUARD
|
||
.get_or_init(|| Mutex::new(()))
|
||
.lock()
|
||
.map_err(|_| "资源布局锁安全打开门禁已损坏".to_string())?;
|
||
validate_project_root(root)?;
|
||
let relative_path = normalize_relative_path(RESOURCE_LAYOUT_LOCK_PATH)?;
|
||
let path = root.join(&relative_path);
|
||
let mut components = relative_path.split('/').collect::<Vec<_>>();
|
||
components
|
||
.pop()
|
||
.ok_or_else(|| "资源布局锁路径缺少文件名".to_string())?;
|
||
let mut guarded_directories = Vec::with_capacity(components.len() + 1);
|
||
let mut current = root.to_path_buf();
|
||
for component in std::iter::once(None).chain(components.into_iter().map(Some)) {
|
||
if let Some(component) = component {
|
||
current.push(component);
|
||
if !current.exists() {
|
||
fs::create_dir(¤t).map_err(|error| {
|
||
format!("创建资源布局锁目录失败:{}: {error}", current.display())
|
||
})?;
|
||
}
|
||
}
|
||
let metadata = fs::symlink_metadata(¤t).map_err(|error| {
|
||
format!(
|
||
"读取资源布局锁目录元数据失败:{}: {error}",
|
||
current.display()
|
||
)
|
||
})?;
|
||
if !metadata.is_dir() || metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
||
return Err(format!(
|
||
"资源布局锁目录必须是普通目录且不能是 reparse point:{}",
|
||
current.display()
|
||
));
|
||
}
|
||
let handle = fs::OpenOptions::new()
|
||
.read(true)
|
||
.share_mode(FILE_SHARE_READ_WRITE)
|
||
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
|
||
.open(¤t)
|
||
.map_err(|error| {
|
||
format!("安全打开资源布局锁目录失败:{}: {error}", current.display())
|
||
})?;
|
||
let opened = handle
|
||
.metadata()
|
||
.map_err(|error| format!("复核资源布局锁目录失败:{}: {error}", current.display()))?;
|
||
if !opened.is_dir() || opened.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
||
return Err(format!(
|
||
"资源布局锁目录打开后身份无效:{}",
|
||
current.display()
|
||
));
|
||
}
|
||
guarded_directories.push(handle);
|
||
}
|
||
if let Ok(metadata) = fs::symlink_metadata(&path) {
|
||
if metadata.file_type().is_symlink()
|
||
|| !metadata.is_file()
|
||
|| metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
|
||
{
|
||
return Err(format!(
|
||
"资源布局锁必须是普通文件且不能是 reparse point:{}",
|
||
path.display()
|
||
));
|
||
}
|
||
}
|
||
let existed = path.exists();
|
||
match fs::OpenOptions::new()
|
||
.create(true)
|
||
.read(true)
|
||
.write(true)
|
||
.share_mode(0)
|
||
.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
|
||
.open(&path)
|
||
{
|
||
Ok(file) => {
|
||
validate_windows_regular_file_handle(&file, "资源布局锁")?;
|
||
if existed {
|
||
crate::secure_windows_game_creator_path_for_current_user(&path, false, true)?;
|
||
} else {
|
||
crate::initialize_windows_game_creator_file_owner_for_current_user(&path)?;
|
||
}
|
||
Ok(Some(file))
|
||
}
|
||
Err(error)
|
||
if matches!(
|
||
error.kind(),
|
||
std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::WouldBlock
|
||
) || matches!(error.raw_os_error(), Some(32 | 33)) =>
|
||
{
|
||
Ok(None)
|
||
}
|
||
Err(error) => Err(format!(
|
||
"获取资源布局系统文件锁失败:{}: {error}",
|
||
path.display()
|
||
)),
|
||
}
|
||
}
|
||
|
||
#[cfg(not(any(unix, windows)))]
|
||
fn try_open_resource_layout_write_lock_file(root: &Path) -> Result<Option<File>, String> {
|
||
Err(format!(
|
||
"当前平台不支持资源布局系统文件锁:{}",
|
||
root.join(RESOURCE_LAYOUT_LOCK_PATH).display()
|
||
))
|
||
}
|
||
|
||
fn current_resource_layout_project_id(root: &Path) -> Result<String, String> {
|
||
validate_project_root(root)?;
|
||
let manifest_path = resolve_local_project_path(root, ".agent/manifest.json")?;
|
||
let manifest = read_manifest(&manifest_path)?;
|
||
let project_id = manifest.project_id.trim();
|
||
if project_id.is_empty() {
|
||
return Err("项目 manifest 缺少 projectId".to_string());
|
||
}
|
||
Ok(project_id.to_string())
|
||
}
|
||
|
||
fn empty_resource_layout(
|
||
project_id: String,
|
||
mode: ProjectResourceCanvasLayoutMode,
|
||
) -> ProjectResourceCanvasLayout {
|
||
ProjectResourceCanvasLayout {
|
||
schema_version: GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION.to_string(),
|
||
project_id,
|
||
mode,
|
||
revision: 0,
|
||
positions: Vec::new(),
|
||
updated_at: 0,
|
||
}
|
||
}
|
||
|
||
fn validate_resource_layout_positions(
|
||
positions: &[ProjectResourceCanvasPosition],
|
||
) -> Result<(), String> {
|
||
if positions.len() > RESOURCE_LAYOUT_MAX_POSITIONS {
|
||
return Err(format!(
|
||
"资源布局最多支持 {RESOURCE_LAYOUT_MAX_POSITIONS} 个位置"
|
||
));
|
||
}
|
||
let mut resource_ids = HashSet::with_capacity(positions.len());
|
||
for position in positions {
|
||
let resource_id = position.resource_id.trim();
|
||
if resource_id.is_empty() {
|
||
return Err("资源布局 resourceId 不能为空".to_string());
|
||
}
|
||
if resource_id.chars().count() > RESOURCE_LAYOUT_MAX_RESOURCE_ID_CHARS {
|
||
return Err(format!(
|
||
"资源布局 resourceId 最多支持 {RESOURCE_LAYOUT_MAX_RESOURCE_ID_CHARS} 个字符"
|
||
));
|
||
}
|
||
if resource_id.chars().any(char::is_control) {
|
||
return Err("资源布局 resourceId 不能包含控制字符".to_string());
|
||
}
|
||
if !resource_ids.insert(resource_id) {
|
||
return Err("资源布局 resourceId 不能重复".to_string());
|
||
}
|
||
if position.x < RESOURCE_LAYOUT_MIN_COORDINATE
|
||
|| position.x > RESOURCE_LAYOUT_MAX_COORDINATE
|
||
|| position.y < RESOURCE_LAYOUT_MIN_COORDINATE
|
||
|| position.y > RESOURCE_LAYOUT_MAX_COORDINATE
|
||
{
|
||
return Err(format!(
|
||
"资源布局坐标必须在 {RESOURCE_LAYOUT_MIN_COORDINATE} 到 {RESOURCE_LAYOUT_MAX_COORDINATE} 之间"
|
||
));
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn validate_resource_layout(
|
||
layout: &ProjectResourceCanvasLayout,
|
||
project_id: &str,
|
||
mode: ProjectResourceCanvasLayoutMode,
|
||
) -> Result<(), String> {
|
||
if layout.schema_version != GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION {
|
||
return Err(format!(
|
||
"不支持的资源布局 schema:{}",
|
||
layout.schema_version
|
||
));
|
||
}
|
||
if layout.project_id != project_id {
|
||
return Err("资源布局 projectId 与当前项目不匹配".to_string());
|
||
}
|
||
if layout.mode != mode {
|
||
return Err("资源布局 mode 与文件不匹配".to_string());
|
||
}
|
||
validate_project_resource_canvas_layout_revision(layout.revision).map_err(str::to_string)?;
|
||
validate_resource_layout_positions(&layout.positions)
|
||
}
|
||
|
||
fn validate_existing_resource_layout_storage(
|
||
root: &Path,
|
||
relative_path: &str,
|
||
) -> Result<(), String> {
|
||
let path = resolve_local_project_path(root, relative_path)?;
|
||
match fs::symlink_metadata(&path) {
|
||
Ok(_) => {
|
||
let (_, metadata) = open_project_snapshot_regular_file(&path, "资源布局 sidecar")?;
|
||
if metadata.len() > RESOURCE_LAYOUT_MAX_BYTES as u64 {
|
||
return Err(format!(
|
||
"资源布局 sidecar 超过 {RESOURCE_LAYOUT_MAX_BYTES} 字节上限"
|
||
));
|
||
}
|
||
}
|
||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||
Err(error) => {
|
||
return Err(format!(
|
||
"读取资源布局 sidecar 元数据失败:{}: {error}",
|
||
path.display()
|
||
));
|
||
}
|
||
}
|
||
let backup_path = agent_runtime_json_sidecar_backup_path(&path);
|
||
match fs::symlink_metadata(&backup_path) {
|
||
Ok(_) => {
|
||
let (_, metadata) =
|
||
open_project_snapshot_regular_file(&backup_path, "资源布局恢复副本")?;
|
||
if metadata.len() > RESOURCE_LAYOUT_MAX_BYTES as u64 {
|
||
return Err(format!(
|
||
"资源布局恢复副本超过 {RESOURCE_LAYOUT_MAX_BYTES} 字节上限"
|
||
));
|
||
}
|
||
}
|
||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||
Err(error) => {
|
||
return Err(format!(
|
||
"读取资源布局恢复副本元数据失败:{}: {error}",
|
||
backup_path.display()
|
||
));
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn read_resource_layout_sidecar(
|
||
root: &Path,
|
||
relative_path: &str,
|
||
) -> Result<Option<ProjectResourceCanvasLayout>, String> {
|
||
validate_existing_resource_layout_storage(root, relative_path)?;
|
||
read_agent_runtime_json_sidecar_with_max_bytes(
|
||
root,
|
||
relative_path,
|
||
"资源布局 sidecar",
|
||
RESOURCE_LAYOUT_MAX_BYTES,
|
||
)
|
||
}
|
||
|
||
pub(crate) fn read_project_resource_canvas_layout_at(
|
||
root: &Path,
|
||
mode: ProjectResourceCanvasLayoutMode,
|
||
) -> Result<ProjectResourceCanvasLayout, String> {
|
||
let project_id = current_resource_layout_project_id(root)?;
|
||
let relative_path = resource_layout_relative_path(mode);
|
||
let Some(layout) = read_resource_layout_sidecar(root, &relative_path)? else {
|
||
return Ok(empty_resource_layout(project_id, mode));
|
||
};
|
||
validate_resource_layout(&layout, &project_id, mode)?;
|
||
Ok(layout)
|
||
}
|
||
|
||
pub(crate) fn update_project_resource_canvas_layout_at(
|
||
root: &Path,
|
||
mode: ProjectResourceCanvasLayoutMode,
|
||
expected_project_id: &str,
|
||
expected_revision: u64,
|
||
positions: Vec<ProjectResourceCanvasPosition>,
|
||
) -> Result<UpdateProjectResourceCanvasLayoutResult, String> {
|
||
validate_project_resource_canvas_layout_revision(expected_revision).map_err(str::to_string)?;
|
||
validate_resource_layout_positions(&positions)?;
|
||
let expected_project_id = expected_project_id.trim();
|
||
if expected_project_id.is_empty() {
|
||
return Err("资源布局 expectedProjectId 不能为空".to_string());
|
||
}
|
||
let preflight_project_id = current_resource_layout_project_id(root)?;
|
||
if preflight_project_id != expected_project_id {
|
||
return Err("资源布局 expectedProjectId 与当前项目不匹配".to_string());
|
||
}
|
||
let _lock = acquire_resource_layout_write_lock(root)?;
|
||
let project_id = current_resource_layout_project_id(root)?;
|
||
if project_id != expected_project_id {
|
||
return Err("项目 manifest 在资源布局锁获取期间发生变化,请重试".to_string());
|
||
}
|
||
let relative_path = resource_layout_relative_path(mode);
|
||
let current = match read_resource_layout_sidecar(root, &relative_path)? {
|
||
Some(layout) => {
|
||
validate_resource_layout(&layout, &project_id, mode)?;
|
||
layout
|
||
}
|
||
None => empty_resource_layout(project_id.clone(), mode),
|
||
};
|
||
if current.revision != expected_revision {
|
||
return Ok(UpdateProjectResourceCanvasLayoutResult {
|
||
status: UpdateProjectResourceCanvasLayoutStatus::Conflict,
|
||
layout: current,
|
||
});
|
||
}
|
||
if current.revision == GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION {
|
||
return Err("资源布局 revision 已达到 JavaScript 安全整数上限,无法继续保存".to_string());
|
||
}
|
||
let next_revision = current.revision + 1;
|
||
if current_resource_layout_project_id(root)? != project_id {
|
||
return Err("项目 manifest 在资源布局写入前发生变化,请重试".to_string());
|
||
}
|
||
let next = ProjectResourceCanvasLayout {
|
||
schema_version: GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION.to_string(),
|
||
project_id,
|
||
mode,
|
||
revision: next_revision,
|
||
positions,
|
||
updated_at: unix_millis().min(u128::from(u64::MAX)) as u64,
|
||
};
|
||
validate_existing_resource_layout_storage(root, &relative_path)?;
|
||
write_agent_runtime_json_sidecar_with_max_bytes(
|
||
root,
|
||
&relative_path,
|
||
"资源布局 sidecar",
|
||
&next,
|
||
RESOURCE_LAYOUT_MAX_BYTES,
|
||
)?;
|
||
Ok(UpdateProjectResourceCanvasLayoutResult {
|
||
status: UpdateProjectResourceCanvasLayoutStatus::Updated,
|
||
layout: next,
|
||
})
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use std::sync::{
|
||
atomic::{AtomicU64, Ordering},
|
||
Arc, Barrier,
|
||
};
|
||
|
||
static NEXT_RESOURCE_LAYOUT_TEST_ID: AtomicU64 = AtomicU64::new(0);
|
||
|
||
fn unique_resource_layout_project_path() -> PathBuf {
|
||
std::env::temp_dir().join(format!(
|
||
"genarrative-resource-layout-{}-{}-{}",
|
||
std::process::id(),
|
||
SystemTime::now()
|
||
.duration_since(UNIX_EPOCH)
|
||
.unwrap_or_default()
|
||
.as_nanos(),
|
||
NEXT_RESOURCE_LAYOUT_TEST_ID.fetch_add(1, Ordering::Relaxed)
|
||
))
|
||
}
|
||
|
||
fn layout_position(id: &str, x: i32) -> ProjectResourceCanvasPosition {
|
||
ProjectResourceCanvasPosition {
|
||
resource_id: id.to_string(),
|
||
section: shared_contracts::game_creation_app::ProjectResourceCanvasSection::Art,
|
||
x,
|
||
y: 24,
|
||
manually_placed: true,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn resource_layout_missing_file_starts_at_revision_zero_and_modes_are_independent() {
|
||
let root = unique_resource_layout_project_path();
|
||
init_local_game_project_at(&root, "layout-project", "布局项目").expect("init project");
|
||
|
||
let dependency = read_project_resource_canvas_layout_at(
|
||
&root,
|
||
ProjectResourceCanvasLayoutMode::Dependency,
|
||
)
|
||
.expect("read dependency layout");
|
||
assert_eq!(dependency.revision, 0);
|
||
assert!(dependency.positions.is_empty());
|
||
assert!(!root
|
||
.join(resource_layout_relative_path(
|
||
ProjectResourceCanvasLayoutMode::Dependency
|
||
))
|
||
.exists());
|
||
|
||
let updated = update_project_resource_canvas_layout_at(
|
||
&root,
|
||
ProjectResourceCanvasLayoutMode::Dependency,
|
||
"layout-project",
|
||
0,
|
||
vec![layout_position("asset-a", 18)],
|
||
)
|
||
.expect("update dependency layout");
|
||
assert_eq!(
|
||
updated.status,
|
||
UpdateProjectResourceCanvasLayoutStatus::Updated
|
||
);
|
||
assert_eq!(updated.layout.revision, 1);
|
||
|
||
let type_layout =
|
||
read_project_resource_canvas_layout_at(&root, ProjectResourceCanvasLayoutMode::Type)
|
||
.expect("read type layout");
|
||
assert_eq!(type_layout.revision, 0);
|
||
assert!(type_layout.positions.is_empty());
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn resource_layout_invalid_roots_have_zero_workbench_side_effects() {
|
||
let missing_root = unique_resource_layout_project_path();
|
||
let missing_error = update_project_resource_canvas_layout_at(
|
||
&missing_root,
|
||
ProjectResourceCanvasLayoutMode::Dependency,
|
||
"missing-project",
|
||
0,
|
||
vec![layout_position("asset-a", 10)],
|
||
)
|
||
.expect_err("missing project root must fail");
|
||
assert!(missing_error.contains("manifest") || missing_error.contains("项目"));
|
||
assert!(!missing_root.exists());
|
||
|
||
let non_project_root = unique_resource_layout_project_path();
|
||
fs::create_dir_all(&non_project_root).expect("create non-project root");
|
||
update_project_resource_canvas_layout_at(
|
||
&non_project_root,
|
||
ProjectResourceCanvasLayoutMode::Dependency,
|
||
"non-project",
|
||
0,
|
||
vec![layout_position("asset-a", 10)],
|
||
)
|
||
.expect_err("non-project root must fail");
|
||
assert!(!non_project_root.join(".agent").exists());
|
||
fs::remove_dir_all(&non_project_root).ok();
|
||
|
||
let invalid_manifest_root = unique_resource_layout_project_path();
|
||
let agent_directory = invalid_manifest_root.join(".agent");
|
||
fs::create_dir_all(&agent_directory).expect("create invalid project agent directory");
|
||
fs::write(agent_directory.join("manifest.json"), b"{invalid-json")
|
||
.expect("write invalid manifest");
|
||
update_project_resource_canvas_layout_at(
|
||
&invalid_manifest_root,
|
||
ProjectResourceCanvasLayoutMode::Dependency,
|
||
"invalid-project",
|
||
0,
|
||
vec![layout_position("asset-a", 10)],
|
||
)
|
||
.expect_err("invalid project manifest must fail");
|
||
assert!(!agent_directory.join("workbench").exists());
|
||
fs::remove_dir_all(invalid_manifest_root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn resource_layout_expected_project_id_fences_stale_windows_before_lock_side_effects() {
|
||
let root = unique_resource_layout_project_path();
|
||
init_local_game_project_at(&root, "layout-current-project", "布局当前项目")
|
||
.expect("init project");
|
||
|
||
let error = update_project_resource_canvas_layout_at(
|
||
&root,
|
||
ProjectResourceCanvasLayoutMode::Dependency,
|
||
"layout-stale-project",
|
||
0,
|
||
vec![layout_position("asset-a", 10)],
|
||
)
|
||
.expect_err("stale project window must fail");
|
||
assert!(error.contains("expectedProjectId"));
|
||
assert!(!root.join(".agent/workbench").exists());
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn resource_layout_live_system_lock_is_not_reclaimed_from_old_mtime() {
|
||
use std::fs::FileTimes;
|
||
use std::os::unix::fs::MetadataExt;
|
||
|
||
let root = unique_resource_layout_project_path();
|
||
init_local_game_project_at(&root, "layout-system-lock", "布局系统锁")
|
||
.expect("init project");
|
||
let first = acquire_resource_layout_write_lock(&root).expect("acquire first lock");
|
||
let lock_path = root.join(RESOURCE_LAYOUT_LOCK_PATH);
|
||
let original = fs::symlink_metadata(&lock_path).expect("stat original lock");
|
||
first
|
||
._file
|
||
.set_times(FileTimes::new().set_modified(UNIX_EPOCH))
|
||
.expect("age live lock mtime");
|
||
|
||
assert!(try_open_resource_layout_write_lock_file(&root)
|
||
.expect("try competing lock")
|
||
.is_none());
|
||
let while_locked = fs::symlink_metadata(&lock_path).expect("stat live lock");
|
||
assert_eq!(while_locked.dev(), original.dev());
|
||
assert_eq!(while_locked.ino(), original.ino());
|
||
|
||
drop(first);
|
||
let second = try_open_resource_layout_write_lock_file(&root)
|
||
.expect("try released lock")
|
||
.expect("released system lock must be acquirable");
|
||
let after_reacquire = fs::symlink_metadata(&lock_path).expect("stat reacquired lock");
|
||
assert_eq!(after_reacquire.dev(), original.dev());
|
||
assert_eq!(after_reacquire.ino(), original.ino());
|
||
drop(second);
|
||
assert!(lock_path.exists());
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn resource_layout_compare_and_swap_returns_latest_without_overwrite() {
|
||
let root = unique_resource_layout_project_path();
|
||
init_local_game_project_at(&root, "layout-cas", "布局 CAS").expect("init project");
|
||
let first = update_project_resource_canvas_layout_at(
|
||
&root,
|
||
ProjectResourceCanvasLayoutMode::Dependency,
|
||
"layout-cas",
|
||
0,
|
||
vec![layout_position("asset-a", 10)],
|
||
)
|
||
.expect("first update");
|
||
assert_eq!(first.layout.revision, 1);
|
||
|
||
let conflict = update_project_resource_canvas_layout_at(
|
||
&root,
|
||
ProjectResourceCanvasLayoutMode::Dependency,
|
||
"layout-cas",
|
||
0,
|
||
vec![layout_position("asset-a", 999)],
|
||
)
|
||
.expect("conflict result");
|
||
assert_eq!(
|
||
conflict.status,
|
||
UpdateProjectResourceCanvasLayoutStatus::Conflict
|
||
);
|
||
assert_eq!(conflict.layout.revision, 1);
|
||
assert_eq!(conflict.layout.positions[0].x, 10);
|
||
|
||
let persisted = read_project_resource_canvas_layout_at(
|
||
&root,
|
||
ProjectResourceCanvasLayoutMode::Dependency,
|
||
)
|
||
.expect("read persisted");
|
||
assert_eq!(persisted.positions[0].x, 10);
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn resource_layout_safe_revision_exhaustion_fails_without_overwrite() {
|
||
let root = unique_resource_layout_project_path();
|
||
init_local_game_project_at(&root, "layout-revision-max", "布局 revision 上限")
|
||
.expect("init project");
|
||
let path = root.join(resource_layout_relative_path(
|
||
ProjectResourceCanvasLayoutMode::Dependency,
|
||
));
|
||
fs::create_dir_all(path.parent().expect("layout parent")).expect("create layout parent");
|
||
let exhausted = ProjectResourceCanvasLayout {
|
||
schema_version: GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION.to_string(),
|
||
project_id: "layout-revision-max".to_string(),
|
||
mode: ProjectResourceCanvasLayoutMode::Dependency,
|
||
revision: GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION,
|
||
positions: vec![layout_position("asset-a", 10)],
|
||
updated_at: 1,
|
||
};
|
||
let original = serde_json::to_vec(&exhausted).expect("serialize exhausted layout");
|
||
fs::write(&path, &original).expect("write exhausted layout");
|
||
|
||
let error = update_project_resource_canvas_layout_at(
|
||
&root,
|
||
ProjectResourceCanvasLayoutMode::Dependency,
|
||
"layout-revision-max",
|
||
GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION,
|
||
vec![layout_position("asset-a", 999)],
|
||
)
|
||
.expect_err("revision exhaustion must fail");
|
||
assert!(error.contains("revision"));
|
||
assert_eq!(fs::read(&path).expect("read exhausted layout"), original);
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn resource_layout_rejects_unsafe_revision_without_side_effects() {
|
||
let root = unique_resource_layout_project_path();
|
||
init_local_game_project_at(&root, "layout-unsafe-revision", "布局非安全 revision")
|
||
.expect("init project");
|
||
|
||
let expected_error = update_project_resource_canvas_layout_at(
|
||
&root,
|
||
ProjectResourceCanvasLayoutMode::Dependency,
|
||
"layout-unsafe-revision",
|
||
GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION + 1,
|
||
vec![layout_position("asset-a", 10)],
|
||
)
|
||
.expect_err("unsafe expected revision must fail");
|
||
assert!(expected_error.contains("revision"));
|
||
assert!(!root.join(".agent/workbench").exists());
|
||
|
||
let path = root.join(resource_layout_relative_path(
|
||
ProjectResourceCanvasLayoutMode::Dependency,
|
||
));
|
||
fs::create_dir_all(path.parent().expect("layout parent")).expect("create layout parent");
|
||
let unsafe_payload = serde_json::json!({
|
||
"schemaVersion": GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION,
|
||
"projectId": "layout-unsafe-revision",
|
||
"mode": "dependency",
|
||
"revision": GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION + 1,
|
||
"positions": [],
|
||
"updatedAt": 1
|
||
});
|
||
let original = serde_json::to_vec(&unsafe_payload).expect("serialize unsafe fixture");
|
||
fs::write(&path, &original).expect("write unsafe fixture");
|
||
|
||
let read_error = read_project_resource_canvas_layout_at(
|
||
&root,
|
||
ProjectResourceCanvasLayoutMode::Dependency,
|
||
)
|
||
.expect_err("unsafe persisted revision must fail");
|
||
assert!(read_error.contains("revision") || read_error.contains("安全整数"));
|
||
assert_eq!(fs::read(&path).expect("read unsafe fixture"), original);
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn resource_layout_rejects_duplicate_ids_and_project_identity_drift() {
|
||
let root = unique_resource_layout_project_path();
|
||
init_local_game_project_at(&root, "layout-identity", "布局身份").expect("init project");
|
||
let duplicate_error = update_project_resource_canvas_layout_at(
|
||
&root,
|
||
ProjectResourceCanvasLayoutMode::Type,
|
||
"layout-identity",
|
||
0,
|
||
vec![
|
||
layout_position("asset-a", 10),
|
||
layout_position("asset-a", 20),
|
||
],
|
||
)
|
||
.expect_err("duplicate ids must fail");
|
||
assert!(duplicate_error.contains("不能重复"));
|
||
|
||
let path = root.join(resource_layout_relative_path(
|
||
ProjectResourceCanvasLayoutMode::Dependency,
|
||
));
|
||
fs::create_dir_all(path.parent().expect("layout parent")).expect("create parent");
|
||
let foreign = ProjectResourceCanvasLayout {
|
||
schema_version: GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION.to_string(),
|
||
project_id: "foreign-project".to_string(),
|
||
mode: ProjectResourceCanvasLayoutMode::Dependency,
|
||
revision: 1,
|
||
positions: vec![],
|
||
updated_at: 1,
|
||
};
|
||
fs::write(
|
||
&path,
|
||
serde_json::to_vec(&foreign).expect("serialize foreign"),
|
||
)
|
||
.expect("write foreign layout");
|
||
let identity_error = read_project_resource_canvas_layout_at(
|
||
&root,
|
||
ProjectResourceCanvasLayoutMode::Dependency,
|
||
)
|
||
.expect_err("foreign project id must fail");
|
||
assert!(identity_error.contains("projectId"));
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn resource_layout_rejects_corrupt_unknown_and_out_of_bounds_payloads() {
|
||
let root = unique_resource_layout_project_path();
|
||
init_local_game_project_at(&root, "layout-invalid", "无效布局").expect("init project");
|
||
let path = root.join(resource_layout_relative_path(
|
||
ProjectResourceCanvasLayoutMode::Dependency,
|
||
));
|
||
fs::create_dir_all(path.parent().expect("layout parent")).expect("create parent");
|
||
fs::write(&path, b"{invalid-json").expect("write corrupt layout");
|
||
let corrupt_error = read_project_resource_canvas_layout_at(
|
||
&root,
|
||
ProjectResourceCanvasLayoutMode::Dependency,
|
||
)
|
||
.expect_err("corrupt layout must fail");
|
||
assert!(corrupt_error.contains("解析") || corrupt_error.contains("JSON"));
|
||
|
||
let unknown = ProjectResourceCanvasLayout {
|
||
schema_version: "game-creator-resource-layout.v999".to_string(),
|
||
project_id: "layout-invalid".to_string(),
|
||
mode: ProjectResourceCanvasLayoutMode::Dependency,
|
||
revision: 1,
|
||
positions: vec![],
|
||
updated_at: 1,
|
||
};
|
||
fs::write(
|
||
&path,
|
||
serde_json::to_vec(&unknown).expect("serialize unknown"),
|
||
)
|
||
.expect("write unknown layout");
|
||
let schema_error = read_project_resource_canvas_layout_at(
|
||
&root,
|
||
ProjectResourceCanvasLayoutMode::Dependency,
|
||
)
|
||
.expect_err("unknown schema must fail");
|
||
assert!(schema_error.contains("schema"));
|
||
|
||
let boundary = update_project_resource_canvas_layout_at(
|
||
&root,
|
||
ProjectResourceCanvasLayoutMode::Type,
|
||
"layout-invalid",
|
||
0,
|
||
vec![layout_position(
|
||
"asset-coordinate-boundary",
|
||
RESOURCE_LAYOUT_MAX_COORDINATE,
|
||
)],
|
||
)
|
||
.expect("boundary coordinate must remain valid");
|
||
assert_eq!(
|
||
boundary.layout.positions[0].x,
|
||
RESOURCE_LAYOUT_MAX_COORDINATE
|
||
);
|
||
|
||
let lower_boundary = update_project_resource_canvas_layout_at(
|
||
&root,
|
||
ProjectResourceCanvasLayoutMode::Type,
|
||
"layout-invalid",
|
||
boundary.layout.revision,
|
||
vec![layout_position(
|
||
"asset-coordinate-lower-boundary",
|
||
RESOURCE_LAYOUT_MIN_COORDINATE,
|
||
)],
|
||
)
|
||
.expect("lower boundary coordinate must remain valid");
|
||
assert_eq!(
|
||
lower_boundary.layout.positions[0].x,
|
||
RESOURCE_LAYOUT_MIN_COORDINATE
|
||
);
|
||
|
||
let coordinate_error = update_project_resource_canvas_layout_at(
|
||
&root,
|
||
ProjectResourceCanvasLayoutMode::Type,
|
||
"layout-invalid",
|
||
lower_boundary.layout.revision,
|
||
vec![layout_position(
|
||
"asset-coordinate",
|
||
RESOURCE_LAYOUT_MAX_COORDINATE + 1,
|
||
)],
|
||
)
|
||
.expect_err("oversized coordinate must fail");
|
||
assert!(coordinate_error.contains("坐标"));
|
||
let lower_coordinate_error = update_project_resource_canvas_layout_at(
|
||
&root,
|
||
ProjectResourceCanvasLayoutMode::Type,
|
||
"layout-invalid",
|
||
lower_boundary.layout.revision,
|
||
vec![layout_position(
|
||
"asset-coordinate-below-lower-boundary",
|
||
RESOURCE_LAYOUT_MIN_COORDINATE - 1,
|
||
)],
|
||
)
|
||
.expect_err("coordinate below lower boundary must fail");
|
||
assert!(lower_coordinate_error.contains("坐标"));
|
||
let id_error = update_project_resource_canvas_layout_at(
|
||
&root,
|
||
ProjectResourceCanvasLayoutMode::Type,
|
||
"layout-invalid",
|
||
0,
|
||
vec![layout_position(
|
||
&"x".repeat(RESOURCE_LAYOUT_MAX_RESOURCE_ID_CHARS + 1),
|
||
10,
|
||
)],
|
||
)
|
||
.expect_err("oversized resource id must fail");
|
||
assert!(id_error.contains("resourceId"));
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[cfg(unix)]
|
||
#[test]
|
||
fn resource_layout_rejects_symlinks_and_hardlinks() {
|
||
use std::os::unix::fs::symlink;
|
||
|
||
let root = unique_resource_layout_project_path();
|
||
init_local_game_project_at(&root, "layout-links", "布局链接").expect("init project");
|
||
let path = root.join(resource_layout_relative_path(
|
||
ProjectResourceCanvasLayoutMode::Dependency,
|
||
));
|
||
fs::create_dir_all(path.parent().expect("layout parent")).expect("create parent");
|
||
let payload = ProjectResourceCanvasLayout {
|
||
schema_version: GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION.to_string(),
|
||
project_id: "layout-links".to_string(),
|
||
mode: ProjectResourceCanvasLayoutMode::Dependency,
|
||
revision: 1,
|
||
positions: vec![],
|
||
updated_at: 1,
|
||
};
|
||
let bytes = serde_json::to_vec(&payload).expect("serialize layout");
|
||
let target = root.join("layout-link-target.json");
|
||
fs::write(&target, &bytes).expect("write symlink target");
|
||
symlink(&target, &path).expect("create sidecar symlink");
|
||
let symlink_error = read_project_resource_canvas_layout_at(
|
||
&root,
|
||
ProjectResourceCanvasLayoutMode::Dependency,
|
||
)
|
||
.expect_err("symlink must fail");
|
||
assert!(symlink_error.contains("符号链接"));
|
||
|
||
fs::remove_file(&path).expect("remove symlink");
|
||
fs::write(&path, &bytes).expect("write sidecar");
|
||
let hardlink = root.join("layout-hardlink.json");
|
||
fs::hard_link(&path, &hardlink).expect("create hardlink");
|
||
let hardlink_error = read_project_resource_canvas_layout_at(
|
||
&root,
|
||
ProjectResourceCanvasLayoutMode::Dependency,
|
||
)
|
||
.expect_err("hardlink must fail");
|
||
assert!(hardlink_error.contains("硬链接"));
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
|
||
#[test]
|
||
fn resource_layout_serializes_concurrent_updates_and_hides_workbench_files() {
|
||
let root = unique_resource_layout_project_path();
|
||
init_local_game_project_at(&root, "layout-concurrent", "布局并发").expect("init project");
|
||
let barrier = Arc::new(Barrier::new(2));
|
||
let handles = [10, 20].map(|x| {
|
||
let root = root.clone();
|
||
let barrier = Arc::clone(&barrier);
|
||
std::thread::spawn(move || {
|
||
barrier.wait();
|
||
update_project_resource_canvas_layout_at(
|
||
&root,
|
||
ProjectResourceCanvasLayoutMode::Dependency,
|
||
"layout-concurrent",
|
||
0,
|
||
vec![layout_position("asset-a", x)],
|
||
)
|
||
})
|
||
});
|
||
let results = handles
|
||
.into_iter()
|
||
.map(|handle| {
|
||
handle
|
||
.join()
|
||
.expect("layout update thread")
|
||
.expect("layout update")
|
||
})
|
||
.collect::<Vec<_>>();
|
||
assert_eq!(
|
||
results
|
||
.iter()
|
||
.filter(|result| result.status == UpdateProjectResourceCanvasLayoutStatus::Updated)
|
||
.count(),
|
||
1
|
||
);
|
||
assert_eq!(
|
||
results
|
||
.iter()
|
||
.filter(|result| result.status == UpdateProjectResourceCanvasLayoutStatus::Conflict)
|
||
.count(),
|
||
1
|
||
);
|
||
|
||
let relative_path =
|
||
resource_layout_relative_path(ProjectResourceCanvasLayoutMode::Dependency);
|
||
let listed = list_local_project_files_at(&root).expect("list project files");
|
||
assert!(!listed
|
||
.files
|
||
.iter()
|
||
.any(|entry| entry.path.starts_with(".agent/workbench")));
|
||
assert!(read_local_project_file_at(&root, &relative_path).is_err());
|
||
assert!(write_local_project_file_at(&root, &relative_path, "tampered").is_err());
|
||
assert!(delete_local_project_file_at(&root, &relative_path).is_err());
|
||
fs::remove_dir_all(root).ok();
|
||
}
|
||
}
|