1use 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 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 match state.by_uuid(&uuid).map(|a| a.label.clone()) {
134 Some(label) => state.park(&label, park),
135 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#[derive(Debug)]
167pub struct Abandoned {
168 pub from: String,
169 pub to: String,
170 pub kept: usize,
172}
173
174pub(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 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 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 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 #[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 #[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 #[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 #[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}