use super::{Error, Result, identify_document};
use crate::context::Context;
use crate::in_use::{self, InUse};
use crate::provider::{self, LoginKind, Provider, ProviderId};
use crate::service::Permit;
use crate::state::{Account, Key, Park, ParkKind, State};
use crate::{atomic, home, park, state, store};
use serde_json::Value;
use std::path::PathBuf;
#[derive(serde::Serialize, serde::Deserialize)]
pub(super) struct Journal {
#[serde(default = "claude")]
pub(super) provider: ProviderId,
pub(super) started_at: i64,
pub(super) from_label: String,
#[serde(rename = "from_uuid")]
pub(super) from_id: String,
pub(super) to_label: String,
#[serde(rename = "to_uuid")]
pub(super) to_id: String,
pub(super) park_service: String,
pub(super) incoming_service: String,
#[serde(default)]
pub(super) from_fingerprint: String,
#[serde(default)]
pub(super) to_fingerprint: String,
#[serde(default)]
pub(super) slot: Option<String>,
}
#[derive(Debug)]
pub struct Recovered {
pub from: String,
pub to: String,
pub finished: bool,
}
impl Recovered {
pub fn code(&self) -> &'static str {
if self.finished {
"interrupted_switch_finished"
} else {
"interrupted_switch_undone"
}
}
}
impl std::fmt::Display for Recovered {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"an earlier switch from `{}` to `{}` was interrupted; {}",
self.from,
self.to,
if self.finished {
"it had in fact finished, and Pitboard has recorded that"
} else {
"it had not finished, and nothing was lost"
}
)
}
}
fn claude() -> ProviderId {
ProviderId::Claude
}
impl Journal {
fn from(&self) -> Key {
Key::new(self.provider, self.from_label.clone())
}
fn to(&self) -> Key {
Key::new(self.provider, self.to_label.clone())
}
}
fn journal_path(ctx: &Context) -> PathBuf {
home::dir(ctx).join("journal.json")
}
pub(super) fn write_journal(ctx: &Context, permit: Permit, entry: &Journal) -> Result<()> {
let path = journal_path(ctx);
let fail = |source| Error::RecoveryFailed {
path: path.clone(),
source,
};
home::ensure(ctx, permit).map_err(fail)?;
let body = serde_json::to_string(entry).expect("a journal entry is always serialisable");
atomic::write(permit, &path, body.as_bytes(), atomic::Perms::Secret).map_err(fail)
}
pub(crate) fn pending(ctx: &Context) -> bool {
journal_path(ctx).exists()
}
pub(crate) fn interrupted_tool(ctx: &Context) -> Option<ProviderId> {
let raw = std::fs::read_to_string(journal_path(ctx)).ok()?;
serde_json::from_str::<Journal>(&raw)
.ok()
.map(|journal| journal.provider)
}
pub(super) fn clear_journal(ctx: &Context, permit: Permit) {
let _ = crate::host::fs::remove_file(permit, &journal_path(ctx));
}
struct Found {
parked: Option<Option<Value>>,
live_owner: Option<String>,
}
#[derive(Default, Debug, PartialEq)]
struct Repair {
hold: Option<(String, Park)>,
drop: bool,
landed: bool,
}
struct Seen {
named: Option<String>,
login: String,
}
fn repair_for(
state: &State,
tool: &'static dyn Provider,
journal: &Journal,
found: &Found,
) -> Option<Repair> {
let parked = found.parked.as_ref()?;
let owner = found.live_owner.as_deref()?;
let mut repair = Repair {
landed: owner == journal.to_id,
..Repair::default()
};
if owner == journal.from_id {
repair.drop = parked.is_some();
} else if let Some(oauth) = parked
&& !state.references(&journal.park_service)
{
repair.hold = Some((
journal.from_id.clone(),
park::describe(tool, &journal.park_service, journal.started_at, oauth),
));
}
Some(repair)
}
fn apply(state: &mut State, journal: &Journal, repair: Repair, seen: Seen, at: i64) {
if repair.drop {
state.discard(&journal.park_service, ParkKind::Vault);
}
if let Some((id, park)) = repair.hold {
match state.by_id(journal.provider, &id).map(Account::key) {
Some(key) => state.park(&key, park),
None => state.release(&park.service),
}
}
if !repair.landed {
return;
}
match state
.by_id(journal.provider, &journal.to_id)
.map(Account::owner)
{
Some(owner) => {
let installed = InUse {
owner: Some(owner),
login: seen.login,
known_at: at,
named: seen.named,
};
state.identified(journal.provider, installed, at);
state.discard(&journal.incoming_service, ParkKind::Vault);
}
None => {
state.in_use.remove(journal.provider.code());
}
}
}
fn read_park(ctx: &Context, service: &str) -> Option<Option<Value>> {
match store::vault_read(ctx, service) {
Ok(raw) => Some(raw.and_then(|r| serde_json::from_str(&r).ok())),
Err(_) => None,
}
}
enum Owner {
Is(String),
Unknown(String),
Unasked(Value),
}
fn live_owner(ctx: &Context, tool: &'static dyn Provider, journal: &Journal) -> (Owner, String) {
match tool.read_live(ctx) {
Err(e) => (Owner::Unknown(e.to_string()), String::new()),
Ok(None) => (Owner::Unknown("nothing is signed in".into()), String::new()),
Ok(Some(live)) => {
let login = tool.fingerprint(&live.raw);
let owner = match live_owner_by_fingerprint(journal, &login) {
Some(id) => Owner::Is(id),
None => Owner::Unasked(live.raw),
};
(owner, login)
}
}
}
fn ask(
ctx: &Context,
state: &State,
tool: &'static dyn Provider,
owner: Owner,
) -> std::result::Result<String, String> {
match owner {
Owner::Is(id) => Ok(id),
Owner::Unknown(why) => Err(why),
Owner::Unasked(live) => identify_document(ctx, tool, &live)
.map(|owner| state.id_of(tool.id(), &owner))
.map_err(|e| e.to_string()),
}
}
fn live_owner_by_fingerprint(journal: &Journal, found: &str) -> Option<String> {
if journal.from_fingerprint.is_empty() || journal.to_fingerprint.is_empty() {
return None;
}
if journal.from_fingerprint == journal.to_fingerprint {
return None;
}
if found.is_empty() {
return None;
}
if found == journal.to_fingerprint {
Some(journal.to_id.clone())
} else if found == journal.from_fingerprint {
Some(journal.from_id.clone())
} else {
None
}
}
#[derive(Debug)]
pub struct Abandoned {
pub from: String,
pub to: String,
pub kept: usize,
}
pub(super) fn abandon(
ctx: &Context,
permit: Permit,
state: &mut State,
) -> Result<Option<Abandoned>> {
let path = journal_path(ctx);
let raw = match std::fs::read_to_string(&path) {
Ok(r) => r,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(source) => return Err(Error::RecoveryFailed { path, source }),
};
let journal =
serde_json::from_str::<Journal>(&raw).map_err(|source| Error::RecoveryRecordCorrupt {
path: path.clone(),
source,
})?;
let tool = match provider::of(journal.provider) {
LoginKind::Document(tool) => tool,
LoginKind::Folder(_) => {
return Err(Error::RecoveryRecordCorrupt {
path,
source: serde::de::Error::custom(
"it names a tool whose login no switch records here",
),
});
}
};
let mut kept = 0;
if let Some(Some(document)) = read_park(ctx, &journal.park_service)
&& park::is_live_twin(ctx, tool, &document)
{
state.discard(&journal.park_service, ParkKind::Vault);
} else if let Some(Some(document)) = read_park(ctx, &journal.park_service)
&& let Some(key) = state
.by_id(journal.provider, &journal.from_id)
.map(Account::key)
{
state.park(
&key,
park::describe(tool, &journal.park_service, ctx.now(), &document),
);
kept += 1;
}
let incoming = read_park(ctx, &journal.incoming_service).flatten();
if incoming.is_some_and(|document| park::is_live_twin(ctx, tool, &document)) {
state.discard(&journal.incoming_service, ParkKind::Vault);
} else if state
.by_id(journal.provider, &journal.to_id)
.and_then(|a| a.parked.as_ref())
.is_some()
{
kept += 1;
}
state::save(ctx, permit, state)?;
clear_journal(ctx, permit);
Ok(Some(Abandoned {
from: state.typed(&journal.from()),
to: state.typed(&journal.to()),
kept,
}))
}
struct Waiting {
tool: &'static dyn Provider,
journal: Journal,
parked: Option<Option<Value>>,
owner: Owner,
seen: Seen,
}
fn read(ctx: &Context, state: &State) -> Result<Option<Waiting>> {
let path = journal_path(ctx);
let raw = match std::fs::read_to_string(&path) {
Ok(r) => r,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(source) => return Err(Error::RecoveryFailed { path, source }),
};
let journal =
serde_json::from_str::<Journal>(&raw).map_err(|source| Error::RecoveryRecordCorrupt {
path: path.clone(),
source,
})?;
let tool = match provider::of(journal.provider) {
LoginKind::Document(tool) => tool,
LoginKind::Folder(_) => {
return Err(Error::RecoveryRecordCorrupt {
path,
source: serde::de::Error::custom(
"it names a tool whose login no switch records here",
),
});
}
};
if let Some(slot) = &journal.slot {
let here = tool.slot(ctx);
if *slot != here {
return Err(Error::RecoveryElsewhere {
tool: journal.provider,
from: state.typed(&journal.from()),
to: state.typed(&journal.to()),
slot: slot.clone(),
});
}
}
let named = in_use::named(ctx, journal.provider);
let (owner, login) = live_owner(ctx, tool, &journal);
Ok(Some(Waiting {
tool,
parked: read_park(ctx, &journal.park_service),
journal,
owner,
seen: Seen { named, login },
}))
}
fn decide(
state: &State,
tool: &'static dyn Provider,
journal: &Journal,
parked: Option<Option<Value>>,
owner: std::result::Result<String, String>,
) -> Result<Repair> {
let found = Found {
parked,
live_owner: owner.as_ref().ok().cloned(),
};
repair_for(state, tool, journal, &found).ok_or_else(|| Error::RecoveryUndetermined {
tool: journal.provider,
from: state.typed(&journal.from()),
to: state.typed(&journal.to()),
detail: owner
.err()
.unwrap_or_else(|| "its parked login could not be read".into()),
})
}
pub(super) fn reconcile(
ctx: &Context,
permit: Permit,
state: &mut State,
) -> Result<Option<Recovered>> {
let Some(Waiting {
tool,
journal,
parked,
owner,
seen,
}) = read(ctx, state)?
else {
return Ok(None);
};
let owner = ask(ctx, state, tool, owner);
let repair = decide(state, tool, &journal, parked, owner)?;
let finished = repair.landed;
apply(state, &journal, repair, seen, ctx.now());
state::save(ctx, permit, state)?;
clear_journal(ctx, permit);
Ok(Some(Recovered {
from: state.typed(&journal.from()),
to: state.typed(&journal.to()),
finished,
}))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Asking {
Service,
Nobody,
}
pub(super) fn refusal(ctx: &Context, state: &State, asking: Asking) -> Option<Error> {
let Waiting {
tool,
journal,
parked,
owner,
..
} = read(ctx, state).ok()??;
if asking == Asking::Nobody
&& matches!(owner, Owner::Unasked(_))
&& !tool.identifies_by_itself()
{
return None;
}
let owner = ask(ctx, state, tool, owner);
decide(state, tool, &journal, parked, owner).err()
}
#[cfg(test)]
mod tests {
use super::*;
const NOW: i64 = 1_700_000_100;
const PARK: &str = "pitboard-park-from-uuid-1700000000000";
const INCOMING: &str = "pitboard-park-to-uuid-1690000000000";
#[test]
fn a_record_0_8_0_wrote_names_each_side_by_its_id() {
let written: Journal = serde_json::from_value(serde_json::json!({
"provider": "claude",
"started_at": 1_700_000_000,
"from_label": "from",
"from_uuid": "from-uuid",
"to_label": "to",
"to_uuid": "to-uuid",
"park_service": PARK,
"incoming_service": INCOMING,
"from_fingerprint": "f",
"to_fingerprint": "t",
"slot": "Claude Code-credentials"
}))
.expect("still read");
assert_eq!(
(written.from_id.as_str(), written.to_id.as_str()),
("from-uuid", "to-uuid")
);
}
fn journal() -> Journal {
Journal {
provider: ProviderId::Claude,
started_at: 1_700_000_000,
from_label: "from".into(),
from_id: "from-uuid".into(),
to_label: "to".into(),
to_id: "to-uuid".into(),
park_service: PARK.into(),
incoming_service: INCOMING.into(),
from_fingerprint: "ffffffffffffffff".into(),
to_fingerprint: "0000000000000000".into(),
slot: None,
}
}
#[test]
fn a_record_from_before_the_fingerprints_falls_back_to_asking() {
let mut j = journal();
j.from_fingerprint = String::new();
j.to_fingerprint = String::new();
assert_eq!(live_owner_by_fingerprint(&j, &fingerprint("refresh")), None);
}
#[test]
fn identical_fingerprints_settle_nothing() {
let mut j = journal();
j.from_fingerprint = fingerprint("refresh");
j.to_fingerprint = j.from_fingerprint.clone();
assert_eq!(live_owner_by_fingerprint(&j, &fingerprint("refresh")), None);
}
fn fingerprint(refresh: &str) -> String {
let live =
serde_json::json!({"claudeAiOauth": {"refreshToken": refresh, "accessToken": "a"}});
provider::document(ProviderId::Claude).fingerprint(&live)
}
fn seen(login: &str) -> Seen {
Seen {
named: None,
login: login.into(),
}
}
fn identified_from(s: &mut State) {
let from = InUse {
owner: Some(account("from", None).owner()),
login: journal().from_fingerprint,
known_at: NOW - 60,
named: None,
};
s.identified(ProviderId::Claude, from, NOW - 60);
}
fn account(label: &str, parked: Option<&str>) -> Account {
Account {
last_used_at: None,
replaced_at: None,
plan: None,
label: label.into(),
id: format!("{label}-uuid"),
account_uuid: format!("{label}-uuid"),
email: format!("{label}@example.com"),
detail: state::Detail::Claude {
organization_uuid: format!("{label}-org"),
oauth_account: serde_json::json!({}),
},
parked: parked.map(|s| Park {
service: s.into(),
parked_at: 1_699_000_000,
kind: ParkKind::Vault,
login: "f".into(),
access_expires_at: None,
refresh_expires_at: None,
session_expires_at: None,
}),
}
}
fn before() -> State {
State {
accounts: vec![account("from", None), account("to", Some(INCOMING))],
..State::default()
}
}
fn written() -> Option<Option<Value>> {
Some(Some(
serde_json::json!({"refreshToken": "outgoing", "accessToken": "a"}),
))
}
fn found(parked: Option<Option<Value>>, owner: Option<&str>) -> Found {
Found {
parked,
live_owner: owner.map(str::to_owned),
}
}
#[test]
fn nothing_parked_and_nothing_installed_changes_nothing() {
let repair = repair_for(
&before(),
provider::document(ProviderId::Claude),
&journal(),
&found(Some(None), Some("from-uuid")),
);
assert_eq!(repair, Some(Repair::default()));
}
#[test]
fn a_park_of_a_login_still_signed_in_is_dropped_not_kept() {
for s in [before(), {
let mut recorded = before();
recorded.park(
&Key::new(ProviderId::Claude, "from"),
account("x", Some(PARK)).parked.unwrap(),
);
recorded
}] {
let repair = repair_for(
&s,
provider::document(ProviderId::Claude),
&journal(),
&found(written(), Some("from-uuid")),
)
.unwrap();
assert!(repair.drop && repair.hold.is_none() && !repair.landed);
let mut applied = s;
apply(
&mut applied,
&journal(),
repair,
seen(&journal().from_fingerprint),
NOW,
);
assert!(!applied.references(PARK));
assert!(applied.discarded.iter().any(|d| d.service == PARK));
}
}
#[test]
fn a_landed_switch_holds_the_outgoing_login_and_consumes_the_incoming_one() {
let mut s = before();
identified_from(&mut s);
let repair = repair_for(
&s,
provider::document(ProviderId::Claude),
&journal(),
&found(written(), Some("to-uuid")),
)
.unwrap();
let (uuid, park) = repair.hold.clone().expect("the orphan must be recovered");
assert_eq!(uuid, "from-uuid", "held by account id, never by a label");
assert_eq!(park.service, PARK);
assert!(repair.landed);
let rotated = fingerprint("rotated-since");
apply(&mut s, &journal(), repair, seen(&rotated), NOW);
assert_eq!(
s.account_in_use(ProviderId::Claude)
.map(|a| a.label.as_str()),
Some("to")
);
assert_eq!(
s.in_use(ProviderId::Claude)
.map(|r| (r.login.as_str(), r.known_at)),
Some((rotated.as_str(), NOW)),
"the login the store holds, which Claude Code may have rotated since the install"
);
let from = s.get(&Key::new(ProviderId::Claude, "from")).unwrap();
assert_eq!(from.parked.as_ref().unwrap().service, PARK);
assert_eq!(
from.replaced_at, None,
"its login is parked, so nothing of it was lost"
);
assert!(
s.get(&Key::new(ProviderId::Claude, "to"))
.unwrap()
.parked
.is_none(),
"the copy now live must never be offered again"
);
assert!(s.discarded.iter().any(|d| d.service == INCOMING));
}
#[test]
fn a_third_account_signed_in_since_keeps_both_parks() {
let mut s = before();
let repair = repair_for(
&s,
provider::document(ProviderId::Claude),
&journal(),
&found(written(), Some("other-uuid")),
)
.unwrap();
apply(&mut s, &journal(), repair, seen(&fingerprint("other")), NOW);
assert!(s.references(PARK) && s.references(INCOMING));
assert!(s.discarded.is_empty());
}
#[test]
fn an_already_recorded_park_is_not_held_twice() {
let mut s = before();
s.park(
&Key::new(ProviderId::Claude, "from"),
account("x", Some(PARK)).parked.unwrap(),
);
let repair = repair_for(
&s,
provider::document(ProviderId::Claude),
&journal(),
&found(written(), Some("to-uuid")),
)
.unwrap();
assert_eq!(repair.hold, None);
}
#[test]
fn an_unknown_outcome_changes_nothing_and_keeps_the_record() {
for unknown in [found(written(), None), found(None, Some("to-uuid"))] {
assert_eq!(
repair_for(
&before(),
provider::document(ProviderId::Claude),
&journal(),
&unknown
),
None,
"could-not-tell must never be read as nothing-there"
);
}
}
#[test]
fn a_park_whose_account_was_forgotten_is_not_filed_under_another() {
let mut s = State {
accounts: vec![account("other", None)],
..State::default()
};
identified_from(&mut s);
let repair = repair_for(
&s,
provider::document(ProviderId::Claude),
&journal(),
&found(written(), Some("to-uuid")),
)
.unwrap();
apply(
&mut s,
&journal(),
repair,
seen(&journal().to_fingerprint),
NOW,
);
assert!(
!s.references(PARK),
"a park must never be filed under whatever account happens to hold a label"
);
assert_eq!(
s.in_use(ProviderId::Claude),
None,
"the store no longer holds `from`'s login, and nobody here says whose it holds"
);
}
#[test]
fn a_label_given_to_another_account_since_is_not_the_destination() {
const THEIRS: &str = "pitboard-park-someone-uuid-1695000000000";
let someone = Account {
id: "someone-uuid".into(),
account_uuid: "someone-uuid".into(),
..account("to", Some(THEIRS))
};
let mut s = State {
accounts: vec![account("from", None), someone],
..State::default()
};
identified_from(&mut s);
let repair = repair_for(
&s,
provider::document(ProviderId::Claude),
&journal(),
&found(written(), Some("to-uuid")),
)
.unwrap();
apply(
&mut s,
&journal(),
repair,
seen(&journal().to_fingerprint),
NOW,
);
assert_eq!(
s.in_use(ProviderId::Claude),
None,
"the login installed is not the account that holds the label now"
);
let to = s.get(&Key::new(ProviderId::Claude, "to")).unwrap();
assert_eq!(to.last_used_at, None, "never in use");
assert_eq!(to.parked.as_ref().map(|p| p.service.as_str()), Some(THEIRS));
assert!(
!s.discarded.iter().any(|d| d.service == INCOMING),
"the destination's park is no account's here to consume"
);
}
}