1use 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 pub(super) incoming_service: String,
25}
26
27#[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
65pub(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
78pub fn pending(ctx: &Context) -> bool {
80 journal_path(ctx).exists()
81}
82
83pub(super) fn clear_journal(ctx: &Context) {
85 let _ = std::fs::remove_file(journal_path(ctx));
86}
87
88struct Found {
89 parked: Option<Option<Value>>,
91 live_owner: Option<String>,
93}
94
95#[derive(Default, Debug, PartialEq)]
96struct Repair {
97 hold: Option<(String, Park)>,
100 drop: bool,
103 landed: bool,
105}
106
107fn 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 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 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 #[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 #[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 #[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 #[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}