1use super::{Error, Result, identify_document};
9use crate::context::Context;
10use crate::in_use::{self, InUse};
11use crate::provider::ProviderId;
12use crate::service::Permit;
13use crate::state::{Account, Key, Park, State};
14use crate::{atomic, home, park, state, store};
15use serde_json::Value;
16use std::path::PathBuf;
17
18#[derive(serde::Serialize, serde::Deserialize)]
19pub(super) struct Journal {
20 #[serde(default = "claude")]
26 pub(super) provider: ProviderId,
27 pub(super) started_at: i64,
28 pub(super) from_label: String,
29 #[serde(rename = "from_uuid")]
31 pub(super) from_id: String,
32 pub(super) to_label: String,
33 #[serde(rename = "to_uuid")]
34 pub(super) to_id: String,
35 pub(super) park_service: String,
36 pub(super) incoming_service: String,
38 #[serde(default)]
44 pub(super) from_fingerprint: String,
45 #[serde(default)]
46 pub(super) to_fingerprint: String,
47 #[serde(default)]
54 pub(super) slot: Option<String>,
55}
56
57#[derive(Debug)]
59pub struct Recovered {
60 pub from: String,
61 pub to: String,
62 pub finished: bool,
63}
64
65impl Recovered {
66 pub fn code(&self) -> &'static str {
67 if self.finished {
68 "interrupted_switch_finished"
69 } else {
70 "interrupted_switch_undone"
71 }
72 }
73}
74
75impl std::fmt::Display for Recovered {
76 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77 write!(
78 f,
79 "an earlier switch from `{}` to `{}` was interrupted; {}",
80 self.from,
81 self.to,
82 if self.finished {
83 "it had in fact finished, and Pitboard has recorded that"
84 } else {
85 "it had not finished, and nothing was lost"
86 }
87 )
88 }
89}
90
91fn claude() -> ProviderId {
92 ProviderId::Claude
93}
94
95impl Journal {
96 fn from(&self) -> Key {
97 Key::new(self.provider, self.from_label.clone())
98 }
99
100 fn to(&self) -> Key {
101 Key::new(self.provider, self.to_label.clone())
102 }
103}
104
105fn journal_path(ctx: &Context) -> PathBuf {
106 home::dir(ctx).join("journal.json")
107}
108
109pub(super) fn write_journal(ctx: &Context, permit: Permit, entry: &Journal) -> Result<()> {
112 let path = journal_path(ctx);
113 let fail = |source| Error::RecoveryFailed {
114 path: path.clone(),
115 source,
116 };
117 home::ensure(ctx, permit).map_err(fail)?;
118 let body = serde_json::to_string(entry).expect("a journal entry is always serialisable");
119 atomic::write(permit, &path, body.as_bytes(), atomic::Perms::Secret).map_err(fail)
120}
121
122pub fn pending(ctx: &Context) -> bool {
124 journal_path(ctx).exists()
125}
126
127pub(crate) fn interrupted_tool(ctx: &Context) -> Option<ProviderId> {
129 let raw = std::fs::read_to_string(journal_path(ctx)).ok()?;
130 serde_json::from_str::<Journal>(&raw)
131 .ok()
132 .map(|journal| journal.provider)
133}
134
135pub(super) fn clear_journal(ctx: &Context, permit: Permit) {
137 let _ = crate::host::fs::remove_file(permit, &journal_path(ctx));
138}
139
140struct Found {
141 parked: Option<Option<Value>>,
143 live_owner: Option<String>,
145}
146
147#[derive(Default, Debug, PartialEq)]
148struct Repair {
149 hold: Option<(String, Park)>,
152 drop: bool,
155 landed: bool,
157}
158
159struct Seen {
162 named: Option<String>,
164 login: String,
167}
168
169fn repair_for(state: &State, journal: &Journal, found: &Found) -> Option<Repair> {
171 let parked = found.parked.as_ref()?;
172 let owner = found.live_owner.as_deref()?;
173 let mut repair = Repair {
174 landed: owner == journal.to_id,
175 ..Repair::default()
176 };
177 if owner == journal.from_id {
178 repair.drop = parked.is_some();
179 } else if let Some(oauth) = parked
180 && !state.references(&journal.park_service)
181 {
182 repair.hold = Some((
183 journal.from_id.clone(),
184 park::describe(
185 journal.provider,
186 &journal.park_service,
187 journal.started_at,
188 oauth,
189 ),
190 ));
191 }
192 Some(repair)
193}
194
195fn apply(state: &mut State, journal: &Journal, repair: Repair, seen: Seen, at: i64) {
198 if repair.drop {
199 state.discard(&journal.park_service);
200 }
201 if let Some((id, park)) = repair.hold {
202 match state.by_id(journal.provider, &id).map(Account::key) {
203 Some(key) => state.park(&key, park),
204 None => state.release(&park.service),
207 }
208 }
209 if !repair.landed {
210 return;
211 }
212 match state
213 .by_id(journal.provider, &journal.to_id)
214 .map(Account::owner)
215 {
216 Some(owner) => {
217 let installed = InUse {
218 owner: Some(owner),
219 login: seen.login,
220 known_at: at,
221 named: seen.named,
222 };
223 state.identified(journal.provider, installed, at);
224 state.discard(&journal.incoming_service);
225 }
226 None => {
230 state.in_use.remove(journal.provider.code());
231 }
232 }
233}
234
235fn read_park(ctx: &Context, service: &str) -> Option<Option<Value>> {
236 match store::vault_read(ctx, service) {
237 Ok(raw) => Some(raw.and_then(|r| serde_json::from_str(&r).ok())),
238 Err(_) => None,
239 }
240}
241
242enum Owner {
244 Is(String),
246 Unknown(String),
248 Unasked(Value),
250}
251
252fn live_owner(ctx: &Context, journal: &Journal) -> (Owner, String) {
258 let tool = crate::provider::of(journal.provider);
259 match tool.read_live(ctx) {
260 Err(e) => (Owner::Unknown(e.to_string()), String::new()),
261 Ok(None) => (Owner::Unknown("nothing is signed in".into()), String::new()),
262 Ok(Some(live)) => {
263 let login = tool.fingerprint(&live.raw);
264 let owner = match live_owner_by_fingerprint(journal, &login) {
265 Some(id) => Owner::Is(id),
266 None => Owner::Unasked(live.raw),
267 };
268 (owner, login)
269 }
270 }
271}
272
273fn ask(
276 ctx: &Context,
277 state: &State,
278 which: ProviderId,
279 owner: Owner,
280) -> std::result::Result<String, String> {
281 match owner {
282 Owner::Is(id) => Ok(id),
283 Owner::Unknown(why) => Err(why),
284 Owner::Unasked(live) => identify_document(ctx, which, &live)
285 .map(|owner| state.id_of(which, &owner))
286 .map_err(|e| e.to_string()),
287 }
288}
289
290fn live_owner_by_fingerprint(journal: &Journal, found: &str) -> Option<String> {
303 if journal.from_fingerprint.is_empty() || journal.to_fingerprint.is_empty() {
304 return None;
305 }
306 if journal.from_fingerprint == journal.to_fingerprint {
307 return None;
308 }
309 if found.is_empty() {
310 return None;
311 }
312 if found == journal.to_fingerprint {
313 Some(journal.to_id.clone())
314 } else if found == journal.from_fingerprint {
315 Some(journal.from_id.clone())
316 } else {
317 None
318 }
319}
320
321#[derive(Debug)]
323pub struct Abandoned {
324 pub from: String,
325 pub to: String,
326 pub kept: usize,
328}
329
330pub(super) fn abandon(
342 ctx: &Context,
343 permit: Permit,
344 state: &mut State,
345) -> Result<Option<Abandoned>> {
346 let path = journal_path(ctx);
347 let raw = match std::fs::read_to_string(&path) {
348 Ok(r) => r,
349 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
350 Err(source) => return Err(Error::RecoveryFailed { path, source }),
351 };
352 let journal = serde_json::from_str::<Journal>(&raw)
353 .map_err(|source| Error::RecoveryRecordCorrupt { path, source })?;
354
355 let mut kept = 0;
358 if let Some(Some(document)) = read_park(ctx, &journal.park_service)
359 && park::is_live_twin(ctx, journal.provider, &document)
360 {
361 state.discard(&journal.park_service);
362 } else if let Some(Some(document)) = read_park(ctx, &journal.park_service)
363 && let Some(key) = state
364 .by_id(journal.provider, &journal.from_id)
365 .map(crate::state::Account::key)
366 {
367 state.park(
368 &key,
369 park::describe(
370 journal.provider,
371 &journal.park_service,
372 ctx.now(),
373 &document,
374 ),
375 );
376 kept += 1;
377 }
378 let incoming = read_park(ctx, &journal.incoming_service).flatten();
379 if incoming.is_some_and(|document| park::is_live_twin(ctx, journal.provider, &document)) {
380 state.discard(&journal.incoming_service);
381 } else if state
382 .by_id(journal.provider, &journal.to_id)
383 .and_then(|a| a.parked.as_ref())
384 .is_some()
385 {
386 kept += 1;
387 }
388 state::save(ctx, permit, state)?;
389 clear_journal(ctx, permit);
390 Ok(Some(Abandoned {
391 from: state.typed(&journal.from()),
392 to: state.typed(&journal.to()),
393 kept,
394 }))
395}
396
397struct Waiting {
400 journal: Journal,
401 parked: Option<Option<Value>>,
403 owner: Owner,
404 seen: Seen,
405}
406
407fn read(ctx: &Context, state: &State) -> Result<Option<Waiting>> {
410 let path = journal_path(ctx);
411 let raw = match std::fs::read_to_string(&path) {
412 Ok(r) => r,
413 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
414 Err(source) => return Err(Error::RecoveryFailed { path, source }),
415 };
416 let journal = serde_json::from_str::<Journal>(&raw)
419 .map_err(|source| Error::RecoveryRecordCorrupt { path, source })?;
420
421 if let Some(slot) = &journal.slot {
422 let here = crate::provider::of(journal.provider).slot(ctx);
423 if *slot != here {
424 return Err(Error::RecoveryElsewhere {
425 tool: journal.provider,
426 from: state.typed(&journal.from()),
427 to: state.typed(&journal.to()),
428 slot: slot.clone(),
429 });
430 }
431 }
432
433 let named = in_use::named(ctx, journal.provider);
434 let (owner, login) = live_owner(ctx, &journal);
435 Ok(Some(Waiting {
436 parked: read_park(ctx, &journal.park_service),
437 journal,
438 owner,
439 seen: Seen { named, login },
440 }))
441}
442
443fn decide(
446 state: &State,
447 journal: &Journal,
448 parked: Option<Option<Value>>,
449 owner: std::result::Result<String, String>,
450) -> Result<Repair> {
451 let found = Found {
452 parked,
453 live_owner: owner.as_ref().ok().cloned(),
454 };
455 repair_for(state, journal, &found).ok_or_else(|| Error::RecoveryUndetermined {
456 tool: journal.provider,
457 from: state.typed(&journal.from()),
458 to: state.typed(&journal.to()),
459 detail: owner
460 .err()
461 .unwrap_or_else(|| "its parked login could not be read".into()),
462 })
463}
464
465pub(super) fn reconcile(
466 ctx: &Context,
467 permit: Permit,
468 state: &mut State,
469) -> Result<Option<Recovered>> {
470 let Some(Waiting {
471 journal,
472 parked,
473 owner,
474 seen,
475 }) = read(ctx, state)?
476 else {
477 return Ok(None);
478 };
479 let owner = ask(ctx, state, journal.provider, owner);
480 let repair = decide(state, &journal, parked, owner)?;
481 let finished = repair.landed;
482 apply(state, &journal, repair, seen, ctx.now());
483 state::save(ctx, permit, state)?;
484 clear_journal(ctx, permit);
485
486 Ok(Some(Recovered {
487 from: state.typed(&journal.from()),
488 to: state.typed(&journal.to()),
489 finished,
490 }))
491}
492
493#[derive(Clone, Copy, Debug, PartialEq, Eq)]
499pub(crate) enum Asking {
500 Service,
501 Nobody,
502}
503
504pub(super) fn refusal(ctx: &Context, state: &State, asking: Asking) -> Option<Error> {
512 let Waiting {
513 journal,
514 parked,
515 owner,
516 ..
517 } = read(ctx, state).ok()??;
518 if asking == Asking::Nobody
519 && matches!(owner, Owner::Unasked(_))
520 && !crate::provider::of(journal.provider).identifies_by_itself()
521 {
522 return None;
523 }
524 let owner = ask(ctx, state, journal.provider, owner);
525 decide(state, &journal, parked, owner).err()
526}
527
528#[cfg(test)]
529mod tests {
530 use super::*;
531
532 const NOW: i64 = 1_700_000_100;
533 const PARK: &str = "pitboard-park-from-uuid-1700000000000";
534 const INCOMING: &str = "pitboard-park-to-uuid-1690000000000";
535
536 #[test]
539 fn a_record_0_8_0_wrote_names_each_side_by_its_id() {
540 let written: Journal = serde_json::from_value(serde_json::json!({
541 "provider": "claude",
542 "started_at": 1_700_000_000,
543 "from_label": "from",
544 "from_uuid": "from-uuid",
545 "to_label": "to",
546 "to_uuid": "to-uuid",
547 "park_service": PARK,
548 "incoming_service": INCOMING,
549 "from_fingerprint": "f",
550 "to_fingerprint": "t",
551 "slot": "Claude Code-credentials"
552 }))
553 .expect("still read");
554 assert_eq!(
555 (written.from_id.as_str(), written.to_id.as_str()),
556 ("from-uuid", "to-uuid")
557 );
558 }
559
560 fn journal() -> Journal {
561 Journal {
562 provider: ProviderId::Claude,
563 started_at: 1_700_000_000,
564 from_label: "from".into(),
565 from_id: "from-uuid".into(),
566 to_label: "to".into(),
567 to_id: "to-uuid".into(),
568 park_service: PARK.into(),
569 incoming_service: INCOMING.into(),
570 from_fingerprint: "ffffffffffffffff".into(),
571 to_fingerprint: "0000000000000000".into(),
572 slot: None,
573 }
574 }
575
576 #[test]
579 fn a_record_from_before_the_fingerprints_falls_back_to_asking() {
580 let mut j = journal();
581 j.from_fingerprint = String::new();
582 j.to_fingerprint = String::new();
583 assert_eq!(live_owner_by_fingerprint(&j, &fingerprint("refresh")), None);
584 }
585
586 #[test]
589 fn identical_fingerprints_settle_nothing() {
590 let mut j = journal();
591 j.from_fingerprint = fingerprint("refresh");
592 j.to_fingerprint = j.from_fingerprint.clone();
593 assert_eq!(live_owner_by_fingerprint(&j, &fingerprint("refresh")), None);
594 }
595
596 fn fingerprint(refresh: &str) -> String {
598 let live =
599 serde_json::json!({"claudeAiOauth": {"refreshToken": refresh, "accessToken": "a"}});
600 crate::provider::of(ProviderId::Claude).fingerprint(&live)
601 }
602
603 fn seen(login: &str) -> Seen {
606 Seen {
607 named: None,
608 login: login.into(),
609 }
610 }
611
612 fn identified_from(s: &mut State) {
615 let from = InUse {
616 owner: Some(account("from", None).owner()),
617 login: journal().from_fingerprint,
618 known_at: NOW - 60,
619 named: None,
620 };
621 s.identified(ProviderId::Claude, from, NOW - 60);
622 }
623
624 fn account(label: &str, parked: Option<&str>) -> Account {
625 Account {
626 last_used_at: None,
627 replaced_at: None,
628 plan: None,
629 label: label.into(),
630 id: format!("{label}-uuid"),
631 account_uuid: format!("{label}-uuid"),
632 email: format!("{label}@example.com"),
633 detail: state::Detail::Claude {
634 organization_uuid: format!("{label}-org"),
635 oauth_account: serde_json::json!({}),
636 },
637 parked: parked.map(|s| Park {
638 service: s.into(),
639 parked_at: 1_699_000_000,
640 refresh_fingerprint: "f".into(),
641 access_expires_at: None,
642 refresh_expires_at: None,
643 }),
644 }
645 }
646
647 fn before() -> State {
649 State {
650 accounts: vec![account("from", None), account("to", Some(INCOMING))],
651 ..State::default()
652 }
653 }
654
655 fn written() -> Option<Option<Value>> {
656 Some(Some(
657 serde_json::json!({"refreshToken": "outgoing", "accessToken": "a"}),
658 ))
659 }
660
661 fn found(parked: Option<Option<Value>>, owner: Option<&str>) -> Found {
662 Found {
663 parked,
664 live_owner: owner.map(str::to_owned),
665 }
666 }
667
668 #[test]
670 fn nothing_parked_and_nothing_installed_changes_nothing() {
671 let repair = repair_for(&before(), &journal(), &found(Some(None), Some("from-uuid")));
672 assert_eq!(repair, Some(Repair::default()));
673 }
674
675 #[test]
678 fn a_park_of_a_login_still_signed_in_is_dropped_not_kept() {
679 for s in [before(), {
680 let mut recorded = before();
681 recorded.park(
682 &crate::state::Key::new(crate::provider::ProviderId::Claude, "from"),
683 account("x", Some(PARK)).parked.unwrap(),
684 );
685 recorded
686 }] {
687 let repair = repair_for(&s, &journal(), &found(written(), Some("from-uuid"))).unwrap();
688 assert!(repair.drop && repair.hold.is_none() && !repair.landed);
689
690 let mut applied = s;
691 apply(
692 &mut applied,
693 &journal(),
694 repair,
695 seen(&journal().from_fingerprint),
696 NOW,
697 );
698 assert!(!applied.references(PARK));
699 assert!(applied.discarded.contains(&PARK.to_string()));
700 }
701 }
702
703 #[test]
706 fn a_landed_switch_holds_the_outgoing_login_and_consumes_the_incoming_one() {
707 let mut s = before();
708 identified_from(&mut s);
709 let repair = repair_for(&s, &journal(), &found(written(), Some("to-uuid"))).unwrap();
710 let (uuid, park) = repair.hold.clone().expect("the orphan must be recovered");
711 assert_eq!(uuid, "from-uuid", "held by account id, never by a label");
712 assert_eq!(park.service, PARK);
713 assert!(repair.landed);
714
715 let rotated = fingerprint("rotated-since");
716 apply(&mut s, &journal(), repair, seen(&rotated), NOW);
717 assert_eq!(
718 s.account_in_use(ProviderId::Claude)
719 .map(|a| a.label.as_str()),
720 Some("to")
721 );
722 assert_eq!(
723 s.in_use(ProviderId::Claude)
724 .map(|r| (r.login.as_str(), r.known_at)),
725 Some((rotated.as_str(), NOW)),
726 "the login the store holds, which Claude Code may have rotated since the install"
727 );
728 let from = s
729 .get(&crate::state::Key::new(
730 crate::provider::ProviderId::Claude,
731 "from",
732 ))
733 .unwrap();
734 assert_eq!(from.parked.as_ref().unwrap().service, PARK);
735 assert_eq!(
736 from.replaced_at, None,
737 "its login is parked, so nothing of it was lost"
738 );
739 assert!(
740 s.get(&crate::state::Key::new(
741 crate::provider::ProviderId::Claude,
742 "to"
743 ))
744 .unwrap()
745 .parked
746 .is_none(),
747 "the copy now live must never be offered again"
748 );
749 assert!(s.discarded.contains(&INCOMING.to_string()));
750 }
751
752 #[test]
755 fn a_third_account_signed_in_since_keeps_both_parks() {
756 let mut s = before();
757 let repair = repair_for(&s, &journal(), &found(written(), Some("other-uuid"))).unwrap();
758 apply(&mut s, &journal(), repair, seen(&fingerprint("other")), NOW);
759 assert!(s.references(PARK) && s.references(INCOMING));
760 assert!(s.discarded.is_empty());
761 }
762
763 #[test]
764 fn an_already_recorded_park_is_not_held_twice() {
765 let mut s = before();
766 s.park(
767 &crate::state::Key::new(crate::provider::ProviderId::Claude, "from"),
768 account("x", Some(PARK)).parked.unwrap(),
769 );
770 let repair = repair_for(&s, &journal(), &found(written(), Some("to-uuid"))).unwrap();
771 assert_eq!(repair.hold, None);
772 }
773
774 #[test]
775 fn an_unknown_outcome_changes_nothing_and_keeps_the_record() {
776 for unknown in [found(written(), None), found(None, Some("to-uuid"))] {
777 assert_eq!(
778 repair_for(&before(), &journal(), &unknown),
779 None,
780 "could-not-tell must never be read as nothing-there"
781 );
782 }
783 }
784
785 #[test]
786 fn a_park_whose_account_was_forgotten_is_not_filed_under_another() {
787 let mut s = State {
788 accounts: vec![account("other", None)],
789 ..State::default()
790 };
791 identified_from(&mut s);
792 let repair = repair_for(&s, &journal(), &found(written(), Some("to-uuid"))).unwrap();
793 apply(
794 &mut s,
795 &journal(),
796 repair,
797 seen(&journal().to_fingerprint),
798 NOW,
799 );
800 assert!(
801 !s.references(PARK),
802 "a park must never be filed under whatever account happens to hold a label"
803 );
804 assert_eq!(
805 s.in_use(ProviderId::Claude),
806 None,
807 "the store no longer holds `from`'s login, and nobody here says whose it holds"
808 );
809 }
810
811 #[test]
815 fn a_label_given_to_another_account_since_is_not_the_destination() {
816 const THEIRS: &str = "pitboard-park-someone-uuid-1695000000000";
817 let someone = Account {
818 id: "someone-uuid".into(),
819 account_uuid: "someone-uuid".into(),
820 ..account("to", Some(THEIRS))
821 };
822 let mut s = State {
823 accounts: vec![account("from", None), someone],
824 ..State::default()
825 };
826 identified_from(&mut s);
827 let repair = repair_for(&s, &journal(), &found(written(), Some("to-uuid"))).unwrap();
828 apply(
829 &mut s,
830 &journal(),
831 repair,
832 seen(&journal().to_fingerprint),
833 NOW,
834 );
835
836 assert_eq!(
837 s.in_use(ProviderId::Claude),
838 None,
839 "the login installed is not the account that holds the label now"
840 );
841 let to = s.get(&Key::new(ProviderId::Claude, "to")).unwrap();
842 assert_eq!(to.last_used_at, None, "never in use");
843 assert_eq!(to.parked.as_ref().map(|p| p.service.as_str()), Some(THEIRS));
844 assert!(
845 !s.discarded.contains(&INCOMING.to_string()),
846 "the destination's park is no account's here to consume"
847 );
848 }
849}