diff --git a/crates/ghostlight-dungeon/src/main.rs b/crates/ghostlight-dungeon/src/main.rs index df1e4e0..43611d5 100644 --- a/crates/ghostlight-dungeon/src/main.rs +++ b/crates/ghostlight-dungeon/src/main.rs @@ -1094,6 +1094,7 @@ fn eve_operation_schema(operation: &str) -> Option<&'static str> { "session_zero.boundary.set" => "ghostlight.session_zero_boundary_set.v1", "session_zero.boundary.remove" => "ghostlight.session_zero_boundary_remove.v1", "session_zero.leave" + | "session_zero.archive" | "session_zero.roster.lock" | "session_zero.compile" | "session_zero.compilation_gaps.review" @@ -1353,6 +1354,18 @@ async fn dispatch_eve_product_command( } Err(error) => return invalid_eve_payload(&invocation, error), }, + "session_zero.archive" => match decode_payload::(&payload) { + Ok(request) => { + archive_session_zero( + Path(session_id.unwrap()), + headers.clone(), + State(state.clone()), + Json(request), + ) + .await + } + Err(error) => return invalid_eve_payload(&invocation, error), + }, "session_zero.member.remove" => { let target = string_field(&payload, "target"); let revision = u64_field(&payload, "expected_revision"); @@ -2889,6 +2902,21 @@ async fn leave_session_zero( .await } +async fn archive_session_zero( + Path(session_id): Path, + headers: HeaderMap, + State(state): State, + Json(request): Json, +) -> Response { + session_zero_simple_command(&headers, &state, session_id, |account_hash| { + SessionZeroCommand::Archive { + actor_account_hash: account_hash, + expected_revision: request.expected_revision, + } + }) + .await +} + async fn remove_session_zero_member( Path(session_id): Path, headers: HeaderMap, diff --git a/crates/ghostlight-dungeon/src/session_zero.rs b/crates/ghostlight-dungeon/src/session_zero.rs index 60bf589..5a7058a 100644 --- a/crates/ghostlight-dungeon/src/session_zero.rs +++ b/crates/ghostlight-dungeon/src/session_zero.rs @@ -2936,6 +2936,15 @@ pub fn session_zero_surface( &[], )); } + if member.is_host { + children.push(command_control( + "session-zero.archive", + "Archive Session Zero", + "session_zero.archive", + serde_json::json!({"expected_revision":state.revision}), + &[], + )); + } if let Some(preview) = state.preview.as_ref() { children.push(serde_json::json!({ "id":"session-zero.world-preview", @@ -3164,6 +3173,7 @@ pub fn session_zero_surface( eve_command("session_zero.decision.resolve", "ghostlight.session_zero_decision_resolve.v1", &[]), eve_command("session_zero.invites.create", "ghostlight.session_zero_invites_create.v1", &["count"]), eve_command("session_zero.leave", "ghostlight.session_zero_revision_command.v1", &[]), + eve_command("session_zero.archive", "ghostlight.session_zero_revision_command.v1", &[]), eve_command("session_zero.member.remove", "ghostlight.session_zero_member_remove.v1", &[]), eve_command("session_zero.roster.lock", "ghostlight.session_zero_revision_command.v1", &[]), eve_command("session_zero.compile", "ghostlight.session_zero_revision_command.v1", &[]), @@ -4749,6 +4759,39 @@ mod tests { draft } + #[tokio::test] + async fn host_can_archive_a_locked_unpublished_session_zero() { + let dir = tempdir().unwrap(); + let store = CampaignStore::open(dir.path().join("session-zero.cc")).unwrap(); + let mut initial = state(); + initial.roster_locked = true; + let initial_revision = initial.revision; + SessionZeroKernel::initialize(&store, &initial).unwrap(); + let kernel = SessionZeroKernel::start(store, initial.id); + + let archived = kernel + .command(SessionZeroCommand::Archive { + actor_account_hash: "account:host".into(), + expected_revision: initial_revision, + }) + .await + .unwrap(); + + assert_eq!(archived.state.status, SessionZeroStatus::Archived); + assert_eq!(archived.state.revision, initial_revision + 1); + } + + #[test] + fn host_surface_exposes_the_revision_bound_archive_command() { + let draft = state(); + let surface = session_zero_surface(&draft, "account:host").unwrap(); + let encoded = serde_json::to_string(&surface).unwrap(); + + assert!(encoded.contains("session_zero.archive")); + assert!(encoded.contains("Archive Session Zero")); + assert!(encoded.contains("expected_revision")); + } + fn model_receipt(stage: &str, receipt_hash: &str, latency_ms: u64) -> ModelStageReceipt { ModelStageReceipt { schema: "ghostlight.persona_stage_receipt.v1".into(),