use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use super::{now_unix, LeanResult, Syncer, SENTINEL_PROTOCOL};
pub const CAPABILITIES: &str = "capabilities.json";
pub const AGENT_GUIDE: &str = "AGENTS.md";
pub const AGENT_GUIDE_TEXT: &str = include_str!("../AGENTS.md");
pub const REMOTE_SEQ: &str = "remote.seq";
pub const PUBLISH: &str = "publish";
pub const PUBLISH_ACK: &str = "publish.ack";
pub const SYNC: &str = "sync";
pub const SYNC_ACK: &str = "sync.ack";
const POSTURE: &str = "sentinels.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BootStamp {
pub holder_id: String,
pub boot_unix: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Capabilities {
pub protocol: u32,
pub verbs: Vec<String>,
#[serde(default = "read_write")]
pub access: String,
pub state: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
pub sentinel_min_interval_secs: u64,
pub sentinel_hourly_budget: u64,
pub syncer_version: String,
pub boot: BootStamp,
}
fn read_write() -> String {
super::Access::ReadWrite.as_str().to_string()
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RemoteSeq {
pub observed_seq: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub observed_etag: Option<String>,
pub integrated_seq: u64,
pub updated_unix: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub sync_requested_unix: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sync_requested_by: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sync_requested_at_seq: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SentinelPosture {
pub enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
pub fn write_atomic(path: &Path, bytes: &[u8]) -> LeanResult<()> {
super::safefs::check_parent(path)?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = path.with_file_name(format!(
"{}.tmp",
path.file_name().map(|n| n.to_string_lossy()).unwrap_or_default()
));
super::safefs::write_via_tmp(path, &tmp, bytes, None).map(|_| ())
}
fn write_json<T: Serialize>(path: &Path, v: &T) -> LeanResult<()> {
let bytes = serde_json::to_vec_pretty(v)
.map_err(|e| super::LeanError::State(format!("{}: {e}", path.display())))?;
write_atomic(path, &bytes)
}
impl Syncer {
pub fn control_path(&self, name: &str) -> PathBuf {
self.cfg.control_dir().join(name)
}
fn posture_path(&self) -> PathBuf {
self.cfg.state_dir().join(POSTURE)
}
pub fn load_posture(&self) -> LeanResult<Option<SentinelPosture>> {
let p = self.posture_path();
if !p.exists() {
return Ok(None);
}
let bytes = std::fs::read(&p)?;
serde_json::from_slice(&bytes)
.map(Some)
.map_err(|e| super::LeanError::State(format!("posture: {e}")))
}
pub fn sentinel_preflight(&self) -> LeanResult<SentinelPosture> {
if self.cfg.sentinel_mode == super::SentinelMode::Off {
let posture =
SentinelPosture { enabled: false, reason: Some("sentinels-off".into()) };
write_json(&self.posture_path(), &posture)?;
return Ok(posture);
}
if let Some(prior) = self.load_posture()? {
if !prior.enabled && self.cfg.sentinel_mode != super::SentinelMode::Force {
return Ok(prior);
}
}
let legacy_cited =
self.state.with_baseline(|b| b.entries.keys().any(|p| super::scan::is_control_path(p)))?;
let never_marked = !self.control_path(CAPABILITIES).exists();
let sentinel_named = self.control_path(PUBLISH).exists() || self.control_path(SYNC).exists();
let preexisting = legacy_cited || (never_marked && sentinel_named);
let posture = if preexisting && self.cfg.sentinel_mode != super::SentinelMode::Force {
SentinelPosture { enabled: false, reason: Some("preexisting-flint-paths".into()) }
} else if preexisting {
SentinelPosture { enabled: true, reason: Some("forced-over-preexisting".into()) }
} else {
SentinelPosture { enabled: true, reason: None }
};
write_json(&self.posture_path(), &posture)?;
Ok(posture)
}
pub fn write_capabilities(&self, posture: &SentinelPosture) -> LeanResult<()> {
let verbs: Vec<String> = if !posture.enabled {
vec![]
} else if self.cfg.access.is_read() {
["sync", "remote-seq"].iter().map(|s| s.to_string()).collect()
} else {
["publish", "sync", "remote-seq"].iter().map(|s| s.to_string()).collect()
};
let holder_id = self
.lease
.as_ref()
.map(|l| l.holder_id.clone())
.or_else(|| {
self.state.load_incarnation().ok().flatten().map(|i| i.holder_id)
})
.unwrap_or_else(|| "unclaimed".into());
let caps = Capabilities {
protocol: SENTINEL_PROTOCOL,
verbs,
access: self.cfg.access.as_str().to_string(),
state: "live".into(),
reason: posture.reason.clone(),
sentinel_min_interval_secs: self.cfg.sentinel_min_interval_secs,
sentinel_hourly_budget: self.cfg.sentinel_hourly_budget,
syncer_version: super::SYNCER_VERSION.to_string(),
boot: BootStamp { holder_id, boot_unix: now_unix() },
};
write_json(&self.control_path(CAPABILITIES), &caps)?;
write_atomic(&self.control_path(AGENT_GUIDE), AGENT_GUIDE_TEXT.as_bytes())
}
pub fn read_capabilities(&self) -> Option<Capabilities> {
let bytes = std::fs::read(self.control_path(CAPABILITIES)).ok()?;
serde_json::from_slice(&bytes).ok()
}
pub fn load_remote_seq(&self) -> RemoteSeq {
std::fs::read(self.control_path(REMOTE_SEQ))
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default()
}
pub fn touch_remote_seq(
&self,
observed_seq: Option<u64>,
observed_etag: Option<String>,
integrated_seq: u64,
) -> LeanResult<()> {
let mut t = self.load_remote_seq();
if let Some(seq) = observed_seq {
t.observed_seq = seq;
}
if observed_etag.is_some() {
t.observed_etag = observed_etag;
}
t.integrated_seq = integrated_seq;
if t.observed_seq < integrated_seq {
t.observed_seq = integrated_seq;
}
if t.sync_requested_at_seq.map(|at| integrated_seq > at).unwrap_or(false) {
t.sync_requested_unix = None;
t.sync_requested_by = None;
t.sync_requested_at_seq = None;
}
t.updated_unix = now_unix();
write_json(&self.control_path(REMOTE_SEQ), &t)
}
pub fn carry_sync_request(&self, requested_unix: u64, requestor: &str) -> LeanResult<()> {
let mut t = self.load_remote_seq();
let newer = t.sync_requested_unix.map(|p| requested_unix > p).unwrap_or(true);
if !newer {
return Ok(());
}
t.sync_requested_unix = Some(requested_unix);
t.sync_requested_by = Some(requestor.to_string());
t.sync_requested_at_seq = Some(t.integrated_seq);
t.updated_unix = now_unix();
write_json(&self.control_path(REMOTE_SEQ), &t)
}
}