返回 CodeWhale
setup_state.rs
根目录 / crates / config / src / setup_state.rs
1 //! Unified setup-state model for the v0.8.67 constitution-first setup lane
2 //! (#3403).
3 //!
4 //! This is the single record every setup step (#3404–#3412) reads and writes so
5 //! that "configured", "skipped", "verified", and "ready" mean the same thing
6 //! everywhere. It is persisted as a JSON sidecar (`setup_state.json`) under
7 //! `$CODEWHALE_HOME`, written atomically through [`crate::persistence`] so it is
8 //! independent of `config.toml`'s comment-preserving writes and can never leave
9 //! a half-written file.
10 //!
11 //! The record holds two things:
12 //!
13 //! 1. A per-[`SetupStep`] [`StepEntry`] (status, required, safe summary,
14 //! writing lane version).
15 //! 2. The constitution-first fields the wizard, the update checkpoint, and
16 //! `/constitution` all coordinate on.
17 //!
18 //! Readiness is a *derived* property ([`first_run_ready`](SetupState::first_run_ready)
19 //! / [`update_ready`](SetupState::update_ready)); it is never persisted, so the
20 //! rules can evolve without a migration.
21 //!
22 //! Secrets never appear here: [`StepEntry::result`] is a short human-facing
23 //! summary (provider name, model id, mode name), never a key.
24
25 use std::collections::BTreeMap;
26 use std::path::{Path, PathBuf};
27
28 use anyhow::{Context, Result};
29 use serde::{Deserialize, Serialize};
30
31 use crate::persistence;
32
33 /// Current schema version of the persisted setup-state record.
34 pub const SETUP_STATE_SCHEMA_VERSION: u32 = 1;
35
36 /// Filename of the setup-state sidecar under `$CODEWHALE_HOME`.
37 pub const SETUP_STATE_FILE_NAME: &str = "setup_state.json";
38
39 /// Version of the *telemetry notice content* — not the app version.
40 ///
41 /// The notice is owed whenever
42 /// [`SetupState::needs_telemetry_notice`] reports that it has not been shown.
43 /// Bumping it re-shows the disclosure to prior acceptors and unanswered users,
44 /// so it is bumped only
45 /// when the collection policy, schema, or disclosure materially changes. Prior
46 /// declines remain off. Keying it to the app version would re-prompt every
47 /// release, which is nagging with extra steps.
48 pub const TELEMETRY_NOTICE_VERSION: &str = "5";
49
50 /// Canonical setup step ids. The ordering matches the first-run spine so a
51 /// `BTreeMap<SetupStep, _>` renders in wizard order.
52 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
53 #[serde(rename_all = "snake_case")]
54 pub enum SetupStep {
55 /// Language first, so later screens and constitution prose are localized.
56 Language,
57 /// Provider + key (or local runtime) and a default model.
58 ProviderModel,
59 /// Trust, approvals, sandbox, network — runtime posture (#3406).
60 TrustSandbox,
61 /// User-global constitution choice / checkpoint.
62 Constitution,
63 /// Operate/Fleet readiness: provider auth, worker runtime, roster, and
64 /// concurrency review. Plan-limit detection remains a separate product
65 /// decision; this step only records reviewed current facts.
66 OperateFleet,
67 /// Hotbar shortcuts are optional, but now have a first-class setup card.
68 Hotbar,
69 /// Tools / MCP / skills / plugins (later lanes; tracked for completeness).
70 ToolsMcp,
71 /// Remote / mobile runtime (later lane; tracked for completeness).
72 RemoteRuntime,
73 /// Persistence paths for setup state, config, constitution, memory, and notes.
74 Persistence,
75 /// Final verification / doctor / ready summary.
76 Verification,
77 }
78
79 impl SetupStep {
80 /// All steps in canonical first-run order.
81 pub const ALL: [SetupStep; 10] = [
82 SetupStep::Language,
83 SetupStep::ProviderModel,
84 SetupStep::TrustSandbox,
85 SetupStep::Constitution,
86 SetupStep::OperateFleet,
87 SetupStep::Hotbar,
88 SetupStep::ToolsMcp,
89 SetupStep::RemoteRuntime,
90 SetupStep::Persistence,
91 SetupStep::Verification,
92 ];
93 }
94
95 /// Status of a single setup step. Shared vocabulary so `/setup`, `doctor`, and
96 /// the context report never invent their own meanings.
97 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
98 #[serde(rename_all = "snake_case")]
99 pub enum StepStatus {
100 /// Never visited.
101 NotStarted,
102 /// Suggested for a good first-run experience but not required.
103 Recommended,
104 /// Available but entirely optional.
105 Optional,
106 /// Intentionally postponed; surfaces in the report, does not block.
107 Deferred,
108 /// Currently being worked on.
109 InProgress,
110 /// Completed and checked (e.g. key validated, mode confirmed).
111 Verified,
112 /// Reached a usable-but-incomplete state needing user action
113 /// (e.g. a key that failed validation). Does not block the ready screen.
114 NeedsAction,
115 /// Attempted and errored.
116 Failed,
117 /// Explicitly skipped by the user.
118 Skipped,
119 }
120
121 impl StepStatus {
122 /// True for statuses that count as "the user dealt with this step" for the
123 /// purpose of reaching the ready screen.
124 #[must_use]
125 pub fn is_settled(self) -> bool {
126 matches!(
127 self,
128 StepStatus::Verified
129 | StepStatus::NeedsAction
130 | StepStatus::Deferred
131 | StepStatus::Optional
132 | StepStatus::Skipped
133 )
134 }
135 }
136
137 /// One persisted entry per setup step.
138 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
139 pub struct StepEntry {
140 pub status: StepStatus,
141 /// Whether this step blocks "ready" for the lane that owns it. First-run and
142 /// update lanes differ; see the readiness helpers on [`SetupState`].
143 #[serde(default)]
144 pub required: bool,
145 /// Short, safe human-facing summary — provider name, model id, mode name,
146 /// health. **Never a secret.**
147 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub result: Option<String>,
149 /// Lane (e.g. `"0.8.67"`) that last wrote this entry, so staleness is
150 /// visible to `/setup`, `doctor`, and the context report.
151 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub version: Option<String>,
153 }
154
155 impl StepEntry {
156 /// A freshly-visited entry written by `version`.
157 #[must_use]
158 pub fn new(status: StepStatus, required: bool, version: impl Into<String>) -> Self {
159 Self {
160 status,
161 required,
162 result: None,
163 version: Some(version.into()),
164 }
165 }
166
167 #[must_use]
168 pub fn with_result(mut self, result: impl Into<String>) -> Self {
169 self.result = Some(result.into());
170 self
171 }
172 }
173
174 /// The user's constitution decision. Every value except [`Unset`] counts as an
175 /// explicit choice for readiness.
176 ///
177 /// [`Unset`]: ConstitutionChoice::Unset
178 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
179 #[serde(rename_all = "snake_case")]
180 pub enum ConstitutionChoice {
181 /// No decision recorded yet.
182 #[default]
183 Unset,
184 /// Accepted the bundled/default constitution floor. Creates no custom file.
185 Bundled,
186 /// Created a guided structured user-global constitution.
187 GuidedCustom,
188 /// Expert full-Markdown override
189 /// (`$CODEWHALE_HOME/prompts/constitution.md` + opt-in env).
190 ExpertOverride,
191 /// Explicitly postponed; bundled law applies until the user returns.
192 Deferred,
193 }
194
195 impl ConstitutionChoice {
196 /// True for any value other than [`Unset`](ConstitutionChoice::Unset).
197 #[must_use]
198 pub fn is_explicit(self) -> bool {
199 !matches!(self, ConstitutionChoice::Unset)
200 }
201 }
202
203 /// How the active custom constitution was authored. Recorded alongside
204 /// [`ConstitutionChoice::GuidedCustom`] so `/setup`, `doctor`, and the report
205 /// can show provenance without parsing free-text step results.
206 ///
207 /// This is a *new optional field* rather than a new [`ConstitutionChoice`]
208 /// variant so records written by this lane still load in older binaries
209 /// (unknown fields are ignored on read; an unknown enum variant would fail the
210 /// whole parse and force the inherited-state fallback).
211 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
212 #[serde(rename_all = "snake_case")]
213 pub enum ConstitutionAuthoring {
214 /// Deterministically rendered from the guided answers.
215 Guided,
216 /// Drafted by the user's configured model from the guided answers, then
217 /// schema-validated, bounded, previewed, and ratified. Advisory authorship
218 /// only — the drafting model gains no authority from having written it.
219 ModelDrafted,
220 }
221
222 /// Which constitution surface is currently the active user-global law.
223 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
224 #[serde(rename_all = "snake_case")]
225 pub enum ConstitutionSource {
226 /// Only the bundled floor is active.
227 #[default]
228 Bundled,
229 /// A structured `constitution.json` under `$CODEWHALE_HOME`.
230 UserGlobal,
231 /// An expert full-Markdown override file.
232 ExpertOverride,
233 }
234
235 /// Validity of the active user-global constitution file, if any.
236 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
237 #[serde(rename_all = "snake_case")]
238 pub enum ConstitutionValidity {
239 /// No custom file, or validity not yet evaluated.
240 #[default]
241 Unknown,
242 /// Parsed and usable.
243 Valid,
244 /// Present but failed to parse / structurally invalid.
245 Invalid,
246 /// Present but carried no usable policy.
247 Empty,
248 /// Present but could not be read.
249 Unreadable,
250 }
251
252 /// Where the current runtime posture came from. Mirrors the rule that a
253 /// constitution may *recommend* posture but only an explicit config action
254 /// (#3406) applies it.
255 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
256 #[serde(rename_all = "snake_case")]
257 pub enum RuntimePostureSource {
258 /// Not yet reviewed.
259 #[default]
260 Unset,
261 /// Carried over from existing config without an explicit confirmation.
262 Inherited,
263 /// The user explicitly reviewed and confirmed the posture in setup.
264 Confirmed,
265 }
266
267 impl RuntimePostureSource {
268 /// True when posture has been inherited or confirmed (either satisfies
269 /// first-run readiness).
270 #[must_use]
271 pub fn is_reviewed(self) -> bool {
272 matches!(
273 self,
274 RuntimePostureSource::Inherited | RuntimePostureSource::Confirmed
275 )
276 }
277 }
278
279 /// The persisted, per-version setup-state record.
280 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
281 pub struct SetupState {
282 pub schema_version: u32,
283
284 /// Per-step status entries.
285 #[serde(default)]
286 pub steps: BTreeMap<SetupStep, StepEntry>,
287
288 // ── Constitution-first fields ───────────────────────────────────────
289 /// The user's constitution decision.
290 #[serde(default)]
291 pub constitution_choice: ConstitutionChoice,
292 /// Lane version (e.g. `"0.8.67"`) whose constitution checkpoint the user has
293 /// completed. Drives the once-per-version update checkpoint (#3794).
294 #[serde(default, skip_serializing_if = "Option::is_none")]
295 pub constitution_checkpoint_completed_for: Option<String>,
296 /// Language the constitution prose was authored/reviewed in.
297 #[serde(default, skip_serializing_if = "Option::is_none")]
298 pub constitution_language: Option<String>,
299 /// Which surface is the active user-global law.
300 #[serde(default)]
301 pub constitution_source: ConstitutionSource,
302 /// Validity of the active user-global constitution file.
303 #[serde(default)]
304 pub constitution_validity: ConstitutionValidity,
305 /// How the active custom constitution was authored (guided deterministic
306 /// vs model-drafted-then-ratified). `None` for bundled/deferred/inherited.
307 #[serde(default, skip_serializing_if = "Option::is_none")]
308 pub constitution_authoring: Option<ConstitutionAuthoring>,
309 /// Stable content hash of the most recently previewed/accepted rendered
310 /// constitution (see [`crate::user_constitution::UserConstitution::preview_hash`]).
311 #[serde(default, skip_serializing_if = "Option::is_none")]
312 pub constitution_preview_hash: Option<String>,
313 /// Monotonic counter bumped each time a custom constitution is saved, so the
314 /// report and `/constitution` can show which revision is live.
315 #[serde(default)]
316 pub constitution_preview_version: u32,
317 /// Where the current runtime posture came from.
318 #[serde(default)]
319 pub runtime_posture_source: RuntimePostureSource,
320
321 /// Host-enforced Workflow dispatch and terminal receipts have been proven
322 /// for this installation. Older records did not carry this proof and must
323 /// deserialize false even if their Operate/Fleet card was marked Verified.
324 #[serde(default, skip_serializing_if = "is_false")]
325 pub operate_receipts_verified: bool,
326
327 /// True when this record was *derived* from existing config rather than
328 /// persisted by an explicit setup run. Lets `/setup` and `doctor` explain
329 /// why an updating user is not treated as a broken fresh install.
330 #[serde(default, skip_serializing_if = "is_false")]
331 pub inherited: bool,
332
333 // ── Telemetry notice ────────────────────────────────────────────────
334 /// [`TELEMETRY_NOTICE_VERSION`] whose telemetry processor disclosure was explicitly accepted or declined.
335 /// `None` means no explicit preference was recorded. Usage defaults on;
336 /// presenting a disclosure never changes these preference fields.
337 ///
338 /// These are *fields* rather than a new [`SetupStep`] variant on purpose:
339 /// an unknown enum variant fails the whole record parse and silently drops
340 /// the user back to derived-inherited state — including their constitution
341 /// checkpoint — while unknown fields are ignored.
342 #[serde(default, skip_serializing_if = "Option::is_none")]
343 pub telemetry_notice_decided_for: Option<String>,
344 /// The privacy preference recorded with the notice. `false` with any
345 /// recorded notice version is a durable opt-out; `true` records that the
346 /// processor disclosure was explicitly accepted for that version.
347 #[serde(default, skip_serializing_if = "is_false")]
348 pub telemetry_opt_in: bool,
349 /// Most recent telemetry policy disclosure actually presented locally.
350 /// This is display bookkeeping, never evidence of human acceptance.
351 #[serde(default, skip_serializing_if = "Option::is_none")]
352 pub telemetry_notice_shown_for: Option<String>,
353 }
354
355 #[allow(clippy::trivially_copy_pass_by_ref)]
356 fn is_false(b: &bool) -> bool {
357 !*b
358 }
359
360 impl 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 telemetry_notice_shown_for: None,
379 }
380 }
381 }
382
383 /// Observable, secret-free facts about existing config used to derive a safe
384 /// inherited setup-state for users who upgrade without a `setup_state.json`.
385 ///
386 /// The caller (TUI/CLI) gathers these from `ConfigToml`, the trust marker, and
387 /// the constitution files; keeping them as plain data keeps this module pure and
388 /// unit-testable.
389 #[derive(Debug, Clone, Default)]
390 pub struct InheritedConfigFacts {
391 /// A provider/model route is configured.
392 pub has_provider_route: bool,
393 /// A key or local runtime is available (presence only — never the value).
394 pub has_credentials_or_local_runtime: bool,
395 /// The user has previously made a trust/approval decision.
396 pub trust_chosen: bool,
397 /// Onboarding language, if known.
398 pub language: Option<String>,
399 /// A structured user-global `constitution.json` exists.
400 pub has_user_constitution: bool,
401 /// An expert full-Markdown override is active.
402 pub has_expert_override: bool,
403 /// Validity of the user-global constitution, if present.
404 pub user_constitution_validity: ConstitutionValidity,
405 }
406
407 impl SetupState {
408 /// Status for a step, defaulting to [`StepStatus::NotStarted`].
409 #[must_use]
410 pub fn status(&self, step: SetupStep) -> StepStatus {
411 self.steps
412 .get(&step)
413 .map_or(StepStatus::NotStarted, |e| e.status)
414 }
415
416 /// Record (insert or replace) an entry for `step`.
417 pub fn set_step(&mut self, step: SetupStep, entry: StepEntry) -> &mut Self {
418 self.steps.insert(step, entry);
419 self
420 }
421
422 #[must_use]
423 fn step_verified(&self, step: SetupStep) -> bool {
424 self.status(step) == StepStatus::Verified
425 }
426
427 /// Provider/model is acceptable for first-run readiness when it is either
428 /// verified or in an actionable needs-action state (the EPIC keeps a
429 /// failed-key path reaching the ready screen).
430 #[must_use]
431 fn provider_model_ready_or_needs_action(&self) -> bool {
432 matches!(
433 self.status(SetupStep::ProviderModel),
434 StepStatus::Verified | StepStatus::NeedsAction
435 )
436 }
437
438 /// First-run "ready": language verified, provider/model ready-or-needs-action,
439 /// runtime posture inherited/confirmed, and an explicit constitution choice.
440 #[must_use]
441 pub fn first_run_ready(&self) -> bool {
442 self.step_verified(SetupStep::Language)
443 && self.provider_model_ready_or_needs_action()
444 && self.runtime_posture_source.is_reviewed()
445 && self.constitution_choice.is_explicit()
446 }
447
448 /// Operate/Fleet "ready": provider credentials are verified, runtime
449 /// posture has been reviewed, and the user has explicitly reviewed the
450 /// Fleet/Operate on-ramp. This is intentionally separate from
451 /// [`first_run_ready`](Self::first_run_ready): a local-first user can be
452 /// ready for ordinary first use before enabling durable multi-worker work.
453 #[must_use]
454 pub fn operate_ready(&self) -> bool {
455 self.first_run_ready()
456 && self.step_verified(SetupStep::ProviderModel)
457 && self.step_verified(SetupStep::OperateFleet)
458 && self.operate_receipts_verified
459 }
460
461 /// Update "ready" for `version`: the constitution checkpoint for that lane is
462 /// complete. Everything else is inherited from existing config.
463 #[must_use]
464 pub fn update_ready(&self, version: &str) -> bool {
465 self.constitution_checkpoint_completed_for.as_deref() == Some(version)
466 }
467
468 /// Whether the once-per-version update checkpoint should still be shown.
469 #[must_use]
470 pub fn needs_constitution_checkpoint(&self, version: &str) -> bool {
471 !self.update_ready(version)
472 }
473
474 /// Mark the constitution checkpoint complete for `version` (the bundled /
475 /// default path is a valid completion).
476 pub fn complete_constitution_checkpoint(
477 &mut self,
478 version: impl Into<String>,
479 choice: ConstitutionChoice,
480 ) -> &mut Self {
481 self.constitution_checkpoint_completed_for = Some(version.into());
482 self.constitution_choice = choice;
483 self
484 }
485
486 /// True when the telemetry notice for `version` has not been shown.
487 ///
488 /// A decision recorded against a *different* notice version does not
489 /// count: the content changed, so the disclosure is owed again.
490 #[must_use]
491 pub fn needs_telemetry_notice(&self, version: &str) -> bool {
492 self.telemetry_notice_shown_for.as_deref() != Some(version)
493 && self.telemetry_notice_decided_for.as_deref() != Some(version)
494 }
495
496 /// Record presentation without inventing a privacy preference.
497 pub fn record_telemetry_notice_shown(&mut self, version: impl Into<String>) -> &mut Self {
498 self.telemetry_notice_shown_for = Some(version.into());
499 self
500 }
501
502 /// Update only telemetry metadata from the latest readable sidecar.
503 ///
504 /// Disclosure bookkeeping and explicit preference writes share this lock:
505 /// a stale disclosure write must never erase a concurrently saved decline.
506 /// Missing state is fresh; corrupt state is never replaced with defaults.
507 pub fn update_telemetry_at(path: &Path, update: impl FnOnce(&mut Self)) -> Result<()> {
508 let parent = path.parent().context("setup-state path has no parent")?;
509 std::fs::create_dir_all(parent)?;
510 let file = std::fs::OpenOptions::new()
511 .create(true)
512 .truncate(false)
513 .read(true)
514 .write(true)
515 .open(path.with_extension("telemetry.lock"))?;
516 let mut lock = fd_lock::RwLock::new(file);
517 let _guard = lock.try_write()?;
518 let mut state = if path.try_exists()? {
519 Self::load_from(path).context("setup state could not be read")?
520 } else {
521 Self::default()
522 };
523 update(&mut state);
524 state.save_to(path)
525 }
526
527 /// Record the privacy preference associated with the telemetry notice.
528 ///
529 /// Acceptance is recorded only after an explicit choice to share counts;
530 /// an explicit opt-out may also arrive from Settings. Deferral,
531 /// skip-onboarding, and non-interactive surfaces leave it untouched.
532 pub fn record_telemetry_notice(
533 &mut self,
534 version: impl Into<String>,
535 opt_in: bool,
536 ) -> &mut Self {
537 self.telemetry_notice_decided_for = Some(version.into());
538 self.telemetry_opt_in = opt_in;
539 self
540 }
541
542 /// True when the current processor disclosure was explicitly accepted.
543 #[must_use]
544 pub fn telemetry_accepted(&self, version: &str) -> bool {
545 self.telemetry_notice_decided_for.as_deref() == Some(version) && self.telemetry_opt_in
546 }
547
548 /// True when the current notice record contains an explicit opt-out.
549 ///
550 /// Distinct from "never shown": only a recorded decline is an opt-out, and
551 /// only an opt-out may be acted on destructively.
552 #[must_use]
553 pub fn telemetry_declined(&self, version: &str) -> bool {
554 self.telemetry_notice_decided_for.as_deref() == Some(version) && !self.telemetry_opt_in
555 }
556
557 /// Whether any recorded telemetry notice was explicitly declined.
558 ///
559 /// Declines recorded by any former notice remain durable opt-outs. A notice-version bump may explain a
560 /// changed policy, but it must never erase a user's earlier "no".
561 #[must_use]
562 pub fn telemetry_opted_out(&self) -> bool {
563 self.telemetry_notice_decided_for.is_some() && !self.telemetry_opt_in
564 }
565
566 /// Derive a safe inherited state for an existing user with no persisted
567 /// `setup_state.json`. Surfaces they already configured become
568 /// [`StepStatus::Verified`]; an update never looks like a fresh, broken
569 /// setup. The constitution checkpoint is intentionally left incomplete so
570 /// updating users still see it once.
571 #[must_use]
572 pub fn derive_inherited(facts: &InheritedConfigFacts) -> Self {
573 let mut state = SetupState {
574 inherited: true,
575 ..SetupState::default()
576 };
577 let inherited = "inherited";
578
579 if facts.language.is_some() {
580 state.set_step(
581 SetupStep::Language,
582 StepEntry::new(StepStatus::Verified, true, inherited),
583 );
584 state.constitution_language = facts.language.clone();
585 }
586
587 if facts.has_provider_route && facts.has_credentials_or_local_runtime {
588 state.set_step(
589 SetupStep::ProviderModel,
590 StepEntry::new(StepStatus::Verified, true, inherited),
591 );
592 } else if facts.has_provider_route {
593 state.set_step(
594 SetupStep::ProviderModel,
595 StepEntry::new(StepStatus::NeedsAction, true, inherited),
596 );
597 }
598
599 if facts.trust_chosen {
600 state.set_step(
601 SetupStep::TrustSandbox,
602 StepEntry::new(StepStatus::Verified, true, inherited),
603 );
604 state.runtime_posture_source = RuntimePostureSource::Inherited;
605 }
606
607 // Constitution: classify the active surface, but never auto-complete the
608 // checkpoint — the update lane requires the user to acknowledge it once.
609 if facts.has_expert_override {
610 state.constitution_source = ConstitutionSource::ExpertOverride;
611 state.constitution_choice = ConstitutionChoice::ExpertOverride;
612 } else if facts.has_user_constitution {
613 state.constitution_source = ConstitutionSource::UserGlobal;
614 state.constitution_validity = facts.user_constitution_validity;
615 if facts.user_constitution_validity == ConstitutionValidity::Valid {
616 state.constitution_choice = ConstitutionChoice::GuidedCustom;
617 }
618 } else {
619 state.constitution_source = ConstitutionSource::Bundled;
620 }
621
622 state
623 }
624
625 /// Path to the setup-state sidecar under `$CODEWHALE_HOME`.
626 pub fn path() -> Result<PathBuf> {
627 Ok(crate::codewhale_home()?.join(SETUP_STATE_FILE_NAME))
628 }
629
630 /// Load the persisted setup-state from the home sidecar.
631 ///
632 /// Returns `Ok(None)` when the file is missing **or** unreadable/corrupt, so
633 /// callers fall back to [`derive_inherited`](Self::derive_inherited) rather
634 /// than forcing a fresh wizard. A corrupt record is logged, never fatal.
635 pub fn load() -> Result<Option<Self>> {
636 Ok(Self::load_from(&Self::path()?))
637 }
638
639 /// Load from an explicit path (testable). See [`load`](Self::load) for the
640 /// missing/corrupt fallback contract.
641 #[must_use]
642 pub fn load_from(path: &Path) -> Option<Self> {
643 let raw = match std::fs::read_to_string(path) {
644 Ok(raw) => raw,
645 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
646 Err(e) => {
647 tracing::warn!(
648 target: "config::setup_state",
649 "could not read {} ({e}); deriving status from existing config",
650 path.display()
651 );
652 return None;
653 }
654 };
655 match serde_json::from_str::<SetupState>(&raw) {
656 Ok(state) => Some(state),
657 Err(e) => {
658 tracing::warn!(
659 target: "config::setup_state",
660 "{} is not a valid setup-state record ({e}); deriving status from existing config",
661 path.display()
662 );
663 None
664 }
665 }
666 }
667
668 /// Atomically persist this record to the home sidecar.
669 pub fn save(&self) -> Result<()> {
670 let path = Self::path()?;
671 self.save_to(&path)
672 }
673
674 /// Atomically persist to an explicit path (testable).
675 pub fn save_to(&self, path: &Path) -> Result<()> {
676 persistence::atomic_write_json(path, self)
677 .with_context(|| format!("failed to persist setup state to {}", path.display()))
678 }
679 }
680
681 #[cfg(test)]
682 mod tests {
683 use super::*;
684
685 fn verified(version: &str) -> StepEntry {
686 StepEntry::new(StepStatus::Verified, true, version)
687 }
688
689 #[test]
690 fn default_is_not_first_run_ready() {
691 let state = SetupState::default();
692 assert!(!state.first_run_ready());
693 assert_eq!(state.constitution_choice, ConstitutionChoice::Unset);
694 }
695
696 #[test]
697 fn persistence_is_optional_before_verification() {
698 let persistence_index = SetupStep::ALL
699 .iter()
700 .position(|step| *step == SetupStep::Persistence)
701 .expect("persistence step");
702 let verification_index = SetupStep::ALL
703 .iter()
704 .position(|step| *step == SetupStep::Verification)
705 .expect("verification step");
706
707 assert!(persistence_index < verification_index);
708
709 let mut state = SetupState::default();
710 state.set_step(SetupStep::Language, verified("0.8.67"));
711 state.set_step(SetupStep::ProviderModel, verified("0.8.67"));
712 state.runtime_posture_source = RuntimePostureSource::Confirmed;
713 state.constitution_choice = ConstitutionChoice::Bundled;
714 assert!(state.first_run_ready());
715
716 state.set_step(
717 SetupStep::Persistence,
718 StepEntry::new(StepStatus::NeedsAction, false, "0.8.67"),
719 );
720 assert!(state.first_run_ready());
721 assert!(!state.operate_ready());
722 }
723
724 #[test]
725 fn first_run_ready_requires_all_pillars() {
726 let mut state = SetupState::default();
727 state.set_step(SetupStep::Language, verified("0.8.67"));
728 state.set_step(SetupStep::ProviderModel, verified("0.8.67"));
729 state.runtime_posture_source = RuntimePostureSource::Confirmed;
730 // Still missing an explicit constitution choice.
731 assert!(!state.first_run_ready());
732 state.constitution_choice = ConstitutionChoice::Bundled;
733 assert!(state.first_run_ready());
734 }
735
736 #[test]
737 fn operate_ready_is_separate_from_first_run_ready() {
738 let mut state = SetupState::default();
739 state.set_step(SetupStep::Language, verified("0.8.67"));
740 state.set_step(SetupStep::ProviderModel, verified("0.8.67"));
741 state.runtime_posture_source = RuntimePostureSource::Confirmed;
742 state.constitution_choice = ConstitutionChoice::Bundled;
743 assert!(state.first_run_ready());
744 assert!(!state.operate_ready());
745
746 state.set_step(SetupStep::OperateFleet, verified("0.8.67"));
747 assert!(
748 !state.operate_ready(),
749 "a legacy Verified card is not receipt proof"
750 );
751 state.operate_receipts_verified = true;
752 assert!(state.operate_ready());
753 }
754
755 #[test]
756 fn legacy_verified_operate_card_without_receipt_proof_fails_closed() {
757 let mut legacy = SetupState::default();
758 legacy.set_step(SetupStep::Language, verified("0.8.67"));
759 legacy.set_step(SetupStep::ProviderModel, verified("0.8.67"));
760 legacy.set_step(SetupStep::OperateFleet, verified("0.8.67"));
761 legacy.runtime_posture_source = RuntimePostureSource::Confirmed;
762 legacy.constitution_choice = ConstitutionChoice::Bundled;
763 let raw = serde_json::to_string(&legacy).expect("serialize legacy-style state");
764 assert!(!raw.contains("operate_receipts_verified"), "{raw}");
765
766 let loaded: SetupState = serde_json::from_str(&raw).expect("load legacy-style state");
767
768 assert_eq!(loaded.status(SetupStep::OperateFleet), StepStatus::Verified);
769 assert!(!loaded.operate_receipts_verified);
770 assert!(!loaded.operate_ready());
771 }
772
773 #[test]
774 fn operate_ready_requires_verified_provider_not_needs_action() {
775 let mut state = SetupState::default();
776 state.set_step(
777 SetupStep::ProviderModel,
778 StepEntry::new(StepStatus::NeedsAction, true, "0.8.67"),
779 );
780 state.runtime_posture_source = RuntimePostureSource::Confirmed;
781 state.set_step(SetupStep::OperateFleet, verified("0.8.67"));
782
783 assert!(!state.operate_ready());
784 }
785
786 #[test]
787 fn needs_action_provider_still_reaches_ready() {
788 let mut state = SetupState::default();
789 state.set_step(SetupStep::Language, verified("0.8.67"));
790 state.set_step(
791 SetupStep::ProviderModel,
792 StepEntry::new(StepStatus::NeedsAction, true, "0.8.67"),
793 );
794 state.runtime_posture_source = RuntimePostureSource::Inherited;
795 state.constitution_choice = ConstitutionChoice::Deferred;
796 assert!(state.first_run_ready());
797 }
798
799 #[test]
800 fn deferred_constitution_counts_as_explicit_choice() {
801 assert!(ConstitutionChoice::Deferred.is_explicit());
802 assert!(ConstitutionChoice::Bundled.is_explicit());
803 assert!(!ConstitutionChoice::Unset.is_explicit());
804 }
805
806 #[test]
807 fn update_ready_tracks_checkpoint_version() {
808 let mut state = SetupState::default();
809 assert!(state.needs_constitution_checkpoint("0.8.67"));
810 state.complete_constitution_checkpoint("0.8.67", ConstitutionChoice::Bundled);
811 assert!(state.update_ready("0.8.67"));
812 assert!(!state.needs_constitution_checkpoint("0.8.67"));
813 // A later lane re-arms the checkpoint.
814 assert!(state.needs_constitution_checkpoint("0.8.68"));
815 }
816
817 #[test]
818 fn derive_inherited_marks_existing_user_safe() {
819 let facts = InheritedConfigFacts {
820 has_provider_route: true,
821 has_credentials_or_local_runtime: true,
822 trust_chosen: true,
823 language: Some("en".to_string()),
824 has_user_constitution: false,
825 has_expert_override: false,
826 user_constitution_validity: ConstitutionValidity::Unknown,
827 };
828 let state = SetupState::derive_inherited(&facts);
829 assert!(state.inherited);
830 assert_eq!(state.status(SetupStep::Language), StepStatus::Verified);
831 assert_eq!(state.status(SetupStep::ProviderModel), StepStatus::Verified);
832 assert_eq!(state.status(SetupStep::TrustSandbox), StepStatus::Verified);
833 assert_eq!(state.constitution_source, ConstitutionSource::Bundled);
834 // The update checkpoint must still be shown to an upgrading user.
835 assert!(state.needs_constitution_checkpoint("0.8.67"));
836 }
837
838 #[test]
839 fn derive_inherited_classifies_provider_without_key_as_needs_action() {
840 let facts = InheritedConfigFacts {
841 has_provider_route: true,
842 has_credentials_or_local_runtime: false,
843 ..InheritedConfigFacts::default()
844 };
845 let state = SetupState::derive_inherited(&facts);
846 assert_eq!(
847 state.status(SetupStep::ProviderModel),
848 StepStatus::NeedsAction
849 );
850 }
851
852 #[test]
853 fn derive_inherited_picks_up_existing_user_constitution() {
854 let facts = InheritedConfigFacts {
855 has_user_constitution: true,
856 user_constitution_validity: ConstitutionValidity::Valid,
857 ..InheritedConfigFacts::default()
858 };
859 let state = SetupState::derive_inherited(&facts);
860 assert_eq!(state.constitution_source, ConstitutionSource::UserGlobal);
861 assert_eq!(state.constitution_choice, ConstitutionChoice::GuidedCustom);
862 assert_eq!(state.constitution_validity, ConstitutionValidity::Valid);
863 }
864
865 #[test]
866 fn round_trips_through_json_sidecar() {
867 let tmp = tempfile::tempdir().unwrap();
868 let path = tmp.path().join(SETUP_STATE_FILE_NAME);
869
870 let mut state = SetupState::default();
871 state.set_step(
872 SetupStep::ProviderModel,
873 verified("0.8.67").with_result("openai · mimo-ultraspeed"),
874 );
875 state.constitution_choice = ConstitutionChoice::GuidedCustom;
876 state.constitution_preview_version = 3;
877 state.save_to(&path).unwrap();
878
879 let loaded = SetupState::load_from(&path).expect("record should load");
880 assert_eq!(loaded, state);
881 // Enum keys serialize as snake_case strings.
882 let raw = std::fs::read_to_string(&path).unwrap();
883 assert!(raw.contains("\"provider_model\""), "{raw}");
884 assert!(raw.contains("openai · mimo-ultraspeed"));
885 }
886
887 #[test]
888 fn constitution_authoring_round_trips_and_stays_optional() {
889 let tmp = tempfile::tempdir().unwrap();
890 let path = tmp.path().join(SETUP_STATE_FILE_NAME);
891
892 let state = SetupState {
893 constitution_choice: ConstitutionChoice::GuidedCustom,
894 constitution_authoring: Some(ConstitutionAuthoring::ModelDrafted),
895 ..Default::default()
896 };
897 state.save_to(&path).unwrap();
898
899 let loaded = SetupState::load_from(&path).expect("record should load");
900 assert_eq!(
901 loaded.constitution_authoring,
902 Some(ConstitutionAuthoring::ModelDrafted)
903 );
904 let raw = std::fs::read_to_string(&path).unwrap();
905 assert!(raw.contains("\"model_drafted\""), "{raw}");
906 }
907
908 #[test]
909 fn record_without_authoring_field_still_loads() {
910 // Records written before the model-drafting lane carry no
911 // constitution_authoring key; they must load with None, not fail.
912 let tmp = tempfile::tempdir().unwrap();
913 let path = tmp.path().join(SETUP_STATE_FILE_NAME);
914 std::fs::write(
915 &path,
916 r#"{"schema_version":1,"constitution_choice":"guided_custom"}"#,
917 )
918 .unwrap();
919 let loaded = SetupState::load_from(&path).expect("legacy record should load");
920 assert_eq!(loaded.constitution_authoring, None);
921 assert_eq!(loaded.constitution_choice, ConstitutionChoice::GuidedCustom);
922 }
923
924 #[test]
925 fn corrupt_record_falls_back_to_none() {
926 let tmp = tempfile::tempdir().unwrap();
927 let path = tmp.path().join(SETUP_STATE_FILE_NAME);
928 std::fs::write(&path, "{ not valid json").unwrap();
929 assert!(SetupState::load_from(&path).is_none());
930 }
931
932 #[test]
933 fn missing_record_is_none_not_error() {
934 let tmp = tempfile::tempdir().unwrap();
935 let path = tmp.path().join("does-not-exist.json");
936 assert!(SetupState::load_from(&path).is_none());
937 }
938
939 #[test]
940 fn step_result_carries_no_secret_by_construction() {
941 // The result field is a caller-supplied safe summary; this documents the
942 // contract that callers pass names, not keys.
943 let entry = verified("0.8.67").with_result("provider: openai, model: mimo");
944 let json = serde_json::to_string(&entry).unwrap();
945 assert!(!json.to_lowercase().contains("sk-"));
946 }
947 }
948
948 lines RUST