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};
9use crate::context::Context;
10use crate::state::{Park, State};
11use crate::{atomic, claude, home, park, state, store, time};
12use serde_json::Value;
13use std::path::PathBuf;
14
15#[derive(serde::Serialize, serde::Deserialize)]
16pub(super) struct Journal {
17    pub(super) started_at: i64,
18    pub(super) from_label: String,
19    pub(super) from_uuid: String,
20    pub(super) to_label: String,
21    pub(super) to_uuid: String,
22    pub(super) park_service: String,
23    /// The park being installed, so recovery consumes exactly that copy.
24    pub(super) incoming_service: String,
25}
26
27/// What a later run found an interrupted switch had done, now recorded in the state.
28#[derive(Debug)]
29pub struct Recovered {
30    pub from: String,
31    pub to: String,
32    pub finished: bool,
33}
34
35impl Recovered {
36    pub fn code(&self) -> &'static str {
37        if self.finished {
38            "interrupted_switch_finished"
39        } else {
40            "interrupted_switch_undone"
41        }
42    }
43}
44
45impl std::fmt::Display for Recovered {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        write!(
48            f,
49            "an earlier switch from `{}` to `{}` was interrupted; {}",
50            self.from,
51            self.to,
52            if self.finished {
53                "it had in fact finished, and pitboard has recorded that"
54            } else {
55                "it had not finished, and nothing was lost"
56            }
57        )
58    }
59}
60
61fn journal_path(ctx: &Context) -> PathBuf {
62    home::dir(ctx).join("journal.json")
63}
64
65/// Durable before the park it names is created: a record lost to a crash would leave a
66/// consumed login looking restorable.
67pub(super) fn write_journal(ctx: &Context, entry: &Journal) -> Result<()> {
68    let path = journal_path(ctx);
69    let fail = |source| Error::RecoveryFailed {
70        path: path.clone(),
71        source,
72    };
73    home::ensure(ctx).map_err(fail)?;
74    let body = serde_json::to_string(entry).expect("a journal entry is always serialisable");
75    atomic::write(&path, body.as_bytes(), atomic::Perms::Secret).map_err(fail)
76}
77
78/// A switch was interrupted, and the next command that changes state will finish it.
79pub fn pending(ctx: &Context) -> bool {
80    journal_path(ctx).exists()
81}
82
83/// The switch reached a state the account index fully describes.
84pub(super) fn clear_journal(ctx: &Context) {
85    let _ = std::fs::remove_file(journal_path(ctx));
86}
87
88struct Found {
89    /// The park the record reserved: written, never written, or `None` if unreadable.
90    parked: Option<Option<Value>>,
91    /// The account the live login belongs to, or `None` if that cannot be learned.
92    live_owner: Option<String>,
93}
94
95#[derive(Default, Debug, PartialEq)]
96struct Repair {
97    /// Hold the interrupted run's park for the account it came from. Keyed by account id,
98    /// not label: the label may have been reused since.
99    hold: Option<(String, Park)>,
100    /// The interrupted run's park copies a login that is still signed in, so Claude Code
101    /// will rotate past it.
102    drop: bool,
103    /// The destination's login is live: it is active, and its park was consumed.
104    landed: bool,
105}
106
107/// `None` when the facts do not settle what happened.
108fn repair_for(state: &State, journal: &Journal, found: &Found) -> Option<Repair> {
109    let parked = found.parked.as_ref()?;
110    let owner = found.live_owner.as_deref()?;
111    let mut repair = Repair {
112        landed: owner == journal.to_uuid,
113        ..Repair::default()
114    };
115    if owner == journal.from_uuid {
116        repair.drop = parked.is_some();
117    } else if let Some(oauth) = parked
118        && !state.references(&journal.park_service)
119    {
120        repair.hold = Some((
121            journal.from_uuid.clone(),
122            park::describe(&journal.park_service, journal.started_at, oauth),
123        ));
124    }
125    Some(repair)
126}
127
128fn apply(state: &mut State, journal: &Journal, repair: Repair) {
129    if repair.drop {
130        state.discard(&journal.park_service);
131    }
132    if let Some((uuid, park)) = repair.hold {
133        match state.by_uuid(&uuid).map(|a| a.label.clone()) {
134            Some(label) => state.park(&label, park),
135            // The account it belongs to is gone, so nothing will ever restore this copy.
136            // Listing it is what gets it deleted rather than left in the keychain.
137            None => state.discard(&park.service),
138        }
139    }
140    if repair.landed && state.get(&journal.to_label).is_some() {
141        state.active = Some(journal.to_label.clone());
142        state.discard(&journal.incoming_service);
143    }
144}
145
146fn read_park(ctx: &Context, service: &str) -> Option<Option<Value>> {
147    match store::vault_read(ctx, service) {
148        Ok(raw) => Some(raw.and_then(|r| serde_json::from_str(&r).ok())),
149        Err(_) => None,
150    }
151}
152
153fn live_owner(ctx: &Context) -> std::result::Result<String, String> {
154    let live = store::read(ctx, &claude::live_service(ctx))
155        .map_err(|e| e.to_string())?
156        .ok_or("nothing is signed in")?;
157    let token = live["claudeAiOauth"]["accessToken"]
158        .as_str()
159        .ok_or("the signed-in credential has no access token")?;
160    identify(ctx, token)
161        .map(|owner| owner.account_uuid)
162        .map_err(|e| e.to_string())
163}
164
165/// What abandoning an unfinishable record decided to keep.
166#[derive(Debug)]
167pub struct Abandoned {
168    pub from: String,
169    pub to: String,
170    /// Copies kept rather than deleted, because which one is live is now unknown.
171    pub kept: usize,
172}
173
174/// Throws away a record that cannot be finished, keeping every copy it names.
175///
176/// Recovery needs Anthropic to say who owns the live login. Offline, or with a session
177/// Anthropic no longer accepts, it cannot, and every command that changes anything stops
178/// at that. This is the way out: nothing is deleted and nothing is installed, so the worst
179/// case is a copy that outlives its use, which `status` shows and `doctor` reports.
180pub(super) fn abandon(ctx: &Context, state: &mut State) -> Result<Option<Abandoned>> {
181    let path = journal_path(ctx);
182    let raw = match std::fs::read_to_string(&path) {
183        Ok(r) => r,
184        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
185        Err(source) => return Err(Error::RecoveryFailed { path, source }),
186    };
187    let journal = serde_json::from_str::<Journal>(&raw)
188        .map_err(|source| Error::RecoveryRecordCorrupt { path, source })?;
189
190    // The copy the interrupted run parked is recorded against the account it came from, so
191    // nothing holds a keychain item that no file names.
192    let mut kept = 0;
193    if let Some(Some(document)) = read_park(ctx, &journal.park_service)
194        && let Some(label) = state.by_uuid(&journal.from_uuid).map(|a| a.label.clone())
195    {
196        state.park(
197            &label,
198            park::describe(&journal.park_service, time::now(), &document),
199        );
200        kept += 1;
201    }
202    if state
203        .by_uuid(&journal.to_uuid)
204        .and_then(|a| a.parked.as_ref())
205        .is_some()
206    {
207        kept += 1;
208    }
209    state::save(ctx, state)?;
210    clear_journal(ctx);
211    Ok(Some(Abandoned {
212        from: journal.from_label,
213        to: journal.to_label,
214        kept,
215    }))
216}
217
218pub(super) fn reconcile(ctx: &Context, state: &mut State) -> Result<Option<Recovered>> {
219    let path = journal_path(ctx);
220    let raw = match std::fs::read_to_string(&path) {
221        Ok(r) => r,
222        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
223        Err(source) => return Err(Error::RecoveryFailed { path, source }),
224    };
225    // Written atomically, so a record that does not parse was damaged afterwards and says
226    // nothing about how far its switch got.
227    let journal = serde_json::from_str::<Journal>(&raw)
228        .map_err(|source| Error::RecoveryRecordCorrupt { path, source })?;
229
230    let owner = live_owner(ctx);
231    let found = Found {
232        parked: read_park(ctx, &journal.park_service),
233        live_owner: owner.as_ref().ok().cloned(),
234    };
235    let Some(repair) = repair_for(state, &journal, &found) else {
236        return Err(Error::RecoveryUndetermined {
237            from: journal.from_label,
238            to: journal.to_label,
239            detail: owner
240                .err()
241                .unwrap_or_else(|| "its parked login could not be read".into()),
242        });
243    };
244    let finished = repair.landed;
245    apply(state, &journal, repair);
246    state::save(ctx, state)?;
247    clear_journal(ctx);
248
249    Ok(Some(Recovered {
250        from: journal.from_label,
251        to: journal.to_label,
252        finished,
253    }))
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    use crate::state::Account;
260
261    const PARK: &str = "pitboard-park-from-uuid-1700000000000";
262    const INCOMING: &str = "pitboard-park-to-uuid-1690000000000";
263
264    fn journal() -> Journal {
265        Journal {
266            started_at: 1_700_000_000,
267            from_label: "from".into(),
268            from_uuid: "from-uuid".into(),
269            to_label: "to".into(),
270            to_uuid: "to-uuid".into(),
271            park_service: PARK.into(),
272            incoming_service: INCOMING.into(),
273        }
274    }
275
276    fn account(label: &str, parked: Option<&str>) -> Account {
277        Account {
278            label: label.into(),
279            account_uuid: format!("{label}-uuid"),
280            email: format!("{label}@example.com"),
281            organization_uuid: format!("{label}-org"),
282            oauth_account: serde_json::json!({}),
283            parked: parked.map(|s| Park {
284                service: s.into(),
285                parked_at: 1_699_000_000,
286                refresh_fingerprint: "f".into(),
287                access_expires_at: None,
288                refresh_expires_at: None,
289            }),
290        }
291    }
292
293    /// `from` signed in with nothing parked, `to` parked: the state before a switch.
294    fn before() -> State {
295        State {
296            accounts: vec![account("from", None), account("to", Some(INCOMING))],
297            ..State::default()
298        }
299    }
300
301    fn written() -> Option<Option<Value>> {
302        Some(Some(
303            serde_json::json!({"refreshToken": "outgoing", "accessToken": "a"}),
304        ))
305    }
306
307    fn found(parked: Option<Option<Value>>, owner: Option<&str>) -> Found {
308        Found {
309            parked,
310            live_owner: owner.map(str::to_owned),
311        }
312    }
313
314    /// Killed after reserving the park name but before writing it.
315    #[test]
316    fn nothing_parked_and_nothing_installed_changes_nothing() {
317        let repair = repair_for(&before(), &journal(), &found(Some(None), Some("from-uuid")));
318        assert_eq!(repair, Some(Repair::default()));
319    }
320
321    /// Killed after the park was written, before the install. `from` is still signed in, so
322    /// the park is a second copy of a live login and Claude Code will rotate past it.
323    #[test]
324    fn a_park_of_a_login_still_signed_in_is_dropped_not_kept() {
325        for s in [before(), {
326            let mut recorded = before();
327            recorded.park("from", account("x", Some(PARK)).parked.unwrap());
328            recorded
329        }] {
330            let repair = repair_for(&s, &journal(), &found(written(), Some("from-uuid"))).unwrap();
331            assert!(repair.drop && repair.hold.is_none() && !repair.landed);
332
333            let mut applied = s;
334            apply(&mut applied, &journal(), repair);
335            assert!(!applied.references(PARK));
336            assert!(applied.discarded.contains(&PARK.to_string()));
337        }
338    }
339
340    /// Killed after the install, before state recorded it. Claude Code may already have
341    /// rotated the token.
342    #[test]
343    fn a_landed_switch_holds_the_outgoing_login_and_consumes_the_incoming_one() {
344        let mut s = before();
345        let repair = repair_for(&s, &journal(), &found(written(), Some("to-uuid"))).unwrap();
346        let (uuid, park) = repair.hold.clone().expect("the orphan must be recovered");
347        assert_eq!(uuid, "from-uuid", "held by account id, never by a label");
348        assert_eq!(park.service, PARK);
349        assert!(repair.landed);
350
351        apply(&mut s, &journal(), repair);
352        assert_eq!(s.active.as_deref(), Some("to"));
353        assert_eq!(
354            s.get("from").unwrap().parked.as_ref().unwrap().service,
355            PARK
356        );
357        assert!(
358            s.get("to").unwrap().parked.is_none(),
359            "the copy now live must never be offered again"
360        );
361        assert!(s.discarded.contains(&INCOMING.to_string()));
362    }
363
364    /// Someone signed in as a third account since: the orphan may be the only copy of
365    /// `from`'s login, and the destination's park may still be good.
366    #[test]
367    fn a_third_account_signed_in_since_keeps_both_parks() {
368        let mut s = before();
369        let repair = repair_for(&s, &journal(), &found(written(), Some("other-uuid"))).unwrap();
370        apply(&mut s, &journal(), repair);
371        assert!(s.references(PARK) && s.references(INCOMING));
372        assert!(s.discarded.is_empty());
373    }
374
375    #[test]
376    fn an_already_recorded_park_is_not_held_twice() {
377        let mut s = before();
378        s.park("from", account("x", Some(PARK)).parked.unwrap());
379        let repair = repair_for(&s, &journal(), &found(written(), Some("to-uuid"))).unwrap();
380        assert_eq!(repair.hold, None);
381    }
382
383    #[test]
384    fn an_unknown_outcome_changes_nothing_and_keeps_the_record() {
385        for unknown in [found(written(), None), found(None, Some("to-uuid"))] {
386            assert_eq!(
387                repair_for(&before(), &journal(), &unknown),
388                None,
389                "could-not-tell must never be read as nothing-there"
390            );
391        }
392    }
393
394    #[test]
395    fn a_park_whose_account_was_forgotten_is_not_filed_under_another() {
396        let mut s = State {
397            accounts: vec![account("other", None)],
398            ..State::default()
399        };
400        let repair = repair_for(&s, &journal(), &found(written(), Some("to-uuid"))).unwrap();
401        apply(&mut s, &journal(), repair);
402        assert!(
403            !s.references(PARK),
404            "a park must never be filed under whatever account happens to hold a label"
405        );
406        assert_eq!(
407            s.active, None,
408            "a destination that is gone is not made active"
409        );
410    }
411}