Publish campaigns only after atomic initialization
This commit is contained in:
parent
2e77310b30
commit
90e7ca38a2
4 changed files with 201 additions and 31 deletions
|
|
@ -61,6 +61,18 @@ pub struct WorldKernel {
|
|||
}
|
||||
|
||||
impl WorldKernel {
|
||||
pub fn initialize_campaign(
|
||||
store: &CampaignStore,
|
||||
command: WorldCommand,
|
||||
) -> Result<CommandResult, KernelError> {
|
||||
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::<Request>(64);
|
||||
tokio::spawn(async move {
|
||||
|
|
|
|||
|
|
@ -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<ghostlight_dungeon::model::ModelStageReceipt>,
|
||||
) -> anyhow::Result<CampaignRuntime> {
|
||||
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>("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();
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
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();
|
||||
|
|
|
|||
Loading…
Reference in a new issue