Skip to main content

pitboard_core/switch/
journal.rs

1//! Finishing what an interrupted run started.
2//!
3//! A switch writes this record before creating the park item it names. Whether an install
4//! landed is decided by asking Anthropic who owns the live credential, an answer that
5//! survives Claude Code rotating the token. When any fact cannot be read, recovery changes
6//! nothing and keeps the record.
7
8use super::{Error, Result, identify_document};
9use crate::context::Context;
10use crate::in_use::{self, InUse};
11use crate::provider::ProviderId;
12use crate::service::Permit;
13use crate::state::{Account, Key, Park, State};
14use crate::{atomic, home, park, state, store};
15use serde_json::Value;
16use std::path::PathBuf;
17
18#[derive(serde::Serialize, serde::Deserialize)]
19pub(super) struct Journal {
20    /// Which tool's login moved. Both sides of a switch are always the same tool's: a
21    /// switch replaces one tool's live login with another account of that same tool.
22    ///
23    /// Absent from a record written before there was a second tool, and every one of those
24    /// was Claude Code's.
25    #[serde(default = "claude")]
26    pub(super) provider: ProviderId,
27    pub(super) started_at: i64,
28    pub(super) from_label: String,
29    /// The accounts' ids, under the names a record has always given them.
30    #[serde(rename = "from_uuid")]
31    pub(super) from_id: String,
32    pub(super) to_label: String,
33    #[serde(rename = "to_uuid")]
34    pub(super) to_id: String,
35    pub(super) park_service: String,
36    /// The park being installed, so recovery consumes exactly that copy.
37    pub(super) incoming_service: String,
38    /// The refresh tokens on each side, as fingerprints. Eight bytes of SHA-256, which is
39    /// what `Park` already records, and no more a secret there than here.
40    ///
41    /// These let recovery answer the question it usually needs Anthropic for. Empty on a
42    /// record written before they existed, which is a record that simply asks.
43    #[serde(default)]
44    pub(super) from_fingerprint: String,
45    #[serde(default)]
46    pub(super) to_fingerprint: String,
47    /// Where the tool's live login was when the switch started, as the tool names it.
48    ///
49    /// Which login is live depends on a home variable, and recovery judges an interrupted
50    /// switch by reading the live login. Read from another place, it would compare the
51    /// switch's two sides with a login that has nothing to do with them, and could decide
52    /// the switch landed when it never did. `None` on a record written before this was kept.
53    #[serde(default)]
54    pub(super) slot: Option<String>,
55}
56
57/// What a later run found an interrupted switch had done, now recorded in the state.
58#[derive(Debug)]
59pub struct Recovered {
60    pub from: String,
61    pub to: String,
62    pub finished: bool,
63}
64
65impl Recovered {
66    pub fn code(&self) -> &'static str {
67        if self.finished {
68            "interrupted_switch_finished"
69        } else {
70            "interrupted_switch_undone"
71        }
72    }
73}
74
75impl std::fmt::Display for Recovered {
76    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77        write!(
78            f,
79            "an earlier switch from `{}` to `{}` was interrupted; {}",
80            self.from,
81            self.to,
82            if self.finished {
83                "it had in fact finished, and Pitboard has recorded that"
84            } else {
85                "it had not finished, and nothing was lost"
86            }
87        )
88    }
89}
90
91fn claude() -> ProviderId {
92    ProviderId::Claude
93}
94
95impl Journal {
96    fn from(&self) -> Key {
97        Key::new(self.provider, self.from_label.clone())
98    }
99
100    fn to(&self) -> Key {
101        Key::new(self.provider, self.to_label.clone())
102    }
103}
104
105fn journal_path(ctx: &Context) -> PathBuf {
106    home::dir(ctx).join("journal.json")
107}
108
109/// Durable before the park it names is created: a record lost to a crash would leave a
110/// consumed login looking restorable.
111pub(super) fn write_journal(ctx: &Context, permit: Permit, entry: &Journal) -> Result<()> {
112    let path = journal_path(ctx);
113    let fail = |source| Error::RecoveryFailed {
114        path: path.clone(),
115        source,
116    };
117    home::ensure(ctx, permit).map_err(fail)?;
118    let body = serde_json::to_string(entry).expect("a journal entry is always serialisable");
119    atomic::write(permit, &path, body.as_bytes(), atomic::Perms::Secret).map_err(fail)
120}
121
122/// A switch was interrupted, and the next command that changes state will finish it.
123pub fn pending(ctx: &Context) -> bool {
124    journal_path(ctx).exists()
125}
126
127/// Which tool's switch was interrupted, where one was and its record can be read.
128pub(crate) fn interrupted_tool(ctx: &Context) -> Option<ProviderId> {
129    let raw = std::fs::read_to_string(journal_path(ctx)).ok()?;
130    serde_json::from_str::<Journal>(&raw)
131        .ok()
132        .map(|journal| journal.provider)
133}
134
135/// The switch reached a state the account index fully describes.
136pub(super) fn clear_journal(ctx: &Context, permit: Permit) {
137    let _ = crate::host::fs::remove_file(permit, &journal_path(ctx));
138}
139
140struct Found {
141    /// The park the record reserved: written, never written, or `None` if unreadable.
142    parked: Option<Option<Value>>,
143    /// The id of the account the live login belongs to, or `None` if that cannot be learned.
144    live_owner: Option<String>,
145}
146
147#[derive(Default, Debug, PartialEq)]
148struct Repair {
149    /// Hold the interrupted run's park for the account it came from. Keyed by account id,
150    /// not label: the label may have been reused since.
151    hold: Option<(String, Park)>,
152    /// The interrupted run's park copies a login that is still signed in, so Claude Code
153    /// will rotate past it.
154    drop: bool,
155    /// The destination's login is live: it is in use, and its park was consumed.
156    landed: bool,
157}
158
159/// What recovery read of the tool's own record and of its store, which is what a switch
160/// that landed leaves recorded as in use.
161struct Seen {
162    /// What the tool's own record named, read before its store.
163    named: Option<String>,
164    /// The fingerprint of the login the store held: the one installed, or what Claude Code
165    /// rotated it to since. Empty where the store could not be read.
166    login: String,
167}
168
169/// `None` when the facts do not settle what happened.
170fn repair_for(state: &State, journal: &Journal, found: &Found) -> Option<Repair> {
171    let parked = found.parked.as_ref()?;
172    let owner = found.live_owner.as_deref()?;
173    let mut repair = Repair {
174        landed: owner == journal.to_id,
175        ..Repair::default()
176    };
177    if owner == journal.from_id {
178        repair.drop = parked.is_some();
179    } else if let Some(oauth) = parked
180        && !state.references(&journal.park_service)
181    {
182        repair.hold = Some((
183            journal.from_id.clone(),
184            park::describe(
185                journal.provider,
186                &journal.park_service,
187                journal.started_at,
188                oauth,
189            ),
190        ));
191    }
192    Some(repair)
193}
194
195/// A switch that landed leaves the login recovery `seen` in the store recorded as the
196/// destination's, at `at`.
197fn apply(state: &mut State, journal: &Journal, repair: Repair, seen: Seen, at: i64) {
198    if repair.drop {
199        state.discard(&journal.park_service);
200    }
201    if let Some((id, park)) = repair.hold {
202        match state.by_id(journal.provider, &id).map(Account::key) {
203            Some(key) => state.park(&key, park),
204            // The account it belongs to is gone, so nothing will ever restore this copy.
205            // Listing it is what gets it deleted rather than left in the keychain.
206            None => state.release(&park.service),
207        }
208    }
209    if !repair.landed {
210        return;
211    }
212    match state
213        .by_id(journal.provider, &journal.to_id)
214        .map(Account::owner)
215    {
216        Some(owner) => {
217            let installed = InUse {
218                owner: Some(owner),
219                login: seen.login,
220                known_at: at,
221                named: seen.named,
222            };
223            state.identified(journal.provider, installed, at);
224            state.discard(&journal.incoming_service);
225        }
226        // The destination was forgotten since, whichever account holds its label now, and an
227        // id alone does not say whose its login is as the service names one. What the record
228        // said before the switch is past.
229        None => {
230            state.in_use.remove(journal.provider.code());
231        }
232    }
233}
234
235fn read_park(ctx: &Context, service: &str) -> Option<Option<Value>> {
236    match store::vault_read(ctx, service) {
237        Ok(raw) => Some(raw.and_then(|r| serde_json::from_str(&r).ok())),
238        Err(_) => None,
239    }
240}
241
242/// Whose the live login is, as far as the record and the login itself can say.
243enum Owner {
244    /// The account's under this id.
245    Is(String),
246    /// Nobody can say, for this reason.
247    Unknown(String),
248    /// The record cannot say whose this login is, and the service has not been asked.
249    Unasked(Value),
250}
251
252/// Whose the live login is, read off the record and the login without asking anybody, and
253/// that login's fingerprint, empty where it could not be read.
254///
255/// The fingerprints settle it without a round trip whenever they can, which is what makes
256/// an interrupted switch recoverable with no network at all.
257fn live_owner(ctx: &Context, journal: &Journal) -> (Owner, String) {
258    let tool = crate::provider::of(journal.provider);
259    match tool.read_live(ctx) {
260        Err(e) => (Owner::Unknown(e.to_string()), String::new()),
261        Ok(None) => (Owner::Unknown("nothing is signed in".into()), String::new()),
262        Ok(Some(live)) => {
263            let login = tool.fingerprint(&live.raw);
264            let owner = match live_owner_by_fingerprint(journal, &login) {
265                Some(id) => Owner::Is(id),
266                None => Owner::Unasked(live.raw),
267            };
268            (owner, login)
269        }
270    }
271}
272
273/// The id of the account whose the live login is, with the service asked where only it
274/// can say.
275fn ask(
276    ctx: &Context,
277    state: &State,
278    which: ProviderId,
279    owner: Owner,
280) -> std::result::Result<String, String> {
281    match owner {
282        Owner::Is(id) => Ok(id),
283        Owner::Unknown(why) => Err(why),
284        Owner::Unasked(live) => identify_document(ctx, which, &live)
285            .map(|owner| state.id_of(which, &owner))
286            .map_err(|e| e.to_string()),
287    }
288}
289
290/// Who owns the live login, answered from the record rather than from Anthropic, where the
291/// record is enough to answer it.
292///
293/// Recovery needs to know which side of the switch the live credential came from, and
294/// asking Anthropic is the only answer that survives Claude Code rotating a token. But the
295/// two candidates are both Pitboard's own documents and their refresh tokens were
296/// fingerprinted when the record was written, so the common case is a comparison and not a
297/// round trip. That is what lets a switch be recovered on a plane.
298///
299/// It narrows the network dependency rather than removing it. A rotation inside the seconds
300/// of an interrupted switch leaves a fingerprint matching neither side, which is exactly
301/// when this says nothing and Anthropic is asked after all.
302fn live_owner_by_fingerprint(journal: &Journal, found: &str) -> Option<String> {
303    if journal.from_fingerprint.is_empty() || journal.to_fingerprint.is_empty() {
304        return None;
305    }
306    if journal.from_fingerprint == journal.to_fingerprint {
307        return None;
308    }
309    if found.is_empty() {
310        return None;
311    }
312    if found == journal.to_fingerprint {
313        Some(journal.to_id.clone())
314    } else if found == journal.from_fingerprint {
315        Some(journal.from_id.clone())
316    } else {
317        None
318    }
319}
320
321/// What abandoning an unfinishable record decided to keep.
322#[derive(Debug)]
323pub struct Abandoned {
324    pub from: String,
325    pub to: String,
326    /// Copies kept rather than deleted, because which one is live is now unknown.
327    pub kept: usize,
328}
329
330/// Throws away a record that cannot be finished, keeping every copy it names.
331///
332/// Recovery needs the service to say who owns the live login. Offline, or with a session
333/// the service no longer accepts, it cannot, and every command that changes anything stops
334/// at that. This is the way out: nothing is installed and nothing that might be the only
335/// copy is deleted, so the worst case is a copy that outlives its use, which `status` shows
336/// and `doctor` reports.
337///
338/// One exception, for a tool whose park may never be a copy: a park whose refresh token is
339/// the live login's own is a second copy for certain, whoever owns it, and is dropped
340/// rather than kept.
341pub(super) fn abandon(
342    ctx: &Context,
343    permit: Permit,
344    state: &mut State,
345) -> Result<Option<Abandoned>> {
346    let path = journal_path(ctx);
347    let raw = match std::fs::read_to_string(&path) {
348        Ok(r) => r,
349        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
350        Err(source) => return Err(Error::RecoveryFailed { path, source }),
351    };
352    let journal = serde_json::from_str::<Journal>(&raw)
353        .map_err(|source| Error::RecoveryRecordCorrupt { path, source })?;
354
355    // The copy the interrupted run parked is recorded against the account it came from, so
356    // nothing holds a keychain item that no file names.
357    let mut kept = 0;
358    if let Some(Some(document)) = read_park(ctx, &journal.park_service)
359        && park::is_live_twin(ctx, journal.provider, &document)
360    {
361        state.discard(&journal.park_service);
362    } else if let Some(Some(document)) = read_park(ctx, &journal.park_service)
363        && let Some(key) = state
364            .by_id(journal.provider, &journal.from_id)
365            .map(crate::state::Account::key)
366    {
367        state.park(
368            &key,
369            park::describe(
370                journal.provider,
371                &journal.park_service,
372                ctx.now(),
373                &document,
374            ),
375        );
376        kept += 1;
377    }
378    let incoming = read_park(ctx, &journal.incoming_service).flatten();
379    if incoming.is_some_and(|document| park::is_live_twin(ctx, journal.provider, &document)) {
380        state.discard(&journal.incoming_service);
381    } else if state
382        .by_id(journal.provider, &journal.to_id)
383        .and_then(|a| a.parked.as_ref())
384        .is_some()
385    {
386        kept += 1;
387    }
388    state::save(ctx, permit, state)?;
389    clear_journal(ctx, permit);
390    Ok(Some(Abandoned {
391        from: state.typed(&journal.from()),
392        to: state.typed(&journal.to()),
393        kept,
394    }))
395}
396
397/// The record of an interrupted switch, with every fact recovery decides it from that can be
398/// read without asking anybody.
399struct Waiting {
400    journal: Journal,
401    /// The park the record reserved: written, never written, or `None` if unreadable.
402    parked: Option<Option<Value>>,
403    owner: Owner,
404    seen: Seen,
405}
406
407/// The record of an interrupted switch, where one is waiting, and what recovery decides it
408/// from. Reads, and writes nothing whatever it finds.
409fn read(ctx: &Context, state: &State) -> Result<Option<Waiting>> {
410    let path = journal_path(ctx);
411    let raw = match std::fs::read_to_string(&path) {
412        Ok(r) => r,
413        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
414        Err(source) => return Err(Error::RecoveryFailed { path, source }),
415    };
416    // Written atomically, so a record that does not parse was damaged afterwards and says
417    // nothing about how far its switch got.
418    let journal = serde_json::from_str::<Journal>(&raw)
419        .map_err(|source| Error::RecoveryRecordCorrupt { path, source })?;
420
421    if let Some(slot) = &journal.slot {
422        let here = crate::provider::of(journal.provider).slot(ctx);
423        if *slot != here {
424            return Err(Error::RecoveryElsewhere {
425                tool: journal.provider,
426                from: state.typed(&journal.from()),
427                to: state.typed(&journal.to()),
428                slot: slot.clone(),
429            });
430        }
431    }
432
433    let named = in_use::named(ctx, journal.provider);
434    let (owner, login) = live_owner(ctx, &journal);
435    Ok(Some(Waiting {
436        parked: read_park(ctx, &journal.park_service),
437        journal,
438        owner,
439        seen: Seen { named, login },
440    }))
441}
442
443/// What finishes or undoes the switch, or, where the facts do not settle what it did, the
444/// refusal of the change that found them so.
445fn decide(
446    state: &State,
447    journal: &Journal,
448    parked: Option<Option<Value>>,
449    owner: std::result::Result<String, String>,
450) -> Result<Repair> {
451    let found = Found {
452        parked,
453        live_owner: owner.as_ref().ok().cloned(),
454    };
455    repair_for(state, journal, &found).ok_or_else(|| Error::RecoveryUndetermined {
456        tool: journal.provider,
457        from: state.typed(&journal.from()),
458        to: state.typed(&journal.to()),
459        detail: owner
460            .err()
461            .unwrap_or_else(|| "its parked login could not be read".into()),
462    })
463}
464
465pub(super) fn reconcile(
466    ctx: &Context,
467    permit: Permit,
468    state: &mut State,
469) -> Result<Option<Recovered>> {
470    let Some(Waiting {
471        journal,
472        parked,
473        owner,
474        seen,
475    }) = read(ctx, state)?
476    else {
477        return Ok(None);
478    };
479    let owner = ask(ctx, state, journal.provider, owner);
480    let repair = decide(state, &journal, parked, owner)?;
481    let finished = repair.landed;
482    apply(state, &journal, repair, seen, ctx.now());
483    state::save(ctx, permit, state)?;
484    clear_journal(ctx, permit);
485
486    Ok(Some(Recovered {
487        from: state.typed(&journal.from()),
488        to: state.typed(&journal.to()),
489        finished,
490    }))
491}
492
493/// Whether working out what an interrupted switch did may ask the service whose the live
494/// login is, where the record cannot say. A change asks, and so does `status`, which asks
495/// the service about every account anyway; `doctor` and `status_offline` send no request.
496/// A tool whose login names its own account is identified from the login either way, which
497/// asks nobody.
498#[derive(Clone, Copy, Debug, PartialEq, Eq)]
499pub(crate) enum Asking {
500    Service,
501    Nobody,
502}
503
504/// The refusal the next change would make over the interrupted switch waiting here, worked
505/// out by reading what [`reconcile`] reads and deciding as it decides, and changing nothing.
506///
507/// `None` where no switch is waiting, where the next change finishes or undoes it, and
508/// where telling which would need the service and `asking` says not to ask it. A record
509/// that cannot be read, or that was written for another slot, is refused by the next
510/// change for that reason and not for this one, and is not this function's to say.
511pub(super) fn refusal(ctx: &Context, state: &State, asking: Asking) -> Option<Error> {
512    let Waiting {
513        journal,
514        parked,
515        owner,
516        ..
517    } = read(ctx, state).ok()??;
518    if asking == Asking::Nobody
519        && matches!(owner, Owner::Unasked(_))
520        && !crate::provider::of(journal.provider).identifies_by_itself()
521    {
522        return None;
523    }
524    let owner = ask(ctx, state, journal.provider, owner);
525    decide(state, &journal, parked, owner).err()
526}
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531
532    const NOW: i64 = 1_700_000_100;
533    const PARK: &str = "pitboard-park-from-uuid-1700000000000";
534    const INCOMING: &str = "pitboard-park-to-uuid-1690000000000";
535
536    /// A record as 0.8.0 writes it. An account enrolled then kept its account uuid as its
537    /// id, so what the record names is still each account's id.
538    #[test]
539    fn a_record_0_8_0_wrote_names_each_side_by_its_id() {
540        let written: Journal = serde_json::from_value(serde_json::json!({
541            "provider": "claude",
542            "started_at": 1_700_000_000,
543            "from_label": "from",
544            "from_uuid": "from-uuid",
545            "to_label": "to",
546            "to_uuid": "to-uuid",
547            "park_service": PARK,
548            "incoming_service": INCOMING,
549            "from_fingerprint": "f",
550            "to_fingerprint": "t",
551            "slot": "Claude Code-credentials"
552        }))
553        .expect("still read");
554        assert_eq!(
555            (written.from_id.as_str(), written.to_id.as_str()),
556            ("from-uuid", "to-uuid")
557        );
558    }
559
560    fn journal() -> Journal {
561        Journal {
562            provider: ProviderId::Claude,
563            started_at: 1_700_000_000,
564            from_label: "from".into(),
565            from_id: "from-uuid".into(),
566            to_label: "to".into(),
567            to_id: "to-uuid".into(),
568            park_service: PARK.into(),
569            incoming_service: INCOMING.into(),
570            from_fingerprint: "ffffffffffffffff".into(),
571            to_fingerprint: "0000000000000000".into(),
572            slot: None,
573        }
574    }
575
576    /// A record written before fingerprints existed simply asks, which is what it always
577    /// did. Reading an old record must never be a reason to refuse.
578    #[test]
579    fn a_record_from_before_the_fingerprints_falls_back_to_asking() {
580        let mut j = journal();
581        j.from_fingerprint = String::new();
582        j.to_fingerprint = String::new();
583        assert_eq!(live_owner_by_fingerprint(&j, &fingerprint("refresh")), None);
584    }
585
586    /// Two sides that fingerprint the same are not two sides. Nothing can be read off that,
587    /// not even from a login that matches both.
588    #[test]
589    fn identical_fingerprints_settle_nothing() {
590        let mut j = journal();
591        j.from_fingerprint = fingerprint("refresh");
592        j.to_fingerprint = j.from_fingerprint.clone();
593        assert_eq!(live_owner_by_fingerprint(&j, &fingerprint("refresh")), None);
594    }
595
596    /// The fingerprint of a Claude Code login on `refresh`.
597    fn fingerprint(refresh: &str) -> String {
598        let live =
599            serde_json::json!({"claudeAiOauth": {"refreshToken": refresh, "accessToken": "a"}});
600        crate::provider::of(ProviderId::Claude).fingerprint(&live)
601    }
602
603    /// The store holding the login fingerprinted `login`, with the tool's own record naming
604    /// nobody.
605    fn seen(login: &str) -> Seen {
606        Seen {
607            named: None,
608            login: login.into(),
609        }
610    }
611
612    /// What a switch's head records before anything moves: the store holds `from`'s login,
613    /// as Anthropic said.
614    fn identified_from(s: &mut State) {
615        let from = InUse {
616            owner: Some(account("from", None).owner()),
617            login: journal().from_fingerprint,
618            known_at: NOW - 60,
619            named: None,
620        };
621        s.identified(ProviderId::Claude, from, NOW - 60);
622    }
623
624    fn account(label: &str, parked: Option<&str>) -> Account {
625        Account {
626            last_used_at: None,
627            replaced_at: None,
628            plan: None,
629            label: label.into(),
630            id: format!("{label}-uuid"),
631            account_uuid: format!("{label}-uuid"),
632            email: format!("{label}@example.com"),
633            detail: state::Detail::Claude {
634                organization_uuid: format!("{label}-org"),
635                oauth_account: serde_json::json!({}),
636            },
637            parked: parked.map(|s| Park {
638                service: s.into(),
639                parked_at: 1_699_000_000,
640                refresh_fingerprint: "f".into(),
641                access_expires_at: None,
642                refresh_expires_at: None,
643            }),
644        }
645    }
646
647    /// `from` signed in with nothing parked, `to` parked: the state before a switch.
648    fn before() -> State {
649        State {
650            accounts: vec![account("from", None), account("to", Some(INCOMING))],
651            ..State::default()
652        }
653    }
654
655    fn written() -> Option<Option<Value>> {
656        Some(Some(
657            serde_json::json!({"refreshToken": "outgoing", "accessToken": "a"}),
658        ))
659    }
660
661    fn found(parked: Option<Option<Value>>, owner: Option<&str>) -> Found {
662        Found {
663            parked,
664            live_owner: owner.map(str::to_owned),
665        }
666    }
667
668    /// Killed after reserving the park name but before writing it.
669    #[test]
670    fn nothing_parked_and_nothing_installed_changes_nothing() {
671        let repair = repair_for(&before(), &journal(), &found(Some(None), Some("from-uuid")));
672        assert_eq!(repair, Some(Repair::default()));
673    }
674
675    /// Killed after the park was written, before the install. `from` is still signed in, so
676    /// the park is a second copy of a live login and Claude Code will rotate past it.
677    #[test]
678    fn a_park_of_a_login_still_signed_in_is_dropped_not_kept() {
679        for s in [before(), {
680            let mut recorded = before();
681            recorded.park(
682                &crate::state::Key::new(crate::provider::ProviderId::Claude, "from"),
683                account("x", Some(PARK)).parked.unwrap(),
684            );
685            recorded
686        }] {
687            let repair = repair_for(&s, &journal(), &found(written(), Some("from-uuid"))).unwrap();
688            assert!(repair.drop && repair.hold.is_none() && !repair.landed);
689
690            let mut applied = s;
691            apply(
692                &mut applied,
693                &journal(),
694                repair,
695                seen(&journal().from_fingerprint),
696                NOW,
697            );
698            assert!(!applied.references(PARK));
699            assert!(applied.discarded.contains(&PARK.to_string()));
700        }
701    }
702
703    /// Killed after the install, before state recorded it. Claude Code may already have
704    /// rotated the token.
705    #[test]
706    fn a_landed_switch_holds_the_outgoing_login_and_consumes_the_incoming_one() {
707        let mut s = before();
708        identified_from(&mut s);
709        let repair = repair_for(&s, &journal(), &found(written(), Some("to-uuid"))).unwrap();
710        let (uuid, park) = repair.hold.clone().expect("the orphan must be recovered");
711        assert_eq!(uuid, "from-uuid", "held by account id, never by a label");
712        assert_eq!(park.service, PARK);
713        assert!(repair.landed);
714
715        let rotated = fingerprint("rotated-since");
716        apply(&mut s, &journal(), repair, seen(&rotated), NOW);
717        assert_eq!(
718            s.account_in_use(ProviderId::Claude)
719                .map(|a| a.label.as_str()),
720            Some("to")
721        );
722        assert_eq!(
723            s.in_use(ProviderId::Claude)
724                .map(|r| (r.login.as_str(), r.known_at)),
725            Some((rotated.as_str(), NOW)),
726            "the login the store holds, which Claude Code may have rotated since the install"
727        );
728        let from = s
729            .get(&crate::state::Key::new(
730                crate::provider::ProviderId::Claude,
731                "from",
732            ))
733            .unwrap();
734        assert_eq!(from.parked.as_ref().unwrap().service, PARK);
735        assert_eq!(
736            from.replaced_at, None,
737            "its login is parked, so nothing of it was lost"
738        );
739        assert!(
740            s.get(&crate::state::Key::new(
741                crate::provider::ProviderId::Claude,
742                "to"
743            ))
744            .unwrap()
745            .parked
746            .is_none(),
747            "the copy now live must never be offered again"
748        );
749        assert!(s.discarded.contains(&INCOMING.to_string()));
750    }
751
752    /// Someone signed in as a third account since: the orphan may be the only copy of
753    /// `from`'s login, and the destination's park may still be good.
754    #[test]
755    fn a_third_account_signed_in_since_keeps_both_parks() {
756        let mut s = before();
757        let repair = repair_for(&s, &journal(), &found(written(), Some("other-uuid"))).unwrap();
758        apply(&mut s, &journal(), repair, seen(&fingerprint("other")), NOW);
759        assert!(s.references(PARK) && s.references(INCOMING));
760        assert!(s.discarded.is_empty());
761    }
762
763    #[test]
764    fn an_already_recorded_park_is_not_held_twice() {
765        let mut s = before();
766        s.park(
767            &crate::state::Key::new(crate::provider::ProviderId::Claude, "from"),
768            account("x", Some(PARK)).parked.unwrap(),
769        );
770        let repair = repair_for(&s, &journal(), &found(written(), Some("to-uuid"))).unwrap();
771        assert_eq!(repair.hold, None);
772    }
773
774    #[test]
775    fn an_unknown_outcome_changes_nothing_and_keeps_the_record() {
776        for unknown in [found(written(), None), found(None, Some("to-uuid"))] {
777            assert_eq!(
778                repair_for(&before(), &journal(), &unknown),
779                None,
780                "could-not-tell must never be read as nothing-there"
781            );
782        }
783    }
784
785    #[test]
786    fn a_park_whose_account_was_forgotten_is_not_filed_under_another() {
787        let mut s = State {
788            accounts: vec![account("other", None)],
789            ..State::default()
790        };
791        identified_from(&mut s);
792        let repair = repair_for(&s, &journal(), &found(written(), Some("to-uuid"))).unwrap();
793        apply(
794            &mut s,
795            &journal(),
796            repair,
797            seen(&journal().to_fingerprint),
798            NOW,
799        );
800        assert!(
801            !s.references(PARK),
802            "a park must never be filed under whatever account happens to hold a label"
803        );
804        assert_eq!(
805            s.in_use(ProviderId::Claude),
806            None,
807            "the store no longer holds `from`'s login, and nobody here says whose it holds"
808        );
809    }
810
811    /// The destination forgotten and its label given to another account since. That account
812    /// is not the one whose login the switch installed, and the destination's park is not
813    /// its to consume.
814    #[test]
815    fn a_label_given_to_another_account_since_is_not_the_destination() {
816        const THEIRS: &str = "pitboard-park-someone-uuid-1695000000000";
817        let someone = Account {
818            id: "someone-uuid".into(),
819            account_uuid: "someone-uuid".into(),
820            ..account("to", Some(THEIRS))
821        };
822        let mut s = State {
823            accounts: vec![account("from", None), someone],
824            ..State::default()
825        };
826        identified_from(&mut s);
827        let repair = repair_for(&s, &journal(), &found(written(), Some("to-uuid"))).unwrap();
828        apply(
829            &mut s,
830            &journal(),
831            repair,
832            seen(&journal().to_fingerprint),
833            NOW,
834        );
835
836        assert_eq!(
837            s.in_use(ProviderId::Claude),
838            None,
839            "the login installed is not the account that holds the label now"
840        );
841        let to = s.get(&Key::new(ProviderId::Claude, "to")).unwrap();
842        assert_eq!(to.last_used_at, None, "never in use");
843        assert_eq!(to.parked.as_ref().map(|p| p.service.as_str()), Some(THEIRS));
844        assert!(
845            !s.discarded.contains(&INCOMING.to_string()),
846            "the destination's park is no account's here to consume"
847        );
848    }
849}