1use 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 #[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 pub(super) incoming_service: String,
33 #[serde(default)]
39 pub(super) from_fingerprint: String,
40 #[serde(default)]
41 pub(super) to_fingerprint: String,
42 #[serde(default)]
49 pub(super) slot: Option<String>,
50}
51
52#[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
104pub(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
117pub fn pending(ctx: &Context) -> bool {
119 journal_path(ctx).exists()
120}
121
122pub(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
130pub(super) fn clear_journal(ctx: &Context) {
132 let _ = std::fs::remove_file(journal_path(ctx));
133}
134
135struct Found {
136 parked: Option<Option<Value>>,
138 live_owner: Option<String>,
140}
141
142#[derive(Default, Debug, PartialEq)]
143struct Repair {
144 hold: Option<(String, Park)>,
147 drop: bool,
150 landed: bool,
152}
153
154fn 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 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
219fn 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#[derive(Debug)]
257pub struct Abandoned {
258 pub from: String,
259 pub to: String,
260 pub kept: usize,
262}
263
264pub(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 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 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 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 #[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 #[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 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 #[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 #[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 #[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 #[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}