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 inherited: bool,
315}
316
317#[allow(clippy::trivially_copy_pass_by_ref)]
318fn is_false(b: &bool) -> bool {
319 !*b
320}
321
322impl Default for SetupState {
323 fn default() -> Self {
324 Self {
325 schema_version: SETUP_STATE_SCHEMA_VERSION,
326 steps: BTreeMap::new(),
327 constitution_choice: ConstitutionChoice::default(),
328 constitution_checkpoint_completed_for: None,
329 constitution_language: None,
330 constitution_source: ConstitutionSource::default(),
331 constitution_validity: ConstitutionValidity::default(),
332 constitution_authoring: None,
333 constitution_preview_hash: None,
334 constitution_preview_version: 0,
335 runtime_posture_source: RuntimePostureSource::default(),
336 inherited: false,
337 }
338 }
339}
340
341#[derive(Debug, Clone, Default)]
348pub struct InheritedConfigFacts {
349 pub has_provider_route: bool,
351 pub has_credentials_or_local_runtime: bool,
353 pub trust_chosen: bool,
355 pub language: Option<String>,
357 pub has_user_constitution: bool,
359 pub has_expert_override: bool,
361 pub user_constitution_validity: ConstitutionValidity,
363}
364
365impl SetupState {
366 #[must_use]
368 pub fn status(&self, step: SetupStep) -> StepStatus {
369 self.steps
370 .get(&step)
371 .map_or(StepStatus::NotStarted, |e| e.status)
372 }
373
374 pub fn set_step(&mut self, step: SetupStep, entry: StepEntry) -> &mut Self {
376 self.steps.insert(step, entry);
377 self
378 }
379
380 #[must_use]
381 fn step_verified(&self, step: SetupStep) -> bool {
382 self.status(step) == StepStatus::Verified
383 }
384
385 #[must_use]
389 fn provider_model_ready_or_needs_action(&self) -> bool {
390 matches!(
391 self.status(SetupStep::ProviderModel),
392 StepStatus::Verified | StepStatus::NeedsAction
393 )
394 }
395
396 #[must_use]
399 pub fn first_run_ready(&self) -> bool {
400 self.step_verified(SetupStep::Language)
401 && self.provider_model_ready_or_needs_action()
402 && self.runtime_posture_source.is_reviewed()
403 && self.constitution_choice.is_explicit()
404 }
405
406 #[must_use]
412 pub fn operate_ready(&self) -> bool {
413 self.first_run_ready()
414 && self.step_verified(SetupStep::ProviderModel)
415 && self.step_verified(SetupStep::OperateFleet)
416 }
417
418 #[must_use]
421 pub fn update_ready(&self, version: &str) -> bool {
422 self.constitution_checkpoint_completed_for.as_deref() == Some(version)
423 }
424
425 #[must_use]
427 pub fn needs_constitution_checkpoint(&self, version: &str) -> bool {
428 !self.update_ready(version)
429 }
430
431 pub fn complete_constitution_checkpoint(
434 &mut self,
435 version: impl Into<String>,
436 choice: ConstitutionChoice,
437 ) -> &mut Self {
438 self.constitution_checkpoint_completed_for = Some(version.into());
439 self.constitution_choice = choice;
440 self
441 }
442
443 #[must_use]
449 pub fn derive_inherited(facts: &InheritedConfigFacts) -> Self {
450 let mut state = SetupState {
451 inherited: true,
452 ..SetupState::default()
453 };
454 let inherited = "inherited";
455
456 if facts.language.is_some() {
457 state.set_step(
458 SetupStep::Language,
459 StepEntry::new(StepStatus::Verified, true, inherited),
460 );
461 state.constitution_language = facts.language.clone();
462 }
463
464 if facts.has_provider_route && facts.has_credentials_or_local_runtime {
465 state.set_step(
466 SetupStep::ProviderModel,
467 StepEntry::new(StepStatus::Verified, true, inherited),
468 );
469 } else if facts.has_provider_route {
470 state.set_step(
471 SetupStep::ProviderModel,
472 StepEntry::new(StepStatus::NeedsAction, true, inherited),
473 );
474 }
475
476 if facts.trust_chosen {
477 state.set_step(
478 SetupStep::TrustSandbox,
479 StepEntry::new(StepStatus::Verified, true, inherited),
480 );
481 state.runtime_posture_source = RuntimePostureSource::Inherited;
482 }
483
484 if facts.has_expert_override {
487 state.constitution_source = ConstitutionSource::ExpertOverride;
488 state.constitution_choice = ConstitutionChoice::ExpertOverride;
489 } else if facts.has_user_constitution {
490 state.constitution_source = ConstitutionSource::UserGlobal;
491 state.constitution_validity = facts.user_constitution_validity;
492 if facts.user_constitution_validity == ConstitutionValidity::Valid {
493 state.constitution_choice = ConstitutionChoice::GuidedCustom;
494 }
495 } else {
496 state.constitution_source = ConstitutionSource::Bundled;
497 }
498
499 state
500 }
501
502 pub fn path() -> Result<PathBuf> {
504 Ok(crate::codewhale_home()?.join(SETUP_STATE_FILE_NAME))
505 }
506
507 pub fn load() -> Result<Option<Self>> {
513 Ok(Self::load_from(&Self::path()?))
514 }
515
516 #[must_use]
519 pub fn load_from(path: &Path) -> Option<Self> {
520 let raw = match std::fs::read_to_string(path) {
521 Ok(raw) => raw,
522 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
523 Err(e) => {
524 tracing::warn!(
525 target: "config::setup_state",
526 "could not read {} ({e}); deriving status from existing config",
527 path.display()
528 );
529 return None;
530 }
531 };
532 match serde_json::from_str::<SetupState>(&raw) {
533 Ok(state) => Some(state),
534 Err(e) => {
535 tracing::warn!(
536 target: "config::setup_state",
537 "{} is not a valid setup-state record ({e}); deriving status from existing config",
538 path.display()
539 );
540 None
541 }
542 }
543 }
544
545 pub fn save(&self) -> Result<()> {
547 let path = Self::path()?;
548 self.save_to(&path)
549 }
550
551 pub fn save_to(&self, path: &Path) -> Result<()> {
553 persistence::atomic_write_json(path, self)
554 .with_context(|| format!("failed to persist setup state to {}", path.display()))
555 }
556}
557
558#[cfg(test)]
559mod tests {
560 use super::*;
561
562 fn verified(version: &str) -> StepEntry {
563 StepEntry::new(StepStatus::Verified, true, version)
564 }
565
566 #[test]
567 fn default_is_not_first_run_ready() {
568 let state = SetupState::default();
569 assert!(!state.first_run_ready());
570 assert_eq!(state.constitution_choice, ConstitutionChoice::Unset);
571 }
572
573 #[test]
574 fn persistence_is_optional_before_verification() {
575 let persistence_index = SetupStep::ALL
576 .iter()
577 .position(|step| *step == SetupStep::Persistence)
578 .expect("persistence step");
579 let verification_index = SetupStep::ALL
580 .iter()
581 .position(|step| *step == SetupStep::Verification)
582 .expect("verification step");
583
584 assert!(persistence_index < verification_index);
585
586 let mut state = SetupState::default();
587 state.set_step(SetupStep::Language, verified("0.8.67"));
588 state.set_step(SetupStep::ProviderModel, verified("0.8.67"));
589 state.runtime_posture_source = RuntimePostureSource::Confirmed;
590 state.constitution_choice = ConstitutionChoice::Bundled;
591 assert!(state.first_run_ready());
592
593 state.set_step(
594 SetupStep::Persistence,
595 StepEntry::new(StepStatus::NeedsAction, false, "0.8.67"),
596 );
597 assert!(state.first_run_ready());
598 assert!(!state.operate_ready());
599 }
600
601 #[test]
602 fn first_run_ready_requires_all_pillars() {
603 let mut state = SetupState::default();
604 state.set_step(SetupStep::Language, verified("0.8.67"));
605 state.set_step(SetupStep::ProviderModel, verified("0.8.67"));
606 state.runtime_posture_source = RuntimePostureSource::Confirmed;
607 assert!(!state.first_run_ready());
609 state.constitution_choice = ConstitutionChoice::Bundled;
610 assert!(state.first_run_ready());
611 }
612
613 #[test]
614 fn operate_ready_is_separate_from_first_run_ready() {
615 let mut state = SetupState::default();
616 state.set_step(SetupStep::Language, verified("0.8.67"));
617 state.set_step(SetupStep::ProviderModel, verified("0.8.67"));
618 state.runtime_posture_source = RuntimePostureSource::Confirmed;
619 state.constitution_choice = ConstitutionChoice::Bundled;
620 assert!(state.first_run_ready());
621 assert!(!state.operate_ready());
622
623 state.set_step(SetupStep::OperateFleet, verified("0.8.67"));
624 assert!(state.operate_ready());
625 }
626
627 #[test]
628 fn operate_ready_requires_verified_provider_not_needs_action() {
629 let mut state = SetupState::default();
630 state.set_step(
631 SetupStep::ProviderModel,
632 StepEntry::new(StepStatus::NeedsAction, true, "0.8.67"),
633 );
634 state.runtime_posture_source = RuntimePostureSource::Confirmed;
635 state.set_step(SetupStep::OperateFleet, verified("0.8.67"));
636
637 assert!(!state.operate_ready());
638 }
639
640 #[test]
641 fn needs_action_provider_still_reaches_ready() {
642 let mut state = SetupState::default();
643 state.set_step(SetupStep::Language, verified("0.8.67"));
644 state.set_step(
645 SetupStep::ProviderModel,
646 StepEntry::new(StepStatus::NeedsAction, true, "0.8.67"),
647 );
648 state.runtime_posture_source = RuntimePostureSource::Inherited;
649 state.constitution_choice = ConstitutionChoice::Deferred;
650 assert!(state.first_run_ready());
651 }
652
653 #[test]
654 fn deferred_constitution_counts_as_explicit_choice() {
655 assert!(ConstitutionChoice::Deferred.is_explicit());
656 assert!(ConstitutionChoice::Bundled.is_explicit());
657 assert!(!ConstitutionChoice::Unset.is_explicit());
658 }
659
660 #[test]
661 fn update_ready_tracks_checkpoint_version() {
662 let mut state = SetupState::default();
663 assert!(state.needs_constitution_checkpoint("0.8.67"));
664 state.complete_constitution_checkpoint("0.8.67", ConstitutionChoice::Bundled);
665 assert!(state.update_ready("0.8.67"));
666 assert!(!state.needs_constitution_checkpoint("0.8.67"));
667 assert!(state.needs_constitution_checkpoint("0.8.68"));
669 }
670
671 #[test]
672 fn derive_inherited_marks_existing_user_safe() {
673 let facts = InheritedConfigFacts {
674 has_provider_route: true,
675 has_credentials_or_local_runtime: true,
676 trust_chosen: true,
677 language: Some("en".to_string()),
678 has_user_constitution: false,
679 has_expert_override: false,
680 user_constitution_validity: ConstitutionValidity::Unknown,
681 };
682 let state = SetupState::derive_inherited(&facts);
683 assert!(state.inherited);
684 assert_eq!(state.status(SetupStep::Language), StepStatus::Verified);
685 assert_eq!(state.status(SetupStep::ProviderModel), StepStatus::Verified);
686 assert_eq!(state.status(SetupStep::TrustSandbox), StepStatus::Verified);
687 assert_eq!(state.constitution_source, ConstitutionSource::Bundled);
688 assert!(state.needs_constitution_checkpoint("0.8.67"));
690 }
691
692 #[test]
693 fn derive_inherited_classifies_provider_without_key_as_needs_action() {
694 let facts = InheritedConfigFacts {
695 has_provider_route: true,
696 has_credentials_or_local_runtime: false,
697 ..InheritedConfigFacts::default()
698 };
699 let state = SetupState::derive_inherited(&facts);
700 assert_eq!(
701 state.status(SetupStep::ProviderModel),
702 StepStatus::NeedsAction
703 );
704 }
705
706 #[test]
707 fn derive_inherited_picks_up_existing_user_constitution() {
708 let facts = InheritedConfigFacts {
709 has_user_constitution: true,
710 user_constitution_validity: ConstitutionValidity::Valid,
711 ..InheritedConfigFacts::default()
712 };
713 let state = SetupState::derive_inherited(&facts);
714 assert_eq!(state.constitution_source, ConstitutionSource::UserGlobal);
715 assert_eq!(state.constitution_choice, ConstitutionChoice::GuidedCustom);
716 assert_eq!(state.constitution_validity, ConstitutionValidity::Valid);
717 }
718
719 #[test]
720 fn round_trips_through_json_sidecar() {
721 let tmp = tempfile::tempdir().unwrap();
722 let path = tmp.path().join(SETUP_STATE_FILE_NAME);
723
724 let mut state = SetupState::default();
725 state.set_step(
726 SetupStep::ProviderModel,
727 verified("0.8.67").with_result("openai · mimo-ultraspeed"),
728 );
729 state.constitution_choice = ConstitutionChoice::GuidedCustom;
730 state.constitution_preview_version = 3;
731 state.save_to(&path).unwrap();
732
733 let loaded = SetupState::load_from(&path).expect("record should load");
734 assert_eq!(loaded, state);
735 let raw = std::fs::read_to_string(&path).unwrap();
737 assert!(raw.contains("\"provider_model\""), "{raw}");
738 assert!(raw.contains("openai · mimo-ultraspeed"));
739 }
740
741 #[test]
742 fn constitution_authoring_round_trips_and_stays_optional() {
743 let tmp = tempfile::tempdir().unwrap();
744 let path = tmp.path().join(SETUP_STATE_FILE_NAME);
745
746 let state = SetupState {
747 constitution_choice: ConstitutionChoice::GuidedCustom,
748 constitution_authoring: Some(ConstitutionAuthoring::ModelDrafted),
749 ..Default::default()
750 };
751 state.save_to(&path).unwrap();
752
753 let loaded = SetupState::load_from(&path).expect("record should load");
754 assert_eq!(
755 loaded.constitution_authoring,
756 Some(ConstitutionAuthoring::ModelDrafted)
757 );
758 let raw = std::fs::read_to_string(&path).unwrap();
759 assert!(raw.contains("\"model_drafted\""), "{raw}");
760 }
761
762 #[test]
763 fn record_without_authoring_field_still_loads() {
764 let tmp = tempfile::tempdir().unwrap();
767 let path = tmp.path().join(SETUP_STATE_FILE_NAME);
768 std::fs::write(
769 &path,
770 r#"{"schema_version":1,"constitution_choice":"guided_custom"}"#,
771 )
772 .unwrap();
773 let loaded = SetupState::load_from(&path).expect("legacy record should load");
774 assert_eq!(loaded.constitution_authoring, None);
775 assert_eq!(loaded.constitution_choice, ConstitutionChoice::GuidedCustom);
776 }
777
778 #[test]
779 fn corrupt_record_falls_back_to_none() {
780 let tmp = tempfile::tempdir().unwrap();
781 let path = tmp.path().join(SETUP_STATE_FILE_NAME);
782 std::fs::write(&path, "{ not valid json").unwrap();
783 assert!(SetupState::load_from(&path).is_none());
784 }
785
786 #[test]
787 fn missing_record_is_none_not_error() {
788 let tmp = tempfile::tempdir().unwrap();
789 let path = tmp.path().join("does-not-exist.json");
790 assert!(SetupState::load_from(&path).is_none());
791 }
792
793 #[test]
794 fn step_result_carries_no_secret_by_construction() {
795 let entry = verified("0.8.67").with_result("provider: openai, model: mimo");
798 let json = serde_json::to_string(&entry).unwrap();
799 assert!(!json.to_lowercase().contains("sk-"));
800 }
801}