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};
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        && let Some(label) = state.by_uuid(&uuid).map(|a| a.label.clone())
134    {
135        state.park(&label, park);
136    }
137    if repair.landed && state.get(&journal.to_label).is_some() {
138        state.active = Some(journal.to_label.clone());
139        state.discard(&journal.incoming_service);
140    }
141}
142
143fn read_park(ctx: &Context, service: &str) -> Option<Option<Value>> {
144    match store::vault_read(ctx, service) {
145        Ok(raw) => Some(raw.and_then(|r| serde_json::from_str(&r).ok())),
146        Err(_) => None,
147    }
148}
149
150fn live_owner(ctx: &Context) -> std::result::Result<String, String> {
151    let live = store::read(ctx, &claude::live_service(ctx))
152        .map_err(|e| e.to_string())?
153        .ok_or("nothing is signed in")?;
154    let token = live["claudeAiOauth"]["accessToken"]
155        .as_str()
156        .ok_or("the signed-in credential has no access token")?;
157    identify(ctx, token)
158        .map(|owner| owner.account_uuid)
159        .map_err(|e| e.to_string())
160}
161
162pub(super) fn reconcile(ctx: &Context, state: &mut State) -> Result<Option<Recovered>> {
163    let path = journal_path(ctx);
164    let raw = match std::fs::read_to_string(&path) {
165        Ok(r) => r,
166        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
167        Err(source) => return Err(Error::RecoveryFailed { path, source }),
168    };
169    // Written atomically, so a record that does not parse was damaged afterwards and says
170    // nothing about how far its switch got.
171    let journal = serde_json::from_str::<Journal>(&raw)
172        .map_err(|source| Error::RecoveryRecordCorrupt { path, source })?;
173
174    let owner = live_owner(ctx);
175    let found = Found {
176        parked: read_park(ctx, &journal.park_service),
177        live_owner: owner.as_ref().ok().cloned(),
178    };
179    let Some(repair) = repair_for(state, &journal, &found) else {
180        return Err(Error::RecoveryUndetermined {
181            from: journal.from_label,
182            to: journal.to_label,
183            detail: owner
184                .err()
185                .unwrap_or_else(|| "its parked login could not be read".into()),
186        });
187    };
188    let finished = repair.landed;
189    apply(state, &journal, repair);
190    state::save(ctx, state)?;
191    clear_journal(ctx);
192
193    Ok(Some(Recovered {
194        from: journal.from_label,
195        to: journal.to_label,
196        finished,
197    }))
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use crate::state::Account;
204
205    const PARK: &str = "pitboard-park-from-uuid-1700000000000";
206    const INCOMING: &str = "pitboard-park-to-uuid-1690000000000";
207
208    fn journal() -> Journal {
209        Journal {
210            started_at: 1_700_000_000,
211            from_label: "from".into(),
212            from_uuid: "from-uuid".into(),
213            to_label: "to".into(),
214            to_uuid: "to-uuid".into(),
215            park_service: PARK.into(),
216            incoming_service: INCOMING.into(),
217        }
218    }
219
220    fn account(label: &str, parked: Option<&str>) -> Account {
221        Account {
222            label: label.into(),
223            account_uuid: format!("{label}-uuid"),
224            email: format!("{label}@example.com"),
225            organization_uuid: format!("{label}-org"),
226            oauth_account: serde_json::json!({}),
227            parked: parked.map(|s| Park {
228                service: s.into(),
229                parked_at: 1_699_000_000,
230                refresh_fingerprint: "f".into(),
231                access_expires_at: None,
232                refresh_expires_at: None,
233            }),
234        }
235    }
236
237    /// `from` signed in with nothing parked, `to` parked: the state before a switch.
238    fn before() -> State {
239        State {
240            accounts: vec![account("from", None), account("to", Some(INCOMING))],
241            ..State::default()
242        }
243    }
244
245    fn written() -> Option<Option<Value>> {
246        Some(Some(
247            serde_json::json!({"refreshToken": "outgoing", "accessToken": "a"}),
248        ))
249    }
250
251    fn found(parked: Option<Option<Value>>, owner: Option<&str>) -> Found {
252        Found {
253            parked,
254            live_owner: owner.map(str::to_owned),
255        }
256    }
257
258    /// Killed after reserving the park name but before writing it.
259    #[test]
260    fn nothing_parked_and_nothing_installed_changes_nothing() {
261        let repair = repair_for(&before(), &journal(), &found(Some(None), Some("from-uuid")));
262        assert_eq!(repair, Some(Repair::default()));
263    }
264
265    /// Killed after the park was written, before the install. `from` is still signed in, so
266    /// the park is a second copy of a live login and Claude Code will rotate past it.
267    #[test]
268    fn a_park_of_a_login_still_signed_in_is_dropped_not_kept() {
269        for s in [before(), {
270            let mut recorded = before();
271            recorded.park("from", account("x", Some(PARK)).parked.unwrap());
272            recorded
273        }] {
274            let repair = repair_for(&s, &journal(), &found(written(), Some("from-uuid"))).unwrap();
275            assert!(repair.drop && repair.hold.is_none() && !repair.landed);
276
277            let mut applied = s;
278            apply(&mut applied, &journal(), repair);
279            assert!(!applied.references(PARK));
280            assert!(applied.discarded.contains(&PARK.to_string()));
281        }
282    }
283
284    /// Killed after the install, before state recorded it. Claude Code may already have
285    /// rotated the token.
286    #[test]
287    fn a_landed_switch_holds_the_outgoing_login_and_consumes_the_incoming_one() {
288        let mut s = before();
289        let repair = repair_for(&s, &journal(), &found(written(), Some("to-uuid"))).unwrap();
290        let (uuid, park) = repair.hold.clone().expect("the orphan must be recovered");
291        assert_eq!(uuid, "from-uuid", "held by account id, never by a label");
292        assert_eq!(park.service, PARK);
293        assert!(repair.landed);
294
295        apply(&mut s, &journal(), repair);
296        assert_eq!(s.active.as_deref(), Some("to"));
297        assert_eq!(
298            s.get("from").unwrap().parked.as_ref().unwrap().service,
299            PARK
300        );
301        assert!(
302            s.get("to").unwrap().parked.is_none(),
303            "the copy now live must never be offered again"
304        );
305        assert!(s.discarded.contains(&INCOMING.to_string()));
306    }
307
308    /// Someone signed in as a third account since: the orphan may be the only copy of
309    /// `from`'s login, and the destination's park may still be good.
310    #[test]
311    fn a_third_account_signed_in_since_keeps_both_parks() {
312        let mut s = before();
313        let repair = repair_for(&s, &journal(), &found(written(), Some("other-uuid"))).unwrap();
314        apply(&mut s, &journal(), repair);
315        assert!(s.references(PARK) && s.references(INCOMING));
316        assert!(s.discarded.is_empty());
317    }
318
319    #[test]
320    fn an_already_recorded_park_is_not_held_twice() {
321        let mut s = before();
322        s.park("from", account("x", Some(PARK)).parked.unwrap());
323        let repair = repair_for(&s, &journal(), &found(written(), Some("to-uuid"))).unwrap();
324        assert_eq!(repair.hold, None);
325    }
326
327    #[test]
328    fn an_unknown_outcome_changes_nothing_and_keeps_the_record() {
329        for unknown in [found(written(), None), found(None, Some("to-uuid"))] {
330            assert_eq!(
331                repair_for(&before(), &journal(), &unknown),
332                None,
333                "could-not-tell must never be read as nothing-there"
334            );
335        }
336    }
337
338    #[test]
339    fn a_park_whose_account_was_forgotten_is_not_filed_under_another() {
340        let mut s = State {
341            accounts: vec![account("other", None)],
342            ..State::default()
343        };
344        let repair = repair_for(&s, &journal(), &found(written(), Some("to-uuid"))).unwrap();
345        apply(&mut s, &journal(), repair);
346        assert!(
347            !s.references(PARK),
348            "a park must never be filed under whatever account happens to hold a label"
349        );
350        assert_eq!(
351            s.active, None,
352            "a destination that is gone is not made active"
353        );
354    }
355}