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 = "3";
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case")]
53pub enum SetupStep {
54 Language,
56 ProviderModel,
58 TrustSandbox,
60 Constitution,
62 OperateFleet,
66 Hotbar,
68 ToolsMcp,
70 RemoteRuntime,
72 Persistence,
74 Verification,
76}
77
78impl SetupStep {
79 pub const ALL: [SetupStep; 10] = [
81 SetupStep::Language,
82 SetupStep::ProviderModel,
83 SetupStep::TrustSandbox,
84 SetupStep::Constitution,
85 SetupStep::OperateFleet,
86 SetupStep::Hotbar,
87 SetupStep::ToolsMcp,
88 SetupStep::RemoteRuntime,
89 SetupStep::Persistence,
90 SetupStep::Verification,
91 ];
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
97#[serde(rename_all = "snake_case")]
98pub enum StepStatus {
99 NotStarted,
101 Recommended,
103 Optional,
105 Deferred,
107 InProgress,
109 Verified,
111 NeedsAction,
114 Failed,
116 Skipped,
118}
119
120impl StepStatus {
121 #[must_use]
124 pub fn is_settled(self) -> bool {
125 matches!(
126 self,
127 StepStatus::Verified
128 | StepStatus::NeedsAction
129 | StepStatus::Deferred
130 | StepStatus::Optional
131 | StepStatus::Skipped
132 )
133 }
134}
135
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
138pub struct StepEntry {
139 pub status: StepStatus,
140 #[serde(default)]
143 pub required: bool,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub result: Option<String>,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub version: Option<String>,
152}
153
154impl StepEntry {
155 #[must_use]
157 pub fn new(status: StepStatus, required: bool, version: impl Into<String>) -> Self {
158 Self {
159 status,
160 required,
161 result: None,
162 version: Some(version.into()),
163 }
164 }
165
166 #[must_use]
167 pub fn with_result(mut self, result: impl Into<String>) -> Self {
168 self.result = Some(result.into());
169 self
170 }
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
178#[serde(rename_all = "snake_case")]
179pub enum ConstitutionChoice {
180 #[default]
182 Unset,
183 Bundled,
185 GuidedCustom,
187 ExpertOverride,
190 Deferred,
192}
193
194impl ConstitutionChoice {
195 #[must_use]
197 pub fn is_explicit(self) -> bool {
198 !matches!(self, ConstitutionChoice::Unset)
199 }
200}
201
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
211#[serde(rename_all = "snake_case")]
212pub enum ConstitutionAuthoring {
213 Guided,
215 ModelDrafted,
219}
220
221#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
223#[serde(rename_all = "snake_case")]
224pub enum ConstitutionSource {
225 #[default]
227 Bundled,
228 UserGlobal,
230 ExpertOverride,
232}
233
234#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
236#[serde(rename_all = "snake_case")]
237pub enum ConstitutionValidity {
238 #[default]
240 Unknown,
241 Valid,
243 Invalid,
245 Empty,
247 Unreadable,
249}
250
251#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
255#[serde(rename_all = "snake_case")]
256pub enum RuntimePostureSource {
257 #[default]
259 Unset,
260 Inherited,
262 Confirmed,
264}
265
266impl RuntimePostureSource {
267 #[must_use]
270 pub fn is_reviewed(self) -> bool {
271 matches!(
272 self,
273 RuntimePostureSource::Inherited | RuntimePostureSource::Confirmed
274 )
275 }
276}
277
278#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
280pub struct SetupState {
281 pub schema_version: u32,
282
283 #[serde(default)]
285 pub steps: BTreeMap<SetupStep, StepEntry>,
286
287 #[serde(default)]
290 pub constitution_choice: ConstitutionChoice,
291 #[serde(default, skip_serializing_if = "Option::is_none")]
294 pub constitution_checkpoint_completed_for: Option<String>,
295 #[serde(default, skip_serializing_if = "Option::is_none")]
297 pub constitution_language: Option<String>,
298 #[serde(default)]
300 pub constitution_source: ConstitutionSource,
301 #[serde(default)]
303 pub constitution_validity: ConstitutionValidity,
304 #[serde(default, skip_serializing_if = "Option::is_none")]
307 pub constitution_authoring: Option<ConstitutionAuthoring>,
308 #[serde(default, skip_serializing_if = "Option::is_none")]
311 pub constitution_preview_hash: Option<String>,
312 #[serde(default)]
315 pub constitution_preview_version: u32,
316 #[serde(default)]
318 pub runtime_posture_source: RuntimePostureSource,
319
320 #[serde(default, skip_serializing_if = "is_false")]
324 pub operate_receipts_verified: bool,
325
326 #[serde(default, skip_serializing_if = "is_false")]
330 pub inherited: bool,
331
332 #[serde(default, skip_serializing_if = "Option::is_none")]
347 pub telemetry_notice_decided_for: Option<String>,
348 #[serde(default, skip_serializing_if = "is_false")]
352 pub telemetry_opt_in: bool,
353}
354
355#[allow(clippy::trivially_copy_pass_by_ref)]
356fn is_false(b: &bool) -> bool {
357 !*b
358}
359
360impl Default for SetupState {
361 fn default() -> Self {
362 Self {
363 schema_version: SETUP_STATE_SCHEMA_VERSION,
364 steps: BTreeMap::new(),
365 constitution_choice: ConstitutionChoice::default(),
366 constitution_checkpoint_completed_for: None,
367 constitution_language: None,
368 constitution_source: ConstitutionSource::default(),
369 constitution_validity: ConstitutionValidity::default(),
370 constitution_authoring: None,
371 constitution_preview_hash: None,
372 constitution_preview_version: 0,
373 runtime_posture_source: RuntimePostureSource::default(),
374 operate_receipts_verified: false,
375 inherited: false,
376 telemetry_notice_decided_for: None,
377 telemetry_opt_in: false,
378 }
379 }
380}
381
382#[derive(Debug, Clone, Default)]
389pub struct InheritedConfigFacts {
390 pub has_provider_route: bool,
392 pub has_credentials_or_local_runtime: bool,
394 pub trust_chosen: bool,
396 pub language: Option<String>,
398 pub has_user_constitution: bool,
400 pub has_expert_override: bool,
402 pub user_constitution_validity: ConstitutionValidity,
404}
405
406impl SetupState {
407 #[must_use]
409 pub fn status(&self, step: SetupStep) -> StepStatus {
410 self.steps
411 .get(&step)
412 .map_or(StepStatus::NotStarted, |e| e.status)
413 }
414
415 pub fn set_step(&mut self, step: SetupStep, entry: StepEntry) -> &mut Self {
417 self.steps.insert(step, entry);
418 self
419 }
420
421 #[must_use]
422 fn step_verified(&self, step: SetupStep) -> bool {
423 self.status(step) == StepStatus::Verified
424 }
425
426 #[must_use]
430 fn provider_model_ready_or_needs_action(&self) -> bool {
431 matches!(
432 self.status(SetupStep::ProviderModel),
433 StepStatus::Verified | StepStatus::NeedsAction
434 )
435 }
436
437 #[must_use]
440 pub fn first_run_ready(&self) -> bool {
441 self.step_verified(SetupStep::Language)
442 && self.provider_model_ready_or_needs_action()
443 && self.runtime_posture_source.is_reviewed()
444 && self.constitution_choice.is_explicit()
445 }
446
447 #[must_use]
453 pub fn operate_ready(&self) -> bool {
454 self.first_run_ready()
455 && self.step_verified(SetupStep::ProviderModel)
456 && self.step_verified(SetupStep::OperateFleet)
457 && self.operate_receipts_verified
458 }
459
460 #[must_use]
463 pub fn update_ready(&self, version: &str) -> bool {
464 self.constitution_checkpoint_completed_for.as_deref() == Some(version)
465 }
466
467 #[must_use]
469 pub fn needs_constitution_checkpoint(&self, version: &str) -> bool {
470 !self.update_ready(version)
471 }
472
473 pub fn complete_constitution_checkpoint(
476 &mut self,
477 version: impl Into<String>,
478 choice: ConstitutionChoice,
479 ) -> &mut Self {
480 self.constitution_checkpoint_completed_for = Some(version.into());
481 self.constitution_choice = choice;
482 self
483 }
484
485 #[must_use]
490 pub fn needs_telemetry_notice(&self, version: &str) -> bool {
491 self.telemetry_notice_decided_for.as_deref() != Some(version)
492 }
493
494 pub fn record_telemetry_notice(
500 &mut self,
501 version: impl Into<String>,
502 opt_in: bool,
503 ) -> &mut Self {
504 self.telemetry_notice_decided_for = Some(version.into());
505 self.telemetry_opt_in = opt_in;
506 self
507 }
508
509 #[must_use]
511 pub fn telemetry_accepted(&self, version: &str) -> bool {
512 !self.needs_telemetry_notice(version) && self.telemetry_opt_in
513 }
514
515 #[must_use]
520 pub fn telemetry_declined(&self, version: &str) -> bool {
521 !self.needs_telemetry_notice(version) && !self.telemetry_opt_in
522 }
523
524 #[must_use]
530 pub fn telemetry_opted_out(&self) -> bool {
531 self.telemetry_notice_decided_for.is_some() && !self.telemetry_opt_in
532 }
533
534 #[must_use]
540 pub fn derive_inherited(facts: &InheritedConfigFacts) -> Self {
541 let mut state = SetupState {
542 inherited: true,
543 ..SetupState::default()
544 };
545 let inherited = "inherited";
546
547 if facts.language.is_some() {
548 state.set_step(
549 SetupStep::Language,
550 StepEntry::new(StepStatus::Verified, true, inherited),
551 );
552 state.constitution_language = facts.language.clone();
553 }
554
555 if facts.has_provider_route && facts.has_credentials_or_local_runtime {
556 state.set_step(
557 SetupStep::ProviderModel,
558 StepEntry::new(StepStatus::Verified, true, inherited),
559 );
560 } else if facts.has_provider_route {
561 state.set_step(
562 SetupStep::ProviderModel,
563 StepEntry::new(StepStatus::NeedsAction, true, inherited),
564 );
565 }
566
567 if facts.trust_chosen {
568 state.set_step(
569 SetupStep::TrustSandbox,
570 StepEntry::new(StepStatus::Verified, true, inherited),
571 );
572 state.runtime_posture_source = RuntimePostureSource::Inherited;
573 }
574
575 if facts.has_expert_override {
578 state.constitution_source = ConstitutionSource::ExpertOverride;
579 state.constitution_choice = ConstitutionChoice::ExpertOverride;
580 } else if facts.has_user_constitution {
581 state.constitution_source = ConstitutionSource::UserGlobal;
582 state.constitution_validity = facts.user_constitution_validity;
583 if facts.user_constitution_validity == ConstitutionValidity::Valid {
584 state.constitution_choice = ConstitutionChoice::GuidedCustom;
585 }
586 } else {
587 state.constitution_source = ConstitutionSource::Bundled;
588 }
589
590 state
591 }
592
593 pub fn path() -> Result<PathBuf> {
595 Ok(crate::codewhale_home()?.join(SETUP_STATE_FILE_NAME))
596 }
597
598 pub fn load() -> Result<Option<Self>> {
604 Ok(Self::load_from(&Self::path()?))
605 }
606
607 #[must_use]
610 pub fn load_from(path: &Path) -> Option<Self> {
611 let raw = match std::fs::read_to_string(path) {
612 Ok(raw) => raw,
613 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
614 Err(e) => {
615 tracing::warn!(
616 target: "config::setup_state",
617 "could not read {} ({e}); deriving status from existing config",
618 path.display()
619 );
620 return None;
621 }
622 };
623 match serde_json::from_str::<SetupState>(&raw) {
624 Ok(state) => Some(state),
625 Err(e) => {
626 tracing::warn!(
627 target: "config::setup_state",
628 "{} is not a valid setup-state record ({e}); deriving status from existing config",
629 path.display()
630 );
631 None
632 }
633 }
634 }
635
636 pub fn save(&self) -> Result<()> {
638 let path = Self::path()?;
639 self.save_to(&path)
640 }
641
642 pub fn save_to(&self, path: &Path) -> Result<()> {
644 persistence::atomic_write_json(path, self)
645 .with_context(|| format!("failed to persist setup state to {}", path.display()))
646 }
647}
648
649#[cfg(test)]
650mod tests {
651 use super::*;
652
653 fn verified(version: &str) -> StepEntry {
654 StepEntry::new(StepStatus::Verified, true, version)
655 }
656
657 #[test]
658 fn default_is_not_first_run_ready() {
659 let state = SetupState::default();
660 assert!(!state.first_run_ready());
661 assert_eq!(state.constitution_choice, ConstitutionChoice::Unset);
662 }
663
664 #[test]
665 fn persistence_is_optional_before_verification() {
666 let persistence_index = SetupStep::ALL
667 .iter()
668 .position(|step| *step == SetupStep::Persistence)
669 .expect("persistence step");
670 let verification_index = SetupStep::ALL
671 .iter()
672 .position(|step| *step == SetupStep::Verification)
673 .expect("verification step");
674
675 assert!(persistence_index < verification_index);
676
677 let mut state = SetupState::default();
678 state.set_step(SetupStep::Language, verified("0.8.67"));
679 state.set_step(SetupStep::ProviderModel, verified("0.8.67"));
680 state.runtime_posture_source = RuntimePostureSource::Confirmed;
681 state.constitution_choice = ConstitutionChoice::Bundled;
682 assert!(state.first_run_ready());
683
684 state.set_step(
685 SetupStep::Persistence,
686 StepEntry::new(StepStatus::NeedsAction, false, "0.8.67"),
687 );
688 assert!(state.first_run_ready());
689 assert!(!state.operate_ready());
690 }
691
692 #[test]
693 fn first_run_ready_requires_all_pillars() {
694 let mut state = SetupState::default();
695 state.set_step(SetupStep::Language, verified("0.8.67"));
696 state.set_step(SetupStep::ProviderModel, verified("0.8.67"));
697 state.runtime_posture_source = RuntimePostureSource::Confirmed;
698 assert!(!state.first_run_ready());
700 state.constitution_choice = ConstitutionChoice::Bundled;
701 assert!(state.first_run_ready());
702 }
703
704 #[test]
705 fn operate_ready_is_separate_from_first_run_ready() {
706 let mut state = SetupState::default();
707 state.set_step(SetupStep::Language, verified("0.8.67"));
708 state.set_step(SetupStep::ProviderModel, verified("0.8.67"));
709 state.runtime_posture_source = RuntimePostureSource::Confirmed;
710 state.constitution_choice = ConstitutionChoice::Bundled;
711 assert!(state.first_run_ready());
712 assert!(!state.operate_ready());
713
714 state.set_step(SetupStep::OperateFleet, verified("0.8.67"));
715 assert!(
716 !state.operate_ready(),
717 "a legacy Verified card is not receipt proof"
718 );
719 state.operate_receipts_verified = true;
720 assert!(state.operate_ready());
721 }
722
723 #[test]
724 fn legacy_verified_operate_card_without_receipt_proof_fails_closed() {
725 let mut legacy = SetupState::default();
726 legacy.set_step(SetupStep::Language, verified("0.8.67"));
727 legacy.set_step(SetupStep::ProviderModel, verified("0.8.67"));
728 legacy.set_step(SetupStep::OperateFleet, verified("0.8.67"));
729 legacy.runtime_posture_source = RuntimePostureSource::Confirmed;
730 legacy.constitution_choice = ConstitutionChoice::Bundled;
731 let raw = serde_json::to_string(&legacy).expect("serialize legacy-style state");
732 assert!(!raw.contains("operate_receipts_verified"), "{raw}");
733
734 let loaded: SetupState = serde_json::from_str(&raw).expect("load legacy-style state");
735
736 assert_eq!(loaded.status(SetupStep::OperateFleet), StepStatus::Verified);
737 assert!(!loaded.operate_receipts_verified);
738 assert!(!loaded.operate_ready());
739 }
740
741 #[test]
742 fn operate_ready_requires_verified_provider_not_needs_action() {
743 let mut state = SetupState::default();
744 state.set_step(
745 SetupStep::ProviderModel,
746 StepEntry::new(StepStatus::NeedsAction, true, "0.8.67"),
747 );
748 state.runtime_posture_source = RuntimePostureSource::Confirmed;
749 state.set_step(SetupStep::OperateFleet, verified("0.8.67"));
750
751 assert!(!state.operate_ready());
752 }
753
754 #[test]
755 fn needs_action_provider_still_reaches_ready() {
756 let mut state = SetupState::default();
757 state.set_step(SetupStep::Language, verified("0.8.67"));
758 state.set_step(
759 SetupStep::ProviderModel,
760 StepEntry::new(StepStatus::NeedsAction, true, "0.8.67"),
761 );
762 state.runtime_posture_source = RuntimePostureSource::Inherited;
763 state.constitution_choice = ConstitutionChoice::Deferred;
764 assert!(state.first_run_ready());
765 }
766
767 #[test]
768 fn deferred_constitution_counts_as_explicit_choice() {
769 assert!(ConstitutionChoice::Deferred.is_explicit());
770 assert!(ConstitutionChoice::Bundled.is_explicit());
771 assert!(!ConstitutionChoice::Unset.is_explicit());
772 }
773
774 #[test]
775 fn update_ready_tracks_checkpoint_version() {
776 let mut state = SetupState::default();
777 assert!(state.needs_constitution_checkpoint("0.8.67"));
778 state.complete_constitution_checkpoint("0.8.67", ConstitutionChoice::Bundled);
779 assert!(state.update_ready("0.8.67"));
780 assert!(!state.needs_constitution_checkpoint("0.8.67"));
781 assert!(state.needs_constitution_checkpoint("0.8.68"));
783 }
784
785 #[test]
786 fn derive_inherited_marks_existing_user_safe() {
787 let facts = InheritedConfigFacts {
788 has_provider_route: true,
789 has_credentials_or_local_runtime: true,
790 trust_chosen: true,
791 language: Some("en".to_string()),
792 has_user_constitution: false,
793 has_expert_override: false,
794 user_constitution_validity: ConstitutionValidity::Unknown,
795 };
796 let state = SetupState::derive_inherited(&facts);
797 assert!(state.inherited);
798 assert_eq!(state.status(SetupStep::Language), StepStatus::Verified);
799 assert_eq!(state.status(SetupStep::ProviderModel), StepStatus::Verified);
800 assert_eq!(state.status(SetupStep::TrustSandbox), StepStatus::Verified);
801 assert_eq!(state.constitution_source, ConstitutionSource::Bundled);
802 assert!(state.needs_constitution_checkpoint("0.8.67"));
804 }
805
806 #[test]
807 fn derive_inherited_classifies_provider_without_key_as_needs_action() {
808 let facts = InheritedConfigFacts {
809 has_provider_route: true,
810 has_credentials_or_local_runtime: false,
811 ..InheritedConfigFacts::default()
812 };
813 let state = SetupState::derive_inherited(&facts);
814 assert_eq!(
815 state.status(SetupStep::ProviderModel),
816 StepStatus::NeedsAction
817 );
818 }
819
820 #[test]
821 fn derive_inherited_picks_up_existing_user_constitution() {
822 let facts = InheritedConfigFacts {
823 has_user_constitution: true,
824 user_constitution_validity: ConstitutionValidity::Valid,
825 ..InheritedConfigFacts::default()
826 };
827 let state = SetupState::derive_inherited(&facts);
828 assert_eq!(state.constitution_source, ConstitutionSource::UserGlobal);
829 assert_eq!(state.constitution_choice, ConstitutionChoice::GuidedCustom);
830 assert_eq!(state.constitution_validity, ConstitutionValidity::Valid);
831 }
832
833 #[test]
834 fn round_trips_through_json_sidecar() {
835 let tmp = tempfile::tempdir().unwrap();
836 let path = tmp.path().join(SETUP_STATE_FILE_NAME);
837
838 let mut state = SetupState::default();
839 state.set_step(
840 SetupStep::ProviderModel,
841 verified("0.8.67").with_result("openai · mimo-ultraspeed"),
842 );
843 state.constitution_choice = ConstitutionChoice::GuidedCustom;
844 state.constitution_preview_version = 3;
845 state.save_to(&path).unwrap();
846
847 let loaded = SetupState::load_from(&path).expect("record should load");
848 assert_eq!(loaded, state);
849 let raw = std::fs::read_to_string(&path).unwrap();
851 assert!(raw.contains("\"provider_model\""), "{raw}");
852 assert!(raw.contains("openai · mimo-ultraspeed"));
853 }
854
855 #[test]
856 fn constitution_authoring_round_trips_and_stays_optional() {
857 let tmp = tempfile::tempdir().unwrap();
858 let path = tmp.path().join(SETUP_STATE_FILE_NAME);
859
860 let state = SetupState {
861 constitution_choice: ConstitutionChoice::GuidedCustom,
862 constitution_authoring: Some(ConstitutionAuthoring::ModelDrafted),
863 ..Default::default()
864 };
865 state.save_to(&path).unwrap();
866
867 let loaded = SetupState::load_from(&path).expect("record should load");
868 assert_eq!(
869 loaded.constitution_authoring,
870 Some(ConstitutionAuthoring::ModelDrafted)
871 );
872 let raw = std::fs::read_to_string(&path).unwrap();
873 assert!(raw.contains("\"model_drafted\""), "{raw}");
874 }
875
876 #[test]
877 fn record_without_authoring_field_still_loads() {
878 let tmp = tempfile::tempdir().unwrap();
881 let path = tmp.path().join(SETUP_STATE_FILE_NAME);
882 std::fs::write(
883 &path,
884 r#"{"schema_version":1,"constitution_choice":"guided_custom"}"#,
885 )
886 .unwrap();
887 let loaded = SetupState::load_from(&path).expect("legacy record should load");
888 assert_eq!(loaded.constitution_authoring, None);
889 assert_eq!(loaded.constitution_choice, ConstitutionChoice::GuidedCustom);
890 }
891
892 #[test]
893 fn corrupt_record_falls_back_to_none() {
894 let tmp = tempfile::tempdir().unwrap();
895 let path = tmp.path().join(SETUP_STATE_FILE_NAME);
896 std::fs::write(&path, "{ not valid json").unwrap();
897 assert!(SetupState::load_from(&path).is_none());
898 }
899
900 #[test]
901 fn missing_record_is_none_not_error() {
902 let tmp = tempfile::tempdir().unwrap();
903 let path = tmp.path().join("does-not-exist.json");
904 assert!(SetupState::load_from(&path).is_none());
905 }
906
907 #[test]
908 fn step_result_carries_no_secret_by_construction() {
909 let entry = verified("0.8.67").with_result("provider: openai, model: mimo");
912 let json = serde_json::to_string(&entry).unwrap();
913 assert!(!json.to_lowercase().contains("sk-"));
914 }
915}