1use std::collections::BTreeMap;
26use std::path::{Path, PathBuf};
27
28use anyhow::{Context, Result};
29use serde::{Deserialize, Serialize};
30
31use crate::persistence;
32
33pub const SETUP_STATE_SCHEMA_VERSION: u32 = 1;
35
36pub const SETUP_STATE_FILE_NAME: &str = "setup_state.json";
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43pub enum SetupStep {
44 Language,
46 ProviderModel,
48 TrustSandbox,
50 Constitution,
52 OperateFleet,
56 Hotbar,
58 ToolsMcp,
60 RemoteRuntime,
62 Persistence,
64 Verification,
66}
67
68impl SetupStep {
69 pub const ALL: [SetupStep; 10] = [
71 SetupStep::Language,
72 SetupStep::ProviderModel,
73 SetupStep::TrustSandbox,
74 SetupStep::Constitution,
75 SetupStep::OperateFleet,
76 SetupStep::Hotbar,
77 SetupStep::ToolsMcp,
78 SetupStep::RemoteRuntime,
79 SetupStep::Persistence,
80 SetupStep::Verification,
81 ];
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
87#[serde(rename_all = "snake_case")]
88pub enum StepStatus {
89 NotStarted,
91 Recommended,
93 Optional,
95 Deferred,
97 InProgress,
99 Verified,
101 NeedsAction,
104 Failed,
106 Skipped,
108}
109
110impl StepStatus {
111 #[must_use]
114 pub fn is_settled(self) -> bool {
115 matches!(
116 self,
117 StepStatus::Verified
118 | StepStatus::NeedsAction
119 | StepStatus::Deferred
120 | StepStatus::Optional
121 | StepStatus::Skipped
122 )
123 }
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128pub struct StepEntry {
129 pub status: StepStatus,
130 #[serde(default)]
133 pub required: bool,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub result: Option<String>,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub version: Option<String>,
142}
143
144impl StepEntry {
145 #[must_use]
147 pub fn new(status: StepStatus, required: bool, version: impl Into<String>) -> Self {
148 Self {
149 status,
150 required,
151 result: None,
152 version: Some(version.into()),
153 }
154 }
155
156 #[must_use]
157 pub fn with_result(mut self, result: impl Into<String>) -> Self {
158 self.result = Some(result.into());
159 self
160 }
161}
162
163#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
168#[serde(rename_all = "snake_case")]
169pub enum ConstitutionChoice {
170 #[default]
172 Unset,
173 Bundled,
175 GuidedCustom,
177 ExpertOverride,
180 Deferred,
182}
183
184impl ConstitutionChoice {
185 #[must_use]
187 pub fn is_explicit(self) -> bool {
188 !matches!(self, ConstitutionChoice::Unset)
189 }
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
201#[serde(rename_all = "snake_case")]
202pub enum ConstitutionAuthoring {
203 Guided,
205 ModelDrafted,
209}
210
211#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
213#[serde(rename_all = "snake_case")]
214pub enum ConstitutionSource {
215 #[default]
217 Bundled,
218 UserGlobal,
220 ExpertOverride,
222}
223
224#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
226#[serde(rename_all = "snake_case")]
227pub enum ConstitutionValidity {
228 #[default]
230 Unknown,
231 Valid,
233 Invalid,
235 Empty,
237 Unreadable,
239}
240
241#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
245#[serde(rename_all = "snake_case")]
246pub enum RuntimePostureSource {
247 #[default]
249 Unset,
250 Inherited,
252 Confirmed,
254}
255
256impl RuntimePostureSource {
257 #[must_use]
260 pub fn is_reviewed(self) -> bool {
261 matches!(
262 self,
263 RuntimePostureSource::Inherited | RuntimePostureSource::Confirmed
264 )
265 }
266}
267
268#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
270pub struct SetupState {
271 pub schema_version: u32,
272
273 #[serde(default)]
275 pub steps: BTreeMap<SetupStep, StepEntry>,
276
277 #[serde(default)]
280 pub constitution_choice: ConstitutionChoice,
281 #[serde(default, skip_serializing_if = "Option::is_none")]
284 pub constitution_checkpoint_completed_for: Option<String>,
285 #[serde(default, skip_serializing_if = "Option::is_none")]
287 pub constitution_language: Option<String>,
288 #[serde(default)]
290 pub constitution_source: ConstitutionSource,
291 #[serde(default)]
293 pub constitution_validity: ConstitutionValidity,
294 #[serde(default, skip_serializing_if = "Option::is_none")]
297 pub constitution_authoring: Option<ConstitutionAuthoring>,
298 #[serde(default, skip_serializing_if = "Option::is_none")]
301 pub constitution_preview_hash: Option<String>,
302 #[serde(default)]
305 pub constitution_preview_version: u32,
306 #[serde(default)]
308 pub runtime_posture_source: RuntimePostureSource,
309
310 #[serde(default, skip_serializing_if = "is_false")]
314 pub operate_receipts_verified: bool,
315
316 #[serde(default, skip_serializing_if = "is_false")]
320 pub inherited: bool,
321}
322
323#[allow(clippy::trivially_copy_pass_by_ref)]
324fn is_false(b: &bool) -> bool {
325 !*b
326}
327
328impl Default for SetupState {
329 fn default() -> Self {
330 Self {
331 schema_version: SETUP_STATE_SCHEMA_VERSION,
332 steps: BTreeMap::new(),
333 constitution_choice: ConstitutionChoice::default(),
334 constitution_checkpoint_completed_for: None,
335 constitution_language: None,
336 constitution_source: ConstitutionSource::default(),
337 constitution_validity: ConstitutionValidity::default(),
338 constitution_authoring: None,
339 constitution_preview_hash: None,
340 constitution_preview_version: 0,
341 runtime_posture_source: RuntimePostureSource::default(),
342 operate_receipts_verified: false,
343 inherited: false,
344 }
345 }
346}
347
348#[derive(Debug, Clone, Default)]
355pub struct InheritedConfigFacts {
356 pub has_provider_route: bool,
358 pub has_credentials_or_local_runtime: bool,
360 pub trust_chosen: bool,
362 pub language: Option<String>,
364 pub has_user_constitution: bool,
366 pub has_expert_override: bool,
368 pub user_constitution_validity: ConstitutionValidity,
370}
371
372impl SetupState {
373 #[must_use]
375 pub fn status(&self, step: SetupStep) -> StepStatus {
376 self.steps
377 .get(&step)
378 .map_or(StepStatus::NotStarted, |e| e.status)
379 }
380
381 pub fn set_step(&mut self, step: SetupStep, entry: StepEntry) -> &mut Self {
383 self.steps.insert(step, entry);
384 self
385 }
386
387 #[must_use]
388 fn step_verified(&self, step: SetupStep) -> bool {
389 self.status(step) == StepStatus::Verified
390 }
391
392 #[must_use]
396 fn provider_model_ready_or_needs_action(&self) -> bool {
397 matches!(
398 self.status(SetupStep::ProviderModel),
399 StepStatus::Verified | StepStatus::NeedsAction
400 )
401 }
402
403 #[must_use]
406 pub fn first_run_ready(&self) -> bool {
407 self.step_verified(SetupStep::Language)
408 && self.provider_model_ready_or_needs_action()
409 && self.runtime_posture_source.is_reviewed()
410 && self.constitution_choice.is_explicit()
411 }
412
413 #[must_use]
419 pub fn operate_ready(&self) -> bool {
420 self.first_run_ready()
421 && self.step_verified(SetupStep::ProviderModel)
422 && self.step_verified(SetupStep::OperateFleet)
423 && self.operate_receipts_verified
424 }
425
426 #[must_use]
429 pub fn update_ready(&self, version: &str) -> bool {
430 self.constitution_checkpoint_completed_for.as_deref() == Some(version)
431 }
432
433 #[must_use]
435 pub fn needs_constitution_checkpoint(&self, version: &str) -> bool {
436 !self.update_ready(version)
437 }
438
439 pub fn complete_constitution_checkpoint(
442 &mut self,
443 version: impl Into<String>,
444 choice: ConstitutionChoice,
445 ) -> &mut Self {
446 self.constitution_checkpoint_completed_for = Some(version.into());
447 self.constitution_choice = choice;
448 self
449 }
450
451 #[must_use]
457 pub fn derive_inherited(facts: &InheritedConfigFacts) -> Self {
458 let mut state = SetupState {
459 inherited: true,
460 ..SetupState::default()
461 };
462 let inherited = "inherited";
463
464 if facts.language.is_some() {
465 state.set_step(
466 SetupStep::Language,
467 StepEntry::new(StepStatus::Verified, true, inherited),
468 );
469 state.constitution_language = facts.language.clone();
470 }
471
472 if facts.has_provider_route && facts.has_credentials_or_local_runtime {
473 state.set_step(
474 SetupStep::ProviderModel,
475 StepEntry::new(StepStatus::Verified, true, inherited),
476 );
477 } else if facts.has_provider_route {
478 state.set_step(
479 SetupStep::ProviderModel,
480 StepEntry::new(StepStatus::NeedsAction, true, inherited),
481 );
482 }
483
484 if facts.trust_chosen {
485 state.set_step(
486 SetupStep::TrustSandbox,
487 StepEntry::new(StepStatus::Verified, true, inherited),
488 );
489 state.runtime_posture_source = RuntimePostureSource::Inherited;
490 }
491
492 if facts.has_expert_override {
495 state.constitution_source = ConstitutionSource::ExpertOverride;
496 state.constitution_choice = ConstitutionChoice::ExpertOverride;
497 } else if facts.has_user_constitution {
498 state.constitution_source = ConstitutionSource::UserGlobal;
499 state.constitution_validity = facts.user_constitution_validity;
500 if facts.user_constitution_validity == ConstitutionValidity::Valid {
501 state.constitution_choice = ConstitutionChoice::GuidedCustom;
502 }
503 } else {
504 state.constitution_source = ConstitutionSource::Bundled;
505 }
506
507 state
508 }
509
510 pub fn path() -> Result<PathBuf> {
512 Ok(crate::codewhale_home()?.join(SETUP_STATE_FILE_NAME))
513 }
514
515 pub fn load() -> Result<Option<Self>> {
521 Ok(Self::load_from(&Self::path()?))
522 }
523
524 #[must_use]
527 pub fn load_from(path: &Path) -> Option<Self> {
528 let raw = match std::fs::read_to_string(path) {
529 Ok(raw) => raw,
530 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
531 Err(e) => {
532 tracing::warn!(
533 target: "config::setup_state",
534 "could not read {} ({e}); deriving status from existing config",
535 path.display()
536 );
537 return None;
538 }
539 };
540 match serde_json::from_str::<SetupState>(&raw) {
541 Ok(state) => Some(state),
542 Err(e) => {
543 tracing::warn!(
544 target: "config::setup_state",
545 "{} is not a valid setup-state record ({e}); deriving status from existing config",
546 path.display()
547 );
548 None
549 }
550 }
551 }
552
553 pub fn save(&self) -> Result<()> {
555 let path = Self::path()?;
556 self.save_to(&path)
557 }
558
559 pub fn save_to(&self, path: &Path) -> Result<()> {
561 persistence::atomic_write_json(path, self)
562 .with_context(|| format!("failed to persist setup state to {}", path.display()))
563 }
564}
565
566#[cfg(test)]
567mod tests {
568 use super::*;
569
570 fn verified(version: &str) -> StepEntry {
571 StepEntry::new(StepStatus::Verified, true, version)
572 }
573
574 #[test]
575 fn default_is_not_first_run_ready() {
576 let state = SetupState::default();
577 assert!(!state.first_run_ready());
578 assert_eq!(state.constitution_choice, ConstitutionChoice::Unset);
579 }
580
581 #[test]
582 fn persistence_is_optional_before_verification() {
583 let persistence_index = SetupStep::ALL
584 .iter()
585 .position(|step| *step == SetupStep::Persistence)
586 .expect("persistence step");
587 let verification_index = SetupStep::ALL
588 .iter()
589 .position(|step| *step == SetupStep::Verification)
590 .expect("verification step");
591
592 assert!(persistence_index < verification_index);
593
594 let mut state = SetupState::default();
595 state.set_step(SetupStep::Language, verified("0.8.67"));
596 state.set_step(SetupStep::ProviderModel, verified("0.8.67"));
597 state.runtime_posture_source = RuntimePostureSource::Confirmed;
598 state.constitution_choice = ConstitutionChoice::Bundled;
599 assert!(state.first_run_ready());
600
601 state.set_step(
602 SetupStep::Persistence,
603 StepEntry::new(StepStatus::NeedsAction, false, "0.8.67"),
604 );
605 assert!(state.first_run_ready());
606 assert!(!state.operate_ready());
607 }
608
609 #[test]
610 fn first_run_ready_requires_all_pillars() {
611 let mut state = SetupState::default();
612 state.set_step(SetupStep::Language, verified("0.8.67"));
613 state.set_step(SetupStep::ProviderModel, verified("0.8.67"));
614 state.runtime_posture_source = RuntimePostureSource::Confirmed;
615 assert!(!state.first_run_ready());
617 state.constitution_choice = ConstitutionChoice::Bundled;
618 assert!(state.first_run_ready());
619 }
620
621 #[test]
622 fn operate_ready_is_separate_from_first_run_ready() {
623 let mut state = SetupState::default();
624 state.set_step(SetupStep::Language, verified("0.8.67"));
625 state.set_step(SetupStep::ProviderModel, verified("0.8.67"));
626 state.runtime_posture_source = RuntimePostureSource::Confirmed;
627 state.constitution_choice = ConstitutionChoice::Bundled;
628 assert!(state.first_run_ready());
629 assert!(!state.operate_ready());
630
631 state.set_step(SetupStep::OperateFleet, verified("0.8.67"));
632 assert!(
633 !state.operate_ready(),
634 "a legacy Verified card is not receipt proof"
635 );
636 state.operate_receipts_verified = true;
637 assert!(state.operate_ready());
638 }
639
640 #[test]
641 fn legacy_verified_operate_card_without_receipt_proof_fails_closed() {
642 let mut legacy = SetupState::default();
643 legacy.set_step(SetupStep::Language, verified("0.8.67"));
644 legacy.set_step(SetupStep::ProviderModel, verified("0.8.67"));
645 legacy.set_step(SetupStep::OperateFleet, verified("0.8.67"));
646 legacy.runtime_posture_source = RuntimePostureSource::Confirmed;
647 legacy.constitution_choice = ConstitutionChoice::Bundled;
648 let raw = serde_json::to_string(&legacy).expect("serialize legacy-style state");
649 assert!(!raw.contains("operate_receipts_verified"), "{raw}");
650
651 let loaded: SetupState = serde_json::from_str(&raw).expect("load legacy-style state");
652
653 assert_eq!(loaded.status(SetupStep::OperateFleet), StepStatus::Verified);
654 assert!(!loaded.operate_receipts_verified);
655 assert!(!loaded.operate_ready());
656 }
657
658 #[test]
659 fn operate_ready_requires_verified_provider_not_needs_action() {
660 let mut state = SetupState::default();
661 state.set_step(
662 SetupStep::ProviderModel,
663 StepEntry::new(StepStatus::NeedsAction, true, "0.8.67"),
664 );
665 state.runtime_posture_source = RuntimePostureSource::Confirmed;
666 state.set_step(SetupStep::OperateFleet, verified("0.8.67"));
667
668 assert!(!state.operate_ready());
669 }
670
671 #[test]
672 fn needs_action_provider_still_reaches_ready() {
673 let mut state = SetupState::default();
674 state.set_step(SetupStep::Language, verified("0.8.67"));
675 state.set_step(
676 SetupStep::ProviderModel,
677 StepEntry::new(StepStatus::NeedsAction, true, "0.8.67"),
678 );
679 state.runtime_posture_source = RuntimePostureSource::Inherited;
680 state.constitution_choice = ConstitutionChoice::Deferred;
681 assert!(state.first_run_ready());
682 }
683
684 #[test]
685 fn deferred_constitution_counts_as_explicit_choice() {
686 assert!(ConstitutionChoice::Deferred.is_explicit());
687 assert!(ConstitutionChoice::Bundled.is_explicit());
688 assert!(!ConstitutionChoice::Unset.is_explicit());
689 }
690
691 #[test]
692 fn update_ready_tracks_checkpoint_version() {
693 let mut state = SetupState::default();
694 assert!(state.needs_constitution_checkpoint("0.8.67"));
695 state.complete_constitution_checkpoint("0.8.67", ConstitutionChoice::Bundled);
696 assert!(state.update_ready("0.8.67"));
697 assert!(!state.needs_constitution_checkpoint("0.8.67"));
698 assert!(state.needs_constitution_checkpoint("0.8.68"));
700 }
701
702 #[test]
703 fn derive_inherited_marks_existing_user_safe() {
704 let facts = InheritedConfigFacts {
705 has_provider_route: true,
706 has_credentials_or_local_runtime: true,
707 trust_chosen: true,
708 language: Some("en".to_string()),
709 has_user_constitution: false,
710 has_expert_override: false,
711 user_constitution_validity: ConstitutionValidity::Unknown,
712 };
713 let state = SetupState::derive_inherited(&facts);
714 assert!(state.inherited);
715 assert_eq!(state.status(SetupStep::Language), StepStatus::Verified);
716 assert_eq!(state.status(SetupStep::ProviderModel), StepStatus::Verified);
717 assert_eq!(state.status(SetupStep::TrustSandbox), StepStatus::Verified);
718 assert_eq!(state.constitution_source, ConstitutionSource::Bundled);
719 assert!(state.needs_constitution_checkpoint("0.8.67"));
721 }
722
723 #[test]
724 fn derive_inherited_classifies_provider_without_key_as_needs_action() {
725 let facts = InheritedConfigFacts {
726 has_provider_route: true,
727 has_credentials_or_local_runtime: false,
728 ..InheritedConfigFacts::default()
729 };
730 let state = SetupState::derive_inherited(&facts);
731 assert_eq!(
732 state.status(SetupStep::ProviderModel),
733 StepStatus::NeedsAction
734 );
735 }
736
737 #[test]
738 fn derive_inherited_picks_up_existing_user_constitution() {
739 let facts = InheritedConfigFacts {
740 has_user_constitution: true,
741 user_constitution_validity: ConstitutionValidity::Valid,
742 ..InheritedConfigFacts::default()
743 };
744 let state = SetupState::derive_inherited(&facts);
745 assert_eq!(state.constitution_source, ConstitutionSource::UserGlobal);
746 assert_eq!(state.constitution_choice, ConstitutionChoice::GuidedCustom);
747 assert_eq!(state.constitution_validity, ConstitutionValidity::Valid);
748 }
749
750 #[test]
751 fn round_trips_through_json_sidecar() {
752 let tmp = tempfile::tempdir().unwrap();
753 let path = tmp.path().join(SETUP_STATE_FILE_NAME);
754
755 let mut state = SetupState::default();
756 state.set_step(
757 SetupStep::ProviderModel,
758 verified("0.8.67").with_result("openai · mimo-ultraspeed"),
759 );
760 state.constitution_choice = ConstitutionChoice::GuidedCustom;
761 state.constitution_preview_version = 3;
762 state.save_to(&path).unwrap();
763
764 let loaded = SetupState::load_from(&path).expect("record should load");
765 assert_eq!(loaded, state);
766 let raw = std::fs::read_to_string(&path).unwrap();
768 assert!(raw.contains("\"provider_model\""), "{raw}");
769 assert!(raw.contains("openai · mimo-ultraspeed"));
770 }
771
772 #[test]
773 fn constitution_authoring_round_trips_and_stays_optional() {
774 let tmp = tempfile::tempdir().unwrap();
775 let path = tmp.path().join(SETUP_STATE_FILE_NAME);
776
777 let state = SetupState {
778 constitution_choice: ConstitutionChoice::GuidedCustom,
779 constitution_authoring: Some(ConstitutionAuthoring::ModelDrafted),
780 ..Default::default()
781 };
782 state.save_to(&path).unwrap();
783
784 let loaded = SetupState::load_from(&path).expect("record should load");
785 assert_eq!(
786 loaded.constitution_authoring,
787 Some(ConstitutionAuthoring::ModelDrafted)
788 );
789 let raw = std::fs::read_to_string(&path).unwrap();
790 assert!(raw.contains("\"model_drafted\""), "{raw}");
791 }
792
793 #[test]
794 fn record_without_authoring_field_still_loads() {
795 let tmp = tempfile::tempdir().unwrap();
798 let path = tmp.path().join(SETUP_STATE_FILE_NAME);
799 std::fs::write(
800 &path,
801 r#"{"schema_version":1,"constitution_choice":"guided_custom"}"#,
802 )
803 .unwrap();
804 let loaded = SetupState::load_from(&path).expect("legacy record should load");
805 assert_eq!(loaded.constitution_authoring, None);
806 assert_eq!(loaded.constitution_choice, ConstitutionChoice::GuidedCustom);
807 }
808
809 #[test]
810 fn corrupt_record_falls_back_to_none() {
811 let tmp = tempfile::tempdir().unwrap();
812 let path = tmp.path().join(SETUP_STATE_FILE_NAME);
813 std::fs::write(&path, "{ not valid json").unwrap();
814 assert!(SetupState::load_from(&path).is_none());
815 }
816
817 #[test]
818 fn missing_record_is_none_not_error() {
819 let tmp = tempfile::tempdir().unwrap();
820 let path = tmp.path().join("does-not-exist.json");
821 assert!(SetupState::load_from(&path).is_none());
822 }
823
824 #[test]
825 fn step_result_carries_no_secret_by_construction() {
826 let entry = verified("0.8.67").with_result("provider: openai, model: mimo");
829 let json = serde_json::to_string(&entry).unwrap();
830 assert!(!json.to_lowercase().contains("sk-"));
831 }
832}