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