use super::*;
use crate::session_manager::{RelayConnectionJob, RelayJobDeferred, SessionManagerControl};
#[derive(Clone)]
pub(super) enum SessionRelays {
Actors(SessionManagerControl),
#[cfg(test)]
Direct,
}
pub struct CredentialSyncCoordinator {
pub(super) handle: CredentialSyncHandle,
pub(super) results: mpsc::UnboundedReceiver<CredentialSyncResult>,
}
impl CredentialSyncCoordinator {
#[cfg(test)]
pub fn spawn() -> Self {
Self::spawn_inner(SessionRelays::Direct, None)
}
pub fn spawn_guarded(
manager: SessionManagerControl,
gate: Arc<crate::recovery_gate::RecoveryGate>,
) -> Self {
Self::spawn_inner(SessionRelays::Actors(manager), Some(gate))
}
fn spawn_inner(
relays: SessionRelays,
gate: Option<Arc<crate::recovery_gate::RecoveryGate>>,
) -> Self {
let (targets_tx, mut targets_rx) = watch::channel(Vec::new());
let (triggers_tx, mut triggers_rx) = mpsc::unbounded_channel::<SyncTrigger>();
let (completed_tx, mut completed_rx) = mpsc::unbounded_channel::<CredentialSyncResult>();
let (results_tx, results_rx) = mpsc::unbounded_channel();
tokio::spawn(async move {
let mut tick = tokio::time::interval_at(
tokio::time::Instant::now() + SYNC_INTERVAL,
SYNC_INTERVAL,
);
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
let mut busy = BTreeSet::<String>::new();
let mut queue = VecDeque::<SyncTrigger>::new();
loop {
tokio::select! {
_ = tick.tick() => {
for profile_id in profiles_with_targets(&targets_rx.borrow()) {
enqueue(&mut queue, SyncTrigger { profile_id, cause: None });
}
}
changed = targets_rx.changed() => {
if changed.is_err() { break; }
for profile_id in profiles_with_targets(&targets_rx.borrow()) {
enqueue(&mut queue, SyncTrigger { profile_id, cause: None });
}
}
trigger = triggers_rx.recv() => {
let Some(trigger) = trigger else { break };
enqueue(&mut queue, trigger);
}
completed = completed_rx.recv() => {
let Some(result) = completed else { break };
busy.remove(&result.profile_id);
if result.trigger.is_some()
|| result.failure.is_some()
|| !result.outcomes.is_empty()
{
let profile_id = result.profile_id.clone();
if results_tx.send(result).is_err() {
tracing::debug!(
%profile_id,
operation = "credential_sync_result",
"credential sync result receiver was already closed"
);
}
}
}
}
let mut deferred = VecDeque::new();
while let Some(trigger) = queue.pop_front() {
if busy.contains(&trigger.profile_id) {
deferred.push_back(trigger);
continue;
}
let targets: Vec<_> = targets_rx
.borrow()
.iter()
.filter(|target| target.profile_id == trigger.profile_id)
.cloned()
.collect();
if targets.is_empty() {
if trigger.cause.is_some() {
let profile_id = trigger.profile_id.clone();
if results_tx
.send(CredentialSyncResult {
profile_id: trigger.profile_id,
trigger: trigger.cause,
failure: None,
outcomes: Vec::new(),
})
.is_err()
{
tracing::debug!(
%profile_id,
operation = "credential_sync_result",
"credential sync result receiver was already closed"
);
}
}
continue;
}
busy.insert(trigger.profile_id.clone());
let completed_tx = completed_tx.clone();
let triggered_by = trigger.cause.as_ref().map(|cause| cause.session_id.clone());
let gate = gate.clone();
let relays = relays.clone();
tokio::spawn(async move {
let joined = tokio::spawn(async move {
reconcile_profile_guarded(
&relays,
&targets,
triggered_by.as_deref(),
gate.as_ref(),
)
.await
})
.await;
let (failure, outcomes) = match joined {
Ok(outcomes) => (None, outcomes),
Err(error) => (Some(format!("sync task stopped: {error}")), Vec::new()),
};
let profile_id = trigger.profile_id.clone();
if completed_tx
.send(CredentialSyncResult {
profile_id: trigger.profile_id,
trigger: trigger.cause,
failure,
outcomes,
})
.is_err()
{
tracing::debug!(
%profile_id,
operation = "credential_sync_completion",
"credential sync coordinator stopped before receiving completion"
);
}
});
}
queue = deferred;
}
});
Self {
handle: CredentialSyncHandle {
targets: Arc::new(targets_tx),
triggers: triggers_tx,
},
results: results_rx,
}
}
pub fn handle(&self) -> CredentialSyncHandle {
self.handle.clone()
}
pub fn try_result(&mut self) -> Option<CredentialSyncResult> {
self.results.try_recv().ok()
}
pub async fn result(&mut self) -> Option<CredentialSyncResult> {
self.results.recv().await
}
}
#[cfg(all(test, unix))]
pub(super) async fn reconcile_profile(
targets: &[CredentialSyncTarget],
triggered_by: Option<&str>,
) -> Vec<CredentialSyncOutcome> {
reconcile_profile_guarded(&SessionRelays::Direct, targets, triggered_by, None).await
}
pub(super) async fn reconcile_profile_guarded(
relays: &SessionRelays,
targets: &[CredentialSyncTarget],
triggered_by: Option<&str>,
gate: Option<&Arc<crate::recovery_gate::RecoveryGate>>,
) -> Vec<CredentialSyncOutcome> {
let Some(first) = targets.first().cloned() else {
return Vec::new();
};
let github_token = match targets.iter().any(|target| target.sync_github_token) {
true => tokio::task::spawn_blocking(crate::controller::controller_github_token)
.await
.unwrap_or_else(|error| {
tracing::warn!("github token lookup task stopped: {error}");
None
}),
false => None,
};
let skills = Arc::new(
tokio::task::spawn_blocking(move || CanonicalSkills::collect(&first))
.await
.unwrap_or_else(|error| {
CanonicalSkills::failed(&format!("skills collection task stopped: {error}"))
}),
);
let mut outcomes = BTreeMap::<String, CredentialSyncOutcome>::new();
for pass in 0..2 {
let mut pulled = false;
for target in targets {
let result = match gate {
Some(gate) => gate
.run_background(&target.session_id, async {
let candidate = target.clone();
let current = tokio::task::spawn_blocking(move || {
let controller = crate::controller::Controller {
config: mj_core::config::Config::load()?,
state: crate::database::load_state()?,
};
Ok::<_, anyhow::Error>(
crate::pollers::credential_sync_target_is_current(
controller, &candidate,
),
)
})
.await
.context("reload credential sync target")??;
if !current {
return Ok(None);
}
reconcile_session(relays, target, &skills, github_token.as_deref()).await
})
.await
.unwrap_or(Ok(None)),
None => reconcile_session(relays, target, &skills, github_token.as_deref()).await,
};
match result {
Ok(None) => {}
Ok(Some(actions))
if actions.is_empty()
&& (triggered_by != Some(target.session_id.as_str())
|| outcomes.contains_key(&target.session_id)) => {}
Ok(Some(actions)) => {
pulled |= actions.contains(&CredentialSyncAction::Pulled);
outcomes.insert(
target.session_id.clone(),
CredentialSyncOutcome {
session_id: target.session_id.clone(),
outcome: Ok(actions),
},
);
}
Err(error) => {
tracing::warn!(
session_id = %target.session_id,
profile_id = %target.profile_id,
pass = pass + 1,
error = %error,
"credential synchronization failed for relay session"
);
outcomes.insert(
target.session_id.clone(),
CredentialSyncOutcome {
session_id: target.session_id.clone(),
outcome: Err(format!("{error:#}")),
},
);
}
}
}
if !pulled || pass == 1 {
break;
}
}
outcomes.into_values().collect()
}
pub(super) fn canonical_session_skills(
target: &CredentialSyncTarget,
format: mj_core::skills::SkillsArchiveFormat,
) -> Result<mj_core::skills::SkillsArchive> {
mj_core::skills::session_skills(target.harness, &target.profile_home, format).with_context(
|| {
format!(
"collect canonical skills for profile {} from {}",
target.profile_id,
target.profile_home.display()
)
},
)
}
pub(super) struct CanonicalSkills {
plain: std::result::Result<mj_core::skills::SkillsArchive, String>,
gzip: std::result::Result<mj_core::skills::SkillsArchive, String>,
}
impl CanonicalSkills {
pub(super) fn collect(target: &CredentialSyncTarget) -> Self {
let collect =
|format| canonical_session_skills(target, format).map_err(|error| format!("{error:#}"));
Self {
plain: collect(mj_core::skills::SkillsArchiveFormat::Plain),
gzip: collect(mj_core::skills::SkillsArchiveFormat::Gzip),
}
}
fn failed(reason: &str) -> Self {
Self {
plain: Err(reason.to_owned()),
gzip: Err(reason.to_owned()),
}
}
fn for_format(
&self,
format: mj_core::skills::SkillsArchiveFormat,
) -> Result<&mj_core::skills::SkillsArchive> {
let collected = match format {
mj_core::skills::SkillsArchiveFormat::Plain => &self.plain,
mj_core::skills::SkillsArchiveFormat::Gzip => &self.gzip,
};
collected.as_ref().map_err(|error| anyhow!("{error}"))
}
}
pub(super) struct SessionCanonical {
pub(super) credential_path: std::path::PathBuf,
pub(super) credential: CredentialSnapshot,
pub(super) credential_bytes: Vec<u8>,
pub(super) skills: Arc<CanonicalSkills>,
pub(super) github_token: Option<String>,
}
impl SessionCanonical {
pub(super) fn read(
target: &CredentialSyncTarget,
skills: Arc<CanonicalSkills>,
github_token: Option<&str>,
) -> Result<Self> {
let credential_path = harness_authentication_marker(target.harness, &target.profile_home);
let (credential, credential_bytes) =
read_credential_file(target.harness, &credential_path)?;
Ok(Self {
credential_path,
credential,
credential_bytes,
skills,
github_token: github_token.map(ToOwned::to_owned),
})
}
}
pub(super) async fn reconcile_session(
relays: &SessionRelays,
target: &CredentialSyncTarget,
skills: &Arc<CanonicalSkills>,
github_token: Option<&str>,
) -> Result<Option<Vec<CredentialSyncAction>>> {
let canonical = SessionCanonical::read(target, skills.clone(), github_token)?;
match relays {
SessionRelays::Actors(manager) => {
let Some(handle) = manager.find_session(target.session_id.clone()).await? else {
tracing::debug!(
session_id = %target.session_id,
"credential sync waits for the session's relay actor"
);
return Ok(None);
};
let (reply, response) = tokio::sync::oneshot::channel();
handle
.run_on_connection(Box::new(CredentialSyncJob {
target: target.clone(),
canonical,
reply,
}))
.await;
match response
.await
.context("the session actor dropped the credential sync")?
{
Ok(actions) => Ok(Some(actions)),
Err(error) if RelayJobDeferred::marks(&error) => {
tracing::debug!(
session_id = %target.session_id,
reason = %error,
"credential sync deferred to the next cycle"
);
Ok(None)
}
Err(error) => Err(error),
}
}
#[cfg(test)]
SessionRelays::Direct => {
let mut client = RelayClient::connect(&target.spec, &target.session_id).await?;
let result = reconcile_on(&mut client, target, &canonical).await;
if let Err(error) = client.detach().await {
tracing::warn!(
session_id = %target.session_id,
"could not close the credential sync connection: {error:#}"
);
}
result.map(Some)
}
}
}
pub(super) async fn reconcile_on(
client: &mut RelayClient,
target: &CredentialSyncTarget,
canonical: &SessionCanonical,
) -> Result<Vec<CredentialSyncAction>> {
let skills = canonical
.skills
.for_format(client.skills_archive_format())?;
reconcile_connected(
client,
target,
&canonical.credential_path,
&canonical.credential,
&canonical.credential_bytes,
skills,
canonical.github_token.as_deref(),
)
.await
}
struct CredentialSyncJob {
target: CredentialSyncTarget,
canonical: SessionCanonical,
reply: tokio::sync::oneshot::Sender<Result<Vec<CredentialSyncAction>>>,
}
impl RelayConnectionJob for CredentialSyncJob {
fn run<'a>(self: Box<Self>, client: &'a mut RelayClient) -> futures::future::BoxFuture<'a, ()> {
Box::pin(async move {
let Self {
target,
canonical,
reply,
} = *self;
let result = reconcile_on(client, &target, &canonical).await;
if reply.send(result).is_err() {
tracing::debug!(
session_id = %target.session_id,
"credential sync result receiver was already closed"
);
}
})
}
fn refuse(self: Box<Self>, error: anyhow::Error) {
if self.reply.send(Err(error)).is_err() {
tracing::debug!(
session_id = %self.target.session_id,
"credential sync refusal receiver was already closed"
);
}
}
}
pub(super) async fn reconcile_connected(
client: &mut RelayClient,
target: &CredentialSyncTarget,
canonical_path: &Path,
canonical: &CredentialSnapshot,
canonical_bytes: &[u8],
canonical_skills: &mj_core::skills::SkillsArchive,
github_token: Option<&str>,
) -> Result<Vec<CredentialSyncAction>> {
let mut actions = Vec::new();
if !target.authenticates_with_api_key {
reconcile_credentials(
client,
target,
canonical_path,
canonical,
canonical_bytes,
&mut actions,
)
.await?;
}
if reconcile_skills(client, target, canonical_skills).await? {
actions.push(CredentialSyncAction::SkillsPushed);
}
if target.sync_github_token
&& let Some(action) = reconcile_github_token(client, target, github_token).await?
{
actions.push(action);
}
Ok(actions)
}
pub(super) async fn reconcile_credentials(
client: &mut RelayClient,
target: &CredentialSyncTarget,
canonical_path: &Path,
canonical: &CredentialSnapshot,
canonical_bytes: &[u8],
actions: &mut Vec<CredentialSyncAction>,
) -> Result<()> {
let session = client.credential_state().await?;
match reconcile(canonical, &session) {
SyncAction::None => {
if canonical.present
&& session.present
&& canonical.fingerprint != session.fingerprint
&& canonical.freshness_epoch_ms.is_none()
&& session.freshness_epoch_ms.is_none()
{
tracing::warn!(
session_id = %target.session_id,
profile_id = %target.profile_id,
"credential copies differ but neither reports a refresh time; leaving both alone"
);
}
}
SyncAction::Push => {
client.install_credentials(canonical_bytes).await?;
actions.push(CredentialSyncAction::Pushed);
}
SyncAction::Pull => {
let bytes = client.read_credentials().await?;
validate_credential_payload(target.harness, &bytes).with_context(|| {
format!(
"session {} returned an unusable credential file",
target.session_id
)
})?;
write_credential_file(target.harness, canonical_path, &bytes).with_context(|| {
format!(
"install fresher credentials from session {} for profile {}",
target.session_id, target.profile_id
)
})?;
actions.push(CredentialSyncAction::Pulled);
}
}
Ok(())
}
pub(super) async fn reconcile_github_token(
client: &mut RelayClient,
target: &CredentialSyncTarget,
canonical: Option<&str>,
) -> Result<Option<CredentialSyncAction>> {
let session = match client.github_token_state().await {
Ok(state) => state,
Err(error) if sync_method_unsupported(&error) => {
tracing::debug!(
session_id = %target.session_id,
profile_id = %target.profile_id,
"worker predates GitHub token sync; skipping until the target is re-provisioned"
);
return Ok(None);
}
Err(error) => return Err(error),
};
match canonical {
Some(token) => {
let canonical = mj_core::credentials::GithubTokenSnapshot::of(token);
if session == canonical {
return Ok(None);
}
let installed = client.install_github_token(token).await?;
if installed != canonical {
bail!(
"session {} GitHub token fingerprint does not match the controller after install",
target.session_id
);
}
Ok(Some(CredentialSyncAction::GithubTokenPushed))
}
None if session.present => {
let removed = client.remove_github_token().await?;
if removed.present {
bail!(
"session {} retained its GitHub token after removal",
target.session_id
);
}
Ok(Some(CredentialSyncAction::GithubTokenRemoved))
}
None => Ok(None),
}
}
pub(super) async fn reconcile_skills(
client: &mut RelayClient,
target: &CredentialSyncTarget,
canonical: &mj_core::skills::SkillsArchive,
) -> Result<bool> {
let canonical_state = canonical.state();
let session = match client.skills_state().await {
Ok(state) => state,
Err(error) if sync_method_unsupported(&error) => {
tracing::debug!(
session_id = %target.session_id,
profile_id = %target.profile_id,
"worker predates skills sync; skipping until the target is re-provisioned"
);
return Ok(false);
}
Err(error) => return Err(error),
};
if session == canonical_state {
return Ok(false);
}
let installed = client
.install_skills(&canonical.encode(client.skills_archive_format()))
.await?;
if installed != canonical_state {
bail!(
"session {} skills fingerprint {} does not match the canonical {} after install",
target.session_id,
installed.fingerprint,
canonical_state.fingerprint
);
}
Ok(true)
}
pub(super) fn sync_method_unsupported(error: &anyhow::Error) -> bool {
error
.downcast_ref::<RelayRejected>()
.is_some_and(|rejected| rejected.0.code == RelayErrorCode::InvalidRequest)
}