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