diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs index 7a03a5eed..b35413714 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs @@ -5,6 +5,9 @@ pub(super) static GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE: OnceLock>, > = OnceLock::new(); +#[cfg(test)] +pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_TEST_LOCK: std::sync::Mutex<()> = + std::sync::Mutex::new(()); pub(super) static STATIC_DELEGATE_PARENT_WAKE_SINGLEFLIGHT: OnceLock< std::sync::Mutex>, > = OnceLock::new(); @@ -238,7 +241,7 @@ pub(in crate::agent) use task_queue::*; pub(in crate::agent) use task_start::*; #[cfg(test)] -pub(crate) use entrypoints::clear_game_creator_manifest_invalidation_event_sink_for_test; +pub(crate) use entrypoints::acquire_game_creator_manifest_invalidation_event_sink_test_guard; #[allow(unused_imports)] pub(crate) use entrypoints::{ chat_with_game_creator_agent_at, chat_with_game_creator_role_agent_at, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs index 04b6afc71..195bcd2eb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs @@ -80,8 +80,37 @@ pub(crate) fn configure_game_creator_manifest_invalidation_event_sink( } #[cfg(test)] -pub(crate) fn clear_game_creator_manifest_invalidation_event_sink_for_test() { - *lock_game_creator_manifest_invalidation_event_sink() = None; +pub(crate) struct GameCreatorManifestInvalidationEventSinkTestGuard { + _isolation: std::sync::MutexGuard<'static, ()>, +} + +#[cfg(test)] +impl GameCreatorManifestInvalidationEventSinkTestGuard { + pub(crate) fn configure(&self, port: u16, token: &str) -> Result<(), String> { + configure_game_creator_manifest_invalidation_event_sink(port, token) + } + + pub(crate) fn configured_sink(&self) -> Option { + lock_game_creator_manifest_invalidation_event_sink().clone() + } +} + +#[cfg(test)] +impl Drop for GameCreatorManifestInvalidationEventSinkTestGuard { + fn drop(&mut self) { + *lock_game_creator_manifest_invalidation_event_sink() = None; + } +} + +#[cfg(test)] +pub(crate) fn acquire_game_creator_manifest_invalidation_event_sink_test_guard( +) -> GameCreatorManifestInvalidationEventSinkTestGuard { + let isolation = GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK_TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + GameCreatorManifestInvalidationEventSinkTestGuard { + _isolation: isolation, + } } fn relay_game_creator_manifest_invalidation(root: &Path, agent_id: &str) -> Result<(), String> { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs index d212f6dcf..49f07b40a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs @@ -2229,6 +2229,9 @@ fn validate_generation_provenance(value: &AssetCanvasGenerationProvenance) -> Re #[cfg(test)] #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum AssetCanvasCommitFaultStage { + FirstSnapshotInstalled, + SnapshotsInstalled, + JournalInstalled, Prepared, FileInstalled, ManifestInstalled, @@ -2248,7 +2251,16 @@ fn maybe_fail_asset_canvas_commit( if fault.is_some_and(|fault| { matches!( (fault, stage), - (AssetCanvasCommitFaultStage::Prepared, "prepared") + ( + AssetCanvasCommitFaultStage::FirstSnapshotInstalled, + "first-snapshot-installed" + ) | ( + AssetCanvasCommitFaultStage::SnapshotsInstalled, + "snapshots-installed" + ) | ( + AssetCanvasCommitFaultStage::JournalInstalled, + "journal-installed" + ) | (AssetCanvasCommitFaultStage::Prepared, "prepared") | (AssetCanvasCommitFaultStage::FileInstalled, "file-installed") | ( AssetCanvasCommitFaultStage::ManifestInstalled, @@ -2590,6 +2602,7 @@ fn commit_asset_canvas_at_internal( "manifest.before.json", &manifest_before_bytes, )?; + maybe_fail_asset_canvas_commit(fault, "first-snapshot-installed")?; write_asset_canvas_transaction_snapshot( root, &input.commit_id, @@ -2608,7 +2621,9 @@ fn commit_asset_canvas_at_internal( "project-revision.after.json", &revision_after_bytes, )?; + maybe_fail_asset_canvas_commit(fault, "snapshots-installed")?; write_asset_canvas_journal(root, &journal)?; + maybe_fail_asset_canvas_commit(fault, "journal-installed")?; write_asset_canvas_ledger(root, &ledger)?; maybe_fail_asset_canvas_commit(fault, "prepared")?; @@ -3079,6 +3094,92 @@ fn recover_asset_canvas_transaction_locked( mark_asset_canvas_reconciliation_locked(root, journal, ledger).map(|outcome| (outcome, None)) } +fn clean_unpublished_asset_canvas_transaction_locked( + root: &Path, + commit_id: &str, + journal: Option<&AssetCanvasTransactionJournal>, +) -> Result { + if let Some(journal) = journal { + if journal.schema_version != ASSET_CANVAS_TRANSACTION_SCHEMA_VERSION + || journal.commit_id != commit_id + || journal.stage != AssetCanvasTransactionStage::Prepared + { + return Err("缺少 ledger 的素材画布 transaction 身份无效".to_string()); + } + let final_path = resolve_local_project_path(root, &journal.final_image_relative_path)?; + match fs::symlink_metadata(&final_path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Ok(_) => return Err("缺少 ledger 的素材画布 transaction 已产生正式文件".to_string()), + Err(_) => return Err("检查未发布素材画布 transaction 正式文件失败".to_string()), + } + let current_manifest = current_asset_canvas_manifest(root)?; + if asset_canvas_sha256(&asset_canvas_json_bytes(¤t_manifest)?) + != journal.manifest_before_sha256 + { + return Err("缺少 ledger 的素材画布 transaction manifest 已变化".to_string()); + } + let current_revision = read_game_creator_agent_runtime_project_revision(root)?; + if journal.project_revision_before_sha256.as_deref() + != Some(asset_canvas_sha256(&asset_canvas_json_bytes(¤t_revision)?).as_str()) + { + return Err("缺少 ledger 的素材画布 transaction revision 已变化".to_string()); + } + } + + let transaction_directory = resolve_local_project_path( + root, + &format!("{ASSET_CANVAS_ROOT}/transactions/{commit_id}"), + )?; + let mut files = Vec::new(); + for entry in fs::read_dir(&transaction_directory) + .map_err(|_| "读取未发布素材画布 transaction 失败".to_string())? + { + let entry = entry.map_err(|_| "读取未发布素材画布 transaction 条目失败".to_string())?; + let metadata = fs::symlink_metadata(entry.path()) + .map_err(|_| "读取未发布素材画布 transaction 元数据失败".to_string())?; + let name = entry.file_name().to_string_lossy().into_owned(); + let is_expected_snapshot = matches!( + name.as_str(), + "manifest.before.json" + | "manifest.after.json" + | "project-revision.before.json" + | "project-revision.after.json" + | "journal.json" + ); + let is_owned_temporary_file = (name.starts_with(".asset-canvas-") + && name.ends_with(".tmp")) + || name.starts_with(".journal.json.tmp."); + if metadata.file_type().is_symlink() + || !metadata.is_file() + || metadata.len() > ASSET_CANVAS_MAX_LEDGER_BYTES as u64 + || (!is_expected_snapshot && !is_owned_temporary_file) + { + return Err("未发布素材画布 transaction 包含未知文件".to_string()); + } + files.push(entry.path()); + } + if files.len() > 16 { + return Err("未发布素材画布 transaction 文件数量超限".to_string()); + } + for path in files { + fs::remove_file(path).map_err(|_| "清理未发布素材画布 transaction 文件失败".to_string())?; + } + fs::remove_dir(&transaction_directory) + .map_err(|_| "清理未发布素材画布 transaction 目录失败".to_string())?; + #[cfg(unix)] + if let Some(parent) = transaction_directory.parent() { + File::open(parent) + .and_then(|directory| directory.sync_all()) + .map_err(|_| "同步素材画布 transaction 清理结果失败".to_string())?; + } + Ok(RecoverAssetCanvasOutcome { + commit_id: commit_id.to_string(), + status: RecoverAssetCanvasOutcomeStatus::RolledBack, + event_id: None, + asset_id: None, + }) +} + pub(crate) fn recover_asset_canvas_transactions_at( root: &Path, expected_project_id: &str, @@ -3114,7 +3215,16 @@ pub(crate) fn recover_asset_canvas_transactions_at( let mut outcomes = Vec::new(); let mut events = Vec::new(); for commit_id in commit_ids { - let Some(journal) = read_asset_canvas_journal(root, &commit_id)? else { + let journal = read_asset_canvas_journal(root, &commit_id)?; + if read_asset_canvas_ledger(root, &commit_id)?.is_none() { + outcomes.push(clean_unpublished_asset_canvas_transaction_locked( + root, + &commit_id, + journal.as_ref(), + )?); + continue; + } + let Some(journal) = journal else { return Err("素材画布 transaction 缺少 journal".to_string()); }; let (outcome, event) = recover_asset_canvas_transaction_locked(root, journal)?; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs index 76e430b68..ca6f747d1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs @@ -128,14 +128,12 @@ impl From for PrivateCanvasContext { } } -#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] +#[derive(Clone)] struct PrivateUploadTicket { host: String, bucket: String, object_key: String, success_action_status: u16, - max_size_bytes: u64, form_fields: BTreeMap, } @@ -145,7 +143,8 @@ struct PrivateReferenceState { resource_id: String, stable_reference: Option, asset_object_id: Option, - upload_ticket: Option, + upload_bucket: Option, + upload_object_key: Option, upload_completed: bool, } @@ -336,6 +335,21 @@ fn validate_generation_ledger(ledger: &AssetCanvasGenerationLedger) -> Result<() { return Err("素材画布私有生成账本内容无效".to_string()); } + for state in &ledger.reference_states { + if state.resource_id.trim().is_empty() + || state.resource_id.chars().count() > 512 + || state.resource_id.chars().any(char::is_control) + || state.upload_bucket.is_some() != state.upload_object_key.is_some() + || state.upload_bucket.as_ref().is_some_and(|value| { + value.is_empty() || value.len() > 512 || value.chars().any(char::is_control) + }) + || state.upload_object_key.as_ref().is_some_and(|value| { + value.is_empty() || value.len() > 2048 || value.chars().any(char::is_control) + }) + { + return Err("素材画布私有生成参考状态无效".to_string()); + } + } if let (Some(body), Some(expected_sha)) = ( ledger.request_body_json.as_deref(), ledger.request_body_sha256.as_deref(), @@ -756,7 +770,8 @@ async fn try_confirm_uploaded_reference( client: &reqwest::Client, api_base_url: &str, api_key: &str, - ticket: &PrivateUploadTicket, + bucket: &str, + object_key: &str, material: &ReferenceMaterial, asset_kind: &str, ) -> Result, String> { @@ -766,8 +781,8 @@ async fn try_confirm_uploaded_reference( )) .bearer_auth(api_key) .json(&serde_json::json!({ - "bucket": ticket.bucket, - "objectKey": ticket.object_key, + "bucket": bucket, + "objectKey": object_key, "contentType": material.media_type, "contentLength": material.bytes.as_ref().map(Vec::len), "contentHash": material.sha256, @@ -790,7 +805,7 @@ async fn try_confirm_uploaded_reference( .unwrap_or(&serde_json::Value::Null); let confirmed_object_key = json_string_field(asset_object, "objectKey") .ok_or_else(|| "参考资源确认响应缺少 objectKey".to_string())?; - if confirmed_object_key != ticket.object_key { + if confirmed_object_key != object_key { return Err("参考资源确认响应 objectKey 不一致".to_string()); } json_string_field(asset_object, "assetObjectId") @@ -867,7 +882,6 @@ async fn request_upload_ticket( bucket, object_key, success_action_status, - max_size_bytes, form_fields, }) } @@ -941,7 +955,8 @@ async fn ensure_reference_states( resource_id: resource_id.clone(), stable_reference: material.stable_reference.clone(), asset_object_id: None, - upload_ticket: None, + upload_bucket: None, + upload_object_key: None, upload_completed: material.stable_reference.is_some(), }); write_generation_ledger(root, ledger)?; @@ -957,19 +972,25 @@ async fn ensure_reference_states( write_generation_ledger(root, ledger)?; continue; } - if let Some(ticket) = ledger.reference_states[index].upload_ticket.clone() { + if let (Some(bucket), Some(object_key)) = ( + ledger.reference_states[index].upload_bucket.clone(), + ledger.reference_states[index].upload_object_key.clone(), + ) { if let Some(asset_object_id) = try_confirm_uploaded_reference( client, api_base_url, api_key, - &ticket, + &bucket, + &object_key, &material, &ledger.asset_kind, ) .await? { - ledger.reference_states[index].stable_reference = Some(ticket.object_key); + ledger.reference_states[index].stable_reference = Some(object_key); ledger.reference_states[index].asset_object_id = Some(asset_object_id); + ledger.reference_states[index].upload_bucket = None; + ledger.reference_states[index].upload_object_key = None; ledger.reference_states[index].upload_completed = true; write_generation_ledger(root, ledger)?; continue; @@ -977,7 +998,8 @@ async fn ensure_reference_states( } let ticket = request_upload_ticket(client, api_base_url, api_key, ledger, &material).await?; - ledger.reference_states[index].upload_ticket = Some(ticket.clone()); + ledger.reference_states[index].upload_bucket = Some(ticket.bucket.clone()); + ledger.reference_states[index].upload_object_key = Some(ticket.object_key.clone()); ledger.reference_states[index].upload_completed = false; write_generation_ledger(root, ledger)?; upload_reference(&ticket, &material, api_base_url).await?; @@ -987,7 +1009,8 @@ async fn ensure_reference_states( client, api_base_url, api_key, - &ticket, + &ticket.bucket, + &ticket.object_key, &material, &ledger.asset_kind, ) @@ -995,6 +1018,8 @@ async fn ensure_reference_states( .ok_or_else(|| "参考资源上传后无法确认稳定对象".to_string())?; ledger.reference_states[index].stable_reference = Some(ticket.object_key); ledger.reference_states[index].asset_object_id = Some(asset_object_id); + ledger.reference_states[index].upload_bucket = None; + ledger.reference_states[index].upload_object_key = None; write_generation_ledger(root, ledger)?; } ledger.resolved_reference_ids = ledger @@ -2283,14 +2308,16 @@ mod tests { resource_id: source_resource_id.clone(), stable_reference: Some("objects/source.png".to_string()), asset_object_id: Some("source-object".to_string()), - upload_ticket: None, + upload_bucket: None, + upload_object_key: None, upload_completed: true, }, PrivateReferenceState { resource_id: "local-asset:style".to_string(), stable_reference: Some("objects/style.png".to_string()), asset_object_id: Some("style-object".to_string()), - upload_ticket: None, + upload_bucket: None, + upload_object_key: None, upload_completed: true, }, ], @@ -2334,6 +2361,64 @@ mod tests { ); } + #[test] + fn private_generation_ledger_never_serializes_upload_credentials_or_provider_url() { + let project_id = "phase-five-private-upload-ledger"; + let (directory, draft) = create_generation_fixture(project_id, "阶段五私有上传账本测试"); + let ticket = PrivateUploadTicket { + host: "https://private-upload.provider.example.test/signed".to_string(), + bucket: "stable-private-bucket".to_string(), + object_key: "asset-canvas-references/project/reference.png".to_string(), + success_action_status: 204, + form_fields: BTreeMap::from([ + ( + "Authorization".to_string(), + "private-authorization".to_string(), + ), + ("policy".to_string(), "private-upload-policy".to_string()), + ( + "signature".to_string(), + "private-upload-signature".to_string(), + ), + ]), + }; + let mut ledger = accepted_ledger( + project_id, + &draft, + "https://editor.example.test", + "private-api-key", + ); + ledger.phase = GenerationLedgerPhase::ReferencesPreparing; + ledger.reference_states = vec![PrivateReferenceState { + resource_id: "local-asset:reference".to_string(), + stable_reference: None, + asset_object_id: None, + upload_bucket: Some(ticket.bucket.clone()), + upload_object_key: Some(ticket.object_key.clone()), + upload_completed: false, + }]; + ledger.requested_reference_resource_ids = vec!["local-asset:reference".to_string()]; + + let persisted = serde_json::to_string_pretty(&ledger).expect("serialize private ledger"); + assert!(persisted.contains("stable-private-bucket")); + assert!(persisted.contains("asset-canvas-references/project/reference.png")); + for forbidden in [ + "uploadTicket", + "formFields", + ticket.host.as_str(), + "private-authorization", + "private-upload-policy", + "private-upload-signature", + "private-api-key", + ] { + assert!( + !persisted.contains(forbidden), + "private ledger leaked forbidden upload material: {forbidden}" + ); + } + drop(directory); + } + #[test] fn unknown_poll_result_keeps_the_original_operation_in_get_only_recovery() { let project_id = "phase-five-poll-reconciliation"; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas_tests.rs index 66975d609..f8b4d0f23 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas_tests.rs @@ -544,6 +544,9 @@ fn same_project_revision_double_commit_cannot_overwrite() { #[test] fn every_commit_fault_stage_recovers_without_ambiguous_overwrite() { for fault in [ + AssetCanvasCommitFaultStage::FirstSnapshotInstalled, + AssetCanvasCommitFaultStage::SnapshotsInstalled, + AssetCanvasCommitFaultStage::JournalInstalled, AssetCanvasCommitFaultStage::Prepared, AssetCanvasCommitFaultStage::FileInstalled, AssetCanvasCommitFaultStage::ManifestInstalled, @@ -573,7 +576,11 @@ fn every_commit_fault_stage_recovers_without_ambiguous_overwrite() { .find(|outcome| outcome.commit_id == input.commit_id) .expect("recovery outcome"); match fault { - AssetCanvasCommitFaultStage::Prepared | AssetCanvasCommitFaultStage::FileInstalled => { + AssetCanvasCommitFaultStage::FirstSnapshotInstalled + | AssetCanvasCommitFaultStage::SnapshotsInstalled + | AssetCanvasCommitFaultStage::JournalInstalled + | AssetCanvasCommitFaultStage::Prepared + | AssetCanvasCommitFaultStage::FileInstalled => { assert_eq!(outcome.status, RecoverAssetCanvasOutcomeStatus::RolledBack); assert!(!fixture .root() @@ -593,6 +600,20 @@ fn every_commit_fault_stage_recovers_without_ambiguous_overwrite() { assert_eq!(recovered_draft.status, AssetCanvasDraftStatus::Editing); assert_eq!(recovered_draft.revision, draft.revision); assert!(recovered_draft.pending_commit.is_none()); + if matches!( + fault, + AssetCanvasCommitFaultStage::FirstSnapshotInstalled + | AssetCanvasCommitFaultStage::SnapshotsInstalled + | AssetCanvasCommitFaultStage::JournalInstalled + ) { + assert!(!fixture + .root() + .join(format!( + ".agent/workbench/asset-canvas/transactions/{}", + input.commit_id + )) + .exists()); + } } _ => { assert!(matches!( @@ -632,6 +653,60 @@ fn every_commit_fault_stage_recovers_without_ambiguous_overwrite() { } } +#[test] +fn unpublished_snapshot_transaction_recovers_and_replays_the_same_idempotency_identity() { + let fixture = initialize_fixture(); + let draft = add_imported_layer(&fixture, &fixture.draft); + let staged = stage_image(&fixture, &draft); + let input = commit_input( + &fixture, + &draft, + &staged, + Uuid::new_v4().to_string(), + Uuid::new_v4().to_string(), + ); + commit_asset_canvas_at_internal( + fixture.root(), + &input, + Some(AssetCanvasCommitFaultStage::SnapshotsInstalled), + ) + .expect_err("stop before journal publication"); + + let recovered = recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID) + .expect("clean unpublished transaction"); + assert_eq!( + recovered.result.outcomes, + vec![RecoverAssetCanvasOutcome { + commit_id: input.commit_id.clone(), + status: RecoverAssetCanvasOutcomeStatus::RolledBack, + event_id: None, + asset_id: None, + }] + ); + + assert!(matches!( + commit_asset_canvas_at(fixture.root(), &input) + .expect("replay same idempotency identity after cleanup") + .result, + CommitAssetCanvasResult::Committed { .. } + )); + assert!(matches!( + commit_asset_canvas_at(fixture.root(), &input) + .expect("repeat committed identity") + .result, + CommitAssetCanvasResult::AlreadyCommitted { .. } + )); + assert_eq!( + current_asset_canvas_manifest(fixture.root()) + .expect("manifest after replay") + .assets + .iter() + .filter(|asset| asset.id.starts_with("canvas-")) + .count(), + 1 + ); +} + #[test] fn mismatched_installed_file_requires_reconciliation_and_is_not_deleted() { let fixture = initialize_fixture(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs index 03ac23701..e4d35d654 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/client.rs @@ -11,6 +11,7 @@ use std::io::{self, BufRead, BufReader, Read, Write}; use std::net::{Ipv4Addr, SocketAddrV4, TcpStream}; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; +use std::sync::{Mutex, OnceLock}; use std::thread; use std::time::{Duration, Instant}; @@ -19,6 +20,76 @@ const AGENT_RUNNER_LOG_INPUT_LINE_MAX_BYTES: usize = 8 * 1024; const AGENT_RUNNER_LOG_OUTPUT_MAX_CHARS: usize = 1_024; const AGENT_RUNNER_CLIENT_EXIT_TIMEOUT: Duration = Duration::from_secs(15); +#[derive(Default)] +pub(super) struct ExternalAgentRunnerGuiOwnerAttachmentState { + generation: u64, + registration: Option, +} + +struct ExternalAgentRunnerGuiOwnerRegistration { + generation: u64, + config_dir: PathBuf, + params: ExternalAgentRunnerRequestParams, + attached_boot_id: Option, +} + +static EXTERNAL_AGENT_RUNNER_GUI_OWNER_ATTACHMENT_STATE: OnceLock< + Mutex, +> = OnceLock::new(); + +fn external_agent_runner_gui_owner_attachment_state( +) -> &'static Mutex { + EXTERNAL_AGENT_RUNNER_GUI_OWNER_ATTACHMENT_STATE + .get_or_init(|| Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default())) +} + +pub(super) fn register_external_agent_runner_gui_owner_attachment( + state: &Mutex, + config_dir: &Path, + params: ExternalAgentRunnerRequestParams, +) { + let mut state = lock_unpoisoned(state); + state.generation = state.generation.wrapping_add(1); + let generation = state.generation; + state.registration = Some(ExternalAgentRunnerGuiOwnerRegistration { + generation, + config_dir: config_dir.to_path_buf(), + params, + attached_boot_id: None, + }); +} + +pub(super) fn attach_registered_external_agent_runner_gui_owner_if_needed_with( + state: &Mutex, + config_dir: &Path, + endpoint: &ExternalAgentRunnerEndpoint, + attach: F, +) -> Result<(), String> +where + F: FnOnce(&ExternalAgentRunnerEndpoint, ExternalAgentRunnerRequestParams) -> Result<(), String>, +{ + let Some((generation, params)) = ({ + let state = lock_unpoisoned(state); + state.registration.as_ref().and_then(|registration| { + (registration.config_dir == config_dir + && registration.attached_boot_id.as_deref() != Some(endpoint.boot_id.as_str())) + .then(|| (registration.generation, registration.params.clone())) + }) + }) else { + return Ok(()); + }; + + attach(endpoint, params)?; + + let mut state = lock_unpoisoned(state); + if let Some(registration) = state.registration.as_mut() { + if registration.generation == generation && registration.config_dir == config_dir { + registration.attached_boot_id = Some(endpoint.boot_id.clone()); + } + } + Ok(()) +} + fn redact_url_queries(line: &str) -> String { line.split_whitespace() .map(|token| { @@ -919,18 +990,24 @@ pub(crate) fn attach_external_agent_runner_gui_owner( ) -> Result<(), String> { EXTERNAL_AGENT_RUNNER_GUI_OWNER_REQUIRED_CLIENT .store(true, std::sync::atomic::Ordering::Release); + let _configure = lock_unpoisoned(external_agent_runner_configure_lock()); let config_dir = external_agent_runner_config_dir() .ok_or_else(|| "外部 Agent Runner 尚未配置 AppData;请显式传入 --config-dir".to_string())?; - let endpoint = ensure_external_agent_runner(&config_dir)?; - let result = send_external_agent_runner_request( - &endpoint, - "runner.attach_gui_owner", + register_external_agent_runner_gui_owner_attachment( + external_agent_runner_gui_owner_attachment_state(), + &config_dir, ExternalAgentRunnerRequestParams { event_sink_port: Some(event_sink.port), event_sink_token: Some(event_sink.token.clone()), ..ExternalAgentRunnerRequestParams::default() }, - )?; + ); + ensure_external_agent_runner(&config_dir).map(|_| ()) +} + +pub(super) fn validate_external_agent_runner_gui_owner_attachment_result( + result: &Value, +) -> Result<(), String> { if result.get("attached").and_then(Value::as_bool) == Some(true) && result.get("eventSinkAttached").and_then(Value::as_bool) == Some(true) { @@ -940,6 +1017,26 @@ pub(crate) fn attach_external_agent_runner_gui_owner( } } +fn attach_external_agent_runner_gui_owner_at( + endpoint: &ExternalAgentRunnerEndpoint, + params: ExternalAgentRunnerRequestParams, +) -> Result<(), String> { + let result = send_external_agent_runner_request(endpoint, "runner.attach_gui_owner", params)?; + validate_external_agent_runner_gui_owner_attachment_result(&result) +} + +fn attach_registered_external_agent_runner_gui_owner_if_needed( + config_dir: &Path, + endpoint: &ExternalAgentRunnerEndpoint, +) -> Result<(), String> { + attach_registered_external_agent_runner_gui_owner_if_needed_with( + external_agent_runner_gui_owner_attachment_state(), + config_dir, + endpoint, + attach_external_agent_runner_gui_owner_at, + ) +} + pub(super) fn shutdown_external_agent_runner_for_client_exit_at( config_dir: &Path, ) -> Result { @@ -1035,6 +1132,9 @@ pub(super) fn ensure_external_agent_runner( match external_agent_runner_endpoint_reuse_decision(&endpoint, &executable_fingerprint) { ExternalAgentRunnerReuseDecision::Reuse => { if ping_external_agent_runner(&endpoint).is_ok() { + attach_registered_external_agent_runner_gui_owner_if_needed( + config_dir, &endpoint, + )?; return Ok(endpoint); } } @@ -1068,6 +1168,7 @@ pub(super) fn ensure_external_agent_runner( let _ = launched.child.wait(); }) .map_err(|error| format!("启动 Agent Runner 子进程回收线程失败:{error}"))?; + attach_registered_external_agent_runner_gui_owner_if_needed(config_dir, &endpoint)?; Ok(endpoint) } Err(error) => { diff --git a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs index 4b92b1e10..4e28d62ac 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/runner/tests.rs @@ -10,6 +10,7 @@ use std::io::{self, Cursor}; use std::net::{Ipv4Addr, SocketAddrV4, TcpListener}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Mutex; use std::time::{Duration, Instant}; static TEST_DIRECTORY_COUNTER: AtomicU64 = AtomicU64::new(0); @@ -551,6 +552,292 @@ fn runner_endpoint_rejects_hard_links() { assert!(error.contains("硬链接")); } +#[test] +fn gui_owner_registration_replays_once_for_each_runner_boot() { + let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default()); + let config_dir = PathBuf::from("registered-gui-appdata"); + let event_sink_port = 31_317; + let event_sink_token = "a".repeat(64); + let params = ExternalAgentRunnerRequestParams { + event_sink_port: Some(event_sink_port), + event_sink_token: Some(event_sink_token.clone()), + ..ExternalAgentRunnerRequestParams::default() + }; + register_external_agent_runner_gui_owner_attachment(&state, &config_dir, params); + + let calls = std::cell::RefCell::new(Vec::new()); + let endpoint_a = test_endpoint( + "gui-owner-replay-token-gui-owner-replay-token", + "gui-owner-boot-a", + 31318, + ); + attach_registered_external_agent_runner_gui_owner_if_needed_with( + &state, + &config_dir, + &endpoint_a, + |endpoint, params| { + calls.borrow_mut().push(( + endpoint.boot_id.clone(), + params + .event_sink_port + .expect("registered sink port is retained"), + params + .event_sink_token + .expect("registered sink token is retained"), + )); + Ok(()) + }, + ) + .expect("first boot attaches"); + attach_registered_external_agent_runner_gui_owner_if_needed_with( + &state, + &config_dir, + &endpoint_a, + |_, _| panic!("same boot must not attach twice"), + ) + .expect("same boot is idempotent"); + + let endpoint_b = test_endpoint( + "gui-owner-replay-token-gui-owner-replay-token", + "gui-owner-boot-b", + 31319, + ); + attach_registered_external_agent_runner_gui_owner_if_needed_with( + &state, + &config_dir, + &endpoint_b, + |endpoint, params| { + calls.borrow_mut().push(( + endpoint.boot_id.clone(), + params + .event_sink_port + .expect("registered sink port is replayed"), + params + .event_sink_token + .expect("registered sink token is replayed"), + )); + Ok(()) + }, + ) + .expect("replacement boot reattaches"); + + assert_eq!( + calls.into_inner(), + vec![ + ( + "gui-owner-boot-a".to_string(), + event_sink_port, + event_sink_token.clone(), + ), + ( + "gui-owner-boot-b".to_string(), + event_sink_port, + event_sink_token, + ), + ] + ); +} + +#[test] +fn gui_owner_registration_failed_replay_remains_pending_for_same_boot() { + let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default()); + let config_dir = PathBuf::from("retry-gui-appdata"); + register_external_agent_runner_gui_owner_attachment( + &state, + &config_dir, + ExternalAgentRunnerRequestParams::default(), + ); + let endpoint = test_endpoint( + "gui-owner-retry-token-gui-owner-retry-token", + "gui-owner-retry-boot", + 31320, + ); + let attempts = std::cell::Cell::new(0_u32); + + let error = attach_registered_external_agent_runner_gui_owner_if_needed_with( + &state, + &config_dir, + &endpoint, + |_, _| { + attempts.set(attempts.get() + 1); + Err("injected attach failure".to_string()) + }, + ) + .expect_err("failed attach must remain pending"); + assert_eq!(error, "injected attach failure"); + attach_registered_external_agent_runner_gui_owner_if_needed_with( + &state, + &config_dir, + &endpoint, + |_, _| { + attempts.set(attempts.get() + 1); + Ok(()) + }, + ) + .expect("same boot retries after failure"); + attach_registered_external_agent_runner_gui_owner_if_needed_with( + &state, + &config_dir, + &endpoint, + |_, _| panic!("successful retry must mark the boot attached"), + ) + .expect("successful retry is idempotent"); + assert_eq!(attempts.get(), 2); +} + +#[test] +fn gui_owner_registration_missing_event_sink_confirmation_retries_same_boot() { + let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default()); + let config_dir = PathBuf::from("missing-sink-confirmation-appdata"); + register_external_agent_runner_gui_owner_attachment( + &state, + &config_dir, + ExternalAgentRunnerRequestParams { + event_sink_port: Some(31_322), + event_sink_token: Some("c".repeat(64)), + ..ExternalAgentRunnerRequestParams::default() + }, + ); + let endpoint = test_endpoint( + "missing-sink-confirmation-runner-token", + "missing-sink-confirmation-boot", + 31_322, + ); + let attempts = std::cell::Cell::new(0_u32); + + attach_registered_external_agent_runner_gui_owner_if_needed_with( + &state, + &config_dir, + &endpoint, + |_, _| { + attempts.set(attempts.get() + 1); + validate_external_agent_runner_gui_owner_attachment_result(&json!({ + "attached": true + })) + }, + ) + .expect_err("missing eventSinkAttached must fail"); + attach_registered_external_agent_runner_gui_owner_if_needed_with( + &state, + &config_dir, + &endpoint, + |_, _| { + attempts.set(attempts.get() + 1); + validate_external_agent_runner_gui_owner_attachment_result(&json!({ + "attached": true, + "eventSinkAttached": true + })) + }, + ) + .expect("same boot retries after missing event sink confirmation"); + assert_eq!(attempts.get(), 2); +} + +#[test] +fn gui_owner_registration_false_event_sink_confirmation_retries_same_boot() { + let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default()); + let config_dir = PathBuf::from("false-sink-confirmation-appdata"); + register_external_agent_runner_gui_owner_attachment( + &state, + &config_dir, + ExternalAgentRunnerRequestParams { + event_sink_port: Some(31_323), + event_sink_token: Some("d".repeat(64)), + ..ExternalAgentRunnerRequestParams::default() + }, + ); + let endpoint = test_endpoint( + "false-sink-confirmation-runner-token", + "false-sink-confirmation-boot", + 31_323, + ); + let attempts = std::cell::Cell::new(0_u32); + + attach_registered_external_agent_runner_gui_owner_if_needed_with( + &state, + &config_dir, + &endpoint, + |_, _| { + attempts.set(attempts.get() + 1); + validate_external_agent_runner_gui_owner_attachment_result(&json!({ + "attached": true, + "eventSinkAttached": false + })) + }, + ) + .expect_err("false eventSinkAttached must fail"); + attach_registered_external_agent_runner_gui_owner_if_needed_with( + &state, + &config_dir, + &endpoint, + |_, _| { + attempts.set(attempts.get() + 1); + validate_external_agent_runner_gui_owner_attachment_result(&json!({ + "attached": true, + "eventSinkAttached": true + })) + }, + ) + .expect("same boot retries after false event sink confirmation"); + assert_eq!(attempts.get(), 2); +} + +#[test] +fn gui_owner_registration_does_not_cross_config_dirs() { + let state = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default()); + let registered_config_dir = PathBuf::from("registered-gui-appdata"); + let other_config_dir = PathBuf::from("other-gui-appdata"); + let event_sink_token = "e".repeat(64); + register_external_agent_runner_gui_owner_attachment( + &state, + ®istered_config_dir, + ExternalAgentRunnerRequestParams { + event_sink_port: Some(31_324), + event_sink_token: Some(event_sink_token.clone()), + ..ExternalAgentRunnerRequestParams::default() + }, + ); + let endpoint = test_endpoint( + "gui-owner-config-token-gui-owner-config-token", + "gui-owner-config-boot", + 31321, + ); + attach_registered_external_agent_runner_gui_owner_if_needed_with( + &state, + &other_config_dir, + &endpoint, + |_, _| panic!("GUI owner registration must stay bound to its AppData"), + ) + .expect("other AppData remains unattached"); + + let calls = std::cell::Cell::new(0_u32); + attach_registered_external_agent_runner_gui_owner_if_needed_with( + &state, + ®istered_config_dir, + &endpoint, + |_, params| { + calls.set(calls.get() + 1); + assert_eq!(params.event_sink_port, Some(31_324)); + assert_eq!( + params.event_sink_token.as_deref(), + Some(event_sink_token.as_str()) + ); + Ok(()) + }, + ) + .expect("registered AppData attaches"); + assert_eq!(calls.get(), 1); + + let unregistered = Mutex::new(ExternalAgentRunnerGuiOwnerAttachmentState::default()); + attach_registered_external_agent_runner_gui_owner_if_needed_with( + &unregistered, + ®istered_config_dir, + &endpoint, + |_, _| panic!("CLI state without GUI registration must not attach"), + ) + .expect("unregistered CLI state remains unchanged"); +} + #[test] fn gui_owner_lock_allows_only_one_frontend_process_per_appdata() { let directory = unique_test_directory(); @@ -567,7 +854,9 @@ fn gui_owner_lock_allows_only_one_frontend_process_per_appdata() { } #[test] -fn attached_gui_owner_loss_forces_runner_shutdown() { +fn manifest_invalidation_sink_isolation_gui_owner_attach_configures_and_cleans_up() { + let sink_guard = crate::acquire_game_creator_manifest_invalidation_event_sink_test_guard(); + assert_eq!(sink_guard.configured_sink(), None); let directory = unique_test_directory(); let config_dir = private_runner_test_config_dir(&directory); let token = "gui-owner-monitor-token-gui-owner-monitor-token"; @@ -593,6 +882,13 @@ fn attached_gui_owner_loss_forces_runner_shutdown() { ); assert!(attached.ok); assert!(state.gui_owner_attached.load(Ordering::Acquire)); + assert_eq!( + sink_guard.configured_sink(), + Some(crate::GameCreatorManifestInvalidationEventSink { + port: 31_318, + token: "b".repeat(64), + }) + ); assert!( !external_agent_runner_shutdown_if_gui_owner_lost(&state).expect("owner remains present") ); @@ -603,7 +899,9 @@ fn attached_gui_owner_loss_forces_runner_shutdown() { assert!(state.draining.load(Ordering::Acquire)); assert!(state.force_shutdown_requested.load(Ordering::Acquire)); assert!(state.shutdown_requested.load(Ordering::Acquire)); - crate::clear_game_creator_manifest_invalidation_event_sink_for_test(); + drop(sink_guard); + let cleanup_guard = crate::acquire_game_creator_manifest_invalidation_event_sink_test_guard(); + assert_eq!(cleanup_guard.configured_sink(), None); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/recovery.rs index 8365f3873..02151f3af 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/recovery.rs @@ -1606,11 +1606,9 @@ async fn project_supervisor_resume_rechecks_delegate_policy_after_delivery_reser .expect("read barrier after rejecting reserved delivery") .is_clear()); - let released = wait_for_agent_runtime_lane_release_async( - &root, - GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, - ) - .await; + let released = + wait_for_agent_runtime_lane_release_async(&root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID) + .await; assert_eq!(released.state.run_id, parent_run_id); fs::remove_dir_all(root).ok(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index 726e055e2..0fac08451 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -3,18 +3,75 @@ use base64::Engine as _; use serde_json::Value; use sha2::{Digest as _, Sha256}; use std::collections::{BTreeMap, BTreeSet}; -use std::io::{Read, Write}; +use std::io::{self, Read, Write}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Barrier, Condvar, Mutex as StdMutex, MutexGuard as StdMutexGuard}; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; use zip::write::SimpleFileOptions; static TEST_PROJECT_COUNTER: AtomicU64 = AtomicU64::new(0); static TEST_MOCK_PORT_COUNTER: AtomicU64 = AtomicU64::new(20_000); static TEST_CONFIG_LOCK: StdMutex<()> = StdMutex::new(()); +const MANIFEST_INVALIDATION_RELAY_TEST_ACCEPT_TIMEOUT: Duration = Duration::from_millis(500); +const MANIFEST_INVALIDATION_RELAY_TEST_PAYLOAD_TIMEOUT: Duration = Duration::from_millis(500); +const MANIFEST_INVALIDATION_RELAY_TEST_MAX_BYTES: usize = 64 * 1024; + +fn read_manifest_invalidation_relay_payload_with_deadline( + listener: &TcpListener, +) -> io::Result> { + listener.set_nonblocking(true)?; + let accept_deadline = Instant::now() + MANIFEST_INVALIDATION_RELAY_TEST_ACCEPT_TIMEOUT; + let (mut stream, _) = loop { + match listener.accept() { + Ok(accepted) => break accepted, + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + if Instant::now() >= accept_deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "manifest invalidation relay accept timed out", + )); + } + std::thread::yield_now(); + } + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + Err(error) => return Err(error), + } + }; + + stream.set_nonblocking(true)?; + let payload_deadline = Instant::now() + MANIFEST_INVALIDATION_RELAY_TEST_PAYLOAD_TIMEOUT; + let mut payload = Vec::new(); + let mut buffer = [0_u8; 4096]; + loop { + match stream.read(&mut buffer) { + Ok(0) => return Ok(payload), + Ok(read) => { + payload.extend_from_slice(&buffer[..read]); + if payload.len() > MANIFEST_INVALIDATION_RELAY_TEST_MAX_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "manifest invalidation relay payload exceeded test limit", + )); + } + } + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + if Instant::now() >= payload_deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "manifest invalidation relay payload timed out", + )); + } + std::thread::yield_now(); + } + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + Err(error) => return Err(error), + } + } +} #[test] -fn non_supervisor_runtime_update_invalidates_manifest_on_the_wire_and_runner_relay() { +fn manifest_invalidation_sink_isolation_relays_non_supervisor_runtime_update() { + let sink_guard = acquire_game_creator_manifest_invalidation_event_sink_test_guard(); let root = unique_project_path(); init_local_game_project_at(&root, "runtime-event-contract", "Runtime 事件合同测试") .expect("init runtime event contract project"); @@ -33,20 +90,12 @@ fn non_supervisor_runtime_update_invalidates_manifest_on_the_wire_and_runner_rel .expect("read manifest invalidation relay fixture address") .port(); let relay_token = "a".repeat(64); - configure_game_creator_manifest_invalidation_event_sink(relay_port, &relay_token) + sink_guard + .configure(relay_port, &relay_token) .expect("configure manifest invalidation relay fixture"); emit_game_creator_agent_runtime_update(&root, "art-asset-plan"); - let (mut relay_stream, _) = relay_listener - .accept() - .expect("accept manifest invalidation relay"); - relay_stream - .set_read_timeout(Some(Duration::from_secs(1))) - .expect("set manifest invalidation relay read timeout"); - let mut relay_payload = Vec::new(); - relay_stream - .read_to_end(&mut relay_payload) - .expect("read manifest invalidation relay"); - clear_game_creator_manifest_invalidation_event_sink_for_test(); + let relay_payload = read_manifest_invalidation_relay_payload_with_deadline(&relay_listener) + .expect("receive manifest invalidation relay within deadline"); let relay: GameCreatorManifestInvalidationRelayEnvelope = serde_json::from_slice(&relay_payload).expect("parse manifest invalidation relay"); assert_eq!(relay.token, relay_token); @@ -56,6 +105,58 @@ fn non_supervisor_runtime_update_invalidates_manifest_on_the_wire_and_runner_rel fs::remove_dir_all(root).ok(); } +#[test] +fn manifest_invalidation_sink_isolation_bounds_timeouts_and_cleans_up_with_raii() { + let cleanup_listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .expect("bind manifest invalidation cleanup fixture"); + let cleanup_port = cleanup_listener + .local_addr() + .expect("read manifest invalidation cleanup fixture address") + .port(); + let cleanup_token = "b".repeat(64); + let unwind = std::panic::catch_unwind(|| { + let sink_guard = acquire_game_creator_manifest_invalidation_event_sink_test_guard(); + sink_guard + .configure(cleanup_port, &cleanup_token) + .expect("configure manifest invalidation cleanup fixture"); + assert_eq!( + sink_guard.configured_sink(), + Some(GameCreatorManifestInvalidationEventSink { + port: cleanup_port, + token: cleanup_token.clone(), + }) + ); + panic!("exercise manifest invalidation sink guard unwind cleanup"); + }); + assert!(unwind.is_err()); + + let sink_guard = acquire_game_creator_manifest_invalidation_event_sink_test_guard(); + assert_eq!(sink_guard.configured_sink(), None); + + let empty_listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .expect("bind empty manifest invalidation relay fixture"); + let accept_started = Instant::now(); + let accept_error = read_manifest_invalidation_relay_payload_with_deadline(&empty_listener) + .expect_err("missing relay must time out"); + assert_eq!(accept_error.kind(), io::ErrorKind::TimedOut); + assert!(accept_started.elapsed() < Duration::from_secs(2)); + + let stalled_listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) + .expect("bind stalled manifest invalidation relay fixture"); + let stalled_stream = TcpStream::connect( + stalled_listener + .local_addr() + .expect("read stalled manifest invalidation relay fixture address"), + ) + .expect("connect stalled manifest invalidation relay fixture"); + let payload_started = Instant::now(); + let payload_error = read_manifest_invalidation_relay_payload_with_deadline(&stalled_listener) + .expect_err("incomplete relay payload must time out"); + assert_eq!(payload_error.kind(), io::ErrorKind::TimedOut); + assert!(payload_started.elapsed() < Duration::from_secs(2)); + drop(stalled_stream); +} + #[test] fn gui_final_exit_is_the_only_run_event_that_requests_runner_shutdown() { assert!(game_creator_gui_run_event_requests_runner_shutdown( diff --git a/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx b/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx index a8e297679..a575c1687 100644 --- a/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx +++ b/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx @@ -3,6 +3,7 @@ import './assetCanvasSurface.css'; import { + type CanvasHistoryAction, type CanvasLayer, type CanvasViewport, createMinimapModel, @@ -13,7 +14,6 @@ import { type ImageCanvasGenerationProgressPhase, type ImageCanvasHostScope, type ImageCanvasMediaRef, - MAX_HISTORY_STEPS, moveViewportFromMinimapPointer, moveViewportFromPan, removeCanvasLayers, @@ -26,6 +26,7 @@ import { CanvasWorld, LayerRenderer, Minimap, + useCanvasHistory, ZoomControls, } from '@genarrative/image-canvas-react'; import { @@ -85,11 +86,6 @@ export type AssetCanvasCommitNotification = { }; type RuntimeCanvasLayer = CanvasLayer & { mediaRef: ImageCanvasMediaRef }; -type HistorySnapshot = { - layers: RuntimeCanvasLayer[]; - viewport: CanvasViewport; - selectedLayerIds: string[]; -}; type PendingGenerationIdentity = { saveAttemptId: string; @@ -232,14 +228,6 @@ function draftCanvasFromRuntime( }; } -function historyClone(input: HistorySnapshot): HistorySnapshot { - return { - layers: input.layers.map((layer) => ({ ...layer })), - viewport: { ...input.viewport }, - selectedLayerIds: [...input.selectedLayerIds], - }; -} - function mediaTypeForFile(file: File) { if (file.type === 'image/png') return 'image/png' as const; if (file.type === 'image/jpeg') return 'image/jpeg' as const; @@ -317,8 +305,6 @@ export function AssetCanvasSurface({ const documentVersionRef = useRef(documentVersion); const epochRef = useRef(0); const dragRef = useRef(null); - const undoRef = useRef([]); - const redoRef = useRef([]); const saveQueueRef = useRef>(Promise.resolve()); const savePromiseRef = useRef | null>(null); const hostRevisionRef = useRef(expectedHostRevision); @@ -346,54 +332,45 @@ export function AssetCanvasSurface({ setLifecycle({ kind: 'canvas.editing', dirty: true }); }, []); - const currentSnapshot = useCallback( - (): HistorySnapshot => ({ - layers: layersRef.current.map((layer) => ({ ...layer })), - viewport: { ...viewportRef.current }, - selectedLayerIds: [...selectionRef.current], + const canvasHistoryRefs = useMemo( + () => ({ + layersRef, + viewportRef, + selectedLayerIdsRef: selectionRef, }), [], ); - - const captureHistory = useCallback(() => { - undoRef.current = [ - ...undoRef.current.slice(-(MAX_HISTORY_STEPS - 1)), - historyClone(currentSnapshot()), - ]; - redoRef.current = []; - }, [currentSnapshot]); - - const applySnapshot = useCallback( - (snapshot: HistorySnapshot) => { - setLayers(snapshot.layers.map((layer) => ({ ...layer }))); - setViewport({ ...snapshot.viewport }); - setSelectedLayerIds([...snapshot.selectedLayerIds]); - markDirty(); - }, - [markDirty], + const canvasHistorySetters = useMemo( + () => ({ + setLayers: (nextLayers: CanvasLayer[]) => + setLayers(nextLayers as RuntimeCanvasLayer[]), + setViewport, + setSelectedLayerIds, + }), + [], + ); + const { + canUndo, + canRedo, + captureCanvasHistory, + undoCanvasChange, + redoCanvasChange, + resetCanvasHistory, + } = useCanvasHistory({ + refs: canvasHistoryRefs, + setters: canvasHistorySetters, + allowContentRemovalOnRestore: true, + }); + const captureHistory = useCallback( + (action: CanvasHistoryAction) => captureCanvasHistory(action), + [captureCanvasHistory], ); - const undo = useCallback(() => { - const previous = undoRef.current.at(-1); - if (!previous) return; - redoRef.current = [ - ...redoRef.current.slice(-(MAX_HISTORY_STEPS - 1)), - historyClone(currentSnapshot()), - ]; - undoRef.current = undoRef.current.slice(0, -1); - applySnapshot(previous); - }, [applySnapshot, currentSnapshot]); - + if (undoCanvasChange().status === 'success') markDirty(); + }, [markDirty, undoCanvasChange]); const redo = useCallback(() => { - const next = redoRef.current.at(-1); - if (!next) return; - undoRef.current = [ - ...undoRef.current.slice(-(MAX_HISTORY_STEPS - 1)), - historyClone(currentSnapshot()), - ]; - redoRef.current = redoRef.current.slice(0, -1); - applySnapshot(next); - }, [applySnapshot, currentSnapshot]); + if (redoCanvasChange().status === 'success') markDirty(); + }, [markDirty, redoCanvasChange]); const hydrateDraft = useCallback( async (nextDraft: ImageCanvasDraft, epoch: number) => { @@ -446,11 +423,10 @@ export function AssetCanvasSurface({ setViewport(nextDraft.canvas.viewport); setBackgroundColor(nextDraft.canvas.backgroundColor); setSelectedLayerIds(nextDraft.canvas.selectedLayerIds); - undoRef.current = []; - redoRef.current = []; + resetCanvasHistory(); setLifecycle({ kind: 'canvas.editing', dirty: false }); }, - [host, stableScope], + [host, resetCanvasHistory, stableScope], ); useEffect(() => { @@ -784,7 +760,7 @@ export function AssetCanvasSurface({ ); return; } - captureHistory(); + captureHistory({ type: 'upload-image', count: files.length }); const baseZ = layersRef.current.reduce( (value, layer) => Math.max(value, layer.zIndex), -1, @@ -838,7 +814,11 @@ export function AssetCanvasSurface({ const deleteSelected = useCallback(() => { if (!selectionRef.current.length) return; - captureHistory(); + captureHistory({ + type: 'delete-image', + count: selectionRef.current.length, + layerIds: [...selectionRef.current], + }); setLayers( (current) => removeCanvasLayers( @@ -1304,18 +1284,14 @@ export function AssetCanvasSurface({ @@ -1527,7 +1503,7 @@ export function AssetCanvasSurface({ isPanning={dragRef.current?.kind === 'pan'} onPointerDown={(event) => { if (event.target !== event.currentTarget) return; - captureHistory(); + captureHistory({ type: 'change-viewport' }); setSelectedLayerIds([]); dragRef.current = { kind: 'pan', @@ -1583,7 +1559,11 @@ export function AssetCanvasSurface({ } return; } - captureHistory(); + captureHistory({ + type: 'move-image', + count: targetIds.length, + layerIds: targetIds, + }); dragRef.current = { kind: 'move', startClientX: event.clientX, @@ -1613,7 +1593,11 @@ export function AssetCanvasSurface({ className="asset-canvas-surface__resize-handle" onPointerDown={(event) => { event.stopPropagation(); - captureHistory(); + captureHistory({ + type: 'resize-image', + count: 1, + layerIds: [layer.id], + }); dragRef.current = { kind: 'resize', startClientX: event.clientX, @@ -1638,13 +1622,13 @@ export function AssetCanvasSurface({ onFit={() => { const next = fitViewportToLayers({ layers, canvasSize }); if (next) { - captureHistory(); + captureHistory({ type: 'change-viewport' }); setViewport(next); markDirty(); } }} onScaleFromCenter={(scale) => { - captureHistory(); + captureHistory({ type: 'change-viewport' }); setViewport((current) => scaleViewportFromScreenPoint({ viewport: current, @@ -1685,7 +1669,7 @@ export function AssetCanvasSurface({ model={minimapModel} onPointerDown={(event) => { const rect = event.currentTarget.getBoundingClientRect(); - captureHistory(); + captureHistory({ type: 'change-viewport' }); setViewport( moveViewportFromMinimapPointer({ viewport, diff --git a/apps/ai-game-creator-shell/src/features/asset-canvas/tauriImageCanvasHostAdapter.ts b/apps/ai-game-creator-shell/src/features/asset-canvas/tauriImageCanvasHostAdapter.ts index 699566b9c..61e92a2c4 100644 --- a/apps/ai-game-creator-shell/src/features/asset-canvas/tauriImageCanvasHostAdapter.ts +++ b/apps/ai-game-creator-shell/src/features/asset-canvas/tauriImageCanvasHostAdapter.ts @@ -1,9 +1,9 @@ import type { ImageCanvasDraft, ImageCanvasGenerationCommitResult, + ImageCanvasGenerationPort, ImageCanvasGenerationProgress, ImageCanvasGenerationRecord, - ImageCanvasGenerationPort, ImageCanvasHostPort, ImageCanvasHostResult, ImageCanvasHostScope, diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index d0d30af4f..6363f66b0 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -474,14 +474,14 @@ type ProjectAgentMudPointAttribution = { 4. 根目录全量 Vitest、前后端 typecheck / lint / build、Rust workspace test / check、SpacetimeDB schema、原生壳、内容 / 编码、生产运维与部署门禁全部通过后,阶段七才允许提交。 5. 本地 `.env`、`.env.local`、密钥、缓存、日志和构建产物不进入阶段七提交;提交前再次执行编码检查和 `git diff --check`。 -### 7.6 素材创作无限画布阶段一验收 +### 7.6 素材创作无限画布阶段一至五最终验收 -实现状态(2026-08-05,阶段四):资源总览新增/图片精修入口、中央素材画布、取消恢复、正式 manifest/revision 实时合并、依赖图重建、dependency/type 双布局协调和三阶段自动定位已经接通。command/event 任意顺序按项目、commit、event 与 revision 去重;低 revision、旧 graph/layout 和失效 focus generation 均不能倒灌。真实 AI 生成仍按合同保持 mock 非目标。 +实现状态(2026-08-05,阶段五):资源总览新增/图片精修入口、中央素材画布、取消恢复、正式 manifest/revision 实时合并、依赖图重建、dependency/type 双布局协调和三阶段自动定位已经接通。command/event 任意顺序按项目、commit、event 与 revision 去重;低 revision、旧 graph/layout 和失效 focus generation 均不能倒灌。Tauri 图片新增与精修已经通过共享 generation port 接入真实 External Editor 异步生成、稳定引用下载、阶段三正式事务和重启恢复;成功不依赖刷新、重开项目、随机等待或手工修复。 -1. 网站与 Tauri 实际 import 同一份 `@genarrative/image-canvas-core` 和 `@genarrative/image-canvas-react`,客户端没有复制的主站画布目录;宿主差异只位于 adapter。 +1. 网站与 Tauri 实际 import 同一份 `@genarrative/image-canvas-core` 和 `@genarrative/image-canvas-react`,客户端没有复制的主站画布目录;viewport、selection、变换、renderer 与 history 算法位于共享层,宿主只保留事件接线与 adapter 副作用。 2. “新增资源”和“精修资源”分别进入 create/refine 素材画布;精修保留原资产、创建新资产,并用规范 `referenceResourceIds` 登记直接血缘。 3. 草稿 schema、revision、容量、项目身份、OS 锁、CAS、恢复副本和媒体引用符合权威专题;损坏、未知 schema、身份错配和超限均失败关闭。 -4. 正式提交携带 `expectedProjectId + expectedRevision + expectedDraftRevision + commitId + idempotencyKey`,按文件、manifest/revision、回读、ledger/draft、事件顺序完成;两窗口并发、重复提交和各崩溃阶段均有确定结果。 +4. 正式提交携带 `expectedProjectId + expectedRevision + expectedDraftRevision + commitId + idempotencyKey`,按事务快照/journal/ledger、文件、manifest/revision、回读、ledger/draft、事件顺序完成;首个快照、全部快照、journal 写入、文件、manifest、revision、验证和 ledger 各崩溃阶段均有确定结果,未发布快照残留只在证明正式文件、manifest 与 revision 均未变化时清理。 5. 保存成功后不刷新、不重开项目即可进入 manifest 投影、依赖图、dependency/type 布局和允许时的选中定位;切项目、切中央状态、改选择或改搜索后的迟到结果不得抢焦点。 6. 搜索/筛选隐藏新资源时保留条件,明确提示“新资源已保存,当前筛选条件下不可见”,只通过显式动作清除条件并定位。 7. 新增、精修、生成、保存、取消、失败和恢复必须覆盖权威专题 §13 的完整验收矩阵;只完成画布 UI 或只完成本地写文件都不能算正式闭环。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 8841a3562..e262d663c 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -5198,7 +5198,8 @@ - 决策:新增 `command.start / command.poll / command.stdin / command.terminate` 四个模型工具,专门承载受控前台持久进程。start / stdin / terminate 默认 `confirm`,poll 默认 `auto`。`command.start` 复用 `command.exec` 的固定 program、逐项 argv、项目 cwd、白名单解析、安全 PATH、隔离环境和参数拒绝,不接受 shell、环境注入、用户 executable、管道、重定向或 daemonize / detach;Runner 直接持有固定 `120x30` PTY、child handle、stdin writer 和输出泵,同项目最多 4 个、同 Agent instance 最多 2 个 running session。V1.2 对 PTY / 后台进程的排除只适用于一次性 `command.exec`。 - 决策:`processId` 是 start action create-once 的 opaque Runtime 身份,完整绑定 project、Agent instance、task、session、run、start action、action / command fingerprint 和 Runner boot;它不是 OS PID。poll / stdin / terminate 每次都从活 registry 和 durable record 交叉复核 owning 身份,跨 Agent、动态 sibling、run 或项目一律失败关闭,不能把知道 ID 当成授权。 -- 决策:2026-07-27 起,独立 Runner 归 Tauri GUI 生命周期所有,同一 AppData 通过 OS GUI owner 锁只允许一个前端进程持有 Runner。GUI 启动子进程显式携带 `--gui-owner-required`,Runner 若在启动检查前发现 owner 已释放就直接失败,不能退化成 CLI-owned Runner;就绪后仍必须调用 `runner.attach_gui_owner`。Runner 由独立 watchdog 线程每 100ms 探测 owner 锁,不依赖服务端主循环;owner 丢失后先标记 draining / forced shutdown 并让服务端在 1.5 秒共享 deadline 内中断 Provider、回收 process session,若主循环或排空链路卡死则 watchdog 在 1.75 秒后复核 bootId、清理 endpoint 并由 Runner 自身进程硬退出。正常最终 `RunEvent::Exit` 仍同步请求专用 `runner.shutdown`;GUI panic、SIGKILL 或构建中途失败不再只依赖退出回调。`runner.shutdown` 不得复用版本切换用的 `runner.shutdown_if_idle`,也不得以 busy 为由继续留在后台。GUI 侧使用专用短连接 / I/O 超时;endpoint 缺失或读取失败不能单独证明 Runner 已退出,必须结合实例锁释放,失败日志只输出脱敏阶段分类。GUI 客户端兜底在 Linux 通过同一 pidfd 校验 / 发信号,Windows 绑定同一进程 handle;macOS 没有等价稳定句柄,客户端不得按裸 PID 强杀,由跨平台 Runner 自身 watchdog 承担主循环卡死的最终兜底。旧 endpoint 缺 start identity 时,只有 GUI owner 路径且认证 ping 同时精确匹配 PID 和 bootId,才允许一次性迁移 busy 旧 Runner;普通 CLI 仍必须被 busy 阻断,不能按相同二进制猜测强杀。客户端强制终止后必须先取得同一 Runner 实例锁,再在锁内复核 bootId 并清理 endpoint;Unix endpoint 必须是当前用户持有的 0600 单硬链接普通文件。退出不得把任务伪造为 completed、不得重放工具副作用;未完成 run 保留既有 durable 状态,下一次启动按 reconciliation / recovery 合同处理。单个 WebView/子窗口关闭不触发 Runner shutdown,普通 CLI 退出也保持原行为,显式 `--runner-shutdown-if-idle` 仍只用于安全关闭空闲 Runner。Runner 重启只做 reconciliation:旧 boot 已进入 prepared / launching / running / terminating 且没有可信 terminal record 的会话进入 `needs-reconciliation`,不得重放 start 或 stdin,不得重发 terminate,也不得按持久化 PID 重连或接管 PTY;可信终态只补 observation / audit / receipt。首版连旧 boot 的 prepared 也保守核对,不自动推断为安全重试。 +- 决策:2026-07-27 起,独立 Runner 归 Tauri GUI 生命周期所有,同一 AppData 通过 OS GUI owner 锁只允许一个前端进程持有 Runner。GUI 启动子进程显式携带 `--gui-owner-required`,Runner 若在启动检查前发现 owner 已释放就直接失败,不能退化成 CLI-owned Runner;就绪后仍必须调用 `runner.attach_gui_owner`。2026-08-05 补充:GUI 客户端把完整 attach 参数按规范化 AppData 保存为进程内登记,并在 `ensure_external_agent_runner` 复用或新启 endpoint 的成功出口按 `bootId` 重放;同一登记 generation 在同一 boot 上只发送一次,新 boot 必须在后续 Runtime 写请求取得 endpoint 前完成登记。只有 Runner 明确确认 attached 后才能记录成功 boot,失败时本次 ensure 失败且后续同 boot 继续重试;登记 mutex 只做快照和成功提交,网络请求期间不持有,锁序固定为 configure lock 后 registration mutex。普通 CLI 没有 GUI 登记,不得因启动、写入或只读 status 产生 attach 副作用。OS owner/watchdog 已建立不代表事件 sink 等进程内附加能力已恢复。Runner 由独立 watchdog 线程每 100ms 探测 owner 锁,不依赖服务端主循环;owner 丢失后先标记 draining / forced shutdown 并让服务端在 1.5 秒共享 deadline 内中断 Provider、回收 process session,若主循环或排空链路卡死则 watchdog 在 1.75 秒后复核 bootId、清理 endpoint 并由 Runner 自身进程硬退出。正常最终 `RunEvent::Exit` 仍同步请求专用 `runner.shutdown`;GUI panic、SIGKILL 或构建中途失败不再只依赖退出回调。`runner.shutdown` 不得复用版本切换用的 `runner.shutdown_if_idle`,也不得以 busy 为由继续留在后台。GUI 侧使用专用短连接 / I/O 超时;endpoint 缺失或读取失败不能单独证明 Runner 已退出,必须结合实例锁释放,失败日志只输出脱敏阶段分类。GUI 客户端兜底在 Linux 通过同一 pidfd 校验 / 发信号,Windows 绑定同一进程 handle;macOS 没有等价稳定句柄,客户端不得按裸 PID 强杀,由跨平台 Runner 自身 watchdog 承担主循环卡死的最终兜底。旧 endpoint 缺 start identity 时,只有 GUI owner 路径且认证 ping 同时精确匹配 PID 和 bootId,才允许一次性迁移 busy 旧 Runner;普通 CLI 仍必须被 busy 阻断,不能按相同二进制猜测强杀。客户端强制终止后必须先取得同一 Runner 实例锁,再在锁内复核 bootId 并清理 endpoint;Unix endpoint 必须是当前用户持有的 0600 单硬链接普通文件。退出不得把任务伪造为 completed、不得重放工具副作用;未完成 run 保留既有 durable 状态,下一次启动按 reconciliation / recovery 合同处理。单个 WebView/子窗口关闭不触发 Runner shutdown,普通 CLI 退出也保持原行为,显式 `--runner-shutdown-if-idle` 仍只用于安全关闭空闲 Runner。Runner 重启只做 reconciliation:旧 boot 已进入 prepared / launching / running / terminating 且没有可信 terminal record 的会话进入 `needs-reconciliation`,不得重放 start 或 stdin,不得重发 terminate,也不得按持久化 PID 重连或接管 PTY;可信终态只补 observation / audit / receipt。首版连旧 boot 的 prepared 也保守核对,不自动推断为安全重试。 +- GUI owner attachment 的完整成功条件固定为 `attached=true` 且 `eventSinkAttached=true`,登记保存并逐 boot 重放真实 sink port/token;任一确认缺失或失败时不得写入 `attached_boot_id`。sink token 不得进入日志、错误信息或公共状态。 - 决策:`command.poll` 使用绑定 processId 的 opaque cursor,并以 `maxChars / waitMs` 分页读取保留逻辑行边界的清洗后私有 PTY transcript;默认 / 最大返回 8,000 / 16,000 字符,最长等待 30 秒,同一 action/cursor 恢复必须稳定。后台输出泵独立等待 child 并排空尾部,单会话清洗后输出上限为 256 KiB,超限终止并落 `output-limit-exceeded`。输出正文只进入 owning Agent 的私有 transcript、observation 和 context bundle,task/event/Agent DB/receipt/action history/activity/output/UI snapshot/report 只保存 cursor、字节数、SHA-256、截断和退出元数据。`command.stdin` 单次最终 UTF-8 bytes 上限 8 KiB,支持 `appendNewline / eof`,是不可重放副作用;公共确认与审计只留 `processId / bytesWritten / contentSha256 / stdinOpen / eof`,不得保存 data、摘要、前后缀或可逆编码。 - 决策:owning run 存在 launching / running / terminating 或未解决 reconciliation 会话时,final reply、finalization journal 和 completed 投影全部阻断。`runner.shutdown_if_idle` 同时检查活 registry、输出泵、终止任务和 durable unresolved record;取消 run 也必须先完成进程收束,不能留下会话后把 Runner 判 idle。 - 决策:terminate 必须携带最后一次 poll cursor,并返回同一 cursor 的零消费状态元数据;后续 poll 不得从 0 重读或跳过尾部。Unix 固定为 graceful request + 完整固定宽限等待、随后只 force kill 同组残留、再 wait / reap / drain PTY;Windows 首版使用 Job force terminate + wait / reap,不宣称已有等价 graceful console event。只发送信号不算完成;signal / Job / wait / reap 或终态审计无法确认都进入 reconciliation。重复 terminate 只幂等返回已知终态,不能按 PID 再杀一次。 @@ -6150,8 +6151,10 @@ - 生成入口决策:客户端共享素材画布只实现首版图片生成与精修,通过共享 `ImageCanvasGenerationPort` 调用既有 External Editor v1 与 `canvas.asset_generate`,不复制网站 `useImageCanvasGenerationWorkflow`,不建立第二套生成、扣费或发布系统。 - 确认决策:编辑 prompt、尺寸、比例、素材类型与参考图只改本地 UI;最终“确认生成”之前零 External 请求。一次已确认意图固定 `intentId + generationId + External idempotencyKey + commitId + commit idempotencyKey`,未知结果、重启和重复点击只能沿原身份恢复。 -- 持久化决策:prompt、精确请求快照、配置指纹、External key、operationId、上传凭证和远端稳定引用只写 `.agent/workbench/asset-canvas/generations/` 私有账本;公开草稿、manifest 与事件只投影安全阶段和本地资产身份。External taskId 不得写入本地 manifest taskId。 +- 持久化决策:prompt、精确请求快照、配置指纹、External key、operationId 和远端稳定引用只写 `.agent/workbench/asset-canvas/generations/` 私有账本;参考图上传只持久化恢复所需的稳定 bucket/objectKey,direct-upload ticket 的 host、formFields、policy、signature 和临时 Authorization 只存在内存。公开草稿、manifest 与事件只投影安全阶段和本地资产身份。External taskId 不得写入本地 manifest taskId。 - 恢复决策:`accepted/running` 只 GET 原 operation,提交未知只用原请求字节和原 key 对账;远端完成后以固定 staging token 下载,再进入阶段三 journal/ledger。应用打开严格先恢复本地正式资产事务,再恢复 generation ledger,因此下载后提交中断只恢复原本地事务,不重新生成或下载。 - 显现决策:`generation-accepted -> generation-running -> remote-completed -> media-downloaded -> asset-durable-committed -> manifest-projected -> layout-ready -> selected` 不可压成 completed;停止 UI 等待只失效聚焦意图,迟到完成仍可安全投影 manifest,但不能抢焦点。 - 精修身份决策:正式本地资源通过 `referenceResourceIds` 引用原资源;External `sourceImageSrc` 只使用 objectKey、项目 resourceId 或素材 assetId,不能把 assetObjectId 当成媒体引用。`sourceResourceId` 只允许真实 External resourceId,不能使用 `local-asset:*` 或 objectKey 冒充。 +- 最终共享边界:Tauri 撤销/重做改为直接消费共享 `useCanvasHistory`;共享 history 统一恢复 viewport、selection、图层位置和缩放边界。Tauri Pointer 代码只负责把宿主事件接到共享 selection/viewport/transform/renderer 算法,不再维护第二套 history 栈。 +- 事务发布前恢复:首个快照、全部快照、journal 后但 ledger 前都是正式故障点。无 ledger 的 transaction 仅在受控目录内容、正式文件不存在且 manifest/revision 精确保持 before 时清理并返回 rolled-back;未知条目或权威状态变化必须失败关闭。清理后允许原 commit/idempotency 身份重放,最终仍只产生一个 manifest asset。 - 关联:`apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs`、`packages/image-canvas-core/src/ports.ts`、`docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md`。 diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index 1be942473..62c76d604 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -94,6 +94,12 @@ npm run test -- apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts --run cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml autonomous_completion_contract -- --nocapture --test-threads=1 ``` +修改 manifest invalidation relay、GUI owner attach 或其测试夹具后,所有会读写进程全局事件 sink 的测试统一使用 `manifest_invalidation_sink_isolation_` 前缀,并至少以 2 个 test thread 重复运行该 filter。测试 fixture 的 accept 和 payload 读取都必须使用总 deadline,不能只在 accept 成功后给 `TcpStream` 设置 read timeout;全局 sink 只能在共享 test-only 串行锁内由 RAII guard 配置和清理。 + +```bash +cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml manifest_invalidation_sink_isolation_ -- --nocapture --test-threads=2 +``` + source allowlist、game-chat 单轮完成门和 Canvas spritesheet 引用门禁均须命中实际测试;若完整 Rust suite 受 Windows `os error 32` 既有文件锁竞态影响,应单独复跑新增 filter 并如实记录,不能把锁竞态失败改报为本次改动通过。 Windows release 的非交互后台命令统一使用 `CREATE_NO_WINDOW`,包括 `command.exec / project.verify`、STDIO MCP、Repository Context Git、`git.inspect / project.git_commit` 和 `taskkill` 清理命令;需要进程组终止时再叠加 `CREATE_NEW_PROCESS_GROUP`,不要使用 `DETACHED_PROCESS`。smoke 时应在实际任务运行期间观察无额外控制台窗口,并在关闭客户端后核对整棵后台进程树为零,再重启确认 reconciliation 可继续。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 728467363..3e3322f0a 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -4205,6 +4205,28 @@ - 处理:文件头白名单同时覆盖 32/64 位与 fat Mach-O 的正序和反字节序合法魔数,并由桌面配置门禁同时反查 staging 脚本和根级产物检查,不能改成只按扩展名或构建退出码判断。 - 验证:在 macOS 上构建真实 desktop-shell release,运行 `npm run desktop-shell:stage-release-binary`,再由 `npm run check:native-shells` 校验 staged 产物。 +## 托管 MCP 新增公开域名时不能只更新网关路由(2026-08-05) + +- 现象:`https://dev.genarrative.world/api/external/v1/mcp` 的 manifest、OpenAPI 和 Bearer 鉴权都正常,但鉴权后的 `initialize` 返回 `403 FORBIDDEN`;通过 SSH 隧道访问同一 api-server 的 loopback 地址却可以正常列出 tools/resources。 +- 原因:`rmcp` Streamable HTTP transport 自带 DNS rebinding 防护。公网网关已经接入 dev 域名,但 `external_mcp::service()` 的 `allowed_hosts` / `allowed_origins` 仍只登记正式域名和 localhost,因此请求在 MCP 协议处理前被 transport 拒绝。 +- 处理:新增公开 MCP 环境时,同批登记对应 Host 与 HTTPS Origin;不要通过客户端伪造 `Host`、关闭防护或改走内部 SpacetimeDB MCP 规避。allowlist 变更属于 api-server 发布内容,必须随正常 API release 部署到目标环境。 +- 验证:自动测试使用真实公开 Host/Origin 执行 `initialize`;部署后再从公网域名完成带 Key 的 `initialize`、`tools/list`、`resources/list`、Skill resource 读取和至少一个只读业务 tool 调用。loopback 成功只能证明 MCP 实现和 Key 可用,不能替代公网 Host 验收。 +- 关联:`server-rs/crates/api-server/src/external_mcp.rs`、`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md`。 + +## GUI owner 锁不能替代逐 boot 的事件接收端登记(2026-08-05) + +- 现象:GUI 首次启动后 manifest 事件转发正常,但 Runner 被替换为新 boot 后只剩 owner 锁和 endpoint 可用,后台更新不再到达 GUI;或者 attach 响应只确认 owner,客户端却误记当前 boot 已完整登记,后续 ensure 不再重试。 +- 原因:把 OS owner 生命周期约束与进程内事件 sink attachment 混成同一状态,或在 `ensure_external_agent_runner` 之外执行一次性 attach;测试若用 actionId 等无关字段代替真实 sink port/token,也无法证明新 boot 重放的是可用接收端。 +- 处理:GUI 按规范化 AppData 私有登记真实 sink port/token,`ensure_external_agent_runner` 的 endpoint 复用和新 Runner 就绪两条成功路径都按 `bootId` 重放。同 boot 成功后幂等,新 boot 必须重挂;RPC、`attached` 或 `eventSinkAttached` 任一失败或缺失都不得记录成功 boot,并允许同 boot 后续重试。不同 AppData 不共享登记,未登记 CLI 不触发 attach;sink token 不进入日志、错误或公共状态。 +- 验证:分别覆盖真实 port/token 跨 boot 原样重放、同 boot 幂等、新 boot 重挂、普通 attach 失败、`eventSinkAttached` 缺失与 false 后同 boot 重试、AppData 隔离和未登记 CLI 零副作用。 + +## manifest relay 测试不能并行覆盖同一个全局 sink(2026-08-05) + +- 现象:crate 根 relay 测试在配置全局 sink 后阻塞等待 `TcpListener::accept()`,同时 Runner GUI owner attach 测试通过另一条路径覆盖并清空 sink;事件可能被发往另一端口,原 listener 随后永久等待。断言或 `expect` 提前失败时,成功路径末尾的手动 clear 也不会执行。 +- 原因:两个跨模块测试读写同一进程全局状态,却没有共用隔离边界;只给 accept 后取得的 stream 设置 read timeout 无法约束 accept 本身,payload 读取也缺少总 deadline。 +- 处理:全部全局 sink 测试共用一把 test-only 串行锁,并由 RAII guard 在 `Drop` 中无条件清空;测试统一使用 `manifest_invalidation_sink_isolation_` 前缀。relay fixture 对 accept 和 payload 分别使用非阻塞轮询与总 deadline,不使用固定 sleep;生产 loopback、token、连接 / 写入超时和 payload 大小校验保持不变。 +- 验证:用 `--test-threads=2` 重复运行统一 filter,覆盖正常 relay、无事件 accept 超时、不完整 payload 超时、panic 展开清理,以及 GUI owner attach 配置与 guard 清理。 + ## 远端图片 completed 不能冒充本地资源创建成功(2026-08-05) - 现象:External operation 已返回 completed,但稳定引用缺失、下载失败、正式资产事务中断或 manifest 已提交而 UI 事件丢失时,界面仍可能提前显示“资源创建成功”,重复回调还可能再次下载、写文件或登记资源。 @@ -4213,3 +4235,24 @@ - 精修补充:`sourceImageSrc` 是可下载的稳定媒体引用,`sourceResourceId` 是资源身份,二者不能因为都可表现为字符串就填同一个 objectKey。本地 `local-asset:*` 只保留在本地 manifest 血缘;没有真实 External resourceId 时省略 `sourceResourceId`。 - 验证:覆盖确认前零调用、同 key 连点、accepted 重启 GET-only、重复 completed、取消后迟到、下载后本地事务恢复、事件丢失、切项目/改选择、旧轮询隔离、实时布局与选择、精修血缘及敏感字段零泄漏。 - 关联:`apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs`、`apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx`、`docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md`。 + +## 可恢复生成账本不能持久化 direct-upload ticket(2026-08-05) + +- 现象:为支持参考图上传中断恢复,把完整 upload ticket 放进 generation ledger;账本随之包含 Provider host、formFields、policy、signature 或临时 Authorization,项目目录泄露即可复用临时凭证。 +- 原因:把“恢复所需的稳定远端身份”和“仅供一次上传的临时授权材料”当成同一种持久状态。原子 sidecar 只能保证写入完整,不能让敏感字段变安全。 +- 处理:ticket 结构不实现 Serialize/Deserialize,host/formFields 只在本次内存调用中使用。账本在上传前只保存稳定 bucket/objectKey;重启先用这组身份调用 object confirm,确认成功后只保留 objectKey/assetObjectId 并清掉上传中间态。账本测试必须直接序列化完整 ledger,扫描 Provider URL、Authorization、policy、signature、API Key 和 ticket 字段名。 +- 验证:运行 `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml private_generation_ledger_never_serializes_upload_credentials_or_provider_url`,并继续检查公开草稿、manifest、事件和普通错误不含 prompt、operationId、Key、绝对路径或媒体正文。 + +## prepared journal 之前同样存在正式事务崩溃窗口(2026-08-05) + +- 现象:事务依次安装 before/after 快照后才写 journal;若进程在首个快照、全部快照或 journal 已写但 ledger 未写时退出,重启扫描看到 transaction 目录却无法进入原先只覆盖 prepared 之后的恢复状态机,可能留下孤儿目录或阻塞项目后续提交。 +- 原因:把 `prepared` 当成事务的第一个可观察持久阶段,忽略了构造 prepared 证据本身也由多次原子文件安装组成。 +- 处理:把首个快照、全部快照和 journal 后/ledger 前加入故障矩阵。无 ledger 时只允许清理受控快照与本模块临时文件;若 journal 已存在,还必须证明正式目标不存在、manifest 和 project revision 精确等于 before。未知文件、正式文件存在或权威状态漂移全部失败关闭,不能递归猜测清理。清理后同步 transaction 父目录,并允许同 commit/idempotency 身份安全重放。 +- 验证:故障矩阵逐阶段恢复;额外用同一幂等身份在快照残留清理后提交两次,必须得到一次 committed、一次 already-committed,manifest 仍只有一个 canvas asset。 + +## 宿主事件接线不能顺手复制共享 history 栈(2026-08-05) + +- 现象:Tauri Surface 已复用共享 viewport/transform/renderer 数学,却另外维护 undo/redo refs、快照克隆和恢复逻辑;网站共享 hook 后续增加内容安全或字段恢复时,两端会静默分叉。 +- 原因:把 Pointer 事件接线、宿主生命周期胶水和可复用 history 算法放在同一组件中,误以为没有复制整个画布目录就已经满足共享源码边界。 +- 处理:两宿主直接消费共享 `useCanvasHistory`;共享 snapshot 统一覆盖 viewport、selection、图层位置和 width/height,宿主只声明本地媒体是否允许安全移除/重做。Tauri 仍可保留 Pointer capture/epoch/host callback 接线,但选择、平移、缩放、变换、renderer 和 history 状态机不得在宿主重写。 +- 验证:主站 history 定向测试覆盖 resize undo/redo;Tauri 新建、导入、编辑、撤销重做和 durable commit 用例必须在同一共享 hook 下通过。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index a4981b36c..844f3889f 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -273,7 +273,8 @@ Agent Runtime 负责: - 2026-07-10 补充:后台任务工具箱已加入 `agent.run_status`。Agent 可在 loop 中读取自己、目标 Agent 或一组 Agent 的 Runtime 状态摘要,判断同伴是否正在运行、最近任务和最近工具动作;Runtime 复用 `agent.run_status` 项目权限策略,策略要求确认或拒绝时不读取状态,observation 不返回 `.agent/runtime/*` 文件绝对路径。 - 2026-07-10 补充:后台任务工具箱已加入 `agent.delegate`。Agent 可在 loop 中把明确任务投递到另一个 Agent 的独立后台队列,复用目标 Agent 原有锁和 pending drain 语义;同一目标 Agent 串行,不同目标 Agent 可并行。该工具受 `agent.delegate` 策略保护,策略要求确认或拒绝时不会写目标对话、不会启动目标后台任务,也不会写 `agent.runtime.agent.delegate` 审计记录。 - 2026-07-10 补充:`agent.delegate` 已形成可恢复的父子任务闭环。`delegationId` 由 durable pending action 的 `actionId` 派生,子任务记录会保存 `parentAgentId / parentRunId / delegationId`,终态记录额外保存经过统一凭据清洗和安全截断的 `terminalDetail`;同一委派的提交和回执分别受 delegation 级 OS 文件锁保护,同一目标 Agent 的 runId 分配与 pending 追加还受任务账本 OS 锁保护。子任务进入 `completed / failed / cancelled / budget-exhausted` 任一终态时,Runtime 按 `delegationId` 幂等生成且至多生成一次 `agent.delegate.result` 回执,失败、排队或活跃取消、预算耗尽都必须回传,不能只覆盖成功。回执会向父 Agent 既有队列追加固定 runId、`source=agent-delegate-receipt` 的续跑任务,把完整的已清洗 `terminalDetail` 交回父 run,不再只保留 80 字符 UI 摘要;回执 prompt 明确禁止重复同一委派,排队期间不提前写入父会话,真正开始执行时才幂等落盘,用户消息或回执消息落盘失败时不会进入 LLM。回执任务保留父 run 关联,并在真正开始或恢复前再次检查父 run 状态,关联缺失或父 run 不存在时失败关闭;该续跑仍受父 Agent 原有 FIFO、per-Agent OS 锁、权限确认、取消、恢复和 `needs-reconciliation` 屏障约束,不直接重入父 run、不插队、不新增独立 worker;父 run 已取消或普通失败时只保留 suppressed receipt 审计,不自动复活,父 Session 归档与切换会被未结束委派阻止,极端归档竞态下回执回落到父 Agent 当前可写 Session。恢复先恢复 pending action / reconciliation 屏障,再扫描“子任务终态已落盘但回执未提交”的窗口并补齐缺失回执;`needs-reconciliation` 本身不回执,只有人工核对后最终取消才回传 `cancelled`。 -- 历史记录(已由 V1.1 独立 Runner 替代):Runtime 最初通过 `resume_game_creator_agent_runtime_tasks` 把本地 JSONL 队列重接到当前 App 进程。当前恢复入口仍保留权限、任务顺序和 `agent.runtime.background_task.recovered` 审计语义,但实际由独立 Runner 接管原 run / session;已发出的上游 LLM 请求仍不能从网络中间点续传。2026-07-27 起,Runner 归 Tauri GUI 生命周期所有,同一 AppData 只允许一个 GUI owner。GUI 启动子进程会显式声明 `--gui-owner-required` 并在就绪后 attach owner;Runner 若在启动检查前已发现 owner 释放则直接失败,不得退化成 CLI-owned Runner。Runner 使用独立 watchdog 线程每 100ms 监控 owner OS 锁,不依赖服务端主循环继续推进;owner 丢失后先触发 1.5 秒共享 deadline 的 draining、Provider 中断和 process session 回收,若主循环或排空链路卡死则在 1.75 秒后由 Runner 自身进程安全硬退出并清理匹配 bootId 的 endpoint。因此正常最终退出、panic、SIGKILL 和 setup 中途失败都不会再因 busy 或主循环卡死而残留后台进程。endpoint 缺失 / 读取失败必须结合 Runner 实例锁判断;GUI 客户端强制兜底在 Linux 使用 pidfd、Windows 使用稳定进程 handle。macOS 没有等价稳定句柄,客户端不得在 start identity 检查后按裸 PID 强杀,而由跨平台 Runner 自身 watchdog 提供硬退出兜底。旧 endpoint 缺 start identity 时,只有认证 ping 精确匹配 PID + bootId 才允许迁移 busy 旧 Runner。未完成任务保持 durable 状态并在下一次启动走 reconciliation / recovery,不能伪造 completed 或重放副作用。关闭单个 WebView / 子窗口和普通 CLI 退出不触发该行为,版本切换与人工命令仍可使用只关闭空闲实例的 `runner.shutdown_if_idle`。 +- 历史记录(已由 V1.1 独立 Runner 替代):Runtime 最初通过 `resume_game_creator_agent_runtime_tasks` 把本地 JSONL 队列重接到当前 App 进程。当前恢复入口仍保留权限、任务顺序和 `agent.runtime.background_task.recovered` 审计语义,但实际由独立 Runner 接管原 run / session;已发出的上游 LLM 请求仍不能从网络中间点续传。2026-07-27 起,Runner 归 Tauri GUI 生命周期所有,同一 AppData 只允许一个 GUI owner。GUI 启动子进程会显式声明 `--gui-owner-required` 并在就绪后 attach owner;Runner 若在启动检查前已发现 owner 释放则直接失败,不得退化成 CLI-owned Runner。Runner 使用独立 watchdog 线程每 100ms 监控 owner OS 锁,不依赖服务端主循环继续推进;owner 丢失后先触发 1.5 秒共享 deadline 的 draining、Provider 中断和 process session 回收,若主循环或排空链路卡死则在 1.75 秒后由 Runner 自身进程安全硬退出并清理匹配 bootId 的 endpoint。GUI 客户端还必须把完整 `runner.attach_gui_owner` 参数作为绑定规范化 AppData 的进程内登记保存;`ensure_external_agent_runner` 无论复用既有 endpoint 还是启动新 Runner,都要在把 endpoint 交给 Runtime 写请求前按新 `bootId` 补登记。同一登记 generation 在同一 boot 上幂等,补登记失败不得记录成功 boot 且本次 `ensure` 失败关闭;未建立 GUI 登记的普通 CLI 不执行该重放。OS owner 锁与 watchdog 已成立只代表进程受 GUI 生命周期约束,不能替代事件 sink 等进程内附加能力的逐 boot 恢复。因此正常最终退出、panic、SIGKILL 和 setup 中途失败都不会再因 busy 或主循环卡死而残留后台进程。endpoint 缺失 / 读取失败必须结合 Runner 实例锁判断;GUI 客户端强制兜底在 Linux 使用 pidfd、Windows 使用稳定进程 handle。macOS 没有等价稳定句柄,客户端不得在 start identity 检查后按裸 PID 强杀,而由跨平台 Runner 自身 watchdog 提供硬退出兜底。旧 endpoint 缺 start identity 时,只有认证 ping 精确匹配 PID + bootId 才允许迁移 busy 旧 Runner。未完成任务保持 durable 状态并在下一次启动走 reconciliation / recovery,不能伪造 completed 或重放副作用。关闭单个 WebView / 子窗口和普通 CLI 退出不触发该行为,版本切换与人工命令仍可使用只关闭空闲实例的 `runner.shutdown_if_idle`。 +- 2026-08-05 GUI owner attachment 确认补充:登记参数必须保存 GUI manifest 事件接收端的真实 `event_sink_port` 与 `event_sink_token`,不得借用 actionId 等无关字段作为测试替身。每次 attach RPC 只有同时返回 `attached=true` 与 `eventSinkAttached=true` 才能把当前 `bootId` 标记为已登记;`eventSinkAttached` 缺失、为 false 或普通 RPC 失败都保持当前 boot 待重试。sink token 只留在私有进程内登记和 RPC 参数中,不进入日志、错误文本或公共状态。 - 2026-07-10 补充,2026-07-16 由 V1.28 澄清:后台 planning 与预算内 final reply 使用专用最小上下文,只预置 Agent 身份、sessionId、runId、执行模式和工具策略;Agent 私有记忆、项目记忆、黑板、对话、资产、项目索引与文件正文只能经对应工具通过权限 gate 后作为 observation 进入下一轮。只有开发窗口的专业 Agent 前台直调可使用对应角色上下文;正式用户前台现已统一进入 `project-supervisor`。长黑板、记忆和对话按尾部截断,确保最新结论与最新定向消息优先保留。 - 2026-07-10 补充,2026-07-16 由 V1.28 澄清:同一 Agent 的开发前台直调、流式调试和后台任务统一使用 `.agent/runtime/locks/.lock` OS 文件锁。开发前台不再在整个 LLM 请求期间占用项目级写锁;同 Agent 后台任务在开发前台运行时只入队,前台成功或失败后把当前 Agent 锁直接移交给 drain,不重新抢锁,也不允许 drain 启动异常把已经完成的调试结果改判为失败。正式用户 GUI 不通过该入口直聊专业 Agent;不同 Agent 继续并行,真实项目写工具只在副作用执行期间短暂申请项目写锁。 - 2026-07-10 补充,2026-08-01 更新:默认 `agent.resume=confirm` 时,客户端自动恢复命令先做只读 recovery preflight。全新项目和已完全终态且没有 task / retry / handoff / finalization / pending action / reconciliation 等 durable recovery work 的项目直接返回空结果,不显示虚假的 `agent.resume` 确认条。确实存在可恢复工作时,自动命令只做 auto gate 并返回待确认错误;主工作区和独立开发 Agent 聊天窗口显示 `agent.resume` 确认条,确认对象绑定发起时的项目路径,切换项目会取消旧确认,异步返回后也不得把旧项目 Runtime 合并到新项目 UI。开发者确认后调用独立 `confirm_resume_game_creator_agent_runtime_tasks`,该命令仍执行 deny-only 权限检查后才接回 durable queue。临时调用失败不锁死项目路径,允许后续刷新重试;明确 deny 或取消都不恢复任务。 @@ -953,6 +954,8 @@ game-project/ - 阶段五只接图片生成与图片精修,通过阶段二共享 `ImageCanvasGenerationPort` 调用客户端 Tauri adapter,并复用 `canvas.asset_generate` 的 External Editor 会话准备、异步提交/查询、稳定引用解析和受控下载能力;不复制网站 `useImageCanvasGenerationWorkflow`,不新增角色动画、视频、音频、图集或平行生成系统。 - 生成面板和确认摘要只操作本地状态;用户点击独立“确认生成”后才通过 `canvas.asset_generate` confirm 权限边界。本地生成意图建立时即分配稳定 `intentId/generationId/idempotencyKey`,取消确认则直接丢弃且零 External 调用;一旦提交,重复点击、重启和未知结果保持原身份,不能自动确认或换键重扣。 -- External endpoint、精确请求、prompt、配置指纹、idempotency key、operationId 和稳定产物引用只进入 `.agent/workbench/asset-canvas/generations/` 私有账本。API Key、Authorization、Provider URL、签名 URL和绝对路径不进入项目事实;草稿/manifest/event/日志只投影安全阶段与本地身份,External taskId 不写 manifest `source.taskId`。 +- External 相对 endpoint、精确请求、prompt、配置指纹、idempotency key、operationId 和稳定产物引用只进入 `.agent/workbench/asset-canvas/generations/` 私有账本。本地参考图 direct-upload ticket 的 host/formFields/policy/signature/临时 Authorization 只存在内存;恢复账本只允许保存稳定 bucket/objectKey。API Key、Authorization、Provider URL、签名 URL和绝对路径不进入项目事实;草稿/manifest/event/日志只投影安全阶段与本地身份,External taskId 不写 manifest `source.taskId`。 - 固定状态顺序为 `generation-accepted -> generation-running -> remote-completed -> media-downloaded -> asset-durable-committed -> manifest-projected -> layout-ready -> selected`。远端 completed 后必须先验证稳定引用,再经 `/assets/read-url` 下载并写入阶段三 staging;随后用账本中的固定本地 commit 身份进入既有 journal/ledger 事务。正式提交失败只恢复本地事务,不重新生成或下载。 - 应用重启时 `accepted/running` 只恢复 GET,`prepared` 只以私有账本原请求字节和原键恢复同一 POST。用户停止等待只失效当前 focus generation;不可取消的远端操作继续 reconcile,迟到 completed 可以更新原项目但不能抢焦点。配置缺失、结果引用不稳定、下载/提交失败和未知结果都不得显示资源创建成功。 +- 最终审计后 Tauri 撤销/重做直接使用与网站相同的共享 `useCanvasHistory`,共享 history 同时恢复 viewport、选择、图层位置和缩放边界;Tauri 自身只保留 Pointer 事件接线,平移、缩放、选择、图层变换和 renderer 算法继续来自共享 core/react。 +- 正式事务把首个快照、全部快照和 journal 写入后但 ledger 写入前纳入故障矩阵。恢复发现无 ledger 的 transaction 时,只有目录内容全部属于受控快照/临时文件、正式目标文件不存在、manifest 与 project revision 仍精确等于 before,才清理目录并返回 rolled-back;任何未知文件或权威状态变化都失败关闭。同一 commit/idempotency 身份在清理后可重放,最终仍只登记一份资产。 diff --git a/docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md b/docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md index 70c1fccab..e3fddbcd9 100644 --- a/docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md +++ b/docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md @@ -864,7 +864,7 @@ cancelling | A10 | 幂等冲突 | 同 key 或 commitId、不同指纹 | 失败关闭,原 ledger/文件/manifest 不变 | | A11 | 两窗口并发 | 相同 expectedRevision 同时提交 | 最多一笔 committed,另一笔 typed conflict,不覆盖成功方 | | A12 | draft 并发 | 相同 expectedDraftRevision 更新 | 最多一笔 updated,另一笔返回最新完整 draft | -| A13 | 崩溃:prepared 后 | 尚未装图片 | 恢复安全回滚 staging/transaction 或继续,不生成幽灵 asset | +| A13 | 崩溃:首个/全部事务快照、journal 或 prepared 后 | 尚未装图片 | 无 ledger 的未发布 transaction 只在正式文件不存在且 manifest/revision 仍为 before 时清理;prepared 安全回滚或继续,不生成幽灵 asset | | A14 | 崩溃:图片后 | manifest 前 | 仅在摘要/before 全匹配时删除新文件,否则 reconciliation-required | | A15 | 崩溃:manifest 后 | revision 前 | before/after 匹配时前向补 revision,否则 reconciliation-required | | A16 | 崩溃:revision 后 | ledger/event 前 | 回读验证后补 ledger/draft,并重发相同 eventId | @@ -881,7 +881,7 @@ cancelling | A27 | 取消 | clean、dirty、generating、staging、committing | 分别符合第 12 节;committing 不伪装成可取消 | | A28 | 恢复草稿 | 主文件损坏但恢复副本可信/不可信 | 可信副本恢复到 clean history 基线;不可信进入对账,不猜测 | -下一阶段只有在矩阵对应的纯模型、共享 React、Web adapter、Tauri adapter、Rust 持久化与 AppSurface 测试全部通过后,才可宣称图片素材创作正式闭环完成。 +阶段一至五最终审计只有在矩阵对应的纯模型、共享 React、Web adapter、Tauri adapter、Rust 持久化与 AppSurface 测试全部通过后,才可宣称图片素材创作正式闭环完成。 ## 14. 2026-08-05 阶段五真实图片生成补丁 @@ -902,7 +902,7 @@ cancelling .agent/workbench/asset-canvas/generations/.json ``` -账本由每个 `projectId + generationId` 的进程内 single-flight、安全相对路径和原子 sidecar 写入保护,包含精确 External endpoint / 请求 JSON / SHA-256、prompt 私有正文、配置指纹、External idempotency key、operationId、稳定 `objectKey/resourceId/assetObjectId`、本地 staging 与 commit 恢复身份。阶段三正式资产事务继续由既有持久锁保护。API Key、Authorization、Provider URL、签名 URL 和绝对路径不得写入账本;API Key 只参与不可逆配置指纹。 +账本由每个 `projectId + generationId` 的进程内 single-flight、安全相对路径和原子 sidecar 写入保护,包含精确 External 相对 endpoint / 请求 JSON / SHA-256、prompt 私有正文、配置指纹、External idempotency key、operationId、稳定 `objectKey/resourceId/assetObjectId`、本地 staging 与 commit 恢复身份。本地参考图上传恢复只允许暂存稳定 bucket/objectKey;direct-upload ticket 的 host、formFields、policy、signature 和临时 Authorization 只存在内存,任何阶段都不得序列化。阶段三正式资产事务继续由既有持久锁保护。API Key、Authorization、Provider URL、签名 URL 和绝对路径不得写入账本;API Key 只参与不可逆配置指纹。 草稿、manifest、Tauri 事件、Agent DB、Runtime observation、trace、普通错误和日志只允许出现 `intentId/generationId`、安全阶段、引用资源 ID、本地 asset/commit/event 身份和稳定错误码。草稿 generation record 不再保存 prompt、External idempotency key 或 operationId;阶段五读取旧空记录兼容,但下一次成功写入必须移除这些私有字段。 diff --git a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md index fbac43ea3..876b2e86b 100644 --- a/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md +++ b/docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md @@ -77,6 +77,8 @@ provider 原图已保存但透明背景处理最终失败时,worker 保留原 `/api/external/v1/mcp` 是 Genarrative 托管的远程端点,Agent 只需配置 URL 和现有 API Key,不安装本地 MCP server。首版兼容 MCP `2025-11-25` initialize 生命周期,使用 JSON-RPC 2.0 和 Streamable HTTP,支持 `initialize`、`notifications/initialized`、`ping`、`tools/list`、`tools/call`、`resources/list`、`resources/read`。服务端使用无协议 session 的 JSON direct 模式,不依赖 sticky session,也不把 `Mcp-Session-Id` 作为业务身份。 +MCP transport 的 DNS rebinding 防护必须同时允许正式入口 `www.genarrative.world` / `genarrative.world`、开发入口 `dev.genarrative.world` 和本机开发入口;对应 HTTPS Origin 也必须与公开环境同步登记。新增公开环境域名时,必须在发布前使用该域名的真实 `Host` 和 `Origin` 执行 `initialize` 回归,不能只用 `localhost` 单测证明端点可用。 + MCP tools 从同一份 OpenAPI operation 自动形成 snake_case 名称,并在进程内复用 External REST router,因此鉴权、scope、owner、入参、幂等、计费和结果查询契约只有一份。生成 tools 把 `idempotencyKey` 显式放进参数,因为 MCP transport 的 Authorization 头不能代替逐次业务幂等键。工具结果使用 `structuredContent`;业务失败使用 `isError=true` 的结构化安全错误,协议不可路由时才返回 JSON-RPC error。 MCP 暴露下列稳定文本资源: diff --git a/packages/image-canvas-core/src/history.ts b/packages/image-canvas-core/src/history.ts index fa86e270c..5cb08cbd8 100644 --- a/packages/image-canvas-core/src/history.ts +++ b/packages/image-canvas-core/src/history.ts @@ -11,6 +11,7 @@ const CANVAS_HISTORY_ACTION_LABELS: Record< string > = { 'move-image': '移动图片', + 'resize-image': '缩放图片', 'move-generation-result': '移动生成结果', 'delete-image': '删除图片', 'delete-generation-result': '删除生成结果', @@ -132,6 +133,7 @@ function mergeLayerForHistoryRestore( target: CanvasLayer, current: CanvasLayer | undefined, restoreAssetKind: boolean, + restoreLayerSize: boolean, ): CanvasLayer { if (!current) { return { ...target }; @@ -148,6 +150,9 @@ function mergeLayerForHistoryRestore( ...current, x: target.x, y: target.y, + ...(restoreLayerSize + ? { width: target.width, height: target.height } + : {}), zIndex: target.zIndex, groupId: target.groupId, ...(resourceAssetKind !== undefined ? { resourceAssetKind } : {}), @@ -202,10 +207,12 @@ export function mergeCanvasHistorySnapshotForRestore({ current, target, assetKindLayerIds, + layerSizeLayerIds, }: { current: CanvasHistorySnapshot; target: CanvasHistorySnapshot; assetKindLayerIds?: ReadonlySet; + layerSizeLayerIds?: ReadonlySet; }): CanvasHistorySnapshot { const currentLayerById = new Map( current.layers.map((layer) => [layer.id, layer] as const), @@ -231,6 +238,7 @@ export function mergeCanvasHistorySnapshotForRestore({ layer, currentLayerById.get(layer.id), assetKindLayerIds?.has(layer.id) ?? true, + layerSizeLayerIds?.has(layer.id) ?? false, ), ), generateDialog: target.generateDialog diff --git a/packages/image-canvas-core/src/interaction.ts b/packages/image-canvas-core/src/interaction.ts index 67b8afbe9..330e7921b 100644 --- a/packages/image-canvas-core/src/interaction.ts +++ b/packages/image-canvas-core/src/interaction.ts @@ -10,6 +10,7 @@ import { MINIMAP_SIZE, resolveSnappedItemPosition, } from './model'; +import { getCanvasGenerationSelectionId } from './selection'; import type { CanvasGenerationDialogState, CanvasLayer, @@ -18,7 +19,6 @@ import type { CanvasViewport, DragState, } from './types'; -import { getCanvasGenerationSelectionId } from './selection'; export type CanvasSize = { width: number; diff --git a/packages/image-canvas-core/src/layerCommands.ts b/packages/image-canvas-core/src/layerCommands.ts index 746506b1d..92879a963 100644 --- a/packages/image-canvas-core/src/layerCommands.ts +++ b/packages/image-canvas-core/src/layerCommands.ts @@ -1,9 +1,9 @@ +import { getCanvasGenerationSelectionId } from './selection'; import type { CanvasClipboard, CanvasContextMenuState, CanvasLayer, } from './types'; -import { getCanvasGenerationSelectionId } from './selection'; export type CanvasLayerMoveMode = 'up' | 'down' | 'top' | 'bottom'; diff --git a/packages/image-canvas-core/src/sharedCanvasCore.test.ts b/packages/image-canvas-core/src/sharedCanvasCore.test.ts index 3b9fbf1c4..8eac1527c 100644 --- a/packages/image-canvas-core/src/sharedCanvasCore.test.ts +++ b/packages/image-canvas-core/src/sharedCanvasCore.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + type CanvasLayer, createCanvasExportModel, createMinimapModel, fitViewportToLayers, @@ -12,7 +13,6 @@ import { scaleViewportFromScreenPoint, selectLayersInsideMarquee, transformCanvasLayers, - type CanvasLayer, } from './index'; function layer(id: string, overrides: Partial = {}): CanvasLayer { diff --git a/packages/image-canvas-core/src/stageController.ts b/packages/image-canvas-core/src/stageController.ts index 85647bedf..8607daa5f 100644 --- a/packages/image-canvas-core/src/stageController.ts +++ b/packages/image-canvas-core/src/stageController.ts @@ -1,10 +1,3 @@ -import type { - CanvasContextMenuState, - CanvasGenerationDialogState, - CanvasLayer, - CanvasViewport, - ImageContextMenuState, -} from './types'; import { getCanvasLayersByIds, resolveContextTargetLayerIds, @@ -14,6 +7,13 @@ import { getSelectedGenerationDialogIds, getSelectedLayerIds, } from './selection'; +import type { + CanvasContextMenuState, + CanvasGenerationDialogState, + CanvasLayer, + CanvasViewport, + ImageContextMenuState, +} from './types'; const CONTEXT_MENU_VIEWPORT_MARGIN = 8; const CONTEXT_MENU_SIZE = { diff --git a/packages/image-canvas-core/src/stageInteractions.ts b/packages/image-canvas-core/src/stageInteractions.ts index 477d776d1..dbcdc7b14 100644 --- a/packages/image-canvas-core/src/stageInteractions.ts +++ b/packages/image-canvas-core/src/stageInteractions.ts @@ -1,11 +1,3 @@ -import type { - CanvasGenerationDialogState, - CanvasLayer, - CanvasMarqueeState, - CanvasViewport, - DragState, - GenerateDialogState, -} from './types'; import type { CanvasPoint } from './interaction'; import { firstSelectedLayerId, @@ -14,6 +6,14 @@ import { getSelectedLayerIds, resolvePointerSelection, } from './selection'; +import type { + CanvasGenerationDialogState, + CanvasLayer, + CanvasMarqueeState, + CanvasViewport, + DragState, + GenerateDialogState, +} from './types'; type PointerSource = { button?: number; diff --git a/packages/image-canvas-core/src/types.ts b/packages/image-canvas-core/src/types.ts index 5bd141337..cd4de47e4 100644 --- a/packages/image-canvas-core/src/types.ts +++ b/packages/image-canvas-core/src/types.ts @@ -193,6 +193,7 @@ export type CanvasHistorySnapshot = { export type CanvasHistoryActionType = | 'move-image' + | 'resize-image' | 'move-generation-result' | 'delete-image' | 'delete-generation-result' diff --git a/packages/image-canvas-react/src/CanvasViewport.tsx b/packages/image-canvas-react/src/CanvasViewport.tsx index 35d8742ec..e9416ce89 100644 --- a/packages/image-canvas-react/src/CanvasViewport.tsx +++ b/packages/image-canvas-react/src/CanvasViewport.tsx @@ -1,6 +1,5 @@ -import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'; - import type { CanvasTool } from '@genarrative/image-canvas-core'; +import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'; export type CanvasViewportProps = HTMLAttributes & { children: ReactNode; diff --git a/packages/image-canvas-react/src/CanvasWorld.tsx b/packages/image-canvas-react/src/CanvasWorld.tsx index 01ce6486e..3437360ab 100644 --- a/packages/image-canvas-react/src/CanvasWorld.tsx +++ b/packages/image-canvas-react/src/CanvasWorld.tsx @@ -1,9 +1,8 @@ -import type { HTMLAttributes, ReactNode } from 'react'; - import { CANVAS_WORLD_SIZE, type CanvasViewport, } from '@genarrative/image-canvas-core'; +import type { HTMLAttributes, ReactNode } from 'react'; export function CanvasWorld({ children, diff --git a/packages/image-canvas-react/src/LayerRenderer.tsx b/packages/image-canvas-react/src/LayerRenderer.tsx index 246cb1103..c3bb09d5c 100644 --- a/packages/image-canvas-react/src/LayerRenderer.tsx +++ b/packages/image-canvas-react/src/LayerRenderer.tsx @@ -1,6 +1,5 @@ -import type { HTMLAttributes, KeyboardEventHandler, ReactNode } from 'react'; - import type { CanvasLayer } from '@genarrative/image-canvas-core'; +import type { HTMLAttributes, KeyboardEventHandler, ReactNode } from 'react'; export function LayerRenderer({ as = 'button', diff --git a/packages/image-canvas-react/src/Minimap.tsx b/packages/image-canvas-react/src/Minimap.tsx index 3be240a2c..e14a02da1 100644 --- a/packages/image-canvas-react/src/Minimap.tsx +++ b/packages/image-canvas-react/src/Minimap.tsx @@ -1,6 +1,5 @@ -import type { PointerEventHandler, Ref } from 'react'; - import type { StageMinimapModel } from '@genarrative/image-canvas-core'; +import type { PointerEventHandler, Ref } from 'react'; export function Minimap({ className = '', diff --git a/packages/image-canvas-react/src/ZoomControls.tsx b/packages/image-canvas-react/src/ZoomControls.tsx index b01ef31b6..e9f853c26 100644 --- a/packages/image-canvas-react/src/ZoomControls.tsx +++ b/packages/image-canvas-react/src/ZoomControls.tsx @@ -1,10 +1,9 @@ -import type { ReactNode } from 'react'; - import { canvasDisplayScaleToViewportScale, - formatCanvasDisplayScalePercent, type CanvasViewport, + formatCanvasDisplayScalePercent, } from '@genarrative/image-canvas-core'; +import type { ReactNode } from 'react'; export type CanvasZoomActions = { displayPercent: string; diff --git a/packages/image-canvas-react/src/index.ts b/packages/image-canvas-react/src/index.ts index c0e365304..a2cdcb5e5 100644 --- a/packages/image-canvas-react/src/index.ts +++ b/packages/image-canvas-react/src/index.ts @@ -4,8 +4,8 @@ export * from './CanvasWorld'; export * from './LayerRenderer'; export * from './Minimap'; export * from './SelectionOverlay'; -export * from './ZoomControls'; export * from './useCanvasHistory'; export * from './useImageCanvasStageController'; export * from './useImageCanvasStageInteractions'; export * from './useImageCanvasViewportControls'; +export * from './ZoomControls'; diff --git a/packages/image-canvas-react/src/sharedCanvasReact.test.tsx b/packages/image-canvas-react/src/sharedCanvasReact.test.tsx index 8e8b3d432..81304b756 100644 --- a/packages/image-canvas-react/src/sharedCanvasReact.test.tsx +++ b/packages/image-canvas-react/src/sharedCanvasReact.test.tsx @@ -1,11 +1,10 @@ /* @vitest-environment jsdom */ +import type { CanvasLayer } from '@genarrative/image-canvas-core'; import { act, renderHook } from '@testing-library/react'; import { createRef } from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { CanvasLayer } from '@genarrative/image-canvas-core'; - import { useImageCanvasViewportControls } from './useImageCanvasViewportControls'; function layer(id: string, x: number): CanvasLayer { diff --git a/packages/image-canvas-react/src/useCanvasHistory.ts b/packages/image-canvas-react/src/useCanvasHistory.ts index 05d5e2bc3..bf8380f8b 100644 --- a/packages/image-canvas-react/src/useCanvasHistory.ts +++ b/packages/image-canvas-react/src/useCanvasHistory.ts @@ -1,5 +1,3 @@ -import { type RefObject, useCallback, useRef, useState } from 'react'; - import type { CanvasGenerationDialogState, CanvasHistoryAction, @@ -11,27 +9,28 @@ import type { GenerateDialogState, } from '@genarrative/image-canvas-core'; import { - MAX_HISTORY_STEPS, canRestoreCanvasHistorySnapshotWithoutContentLoss, isProtectedCanvasHistoryAction, + MAX_HISTORY_STEPS, mergeCanvasHistorySnapshotForRestore, } from '@genarrative/image-canvas-core'; +import { type RefObject, useCallback, useRef, useState } from 'react'; type CanvasHistoryRefs = { layersRef: RefObject; viewportRef: RefObject; - generateDialogRef: RefObject; - inactiveGenerateDialogsRef: RefObject; - selectedLayerIdRef: RefObject; + generateDialogRef?: RefObject; + inactiveGenerateDialogsRef?: RefObject; + selectedLayerIdRef?: RefObject; selectedLayerIdsRef: RefObject; }; type CanvasHistorySetters = { setLayers: (layers: CanvasLayer[]) => void; setViewport: (viewport: CanvasViewport) => void; - setGenerateDialog: (dialog: GenerateDialogState | null) => void; - setInactiveGenerateDialogs: (dialogs: CanvasGenerationDialogState[]) => void; - setSelectedLayerId: (layerId: string | null) => void; + setGenerateDialog?: (dialog: GenerateDialogState | null) => void; + setInactiveGenerateDialogs?: (dialogs: CanvasGenerationDialogState[]) => void; + setSelectedLayerId?: (layerId: string | null) => void; setSelectedLayerIds: (layerIds: string[]) => void; }; @@ -69,11 +68,13 @@ export function useCanvasHistory({ setters, resetters, effects, + allowContentRemovalOnRestore = false, }: { refs: CanvasHistoryRefs; setters: CanvasHistorySetters; - resetters: CanvasHistoryResetters; + resetters?: CanvasHistoryResetters; effects?: CanvasHistoryEffects; + allowContentRemovalOnRestore?: boolean; }) { const undoStackRef = useRef([]); const redoStackRef = useRef([]); @@ -84,13 +85,13 @@ export function useCanvasHistory({ (): CanvasHistorySnapshot => ({ layers: refs.layersRef.current.map((layer) => ({ ...layer })), viewport: { ...refs.viewportRef.current }, - generateDialog: refs.generateDialogRef.current + generateDialog: refs.generateDialogRef?.current ? cloneGenerateDialog(refs.generateDialogRef.current) : null, - inactiveGenerateDialogs: refs.inactiveGenerateDialogsRef.current.map( - cloneCanvasGenerationDialog, - ), - selectedLayerId: refs.selectedLayerIdRef.current, + inactiveGenerateDialogs: ( + refs.inactiveGenerateDialogsRef?.current ?? [] + ).map(cloneCanvasGenerationDialog), + selectedLayerId: refs.selectedLayerIdRef?.current ?? null, selectedLayerIds: [...refs.selectedLayerIdsRef.current], }), [refs], @@ -112,22 +113,28 @@ export function useCanvasHistory({ ? new Set(action.layerIds) : undefined : new Set(), + layerSizeLayerIds: + action?.type === 'resize-image' + ? action.layerIds?.length + ? new Set(action.layerIds) + : new Set(snapshot.layers.map((layer) => layer.id)) + : new Set(), }); setters.setLayers(mergedSnapshot.layers.map((layer) => ({ ...layer }))); setters.setViewport({ ...mergedSnapshot.viewport }); - setters.setGenerateDialog( + setters.setGenerateDialog?.( mergedSnapshot.generateDialog ? cloneGenerateDialog(mergedSnapshot.generateDialog) : null, ); - setters.setInactiveGenerateDialogs( + setters.setInactiveGenerateDialogs?.( mergedSnapshot.inactiveGenerateDialogs.map(cloneCanvasGenerationDialog), ); - setters.setSelectedLayerId(mergedSnapshot.selectedLayerId); + setters.setSelectedLayerId?.(mergedSnapshot.selectedLayerId); setters.setSelectedLayerIds([...mergedSnapshot.selectedLayerIds]); - resetters.setHoveredLayerId(null); - resetters.setMetadataLayer(null); - resetters.resetCanvasInteractionState(); + resetters?.setHoveredLayerId(null); + resetters?.setMetadataLayer(null); + resetters?.resetCanvasInteractionState(); if (action?.type === 'change-asset-kind') { const currentLayerById = new Map( @@ -215,11 +222,12 @@ export function useCanvasHistory({ } const currentSnapshot = getCanvasHistorySnapshot(); if ( - isProtectedCanvasHistoryAction(previousEntry.action) || - !canRestoreCanvasHistorySnapshotWithoutContentLoss({ - current: currentSnapshot, - target: previousEntry.snapshot, - }) + !allowContentRemovalOnRestore && + (isProtectedCanvasHistoryAction(previousEntry.action) || + !canRestoreCanvasHistorySnapshotWithoutContentLoss({ + current: currentSnapshot, + target: previousEntry.snapshot, + })) ) { return { status: 'blocked', @@ -244,7 +252,11 @@ export function useCanvasHistory({ ); setHistoryVersion((version) => version + 1); return { status: 'success', action: previousEntry.action }; - }, [getCanvasHistorySnapshot, restoreCanvasHistorySnapshot]); + }, [ + allowContentRemovalOnRestore, + getCanvasHistorySnapshot, + restoreCanvasHistorySnapshot, + ]); const redoCanvasChange = useCallback((): CanvasHistoryApplyResult => { const nextEntry = redoStackRef.current.at(-1); @@ -253,6 +265,7 @@ export function useCanvasHistory({ } const currentSnapshot = getCanvasHistorySnapshot(); if ( + !allowContentRemovalOnRestore && !canRestoreCanvasHistorySnapshotWithoutContentLoss({ current: currentSnapshot, target: nextEntry.snapshot, @@ -281,7 +294,17 @@ export function useCanvasHistory({ ); setHistoryVersion((version) => version + 1); return { status: 'success', action: nextEntry.action }; - }, [getCanvasHistorySnapshot, restoreCanvasHistorySnapshot]); + }, [ + allowContentRemovalOnRestore, + getCanvasHistorySnapshot, + restoreCanvasHistorySnapshot, + ]); + + const resetCanvasHistory = useCallback(() => { + undoStackRef.current = []; + redoStackRef.current = []; + setHistoryVersion((version) => version + 1); + }, []); return { canUndo: undoStackRef.current.length > 0, @@ -294,5 +317,6 @@ export function useCanvasHistory({ discardCanvasHistoryEntriesById, undoCanvasChange, redoCanvasChange, + resetCanvasHistory, }; } diff --git a/packages/image-canvas-react/src/useImageCanvasStageController.ts b/packages/image-canvas-react/src/useImageCanvasStageController.ts index 085b6d0a7..4c4a58895 100644 --- a/packages/image-canvas-react/src/useImageCanvasStageController.ts +++ b/packages/image-canvas-react/src/useImageCanvasStageController.ts @@ -1,11 +1,3 @@ -import { - type Dispatch, - type MouseEvent as ReactMouseEvent, - type SetStateAction, - useCallback, - useMemo, -} from 'react'; - import type { CanvasContextMenuState, CanvasGenerationDialogState, @@ -13,6 +5,13 @@ import type { CanvasViewport, ImageContextMenuState, } from '@genarrative/image-canvas-core'; +import { + type Dispatch, + type MouseEvent as ReactMouseEvent, + type SetStateAction, + useCallback, + useMemo, +} from 'react'; function getBrowserViewportBounds() { return typeof window === 'undefined' diff --git a/packages/image-canvas-react/src/useImageCanvasStageInteractions.ts b/packages/image-canvas-react/src/useImageCanvasStageInteractions.ts index 5b1612c61..88461d4f6 100644 --- a/packages/image-canvas-react/src/useImageCanvasStageInteractions.ts +++ b/packages/image-canvas-react/src/useImageCanvasStageInteractions.ts @@ -1,15 +1,3 @@ -import { - type Dispatch, - type MouseEvent as ReactMouseEvent, - type PointerEvent as ReactPointerEvent, - type RefObject, - type SetStateAction, - useCallback, - useMemo, - useRef, - useState, -} from 'react'; - import type { CanvasGenerationDialogState, CanvasHistoryAction, @@ -44,6 +32,17 @@ import { updateGenerateDialogForLayerPointerDown, updateMinimapDragMovement, } from '@genarrative/image-canvas-core'; +import { + type Dispatch, + type MouseEvent as ReactMouseEvent, + type PointerEvent as ReactPointerEvent, + type RefObject, + type SetStateAction, + useCallback, + useMemo, + useRef, + useState, +} from 'react'; const CLICK_COLLAPSE_MOVEMENT_THRESHOLD_PX = 3; diff --git a/packages/image-canvas-react/src/useImageCanvasViewportControls.ts b/packages/image-canvas-react/src/useImageCanvasViewportControls.ts index a15005f64..39f0a2614 100644 --- a/packages/image-canvas-react/src/useImageCanvasViewportControls.ts +++ b/packages/image-canvas-react/src/useImageCanvasViewportControls.ts @@ -1,22 +1,11 @@ -import { - type Dispatch, - type RefObject, - type SetStateAction, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; - import type { CanvasLayer, CanvasViewport, DragState, } from '@genarrative/image-canvas-core'; import { - DEFAULT_CANVAS_SIZE, createMinimapModel, + DEFAULT_CANVAS_SIZE, fitViewportToLayers, getCanvasDropPoint as resolveCanvasDropPoint, getCanvasPointFromClient as resolveCanvasPointFromClient, @@ -27,6 +16,16 @@ import { scaleViewportFromScreenPoint, zoomViewportFromWheel, } from '@genarrative/image-canvas-core'; +import { + type Dispatch, + type RefObject, + type SetStateAction, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; export const DEFAULT_IMAGE_CANVAS_VIEWPORT: CanvasViewport = { x: -260, diff --git a/server-rs/crates/api-server/src/external_mcp.rs b/server-rs/crates/api-server/src/external_mcp.rs index a7f9d60fe..5f9a056b9 100644 --- a/server-rs/crates/api-server/src/external_mcp.rs +++ b/server-rs/crates/api-server/src/external_mcp.rs @@ -83,6 +83,7 @@ pub(crate) fn service() -> GenarrativeExternalMcpService { .with_allowed_hosts([ "www.genarrative.world", "genarrative.world", + "dev.genarrative.world", "localhost", "127.0.0.1", "::1", @@ -90,6 +91,7 @@ pub(crate) fn service() -> GenarrativeExternalMcpService { .with_allowed_origins([ "https://www.genarrative.world", "https://genarrative.world", + "https://dev.genarrative.world", "http://localhost:3000", "http://127.0.0.1:3000", ]); @@ -603,7 +605,7 @@ mod tests { use axum::{ http::{ StatusCode, - header::{ACCEPT, HOST}, + header::{ACCEPT, HOST, ORIGIN}, }, middleware, }; @@ -759,6 +761,47 @@ mod tests { ); } + #[tokio::test] + async fn streamable_http_accepts_dev_host_and_origin_without_weakening_guards() { + for (host, origin, expected_status) in [ + ("dev.genarrative.world", None, StatusCode::OK), + ( + "dev.genarrative.world", + Some("https://dev.genarrative.world"), + StatusCode::OK, + ), + ("untrusted.example", None, StatusCode::FORBIDDEN), + ( + "dev.genarrative.world", + Some("https://untrusted.example"), + StatusCode::FORBIDDEN, + ), + ] { + let mut request = Request::builder() + .method(Method::POST) + .uri("/api/external/v1/mcp") + .header(HOST, host) + .header(CONTENT_TYPE, "application/json") + .header(ACCEPT, "application/json, text/event-stream"); + if let Some(origin) = origin { + request = request.header(ORIGIN, origin); + } + let request = request + .body(Body::from( + r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"dev-host-test-agent","version":"1.0"}}}"#, + )) + .expect("development initialize request should build"); + + let response = service() + .oneshot(request) + .await + .expect("MCP service should be infallible"); + + assert_eq!(response.status(), expected_status, "host={host}"); + assert!(response.headers().get("mcp-session-id").is_none()); + } + } + #[tokio::test] async fn streamable_http_initialize_is_stateless_json() { let request = Request::builder() diff --git a/src/components/image-editor/ImageCanvasEditorModel.ts b/src/components/image-editor/ImageCanvasEditorModel.ts index 5a2a0d0d2..bbc59690b 100644 --- a/src/components/image-editor/ImageCanvasEditorModel.ts +++ b/src/components/image-editor/ImageCanvasEditorModel.ts @@ -1,14 +1,15 @@ -import type { - EditorAssetLibrarySnapshot, - EditorCharacterAnimationGenerationResult, - EditorProjectLayerSnapshot, -} from '../../services/image-editor/editorProjectClient'; import { canvasDisplayScaleToViewportScale, clamp, resolveSnappedItemPosition, viewportScaleToCanvasDisplayScale, } from '@genarrative/image-canvas-core'; + +import type { + EditorAssetLibrarySnapshot, + EditorCharacterAnimationGenerationResult, + EditorProjectLayerSnapshot, +} from '../../services/image-editor/editorProjectClient'; import type { CanvasAssetKind, CanvasContextMenuState, @@ -26,23 +27,23 @@ export { CANVAS_DISPLAY_SCALE_BASE, CANVAS_WORLD_ORIGIN, CANVAS_WORLD_SIZE, + canvasDisplayScaleToViewportScale, + clamp, DEFAULT_CANVAS_SIZE, + findNearestSnap, FIT_VIEW_PADDING, + formatCanvasDisplayScalePercent, + formatPercent, + getLayerBounds, MAX_HISTORY_STEPS, MAX_SCALE, MIN_SCALE, MINIMAP_DRAG_SENSITIVITY, MINIMAP_PADDING, MINIMAP_SIZE, + resolveSnappedItemPosition, SNAP_DISTRIBUTION_OVERLAP_TOLERANCE, SNAP_THRESHOLD_SCREEN_PX, - canvasDisplayScaleToViewportScale, - clamp, - findNearestSnap, - formatCanvasDisplayScalePercent, - formatPercent, - getLayerBounds, - resolveSnappedItemPosition, viewportScaleToCanvasDisplayScale, } from '@genarrative/image-canvas-core'; diff --git a/src/components/image-editor/ImageCanvasEditorPortal.tsx b/src/components/image-editor/ImageCanvasEditorPortal.tsx index caff29c14..55a9c472d 100644 --- a/src/components/image-editor/ImageCanvasEditorPortal.tsx +++ b/src/components/image-editor/ImageCanvasEditorPortal.tsx @@ -1,5 +1,5 @@ -import type { ReactNode } from 'react'; import { CanvasPortal } from '@genarrative/image-canvas-react'; +import type { ReactNode } from 'react'; import { useAuthUi } from '../auth/AuthUiContext'; diff --git a/src/components/image-editor/ImageCanvasEditorTypes.ts b/src/components/image-editor/ImageCanvasEditorTypes.ts index dfe6f938f..5fef6e8dd 100644 --- a/src/components/image-editor/ImageCanvasEditorTypes.ts +++ b/src/components/image-editor/ImageCanvasEditorTypes.ts @@ -1,3 +1,13 @@ +import type { + CanvasAssetKind, + CanvasGenerationInputs, + CanvasHistoryAction, + CanvasLayer as SharedCanvasLayer, + CanvasMediaType, + CanvasSourceType, + CanvasViewport, +} from '@genarrative/image-canvas-core'; + import type { EditorAssetSnapshot, EditorCharacterAnimationFrameCount, @@ -11,26 +21,6 @@ import type { EditorVideoResolution, EditorVideoSoundMode, } from '../../services/image-editor/editorProjectClient'; -import type { - CanvasAssetKind, - CanvasContextMenuState, - CanvasGenerationInputField, - CanvasGenerationInputReference, - CanvasGenerationInputs, - CanvasHistoryAction, - CanvasHistoryActionType, - CanvasHistoryApplyResult, - CanvasLayer as SharedCanvasLayer, - CanvasMarqueeState, - CanvasMediaType, - CanvasSnapItem, - CanvasSourceType, - CanvasTool, - CanvasViewport, - DragState, - SnapCandidate, - SnapGuide, -} from '@genarrative/image-canvas-core'; export type { CanvasAssetKind, diff --git a/src/components/image-editor/ImageCanvasHistoryModel.test.ts b/src/components/image-editor/ImageCanvasHistoryModel.test.ts index 25401a6f4..bb9af7e63 100644 --- a/src/components/image-editor/ImageCanvasHistoryModel.test.ts +++ b/src/components/image-editor/ImageCanvasHistoryModel.test.ts @@ -199,6 +199,19 @@ describe('ImageCanvasHistoryModel', () => { }); }); + it('restores layer size only for layers targeted by a resize action', () => { + const targetLayer = createLayer({ width: 320, height: 240 }); + const currentLayer = createLayer({ width: 640, height: 480 }); + + const merged = mergeCanvasHistorySnapshotForRestore({ + current: createSnapshot([currentLayer]), + target: createSnapshot([targetLayer]), + layerSizeLayerIds: new Set([targetLayer.id]), + }); + + expect(merged.layers[0]).toMatchObject({ width: 320, height: 240 }); + }); + it('restores the complete target layer after that layer was deleted', () => { const targetLayer = createLayer({ resourceId: 'deleted-resource', diff --git a/src/components/image-editor/ImageCanvasPanelDockView.tsx b/src/components/image-editor/ImageCanvasPanelDockView.tsx index 4c5ebf503..fe7b67e3f 100644 --- a/src/components/image-editor/ImageCanvasPanelDockView.tsx +++ b/src/components/image-editor/ImageCanvasPanelDockView.tsx @@ -1,3 +1,4 @@ +import { Minimap, ZoomControls } from '@genarrative/image-canvas-react'; import { ImagePlus, Layers, @@ -14,7 +15,6 @@ import type { PointerEvent as ReactPointerEvent, } from 'react'; import { useEffect, useRef } from 'react'; -import { Minimap, ZoomControls } from '@genarrative/image-canvas-react'; import { PlatformFloatingMenu, diff --git a/src/components/image-editor/ImageCanvasStageView.tsx b/src/components/image-editor/ImageCanvasStageView.tsx index 7866adb8f..c1d4506f5 100644 --- a/src/components/image-editor/ImageCanvasStageView.tsx +++ b/src/components/image-editor/ImageCanvasStageView.tsx @@ -1,3 +1,4 @@ +import { CanvasViewport as SharedCanvasViewport } from '@genarrative/image-canvas-react'; import type { CSSProperties, DragEvent as ReactDragEvent, @@ -9,7 +10,6 @@ import type { import type { ExternalGenerationTaskRecord } from '@/packages/shared/src'; import { EditorAgentConversationPanelView } from '@/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx'; -import { CanvasViewport as SharedCanvasViewport } from '@genarrative/image-canvas-react'; import { PlatformRuntimeStatusToast } from '../common/PlatformRuntimeStatusToast'; import { ImageCanvasBottomToolbarView } from './ImageCanvasBottomToolbarView'; diff --git a/src/components/image-editor/ImageCanvasWorldView.tsx b/src/components/image-editor/ImageCanvasWorldView.tsx index 564802fcb..dba2a0fdc 100644 --- a/src/components/image-editor/ImageCanvasWorldView.tsx +++ b/src/components/image-editor/ImageCanvasWorldView.tsx @@ -1,3 +1,8 @@ +import { + CanvasWorld as SharedCanvasWorld, + LayerRenderer, + SelectionOverlay, +} from '@genarrative/image-canvas-react'; import { AppWindow, Clapperboard, @@ -24,11 +29,6 @@ import type { import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl'; -import { - CanvasWorld as SharedCanvasWorld, - LayerRenderer, - SelectionOverlay, -} from '@genarrative/image-canvas-react'; import { PlatformFloatingMenu, PlatformFloatingMenuItem, diff --git a/src/components/image-editor/useCanvasHistory.test.tsx b/src/components/image-editor/useCanvasHistory.test.tsx index a45157ad6..b187ed58d 100644 --- a/src/components/image-editor/useCanvasHistory.test.tsx +++ b/src/components/image-editor/useCanvasHistory.test.tsx @@ -128,6 +128,9 @@ function HistoryHarness({ {layers.map((layer) => `${layer.id}:${layer.x}`).join(',')} + + {layers.map((layer) => `${layer.width}x${layer.height}`).join(',')} + {layers .map( @@ -171,6 +174,14 @@ function HistoryHarness({ > capture + +