1use crate::app::state::AppBindingResolution;
2use anyhow::{Context, Result};
3use serde::{Deserialize, Serialize};
4use std::collections::{HashMap, HashSet};
5use std::fs;
6use std::io::ErrorKind;
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct AppGeneration {
11 pub id: u64,
12 pub app_name: String,
13 pub version: String,
14 pub bindings: Vec<AppBindingResolution>,
15 pub capabilities: Vec<String>,
16 #[serde(default)]
17 pub enabled_features: Vec<String>,
18 #[serde(default)]
19 pub scene: String,
20 pub profile: String,
21 #[serde(default)]
22 pub binding_set_id: String,
23 #[serde(default)]
24 pub closure_id: String,
25 #[serde(default)]
26 pub lock_digest: String,
27 #[serde(default)]
28 pub lock_path: String,
29 #[serde(default)]
30 pub parent_generation: Option<u64>,
31 #[serde(default)]
32 pub created_by: String,
33 pub status: GenerationStatus,
34 #[serde(default)]
35 pub validation_results: Vec<ValidationResult>,
36 pub created_at: u64,
37}
38
39impl Default for AppGeneration {
40 fn default() -> Self {
41 Self {
42 id: 0,
43 app_name: String::new(),
44 version: String::new(),
45 bindings: vec![],
46 capabilities: vec![],
47 enabled_features: vec![],
48 scene: String::new(),
49 profile: String::new(),
50 binding_set_id: String::new(),
51 closure_id: String::new(),
52 lock_digest: String::new(),
53 lock_path: String::new(),
54 parent_generation: None,
55 created_by: String::new(),
56 status: GenerationStatus::Candidate,
57 validation_results: vec![],
58 created_at: 0,
59 }
60 }
61}
62
63#[derive(Debug, Clone, Default)]
64pub struct AppGenerationSummaryMetadata {
65 pub scene: String,
66 pub binding_set_id: String,
67 pub closure_id: String,
68 pub lock_digest: String,
69 pub lock_path: String,
70 pub parent_generation: Option<u64>,
71 pub created_by: String,
72}
73
74#[derive(Debug, Clone, Default)]
75pub struct AppGenerationProposal {
76 pub app_name: String,
77 pub version: String,
78 pub bindings: Vec<AppBindingResolution>,
79 pub capabilities: Vec<String>,
80 pub enabled_features: Vec<String>,
81 pub profile: String,
82 pub metadata: AppGenerationSummaryMetadata,
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(rename_all = "lowercase")]
87pub enum GenerationStatus {
88 Candidate,
89 Verified,
90 Active,
91 Rollback,
92 Failed,
93 Archived,
94}
95
96pub const GENERATION_INDEX_SCHEMA_VERSION: u64 = 1;
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct AppGenerationIndex {
100 #[serde(default = "default_generation_index_schema_version")]
101 pub schema_version: u64,
102 #[serde(default)]
103 pub active: Option<u64>,
104 #[serde(default)]
105 pub previous: Option<u64>,
106 #[serde(default)]
107 pub candidate: Option<u64>,
108 pub next_id: u64,
109 #[serde(default)]
110 pub generations: Vec<AppGeneration>,
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(rename_all = "snake_case")]
115pub enum GenerationIndexDiagnosticLevel {
116 Warning,
117 RepairNeeded,
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121pub struct GenerationIndexDiagnostic {
122 pub level: GenerationIndexDiagnosticLevel,
123 pub code: String,
124 pub message: String,
125 #[serde(default)]
126 pub pointer: Option<String>,
127 #[serde(default)]
128 pub generation_id: Option<u64>,
129}
130
131#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
132pub struct GenerationIndexConsistencyReport {
133 pub is_consistent: bool,
134 pub repair_recommended: bool,
135 #[serde(default)]
136 pub diagnostics: Vec<GenerationIndexDiagnostic>,
137}
138
139#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
140pub struct StartupGenerationStoreDiagnostics {
141 pub active_pointer: Option<u64>,
142 pub previous_pointer: Option<u64>,
143 pub generation_index_present: bool,
144 #[serde(default)]
145 pub diagnostics: Vec<GenerationIndexDiagnostic>,
146}
147
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(rename_all = "snake_case")]
150pub enum ActivationPersistenceStepKind {
151 CheckTargetStatus,
152 CheckTargetLockMetadata,
153 WriteGenerationLock,
154 WritePreviousPointer,
155 ReplaceActivePointer,
156 ReplaceRootLockMirror,
157 UpdateGenerationIndex,
158}
159
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161pub struct ActivationPersistenceStep {
162 pub kind: ActivationPersistenceStepKind,
163 pub description: String,
164 #[serde(default)]
165 pub path: Option<String>,
166 #[serde(default)]
167 pub generation_id: Option<u64>,
168 #[serde(default)]
169 pub best_effort: bool,
170}
171
172#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
173pub struct ActivationPersistencePlan {
174 pub target_generation_id: u64,
175 pub target_lock_path: String,
176 #[serde(default)]
177 pub previous_active_generation_id: Option<u64>,
178 #[serde(default)]
179 pub steps: Vec<ActivationPersistenceStep>,
180}
181
182impl Default for AppGenerationIndex {
183 fn default() -> Self {
184 Self {
185 schema_version: GENERATION_INDEX_SCHEMA_VERSION,
186 active: None,
187 previous: None,
188 candidate: None,
189 next_id: 1,
190 generations: Vec::new(),
191 }
192 }
193}
194
195impl AppGenerationIndex {
196 pub fn generation(&self, id: u64) -> Option<&AppGeneration> {
197 self.generations
198 .iter()
199 .find(|generation| generation.id == id)
200 }
201
202 pub fn from_store(store: &AppGenerationStore) -> Self {
203 let mut generations: Vec<AppGeneration> = Vec::new();
204 let mut push_unique = |generation: &AppGeneration| {
205 if generations
206 .iter()
207 .any(|existing| existing.id == generation.id)
208 {
209 return;
210 }
211 generations.push(generation.clone());
212 };
213
214 if let Some(active) = &store.active {
215 push_unique(active);
216 }
217 if let Some(previous) = &store.rollback {
218 push_unique(previous);
219 }
220 if let Some(candidate) = &store.candidate {
221 push_unique(candidate);
222 }
223
224 generations.sort_by_key(|generation| generation.id);
225
226 Self {
227 schema_version: GENERATION_INDEX_SCHEMA_VERSION,
228 active: store.active.as_ref().map(|generation| generation.id),
229 previous: store.rollback.as_ref().map(|generation| generation.id),
230 candidate: store.candidate.as_ref().map(|generation| generation.id),
231 next_id: store.next_generation_id(),
232 generations,
233 }
234 }
235
236 pub fn from_active_summary(active: &AppGeneration) -> Self {
237 Self {
238 schema_version: GENERATION_INDEX_SCHEMA_VERSION,
239 active: Some(active.id),
240 previous: None,
241 candidate: None,
242 next_id: active.id.saturating_add(1).max(1),
243 generations: vec![active.clone()],
244 }
245 }
246
247 pub fn repair_from_sources(
248 store: Option<&AppGenerationStore>,
249 active_summary: Option<&AppGeneration>,
250 ) -> Option<Self> {
251 let store_has_data = store.is_some_and(|store| {
252 store.active.is_some()
253 || store.rollback.is_some()
254 || store.candidate.is_some()
255 || store.next_id != 0
256 });
257 if !store_has_data && active_summary.is_none() {
258 return None;
259 }
260
261 let mut index = store.map(Self::from_store).unwrap_or_default();
262
263 if let Some(active_summary) = active_summary {
264 index.upsert_generation(active_summary.clone());
265 index.active = Some(active_summary.id);
266 index.next_id = index
267 .next_id
268 .max(active_summary.id.saturating_add(1))
269 .max(1);
270 }
271
272 if index.next_id == 0 {
273 index.next_id = 1;
274 }
275 index.generations.sort_by_key(|generation| generation.id);
276 Some(index)
277 }
278
279 pub fn consistency_report(
280 &self,
281 active_pointer: Option<u64>,
282 previous_pointer: Option<u64>,
283 ) -> GenerationIndexConsistencyReport {
284 let mut report = GenerationIndexConsistencyReport {
285 is_consistent: true,
286 repair_recommended: false,
287 diagnostics: Vec::new(),
288 };
289
290 report.compare_pointer("active", active_pointer, self.active);
291 report.compare_pointer("previous", previous_pointer, self.previous);
292
293 for (pointer, generation_id) in [
294 ("active", self.active),
295 ("previous", self.previous),
296 ("candidate", self.candidate),
297 ] {
298 if let Some(generation_id) = generation_id {
299 match self.generation(generation_id) {
300 Some(generation) => {
301 if generation.lock_path.trim().is_empty() {
302 report.warn(
303 "missing_lock_path",
304 format!(
305 "Generation {} referenced by {} is missing lock_path metadata",
306 generation_id, pointer
307 ),
308 Some(pointer),
309 Some(generation_id),
310 );
311 }
312
313 let mut missing_fields = Vec::new();
314 if generation.scene.trim().is_empty() {
315 missing_fields.push("scene");
316 }
317 if generation.binding_set_id.trim().is_empty() {
318 missing_fields.push("binding_set_id");
319 }
320 if generation.closure_id.trim().is_empty() {
321 missing_fields.push("closure_id");
322 }
323 if generation.lock_digest.trim().is_empty() {
324 missing_fields.push("lock_digest");
325 }
326 if generation.created_by.trim().is_empty() {
327 missing_fields.push("created_by");
328 }
329
330 if !missing_fields.is_empty() {
331 report.warn(
332 "incomplete_generation_summary",
333 format!(
334 "Generation {} referenced by {} is missing summary fields: {}",
335 generation_id,
336 pointer,
337 missing_fields.join(", ")
338 ),
339 Some(pointer),
340 Some(generation_id),
341 );
342 }
343 }
344 None => report.repair_needed(
345 "pointer_target_missing_from_index",
346 format!(
347 "Generation {} referenced by {} is missing from generation index",
348 generation_id, pointer
349 ),
350 Some(pointer),
351 Some(generation_id),
352 ),
353 }
354 }
355 }
356
357 report
358 }
359
360 pub fn into_store(self) -> AppGenerationStore {
361 let active = self.active.and_then(|id| self.generation(id).cloned());
362 let rollback = self.previous.and_then(|id| self.generation(id).cloned());
363 let candidate = self.candidate.and_then(|id| self.generation(id).cloned());
364
365 AppGenerationStore {
366 active,
367 candidate,
368 rollback,
369 next_id: if self.next_id == 0 { 1 } else { self.next_id },
370 }
371 }
372
373 fn normalized_for_save(&self) -> Self {
374 let mut normalized = self.clone();
375 if normalized.schema_version == 0 {
376 normalized.schema_version = GENERATION_INDEX_SCHEMA_VERSION;
377 }
378 if normalized.next_id == 0 {
379 normalized.next_id = 1;
380 }
381 normalized
382 .generations
383 .sort_by_key(|generation| generation.id);
384 normalized
385 }
386
387 fn upsert_generation(&mut self, generation: AppGeneration) {
388 if let Some(existing) = self
389 .generations
390 .iter_mut()
391 .find(|existing| existing.id == generation.id)
392 {
393 *existing = generation;
394 return;
395 }
396
397 self.generations.push(generation);
398 }
399
400 fn validate(&self, path: &Path) -> Result<()> {
401 let mut seen = HashSet::new();
402 for generation in &self.generations {
403 if !seen.insert(generation.id) {
404 anyhow::bail!(
405 "Duplicate generation id {} in {}",
406 generation.id,
407 path.display()
408 );
409 }
410 }
411
412 for (label, id) in [
413 ("active", self.active),
414 ("previous", self.previous),
415 ("candidate", self.candidate),
416 ] {
417 if let Some(id) = id {
418 if self.generation(id).is_none() {
419 anyhow::bail!(
420 "Generation index {} pointer references missing generation {} in {}",
421 label,
422 id,
423 path.display()
424 );
425 }
426 }
427 }
428
429 Ok(())
430 }
431}
432
433fn default_generation_index_schema_version() -> u64 {
434 GENERATION_INDEX_SCHEMA_VERSION
435}
436
437#[derive(Debug, Clone, Serialize, Deserialize)]
438pub struct ValidationResult {
439 pub check: String,
440 pub passed: bool,
441 pub message: String,
442}
443
444#[derive(Debug, Clone, Serialize, Deserialize, Default)]
445pub struct AppGenerationStore {
446 pub active: Option<AppGeneration>,
447 pub candidate: Option<AppGeneration>,
448 pub rollback: Option<AppGeneration>,
449 pub next_id: u64,
450}
451
452impl AppGenerationStore {
453 pub fn generation(&self, generation_id: u64) -> Option<&AppGeneration> {
454 self.active
455 .as_ref()
456 .filter(|generation| generation.id == generation_id)
457 .or_else(|| {
458 self.candidate
459 .as_ref()
460 .filter(|generation| generation.id == generation_id)
461 })
462 .or_else(|| {
463 self.rollback
464 .as_ref()
465 .filter(|generation| generation.id == generation_id)
466 })
467 }
468
469 fn allocate_generation_id(&mut self) -> u64 {
470 if self.next_id == 0 {
471 self.next_id = 1;
472 }
473 let id = self.next_id;
474 self.next_id += 1;
475 id
476 }
477
478 pub fn next_generation_id(&self) -> u64 {
479 if self.next_id == 0 {
480 1
481 } else {
482 self.next_id
483 }
484 }
485
486 pub fn propose(&mut self, proposal: AppGenerationProposal) -> &AppGeneration {
487 let id = self.allocate_generation_id();
488 let AppGenerationProposal {
489 app_name,
490 version,
491 bindings,
492 capabilities,
493 enabled_features,
494 profile,
495 metadata,
496 } = proposal;
497
498 let now = std::time::SystemTime::now()
499 .duration_since(std::time::UNIX_EPOCH)
500 .unwrap_or_default()
501 .as_secs();
502
503 self.candidate = Some(AppGeneration {
504 id,
505 app_name,
506 version,
507 bindings,
508 capabilities,
509 enabled_features,
510 scene: metadata.scene,
511 profile,
512 binding_set_id: metadata.binding_set_id,
513 closure_id: metadata.closure_id,
514 lock_digest: metadata.lock_digest,
515 lock_path: metadata.lock_path,
516 parent_generation: metadata.parent_generation,
517 created_by: metadata.created_by,
518 status: GenerationStatus::Candidate,
519 validation_results: vec![],
520 created_at: now,
521 });
522
523 self.candidate.as_ref().unwrap()
524 }
525
526 pub fn verify_candidate(
527 &mut self,
528 registry: Option<&crate::app::CapabilityRegistry>,
529 ) -> Result<&AppGeneration, String> {
530 let candidate = self
531 .candidate
532 .as_mut()
533 .ok_or_else(|| "No candidate generation to verify".to_string())?;
534
535 let mut results = Vec::new();
536
537 let has_bindings = !candidate.bindings.is_empty();
538 results.push(ValidationResult {
539 check: "boot".into(),
540 passed: has_bindings,
541 message: if has_bindings {
542 "Bindings present".into()
543 } else {
544 "No bindings resolved".into()
545 },
546 });
547
548 let has_capabilities = !candidate.capabilities.is_empty();
549 results.push(ValidationResult {
550 check: "capabilities".into(),
551 passed: has_capabilities,
552 message: if has_capabilities {
553 format!("{} capabilities declared", candidate.capabilities.len())
554 } else {
555 "No capabilities declared".into()
556 },
557 });
558
559 let bound_caps: std::collections::HashSet<&str> = candidate
560 .bindings
561 .iter()
562 .map(|b| b.capability.as_str())
563 .collect();
564 let unbound: Vec<&str> = candidate
565 .capabilities
566 .iter()
567 .filter(|c| !bound_caps.contains(c.as_str()))
568 .map(|c| c.as_str())
569 .collect();
570 let all_bound = unbound.is_empty();
571 results.push(ValidationResult {
572 check: "binding-coverage".into(),
573 passed: all_bound,
574 message: if all_bound {
575 "All capabilities have bindings".into()
576 } else {
577 format!("Unbound capabilities: {:?}", unbound)
578 },
579 });
580
581 if let Some(reg) = registry {
582 let missing: Vec<&str> = candidate
583 .capabilities
584 .iter()
585 .filter(|c| !reg.contains_key(c.as_str()))
586 .map(|c| c.as_str())
587 .collect();
588 let all_in_registry = missing.is_empty();
589 results.push(ValidationResult {
590 check: "registry-coverage".into(),
591 passed: all_in_registry,
592 message: if all_in_registry {
593 "All capabilities found in registry".into()
594 } else {
595 format!("Missing from registry: {:?}", missing)
596 },
597 });
598 }
599
600 let all_passed = results.iter().all(|r| r.passed);
601 candidate.validation_results = results;
602 candidate.status = if all_passed {
603 GenerationStatus::Verified
604 } else {
605 GenerationStatus::Failed
606 };
607
608 if all_passed {
609 Ok(candidate)
610 } else {
611 Err("Verification failed".into())
612 }
613 }
614
615 pub fn activate(&mut self) -> Result<&AppGeneration, String> {
616 let candidate = self
617 .candidate
618 .take()
619 .ok_or_else(|| "No candidate generation to activate".to_string())?;
620
621 if candidate.status != GenerationStatus::Verified {
622 self.candidate = Some(candidate);
623 return Err("Candidate must be verified before activation".into());
624 }
625
626 let previous_active = self.active.take();
627
628 let mut activated = candidate;
629 activated.status = GenerationStatus::Active;
630 self.active = Some(activated.clone());
631
632 if let Some(mut prev_active) = previous_active {
633 prev_active.status = GenerationStatus::Rollback;
634 self.rollback = Some(prev_active);
635 }
636
637 Ok(self.active.as_ref().unwrap())
638 }
639
640 pub fn rollback(&mut self) -> Result<&AppGeneration, String> {
641 let rollback_gen = self
642 .rollback
643 .take()
644 .ok_or_else(|| "No rollback generation available".to_string())?;
645
646 if let Some(mut current) = self.active.take() {
647 current.status = GenerationStatus::Failed;
648 self.candidate = Some(current);
649 }
650
651 let mut restored = rollback_gen;
652 restored.status = GenerationStatus::Active;
653 self.active = Some(restored);
654
655 Ok(self.active.as_ref().unwrap())
656 }
657
658 pub fn switch_to_existing(&mut self, generation_id: u64) -> Result<&AppGeneration, String> {
659 if self
660 .active
661 .as_ref()
662 .is_some_and(|generation| generation.id == generation_id)
663 {
664 return Ok(self.active.as_ref().unwrap());
665 }
666
667 let target_status = self
668 .generation(generation_id)
669 .map(|generation| generation.status)
670 .ok_or_else(|| format!("Generation {} not found", generation_id))?;
671
672 match target_status {
673 GenerationStatus::Verified | GenerationStatus::Rollback => {}
674 GenerationStatus::Candidate => {
675 return Err(format!(
676 "Generation {} must be verified before activation",
677 generation_id
678 ));
679 }
680 GenerationStatus::Failed => {
681 return Err(format!(
682 "Generation {} failed verification and cannot be activated",
683 generation_id
684 ));
685 }
686 GenerationStatus::Archived => {
687 return Err(format!(
688 "Generation {} is archived and cannot be activated",
689 generation_id
690 ));
691 }
692 GenerationStatus::Active => {
693 return Err(format!(
694 "Generation {} is already active but not stored in the active slot",
695 generation_id
696 ));
697 }
698 }
699
700 let previous_active = self.active.take();
701 let mut target = if self
702 .candidate
703 .as_ref()
704 .is_some_and(|generation| generation.id == generation_id)
705 {
706 self.candidate.take().unwrap()
707 } else if self
708 .rollback
709 .as_ref()
710 .is_some_and(|generation| generation.id == generation_id)
711 {
712 self.rollback.take().unwrap()
713 } else {
714 return Err(format!("Generation {} not found", generation_id));
715 };
716
717 target.status = GenerationStatus::Active;
718 self.active = Some(target);
719
720 if let Some(mut previous_active) = previous_active {
721 previous_active.status = GenerationStatus::Rollback;
722 self.rollback = Some(previous_active);
723 } else {
724 self.rollback = None;
725 }
726
727 Ok(self.active.as_ref().unwrap())
728 }
729}
730
731impl GenerationIndexConsistencyReport {
732 fn compare_pointer(&mut self, pointer: &str, actual: Option<u64>, indexed: Option<u64>) {
733 if actual != indexed {
734 self.repair_needed(
735 "pointer_mismatch",
736 format!(
737 "{} pointer mismatch: authoritative pointer is {:?}, generation index records {:?}",
738 pointer, actual, indexed
739 ),
740 Some(pointer),
741 actual.or(indexed),
742 );
743 }
744 }
745
746 fn warn(
747 &mut self,
748 code: &str,
749 message: String,
750 pointer: Option<&str>,
751 generation_id: Option<u64>,
752 ) {
753 self.diagnostics.push(GenerationIndexDiagnostic {
754 level: GenerationIndexDiagnosticLevel::Warning,
755 code: code.into(),
756 message,
757 pointer: pointer.map(str::to_owned),
758 generation_id,
759 });
760 }
761
762 fn repair_needed(
763 &mut self,
764 code: &str,
765 message: String,
766 pointer: Option<&str>,
767 generation_id: Option<u64>,
768 ) {
769 self.is_consistent = false;
770 self.repair_recommended = true;
771 self.diagnostics.push(GenerationIndexDiagnostic {
772 level: GenerationIndexDiagnosticLevel::RepairNeeded,
773 code: code.into(),
774 message,
775 pointer: pointer.map(str::to_owned),
776 generation_id,
777 });
778 }
779}
780
781impl StartupGenerationStoreDiagnostics {
782 pub fn is_clean(&self) -> bool {
783 self.diagnostics.is_empty()
784 }
785}
786
787pub type GenerationStoreMap = HashMap<String, AppGenerationStore>;
788
789pub fn generation_index_path(instance_dir: &Path) -> PathBuf {
790 instance_dir.join("generation-store.toml")
791}
792
793pub fn active_generation_pointer_path(instance_dir: &Path) -> PathBuf {
794 instance_dir.join("active")
795}
796
797pub fn previous_generation_pointer_path(instance_dir: &Path) -> PathBuf {
798 instance_dir.join("previous")
799}
800
801pub fn inspect_startup_generation_store(
802 instance_dir: &Path,
803 store: &AppGenerationStore,
804) -> StartupGenerationStoreDiagnostics {
805 let expected_active = store.active.as_ref().map(|generation| generation.id);
806 let expected_previous = store.rollback.as_ref().map(|generation| generation.id);
807 let mut diagnostics = StartupGenerationStoreDiagnostics::default();
808
809 let active_pointer = match read_active_generation_pointer(instance_dir) {
810 Ok(pointer) => pointer,
811 Err(error) => {
812 diagnostics.diagnostics.push(GenerationIndexDiagnostic {
813 level: GenerationIndexDiagnosticLevel::Warning,
814 code: "startup_active_pointer_unreadable".into(),
815 message: format!(
816 "Startup ignored unreadable active pointer at '{}': {error:#}",
817 active_generation_pointer_path(instance_dir).display()
818 ),
819 pointer: Some("active".into()),
820 generation_id: None,
821 });
822 None
823 }
824 };
825 diagnostics.active_pointer = active_pointer;
826
827 let previous_pointer = match read_previous_generation_pointer(instance_dir) {
828 Ok(pointer) => pointer,
829 Err(error) => {
830 diagnostics.diagnostics.push(GenerationIndexDiagnostic {
831 level: GenerationIndexDiagnosticLevel::Warning,
832 code: "startup_previous_pointer_unreadable".into(),
833 message: format!(
834 "Startup ignored unreadable previous pointer at '{}': {error:#}",
835 previous_generation_pointer_path(instance_dir).display()
836 ),
837 pointer: Some("previous".into()),
838 generation_id: None,
839 });
840 None
841 }
842 };
843 diagnostics.previous_pointer = previous_pointer;
844
845 if let Some(active_pointer) = active_pointer {
846 if Some(active_pointer) != expected_active {
847 diagnostics.diagnostics.push(GenerationIndexDiagnostic {
848 level: GenerationIndexDiagnosticLevel::Warning,
849 code: "startup_active_pointer_mismatch".into(),
850 message: format!(
851 "Startup retained lock-derived active generation {:?} while active pointer recorded {:?}",
852 expected_active, active_pointer
853 ),
854 pointer: Some("active".into()),
855 generation_id: Some(active_pointer),
856 });
857 }
858 }
859
860 if let Some(previous_pointer) = previous_pointer {
861 if Some(previous_pointer) != expected_previous {
862 diagnostics.diagnostics.push(GenerationIndexDiagnostic {
863 level: GenerationIndexDiagnosticLevel::Warning,
864 code: "startup_previous_pointer_mismatch".into(),
865 message: format!(
866 "Startup retained lock-derived previous generation {:?} while previous pointer recorded {:?}",
867 expected_previous, previous_pointer
868 ),
869 pointer: Some("previous".into()),
870 generation_id: Some(previous_pointer),
871 });
872 }
873 }
874
875 match load_generation_index(instance_dir) {
876 Ok(Some(index)) => {
877 diagnostics.generation_index_present = true;
878
879 if index.active != expected_active {
880 diagnostics.diagnostics.push(GenerationIndexDiagnostic {
881 level: GenerationIndexDiagnosticLevel::Warning,
882 code: "startup_generation_index_active_mismatch".into(),
883 message: format!(
884 "Startup retained lock-derived active generation {:?} while generation-store.toml recorded {:?}",
885 expected_active, index.active
886 ),
887 pointer: Some("active".into()),
888 generation_id: index.active.or(expected_active),
889 });
890 }
891
892 if index.previous != expected_previous {
893 diagnostics.diagnostics.push(GenerationIndexDiagnostic {
894 level: GenerationIndexDiagnosticLevel::Warning,
895 code: "startup_generation_index_previous_mismatch".into(),
896 message: format!(
897 "Startup retained lock-derived previous generation {:?} while generation-store.toml recorded {:?}",
898 expected_previous, index.previous
899 ),
900 pointer: Some("previous".into()),
901 generation_id: index.previous.or(expected_previous),
902 });
903 }
904
905 if let (Some(expected_active), Some(indexed_active)) = (
906 store.active.as_ref(),
907 index.active.and_then(|id| index.generation(id)),
908 ) {
909 let mut mismatched_fields = Vec::new();
910 if indexed_active.lock_path != expected_active.lock_path {
911 mismatched_fields.push("lock_path");
912 }
913 if indexed_active.scene != expected_active.scene {
914 mismatched_fields.push("scene");
915 }
916 if indexed_active.profile != expected_active.profile {
917 mismatched_fields.push("profile");
918 }
919 if indexed_active.binding_set_id != expected_active.binding_set_id {
920 mismatched_fields.push("binding_set_id");
921 }
922 if indexed_active.closure_id != expected_active.closure_id {
923 mismatched_fields.push("closure_id");
924 }
925
926 if !mismatched_fields.is_empty() {
927 diagnostics.diagnostics.push(GenerationIndexDiagnostic {
928 level: GenerationIndexDiagnosticLevel::Warning,
929 code: "startup_generation_index_summary_mismatch".into(),
930 message: format!(
931 "Startup retained lock-derived active generation {} while generation-store.toml differed in: {}",
932 expected_active.id,
933 mismatched_fields.join(", ")
934 ),
935 pointer: Some("active".into()),
936 generation_id: Some(expected_active.id),
937 });
938 }
939 }
940 }
941 Ok(None) => {}
942 Err(error) => {
943 diagnostics.diagnostics.push(GenerationIndexDiagnostic {
944 level: GenerationIndexDiagnosticLevel::Warning,
945 code: "startup_generation_index_unreadable".into(),
946 message: format!(
947 "Startup ignored unreadable generation-store.toml at '{}': {error:#}",
948 generation_index_path(instance_dir).display()
949 ),
950 pointer: None,
951 generation_id: None,
952 });
953 }
954 }
955
956 diagnostics
957}
958
959pub fn plan_activation_persistence(
960 instance_dir: &Path,
961 target: &AppGeneration,
962 store: Option<&AppGenerationStore>,
963 index: Option<&AppGenerationIndex>,
964) -> Result<ActivationPersistencePlan, String> {
965 if !matches!(
966 target.status,
967 GenerationStatus::Verified | GenerationStatus::Active
968 ) {
969 return Err(format!(
970 "Generation {} must be verified or active before activation persistence can be planned",
971 target.id
972 ));
973 }
974
975 if target.lock_path.trim().is_empty() {
976 return Err(format!(
977 "Generation {} is missing lock_path metadata required for activation persistence planning",
978 target.id
979 ));
980 }
981
982 if let Some(index) = index {
983 if index.generation(target.id).is_none() {
984 return Err(format!(
985 "Cannot plan activation persistence: target generation {} is missing from generation index",
986 target.id
987 ));
988 }
989 }
990
991 let store_active_id =
992 store.and_then(|store| store.active.as_ref().map(|generation| generation.id));
993 let index_active_id = index.and_then(|index| index.active);
994 let current_active_id =
995 reconcile_planned_generation_id("active", store_active_id, index_active_id)?;
996
997 let store_previous_id =
998 store.and_then(|store| store.rollback.as_ref().map(|generation| generation.id));
999 let index_previous_id = index.and_then(|index| index.previous);
1000 let current_previous_id =
1001 reconcile_planned_generation_id("previous", store_previous_id, index_previous_id)?;
1002
1003 let previous_active_generation_id = match target.status {
1004 GenerationStatus::Active => {
1005 if let Some(current_active_id) = current_active_id {
1006 if current_active_id != target.id {
1007 return Err(format!(
1008 "Generation {} is marked active but store/index currently point to generation {}",
1009 target.id, current_active_id
1010 ));
1011 }
1012 }
1013 current_previous_id
1014 }
1015 GenerationStatus::Verified => current_active_id,
1016 _ => unreachable!(),
1017 };
1018
1019 let target_lock_path = planned_generation_lock_path(instance_dir, &target.lock_path)
1020 .display()
1021 .to_string();
1022 let previous_pointer_path = previous_generation_pointer_path(instance_dir)
1023 .display()
1024 .to_string();
1025 let active_pointer_path = active_generation_pointer_path(instance_dir)
1026 .display()
1027 .to_string();
1028 let root_lock_mirror_path = instance_dir.join("lock.toml").display().to_string();
1029 let generation_index_path = generation_index_path(instance_dir).display().to_string();
1030
1031 Ok(ActivationPersistencePlan {
1032 target_generation_id: target.id,
1033 target_lock_path: target_lock_path.clone(),
1034 previous_active_generation_id,
1035 steps: vec![
1036 ActivationPersistenceStep {
1037 kind: ActivationPersistenceStepKind::CheckTargetStatus,
1038 description: format!(
1039 "Confirm generation {} remains verified or active before planning activation persistence",
1040 target.id
1041 ),
1042 path: None,
1043 generation_id: Some(target.id),
1044 best_effort: false,
1045 },
1046 ActivationPersistenceStep {
1047 kind: ActivationPersistenceStepKind::CheckTargetLockMetadata,
1048 description: format!(
1049 "Confirm generation {} lock metadata resolves to {}",
1050 target.id, target_lock_path
1051 ),
1052 path: Some(target_lock_path.clone()),
1053 generation_id: Some(target.id),
1054 best_effort: false,
1055 },
1056 ActivationPersistenceStep {
1057 kind: ActivationPersistenceStepKind::WriteGenerationLock,
1058 description: format!(
1059 "Write immutable generation lock for generation {} before pointer changes",
1060 target.id
1061 ),
1062 path: Some(target_lock_path),
1063 generation_id: Some(target.id),
1064 best_effort: false,
1065 },
1066 ActivationPersistenceStep {
1067 kind: ActivationPersistenceStepKind::WritePreviousPointer,
1068 description: match previous_active_generation_id {
1069 Some(previous_active_generation_id) => format!(
1070 "Write previous pointer to generation {} before replacing active pointer",
1071 previous_active_generation_id
1072 ),
1073 None => "Clear previous pointer before replacing active pointer".into(),
1074 },
1075 path: Some(previous_pointer_path),
1076 generation_id: previous_active_generation_id,
1077 best_effort: false,
1078 },
1079 ActivationPersistenceStep {
1080 kind: ActivationPersistenceStepKind::ReplaceActivePointer,
1081 description: format!(
1082 "Atomically replace active pointer with generation {}",
1083 target.id
1084 ),
1085 path: Some(active_pointer_path),
1086 generation_id: Some(target.id),
1087 best_effort: false,
1088 },
1089 ActivationPersistenceStep {
1090 kind: ActivationPersistenceStepKind::ReplaceRootLockMirror,
1091 description: format!(
1092 "Atomically replace root lock mirror from generation {} after active pointer update",
1093 target.id
1094 ),
1095 path: Some(root_lock_mirror_path),
1096 generation_id: Some(target.id),
1097 best_effort: false,
1098 },
1099 ActivationPersistenceStep {
1100 kind: ActivationPersistenceStepKind::UpdateGenerationIndex,
1101 description: "Update generation index as repairable best-effort metadata after pointer changes".into(),
1102 path: Some(generation_index_path),
1103 generation_id: Some(target.id),
1104 best_effort: true,
1105 },
1106 ],
1107 })
1108}
1109
1110fn reconcile_planned_generation_id(
1111 label: &str,
1112 store_id: Option<u64>,
1113 index_id: Option<u64>,
1114) -> Result<Option<u64>, String> {
1115 match (store_id, index_id) {
1116 (Some(store_id), Some(index_id)) if store_id != index_id => Err(format!(
1117 "Cannot plan activation persistence: {} generation differs between store ({}) and index ({})",
1118 label, store_id, index_id
1119 )),
1120 (Some(store_id), _) => Ok(Some(store_id)),
1121 (_, Some(index_id)) => Ok(Some(index_id)),
1122 (None, None) => Ok(None),
1123 }
1124}
1125
1126fn planned_generation_lock_path(instance_dir: &Path, lock_path: &str) -> PathBuf {
1127 let lock_path = Path::new(lock_path);
1128 if lock_path.is_absolute() {
1129 lock_path.to_path_buf()
1130 } else {
1131 instance_dir.join(lock_path)
1132 }
1133}
1134
1135pub fn load_generation_index(instance_dir: &Path) -> Result<Option<AppGenerationIndex>> {
1136 load_generation_index_from_path(&generation_index_path(instance_dir))
1137}
1138
1139pub fn load_generation_index_from_path(path: &Path) -> Result<Option<AppGenerationIndex>> {
1140 let content = match fs::read_to_string(path) {
1141 Ok(content) => content,
1142 Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
1143 Err(error) => {
1144 return Err(error).with_context(|| format!("Failed to read {}", path.display()));
1145 }
1146 };
1147
1148 let mut index: AppGenerationIndex =
1149 toml::from_str(&content).with_context(|| format!("Failed to parse {}", path.display()))?;
1150 if index.schema_version == 0 {
1151 index.schema_version = GENERATION_INDEX_SCHEMA_VERSION;
1152 } else if index.schema_version > GENERATION_INDEX_SCHEMA_VERSION {
1153 anyhow::bail!(
1154 "Unsupported generation index schema version {} in {}",
1155 index.schema_version,
1156 path.display()
1157 );
1158 }
1159 if index.next_id == 0 {
1160 index.next_id = 1;
1161 }
1162 index.validate(path)?;
1163
1164 Ok(Some(index))
1165}
1166
1167pub fn save_generation_index(instance_dir: &Path, index: &AppGenerationIndex) -> Result<()> {
1168 save_generation_index_to_path(&generation_index_path(instance_dir), index)
1169}
1170
1171pub fn save_generation_index_to_path(path: &Path, index: &AppGenerationIndex) -> Result<()> {
1172 let serializable = index.normalized_for_save();
1173 serializable.validate(path)?;
1174
1175 let content = toml::to_string_pretty(&serializable)
1176 .with_context(|| "Failed to serialize generation index")?;
1177 write_string_safely(path, &content)
1178}
1179
1180pub fn read_generation_pointer(path: &Path) -> Result<Option<u64>> {
1181 let content = match fs::read_to_string(path) {
1182 Ok(content) => content,
1183 Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
1184 Err(error) => {
1185 return Err(error).with_context(|| format!("Failed to read {}", path.display()));
1186 }
1187 };
1188
1189 let trimmed = content.trim();
1190 if trimmed.is_empty() {
1191 return Ok(None);
1192 }
1193
1194 let generation_id = trimmed
1195 .parse::<u64>()
1196 .with_context(|| format!("Failed to parse generation pointer {}", path.display()))?;
1197 Ok(Some(generation_id))
1198}
1199
1200pub fn read_active_generation_pointer(instance_dir: &Path) -> Result<Option<u64>> {
1201 read_generation_pointer(&active_generation_pointer_path(instance_dir))
1202}
1203
1204pub fn read_previous_generation_pointer(instance_dir: &Path) -> Result<Option<u64>> {
1205 read_generation_pointer(&previous_generation_pointer_path(instance_dir))
1206}
1207
1208pub fn write_generation_pointer(path: &Path, generation_id: Option<u64>) -> Result<()> {
1209 match generation_id {
1210 Some(generation_id) => write_string_safely(path, &format!("{}\n", generation_id)),
1211 None => {
1212 if let Err(error) = fs::remove_file(path) {
1213 if error.kind() != ErrorKind::NotFound {
1214 return Err(error)
1215 .with_context(|| format!("Failed to remove {}", path.display()));
1216 }
1217 }
1218 Ok(())
1219 }
1220 }
1221}
1222
1223pub fn write_active_generation_pointer(
1224 instance_dir: &Path,
1225 generation_id: Option<u64>,
1226) -> Result<()> {
1227 write_generation_pointer(&active_generation_pointer_path(instance_dir), generation_id)
1228}
1229
1230pub fn write_previous_generation_pointer(
1231 instance_dir: &Path,
1232 generation_id: Option<u64>,
1233) -> Result<()> {
1234 write_generation_pointer(
1235 &previous_generation_pointer_path(instance_dir),
1236 generation_id,
1237 )
1238}
1239
1240fn write_string_safely(path: &Path, content: &str) -> Result<()> {
1241 if let Some(parent) = path.parent() {
1242 fs::create_dir_all(parent)
1243 .with_context(|| format!("Failed to create {}", parent.display()))?;
1244 }
1245
1246 let temp_path = temp_write_path(path);
1247 fs::write(&temp_path, content)
1248 .with_context(|| format!("Failed to write {}", temp_path.display()))?;
1249
1250 if path.exists() {
1251 let backup_path = backup_write_path(path);
1252 if backup_path.exists() {
1253 fs::remove_file(&backup_path)
1254 .with_context(|| format!("Failed to clear {}", backup_path.display()))?;
1255 }
1256
1257 fs::rename(path, &backup_path)
1258 .with_context(|| format!("Failed to stage existing {}", path.display()))?;
1259
1260 match fs::rename(&temp_path, path) {
1261 Ok(()) => {
1262 fs::remove_file(&backup_path).with_context(|| {
1263 format!("Failed to remove backup {}", backup_path.display())
1264 })?;
1265 Ok(())
1266 }
1267 Err(error) => {
1268 let restore_result = fs::rename(&backup_path, path);
1269 if restore_result.is_err() {
1270 let _ = fs::rename(&temp_path, path);
1271 }
1272 Err(error).with_context(|| format!("Failed to move {} into place", path.display()))
1273 }
1274 }
1275 } else {
1276 fs::rename(&temp_path, path)
1277 .with_context(|| format!("Failed to move {} into place", path.display()))
1278 }?;
1279
1280 Ok(())
1281}
1282
1283fn temp_write_path(path: &Path) -> PathBuf {
1284 let file_name = path
1285 .file_name()
1286 .and_then(|value| value.to_str())
1287 .unwrap_or("generation.tmp");
1288 let unique = std::time::SystemTime::now()
1289 .duration_since(std::time::UNIX_EPOCH)
1290 .unwrap_or_default()
1291 .as_nanos();
1292 path.with_file_name(format!("{}.{}.tmp", file_name, unique))
1293}
1294
1295fn backup_write_path(path: &Path) -> PathBuf {
1296 let file_name = path
1297 .file_name()
1298 .and_then(|value| value.to_str())
1299 .unwrap_or("generation.bak");
1300 path.with_file_name(format!("{}.bak", file_name))
1301}
1302
1303#[cfg(test)]
1304mod tests {
1305 use super::*;
1306 use tempfile::tempdir;
1307
1308 #[test]
1309 fn app_generation_defaults_summary_metadata_safely() {
1310 let generation = AppGeneration::default();
1311
1312 assert_eq!(generation.scene, "");
1313 assert_eq!(generation.binding_set_id, "");
1314 assert_eq!(generation.closure_id, "");
1315 assert_eq!(generation.lock_digest, "");
1316 assert_eq!(generation.lock_path, "");
1317 assert_eq!(generation.parent_generation, None);
1318 assert_eq!(generation.created_by, "");
1319 assert_eq!(generation.status, GenerationStatus::Candidate);
1320 }
1321
1322 #[test]
1323 fn app_generation_deserializes_old_payload_with_defaulted_metadata() {
1324 let payload = serde_json::json!({
1325 "id": 7,
1326 "app_name": "weft-claw",
1327 "version": "0.1.0",
1328 "bindings": [],
1329 "capabilities": ["core.execution"],
1330 "enabled_features": [],
1331 "profile": "developer",
1332 "status": "candidate",
1333 "validation_results": [],
1334 "created_at": 123
1335 });
1336
1337 let generation: AppGeneration =
1338 serde_json::from_value(payload).expect("legacy generation payload should deserialize");
1339
1340 assert_eq!(generation.scene, "");
1341 assert_eq!(generation.binding_set_id, "");
1342 assert_eq!(generation.closure_id, "");
1343 assert_eq!(generation.lock_digest, "");
1344 assert_eq!(generation.lock_path, "");
1345 assert_eq!(generation.parent_generation, None);
1346 assert_eq!(generation.created_by, "");
1347 }
1348
1349 #[test]
1350 fn propose_initializes_summary_metadata_from_supplied_values() {
1351 let mut store = AppGenerationStore::default();
1352 let generation = store.propose(AppGenerationProposal {
1353 app_name: "weft-claw".into(),
1354 version: "0.1.0".into(),
1355 bindings: vec![],
1356 capabilities: vec!["core.execution".into()],
1357 enabled_features: vec![],
1358 profile: "developer".into(),
1359 metadata: AppGenerationSummaryMetadata {
1360 scene: "team".into(),
1361 binding_set_id: "binding-set:sha256:test".into(),
1362 closure_id: "closure:sha256:test".into(),
1363 lock_digest: "sha256:lock".into(),
1364 lock_path: "generations/1.lock.toml".into(),
1365 parent_generation: Some(3),
1366 created_by: "api".into(),
1367 },
1368 });
1369
1370 assert_eq!(generation.scene, "team");
1371 assert_eq!(generation.binding_set_id, "binding-set:sha256:test");
1372 assert_eq!(generation.closure_id, "closure:sha256:test");
1373 assert_eq!(generation.lock_digest, "sha256:lock");
1374 assert_eq!(generation.lock_path, "generations/1.lock.toml");
1375 assert_eq!(generation.parent_generation, Some(3));
1376 assert_eq!(generation.created_by, "api");
1377 }
1378
1379 fn sample_generation(id: u64, status: GenerationStatus) -> AppGeneration {
1380 AppGeneration {
1381 id,
1382 app_name: "weft-claw".into(),
1383 version: "0.1.0".into(),
1384 bindings: vec![],
1385 capabilities: vec!["core.execution".into()],
1386 enabled_features: vec![],
1387 scene: "team".into(),
1388 profile: "developer".into(),
1389 binding_set_id: format!("binding-set:sha256:{}", id),
1390 closure_id: format!("closure:sha256:{}", id),
1391 lock_digest: format!("sha256:lock-{}", id),
1392 lock_path: format!("generations/{}.lock.toml", id),
1393 parent_generation: id.checked_sub(1),
1394 created_by: "cli".into(),
1395 status,
1396 validation_results: vec![],
1397 created_at: 1710000000 + id,
1398 }
1399 }
1400
1401 #[test]
1402 fn generation_index_round_trips_with_generation_summaries() {
1403 let root = tempdir().expect("temp dir");
1404 let instance_dir = root.path().join(".weft").join("weft-claw");
1405
1406 let index = AppGenerationIndex {
1407 schema_version: GENERATION_INDEX_SCHEMA_VERSION,
1408 active: Some(20),
1409 previous: Some(19),
1410 candidate: Some(21),
1411 next_id: 22,
1412 generations: vec![
1413 sample_generation(19, GenerationStatus::Verified),
1414 sample_generation(20, GenerationStatus::Active),
1415 sample_generation(21, GenerationStatus::Candidate),
1416 ],
1417 };
1418
1419 save_generation_index(&instance_dir, &index).expect("index saved");
1420 let loaded = load_generation_index(&instance_dir)
1421 .expect("index loaded")
1422 .expect("index present");
1423
1424 assert_eq!(loaded.schema_version, GENERATION_INDEX_SCHEMA_VERSION);
1425 assert_eq!(loaded.active, Some(20));
1426 assert_eq!(loaded.previous, Some(19));
1427 assert_eq!(loaded.candidate, Some(21));
1428 assert_eq!(loaded.next_id, 22);
1429 assert_eq!(loaded.generations.len(), 3);
1430
1431 let active = loaded.generation(20).expect("active generation stored");
1432 assert_eq!(active.scene, "team");
1433 assert_eq!(active.profile, "developer");
1434 assert_eq!(active.binding_set_id, "binding-set:sha256:20");
1435 assert_eq!(active.closure_id, "closure:sha256:20");
1436 assert_eq!(active.lock_digest, "sha256:lock-20");
1437 assert_eq!(active.lock_path, "generations/20.lock.toml");
1438 assert_eq!(active.parent_generation, Some(19));
1439 assert_eq!(active.created_by, "cli");
1440 }
1441
1442 #[test]
1443 fn missing_generation_index_returns_none() {
1444 let root = tempdir().expect("temp dir");
1445 let instance_dir = root.path().join(".weft").join("weft-claw");
1446
1447 let loaded = load_generation_index(&instance_dir).expect("missing index handled");
1448
1449 assert!(loaded.is_none());
1450 }
1451
1452 #[test]
1453 fn generation_pointer_read_write_round_trip() {
1454 let root = tempdir().expect("temp dir");
1455 let instance_dir = root.path().join(".weft").join("weft-claw");
1456
1457 write_active_generation_pointer(&instance_dir, Some(20)).expect("active pointer written");
1458 write_previous_generation_pointer(&instance_dir, Some(19))
1459 .expect("previous pointer written");
1460
1461 assert_eq!(
1462 read_active_generation_pointer(&instance_dir).expect("active pointer read"),
1463 Some(20)
1464 );
1465 assert_eq!(
1466 read_previous_generation_pointer(&instance_dir).expect("previous pointer read"),
1467 Some(19)
1468 );
1469
1470 write_previous_generation_pointer(&instance_dir, None).expect("previous pointer cleared");
1471
1472 assert_eq!(
1473 read_previous_generation_pointer(&instance_dir).expect("cleared pointer read"),
1474 None
1475 );
1476 assert!(!previous_generation_pointer_path(&instance_dir).exists());
1477 }
1478
1479 #[test]
1480 fn generation_index_store_conversion_preserves_summary_fields() {
1481 let active = sample_generation(30, GenerationStatus::Active);
1482 let previous = sample_generation(29, GenerationStatus::Verified);
1483 let candidate = sample_generation(31, GenerationStatus::Candidate);
1484 let store = AppGenerationStore {
1485 active: Some(active.clone()),
1486 candidate: Some(candidate.clone()),
1487 rollback: Some(previous.clone()),
1488 next_id: 32,
1489 };
1490
1491 let index = AppGenerationIndex::from_store(&store);
1492
1493 assert_eq!(index.active, Some(30));
1494 assert_eq!(index.previous, Some(29));
1495 assert_eq!(index.candidate, Some(31));
1496 assert_eq!(index.next_id, 32);
1497 assert_eq!(
1498 index.generation(30).expect("active summary").lock_path,
1499 active.lock_path
1500 );
1501 assert_eq!(
1502 index.generation(29).expect("previous summary").scene,
1503 previous.scene
1504 );
1505 assert_eq!(
1506 index
1507 .generation(31)
1508 .expect("candidate summary")
1509 .binding_set_id,
1510 candidate.binding_set_id
1511 );
1512
1513 let restored_store = index.into_store();
1514
1515 assert_eq!(restored_store.next_id, 32);
1516 assert_eq!(
1517 restored_store.active.expect("active restored").closure_id,
1518 active.closure_id
1519 );
1520 assert_eq!(
1521 restored_store
1522 .rollback
1523 .expect("previous restored")
1524 .created_by,
1525 previous.created_by
1526 );
1527 assert_eq!(
1528 restored_store
1529 .candidate
1530 .expect("candidate restored")
1531 .lock_digest,
1532 candidate.lock_digest
1533 );
1534 }
1535
1536 #[test]
1537 fn generation_index_repair_rebuilds_missing_index_from_store() {
1538 let active = sample_generation(30, GenerationStatus::Active);
1539 let previous = sample_generation(29, GenerationStatus::Verified);
1540 let candidate = sample_generation(31, GenerationStatus::Candidate);
1541 let store = AppGenerationStore {
1542 active: Some(active.clone()),
1543 candidate: Some(candidate.clone()),
1544 rollback: Some(previous.clone()),
1545 next_id: 32,
1546 };
1547
1548 let repaired = AppGenerationIndex::repair_from_sources(Some(&store), None)
1549 .expect("repair should rebuild index from store");
1550
1551 assert_eq!(repaired.active, Some(30));
1552 assert_eq!(repaired.previous, Some(29));
1553 assert_eq!(repaired.candidate, Some(31));
1554 assert_eq!(repaired.next_id, 32);
1555 assert_eq!(repaired.generations.len(), 3);
1556 assert_eq!(
1557 repaired.generation(30).expect("active exists").scene,
1558 active.scene
1559 );
1560 assert_eq!(
1561 repaired
1562 .generation(31)
1563 .expect("candidate exists")
1564 .binding_set_id,
1565 candidate.binding_set_id
1566 );
1567 }
1568
1569 #[test]
1570 fn generation_index_repair_uses_active_summary_when_store_missing() {
1571 let active = sample_generation(40, GenerationStatus::Active);
1572
1573 let repaired = AppGenerationIndex::repair_from_sources(None, Some(&active))
1574 .expect("repair should build index from active summary");
1575
1576 assert_eq!(repaired.active, Some(40));
1577 assert_eq!(repaired.previous, None);
1578 assert_eq!(repaired.candidate, None);
1579 assert_eq!(repaired.next_id, 41);
1580 assert_eq!(repaired.generations.len(), 1);
1581 assert_eq!(
1582 repaired.generation(40).expect("active exists").lock_path,
1583 active.lock_path
1584 );
1585 }
1586
1587 #[test]
1588 fn generation_index_consistency_report_flags_pointer_mismatch() {
1589 let index = AppGenerationIndex {
1590 schema_version: GENERATION_INDEX_SCHEMA_VERSION,
1591 active: Some(20),
1592 previous: Some(19),
1593 candidate: None,
1594 next_id: 21,
1595 generations: vec![
1596 sample_generation(19, GenerationStatus::Verified),
1597 sample_generation(20, GenerationStatus::Active),
1598 ],
1599 };
1600
1601 let report = index.consistency_report(Some(21), Some(19));
1602
1603 assert!(!report.is_consistent);
1604 assert!(report.repair_recommended);
1605 assert_eq!(report.diagnostics.len(), 1);
1606 assert_eq!(
1607 report.diagnostics[0].level,
1608 GenerationIndexDiagnosticLevel::RepairNeeded
1609 );
1610 assert_eq!(report.diagnostics[0].code, "pointer_mismatch");
1611 assert_eq!(report.diagnostics[0].pointer.as_deref(), Some("active"));
1612 assert_eq!(report.diagnostics[0].generation_id, Some(21));
1613 }
1614
1615 #[test]
1616 fn generation_index_consistency_report_is_clean_for_valid_index() {
1617 let index = AppGenerationIndex {
1618 schema_version: GENERATION_INDEX_SCHEMA_VERSION,
1619 active: Some(20),
1620 previous: Some(19),
1621 candidate: Some(21),
1622 next_id: 22,
1623 generations: vec![
1624 sample_generation(19, GenerationStatus::Verified),
1625 sample_generation(20, GenerationStatus::Active),
1626 sample_generation(21, GenerationStatus::Candidate),
1627 ],
1628 };
1629
1630 let report = index.consistency_report(Some(20), Some(19));
1631
1632 assert!(report.is_consistent);
1633 assert!(!report.repair_recommended);
1634 assert!(report.diagnostics.is_empty());
1635 }
1636
1637 #[test]
1638 fn generation_index_consistency_report_warns_for_missing_lock_path_and_summary_fields() {
1639 let mut active = sample_generation(20, GenerationStatus::Active);
1640 active.lock_path.clear();
1641 active.scene.clear();
1642 active.binding_set_id.clear();
1643 active.closure_id.clear();
1644 active.lock_digest.clear();
1645 active.created_by.clear();
1646
1647 let index = AppGenerationIndex {
1648 schema_version: GENERATION_INDEX_SCHEMA_VERSION,
1649 active: Some(20),
1650 previous: None,
1651 candidate: None,
1652 next_id: 21,
1653 generations: vec![active],
1654 };
1655
1656 let report = index.consistency_report(Some(20), None);
1657
1658 assert!(report.is_consistent);
1659 assert!(!report.repair_recommended);
1660 assert_eq!(report.diagnostics.len(), 2);
1661 assert_eq!(
1662 report.diagnostics[0].level,
1663 GenerationIndexDiagnosticLevel::Warning
1664 );
1665 assert_eq!(report.diagnostics[0].code, "missing_lock_path");
1666 assert_eq!(
1667 report.diagnostics[1].level,
1668 GenerationIndexDiagnosticLevel::Warning
1669 );
1670 assert_eq!(report.diagnostics[1].code, "incomplete_generation_summary");
1671 }
1672
1673 #[test]
1674 fn startup_generation_store_diagnostics_ignore_missing_pointer_and_index_files() {
1675 let root = tempdir().expect("temp dir");
1676 let instance_dir = root.path().join(".weft").join("weft-claw");
1677 fs::create_dir_all(&instance_dir).expect("instance dir created");
1678
1679 let active = sample_generation(20, GenerationStatus::Active);
1680 let store = AppGenerationStore {
1681 active: Some(active),
1682 candidate: None,
1683 rollback: None,
1684 next_id: 21,
1685 };
1686
1687 let diagnostics = inspect_startup_generation_store(&instance_dir, &store);
1688
1689 assert!(diagnostics.is_clean());
1690 assert_eq!(diagnostics.active_pointer, None);
1691 assert_eq!(diagnostics.previous_pointer, None);
1692 assert!(!diagnostics.generation_index_present);
1693 assert!(diagnostics.diagnostics.is_empty());
1694 }
1695
1696 #[test]
1697 fn startup_generation_store_diagnostics_warn_on_pointer_and_index_mismatch() {
1698 let root = tempdir().expect("temp dir");
1699 let instance_dir = root.path().join(".weft").join("weft-claw");
1700 fs::create_dir_all(&instance_dir).expect("instance dir created");
1701
1702 write_active_generation_pointer(&instance_dir, Some(21)).expect("active pointer written");
1703 write_previous_generation_pointer(&instance_dir, Some(18))
1704 .expect("previous pointer written");
1705 save_generation_index(
1706 &instance_dir,
1707 &AppGenerationIndex {
1708 schema_version: GENERATION_INDEX_SCHEMA_VERSION,
1709 active: Some(21),
1710 previous: Some(18),
1711 candidate: None,
1712 next_id: 22,
1713 generations: vec![
1714 sample_generation(18, GenerationStatus::Verified),
1715 sample_generation(21, GenerationStatus::Active),
1716 ],
1717 },
1718 )
1719 .expect("index saved");
1720
1721 let store = AppGenerationStore {
1722 active: Some(sample_generation(20, GenerationStatus::Active)),
1723 candidate: None,
1724 rollback: Some(sample_generation(19, GenerationStatus::Verified)),
1725 next_id: 21,
1726 };
1727
1728 let diagnostics = inspect_startup_generation_store(&instance_dir, &store);
1729
1730 assert!(diagnostics.generation_index_present);
1731 assert_eq!(diagnostics.active_pointer, Some(21));
1732 assert_eq!(diagnostics.previous_pointer, Some(18));
1733 assert!(diagnostics
1734 .diagnostics
1735 .iter()
1736 .any(|diagnostic| diagnostic.code == "startup_active_pointer_mismatch"));
1737 assert!(diagnostics
1738 .diagnostics
1739 .iter()
1740 .any(|diagnostic| diagnostic.code == "startup_previous_pointer_mismatch"));
1741 assert!(diagnostics
1742 .diagnostics
1743 .iter()
1744 .any(|diagnostic| diagnostic.code == "startup_generation_index_active_mismatch"));
1745 assert!(diagnostics
1746 .diagnostics
1747 .iter()
1748 .any(|diagnostic| diagnostic.code == "startup_generation_index_previous_mismatch"));
1749 }
1750
1751 #[test]
1752 fn startup_generation_store_diagnostics_are_clean_for_matching_pointer_and_index() {
1753 let root = tempdir().expect("temp dir");
1754 let instance_dir = root.path().join(".weft").join("weft-claw");
1755 fs::create_dir_all(&instance_dir).expect("instance dir created");
1756
1757 let active = sample_generation(20, GenerationStatus::Active);
1758 let previous = sample_generation(19, GenerationStatus::Verified);
1759 let store = AppGenerationStore {
1760 active: Some(active.clone()),
1761 candidate: None,
1762 rollback: Some(previous.clone()),
1763 next_id: 21,
1764 };
1765
1766 write_active_generation_pointer(&instance_dir, Some(20)).expect("active pointer written");
1767 write_previous_generation_pointer(&instance_dir, Some(19))
1768 .expect("previous pointer written");
1769 save_generation_index(&instance_dir, &AppGenerationIndex::from_store(&store))
1770 .expect("index saved");
1771
1772 let diagnostics = inspect_startup_generation_store(&instance_dir, &store);
1773
1774 assert!(diagnostics.is_clean());
1775 assert!(diagnostics.generation_index_present);
1776 assert_eq!(diagnostics.active_pointer, Some(20));
1777 assert_eq!(diagnostics.previous_pointer, Some(19));
1778 assert!(diagnostics.diagnostics.is_empty());
1779 }
1780
1781 #[test]
1782 fn generation_index_rejects_unknown_future_schema_version() {
1783 let root = tempdir().expect("temp dir");
1784 let index_path = generation_index_path(&root.path().join(".weft").join("weft-claw"));
1785
1786 if let Some(parent) = index_path.parent() {
1787 fs::create_dir_all(parent).expect("instance dir created");
1788 }
1789 fs::write(
1790 &index_path,
1791 "schema_version = 99\nnext_id = 2\n[[generations]]\nid = 1\napp_name = 'weft-claw'\nversion = '0.1.0'\nbindings = []\ncapabilities = []\nprofile = 'developer'\nstatus = 'verified'\nlock_path = 'generations/1.lock.toml'\ncreated_at = 1710000001\n",
1792 )
1793 .expect("index fixture written");
1794
1795 let error =
1796 load_generation_index_from_path(&index_path).expect_err("future schema should fail");
1797
1798 assert!(format!("{error:#}").contains("Unsupported generation index schema version 99"));
1799 }
1800
1801 #[test]
1802 fn app_generation_index_default_is_persistable() {
1803 let index = AppGenerationIndex::default();
1804
1805 assert_eq!(index.schema_version, GENERATION_INDEX_SCHEMA_VERSION);
1806 assert_eq!(index.next_id, 1);
1807 assert!(index.generations.is_empty());
1808 }
1809
1810 #[test]
1811 fn generation_index_rejects_pointer_to_missing_generation() {
1812 let root = tempdir().expect("temp dir");
1813 let instance_dir = root.path().join(".weft").join("weft-claw");
1814 let index_path = generation_index_path(&instance_dir);
1815
1816 if let Some(parent) = index_path.parent() {
1817 fs::create_dir_all(parent).expect("instance dir created");
1818 }
1819 fs::write(&index_path, "schema_version = 1\nactive = 3\nnext_id = 4\n")
1820 .expect("index fixture written");
1821
1822 let error = load_generation_index_from_path(&index_path)
1823 .expect_err("missing active generation should fail");
1824
1825 assert!(format!("{error:#}").contains("active pointer references missing generation 3"));
1826 }
1827
1828 #[test]
1829 fn generation_index_rejects_duplicate_generation_ids() {
1830 let root = tempdir().expect("temp dir");
1831 let instance_dir = root.path().join(".weft").join("weft-claw");
1832 let index = AppGenerationIndex {
1833 schema_version: GENERATION_INDEX_SCHEMA_VERSION,
1834 active: Some(1),
1835 previous: None,
1836 candidate: None,
1837 next_id: 2,
1838 generations: vec![
1839 sample_generation(1, GenerationStatus::Active),
1840 sample_generation(1, GenerationStatus::Verified),
1841 ],
1842 };
1843
1844 let error = save_generation_index(&instance_dir, &index)
1845 .expect_err("duplicate generation ids should fail");
1846
1847 assert!(format!("{error:#}").contains("Duplicate generation id 1"));
1848 }
1849
1850 #[test]
1851 fn activation_persistence_plan_orders_checks_and_writes() {
1852 let root = tempdir().expect("temp dir");
1853 let instance_dir = root.path().join(".weft").join("weft-claw");
1854 let target = sample_generation(21, GenerationStatus::Verified);
1855 let store = AppGenerationStore {
1856 active: Some(sample_generation(20, GenerationStatus::Active)),
1857 candidate: Some(target.clone()),
1858 rollback: Some(sample_generation(19, GenerationStatus::Rollback)),
1859 next_id: 22,
1860 };
1861 let index = AppGenerationIndex::from_store(&store);
1862
1863 let plan = plan_activation_persistence(&instance_dir, &target, Some(&store), Some(&index))
1864 .expect("plan should succeed");
1865
1866 assert_eq!(plan.target_generation_id, 21);
1867 assert_eq!(plan.previous_active_generation_id, Some(20));
1868 assert_eq!(
1869 plan.target_lock_path,
1870 instance_dir
1871 .join("generations/21.lock.toml")
1872 .display()
1873 .to_string()
1874 );
1875 assert_eq!(
1876 plan.steps.iter().map(|step| step.kind).collect::<Vec<_>>(),
1877 vec![
1878 ActivationPersistenceStepKind::CheckTargetStatus,
1879 ActivationPersistenceStepKind::CheckTargetLockMetadata,
1880 ActivationPersistenceStepKind::WriteGenerationLock,
1881 ActivationPersistenceStepKind::WritePreviousPointer,
1882 ActivationPersistenceStepKind::ReplaceActivePointer,
1883 ActivationPersistenceStepKind::ReplaceRootLockMirror,
1884 ActivationPersistenceStepKind::UpdateGenerationIndex,
1885 ]
1886 );
1887 assert_eq!(
1888 plan.steps[2].path.as_deref(),
1889 Some(plan.target_lock_path.as_str())
1890 );
1891 assert_eq!(plan.steps[3].generation_id, Some(20));
1892 assert_eq!(
1893 plan.steps[3].path.as_deref(),
1894 Some(
1895 previous_generation_pointer_path(&instance_dir)
1896 .display()
1897 .to_string()
1898 .as_str()
1899 )
1900 );
1901 assert_eq!(
1902 plan.steps[4].path.as_deref(),
1903 Some(
1904 active_generation_pointer_path(&instance_dir)
1905 .display()
1906 .to_string()
1907 .as_str()
1908 )
1909 );
1910 assert!(plan.steps[6].best_effort);
1911 }
1912
1913 #[test]
1914 fn activation_persistence_plan_rejects_unverified_target() {
1915 let root = tempdir().expect("temp dir");
1916 let instance_dir = root.path().join(".weft").join("weft-claw");
1917 let target = sample_generation(21, GenerationStatus::Candidate);
1918
1919 let error = plan_activation_persistence(&instance_dir, &target, None, None)
1920 .expect_err("candidate target should be rejected");
1921
1922 assert!(error.contains("must be verified or active"));
1923 }
1924
1925 #[test]
1926 fn activation_persistence_plan_uses_existing_previous_for_active_target() {
1927 let root = tempdir().expect("temp dir");
1928 let instance_dir = root.path().join(".weft").join("weft-claw");
1929 let target = sample_generation(20, GenerationStatus::Active);
1930 let store = AppGenerationStore {
1931 active: Some(target.clone()),
1932 candidate: None,
1933 rollback: Some(sample_generation(19, GenerationStatus::Rollback)),
1934 next_id: 21,
1935 };
1936
1937 let plan = plan_activation_persistence(&instance_dir, &target, Some(&store), None)
1938 .expect("active target plan should succeed");
1939
1940 assert_eq!(plan.previous_active_generation_id, Some(19));
1941 assert_eq!(plan.steps[3].generation_id, Some(19));
1942 assert!(plan.steps[3]
1943 .description
1944 .contains("Write previous pointer to generation 19"));
1945 }
1946
1947 #[test]
1948 fn activation_persistence_plan_rejects_missing_lock_path_metadata() {
1949 let root = tempdir().expect("temp dir");
1950 let instance_dir = root.path().join(".weft").join("weft-claw");
1951 let mut target = sample_generation(21, GenerationStatus::Verified);
1952 target.lock_path.clear();
1953
1954 let error = plan_activation_persistence(&instance_dir, &target, None, None)
1955 .expect_err("missing lock_path should be rejected");
1956
1957 assert!(error.contains("missing lock_path metadata"));
1958 }
1959
1960 #[test]
1961 fn activation_persistence_plan_derives_previous_active_from_index_when_store_missing() {
1962 let root = tempdir().expect("temp dir");
1963 let instance_dir = root.path().join(".weft").join("weft-claw");
1964 let target = sample_generation(21, GenerationStatus::Verified);
1965 let index = AppGenerationIndex {
1966 schema_version: GENERATION_INDEX_SCHEMA_VERSION,
1967 active: Some(20),
1968 previous: Some(19),
1969 candidate: Some(21),
1970 next_id: 22,
1971 generations: vec![
1972 sample_generation(19, GenerationStatus::Rollback),
1973 sample_generation(20, GenerationStatus::Active),
1974 target.clone(),
1975 ],
1976 };
1977
1978 let plan = plan_activation_persistence(&instance_dir, &target, None, Some(&index))
1979 .expect("index should provide previous active context");
1980
1981 assert_eq!(plan.previous_active_generation_id, Some(20));
1982 assert_eq!(plan.steps[3].generation_id, Some(20));
1983 }
1984
1985 #[test]
1986 fn activation_persistence_plan_rejects_index_missing_target_generation() {
1987 let root = tempdir().expect("temp dir");
1988 let instance_dir = root.path().join(".weft").join("weft-claw");
1989 let target = sample_generation(21, GenerationStatus::Verified);
1990 let index = AppGenerationIndex {
1991 schema_version: GENERATION_INDEX_SCHEMA_VERSION,
1992 active: Some(20),
1993 previous: Some(19),
1994 candidate: Some(21),
1995 next_id: 22,
1996 generations: vec![
1997 sample_generation(19, GenerationStatus::Rollback),
1998 sample_generation(20, GenerationStatus::Active),
1999 ],
2000 };
2001
2002 let error = plan_activation_persistence(&instance_dir, &target, None, Some(&index))
2003 .expect_err("index missing target generation should fail");
2004
2005 assert!(error.contains("target generation 21 is missing from generation index"));
2006 }
2007}