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