use anyhow::{bail, Result};
use serde::Serialize;
use crate::display_safe::display_safe;
use crate::paths::state::StateLayout;
use super::session::{self, SessionLifecycle};
#[derive(Debug, Clone, Default)]
pub(crate) struct ExclusiveWriteOptions {
pub(crate) actor_id: Option<String>,
pub(crate) session_id: Option<String>,
pub(crate) expected_revision: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct ExclusiveWriteGuard {
pub(crate) expected_revision: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum AppendWriteOutcome {
Applied,
IdempotentNoop,
OwnershipConflict,
StaleSession,
UnsupportedMultiwriter,
DuplicateIdConflict,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AppendWriteAuthority {
OwnerOnly,
OwnerOrSupervisor,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct AppendWriteConflict {
pub(crate) outcome: AppendWriteOutcome,
pub(crate) message: String,
}
impl ExclusiveWriteOptions {
fn has_identity_fields(&self) -> bool {
self.actor_id.is_some() || self.session_id.is_some()
}
}
pub(crate) fn authorize_owner_surface_write(
layout: &StateLayout,
surface: &str,
options: &ExclusiveWriteOptions,
) -> Result<ExclusiveWriteGuard> {
if options.has_identity_fields() && options.expected_revision.is_none() {
bail!(
"`--expected-revision` is required when protected-write identity flags are provided for `{surface}`"
);
}
let Some(state) = session::load_for_layout(layout)? else {
return Ok(ExclusiveWriteGuard {
expected_revision: options.expected_revision,
});
};
if state.lifecycle() != SessionLifecycle::Autonomous {
return Ok(ExclusiveWriteGuard {
expected_revision: options.expected_revision,
});
}
let now = session::now_epoch_s()?;
if session::is_stale(&state, now) {
bail!(
"`{surface}` is protected by stale autonomous session `{}` owned by `{}`; use `ccd session-state takeover` or `ccd session-state clear` before mutating it",
display_safe(state.session_id.as_deref().unwrap_or("unknown")),
display_safe(state.owner_id.as_deref().unwrap_or("unknown")),
);
}
let Some(actor_id) = options.actor_id.as_deref() else {
bail!(
"`{surface}` is protected by active autonomous session `{}` owned by `{}`; provide `--actor-id`, `--session-id`, and `--expected-revision` matching the active owner",
display_safe(state.session_id.as_deref().unwrap_or("unknown")),
display_safe(state.owner_id.as_deref().unwrap_or("unknown")),
);
};
let Some(session_id) = options.session_id.as_deref() else {
bail!(
"`{surface}` is protected by active autonomous session `{}` owned by `{}`; provide `--actor-id`, `--session-id`, and `--expected-revision` matching the active owner",
display_safe(state.session_id.as_deref().unwrap_or("unknown")),
display_safe(state.owner_id.as_deref().unwrap_or("unknown")),
);
};
let Some(expected_revision) = options.expected_revision else {
bail!(
"`{surface}` is protected by active autonomous session `{}` owned by `{}`; provide `--actor-id`, `--session-id`, and `--expected-revision` matching the active owner",
display_safe(state.session_id.as_deref().unwrap_or("unknown")),
display_safe(state.owner_id.as_deref().unwrap_or("unknown")),
);
};
if state.owner_id.as_deref() != Some(actor_id) {
bail!(
"`{surface}` requires owner actor `{}`; got `{}`",
display_safe(state.owner_id.as_deref().unwrap_or("unknown")),
display_safe(actor_id),
);
}
if state.session_id.as_deref() != Some(session_id) {
bail!(
"`{surface}` requires active session id `{}`; got `{}`",
display_safe(state.session_id.as_deref().unwrap_or("unknown")),
display_safe(session_id),
);
}
Ok(ExclusiveWriteGuard {
expected_revision: Some(expected_revision),
})
}
pub(crate) fn authorize_append_surface_write(
layout: &StateLayout,
surface: &str,
options: &ExclusiveWriteOptions,
authority: AppendWriteAuthority,
) -> Result<Option<AppendWriteConflict>> {
let Some(state) = session::load_for_layout(layout)? else {
return Ok(None);
};
if state.lifecycle() != SessionLifecycle::Autonomous {
return Ok(None);
}
let now = session::now_epoch_s()?;
if session::is_stale(&state, now) {
return Ok(Some(AppendWriteConflict {
outcome: AppendWriteOutcome::StaleSession,
message: format!(
"`{surface}` is protected by stale autonomous session `{}` owned by `{}`; use `ccd session-state takeover` or `ccd session-state clear` before mutating it",
display_safe(state.session_id.as_deref().unwrap_or("unknown")),
display_safe(state.owner_id.as_deref().unwrap_or("unknown")),
),
}));
}
let Some(actor_id) = options.actor_id.as_deref() else {
return Ok(Some(AppendWriteConflict {
outcome: AppendWriteOutcome::OwnershipConflict,
message: missing_append_identity_message(&state, surface, authority),
}));
};
let Some(session_id) = options.session_id.as_deref() else {
return Ok(Some(AppendWriteConflict {
outcome: AppendWriteOutcome::OwnershipConflict,
message: missing_append_identity_message(&state, surface, authority),
}));
};
if state.session_id.as_deref() != Some(session_id) {
return Ok(Some(AppendWriteConflict {
outcome: AppendWriteOutcome::OwnershipConflict,
message: format!(
"`{surface}` requires active session id `{}`; got `{}`",
display_safe(state.session_id.as_deref().unwrap_or("unknown")),
display_safe(session_id),
),
}));
}
let actor_allowed = match authority {
AppendWriteAuthority::OwnerOnly => state.owner_id.as_deref() == Some(actor_id),
AppendWriteAuthority::OwnerOrSupervisor => {
state.owner_id.as_deref() == Some(actor_id)
|| state.supervisor_id.as_deref() == Some(actor_id)
}
};
if actor_allowed {
return Ok(None);
}
let expected_actor = match authority {
AppendWriteAuthority::OwnerOnly => state.owner_id.as_deref().unwrap_or("unknown"),
AppendWriteAuthority::OwnerOrSupervisor => state
.owner_id
.as_deref()
.or(state.supervisor_id.as_deref())
.unwrap_or("unknown"),
};
Ok(Some(AppendWriteConflict {
outcome: AppendWriteOutcome::OwnershipConflict,
message: match authority {
AppendWriteAuthority::OwnerOnly => format!(
"`{surface}` requires owner actor `{}`; got `{}`",
display_safe(expected_actor),
display_safe(actor_id),
),
AppendWriteAuthority::OwnerOrSupervisor => format!(
"`{surface}` requires the active owner `{}` or supervisor `{}`; got `{}`",
display_safe(state.owner_id.as_deref().unwrap_or("unknown")),
display_safe(state.supervisor_id.as_deref().unwrap_or("unknown")),
display_safe(actor_id),
),
},
}))
}
fn missing_append_identity_message(
state: &session::SessionStateFile,
surface: &str,
authority: AppendWriteAuthority,
) -> String {
match authority {
AppendWriteAuthority::OwnerOnly => format!(
"`{surface}` is protected by active autonomous session `{}` owned by `{}`; provide `--actor-id` and `--session-id` matching the active owner",
display_safe(state.session_id.as_deref().unwrap_or("unknown")),
display_safe(state.owner_id.as_deref().unwrap_or("unknown")),
),
AppendWriteAuthority::OwnerOrSupervisor => format!(
"`{surface}` is protected by active autonomous session `{}` owned by `{}`; provide `--actor-id` and `--session-id` matching the active owner or supervisor",
display_safe(state.session_id.as_deref().unwrap_or("unknown")),
display_safe(state.owner_id.as_deref().unwrap_or("unknown")),
),
}
}
#[cfg(test)]
mod tests {
use super::super::session::{SessionMode, SessionOwnerKind, SessionStateFile};
use super::*;
#[test]
fn missing_append_identity_message_escapes_newlines_in_session_fields() {
let state = SessionStateFile {
schema_version: 0,
started_at_epoch_s: 0,
last_started_at_epoch_s: 0,
start_count: 1,
session_id: Some("ses\nFAKE".to_owned()),
mode: SessionMode::General,
owner_kind: SessionOwnerKind::RuntimeWorker,
owner_id: Some("owner\x1b[2Jroot".to_owned()),
supervisor_id: None,
lease_ttl_secs: Some(3600),
last_heartbeat_at_epoch_s: Some(0),
revision: 1,
};
let rendered = missing_append_identity_message(
&state,
"handoff write",
AppendWriteAuthority::OwnerOnly,
);
assert!(
!rendered.contains('\n'),
"message must not contain literal newlines; got: {rendered:?}"
);
assert!(
!rendered.contains('\x1b'),
"message must not contain literal ESC bytes; got: {rendered:?}"
);
assert!(
rendered.contains(r"ses\nFAKE"),
"session_id must render in escaped form; got: {rendered:?}"
);
assert!(
rendered.contains(r"owner\x1b[2Jroot"),
"owner_id must render in escaped form; got: {rendered:?}"
);
}
}