diff --git a/crates/ghostlight-dungeon/src/kernel.rs b/crates/ghostlight-dungeon/src/kernel.rs index af272d7..ccf1463 100644 --- a/crates/ghostlight-dungeon/src/kernel.rs +++ b/crates/ghostlight-dungeon/src/kernel.rs @@ -61,6 +61,18 @@ pub struct WorldKernel { } impl WorldKernel { + pub fn initialize_campaign( + store: &CampaignStore, + command: WorldCommand, + ) -> Result { + if !matches!(command, WorldCommand::CreateCampaign { .. }) { + return Err(KernelError::Invalid( + "campaign initialization accepts only CreateCampaign".into(), + )); + } + execute(store, &mut BTreeMap::new(), command) + } + pub fn start(store: CampaignStore) -> Self { let (tx, mut rx) = mpsc::channel::(64); tokio::spawn(async move { diff --git a/crates/ghostlight-dungeon/src/main.rs b/crates/ghostlight-dungeon/src/main.rs index 0358d4a..f03c328 100644 --- a/crates/ghostlight-dungeon/src/main.rs +++ b/crates/ghostlight-dungeon/src/main.rs @@ -728,26 +728,22 @@ async fn approve_preview( None => return StatusCode::UNAUTHORIZED.into_response(), }; let _live = LiveTurnGuard::enter(&state).await; - let mut previews = state.compile_previews.lock().await; + let previews = state.compile_previews.lock().await; let Some(owned) = previews.get(&preview_id).cloned() else { return (StatusCode::NOT_FOUND, "preview missing or already consumed").into_response(); }; if owned.session_hash != session { return StatusCode::FORBIDDEN.into_response(); } - previews.remove(&preview_id); drop(previews); let preview = owned.value; let model_receipts = owned.model_receipts; let campaign_id = preview.campaign.id; - match state - .registry - .create(preview.campaign, preview.evidence_receipts, model_receipts) - .await - { + match create_or_recover_preview_campaign(&state, &preview, model_receipts).await { Ok(runtime) => match select_campaign(&state, &session, campaign_id).await { Ok(()) => match load_campaign(&runtime.store) { Ok(campaign) => { + state.compile_previews.lock().await.remove(&preview_id); if let Err(error) = refresh_mesh(&state).await { tracing::warn!(%error, "campaign approval CultMesh publication failed"); } @@ -767,6 +763,44 @@ async fn approve_preview( } } +async fn create_or_recover_preview_campaign( + state: &AppState, + preview: &WorldCompilePreview, + model_receipts: Vec, +) -> anyhow::Result { + let campaign_id = preview.campaign.id; + let runtime = match state.registry.runtime(campaign_id).await { + Ok(runtime) => runtime, + Err(_) => match state + .registry + .create( + preview.campaign.clone(), + preview.evidence_receipts.clone(), + model_receipts, + ) + .await + { + Ok(runtime) => runtime, + Err(create_error) => state + .registry + .runtime(campaign_id) + .await + .map_err(|_| create_error)?, + }, + }; + let persisted_seed = runtime + .store + .load::("campaign_seed.v1", &campaign_id.to_string())? + .ok_or_else(|| anyhow::anyhow!("published campaign has no approved seed"))? + .1; + if persisted_seed != preview.campaign { + return Err(anyhow::anyhow!( + "published campaign id belongs to a different approval preview" + )); + } + Ok(runtime) +} + #[derive(Deserialize)] struct DestinationRequest { origin_location_id: String, @@ -2570,6 +2604,66 @@ mod tests { ); } + #[tokio::test] + async fn failed_campaign_approval_keeps_its_retryable_preview() { + let dir = tempfile::tempdir().unwrap(); + let state = empty_app_state(dir.path()); + let session = "valid-session"; + let session_hash = secret_hash(session); + state + .auth + .lock() + .await + .state + .session_hashes + .insert(session_hash.clone()); + let mut campaign = seed("Invalid preview"); + campaign + .actors + .get_mut(&campaign.player_actor_id) + .unwrap() + .location_id = "missing".into(); + let preview_id = "preview".to_owned(); + state.compile_previews.lock().await.insert( + preview_id.clone(), + OwnedPreview { + session_hash, + value: WorldCompilePreview { + schema: "ghostlight.world_compile_preview.v1".into(), + title: "Invalid preview".into(), + campaign, + evidence_receipts: vec![], + evidence_coverage: vec![], + gaps: vec![], + branch_assumptions: vec![], + requires_approval: true, + }, + model_receipts: vec![], + }, + ); + let app = app_router(state.clone(), dir.path().join("web")); + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/api/compiler/approve/{preview_id}")) + .header(header::COOKIE, format!("ghostlight_session={session}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::CONFLICT); + assert!( + state + .compile_previews + .lock() + .await + .contains_key(&preview_id) + ); + assert!(state.registry.list().await.is_empty()); + } + #[tokio::test] async fn live_turn_cancels_background_inference_and_excludes_its_commit_gate() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/ghostlight-dungeon/src/persistence.rs b/crates/ghostlight-dungeon/src/persistence.rs index 2518451..93b92a4 100644 --- a/crates/ghostlight-dungeon/src/persistence.rs +++ b/crates/ghostlight-dungeon/src/persistence.rs @@ -1,6 +1,7 @@ use crate::domain::{ - Campaign, GestaltMaterializationReceipt, ResolutionControlReceipt, ResolutionWaveCommit, - StrategicTickReceipt, VaultEvidenceReceipt, VaultManifest, WorldCommitReceipt, + Campaign, CampaignLifecycleReceipt, GestaltMaterializationReceipt, ResolutionControlReceipt, + ResolutionWaveCommit, StrategicTickReceipt, VaultEvidenceReceipt, VaultManifest, + WorldCommitReceipt, }; use anyhow::{Context, Result, anyhow}; use chrono::Utc; @@ -123,6 +124,19 @@ impl CampaignStore { candidate, )?); } + rows.push(envelope( + "campaign_lifecycle_receipt.v1", + "ghostlight.campaign_lifecycle_receipt.v1", + &format!("{}:create", campaign.id), + &CampaignLifecycleReceipt { + schema: "ghostlight.campaign_lifecycle_receipt.v1".into(), + campaign_id: campaign.id, + operation: "create".into(), + parent_campaign_id: None, + parent_revision: None, + created_at: Utc::now(), + }, + )?); if !self.inner.compare_and_swap_batch(&[], rows)? { return Err(anyhow!("campaign store is not empty")); } diff --git a/crates/ghostlight-dungeon/src/registry.rs b/crates/ghostlight-dungeon/src/registry.rs index 6262011..7c497cb 100644 --- a/crates/ghostlight-dungeon/src/registry.rs +++ b/crates/ghostlight-dungeon/src/registry.rs @@ -121,29 +121,41 @@ impl CampaignRegistry { return Err(anyhow!("campaign already exists")); } let directory = self.root.join(campaign.id.to_string()); - fs::create_dir(&directory)?; - let store = CampaignStore::open(directory.join("campaign.cc"))?; + if directory.exists() { + return Err(anyhow!("campaign directory already exists")); + } + let staging = self + .root + .join(format!(".creating-{}-{}", campaign.id, Uuid::new_v4())); + fs::create_dir(&staging)?; + let prepared = (|| -> Result<()> { + let store = CampaignStore::open(staging.join("campaign.cc"))?; + WorldKernel::initialize_campaign( + &store, + crate::domain::WorldCommand::CreateCampaign { + campaign: campaign.clone(), + evidence_receipts: evidence, + model_stage_receipts: model_receipts, + }, + ) + .map_err(|error| anyhow!(error.to_string()))?; + drop(store); + fs::rename(&staging, &directory)?; + Ok(()) + })(); + if let Err(error) = prepared { + cleanup_staging_directory(&self.root, &staging); + return Err(error); + } + let store = match CampaignStore::open(directory.join("campaign.cc")) { + Ok(store) => store, + Err(error) => { + let _ = fs::rename(&directory, &staging); + cleanup_staging_directory(&self.root, &staging); + return Err(error); + } + }; let kernel = WorldKernel::start(store.clone()); - kernel - .command(crate::domain::WorldCommand::CreateCampaign { - campaign: campaign.clone(), - evidence_receipts: evidence, - model_stage_receipts: model_receipts, - }) - .await?; - store.insert( - "campaign_lifecycle_receipt.v1", - "ghostlight.campaign_lifecycle_receipt.v1", - &format!("{}:create", campaign.id), - &CampaignLifecycleReceipt { - schema: "ghostlight.campaign_lifecycle_receipt.v1".into(), - campaign_id: campaign.id, - operation: "create".into(), - parent_campaign_id: None, - parent_revision: None, - created_at: Utc::now(), - }, - )?; let runtime = CampaignRuntime { store, kernel }; self.runtimes .write() @@ -262,6 +274,17 @@ impl CampaignRegistry { } } +fn cleanup_staging_directory(root: &Path, staging: &Path) { + let safe = staging.parent() == Some(root) + && staging + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with(".creating-")); + if safe && staging.exists() { + let _ = fs::remove_dir_all(staging); + } +} + fn validated_branch_name(name: String) -> Result { let name = name.trim(); if name.is_empty() || name.chars().count() > 80 || name.chars().any(char::is_control) { @@ -445,6 +468,33 @@ mod tests { assert_eq!(forked.name, "A clean branch"); } + #[tokio::test] + async fn failed_creation_never_enters_the_discoverable_campaign_namespace() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("campaigns"); + let registry = CampaignRegistry::new(&root).unwrap(); + let mut invalid = seed("Invalid"); + invalid + .actors + .get_mut(&invalid.player_actor_id) + .unwrap() + .location_id = "missing".into(); + + assert!(registry.create(invalid, vec![], vec![]).await.is_err()); + assert!(registry.list().await.is_empty()); + assert_eq!(std::fs::read_dir(&root).unwrap().count(), 0); + + let valid = seed("Valid"); + let runtime = registry + .create(valid.clone(), vec![], vec![]) + .await + .unwrap(); + assert_eq!( + runtime.store.keys("campaign_lifecycle_receipt.v1").unwrap(), + vec![format!("{}:create", valid.id)] + ); + } + #[tokio::test] async fn load_existing_migrates_flat_campaign_without_advancing_world_revision() { let dir = tempfile::tempdir().unwrap();