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 pub(super) from_uuid: String,
29 pub(super) to_label: String,
30 pub(super) to_uuid: String,
31 pub(super) park_service: String,
32 pub(super) incoming_service: String,
34 #[serde(default)]
40 pub(super) from_fingerprint: String,
41 #[serde(default)]
42 pub(super) to_fingerprint: String,
43 #[serde(default)]
50 pub(super) slot: Option<String>,
51}
52
53#[derive(Debug)]
55pub struct Recovered {
56 pub from: String,
57 pub to: String,
58 pub finished: bool,
59}
60
61impl Recovered {
62 pub fn code(&self) -> &'static str {
63 if self.finished {
64 "interrupted_switch_finished"
65 } else {
66 "interrupted_switch_undone"
67 }
68 }
69}
70
71impl std::fmt::Display for Recovered {
72 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73 write!(
74 f,
75 "an earlier switch from `{}` to `{}` was interrupted; {}",
76 self.from,
77 self.to,
78 if self.finished {
79 "it had in fact finished, and Pitboard has recorded that"
80 } else {
81 "it had not finished, and nothing was lost"
82 }
83 )
84 }
85}
86
87fn claude() -> ProviderId {
88 ProviderId::Claude
89}
90
91impl Journal {
92 fn from(&self) -> Key {
93 Key::new(self.provider, self.from_label.clone())
94 }
95
96 fn to(&self) -> Key {
97 Key::new(self.provider, self.to_label.clone())
98 }
99}
100
101fn journal_path(ctx: &Context) -> PathBuf {
102 home::dir(ctx).join("journal.json")
103}
104
105pub(super) fn write_journal(ctx: &Context, permit: Permit, entry: &Journal) -> Result<()> {
108 let path = journal_path(ctx);
109 let fail = |source| Error::RecoveryFailed {
110 path: path.clone(),
111 source,
112 };
113 home::ensure(ctx, permit).map_err(fail)?;
114 let body = serde_json::to_string(entry).expect("a journal entry is always serialisable");
115 atomic::write(permit, &path, body.as_bytes(), atomic::Perms::Secret).map_err(fail)
116}
117
118pub fn pending(ctx: &Context) -> bool {
120 journal_path(ctx).exists()
121}
122
123pub(crate) fn interrupted_tool(ctx: &Context) -> Option<ProviderId> {
125 let raw = std::fs::read_to_string(journal_path(ctx)).ok()?;
126 serde_json::from_str::<Journal>(&raw)
127 .ok()
128 .map(|journal| journal.provider)
129}
130
131pub(super) fn clear_journal(ctx: &Context, permit: Permit) {
133 let _ = crate::host::fs::remove_file(permit, &journal_path(ctx));
134}
135
136struct Found {
137 parked: Option<Option<Value>>,
139 live_owner: Option<String>,
141}
142
143#[derive(Default, Debug, PartialEq)]
144struct Repair {
145 hold: Option<(String, Park)>,
148 drop: bool,
151 landed: bool,
153}
154
155fn repair_for(state: &State, journal: &Journal, found: &Found) -> Option<Repair> {
157 let parked = found.parked.as_ref()?;
158 let owner = found.live_owner.as_deref()?;
159 let mut repair = Repair {
160 landed: owner == journal.to_uuid,
161 ..Repair::default()
162 };
163 if owner == journal.from_uuid {
164 repair.drop = parked.is_some();
165 } else if let Some(oauth) = parked
166 && !state.references(&journal.park_service)
167 {
168 repair.hold = Some((
169 journal.from_uuid.clone(),
170 park::describe(
171 journal.provider,
172 &journal.park_service,
173 journal.started_at,
174 oauth,
175 ),
176 ));
177 }
178 Some(repair)
179}
180
181fn apply(state: &mut State, journal: &Journal, repair: Repair) {
182 if repair.drop {
183 state.discard(&journal.park_service);
184 }
185 if let Some((uuid, park)) = repair.hold {
186 match state
187 .by_uuid(journal.provider, &uuid)
188 .map(crate::state::Account::key)
189 {
190 Some(key) => state.park(&key, park),
191 None => state.release(&park.service),
194 }
195 }
196 if repair.landed && state.get(&journal.to()).is_some() {
197 state.set_active(journal.provider, Some(journal.to_label.clone()));
198 state.discard(&journal.incoming_service);
199 }
200}
201
202fn read_park(ctx: &Context, service: &str) -> Option<Option<Value>> {
203 match store::vault_read(ctx, service) {
204 Ok(raw) => Some(raw.and_then(|r| serde_json::from_str(&r).ok())),
205 Err(_) => None,
206 }
207}
208
209enum Owner {
211 Is(String),
213 Unknown(String),
215 Unasked(Value),
217}
218
219fn live_owner(ctx: &Context, journal: &Journal) -> Owner {
224 match crate::provider::of(journal.provider).read_live(ctx) {
225 Err(e) => Owner::Unknown(e.to_string()),
226 Ok(None) => Owner::Unknown("nothing is signed in".into()),
227 Ok(Some(live)) => match live_owner_by_fingerprint(journal, &live.raw) {
228 Some(uuid) => Owner::Is(uuid),
229 None => Owner::Unasked(live.raw),
230 },
231 }
232}
233
234fn ask(ctx: &Context, which: ProviderId, owner: Owner) -> std::result::Result<String, String> {
236 match owner {
237 Owner::Is(uuid) => Ok(uuid),
238 Owner::Unknown(why) => Err(why),
239 Owner::Unasked(live) => identify_document(ctx, which, &live)
240 .map(|owner| owner.account_uuid)
241 .map_err(|e| e.to_string()),
242 }
243}
244
245fn live_owner_by_fingerprint(journal: &Journal, live: &Value) -> Option<String> {
258 if journal.from_fingerprint.is_empty() || journal.to_fingerprint.is_empty() {
259 return None;
260 }
261 if journal.from_fingerprint == journal.to_fingerprint {
262 return None;
263 }
264 let found = crate::provider::of(journal.provider).fingerprint(live);
265 if found.is_empty() {
266 return None;
267 }
268 if found == journal.to_fingerprint {
269 Some(journal.to_uuid.clone())
270 } else if found == journal.from_fingerprint {
271 Some(journal.from_uuid.clone())
272 } else {
273 None
274 }
275}
276
277#[derive(Debug)]
279pub struct Abandoned {
280 pub from: String,
281 pub to: String,
282 pub kept: usize,
284}
285
286pub(super) fn abandon(
298 ctx: &Context,
299 permit: Permit,
300 state: &mut State,
301) -> Result<Option<Abandoned>> {
302 let path = journal_path(ctx);
303 let raw = match std::fs::read_to_string(&path) {
304 Ok(r) => r,
305 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
306 Err(source) => return Err(Error::RecoveryFailed { path, source }),
307 };
308 let journal = serde_json::from_str::<Journal>(&raw)
309 .map_err(|source| Error::RecoveryRecordCorrupt { path, source })?;
310
311 let mut kept = 0;
314 if let Some(Some(document)) = read_park(ctx, &journal.park_service)
315 && park::is_live_twin(ctx, journal.provider, &document)
316 {
317 state.discard(&journal.park_service);
318 } else if let Some(Some(document)) = read_park(ctx, &journal.park_service)
319 && let Some(key) = state
320 .by_uuid(journal.provider, &journal.from_uuid)
321 .map(crate::state::Account::key)
322 {
323 state.park(
324 &key,
325 park::describe(
326 journal.provider,
327 &journal.park_service,
328 ctx.now(),
329 &document,
330 ),
331 );
332 kept += 1;
333 }
334 let incoming = read_park(ctx, &journal.incoming_service).flatten();
335 if incoming.is_some_and(|document| park::is_live_twin(ctx, journal.provider, &document)) {
336 state.discard(&journal.incoming_service);
337 } else if state
338 .by_uuid(journal.provider, &journal.to_uuid)
339 .and_then(|a| a.parked.as_ref())
340 .is_some()
341 {
342 kept += 1;
343 }
344 state::save(ctx, permit, state)?;
345 clear_journal(ctx, permit);
346 Ok(Some(Abandoned {
347 from: state.typed(&journal.from()),
348 to: state.typed(&journal.to()),
349 kept,
350 }))
351}
352
353struct Waiting {
356 journal: Journal,
357 parked: Option<Option<Value>>,
359 owner: Owner,
360}
361
362fn read(ctx: &Context, state: &State) -> Result<Option<Waiting>> {
365 let path = journal_path(ctx);
366 let raw = match std::fs::read_to_string(&path) {
367 Ok(r) => r,
368 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
369 Err(source) => return Err(Error::RecoveryFailed { path, source }),
370 };
371 let journal = serde_json::from_str::<Journal>(&raw)
374 .map_err(|source| Error::RecoveryRecordCorrupt { path, source })?;
375
376 if let Some(slot) = &journal.slot {
377 let here = crate::provider::of(journal.provider).slot(ctx);
378 if *slot != here {
379 return Err(Error::RecoveryElsewhere {
380 tool: journal.provider,
381 from: state.typed(&journal.from()),
382 to: state.typed(&journal.to()),
383 slot: slot.clone(),
384 });
385 }
386 }
387
388 let owner = live_owner(ctx, &journal);
389 Ok(Some(Waiting {
390 parked: read_park(ctx, &journal.park_service),
391 journal,
392 owner,
393 }))
394}
395
396fn decide(
399 state: &State,
400 journal: &Journal,
401 parked: Option<Option<Value>>,
402 owner: std::result::Result<String, String>,
403) -> Result<Repair> {
404 let found = Found {
405 parked,
406 live_owner: owner.as_ref().ok().cloned(),
407 };
408 repair_for(state, journal, &found).ok_or_else(|| Error::RecoveryUndetermined {
409 tool: journal.provider,
410 from: state.typed(&journal.from()),
411 to: state.typed(&journal.to()),
412 detail: owner
413 .err()
414 .unwrap_or_else(|| "its parked login could not be read".into()),
415 })
416}
417
418pub(super) fn reconcile(
419 ctx: &Context,
420 permit: Permit,
421 state: &mut State,
422) -> Result<Option<Recovered>> {
423 let Some(Waiting {
424 journal,
425 parked,
426 owner,
427 }) = read(ctx, state)?
428 else {
429 return Ok(None);
430 };
431 let owner = ask(ctx, journal.provider, owner);
432 let repair = decide(state, &journal, parked, owner)?;
433 let finished = repair.landed;
434 apply(state, &journal, repair);
435 state::save(ctx, permit, state)?;
436 clear_journal(ctx, permit);
437
438 Ok(Some(Recovered {
439 from: state.typed(&journal.from()),
440 to: state.typed(&journal.to()),
441 finished,
442 }))
443}
444
445#[derive(Clone, Copy, Debug, PartialEq, Eq)]
451pub(crate) enum Asking {
452 Service,
453 Nobody,
454}
455
456pub(super) fn refusal(ctx: &Context, state: &State, asking: Asking) -> Option<Error> {
464 let Waiting {
465 journal,
466 parked,
467 owner,
468 } = read(ctx, state).ok()??;
469 if asking == Asking::Nobody
470 && matches!(owner, Owner::Unasked(_))
471 && !crate::provider::of(journal.provider).identifies_by_itself()
472 {
473 return None;
474 }
475 let owner = ask(ctx, journal.provider, owner);
476 decide(state, &journal, parked, owner).err()
477}
478
479#[cfg(test)]
480mod tests {
481 use super::*;
482 use crate::state::Account;
483
484 const PARK: &str = "pitboard-park-from-uuid-1700000000000";
485 const INCOMING: &str = "pitboard-park-to-uuid-1690000000000";
486
487 fn journal() -> Journal {
488 Journal {
489 provider: ProviderId::Claude,
490 started_at: 1_700_000_000,
491 from_label: "from".into(),
492 from_uuid: "from-uuid".into(),
493 to_label: "to".into(),
494 to_uuid: "to-uuid".into(),
495 park_service: PARK.into(),
496 incoming_service: INCOMING.into(),
497 from_fingerprint: "ffffffffffffffff".into(),
498 to_fingerprint: "0000000000000000".into(),
499 slot: None,
500 }
501 }
502
503 #[test]
506 fn a_record_from_before_the_fingerprints_falls_back_to_asking() {
507 let mut j = journal();
508 j.from_fingerprint = String::new();
509 j.to_fingerprint = String::new();
510 assert_eq!(live_owner_by_fingerprint(&j, &live("refresh")), None);
511 }
512
513 #[test]
516 fn identical_fingerprints_settle_nothing() {
517 let mut j = journal();
518 let live = live("refresh");
519 j.from_fingerprint = crate::provider::of(ProviderId::Claude).fingerprint(&live);
520 j.to_fingerprint = j.from_fingerprint.clone();
521 assert_eq!(live_owner_by_fingerprint(&j, &live), None);
522 }
523
524 fn live(refresh: &str) -> Value {
526 serde_json::json!({"claudeAiOauth": {"refreshToken": refresh, "accessToken": "a"}})
527 }
528
529 fn account(label: &str, parked: Option<&str>) -> Account {
530 Account {
531 last_used_at: None,
532 label: label.into(),
533 account_uuid: format!("{label}-uuid"),
534 email: format!("{label}@example.com"),
535 detail: state::Detail::Claude {
536 organization_uuid: format!("{label}-org"),
537 oauth_account: serde_json::json!({}),
538 },
539 parked: parked.map(|s| Park {
540 service: s.into(),
541 parked_at: 1_699_000_000,
542 refresh_fingerprint: "f".into(),
543 access_expires_at: None,
544 refresh_expires_at: None,
545 }),
546 }
547 }
548
549 fn before() -> State {
551 State {
552 accounts: vec![account("from", None), account("to", Some(INCOMING))],
553 ..State::default()
554 }
555 }
556
557 fn written() -> Option<Option<Value>> {
558 Some(Some(
559 serde_json::json!({"refreshToken": "outgoing", "accessToken": "a"}),
560 ))
561 }
562
563 fn found(parked: Option<Option<Value>>, owner: Option<&str>) -> Found {
564 Found {
565 parked,
566 live_owner: owner.map(str::to_owned),
567 }
568 }
569
570 #[test]
572 fn nothing_parked_and_nothing_installed_changes_nothing() {
573 let repair = repair_for(&before(), &journal(), &found(Some(None), Some("from-uuid")));
574 assert_eq!(repair, Some(Repair::default()));
575 }
576
577 #[test]
580 fn a_park_of_a_login_still_signed_in_is_dropped_not_kept() {
581 for s in [before(), {
582 let mut recorded = before();
583 recorded.park(
584 &crate::state::Key::new(crate::provider::ProviderId::Claude, "from"),
585 account("x", Some(PARK)).parked.unwrap(),
586 );
587 recorded
588 }] {
589 let repair = repair_for(&s, &journal(), &found(written(), Some("from-uuid"))).unwrap();
590 assert!(repair.drop && repair.hold.is_none() && !repair.landed);
591
592 let mut applied = s;
593 apply(&mut applied, &journal(), repair);
594 assert!(!applied.references(PARK));
595 assert!(applied.discarded.contains(&PARK.to_string()));
596 }
597 }
598
599 #[test]
602 fn a_landed_switch_holds_the_outgoing_login_and_consumes_the_incoming_one() {
603 let mut s = before();
604 let repair = repair_for(&s, &journal(), &found(written(), Some("to-uuid"))).unwrap();
605 let (uuid, park) = repair.hold.clone().expect("the orphan must be recovered");
606 assert_eq!(uuid, "from-uuid", "held by account id, never by a label");
607 assert_eq!(park.service, PARK);
608 assert!(repair.landed);
609
610 apply(&mut s, &journal(), repair);
611 assert_eq!(s.active_for(ProviderId::Claude), Some("to"));
612 assert_eq!(
613 s.get(&crate::state::Key::new(
614 crate::provider::ProviderId::Claude,
615 "from"
616 ))
617 .unwrap()
618 .parked
619 .as_ref()
620 .unwrap()
621 .service,
622 PARK
623 );
624 assert!(
625 s.get(&crate::state::Key::new(
626 crate::provider::ProviderId::Claude,
627 "to"
628 ))
629 .unwrap()
630 .parked
631 .is_none(),
632 "the copy now live must never be offered again"
633 );
634 assert!(s.discarded.contains(&INCOMING.to_string()));
635 }
636
637 #[test]
640 fn a_third_account_signed_in_since_keeps_both_parks() {
641 let mut s = before();
642 let repair = repair_for(&s, &journal(), &found(written(), Some("other-uuid"))).unwrap();
643 apply(&mut s, &journal(), repair);
644 assert!(s.references(PARK) && s.references(INCOMING));
645 assert!(s.discarded.is_empty());
646 }
647
648 #[test]
649 fn an_already_recorded_park_is_not_held_twice() {
650 let mut s = before();
651 s.park(
652 &crate::state::Key::new(crate::provider::ProviderId::Claude, "from"),
653 account("x", Some(PARK)).parked.unwrap(),
654 );
655 let repair = repair_for(&s, &journal(), &found(written(), Some("to-uuid"))).unwrap();
656 assert_eq!(repair.hold, None);
657 }
658
659 #[test]
660 fn an_unknown_outcome_changes_nothing_and_keeps_the_record() {
661 for unknown in [found(written(), None), found(None, Some("to-uuid"))] {
662 assert_eq!(
663 repair_for(&before(), &journal(), &unknown),
664 None,
665 "could-not-tell must never be read as nothing-there"
666 );
667 }
668 }
669
670 #[test]
671 fn a_park_whose_account_was_forgotten_is_not_filed_under_another() {
672 let mut s = State {
673 accounts: vec![account("other", None)],
674 ..State::default()
675 };
676 let repair = repair_for(&s, &journal(), &found(written(), Some("to-uuid"))).unwrap();
677 apply(&mut s, &journal(), repair);
678 assert!(
679 !s.references(PARK),
680 "a park must never be filed under whatever account happens to hold a label"
681 );
682 assert_eq!(
683 s.active_for(ProviderId::Claude),
684 None,
685 "a destination that is gone is not made active"
686 );
687 }
688}