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
39pub const TELEMETRY_NOTICE_VERSION: &str = "1";
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub enum SetupStep {
53 Language,
55 ProviderModel,
57 TrustSandbox,
59 Constitution,
61 OperateFleet,
65 Hotbar,
67 ToolsMcp,
69 RemoteRuntime,
71 Persistence,
73 Verification,
75}
76
77impl SetupStep {
78 pub const ALL: [SetupStep; 10] = [
80 SetupStep::Language,
81 SetupStep::ProviderModel,
82 SetupStep::TrustSandbox,
83 SetupStep::Constitution,
84 SetupStep::OperateFleet,
85 SetupStep::Hotbar,
86 SetupStep::ToolsMcp,
87 SetupStep::RemoteRuntime,
88 SetupStep::Persistence,
89 SetupStep::Verification,
90 ];
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(rename_all = "snake_case")]
97pub enum StepStatus {
98 NotStarted,
100 Recommended,
102 Optional,
104 Deferred,
106 InProgress,
108 Verified,
110 NeedsAction,
113 Failed,
115 Skipped,
117}
118
119impl StepStatus {
120 #[must_use]
123 pub fn is_settled(self) -> bool {
124 matches!(
125 self,
126 StepStatus::Verified
127 | StepStatus::NeedsAction
128 | StepStatus::Deferred
129 | StepStatus::Optional
130 | StepStatus::Skipped
131 )
132 }
133}
134
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137pub struct StepEntry {
138 pub status: StepStatus,
139 #[serde(default)]
142 pub required: bool,
143 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub result: Option<String>,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub version: Option<String>,
151}
152
153impl StepEntry {
154 #[must_use]
156 pub fn new(status: StepStatus, required: bool, version: impl Into<String>) -> Self {
157 Self {
158 status,
159 required,
160 result: None,
161 version: Some(version.into()),
162 }
163 }
164
165 #[must_use]
166 pub fn with_result(mut self, result: impl Into<String>) -> Self {
167 self.result = Some(result.into());
168 self
169 }
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
177#[serde(rename_all = "snake_case")]
178pub enum ConstitutionChoice {
179 #[default]
181 Unset,
182 Bundled,
184 GuidedCustom,
186 ExpertOverride,
189 Deferred,
191}
192
193impl ConstitutionChoice {
194 #[must_use]
196 pub fn is_explicit(self) -> bool {
197 !matches!(self, ConstitutionChoice::Unset)
198 }
199}
200
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
210#[serde(rename_all = "snake_case")]
211pub enum ConstitutionAuthoring {
212 Guided,
214 ModelDrafted,
218}
219
220#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
222#[serde(rename_all = "snake_case")]
223pub enum ConstitutionSource {
224 #[default]
226 Bundled,
227 UserGlobal,
229 ExpertOverride,
231}
232
233#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
235#[serde(rename_all = "snake_case")]
236pub enum ConstitutionValidity {
237 #[default]
239 Unknown,
240 Valid,
242 Invalid,
244 Empty,
246 Unreadable,
248}
249
250#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
254#[serde(rename_all = "snake_case")]
255pub enum RuntimePostureSource {
256 #[default]
258 Unset,
259 Inherited,
261 Confirmed,
263}
264
265impl RuntimePostureSource {
266 #[must_use]
269 pub fn is_reviewed(self) -> bool {
270 matches!(
271 self,
272 RuntimePostureSource::Inherited | RuntimePostureSource::Confirmed
273 )
274 }
275}
276
277#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
279pub struct SetupState {
280 pub schema_version: u32,
281
282 #[serde(default)]
284 pub steps: BTreeMap<SetupStep, StepEntry>,
285
286 #[serde(default)]
289 pub constitution_choice: ConstitutionChoice,
290 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub constitution_checkpoint_completed_for: Option<String>,
294 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub constitution_language: Option<String>,
297 #[serde(default)]
299 pub constitution_source: ConstitutionSource,
300 #[serde(default)]
302 pub constitution_validity: ConstitutionValidity,
303 #[serde(default, skip_serializing_if = "Option::is_none")]
306 pub constitution_authoring: Option<ConstitutionAuthoring>,
307 #[serde(default, skip_serializing_if = "Option::is_none")]
310 pub constitution_preview_hash: Option<String>,
311 #[serde(default)]
314 pub constitution_preview_version: u32,
315 #[serde(default)]
317 pub runtime_posture_source: RuntimePostureSource,
318
319 #[serde(default, skip_serializing_if = "is_false")]
323 pub operate_receipts_verified: bool,
324
325 #[serde(default, skip_serializing_if = "is_false")]
329 pub inherited: bool,
330
331 #[serde(default, skip_serializing_if = "Option::is_none")]
346 pub telemetry_notice_decided_for: Option<String>,
347 #[serde(default, skip_serializing_if = "is_false")]
351 pub telemetry_opt_in: bool,
352}
353
354#[allow(clippy::trivially_copy_pass_by_ref)]
355fn is_false(b: &bool) -> bool {
356 !*b
357}
358
359impl Default for SetupState {
360 fn default() -> Self {
361 Self {
362 schema_version: SETUP_STATE_SCHEMA_VERSION,
363 steps: BTreeMap::new(),
364 constitution_choice: ConstitutionChoice::default(),
365 constitution_checkpoint_completed_for: None,
366 constitution_language: None,
367 constitution_source: ConstitutionSource::default(),
368 constitution_validity: ConstitutionValidity::default(),
369 constitution_authoring: None,
370 constitution_preview_hash: None,
371 constitution_preview_version: 0,
372 runtime_posture_source: RuntimePostureSource::default(),
373 operate_receipts_verified: false,
374 inherited: false,
375 telemetry_notice_decided_for: None,
376 telemetry_opt_in: false,
377 }
378 }
379}
380
381#[derive(Debug, Clone, Default)]
388pub struct InheritedConfigFacts {
389 pub has_provider_route: bool,
391 pub has_credentials_or_local_runtime: bool,
393 pub trust_chosen: bool,
395 pub language: Option<String>,
397 pub has_user_constitution: bool,
399 pub has_expert_override: bool,
401 pub user_constitution_validity: ConstitutionValidity,
403}
404
405impl SetupState {
406 #[must_use]
408 pub fn status(&self, step: SetupStep) -> StepStatus {
409 self.steps
410 .get(&step)
411 .map_or(StepStatus::NotStarted, |e| e.status)
412 }
413
414 pub fn set_step(&mut self, step: SetupStep, entry: StepEntry) -> &mut Self {
416 self.steps.insert(step, entry);
417 self
418 }
419
420 #[must_use]
421 fn step_verified(&self, step: SetupStep) -> bool {
422 self.status(step) == StepStatus::Verified
423 }
424
425 #[must_use]
429 fn provider_model_ready_or_needs_action(&self) -> bool {
430 matches!(
431 self.status(SetupStep::ProviderModel),
432 StepStatus::Verified | StepStatus::NeedsAction
433 )
434 }
435
436 #[must_use]
439 pub fn first_run_ready(&self) -> bool {
440 self.step_verified(SetupStep::Language)
441 && self.provider_model_ready_or_needs_action()
442 && self.runtime_posture_source.is_reviewed()
443 && self.constitution_choice.is_explicit()
444 }
445
446 #[must_use]
452 pub fn operate_ready(&self) -> bool {
453 self.first_run_ready()
454 && self.step_verified(SetupStep::ProviderModel)
455 && self.step_verified(SetupStep::OperateFleet)
456 && self.operate_receipts_verified
457 }
458
459 #[must_use]
462 pub fn update_ready(&self, version: &str) -> bool {
463 self.constitution_checkpoint_completed_for.as_deref() == Some(version)
464 }
465
466 #[must_use]
468 pub fn needs_constitution_checkpoint(&self, version: &str) -> bool {
469 !self.update_ready(version)
470 }
471
472 pub fn complete_constitution_checkpoint(
475 &mut self,
476 version: impl Into<String>,
477 choice: ConstitutionChoice,
478 ) -> &mut Self {
479 self.constitution_checkpoint_completed_for = Some(version.into());
480 self.constitution_choice = choice;
481 self
482 }
483
484 #[must_use]
489 pub fn needs_telemetry_notice(&self, version: &str) -> bool {
490 self.telemetry_notice_decided_for.as_deref() != Some(version)
491 }
492
493 pub fn record_telemetry_notice(
499 &mut self,
500 version: impl Into<String>,
501 opt_in: bool,
502 ) -> &mut Self {
503 self.telemetry_notice_decided_for = Some(version.into());
504 self.telemetry_opt_in = opt_in;
505 self
506 }
507
508 #[must_use]
515 pub fn telemetry_accepted(&self, version: &str) -> bool {
516 !self.needs_telemetry_notice(version) && self.telemetry_opt_in
517 }
518
519 #[must_use]
524 pub fn telemetry_declined(&self, version: &str) -> bool {
525 !self.needs_telemetry_notice(version) && !self.telemetry_opt_in
526 }
527
528 #[must_use]
534 pub fn derive_inherited(facts: &InheritedConfigFacts) -> Self {
535 let mut state = SetupState {
536 inherited: true,
537 ..SetupState::default()
538 };
539 let inherited = "inherited";
540
541 if facts.language.is_some() {
542 state.set_step(
543 SetupStep::Language,
544 StepEntry::new(StepStatus::Verified, true, inherited),
545 );
546 state.constitution_language = facts.language.clone();
547 }
548
549 if facts.has_provider_route && facts.has_credentials_or_local_runtime {
550 state.set_step(
551 SetupStep::ProviderModel,
552 StepEntry::new(StepStatus::Verified, true, inherited),
553 );
554 } else if facts.has_provider_route {
555 state.set_step(
556 SetupStep::ProviderModel,
557 StepEntry::new(StepStatus::NeedsAction, true, inherited),
558 );
559 }
560
561 if facts.trust_chosen {
562 state.set_step(
563 SetupStep::TrustSandbox,
564 StepEntry::new(StepStatus::Verified, true, inherited),
565 );
566 state.runtime_posture_source = RuntimePostureSource::Inherited;
567 }
568
569 if facts.has_expert_override {
572 state.constitution_source = ConstitutionSource::ExpertOverride;
573 state.constitution_choice = ConstitutionChoice::ExpertOverride;
574 } else if facts.has_user_constitution {
575 state.constitution_source = ConstitutionSource::UserGlobal;
576 state.constitution_validity = facts.user_constitution_validity;
577 if facts.user_constitution_validity == ConstitutionValidity::Valid {
578 state.constitution_choice = ConstitutionChoice::GuidedCustom;
579 }
580 } else {
581 state.constitution_source = ConstitutionSource::Bundled;
582 }
583
584 state
585 }
586
587 pub fn path() -> Result<PathBuf> {
589 Ok(crate::codewhale_home()?.join(SETUP_STATE_FILE_NAME))
590 }
591
592 pub fn load() -> Result<Option<Self>> {
598 Ok(Self::load_from(&Self::path()?))
599 }
600
601 #[must_use]
604 pub fn load_from(path: &Path) -> Option<Self> {
605 let raw = match std::fs::read_to_string(path) {
606 Ok(raw) => raw,
607 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
608 Err(e) => {
609 tracing::warn!(
610 target: "config::setup_state",
611 "could not read {} ({e}); deriving status from existing config",
612 path.display()
613 );
614 return None;
615 }
616 };
617 match serde_json::from_str::<SetupState>(&raw) {
618 Ok(state) => Some(state),
619 Err(e) => {
620 tracing::warn!(
621 target: "config::setup_state",
622 "{} is not a valid setup-state record ({e}); deriving status from existing config",
623 path.display()
624 );
625 None
626 }
627 }
628 }
629
630 pub fn save(&self) -> Result<()> {
632 let path = Self::path()?;
633 self.save_to(&path)
634 }
635
636 pub fn save_to(&self, path: &Path) -> Result<()> {
638 persistence::atomic_write_json(path, self)
639 .with_context(|| format!("failed to persist setup state to {}", path.display()))
640 }
641}
642
643#[cfg(test)]
644mod tests {
645 use super::*;
646
647 fn verified(version: &str) -> StepEntry {
648 StepEntry::new(StepStatus::Verified, true, version)
649 }
650
651 #[test]
652 fn default_is_not_first_run_ready() {
653 let state = SetupState::default();
654 assert!(!state.first_run_ready());
655 assert_eq!(state.constitution_choice, ConstitutionChoice::Unset);
656 }
657
658 #[test]
659 fn persistence_is_optional_before_verification() {
660 let persistence_index = SetupStep::ALL
661 .iter()
662 .position(|step| *step == SetupStep::Persistence)
663 .expect("persistence step");
664 let verification_index = SetupStep::ALL
665 .iter()
666 .position(|step| *step == SetupStep::Verification)
667 .expect("verification step");
668
669 assert!(persistence_index < verification_index);
670
671 let mut state = SetupState::default();
672 state.set_step(SetupStep::Language, verified("0.8.67"));
673 state.set_step(SetupStep::ProviderModel, verified("0.8.67"));
674 state.runtime_posture_source = RuntimePostureSource::Confirmed;
675 state.constitution_choice = ConstitutionChoice::Bundled;
676 assert!(state.first_run_ready());
677
678 state.set_step(
679 SetupStep::Persistence,
680 StepEntry::new(StepStatus::NeedsAction, false, "0.8.67"),
681 );
682 assert!(state.first_run_ready());
683 assert!(!state.operate_ready());
684 }
685
686 #[test]
687 fn first_run_ready_requires_all_pillars() {
688 let mut state = SetupState::default();
689 state.set_step(SetupStep::Language, verified("0.8.67"));
690 state.set_step(SetupStep::ProviderModel, verified("0.8.67"));
691 state.runtime_posture_source = RuntimePostureSource::Confirmed;
692 assert!(!state.first_run_ready());
694 state.constitution_choice = ConstitutionChoice::Bundled;
695 assert!(state.first_run_ready());
696 }
697
698 #[test]
699 fn operate_ready_is_separate_from_first_run_ready() {
700 let mut state = SetupState::default();
701 state.set_step(SetupStep::Language, verified("0.8.67"));
702 state.set_step(SetupStep::ProviderModel, verified("0.8.67"));
703 state.runtime_posture_source = RuntimePostureSource::Confirmed;
704 state.constitution_choice = ConstitutionChoice::Bundled;
705 assert!(state.first_run_ready());
706 assert!(!state.operate_ready());
707
708 state.set_step(SetupStep::OperateFleet, verified("0.8.67"));
709 assert!(
710 !state.operate_ready(),
711 "a legacy Verified card is not receipt proof"
712 );
713 state.operate_receipts_verified = true;
714 assert!(state.operate_ready());
715 }
716
717 #[test]
718 fn legacy_verified_operate_card_without_receipt_proof_fails_closed() {
719 let mut legacy = SetupState::default();
720 legacy.set_step(SetupStep::Language, verified("0.8.67"));
721 legacy.set_step(SetupStep::ProviderModel, verified("0.8.67"));
722 legacy.set_step(SetupStep::OperateFleet, verified("0.8.67"));
723 legacy.runtime_posture_source = RuntimePostureSource::Confirmed;
724 legacy.constitution_choice = ConstitutionChoice::Bundled;
725 let raw = serde_json::to_string(&legacy).expect("serialize legacy-style state");
726 assert!(!raw.contains("operate_receipts_verified"), "{raw}");
727
728 let loaded: SetupState = serde_json::from_str(&raw).expect("load legacy-style state");
729
730 assert_eq!(loaded.status(SetupStep::OperateFleet), StepStatus::Verified);
731 assert!(!loaded.operate_receipts_verified);
732 assert!(!loaded.operate_ready());
733 }
734
735 #[test]
736 fn operate_ready_requires_verified_provider_not_needs_action() {
737 let mut state = SetupState::default();
738 state.set_step(
739 SetupStep::ProviderModel,
740 StepEntry::new(StepStatus::NeedsAction, true, "0.8.67"),
741 );
742 state.runtime_posture_source = RuntimePostureSource::Confirmed;
743 state.set_step(SetupStep::OperateFleet, verified("0.8.67"));
744
745 assert!(!state.operate_ready());
746 }
747
748 #[test]
749 fn needs_action_provider_still_reaches_ready() {
750 let mut state = SetupState::default();
751 state.set_step(SetupStep::Language, verified("0.8.67"));
752 state.set_step(
753 SetupStep::ProviderModel,
754 StepEntry::new(StepStatus::NeedsAction, true, "0.8.67"),
755 );
756 state.runtime_posture_source = RuntimePostureSource::Inherited;
757 state.constitution_choice = ConstitutionChoice::Deferred;
758 assert!(state.first_run_ready());
759 }
760
761 #[test]
762 fn deferred_constitution_counts_as_explicit_choice() {
763 assert!(ConstitutionChoice::Deferred.is_explicit());
764 assert!(ConstitutionChoice::Bundled.is_explicit());
765 assert!(!ConstitutionChoice::Unset.is_explicit());
766 }
767
768 #[test]
769 fn update_ready_tracks_checkpoint_version() {
770 let mut state = SetupState::default();
771 assert!(state.needs_constitution_checkpoint("0.8.67"));
772 state.complete_constitution_checkpoint("0.8.67", ConstitutionChoice::Bundled);
773 assert!(state.update_ready("0.8.67"));
774 assert!(!state.needs_constitution_checkpoint("0.8.67"));
775 assert!(state.needs_constitution_checkpoint("0.8.68"));
777 }
778
779 #[test]
780 fn derive_inherited_marks_existing_user_safe() {
781 let facts = InheritedConfigFacts {
782 has_provider_route: true,
783 has_credentials_or_local_runtime: true,
784 trust_chosen: true,
785 language: Some("en".to_string()),
786 has_user_constitution: false,
787 has_expert_override: false,
788 user_constitution_validity: ConstitutionValidity::Unknown,
789 };
790 let state = SetupState::derive_inherited(&facts);
791 assert!(state.inherited);
792 assert_eq!(state.status(SetupStep::Language), StepStatus::Verified);
793 assert_eq!(state.status(SetupStep::ProviderModel), StepStatus::Verified);
794 assert_eq!(state.status(SetupStep::TrustSandbox), StepStatus::Verified);
795 assert_eq!(state.constitution_source, ConstitutionSource::Bundled);
796 assert!(state.needs_constitution_checkpoint("0.8.67"));
798 }
799
800 #[test]
801 fn derive_inherited_classifies_provider_without_key_as_needs_action() {
802 let facts = InheritedConfigFacts {
803 has_provider_route: true,
804 has_credentials_or_local_runtime: false,
805 ..InheritedConfigFacts::default()
806 };
807 let state = SetupState::derive_inherited(&facts);
808 assert_eq!(
809 state.status(SetupStep::ProviderModel),
810 StepStatus::NeedsAction
811 );
812 }
813
814 #[test]
815 fn derive_inherited_picks_up_existing_user_constitution() {
816 let facts = InheritedConfigFacts {
817 has_user_constitution: true,
818 user_constitution_validity: ConstitutionValidity::Valid,
819 ..InheritedConfigFacts::default()
820 };
821 let state = SetupState::derive_inherited(&facts);
822 assert_eq!(state.constitution_source, ConstitutionSource::UserGlobal);
823 assert_eq!(state.constitution_choice, ConstitutionChoice::GuidedCustom);
824 assert_eq!(state.constitution_validity, ConstitutionValidity::Valid);
825 }
826
827 #[test]
828 fn round_trips_through_json_sidecar() {
829 let tmp = tempfile::tempdir().unwrap();
830 let path = tmp.path().join(SETUP_STATE_FILE_NAME);
831
832 let mut state = SetupState::default();
833 state.set_step(
834 SetupStep::ProviderModel,
835 verified("0.8.67").with_result("openai · mimo-ultraspeed"),
836 );
837 state.constitution_choice = ConstitutionChoice::GuidedCustom;
838 state.constitution_preview_version = 3;
839 state.save_to(&path).unwrap();
840
841 let loaded = SetupState::load_from(&path).expect("record should load");
842 assert_eq!(loaded, state);
843 let raw = std::fs::read_to_string(&path).unwrap();
845 assert!(raw.contains("\"provider_model\""), "{raw}");
846 assert!(raw.contains("openai · mimo-ultraspeed"));
847 }
848
849 #[test]
850 fn constitution_authoring_round_trips_and_stays_optional() {
851 let tmp = tempfile::tempdir().unwrap();
852 let path = tmp.path().join(SETUP_STATE_FILE_NAME);
853
854 let state = SetupState {
855 constitution_choice: ConstitutionChoice::GuidedCustom,
856 constitution_authoring: Some(ConstitutionAuthoring::ModelDrafted),
857 ..Default::default()
858 };
859 state.save_to(&path).unwrap();
860
861 let loaded = SetupState::load_from(&path).expect("record should load");
862 assert_eq!(
863 loaded.constitution_authoring,
864 Some(ConstitutionAuthoring::ModelDrafted)
865 );
866 let raw = std::fs::read_to_string(&path).unwrap();
867 assert!(raw.contains("\"model_drafted\""), "{raw}");
868 }
869
870 #[test]
871 fn record_without_authoring_field_still_loads() {
872 let tmp = tempfile::tempdir().unwrap();
875 let path = tmp.path().join(SETUP_STATE_FILE_NAME);
876 std::fs::write(
877 &path,
878 r#"{"schema_version":1,"constitution_choice":"guided_custom"}"#,
879 )
880 .unwrap();
881 let loaded = SetupState::load_from(&path).expect("legacy record should load");
882 assert_eq!(loaded.constitution_authoring, None);
883 assert_eq!(loaded.constitution_choice, ConstitutionChoice::GuidedCustom);
884 }
885
886 #[test]
887 fn corrupt_record_falls_back_to_none() {
888 let tmp = tempfile::tempdir().unwrap();
889 let path = tmp.path().join(SETUP_STATE_FILE_NAME);
890 std::fs::write(&path, "{ not valid json").unwrap();
891 assert!(SetupState::load_from(&path).is_none());
892 }
893
894 #[test]
895 fn missing_record_is_none_not_error() {
896 let tmp = tempfile::tempdir().unwrap();
897 let path = tmp.path().join("does-not-exist.json");
898 assert!(SetupState::load_from(&path).is_none());
899 }
900
901 #[test]
902 fn step_result_carries_no_secret_by_construction() {
903 let entry = verified("0.8.67").with_result("provider: openai, model: mimo");
906 let json = serde_json::to_string(&entry).unwrap();
907 assert!(!json.to_lowercase().contains("sk-"));
908 }
909}