use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::persistence;
pub const SETUP_STATE_SCHEMA_VERSION: u32 = 1;
pub const SETUP_STATE_FILE_NAME: &str = "setup_state.json";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SetupStep {
Language,
ProviderModel,
TrustSandbox,
Constitution,
OperateFleet,
Hotbar,
ToolsMcp,
RemoteRuntime,
Persistence,
Verification,
}
impl SetupStep {
pub const ALL: [SetupStep; 10] = [
SetupStep::Language,
SetupStep::ProviderModel,
SetupStep::TrustSandbox,
SetupStep::Constitution,
SetupStep::OperateFleet,
SetupStep::Hotbar,
SetupStep::ToolsMcp,
SetupStep::RemoteRuntime,
SetupStep::Persistence,
SetupStep::Verification,
];
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StepStatus {
NotStarted,
Recommended,
Optional,
Deferred,
InProgress,
Verified,
NeedsAction,
Failed,
Skipped,
}
impl StepStatus {
#[must_use]
pub fn is_settled(self) -> bool {
matches!(
self,
StepStatus::Verified
| StepStatus::NeedsAction
| StepStatus::Deferred
| StepStatus::Optional
| StepStatus::Skipped
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StepEntry {
pub status: StepStatus,
#[serde(default)]
pub required: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub result: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
}
impl StepEntry {
#[must_use]
pub fn new(status: StepStatus, required: bool, version: impl Into<String>) -> Self {
Self {
status,
required,
result: None,
version: Some(version.into()),
}
}
#[must_use]
pub fn with_result(mut self, result: impl Into<String>) -> Self {
self.result = Some(result.into());
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConstitutionChoice {
#[default]
Unset,
Bundled,
GuidedCustom,
ExpertOverride,
Deferred,
}
impl ConstitutionChoice {
#[must_use]
pub fn is_explicit(self) -> bool {
!matches!(self, ConstitutionChoice::Unset)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConstitutionAuthoring {
Guided,
ModelDrafted,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConstitutionSource {
#[default]
Bundled,
UserGlobal,
ExpertOverride,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConstitutionValidity {
#[default]
Unknown,
Valid,
Invalid,
Empty,
Unreadable,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RuntimePostureSource {
#[default]
Unset,
Inherited,
Confirmed,
}
impl RuntimePostureSource {
#[must_use]
pub fn is_reviewed(self) -> bool {
matches!(
self,
RuntimePostureSource::Inherited | RuntimePostureSource::Confirmed
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SetupState {
pub schema_version: u32,
#[serde(default)]
pub steps: BTreeMap<SetupStep, StepEntry>,
#[serde(default)]
pub constitution_choice: ConstitutionChoice,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub constitution_checkpoint_completed_for: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub constitution_language: Option<String>,
#[serde(default)]
pub constitution_source: ConstitutionSource,
#[serde(default)]
pub constitution_validity: ConstitutionValidity,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub constitution_authoring: Option<ConstitutionAuthoring>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub constitution_preview_hash: Option<String>,
#[serde(default)]
pub constitution_preview_version: u32,
#[serde(default)]
pub runtime_posture_source: RuntimePostureSource,
#[serde(default, skip_serializing_if = "is_false")]
pub operate_receipts_verified: bool,
#[serde(default, skip_serializing_if = "is_false")]
pub inherited: bool,
}
#[allow(clippy::trivially_copy_pass_by_ref)]
fn is_false(b: &bool) -> bool {
!*b
}
impl Default for SetupState {
fn default() -> Self {
Self {
schema_version: SETUP_STATE_SCHEMA_VERSION,
steps: BTreeMap::new(),
constitution_choice: ConstitutionChoice::default(),
constitution_checkpoint_completed_for: None,
constitution_language: None,
constitution_source: ConstitutionSource::default(),
constitution_validity: ConstitutionValidity::default(),
constitution_authoring: None,
constitution_preview_hash: None,
constitution_preview_version: 0,
runtime_posture_source: RuntimePostureSource::default(),
operate_receipts_verified: false,
inherited: false,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct InheritedConfigFacts {
pub has_provider_route: bool,
pub has_credentials_or_local_runtime: bool,
pub trust_chosen: bool,
pub language: Option<String>,
pub has_user_constitution: bool,
pub has_expert_override: bool,
pub user_constitution_validity: ConstitutionValidity,
}
impl SetupState {
#[must_use]
pub fn status(&self, step: SetupStep) -> StepStatus {
self.steps
.get(&step)
.map_or(StepStatus::NotStarted, |e| e.status)
}
pub fn set_step(&mut self, step: SetupStep, entry: StepEntry) -> &mut Self {
self.steps.insert(step, entry);
self
}
#[must_use]
fn step_verified(&self, step: SetupStep) -> bool {
self.status(step) == StepStatus::Verified
}
#[must_use]
fn provider_model_ready_or_needs_action(&self) -> bool {
matches!(
self.status(SetupStep::ProviderModel),
StepStatus::Verified | StepStatus::NeedsAction
)
}
#[must_use]
pub fn first_run_ready(&self) -> bool {
self.step_verified(SetupStep::Language)
&& self.provider_model_ready_or_needs_action()
&& self.runtime_posture_source.is_reviewed()
&& self.constitution_choice.is_explicit()
}
#[must_use]
pub fn operate_ready(&self) -> bool {
self.first_run_ready()
&& self.step_verified(SetupStep::ProviderModel)
&& self.step_verified(SetupStep::OperateFleet)
&& self.operate_receipts_verified
}
#[must_use]
pub fn update_ready(&self, version: &str) -> bool {
self.constitution_checkpoint_completed_for.as_deref() == Some(version)
}
#[must_use]
pub fn needs_constitution_checkpoint(&self, version: &str) -> bool {
!self.update_ready(version)
}
pub fn complete_constitution_checkpoint(
&mut self,
version: impl Into<String>,
choice: ConstitutionChoice,
) -> &mut Self {
self.constitution_checkpoint_completed_for = Some(version.into());
self.constitution_choice = choice;
self
}
#[must_use]
pub fn derive_inherited(facts: &InheritedConfigFacts) -> Self {
let mut state = SetupState {
inherited: true,
..SetupState::default()
};
let inherited = "inherited";
if facts.language.is_some() {
state.set_step(
SetupStep::Language,
StepEntry::new(StepStatus::Verified, true, inherited),
);
state.constitution_language = facts.language.clone();
}
if facts.has_provider_route && facts.has_credentials_or_local_runtime {
state.set_step(
SetupStep::ProviderModel,
StepEntry::new(StepStatus::Verified, true, inherited),
);
} else if facts.has_provider_route {
state.set_step(
SetupStep::ProviderModel,
StepEntry::new(StepStatus::NeedsAction, true, inherited),
);
}
if facts.trust_chosen {
state.set_step(
SetupStep::TrustSandbox,
StepEntry::new(StepStatus::Verified, true, inherited),
);
state.runtime_posture_source = RuntimePostureSource::Inherited;
}
if facts.has_expert_override {
state.constitution_source = ConstitutionSource::ExpertOverride;
state.constitution_choice = ConstitutionChoice::ExpertOverride;
} else if facts.has_user_constitution {
state.constitution_source = ConstitutionSource::UserGlobal;
state.constitution_validity = facts.user_constitution_validity;
if facts.user_constitution_validity == ConstitutionValidity::Valid {
state.constitution_choice = ConstitutionChoice::GuidedCustom;
}
} else {
state.constitution_source = ConstitutionSource::Bundled;
}
state
}
pub fn path() -> Result<PathBuf> {
Ok(crate::codewhale_home()?.join(SETUP_STATE_FILE_NAME))
}
pub fn load() -> Result<Option<Self>> {
Ok(Self::load_from(&Self::path()?))
}
#[must_use]
pub fn load_from(path: &Path) -> Option<Self> {
let raw = match std::fs::read_to_string(path) {
Ok(raw) => raw,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
Err(e) => {
tracing::warn!(
target: "config::setup_state",
"could not read {} ({e}); deriving status from existing config",
path.display()
);
return None;
}
};
match serde_json::from_str::<SetupState>(&raw) {
Ok(state) => Some(state),
Err(e) => {
tracing::warn!(
target: "config::setup_state",
"{} is not a valid setup-state record ({e}); deriving status from existing config",
path.display()
);
None
}
}
}
pub fn save(&self) -> Result<()> {
let path = Self::path()?;
self.save_to(&path)
}
pub fn save_to(&self, path: &Path) -> Result<()> {
persistence::atomic_write_json(path, self)
.with_context(|| format!("failed to persist setup state to {}", path.display()))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn verified(version: &str) -> StepEntry {
StepEntry::new(StepStatus::Verified, true, version)
}
#[test]
fn default_is_not_first_run_ready() {
let state = SetupState::default();
assert!(!state.first_run_ready());
assert_eq!(state.constitution_choice, ConstitutionChoice::Unset);
}
#[test]
fn persistence_is_optional_before_verification() {
let persistence_index = SetupStep::ALL
.iter()
.position(|step| *step == SetupStep::Persistence)
.expect("persistence step");
let verification_index = SetupStep::ALL
.iter()
.position(|step| *step == SetupStep::Verification)
.expect("verification step");
assert!(persistence_index < verification_index);
let mut state = SetupState::default();
state.set_step(SetupStep::Language, verified("0.8.67"));
state.set_step(SetupStep::ProviderModel, verified("0.8.67"));
state.runtime_posture_source = RuntimePostureSource::Confirmed;
state.constitution_choice = ConstitutionChoice::Bundled;
assert!(state.first_run_ready());
state.set_step(
SetupStep::Persistence,
StepEntry::new(StepStatus::NeedsAction, false, "0.8.67"),
);
assert!(state.first_run_ready());
assert!(!state.operate_ready());
}
#[test]
fn first_run_ready_requires_all_pillars() {
let mut state = SetupState::default();
state.set_step(SetupStep::Language, verified("0.8.67"));
state.set_step(SetupStep::ProviderModel, verified("0.8.67"));
state.runtime_posture_source = RuntimePostureSource::Confirmed;
assert!(!state.first_run_ready());
state.constitution_choice = ConstitutionChoice::Bundled;
assert!(state.first_run_ready());
}
#[test]
fn operate_ready_is_separate_from_first_run_ready() {
let mut state = SetupState::default();
state.set_step(SetupStep::Language, verified("0.8.67"));
state.set_step(SetupStep::ProviderModel, verified("0.8.67"));
state.runtime_posture_source = RuntimePostureSource::Confirmed;
state.constitution_choice = ConstitutionChoice::Bundled;
assert!(state.first_run_ready());
assert!(!state.operate_ready());
state.set_step(SetupStep::OperateFleet, verified("0.8.67"));
assert!(
!state.operate_ready(),
"a legacy Verified card is not receipt proof"
);
state.operate_receipts_verified = true;
assert!(state.operate_ready());
}
#[test]
fn legacy_verified_operate_card_without_receipt_proof_fails_closed() {
let mut legacy = SetupState::default();
legacy.set_step(SetupStep::Language, verified("0.8.67"));
legacy.set_step(SetupStep::ProviderModel, verified("0.8.67"));
legacy.set_step(SetupStep::OperateFleet, verified("0.8.67"));
legacy.runtime_posture_source = RuntimePostureSource::Confirmed;
legacy.constitution_choice = ConstitutionChoice::Bundled;
let raw = serde_json::to_string(&legacy).expect("serialize legacy-style state");
assert!(!raw.contains("operate_receipts_verified"), "{raw}");
let loaded: SetupState = serde_json::from_str(&raw).expect("load legacy-style state");
assert_eq!(loaded.status(SetupStep::OperateFleet), StepStatus::Verified);
assert!(!loaded.operate_receipts_verified);
assert!(!loaded.operate_ready());
}
#[test]
fn operate_ready_requires_verified_provider_not_needs_action() {
let mut state = SetupState::default();
state.set_step(
SetupStep::ProviderModel,
StepEntry::new(StepStatus::NeedsAction, true, "0.8.67"),
);
state.runtime_posture_source = RuntimePostureSource::Confirmed;
state.set_step(SetupStep::OperateFleet, verified("0.8.67"));
assert!(!state.operate_ready());
}
#[test]
fn needs_action_provider_still_reaches_ready() {
let mut state = SetupState::default();
state.set_step(SetupStep::Language, verified("0.8.67"));
state.set_step(
SetupStep::ProviderModel,
StepEntry::new(StepStatus::NeedsAction, true, "0.8.67"),
);
state.runtime_posture_source = RuntimePostureSource::Inherited;
state.constitution_choice = ConstitutionChoice::Deferred;
assert!(state.first_run_ready());
}
#[test]
fn deferred_constitution_counts_as_explicit_choice() {
assert!(ConstitutionChoice::Deferred.is_explicit());
assert!(ConstitutionChoice::Bundled.is_explicit());
assert!(!ConstitutionChoice::Unset.is_explicit());
}
#[test]
fn update_ready_tracks_checkpoint_version() {
let mut state = SetupState::default();
assert!(state.needs_constitution_checkpoint("0.8.67"));
state.complete_constitution_checkpoint("0.8.67", ConstitutionChoice::Bundled);
assert!(state.update_ready("0.8.67"));
assert!(!state.needs_constitution_checkpoint("0.8.67"));
assert!(state.needs_constitution_checkpoint("0.8.68"));
}
#[test]
fn derive_inherited_marks_existing_user_safe() {
let facts = InheritedConfigFacts {
has_provider_route: true,
has_credentials_or_local_runtime: true,
trust_chosen: true,
language: Some("en".to_string()),
has_user_constitution: false,
has_expert_override: false,
user_constitution_validity: ConstitutionValidity::Unknown,
};
let state = SetupState::derive_inherited(&facts);
assert!(state.inherited);
assert_eq!(state.status(SetupStep::Language), StepStatus::Verified);
assert_eq!(state.status(SetupStep::ProviderModel), StepStatus::Verified);
assert_eq!(state.status(SetupStep::TrustSandbox), StepStatus::Verified);
assert_eq!(state.constitution_source, ConstitutionSource::Bundled);
assert!(state.needs_constitution_checkpoint("0.8.67"));
}
#[test]
fn derive_inherited_classifies_provider_without_key_as_needs_action() {
let facts = InheritedConfigFacts {
has_provider_route: true,
has_credentials_or_local_runtime: false,
..InheritedConfigFacts::default()
};
let state = SetupState::derive_inherited(&facts);
assert_eq!(
state.status(SetupStep::ProviderModel),
StepStatus::NeedsAction
);
}
#[test]
fn derive_inherited_picks_up_existing_user_constitution() {
let facts = InheritedConfigFacts {
has_user_constitution: true,
user_constitution_validity: ConstitutionValidity::Valid,
..InheritedConfigFacts::default()
};
let state = SetupState::derive_inherited(&facts);
assert_eq!(state.constitution_source, ConstitutionSource::UserGlobal);
assert_eq!(state.constitution_choice, ConstitutionChoice::GuidedCustom);
assert_eq!(state.constitution_validity, ConstitutionValidity::Valid);
}
#[test]
fn round_trips_through_json_sidecar() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join(SETUP_STATE_FILE_NAME);
let mut state = SetupState::default();
state.set_step(
SetupStep::ProviderModel,
verified("0.8.67").with_result("openai · mimo-ultraspeed"),
);
state.constitution_choice = ConstitutionChoice::GuidedCustom;
state.constitution_preview_version = 3;
state.save_to(&path).unwrap();
let loaded = SetupState::load_from(&path).expect("record should load");
assert_eq!(loaded, state);
let raw = std::fs::read_to_string(&path).unwrap();
assert!(raw.contains("\"provider_model\""), "{raw}");
assert!(raw.contains("openai · mimo-ultraspeed"));
}
#[test]
fn constitution_authoring_round_trips_and_stays_optional() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join(SETUP_STATE_FILE_NAME);
let state = SetupState {
constitution_choice: ConstitutionChoice::GuidedCustom,
constitution_authoring: Some(ConstitutionAuthoring::ModelDrafted),
..Default::default()
};
state.save_to(&path).unwrap();
let loaded = SetupState::load_from(&path).expect("record should load");
assert_eq!(
loaded.constitution_authoring,
Some(ConstitutionAuthoring::ModelDrafted)
);
let raw = std::fs::read_to_string(&path).unwrap();
assert!(raw.contains("\"model_drafted\""), "{raw}");
}
#[test]
fn record_without_authoring_field_still_loads() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join(SETUP_STATE_FILE_NAME);
std::fs::write(
&path,
r#"{"schema_version":1,"constitution_choice":"guided_custom"}"#,
)
.unwrap();
let loaded = SetupState::load_from(&path).expect("legacy record should load");
assert_eq!(loaded.constitution_authoring, None);
assert_eq!(loaded.constitution_choice, ConstitutionChoice::GuidedCustom);
}
#[test]
fn corrupt_record_falls_back_to_none() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join(SETUP_STATE_FILE_NAME);
std::fs::write(&path, "{ not valid json").unwrap();
assert!(SetupState::load_from(&path).is_none());
}
#[test]
fn missing_record_is_none_not_error() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("does-not-exist.json");
assert!(SetupState::load_from(&path).is_none());
}
#[test]
fn step_result_carries_no_secret_by_construction() {
let entry = verified("0.8.67").with_result("provider: openai, model: mimo");
let json = serde_json::to_string(&entry).unwrap();
assert!(!json.to_lowercase().contains("sk-"));
}
}