1use super::{Error, Result, identify_document};
9use crate::context::Context;
10use crate::provider::ProviderId;
11use crate::service::Permit;
12use crate::state::{Key, Park, State};
13use crate::{atomic, home, park, state, store};
14use serde_json::Value;
15use std::path::PathBuf;
16
17#[derive(serde::Serialize, serde::Deserialize)]
18pub(super) struct Journal {
19 #[serde(default = "claude")]
25 pub(super) provider: ProviderId,
26 pub(super) started_at: i64,
27 pub(super) from_label: String,
28 #[serde(rename = "from_uuid")]
30 pub(super) from_id: String,
31 pub(super) to_label: String,
32 #[serde(rename = "to_uuid")]
33 pub(super) to_id: String,
34 pub(super) park_service: String,
35 pub(super) incoming_service: String,
37 #[serde(default)]
43 pub(super) from_fingerprint: String,
44 #[serde(default)]
45 pub(super) to_fingerprint: String,
46 #[serde(default)]
53 pub(super) slot: Option<String>,
54}
55
56#[derive(Debug)]
58pub struct Recovered {
59 pub from: String,
60 pub to: String,
61 pub finished: bool,
62}
63
64impl Recovered {
65 pub fn code(&self) -> &'static str {
66 if self.finished {
67 "interrupted_switch_finished"
68 } else {
69 "interrupted_switch_undone"
70 }
71 }
72}
73
74impl std::fmt::Display for Recovered {
75 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76 write!(
77 f,
78 "an earlier switch from `{}` to `{}` was interrupted; {}",
79 self.from,
80 self.to,
81 if self.finished {
82 "it had in fact finished, and Pitboard has recorded that"
83 } else {
84 "it had not finished, and nothing was lost"
85 }
86 )
87 }
88}
89
90fn claude() -> ProviderId {
91 ProviderId::Claude
92}
93
94impl Journal {
95 fn from(&self) -> Key {
96 Key::new(self.provider, self.from_label.clone())
97 }
98
99 fn to(&self) -> Key {
100 Key::new(self.provider, self.to_label.clone())
101 }
102}
103
104fn journal_path(ctx: &Context) -> PathBuf {
105 home::dir(ctx).join("journal.json")
106}
107
108pub(super) fn write_journal(ctx: &Context, permit: Permit, entry: &Journal) -> Result<()> {
111 let path = journal_path(ctx);
112 let fail = |source| Error::RecoveryFailed {
113 path: path.clone(),
114 source,
115 };
116 home::ensure(ctx, permit).map_err(fail)?;
117 let body = serde_json::to_string(entry).expect("a journal entry is always serialisable");
118 atomic::write(permit, &path, body.as_bytes(), atomic::Perms::Secret).map_err(fail)
119}
120
121pub fn pending(ctx: &Context) -> bool {
123 journal_path(ctx).exists()
124}
125
126pub(crate) fn interrupted_tool(ctx: &Context) -> Option<ProviderId> {
128 let raw = std::fs::read_to_string(journal_path(ctx)).ok()?;
129 serde_json::from_str::<Journal>(&raw)
130 .ok()
131 .map(|journal| journal.provider)
132}
133
134pub(super) fn clear_journal(ctx: &Context, permit: Permit) {
136 let _ = crate::host::fs::remove_file(permit, &journal_path(ctx));
137}
138
139struct Found {
140 parked: Option<Option<Value>>,
142 live_owner: Option<String>,
144}
145
146#[derive(Default, Debug, PartialEq)]
147struct Repair {
148 hold: Option<(String, Park)>,
151 drop: bool,
154 landed: bool,
156}
157
158fn repair_for(state: &State, journal: &Journal, found: &Found) -> Option<Repair> {
160 let parked = found.parked.as_ref()?;
161 let owner = found.live_owner.as_deref()?;
162 let mut repair = Repair {
163 landed: owner == journal.to_id,
164 ..Repair::default()
165 };
166 if owner == journal.from_id {
167 repair.drop = parked.is_some();
168 } else if let Some(oauth) = parked
169 && !state.references(&journal.park_service)
170 {
171 repair.hold = Some((
172 journal.from_id.clone(),
173 park::describe(
174 journal.provider,
175 &journal.park_service,
176 journal.started_at,
177 oauth,
178 ),
179 ));
180 }
181 Some(repair)
182}
183
184fn apply(state: &mut State, journal: &Journal, repair: Repair) {
185 if repair.drop {
186 state.discard(&journal.park_service);
187 }
188 if let Some((id, park)) = repair.hold {
189 match state
190 .by_id(journal.provider, &id)
191 .map(crate::state::Account::key)
192 {
193 Some(key) => state.park(&key, park),
194 None => state.release(&park.service),
197 }
198 }
199 if repair.landed && state.get(&journal.to()).is_some() {
200 state.set_active(journal.provider, Some(journal.to_label.clone()));
201 state.discard(&journal.incoming_service);
202 }
203}
204
205fn read_park(ctx: &Context, service: &str) -> Option<Option<Value>> {
206 match store::vault_read(ctx, service) {
207 Ok(raw) => Some(raw.and_then(|r| serde_json::from_str(&r).ok())),
208 Err(_) => None,
209 }
210}
211
212enum Owner {
214 Is(String),
216 Unknown(String),
218 Unasked(Value),
220}
221
222fn live_owner(ctx: &Context, journal: &Journal) -> Owner {
227 match crate::provider::of(journal.provider).read_live(ctx) {
228 Err(e) => Owner::Unknown(e.to_string()),
229 Ok(None) => Owner::Unknown("nothing is signed in".into()),
230 Ok(Some(live)) => match live_owner_by_fingerprint(journal, &live.raw) {
231 Some(id) => Owner::Is(id),
232 None => Owner::Unasked(live.raw),
233 },
234 }
235}
236
237fn ask(
240 ctx: &Context,
241 state: &State,
242 which: ProviderId,
243 owner: Owner,
244) -> std::result::Result<String, String> {
245 match owner {
246 Owner::Is(id) => Ok(id),
247 Owner::Unknown(why) => Err(why),
248 Owner::Unasked(live) => identify_document(ctx, which, &live)
249 .map(|owner| state.id_of(which, &owner))
250 .map_err(|e| e.to_string()),
251 }
252}
253
254fn live_owner_by_fingerprint(journal: &Journal, live: &Value) -> Option<String> {
267 if journal.from_fingerprint.is_empty() || journal.to_fingerprint.is_empty() {
268 return None;
269 }
270 if journal.from_fingerprint == journal.to_fingerprint {
271 return None;
272 }
273 let found = crate::provider::of(journal.provider).fingerprint(live);
274 if found.is_empty() {
275 return None;
276 }
277 if found == journal.to_fingerprint {
278 Some(journal.to_id.clone())
279 } else if found == journal.from_fingerprint {
280 Some(journal.from_id.clone())
281 } else {
282 None
283 }
284}
285
286#[derive(Debug)]
288pub struct Abandoned {
289 pub from: String,
290 pub to: String,
291 pub kept: usize,
293}
294
295pub(super) fn abandon(
307 ctx: &Context,
308 permit: Permit,
309 state: &mut State,
310) -> Result<Option<Abandoned>> {
311 let path = journal_path(ctx);
312 let raw = match std::fs::read_to_string(&path) {
313 Ok(r) => r,
314 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
315 Err(source) => return Err(Error::RecoveryFailed { path, source }),
316 };
317 let journal = serde_json::from_str::<Journal>(&raw)
318 .map_err(|source| Error::RecoveryRecordCorrupt { path, source })?;
319
320 let mut kept = 0;
323 if let Some(Some(document)) = read_park(ctx, &journal.park_service)
324 && park::is_live_twin(ctx, journal.provider, &document)
325 {
326 state.discard(&journal.park_service);
327 } else if let Some(Some(document)) = read_park(ctx, &journal.park_service)
328 && let Some(key) = state
329 .by_id(journal.provider, &journal.from_id)
330 .map(crate::state::Account::key)
331 {
332 state.park(
333 &key,
334 park::describe(
335 journal.provider,
336 &journal.park_service,
337 ctx.now(),
338 &document,
339 ),
340 );
341 kept += 1;
342 }
343 let incoming = read_park(ctx, &journal.incoming_service).flatten();
344 if incoming.is_some_and(|document| park::is_live_twin(ctx, journal.provider, &document)) {
345 state.discard(&journal.incoming_service);
346 } else if state
347 .by_id(journal.provider, &journal.to_id)
348 .and_then(|a| a.parked.as_ref())
349 .is_some()
350 {
351 kept += 1;
352 }
353 state::save(ctx, permit, state)?;
354 clear_journal(ctx, permit);
355 Ok(Some(Abandoned {
356 from: state.typed(&journal.from()),
357 to: state.typed(&journal.to()),
358 kept,
359 }))
360}
361
362struct Waiting {
365 journal: Journal,
366 parked: Option<Option<Value>>,
368 owner: Owner,
369}
370
371fn read(ctx: &Context, state: &State) -> Result<Option<Waiting>> {
374 let path = journal_path(ctx);
375 let raw = match std::fs::read_to_string(&path) {
376 Ok(r) => r,
377 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
378 Err(source) => return Err(Error::RecoveryFailed { path, source }),
379 };
380 let journal = serde_json::from_str::<Journal>(&raw)
383 .map_err(|source| Error::RecoveryRecordCorrupt { path, source })?;
384
385 if let Some(slot) = &journal.slot {
386 let here = crate::provider::of(journal.provider).slot(ctx);
387 if *slot != here {
388 return Err(Error::RecoveryElsewhere {
389 tool: journal.provider,
390 from: state.typed(&journal.from()),
391 to: state.typed(&journal.to()),
392 slot: slot.clone(),
393 });
394 }
395 }
396
397 let owner = live_owner(ctx, &journal);
398 Ok(Some(Waiting {
399 parked: read_park(ctx, &journal.park_service),
400 journal,
401 owner,
402 }))
403}
404
405fn decide(
408 state: &State,
409 journal: &Journal,
410 parked: Option<Option<Value>>,
411 owner: std::result::Result<String, String>,
412) -> Result<Repair> {
413 let found = Found {
414 parked,
415 live_owner: owner.as_ref().ok().cloned(),
416 };
417 repair_for(state, journal, &found).ok_or_else(|| Error::RecoveryUndetermined {
418 tool: journal.provider,
419 from: state.typed(&journal.from()),
420 to: state.typed(&journal.to()),
421 detail: owner
422 .err()
423 .unwrap_or_else(|| "its parked login could not be read".into()),
424 })
425}
426
427pub(super) fn reconcile(
428 ctx: &Context,
429 permit: Permit,
430 state: &mut State,
431) -> Result<Option<Recovered>> {
432 let Some(Waiting {
433 journal,
434 parked,
435 owner,
436 }) = read(ctx, state)?
437 else {
438 return Ok(None);
439 };
440 let owner = ask(ctx, state, journal.provider, owner);
441 let repair = decide(state, &journal, parked, owner)?;
442 let finished = repair.landed;
443 apply(state, &journal, repair);
444 state::save(ctx, permit, state)?;
445 clear_journal(ctx, permit);
446
447 Ok(Some(Recovered {
448 from: state.typed(&journal.from()),
449 to: state.typed(&journal.to()),
450 finished,
451 }))
452}
453
454#[derive(Clone, Copy, Debug, PartialEq, Eq)]
460pub(crate) enum Asking {
461 Service,
462 Nobody,
463}
464
465pub(super) fn refusal(ctx: &Context, state: &State, asking: Asking) -> Option<Error> {
473 let Waiting {
474 journal,
475 parked,
476 owner,
477 } = read(ctx, state).ok()??;
478 if asking == Asking::Nobody
479 && matches!(owner, Owner::Unasked(_))
480 && !crate::provider::of(journal.provider).identifies_by_itself()
481 {
482 return None;
483 }
484 let owner = ask(ctx, state, journal.provider, owner);
485 decide(state, &journal, parked, owner).err()
486}
487
488#[cfg(test)]
489mod tests {
490 use super::*;
491 use crate::state::Account;
492
493 const PARK: &str = "pitboard-park-from-uuid-1700000000000";
494 const INCOMING: &str = "pitboard-park-to-uuid-1690000000000";
495
496 #[test]
499 fn a_record_0_8_0_wrote_names_each_side_by_its_id() {
500 let written: Journal = serde_json::from_value(serde_json::json!({
501 "provider": "claude",
502 "started_at": 1_700_000_000,
503 "from_label": "from",
504 "from_uuid": "from-uuid",
505 "to_label": "to",
506 "to_uuid": "to-uuid",
507 "park_service": PARK,
508 "incoming_service": INCOMING,
509 "from_fingerprint": "f",
510 "to_fingerprint": "t",
511 "slot": "Claude Code-credentials"
512 }))
513 .expect("still read");
514 assert_eq!(
515 (written.from_id.as_str(), written.to_id.as_str()),
516 ("from-uuid", "to-uuid")
517 );
518 }
519
520 fn journal() -> Journal {
521 Journal {
522 provider: ProviderId::Claude,
523 started_at: 1_700_000_000,
524 from_label: "from".into(),
525 from_id: "from-uuid".into(),
526 to_label: "to".into(),
527 to_id: "to-uuid".into(),
528 park_service: PARK.into(),
529 incoming_service: INCOMING.into(),
530 from_fingerprint: "ffffffffffffffff".into(),
531 to_fingerprint: "0000000000000000".into(),
532 slot: None,
533 }
534 }
535
536 #[test]
539 fn a_record_from_before_the_fingerprints_falls_back_to_asking() {
540 let mut j = journal();
541 j.from_fingerprint = String::new();
542 j.to_fingerprint = String::new();
543 assert_eq!(live_owner_by_fingerprint(&j, &live("refresh")), None);
544 }
545
546 #[test]
549 fn identical_fingerprints_settle_nothing() {
550 let mut j = journal();
551 let live = live("refresh");
552 j.from_fingerprint = crate::provider::of(ProviderId::Claude).fingerprint(&live);
553 j.to_fingerprint = j.from_fingerprint.clone();
554 assert_eq!(live_owner_by_fingerprint(&j, &live), None);
555 }
556
557 fn live(refresh: &str) -> Value {
559 serde_json::json!({"claudeAiOauth": {"refreshToken": refresh, "accessToken": "a"}})
560 }
561
562 fn account(label: &str, parked: Option<&str>) -> Account {
563 Account {
564 last_used_at: None,
565 label: label.into(),
566 id: format!("{label}-uuid"),
567 account_uuid: format!("{label}-uuid"),
568 email: format!("{label}@example.com"),
569 detail: state::Detail::Claude {
570 organization_uuid: format!("{label}-org"),
571 oauth_account: serde_json::json!({}),
572 },
573 parked: parked.map(|s| Park {
574 service: s.into(),
575 parked_at: 1_699_000_000,
576 refresh_fingerprint: "f".into(),
577 access_expires_at: None,
578 refresh_expires_at: None,
579 }),
580 }
581 }
582
583 fn before() -> State {
585 State {
586 accounts: vec![account("from", None), account("to", Some(INCOMING))],
587 ..State::default()
588 }
589 }
590
591 fn written() -> Option<Option<Value>> {
592 Some(Some(
593 serde_json::json!({"refreshToken": "outgoing", "accessToken": "a"}),
594 ))
595 }
596
597 fn found(parked: Option<Option<Value>>, owner: Option<&str>) -> Found {
598 Found {
599 parked,
600 live_owner: owner.map(str::to_owned),
601 }
602 }
603
604 #[test]
606 fn nothing_parked_and_nothing_installed_changes_nothing() {
607 let repair = repair_for(&before(), &journal(), &found(Some(None), Some("from-uuid")));
608 assert_eq!(repair, Some(Repair::default()));
609 }
610
611 #[test]
614 fn a_park_of_a_login_still_signed_in_is_dropped_not_kept() {
615 for s in [before(), {
616 let mut recorded = before();
617 recorded.park(
618 &crate::state::Key::new(crate::provider::ProviderId::Claude, "from"),
619 account("x", Some(PARK)).parked.unwrap(),
620 );
621 recorded
622 }] {
623 let repair = repair_for(&s, &journal(), &found(written(), Some("from-uuid"))).unwrap();
624 assert!(repair.drop && repair.hold.is_none() && !repair.landed);
625
626 let mut applied = s;
627 apply(&mut applied, &journal(), repair);
628 assert!(!applied.references(PARK));
629 assert!(applied.discarded.contains(&PARK.to_string()));
630 }
631 }
632
633 #[test]
636 fn a_landed_switch_holds_the_outgoing_login_and_consumes_the_incoming_one() {
637 let mut s = before();
638 let repair = repair_for(&s, &journal(), &found(written(), Some("to-uuid"))).unwrap();
639 let (uuid, park) = repair.hold.clone().expect("the orphan must be recovered");
640 assert_eq!(uuid, "from-uuid", "held by account id, never by a label");
641 assert_eq!(park.service, PARK);
642 assert!(repair.landed);
643
644 apply(&mut s, &journal(), repair);
645 assert_eq!(s.active_for(ProviderId::Claude), Some("to"));
646 assert_eq!(
647 s.get(&crate::state::Key::new(
648 crate::provider::ProviderId::Claude,
649 "from"
650 ))
651 .unwrap()
652 .parked
653 .as_ref()
654 .unwrap()
655 .service,
656 PARK
657 );
658 assert!(
659 s.get(&crate::state::Key::new(
660 crate::provider::ProviderId::Claude,
661 "to"
662 ))
663 .unwrap()
664 .parked
665 .is_none(),
666 "the copy now live must never be offered again"
667 );
668 assert!(s.discarded.contains(&INCOMING.to_string()));
669 }
670
671 #[test]
674 fn a_third_account_signed_in_since_keeps_both_parks() {
675 let mut s = before();
676 let repair = repair_for(&s, &journal(), &found(written(), Some("other-uuid"))).unwrap();
677 apply(&mut s, &journal(), repair);
678 assert!(s.references(PARK) && s.references(INCOMING));
679 assert!(s.discarded.is_empty());
680 }
681
682 #[test]
683 fn an_already_recorded_park_is_not_held_twice() {
684 let mut s = before();
685 s.park(
686 &crate::state::Key::new(crate::provider::ProviderId::Claude, "from"),
687 account("x", Some(PARK)).parked.unwrap(),
688 );
689 let repair = repair_for(&s, &journal(), &found(written(), Some("to-uuid"))).unwrap();
690 assert_eq!(repair.hold, None);
691 }
692
693 #[test]
694 fn an_unknown_outcome_changes_nothing_and_keeps_the_record() {
695 for unknown in [found(written(), None), found(None, Some("to-uuid"))] {
696 assert_eq!(
697 repair_for(&before(), &journal(), &unknown),
698 None,
699 "could-not-tell must never be read as nothing-there"
700 );
701 }
702 }
703
704 #[test]
705 fn a_park_whose_account_was_forgotten_is_not_filed_under_another() {
706 let mut s = State {
707 accounts: vec![account("other", None)],
708 ..State::default()
709 };
710 let repair = repair_for(&s, &journal(), &found(written(), Some("to-uuid"))).unwrap();
711 apply(&mut s, &journal(), repair);
712 assert!(
713 !s.references(PARK),
714 "a park must never be filed under whatever account happens to hold a label"
715 );
716 assert_eq!(
717 s.active_for(ProviderId::Claude),
718 None,
719 "a destination that is gone is not made active"
720 );
721 }
722}