完成资源管理的功能修复 #154

Merged
kdletters merged 14 commits from codex/resource-management-final into master 2026-08-12 13:14:55 +08:00
40 changed files with 8519 additions and 1646 deletions
+1
View File
@@ -10,6 +10,7 @@
"dependencies": {
"@lexical/react": "^0.47.0",
"@lexical/utils": "^0.47.0",
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-clipboard-manager": "2.3.2",
"@tauri-apps/plugin-http": "^2.5.9",
"@tauri-apps/plugin-opener": "~2",
+1
View File
@@ -36,6 +36,7 @@
"dependencies": {
"@lexical/react": "^0.47.0",
"@lexical/utils": "^0.47.0",
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-clipboard-manager": "2.3.2",
"@tauri-apps/plugin-http": "^2.5.9",
"@tauri-apps/plugin-opener": "~2",
+4 -37
View File
@@ -725,16 +725,6 @@ dependencies = [
"url",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
@@ -758,7 +748,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
dependencies = [
"bitflags 2.13.0",
"core-foundation 0.10.1",
"core-foundation",
"core-graphics-types",
"foreign-types 0.5.0",
"libc",
@@ -771,7 +761,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.13.0",
"core-foundation 0.10.1",
"core-foundation",
"libc",
]
@@ -2091,11 +2081,9 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@@ -4421,7 +4409,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.13.0",
"core-foundation 0.10.1",
"core-foundation",
"core-foundation-sys",
"libc",
"security-framework-sys",
@@ -4977,27 +4965,6 @@ dependencies = [
"syn 2.0.118",
]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.13.0",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "system-deps"
version = "6.2.2"
@@ -5019,7 +4986,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
dependencies = [
"bitflags 2.13.0",
"block2",
"core-foundation 0.10.1",
"core-foundation",
"core-graphics",
"crossbeam-channel",
"dbus",
@@ -42,7 +42,7 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "mul
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false }
tauri = { version = "2.11.2", features = [] }
tauri-plugin-dialog = "2.7.1"
tauri-plugin-http = "2.5.9"
tauri-plugin-http = { version = "2.5.9", default-features = false, features = ["charset", "cookies", "http2", "rustls-tls"] }
tauri-plugin-opener = "2"
tempfile = "3"
tokio = { version = "1", features = ["io-util", "macros", "process", "rt-multi-thread", "signal", "sync", "time"] }
@@ -5721,6 +5721,9 @@ mod canvas_generation_tests {
};
fn read_test_http_request(stream: &mut std::net::TcpStream) -> String {
stream
.set_nonblocking(false)
.expect("set request stream blocking");
stream
.set_read_timeout(Some(Duration::from_secs(10)))
.expect("set request read timeout");
@@ -254,11 +254,17 @@ fn persist_game_chat_main_asset_audit_and_route(
.expect("persist game-chat main asset route");
}
fn game_chat_main_art_child_fixture(
fn game_chat_main_art_child_fixture_with_lane(
root: &Path,
target_agent_id: &str,
missing_slots: &[&str],
) -> (AgentRuntimeState, AgentRuntimeState, String) {
hold_child_lane: bool,
) -> (
AgentRuntimeState,
AgentRuntimeState,
String,
Option<AgentRuntimeTaskLock>,
) {
init_local_game_project_at(root, "game-chat-art-child", "单主美术委派")
.expect("init game-chat art child project");
let root_run_id = "game-chat-main-art-root";
@@ -310,6 +316,11 @@ fn game_chat_main_art_child_fixture(
)
.expect("persist game-chat main route");
let child_lane = hold_child_lane.then(|| {
try_acquire_game_creator_agent_runtime_task_lock(root, target_agent_id)
.expect("acquire delegated art child lane")
.expect("delegated art child lane available")
});
let action_id = format!("game-chat-main-delegate-{target_agent_id}");
let observation = observe_agent_runtime_agent_delegate(
root,
@@ -335,11 +346,27 @@ fn game_chat_main_art_child_fixture(
)
.expect("read delegated art child")
.expect("delegated art child exists");
(
main,
agent_runtime_state_from_task_record(&child),
delegation_id,
)
let mut child = agent_runtime_state_from_task_record(&child);
if child_lane.is_some() {
child.status = "running".to_string();
child.phase = "planning".to_string();
child.current_action = "执行受限美术子任务".to_string();
append_game_creator_agent_runtime_task(root, &child)
.expect("persist running delegated art child");
write_game_creator_agent_runtime_state(root, &child)
.expect("persist running delegated art runtime");
}
(main, child, delegation_id, child_lane)
}
fn game_chat_main_art_child_fixture(
root: &Path,
target_agent_id: &str,
missing_slots: &[&str],
) -> (AgentRuntimeState, AgentRuntimeState, String) {
let (main, child, delegation_id, _child_lane) =
game_chat_main_art_child_fixture_with_lane(root, target_agent_id, missing_slots, false);
(main, child, delegation_id)
}
fn game_chat_main_without_asset_audit_fixture(root: &Path) -> String {
@@ -360,8 +387,12 @@ fn game_chat_main_without_asset_audit_fixture(root: &Path) -> String {
async fn game_chat_main_agent_delegates_only_real_missing_art_and_limits_children_to_assets() {
let temporary = tempfile::tempdir().expect("create game-chat art child root");
let root = temporary.path().join("project");
let (_main, mut child, _delegation_id) =
game_chat_main_art_child_fixture(&root, "art-asset-plan", &["core-spritesheet"]);
let (_main, mut child, _delegation_id, _child_lane) = game_chat_main_art_child_fixture_with_lane(
&root,
"art-asset-plan",
&["core-spritesheet"],
true,
);
assert_eq!(child.agent_id, "art-asset-plan");
assert_eq!(child.source, "agent-delegate");
assert_eq!(child.parent_agent_id.as_deref(), Some("code-prototype"));
@@ -684,12 +715,25 @@ fn game_chat_main_agent_rejects_unneeded_or_wrong_art_delegation() {
assert!(out_of_order.summary.contains("先完成并认领 art-director"));
}
#[test]
fn game_chat_art_receipt_wakes_the_same_code_prototype_run() {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn game_chat_art_receipt_wakes_the_same_code_prototype_run() {
let temporary = tempfile::tempdir().expect("create game-chat receipt wake root");
let root = temporary.path().join("project");
let (mut main, mut child, delegation_id) =
game_chat_main_art_child_fixture(&root, "art-asset-plan", &["core-spritesheet"]);
let (mut main, mut child, delegation_id, child_lane) =
game_chat_main_art_child_fixture_with_lane(
&root,
"art-asset-plan",
&["core-spritesheet"],
true,
);
let child_lane = child_lane.expect("delegated art child lane is held");
main.status = "running".to_string();
main.phase = "waiting-for-delegate-receipts".to_string();
main.current_action = "等待临时美术 Agent 回执".to_string();
main.waiting_on = "art-asset-plan 完成并回执".to_string();
append_game_creator_agent_runtime_task(&root, &main).expect("persist waiting main task");
write_game_creator_agent_runtime_state(&root, &main).expect("persist waiting main runtime");
child.status = "completed".to_string();
child.phase = "completed".to_string();
child.current_action = "已写入临时美术资产".to_string();
@@ -702,6 +746,9 @@ fn game_chat_art_receipt_wakes_the_same_code_prototype_run() {
)
.expect("read completed art child")
.expect("completed art child exists");
let parent_lane = try_acquire_game_creator_agent_runtime_task_lock(&root, "code-prototype")
.expect("acquire waiting main-agent execution lane")
.expect("waiting main-agent execution lane is free");
publish_game_creator_agent_delegate_result(
&root,
&child_task,
@@ -756,12 +803,8 @@ fn game_chat_art_receipt_wakes_the_same_code_prototype_run() {
assert_eq!(duplicate.status, "failed", "{duplicate:?}");
assert!(duplicate.summary.contains("最多委派一次"));
main.status = "running".to_string();
main.phase = "waiting-for-delegate-receipts".to_string();
main.current_action = "等待临时美术 Agent 回执".to_string();
main.waiting_on = "art-asset-plan 完成并回执".to_string();
append_game_creator_agent_runtime_task(&root, &main).expect("persist waiting main task");
write_game_creator_agent_runtime_state(&root, &main).expect("persist waiting main runtime");
drop(child_lane);
drop(parent_lane);
let waiting = read_latest_game_creator_agent_runtime_task_by_run_id(
&root,
"code-prototype",
@@ -771,6 +814,21 @@ fn game_chat_art_receipt_wakes_the_same_code_prototype_run() {
.expect("waiting main task exists");
let wake_started = wake_waiting_static_delegate_parent_run_at(&root, &waiting)
.expect("wake same main run after art receipt");
cancel_game_creator_agent_runtime_task_at(&root, "code-prototype", &main.run_id)
.expect("cancel resumed fixture run");
let release_deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
if game_creator_agent_runtime_task_lock_is_available(&root, "code-prototype")
.expect("probe resumed main-agent lane release")
{
break;
}
assert!(
std::time::Instant::now() < release_deadline,
"resumed main-agent lane did not release before fixture teardown"
);
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
let resumed = read_all_game_creator_agent_runtime_tasks(&game_creator_agent_runtime_task_path(
&root,
"code-prototype",
@@ -786,8 +844,17 @@ fn game_chat_art_receipt_wakes_the_same_code_prototype_run() {
#[test]
fn autonomous_parent_waits_for_active_child_while_registered_derived_visuals_need_repair() {
let temporary = crate::tests::canonical_test_tempdir("main-loop-legacy-");
let root = temporary.path().join("project");
let temporary_root = std::env::temp_dir()
.canonicalize()
.expect("canonicalize system temporary directory");
let root = temporary_root.join(format!(
"genarrative-agent-main-loop-legacy-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock")
.as_nanos()
));
init_local_game_project_at(&root, "legacy-derived-visuals", "旧派生视觉返工门禁")
.expect("project init");
assert!(!autonomous_registered_derived_visuals_need_repair_at(&root));
@@ -1099,7 +1099,7 @@ async fn game_chat_pure_continue_inherits_failed_root_semantics_and_manifest_pro
let _ = crate::tests::wait_for_agent_runtime_manifest_projection_async(
&root,
"code-prototype",
&scheduled[0].state.run_id,
&autonomous_manifest_ready_task_run_id(&continuation.run_id, "code-prototype"),
"failed",
"budget-exhausted",
GameCreationAppTaskStatus::Failed,
@@ -1474,14 +1474,32 @@ pub(crate) fn read_local_project_file(
}
#[tauri::command]
pub(crate) fn read_local_project_image_preview(
pub(crate) async fn read_local_project_image_preview(
preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>,
project_path: String,
relative_path: String,
scope_id: String,
request_id: String,
) -> Result<LocalProjectImagePreview, String> {
preview_manager
.run(&scope_id, &request_id, move |cancellation| {
read_local_project_image_preview_at(&project_path, &relative_path, cancellation)
})
.await
}
pub(crate) fn read_local_project_image_preview_at(
project_path: &str,
relative_path: &str,
cancellation: &ProjectResourcePreviewScopeCancellation,
) -> Result<LocalProjectImagePreview, String> {
cancellation.check()?;
let root = Path::new(project_path.trim());
enforce_project_auto_permission_policy(root, "file.read")?;
cancellation.check()?;
let normalized_path = normalize_relative_path(relative_path.trim())?;
let manifest = read_manifest(&root.join(".agent/manifest.json"))?;
cancellation.check()?;
let is_registered_asset = manifest
.assets
.iter()
@@ -1493,18 +1511,37 @@ pub(crate) fn read_local_project_image_preview(
if !is_registered_asset && !is_completed_task_artifact {
return Err("只能预览已登记资源或已完成任务的图片产物".to_string());
}
load_local_project_image_preview(root, &normalized_path)
cancellation.check()?;
load_local_project_image_preview_with_cancellation(root, &normalized_path, cancellation)
}
#[tauri::command]
pub(crate) fn read_local_project_text_preview(
pub(crate) async fn read_local_project_text_preview(
preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>,
project_path: String,
relative_path: String,
scope_id: String,
request_id: String,
) -> Result<LocalProjectTextPreview, String> {
preview_manager
.run(&scope_id, &request_id, move |cancellation| {
read_local_project_text_preview_at(&project_path, &relative_path, cancellation)
})
.await
}
pub(crate) fn read_local_project_text_preview_at(
project_path: &str,
relative_path: &str,
cancellation: &ProjectResourcePreviewScopeCancellation,
) -> Result<LocalProjectTextPreview, String> {
cancellation.check()?;
let root = Path::new(project_path.trim());
enforce_project_auto_permission_policy(root, "file.read")?;
cancellation.check()?;
let normalized_path = normalize_relative_path(relative_path.trim())?;
let manifest = read_manifest(&root.join(".agent/manifest.json"))?;
cancellation.check()?;
let is_registered_document = manifest.assets.iter().any(|asset| {
asset.local_path == normalized_path
&& is_supported_project_text_resource(&asset.local_path, &asset.media_type)
@@ -1516,19 +1553,44 @@ pub(crate) fn read_local_project_text_preview(
if !is_registered_document {
return Err("只能读取当前项目已登记的文档资源".to_string());
}
load_local_project_text_preview(root, &normalized_path)
cancellation.check()?;
load_local_project_text_preview_with_cancellation(root, &normalized_path, cancellation)
}
#[tauri::command]
pub(crate) fn read_local_project_media_preview(
pub(crate) async fn read_local_project_media_preview(
preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>,
project_path: String,
relative_path: String,
category: String,
scope_id: String,
request_id: String,
) -> Result<LocalProjectMediaPreview, String> {
preview_manager
.run(&scope_id, &request_id, move |cancellation| {
read_local_project_media_preview_at(
&project_path,
&relative_path,
&category,
cancellation,
)
})
.await
}
pub(crate) fn read_local_project_media_preview_at(
project_path: &str,
relative_path: &str,
category: &str,
cancellation: &ProjectResourcePreviewScopeCancellation,
) -> Result<LocalProjectMediaPreview, String> {
cancellation.check()?;
let root = Path::new(project_path.trim());
enforce_project_auto_permission_policy(root, "file.read")?;
cancellation.check()?;
let normalized_path = normalize_relative_path(relative_path.trim())?;
let manifest = read_manifest(&root.join(".agent/manifest.json"))?;
cancellation.check()?;
let kind = match category.trim() {
"art" => ProjectMediaPreviewKind::Art,
"audio" => ProjectMediaPreviewKind::Audio,
@@ -1559,7 +1621,16 @@ pub(crate) fn read_local_project_media_preview(
if !is_registered_media {
return Err("只能预览当前项目已登记的媒体资源".to_string());
}
load_local_project_media_preview(root, &normalized_path, kind)
cancellation.check()?;
load_local_project_media_preview_with_cancellation(root, &normalized_path, kind, cancellation)
}
#[tauri::command]
pub(crate) fn cancel_local_project_resource_preview_scope(
preview_manager: tauri::State<'_, ProjectResourcePreviewReadManager>,
scope_id: String,
) -> Result<(), String> {
preview_manager.cancel_scope(&scope_id)
}
#[tauri::command]
@@ -2,6 +2,7 @@ use crate::project::{
normalize_relative_path, open_project_snapshot_regular_file,
reject_sensitive_project_file_read, resolve_local_project_path,
};
use crate::resource_preview_scheduler::ProjectResourcePreviewScopeCancellation;
use base64::Engine as _;
use serde::Serialize;
use sha2::{Digest, Sha256};
@@ -15,6 +16,7 @@ pub(crate) const AGENT_RUNTIME_IMAGE_INSPECT_MAX_FILE_BYTES: u64 = 8 * 1024 * 10
pub(crate) const AGENT_RUNTIME_IMAGE_INSPECT_MAX_TOTAL_BYTES: u64 = 12 * 1024 * 1024;
const PROJECT_IMAGE_PREVIEW_MAX_DIMENSION: u32 = 8_192;
const PROJECT_IMAGE_PREVIEW_MAX_PIXELS: u64 = 32 * 1024 * 1024;
const PROJECT_IMAGE_PREVIEW_READ_CHUNK_BYTES: usize = 64 * 1024;
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -41,12 +43,34 @@ impl AgentRuntimeInspectionImage {
base64::engine::general_purpose::STANDARD.encode(&self.bytes)
)
}
fn data_url_with_cancellation(
&self,
cancellation: &ProjectResourcePreviewScopeCancellation,
) -> Result<String, String> {
cancellation.check()?;
Ok(self.data_url())
}
}
#[cfg(test)]
pub(crate) fn load_local_project_image_preview(
root: &Path,
relative_path: &str,
) -> Result<LocalProjectImagePreview, String> {
load_local_project_image_preview_with_cancellation(
root,
relative_path,
&ProjectResourcePreviewScopeCancellation::uncancelled(),
)
}
pub(crate) fn load_local_project_image_preview_with_cancellation(
root: &Path,
relative_path: &str,
cancellation: &ProjectResourcePreviewScopeCancellation,
) -> Result<LocalProjectImagePreview, String> {
cancellation.check()?;
let normalized = normalize_relative_path(relative_path.trim())?;
if !normalized.starts_with("assets/") && !normalized.starts_with("game/") {
return Err("图片预览只允许读取 assets/ 或 game/ 下的项目图片".to_string());
@@ -54,12 +78,15 @@ pub(crate) fn load_local_project_image_preview(
reject_sensitive_project_file_read(&normalized)?;
let absolute = resolve_local_project_path(root, &normalized)?;
validate_agent_runtime_inspection_ancestors(root, &absolute)?;
let image = read_agent_runtime_inspection_image(&absolute, normalized)?;
cancellation.check()?;
let image =
read_agent_runtime_inspection_image_with_cancellation(&absolute, normalized, cancellation)?;
cancellation.check()?;
Ok(LocalProjectImagePreview {
path: image.relative_path.clone(),
media_type: image.media_type.to_string(),
byte_len: image.byte_len,
data_url: image.data_url(),
data_url: image.data_url_with_cancellation(cancellation)?,
})
}
@@ -236,7 +263,21 @@ fn read_agent_runtime_inspection_image(
path: &Path,
relative_path: String,
) -> Result<AgentRuntimeInspectionImage, String> {
read_agent_runtime_inspection_image_with_cancellation(
path,
relative_path,
&ProjectResourcePreviewScopeCancellation::uncancelled(),
)
}
fn read_agent_runtime_inspection_image_with_cancellation(
path: &Path,
relative_path: String,
cancellation: &ProjectResourcePreviewScopeCancellation,
) -> Result<AgentRuntimeInspectionImage, String> {
cancellation.check()?;
let (mut file, initial_metadata) = open_project_snapshot_regular_file(path, "视觉检查图片")?;
cancellation.check()?;
if initial_metadata.len() == 0 {
return Err(format!("image.inspect 图片不能为空:{relative_path}"));
}
@@ -248,10 +289,26 @@ fn read_agent_runtime_inspection_image(
}
let mut bytes = Vec::with_capacity(initial_metadata.len() as usize);
file.by_ref()
.take(AGENT_RUNTIME_IMAGE_INSPECT_MAX_FILE_BYTES + 1)
.read_to_end(&mut bytes)
.map_err(|error| format!("读取 image.inspect 图片失败:{relative_path}: {error}"))?;
let mut chunk = [0_u8; PROJECT_IMAGE_PREVIEW_READ_CHUNK_BYTES];
loop {
cancellation.check()?;
let remaining =
(AGENT_RUNTIME_IMAGE_INSPECT_MAX_FILE_BYTES + 1).saturating_sub(bytes.len() as u64);
if remaining == 0 {
break;
}
let read_len = usize::try_from(remaining)
.unwrap_or(usize::MAX)
.min(chunk.len());
let count = file
.read(&mut chunk[..read_len])
.map_err(|error| format!("读取 image.inspect 图片失败:{relative_path}: {error}"))?;
if count == 0 {
break;
}
bytes.extend_from_slice(&chunk[..count]);
}
cancellation.check()?;
if bytes.len() as u64 > AGENT_RUNTIME_IMAGE_INSPECT_MAX_FILE_BYTES {
return Err(format!(
"image.inspect 单张图片不能超过 {} MiB{relative_path}",
@@ -270,14 +327,17 @@ fn read_agent_runtime_inspection_image(
));
}
cancellation.check()?;
let (reopened, reopened_metadata) = open_project_snapshot_regular_file(path, "视觉检查图片")?;
if !same_open_file_identity(&file, &initial_metadata, &reopened, &reopened_metadata)? {
return Err(format!(
"image.inspect 图片路径读取期间发生替换:{relative_path}"
));
}
cancellation.check()?;
let media_type = detect_agent_runtime_image_media_type(&bytes)
.ok_or_else(|| format!("image.inspect 只支持 PNG、JPEG 或 WEBP{relative_path}"))?;
cancellation.check()?;
let (width, height) = detect_raster_image_dimensions(&bytes, media_type)
.ok_or_else(|| format!("image.inspect 图片结构无效:{relative_path}"))?;
let pixels = u64::from(width)
@@ -293,6 +353,7 @@ fn read_agent_runtime_inspection_image(
"image.inspect 图片尺寸过大:{width}x{height},最大边长 {PROJECT_IMAGE_PREVIEW_MAX_DIMENSION},最大像素 {PROJECT_IMAGE_PREVIEW_MAX_PIXELS}{relative_path}"
));
}
cancellation.check()?;
let sha256 = format!("{:x}", Sha256::digest(&bytes));
Ok(AgentRuntimeInspectionImage {
relative_path,
@@ -588,6 +649,28 @@ mod tests {
assert!(preview.data_url.starts_with("data:image/png;base64,"));
}
#[test]
fn cancelled_image_preview_does_not_enter_base64_encoding() {
let root = tempfile::tempdir().expect("temp root");
fs::create_dir_all(root.path().join("assets/ui")).expect("asset dir");
fs::write(root.path().join("assets/ui/prototype.png"), png_bytes()).expect("image");
let image = read_agent_runtime_inspection_image(
&root.path().join("assets/ui/prototype.png"),
"assets/ui/prototype.png".to_string(),
)
.expect("read project image");
let cancellation = ProjectResourcePreviewScopeCancellation::uncancelled();
cancellation.cancel();
assert_eq!(
image.data_url_with_cancellation(&cancellation),
Err(
crate::resource_preview_scheduler::PROJECT_RESOURCE_PREVIEW_CANCELLED_ERROR
.to_string()
)
);
}
#[test]
fn local_project_image_preview_rejects_non_project_asset_paths() {
let root = tempfile::tempdir().expect("temp root");
@@ -74,6 +74,7 @@ mod provider_handoff;
mod provider_retry;
mod repository_context;
mod resource_inspect;
mod resource_preview_scheduler;
mod runner;
mod swarm_cli;
mod tool_plan_handoff;
@@ -104,6 +105,7 @@ use process_session::*;
use project::*;
use repository_context::*;
use resource_inspect::*;
use resource_preview_scheduler::*;
use runner::*;
use swarm_cli::*;
use user_input::*;
@@ -2040,6 +2042,7 @@ fn main() {
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_clipboard_manager::init())
.manage(game_creator_preview_registry())
.manage(ProjectResourcePreviewReadManager::default())
.setup(move |app| {
if let Some(path) = setup_log.as_deref() {
let _ = append_bounded_diagnostic_line(path, "startup.setup.begin");
@@ -2230,6 +2233,7 @@ fn main() {
read_local_project_image_preview,
read_local_project_text_preview,
read_local_project_media_preview,
cancel_local_project_resource_preview_scope,
write_local_project_file,
delete_local_project_file,
read_local_game_memory,
@@ -862,11 +862,27 @@ mod tests {
.expect_err("unknown schema must fail");
assert!(schema_error.contains("schema"));
let coordinate_error = update_project_resource_canvas_layout_at(
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 coordinate_error = update_project_resource_canvas_layout_at(
&root,
ProjectResourceCanvasLayoutMode::Type,
"layout-invalid",
boundary.layout.revision,
vec![layout_position(
"asset-coordinate",
RESOURCE_LAYOUT_MAX_COORDINATE + 1,
@@ -5,6 +5,7 @@ use crate::project::{
normalize_relative_path, open_project_snapshot_regular_file,
reject_sensitive_project_file_read, resolve_local_project_path,
};
use crate::resource_preview_scheduler::ProjectResourcePreviewScopeCancellation;
use base64::Engine as _;
use serde::Serialize;
use std::io::Read;
@@ -12,6 +13,7 @@ use std::path::Path;
const PROJECT_TEXT_PREVIEW_MAX_FILE_BYTES: u64 = 2 * 1024 * 1024;
const PROJECT_MEDIA_PREVIEW_MAX_FILE_BYTES: u64 = 32 * 1024 * 1024;
const PROJECT_RESOURCE_PREVIEW_READ_CHUNK_BYTES: usize = 64 * 1024;
#[derive(Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
@@ -118,10 +120,24 @@ pub(crate) fn is_supported_project_audio_resource(path: &str, media_type: &str)
) || media_type.starts_with("audio/")
}
#[cfg(test)]
pub(crate) fn load_local_project_text_preview(
root: &Path,
relative_path: &str,
) -> Result<LocalProjectTextPreview, String> {
load_local_project_text_preview_with_cancellation(
root,
relative_path,
&ProjectResourcePreviewScopeCancellation::uncancelled(),
)
}
pub(crate) fn load_local_project_text_preview_with_cancellation(
root: &Path,
relative_path: &str,
cancellation: &ProjectResourcePreviewScopeCancellation,
) -> Result<LocalProjectTextPreview, String> {
cancellation.check()?;
let normalized = normalize_relative_path(relative_path.trim())?;
reject_sensitive_project_file_read(&normalized)?;
let media_type = project_text_media_type(&normalized)
@@ -131,7 +147,9 @@ pub(crate) fn load_local_project_text_preview(
&normalized,
PROJECT_TEXT_PREVIEW_MAX_FILE_BYTES,
"项目文档",
cancellation,
)?;
cancellation.check()?;
let content =
String::from_utf8(bytes).map_err(|_| "文档预览只支持 UTF-8 编码的文本文件".to_string())?;
Ok(LocalProjectTextPreview {
@@ -142,11 +160,27 @@ pub(crate) fn load_local_project_text_preview(
})
}
#[cfg(test)]
pub(crate) fn load_local_project_media_preview(
root: &Path,
relative_path: &str,
kind: ProjectMediaPreviewKind,
) -> Result<LocalProjectMediaPreview, String> {
load_local_project_media_preview_with_cancellation(
root,
relative_path,
kind,
&ProjectResourcePreviewScopeCancellation::uncancelled(),
)
}
pub(crate) fn load_local_project_media_preview_with_cancellation(
root: &Path,
relative_path: &str,
kind: ProjectMediaPreviewKind,
cancellation: &ProjectResourcePreviewScopeCancellation,
) -> Result<LocalProjectMediaPreview, String> {
cancellation.check()?;
let normalized = normalize_relative_path(relative_path.trim())?;
reject_sensitive_project_file_read(&normalized)?;
let bytes = read_stable_project_resource(
@@ -154,39 +188,70 @@ pub(crate) fn load_local_project_media_preview(
&normalized,
PROJECT_MEDIA_PREVIEW_MAX_FILE_BYTES,
"项目媒体资源",
cancellation,
)?;
if bytes.is_empty() {
return Err("媒体文件为空,无法预览".to_string());
}
cancellation.check()?;
let media_type = detect_project_media_type(&normalized, &bytes, kind)?;
cancellation.check()?;
Ok(LocalProjectMediaPreview {
path: normalized,
media_type: media_type.to_string(),
byte_len: bytes.len() as u64,
data_url: format!(
"data:{media_type};base64,{}",
base64::engine::general_purpose::STANDARD.encode(bytes)
),
data_url: encode_project_resource_preview_data_url(media_type, &bytes, cancellation)?,
})
}
fn encode_project_resource_preview_data_url(
media_type: &str,
bytes: &[u8],
cancellation: &ProjectResourcePreviewScopeCancellation,
) -> Result<String, String> {
cancellation.check()?;
Ok(format!(
"data:{media_type};base64,{}",
base64::engine::general_purpose::STANDARD.encode(bytes)
))
}
fn read_stable_project_resource(
root: &Path,
normalized: &str,
max_bytes: u64,
label: &str,
cancellation: &ProjectResourcePreviewScopeCancellation,
) -> Result<Vec<u8>, String> {
cancellation.check()?;
let absolute = resolve_local_project_path(root, normalized)?;
validate_agent_runtime_inspection_ancestors(root, &absolute)?;
cancellation.check()?;
let (mut file, initial_metadata) = open_project_snapshot_regular_file(&absolute, label)?;
cancellation.check()?;
if initial_metadata.len() > max_bytes {
return Err(format!("{label}不能超过 {} MiB", max_bytes / 1024 / 1024));
}
let mut bytes = Vec::with_capacity(initial_metadata.len() as usize);
file.by_ref()
.take(max_bytes + 1)
.read_to_end(&mut bytes)
.map_err(|error| format!("读取{label}失败:{normalized}: {error}"))?;
let mut chunk = [0_u8; PROJECT_RESOURCE_PREVIEW_READ_CHUNK_BYTES];
loop {
cancellation.check()?;
let remaining = (max_bytes + 1).saturating_sub(bytes.len() as u64);
if remaining == 0 {
break;
}
let read_len = usize::try_from(remaining)
.unwrap_or(usize::MAX)
.min(chunk.len());
let count = file
.read(&mut chunk[..read_len])
.map_err(|error| format!("读取{label}失败:{normalized}: {error}"))?;
if count == 0 {
break;
}
bytes.extend_from_slice(&chunk[..count]);
}
cancellation.check()?;
if bytes.len() as u64 > max_bytes {
return Err(format!("{label}不能超过 {} MiB", max_bytes / 1024 / 1024));
}
@@ -199,10 +264,12 @@ fn read_stable_project_resource(
{
return Err(format!("{label}读取期间发生漂移:{normalized}"));
}
cancellation.check()?;
let (reopened, reopened_metadata) = open_project_snapshot_regular_file(&absolute, label)?;
if !same_open_file_identity(&file, &initial_metadata, &reopened, &reopened_metadata)? {
return Err(format!("{label}路径读取期间发生替换:{normalized}"));
}
cancellation.check()?;
Ok(bytes)
}
@@ -503,4 +570,17 @@ mod tests {
assert!(load_local_project_text_preview(root.path(), "docs/link.md").is_err());
assert!(load_local_project_text_preview(root.path(), "docs/hard.md").is_err());
}
#[test]
fn cancelled_media_preview_does_not_enter_base64_encoding() {
let cancellation = ProjectResourcePreviewScopeCancellation::uncancelled();
cancellation.cancel();
assert_eq!(
encode_project_resource_preview_data_url("audio/mpeg", b"ID3", &cancellation),
Err(
crate::resource_preview_scheduler::PROJECT_RESOURCE_PREVIEW_CANCELLED_ERROR
.to_string()
)
);
}
}
File diff suppressed because it is too large Load Diff
@@ -6004,6 +6004,8 @@ fn project_checkpoint_excludes_and_preserves_sensitive_local_files() {
#[test]
fn local_project_image_preview_obeys_auto_file_read_policy() {
let root = unique_project_path();
let project_path = root.to_string_lossy().into_owned();
let cancellation = ProjectResourcePreviewScopeCancellation::uncancelled();
init_local_game_project_at(&root, "image-preview-policy", "图片预览策略项目")
.expect("project init");
fs::create_dir_all(root.join("assets")).expect("asset dir");
@@ -6041,11 +6043,9 @@ fn local_project_image_preview_obeys_auto_file_read_policy() {
},
)
.expect("confirm policy");
let confirm_error = read_local_project_image_preview(
root.to_string_lossy().into_owned(),
"assets/preview.png".to_string(),
)
.expect_err("confirm policy blocks automatic preview");
let confirm_error =
read_local_project_image_preview_at(&project_path, "assets/preview.png", &cancellation)
.expect_err("confirm policy blocks automatic preview");
assert!(confirm_error.contains("要求用户确认:file.read"));
write_project_permission_policy_at(
@@ -6057,11 +6057,9 @@ fn local_project_image_preview_obeys_auto_file_read_policy() {
},
)
.expect("deny policy");
let deny_error = read_local_project_image_preview(
root.to_string_lossy().into_owned(),
"assets/preview.png".to_string(),
)
.expect_err("deny policy blocks preview");
let deny_error =
read_local_project_image_preview_at(&project_path, "assets/preview.png", &cancellation)
.expect_err("deny policy blocks preview");
assert!(deny_error.contains("拒绝执行:file.read"));
write_project_permission_policy_at(
@@ -6073,17 +6071,16 @@ fn local_project_image_preview_obeys_auto_file_read_policy() {
},
)
.expect("auto policy");
let preview = read_local_project_image_preview(
root.to_string_lossy().into_owned(),
"assets/preview.png".to_string(),
)
.expect("auto preview");
let preview =
read_local_project_image_preview_at(&project_path, "assets/preview.png", &cancellation)
.expect("auto preview");
assert_eq!(preview.media_type, "image/png");
fs::write(root.join("assets/unregistered.png"), &preview_bytes).expect("unregistered image");
let unregistered_error = read_local_project_image_preview(
root.to_string_lossy().into_owned(),
"assets/unregistered.png".to_string(),
let unregistered_error = read_local_project_image_preview_at(
&project_path,
"assets/unregistered.png",
&cancellation,
)
.expect_err("unregistered image rejected");
assert!(unregistered_error.contains("只能预览已登记资源"));
@@ -6094,6 +6091,8 @@ fn local_project_image_preview_obeys_auto_file_read_policy() {
#[test]
fn local_project_resource_previews_require_registered_safe_resources() {
let root = unique_project_path();
let project_path = root.to_string_lossy().into_owned();
let cancellation = ProjectResourcePreviewScopeCancellation::uncancelled();
init_local_game_project_at(&root, "resource-preview-policy", "资源预览策略项目")
.expect("project init");
fs::create_dir_all(root.join("assets")).expect("asset dir");
@@ -6151,44 +6150,37 @@ fn local_project_resource_previews_require_registered_safe_resources() {
)
.expect("register audio");
let document = read_local_project_text_preview(
root.to_string_lossy().into_owned(),
"game/design.md".to_string(),
)
.expect("read registered document");
let document =
read_local_project_text_preview_at(&project_path, "game/design.md", &cancellation)
.expect("read registered document");
assert_eq!(document.media_type, "text/markdown");
assert!(document.content.contains("安全正文"));
let svg = read_local_project_media_preview(
root.to_string_lossy().into_owned(),
"assets/icon.svg".to_string(),
"art".to_string(),
)
.expect("read registered svg");
let svg =
read_local_project_media_preview_at(&project_path, "assets/icon.svg", "art", &cancellation)
.expect("read registered svg");
assert_eq!(svg.media_type, "image/svg+xml");
let audio = read_local_project_media_preview(
root.to_string_lossy().into_owned(),
"assets/bgm.mp3".to_string(),
"audio".to_string(),
let audio = read_local_project_media_preview_at(
&project_path,
"assets/bgm.mp3",
"audio",
&cancellation,
)
.expect("read registered audio");
assert_eq!(audio.media_type, "audio/mpeg");
let unregistered_error = read_local_project_text_preview(
root.to_string_lossy().into_owned(),
"game/unregistered.md".to_string(),
)
.expect_err("unregistered document rejected");
let unregistered_error =
read_local_project_text_preview_at(&project_path, "game/unregistered.md", &cancellation)
.expect_err("unregistered document rejected");
assert!(unregistered_error.contains("已登记的文档资源"));
assert!(read_local_project_text_preview(
root.to_string_lossy().into_owned(),
"../outside.md".to_string(),
)
.is_err());
assert!(read_local_project_media_preview(
root.to_string_lossy().into_owned(),
"assets/bgm.mp3".to_string(),
"art".to_string(),
assert!(
read_local_project_text_preview_at(&project_path, "../outside.md", &cancellation,).is_err()
);
assert!(read_local_project_media_preview_at(
&project_path,
"assets/bgm.mp3",
"art",
&cancellation,
)
.is_err());
@@ -22,6 +22,11 @@ import type {
PendingCommand,
PendingUiConfirmation,
} from '../../app/types';
import {
cancelLocalProjectResourcePreviewScope,
createProjectResourcePreviewRequestId,
createProjectResourcePreviewScopeId,
} from '../../services/projectResourcePreviewTransport';
import {
formatAgentRuntimeEvent,
isAgentRuntimeTerminalState,
@@ -1185,6 +1190,7 @@ export function SupervisorChatOnlyView({
);
return undefined;
}
const scopeId = createProjectResourcePreviewScopeId();
let cancelled = false;
setSelectedResultImage(null);
setResultImagePreviews(
@@ -1202,6 +1208,8 @@ export function SupervisorChatOnlyView({
{
projectPath,
relativePath: image.path,
scopeId,
requestId: createProjectResourcePreviewRequestId(),
},
);
if (
@@ -1223,6 +1231,7 @@ export function SupervisorChatOnlyView({
});
return () => {
cancelled = true;
cancelLocalProjectResourcePreviewScope(scopeId);
};
// resultImageKey is the stable semantic identity for the derived image list.
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -0,0 +1,51 @@
const PROJECT_RESOURCE_PREVIEW_CANCELLED_ERROR =
'project-resource-preview-scope-cancelled';
let projectResourcePreviewOpaqueIdSequence = 0;
function createProjectResourcePreviewOpaqueId(prefix: 'scope' | 'request') {
projectResourcePreviewOpaqueIdSequence += 1;
const cryptoApi = globalThis.crypto;
const randomId = cryptoApi?.randomUUID?.();
if (randomId) {
return `${prefix}:${randomId}`;
}
if (cryptoApi?.getRandomValues) {
const randomWords = cryptoApi.getRandomValues(new Uint32Array(4));
return `${prefix}:${Array.from(randomWords, (word) => word.toString(36)).join('-')}`;
}
return `${prefix}:${Date.now().toString(36)}:${Math.random().toString(36).slice(2)}:${projectResourcePreviewOpaqueIdSequence.toString(36)}`;
}
export function createProjectResourcePreviewScopeId() {
return createProjectResourcePreviewOpaqueId('scope');
}
export function createProjectResourcePreviewRequestId() {
return createProjectResourcePreviewOpaqueId('request');
}
export function isProjectResourcePreviewCancellation(error: unknown) {
const message =
error instanceof Error
? error.message
: typeof error === 'string'
? error
: null;
return message === PROJECT_RESOURCE_PREVIEW_CANCELLED_ERROR;
}
export function cancelLocalProjectResourcePreviewScope(scopeId: string) {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
return;
}
try {
void Promise.resolve(
invoke('cancel_local_project_resource_preview_scope', { scopeId }),
).catch(() => undefined);
} catch {
// The epoch/owner fence remains authoritative if the WebView is already
// tearing down and can no longer deliver the best-effort cancellation.
}
}
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
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,167 @@
import type { ProjectResourceCanvasLayoutMode } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import type { ProjectResource } from './resourceProjectionModel';
export const PROJECT_RESOURCE_CARD_PREVIEW_CONCURRENCY = 3;
export const PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT = 48;
Outdated
Review

[P1] 为预览缓存增加总字节上限

这里仅按 48 项限制缓存数量,但 Rust 允许单个媒体预览达到 32 MiB,并将其编码为约 42.7 MiB 的 base64 data URL。连续浏览 48 个大媒体时,仅缓存字符串就可能接近 2 GiB,尚未计算解码缓冲和媒体 DOM,WebView 存在 OOM 风险。请增加明确的总字节预算,或改用能够及时 revoke 的 Blob URL 等有界方案,并补连续浏览大媒体的回归。

**[P1] 为预览缓存增加总字节上限** 这里仅按 48 项限制缓存数量,但 Rust 允许单个媒体预览达到 32 MiB,并将其编码为约 42.7 MiB 的 base64 data URL。连续浏览 48 个大媒体时,仅缓存字符串就可能接近 2 GiB,尚未计算解码缓冲和媒体 DOM,WebView 存在 OOM 风险。请增加明确的总字节预算,或改用能够及时 revoke 的 Blob URL 等有界方案,并补连续浏览大媒体的回归。
export const PROJECT_RESOURCE_CARD_PREVIEW_CACHE_BYTE_LIMIT = 64 * 1024 * 1024;
export const PROJECT_RESOURCE_CARD_PREVIEW_QUEUE_LIMIT = 96;
export const PROJECT_RESOURCE_CARD_PREVIEW_ACTIVE_QUEUE_RESERVE =
PROJECT_RESOURCE_CARD_PREVIEW_CONCURRENCY;
export type ProjectResourceCardPreviewKind =
| 'raster-image'
| 'media-image'
| 'video'
| 'audio'
| 'document'
| 'version'
| 'placeholder';
export type ProjectResourceCardPreviewPayload = {
path: string;
mediaType: string;
byteLen: number;
sourceUrl?: string;
content?: string;
};
export type ProjectResourceCardPreviewTransportPayload = Omit<
ProjectResourceCardPreviewPayload,
'sourceUrl'
> & {
dataUrl?: string;
};
export type ProjectResourceCardPreviewCacheEntry = {
identity: string;
retainedBytes: number;
};
export type ProjectResourceCardPreviewState =
| { status: 'idle' }
| { status: 'loading' }
| {
status: 'loaded';
preview: ProjectResourceCardPreviewPayload;
}
| { status: 'failed'; error: string; retryable: boolean };
export const IDLE_PROJECT_RESOURCE_CARD_PREVIEW = {
status: 'idle',
} as const satisfies ProjectResourceCardPreviewState;
function normalizedProjectResourceCardPreviewRetainedBytes(
retainedBytes: number,
) {
return Number.isSafeInteger(retainedBytes) && retainedBytes >= 0
? retainedBytes
: PROJECT_RESOURCE_CARD_PREVIEW_CACHE_BYTE_LIMIT + 1;
}
export function projectResourceCardPreviewEvictionIdentities(
entries: readonly ProjectResourceCardPreviewCacheEntry[],
protectedIdentity: string | null,
): string[] {
let retainedCount = entries.length;
let retainedBytes = entries.reduce(
(total, entry) =>
total +
normalizedProjectResourceCardPreviewRetainedBytes(entry.retainedBytes),
0,
);
const evicted = new Set<string>();
const overBudget = () =>
retainedCount > PROJECT_RESOURCE_CARD_PREVIEW_CACHE_LIMIT ||
retainedBytes > PROJECT_RESOURCE_CARD_PREVIEW_CACHE_BYTE_LIMIT;
for (const entry of entries) {
if (!overBudget()) {
break;
}
if (entry.identity === protectedIdentity) {
continue;
}
evicted.add(entry.identity);
retainedCount -= 1;
retainedBytes -= normalizedProjectResourceCardPreviewRetainedBytes(
entry.retainedBytes,
);
}
if (overBudget() && protectedIdentity) {
const protectedEntry = entries.find(
(entry) => entry.identity === protectedIdentity,
);
if (protectedEntry && !evicted.has(protectedEntry.identity)) {
evicted.add(protectedEntry.identity);
}
}
return Array.from(evicted);
}
const rasterImageExtension = /\.(png|jpe?g|webp)$/iu;
const extendedImageExtension = /\.(gif|svg|avif|bmp)$/iu;
const videoExtension = /\.(mp4|webm|mov)$/iu;
export function projectResourceCardPreviewKind(
resource: ProjectResource,
): ProjectResourceCardPreviewKind {
if (resource.category === 'version') {
return 'version';
}
if (resource.category === 'document') {
return 'document';
}
if (resource.category === 'audio') {
return 'audio';
}
const mediaType = resource.mediaType.trim().toLowerCase();
if (mediaType.startsWith('video/') || videoExtension.test(resource.path)) {
return 'video';
}
if (
['image/png', 'image/jpeg', 'image/jpg', 'image/webp'].includes(
mediaType,
) ||
rasterImageExtension.test(resource.path)
) {
return 'raster-image';
}
if (
mediaType.startsWith('image/') ||
extendedImageExtension.test(resource.path)
) {
return 'media-image';
}
return 'placeholder';
}
export function projectResourceCardPreviewIdentity(input: {
projectPath: string;
projectId: string;
mode: ProjectResourceCanvasLayoutMode;
resource: ProjectResource;
}) {
return JSON.stringify([
input.projectPath,
input.projectId,
input.mode,
input.resource.id,
input.resource.category,
input.resource.path,
input.resource.mediaType,
]);
}
export function summarizeProjectResourceDocument(content: string) {
return content
.replace(/!\[[^\]]*\]\([^)]*\)/gu, ' ')
.replace(/\[([^\]]+)\]\([^)]*\)/gu, '$1')
.replace(/<[^>]*>/gu, ' ')
.replace(/[`*_~>#|{}[\]]/gu, ' ')
.replace(/^\s*[-+]\s+/gmu, '')
.replace(/\s+/gu, ' ')
.trim()
.slice(0, 180);
}
@@ -0,0 +1,122 @@
import type {
ProjectResourceCanvasLayoutMode,
ProjectResourceCanvasSection,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { RESOURCE_CANVAS_CARD_HEIGHT } from './resourceCanvasLayoutModel';
export const RESOURCE_SECTION_HEIGHT_STEP = 72;
export const RESOURCE_SECTION_MIN_HEIGHT = RESOURCE_CANVAS_CARD_HEIGHT + 88;
export const RESOURCE_SECTION_DEFAULT_HEIGHT = 340;
export const RESOURCE_SECTION_FALLBACK_MAX_HEIGHT = 640;
export const RESOURCE_CANVAS_VERTICAL_INSET = 24;
export const RESOURCE_SECTION_ZOOM_MIN = 0.5;
export const RESOURCE_SECTION_ZOOM_MAX = 2;
export const RESOURCE_SECTION_ZOOM_DEFAULT = 1;
export const RESOURCE_SECTION_ZOOM_STEP = 0.1;
export type ProjectResourceSectionHeightBounds = {
min: number;
max: number;
};
export type ProjectResourceSectionHeightAction =
| 'decrease'
| 'increase'
| 'reset';
export type ProjectResourceSectionZoomAction =
| 'decrease'
| 'increase'
| 'reset';
export function projectResourceSectionHeightKey(input: {
projectId: string;
mode: ProjectResourceCanvasLayoutMode;
section: ProjectResourceCanvasSection;
}) {
return `${input.projectId}\n${input.mode}\n${input.section}`;
}
export function projectResourceSectionHeightBounds(
canvasClientHeight: number,
): ProjectResourceSectionHeightBounds {
const availableHeight =
Number.isFinite(canvasClientHeight) && canvasClientHeight > 0
? Math.floor(canvasClientHeight) - RESOURCE_CANVAS_VERTICAL_INSET
: RESOURCE_SECTION_FALLBACK_MAX_HEIGHT;
return {
min: RESOURCE_SECTION_MIN_HEIGHT,
max: Math.max(RESOURCE_SECTION_MIN_HEIGHT, availableHeight),
};
}
export function clampProjectResourceSectionHeight(
height: number,
bounds: ProjectResourceSectionHeightBounds,
) {
const normalized = Number.isFinite(height)
? Math.round(height)
: RESOURCE_SECTION_DEFAULT_HEIGHT;
return Math.min(bounds.max, Math.max(bounds.min, normalized));
}
export function defaultProjectResourceSectionHeight(
bounds: ProjectResourceSectionHeightBounds,
) {
return clampProjectResourceSectionHeight(
RESOURCE_SECTION_DEFAULT_HEIGHT,
bounds,
);
}
export function updateProjectResourceSectionHeight(
currentHeight: number,
action: ProjectResourceSectionHeightAction,
bounds: ProjectResourceSectionHeightBounds,
) {
if (action === 'reset') {
return defaultProjectResourceSectionHeight(bounds);
}
const delta =
action === 'increase'
? RESOURCE_SECTION_HEIGHT_STEP
: -RESOURCE_SECTION_HEIGHT_STEP;
return clampProjectResourceSectionHeight(currentHeight + delta, bounds);
}
export function clampProjectResourceSectionZoom(zoom: number) {
const normalized = Number.isFinite(zoom)
? Math.round(zoom * 100) / 100
: RESOURCE_SECTION_ZOOM_DEFAULT;
return Math.min(
RESOURCE_SECTION_ZOOM_MAX,
Math.max(RESOURCE_SECTION_ZOOM_MIN, normalized),
);
}
export function updateProjectResourceSectionZoom(
currentZoom: number,
action: ProjectResourceSectionZoomAction,
) {
if (action === 'reset') {
return RESOURCE_SECTION_ZOOM_DEFAULT;
}
return clampProjectResourceSectionZoom(
currentZoom +
(action === 'increase'
? RESOURCE_SECTION_ZOOM_STEP
: -RESOURCE_SECTION_ZOOM_STEP),
);
}
export function projectResourceSectionZoomFromWheel(
currentZoom: number,
deltaY: number,
) {
if (!Number.isFinite(deltaY) || deltaY === 0) {
return clampProjectResourceSectionZoom(currentZoom);
}
return clampProjectResourceSectionZoom(
currentZoom * Math.exp(-deltaY * 0.0025),
);
}
@@ -6,12 +6,16 @@ import type {
ProjectResourceCanvasSection,
UpdateProjectResourceCanvasLayoutResult,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { isSafeProjectResourceCanvasLayoutRevision } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import {
isSafeProjectResourceCanvasCoordinate,
isSafeProjectResourceCanvasLayoutRevision,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import {
createEmptyResourceCanvasLayout,
moveResourceCanvasPosition,
reconcileResourceCanvasLayout,
type ResourceCanvasItem,
type ResourceCanvasLayoutTopology,
} from './resourceCanvasLayoutModel';
type LayoutNotice =
@@ -76,6 +80,68 @@ export function createResourceSignature(resources: ResourceCanvasItem[]) {
.join('\n');
}
/**
* A bounded, identity-only input to dependency automatic placement. Display
* labels deliberately stay out: the resource list's coordination signature
* already covers them, while topology changes must be detectable even when
* every depth and resource count is unchanged.
*/
export function createResourceTopologySignature(
topology: ResourceCanvasLayoutTopology | undefined,
) {
if (!topology) {
return '';
}
const entries = [
...topology.referenceEdges
.map(
(edge) =>
`r:${JSON.stringify([edge.sourceResourceId, edge.targetResourceId])}`,
)
.sort(compareStableText),
...topology.taskFlows
.map(
(flow) =>
`f:${JSON.stringify([
[...new Set(flow.sourceResourceIds)].sort(compareStableText),
[...new Set(flow.targetResourceIds)].sort(compareStableText),
])}`,
)
.sort(compareStableText),
];
return stableTopologyDigest([...new Set(entries)].sort(compareStableText));
}
function compareStableText(left: string, right: string) {
return left < right ? -1 : left > right ? 1 : 0;
}
/**
* Keeps the sidecar coordination input fixed-size even for a large graph.
* The canonical entries retain all stable endpoint and flow-member identities
* while being digested; labels and browser geometry never participate.
*/
function stableTopologyDigest(entries: readonly string[]) {
let forward = 0x811c9dc5;
let reverse = 0x9e3779b9;
for (const entry of entries) {
for (let index = 0; index < entry.length; index += 1) {
forward = Math.imul(forward ^ entry.charCodeAt(index), 0x01000193);
reverse = Math.imul(
reverse ^ entry.charCodeAt(entry.length - index - 1),
0x85ebca6b,
);
}
forward = Math.imul(forward ^ 10, 0x01000193);
reverse = Math.imul(reverse ^ 10, 0x85ebca6b);
}
return `${(entries.length >>> 0).toString(16).padStart(8, '0')}:${(
forward >>> 0
)
.toString(16)
.padStart(8, '0')}:${(reverse >>> 0).toString(16).padStart(8, '0')}`;
}
function layoutMatchesScope(
layout: ProjectResourceCanvasLayout,
scope: LayoutScope,
@@ -102,23 +168,46 @@ function positionsEqual(
);
}
function layoutCoordinatesAreSafe(layout: ProjectResourceCanvasLayout) {
return layout.positions.every(
(position) =>
isSafeProjectResourceCanvasCoordinate(position.x) &&
isSafeProjectResourceCanvasCoordinate(position.y),
);
}
function reconcileLayout(
source: ProjectResourceCanvasLayout,
resources: ResourceCanvasItem[],
rederiveAutomaticPositions: boolean,
topology: ResourceCanvasLayoutTopology | undefined,
) {
if (!rederiveAutomaticPositions) {
return reconcileResourceCanvasLayout(source, resources);
const reconciled = reconcileResourceCanvasLayout(
source,
resources,
topology,
);
return layoutCoordinatesAreSafe(reconciled.layout)
? reconciled
: { layout: source, changed: false };
}
const manualSource = {
...source,
positions: source.positions.filter((position) => position.manuallyPlaced),
};
const reconciled = reconcileResourceCanvasLayout(manualSource, resources);
return {
const reconciled = reconcileResourceCanvasLayout(
manualSource,
resources,
topology,
);
const result = {
layout: reconciled.layout,
changed: !positionsEqual(source.positions, reconciled.layout.positions),
};
return layoutCoordinatesAreSafe(result.layout)
? result
: { layout: source, changed: false };
}
export function useProjectResourceCanvasLayout({
@@ -126,6 +215,7 @@ export function useProjectResourceCanvasLayout({
projectId,
mode,
resources,
topology,
initializationReady = true,
renderFallbackWhileBlocked = false,
rederiveAutomaticPositions = false,
@@ -134,13 +224,18 @@ export function useProjectResourceCanvasLayout({
projectId: string;
mode: ProjectResourceCanvasLayoutMode;
resources: ResourceCanvasItem[];
topology?: ResourceCanvasLayoutTopology;
initializationReady?: boolean;
renderFallbackWhileBlocked?: boolean;
rederiveAutomaticPositions?: boolean;
}) {
const topologySignature = useMemo(
() => createResourceTopologySignature(topology),
[topology],
);
const resourceSignature = useMemo(
() => createResourceSignature(resources),
[resources],
() => [createResourceSignature(resources), topologySignature].join('\n'),
[resources, topologySignature],
);
const scopeKey = createScopeKey(
projectPath,
@@ -151,7 +246,8 @@ export function useProjectResourceCanvasLayout({
const fallback = useMemo(() => {
const empty = createEmptyResourceCanvasLayout(projectId, mode);
return initializationReady || renderFallbackWhileBlocked
? reconcileLayout(empty, resources, rederiveAutomaticPositions).layout
? reconcileLayout(empty, resources, rederiveAutomaticPositions, topology)
.layout
: empty;
}, [
initializationReady,
@@ -160,6 +256,7 @@ export function useProjectResourceCanvasLayout({
rederiveAutomaticPositions,
renderFallbackWhileBlocked,
resources,
topology,
]);
const [layout, setLayout] = useState<ProjectResourceCanvasLayout>(fallback);
const [notice, setNotice] = useState<LayoutNotice>('');
@@ -169,6 +266,7 @@ export function useProjectResourceCanvasLayout({
const layoutRef = useRef<ProjectResourceCanvasLayout>(fallback);
const persistedLayoutRef = useRef<ProjectResourceCanvasLayout>(fallback);
const resourcesRef = useRef(resources);
const topologyRef = useRef(topology);
const resourceSignatureRef = useRef(resourceSignature);
const initializedScopeEpochRef = useRef<number | null>(null);
const scopeRef = useRef<LayoutScope>({
@@ -188,6 +286,7 @@ export function useProjectResourceCanvasLayout({
>(() => undefined);
resourcesRef.current = resources;
topologyRef.current = topology;
resourceSignatureRef.current = resourceSignature;
const applyLayout = useCallback((next: ProjectResourceCanvasLayout) => {
@@ -213,6 +312,7 @@ export function useProjectResourceCanvasLayout({
persistedLayoutRef.current,
resourcesRef.current,
rederiveAutomaticPositions,
topologyRef.current,
).layout;
for (const intent of writeQueueRef.current) {
if (intent.scopeEpoch === scopeEpoch && intent.kind === 'manual') {
@@ -292,6 +392,7 @@ export function useProjectResourceCanvasLayout({
persistedLayoutRef.current,
resourcesRef.current,
rederiveAutomaticPositions,
topologyRef.current,
);
if (intent.kind === 'resources' && !reconciled.changed) {
removeWriteIntent(intent);
@@ -340,7 +441,10 @@ export function useProjectResourceCanvasLayout({
}
const expectedRevision = persistedLayoutRef.current.revision;
if (!isSafeProjectResourceCanvasLayoutRevision(expectedRevision)) {
if (
!isSafeProjectResourceCanvasLayoutRevision(expectedRevision) ||
!layoutCoordinatesAreSafe(candidate)
) {
removeWriteIntent(intent);
rebuildOptimisticLayout(scope.epoch);
if (intent.kind === 'manual') {
@@ -375,9 +479,12 @@ export function useProjectResourceCanvasLayout({
return;
}
if (
!isSafeProjectResourceCanvasLayoutRevision(result.layout.revision)
!isSafeProjectResourceCanvasLayoutRevision(result.layout.revision) ||
!layoutCoordinatesAreSafe(result.layout)
) {
throw new Error('layout response revision is not a safe integer');
throw new Error(
'layout response revision or coordinates are invalid',
);
}
if (!layoutMatchesScope(result.layout, currentScope)) {
throw new Error('layout response scope mismatch');
@@ -394,6 +501,7 @@ export function useProjectResourceCanvasLayout({
result.layout,
resourcesRef.current,
rederiveAutomaticPositions,
topologyRef.current,
).changed
) {
enqueueResourceSyncRef.current(currentScope.epoch);
@@ -413,6 +521,7 @@ export function useProjectResourceCanvasLayout({
result.layout,
resourcesRef.current,
rederiveAutomaticPositions,
topologyRef.current,
).changed;
const nextRetry =
intent.kind === 'resources' ? intent.conflictRetries + 1 : 0;
@@ -513,6 +622,7 @@ export function useProjectResourceCanvasLayout({
emptyLayout,
resourcesRef.current,
rederiveAutomaticPositions,
topologyRef.current,
).layout;
persistedLayoutRef.current = initialFallback;
applyLayout(initialFallback);
@@ -536,8 +646,13 @@ export function useProjectResourceCanvasLayout({
if (cancelled || scopeRef.current.epoch !== epoch) {
return;
}
if (!isSafeProjectResourceCanvasLayoutRevision(loaded.revision)) {
throw new Error('layout response revision is not a safe integer');
if (
!isSafeProjectResourceCanvasLayoutRevision(loaded.revision) ||
!layoutCoordinatesAreSafe(loaded)
) {
throw new Error(
'layout response revision or coordinates are invalid',
);
}
if (!layoutMatchesScope(loaded, scope)) {
return;
@@ -550,6 +665,7 @@ export function useProjectResourceCanvasLayout({
loaded,
resourcesRef.current,
rederiveAutomaticPositions,
topologyRef.current,
).changed
) {
enqueueResourceSyncRef.current(epoch);
@@ -596,6 +712,7 @@ export function useProjectResourceCanvasLayout({
layoutRef.current,
resourcesRef.current,
rederiveAutomaticPositions,
topologyRef.current,
);
applyLayout(reconciledCurrent.layout);
if (
@@ -604,6 +721,7 @@ export function useProjectResourceCanvasLayout({
persistedLayoutRef.current,
resourcesRef.current,
rederiveAutomaticPositions,
topologyRef.current,
).changed
) {
enqueueResourceSyncRef.current(scope.epoch);
@@ -0,0 +1,239 @@
import {
type RefObject,
useCallback,
useLayoutEffect,
useMemo,
useState,
} from 'react';
import type {
ProjectResourceCanvasLayoutMode,
ProjectResourceCanvasSection,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
import {
clampProjectResourceSectionHeight,
clampProjectResourceSectionZoom,
defaultProjectResourceSectionHeight,
type ProjectResourceSectionHeightAction,
projectResourceSectionHeightBounds,
projectResourceSectionHeightKey,
type ProjectResourceSectionZoomAction,
RESOURCE_SECTION_ZOOM_DEFAULT,
RESOURCE_SECTION_ZOOM_MAX,
RESOURCE_SECTION_ZOOM_MIN,
updateProjectResourceSectionHeight,
updateProjectResourceSectionZoom,
} from './resourceSectionHeightModel';
const RESOURCE_LAYOUT_MODES: readonly ProjectResourceCanvasLayoutMode[] = [
'dependency',
'type',
];
const RESOURCE_SECTIONS: readonly ProjectResourceCanvasSection[] = [
'document',
'version',
'art',
'audio',
];
export type ProjectResourceSectionHeightState = {
height: number;
min: number;
max: number;
defaultHeight: number;
atMin: boolean;
atMax: boolean;
atDefault: boolean;
zoom: number;
atMinZoom: boolean;
atMaxZoom: boolean;
atDefaultZoom: boolean;
};
export function useProjectResourceSectionHeights(input: {
projectId: string;
mode: ProjectResourceCanvasLayoutMode;
canvasRef: RefObject<HTMLDivElement | null>;
enabled: boolean;
}) {
const { canvasRef, enabled, mode, projectId } = input;
const [bounds, setBounds] = useState(() =>
projectResourceSectionHeightBounds(0),
);
const [sessionHeights, setSessionHeights] = useState<Map<string, number>>(
() => new Map(),
);
const [sessionZooms, setSessionZooms] = useState<Map<string, number>>(
() => new Map(),
);
useLayoutEffect(() => {
if (!enabled) {
return undefined;
}
let frameId: number | null = null;
const measure = () => {
frameId = null;
const canvasHeight = canvasRef.current?.clientHeight ?? 0;
if (canvasHeight <= 0) {
return;
}
const nextBounds = projectResourceSectionHeightBounds(canvasHeight);
setBounds((current) =>
current.min === nextBounds.min && current.max === nextBounds.max
? current
: nextBounds,
);
setSessionHeights((current) => {
let next: Map<string, number> | null = null;
for (const layoutMode of RESOURCE_LAYOUT_MODES) {
for (const section of RESOURCE_SECTIONS) {
const key = projectResourceSectionHeightKey({
projectId,
mode: layoutMode,
section,
});
const height = current.get(key);
if (height === undefined) {
continue;
}
const clamped = clampProjectResourceSectionHeight(
height,
nextBounds,
);
if (clamped !== height) {
next ??= new Map(current);
next.set(key, clamped);
}
}
}
return next ?? current;
});
};
const scheduleMeasure = () => {
if (frameId !== null) {
return;
}
frameId = window.requestAnimationFrame(measure);
};
measure();
window.addEventListener('resize', scheduleMeasure);
return () => {
if (frameId !== null) {
window.cancelAnimationFrame(frameId);
}
window.removeEventListener('resize', scheduleMeasure);
};
}, [canvasRef, enabled, projectId]);
const sectionStates = useMemo(() => {
const defaultHeight = defaultProjectResourceSectionHeight(bounds);
return new Map(
RESOURCE_SECTIONS.map((section) => {
const key = projectResourceSectionHeightKey({
projectId,
mode,
section,
});
const height = clampProjectResourceSectionHeight(
sessionHeights.get(key) ?? defaultHeight,
bounds,
);
const zoom = clampProjectResourceSectionZoom(
sessionZooms.get(key) ?? RESOURCE_SECTION_ZOOM_DEFAULT,
);
return [
section,
{
height,
min: bounds.min,
max: bounds.max,
defaultHeight,
atMin: height === bounds.min,
atMax: height === bounds.max,
atDefault: height === defaultHeight,
zoom,
atMinZoom: zoom === RESOURCE_SECTION_ZOOM_MIN,
atMaxZoom: zoom === RESOURCE_SECTION_ZOOM_MAX,
atDefaultZoom: zoom === RESOURCE_SECTION_ZOOM_DEFAULT,
} satisfies ProjectResourceSectionHeightState,
] as const;
}),
);
}, [bounds, mode, projectId, sessionHeights, sessionZooms]);
const updateSectionHeight = useCallback(
(
section: ProjectResourceCanvasSection,
action: ProjectResourceSectionHeightAction,
) => {
const key = projectResourceSectionHeightKey({ projectId, mode, section });
setSessionHeights((current) => {
const currentHeight = clampProjectResourceSectionHeight(
current.get(key) ?? defaultProjectResourceSectionHeight(bounds),
bounds,
);
const nextHeight = updateProjectResourceSectionHeight(
currentHeight,
action,
bounds,
);
if (nextHeight === currentHeight) {
return current;
}
const next = new Map(current);
next.set(key, nextHeight);
return next;
});
},
[bounds, mode, projectId],
);
const setSectionZoom = useCallback(
(section: ProjectResourceCanvasSection, zoom: number) => {
const key = projectResourceSectionHeightKey({ projectId, mode, section });
const nextZoom = clampProjectResourceSectionZoom(zoom);
setSessionZooms((current) => {
const currentZoom = clampProjectResourceSectionZoom(
current.get(key) ?? RESOURCE_SECTION_ZOOM_DEFAULT,
);
if (nextZoom === currentZoom) {
return current;
}
const next = new Map(current);
next.set(key, nextZoom);
return next;
});
},
[mode, projectId],
);
const updateSectionZoom = useCallback(
(
section: ProjectResourceCanvasSection,
action: ProjectResourceSectionZoomAction,
) => {
const key = projectResourceSectionHeightKey({ projectId, mode, section });
setSessionZooms((current) => {
const currentZoom = clampProjectResourceSectionZoom(
current.get(key) ?? RESOURCE_SECTION_ZOOM_DEFAULT,
);
const nextZoom = updateProjectResourceSectionZoom(currentZoom, action);
if (nextZoom === currentZoom) {
return current;
}
const next = new Map(current);
next.set(key, nextZoom);
return next;
});
},
[mode, projectId],
);
return {
sectionStates,
setSectionZoom,
updateSectionHeight,
updateSectionZoom,
};
}

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