完成素材无限画布最终审计与恢复安全收口
同步资源管理最新稳定的 manifest relay、Runner 重挂与 MCP 域名修复 统一网站与 Tauri 的共享画布 history、缩放恢复和宿主边界 移除生成账本中的临时上传凭证与 Provider 敏感字段 补齐事务快照发布前故障恢复和同幂等身份重放 更新最终 PRD、技术方案、decision-log 与 pitfalls 补齐前端、Tauri Rust、竞态、锁、恢复和原生壳门禁
This commit is contained in:
@@ -5,6 +5,9 @@ pub(super) static GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE: OnceLock<tauri::
|
||||
pub(super) static GAME_CREATOR_MANIFEST_INVALIDATION_EVENT_SINK: OnceLock<
|
||||
std::sync::Mutex<Option<GameCreatorManifestInvalidationEventSink>>,
|
||||
> = 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<std::collections::BTreeMap<String, bool>>,
|
||||
> = 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,
|
||||
|
||||
@@ -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<GameCreatorManifestInvalidationEventSink> {
|
||||
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> {
|
||||
|
||||
@@ -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<RecoverAssetCanvasOutcome, String> {
|
||||
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)?;
|
||||
|
||||
@@ -128,14 +128,12 @@ impl From<ExternalCanvasGenerationContext> 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<String, String>,
|
||||
}
|
||||
|
||||
@@ -145,7 +143,8 @@ struct PrivateReferenceState {
|
||||
resource_id: String,
|
||||
stable_reference: Option<String>,
|
||||
asset_object_id: Option<String>,
|
||||
upload_ticket: Option<PrivateUploadTicket>,
|
||||
upload_bucket: Option<String>,
|
||||
upload_object_key: Option<String>,
|
||||
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<Option<String>, 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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<ExternalAgentRunnerGuiOwnerRegistration>,
|
||||
}
|
||||
|
||||
struct ExternalAgentRunnerGuiOwnerRegistration {
|
||||
generation: u64,
|
||||
config_dir: PathBuf,
|
||||
params: ExternalAgentRunnerRequestParams,
|
||||
attached_boot_id: Option<String>,
|
||||
}
|
||||
|
||||
static EXTERNAL_AGENT_RUNNER_GUI_OWNER_ATTACHMENT_STATE: OnceLock<
|
||||
Mutex<ExternalAgentRunnerGuiOwnerAttachmentState>,
|
||||
> = OnceLock::new();
|
||||
|
||||
fn external_agent_runner_gui_owner_attachment_state(
|
||||
) -> &'static Mutex<ExternalAgentRunnerGuiOwnerAttachmentState> {
|
||||
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<ExternalAgentRunnerGuiOwnerAttachmentState>,
|
||||
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<F>(
|
||||
state: &Mutex<ExternalAgentRunnerGuiOwnerAttachmentState>,
|
||||
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<bool, String> {
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<Vec<u8>> {
|
||||
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(
|
||||
|
||||
@@ -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<DragState | null>(null);
|
||||
const undoRef = useRef<HistorySnapshot[]>([]);
|
||||
const redoRef = useRef<HistorySnapshot[]>([]);
|
||||
const saveQueueRef = useRef<Promise<unknown>>(Promise.resolve());
|
||||
const savePromiseRef = useRef<Promise<void> | 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({
|
||||
<button
|
||||
type="button"
|
||||
onClick={undo}
|
||||
disabled={
|
||||
!undoRef.current.length || lifecycle.kind === 'canvas.generating'
|
||||
}
|
||||
disabled={!canUndo || lifecycle.kind === 'canvas.generating'}
|
||||
>
|
||||
撤销
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={redo}
|
||||
disabled={
|
||||
!redoRef.current.length || lifecycle.kind === 'canvas.generating'
|
||||
}
|
||||
disabled={!canRedo || lifecycle.kind === 'canvas.generating'}
|
||||
>
|
||||
重做
|
||||
</button>
|
||||
@@ -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,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type {
|
||||
ImageCanvasDraft,
|
||||
ImageCanvasGenerationCommitResult,
|
||||
ImageCanvasGenerationPort,
|
||||
ImageCanvasGenerationProgress,
|
||||
ImageCanvasGenerationRecord,
|
||||
ImageCanvasGenerationPort,
|
||||
ImageCanvasHostPort,
|
||||
ImageCanvasHostResult,
|
||||
ImageCanvasHostScope,
|
||||
|
||||
@@ -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 或只完成本地写文件都不能算正式闭环。
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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 可继续。
|
||||
|
||||
@@ -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 下通过。
|
||||
|
||||
@@ -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/<agentId>.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 身份在清理后可重放,最终仍只登记一份资产。
|
||||
|
||||
@@ -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/<generationId>.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;阶段五读取旧空记录兼容,但下一次成功写入必须移除这些私有字段。
|
||||
|
||||
|
||||
@@ -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 暴露下列稳定文本资源:
|
||||
|
||||
@@ -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<string>;
|
||||
layerSizeLayerIds?: ReadonlySet<string>;
|
||||
}): 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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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> = {}): CanvasLayer {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -193,6 +193,7 @@ export type CanvasHistorySnapshot = {
|
||||
|
||||
export type CanvasHistoryActionType =
|
||||
| 'move-image'
|
||||
| 'resize-image'
|
||||
| 'move-generation-result'
|
||||
| 'delete-image'
|
||||
| 'delete-generation-result'
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user