use std::process::Stdio;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use serde::{Deserialize, Serialize};
use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use tracing::{debug, warn};
use crate::config::LifecycleIntegrationConfig;
pub const LIFECYCLE_PROTOCOL_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LifecycleExecutionMode {
Tui,
Run,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LifecycleState {
Idle,
Working,
Blocked,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LifecycleEventKind {
ProcessStarted,
StateChanged,
SessionIdentified,
ProcessStopping,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct LifecycleContext {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub change_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
}
impl LifecycleContext {
pub fn workspace(path: impl Into<String>) -> Self {
Self {
workspace: Some(path.into()),
..Default::default()
}
}
pub fn is_empty(&self) -> bool {
self.workspace.is_none() && self.change_id.is_none() && self.session_id.is_none()
}
pub fn with_change_id(mut self, change_id: Option<String>) -> Self {
self.change_id = change_id;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LifecycleMessage {
pub protocol_version: u32,
pub sequence: u64,
pub kind: LifecycleEventKind,
pub mode: LifecycleExecutionMode,
pub pid: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state: Option<LifecycleState>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<LifecycleContext>,
}
impl LifecycleMessage {
pub fn to_jsonl(&self) -> String {
match serde_json::to_string(self) {
Ok(mut line) => {
line.push('\n');
line
}
Err(err) => {
warn!("Failed to serialize lifecycle message: {}", err);
String::new()
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LifecycleEvent {
ProcessStarted { context: LifecycleContext },
StateChanged {
state: LifecycleState,
context: LifecycleContext,
},
#[allow(dead_code)]
SessionIdentified { session_id: String },
ProcessStopping,
}
pub fn build_message(
event: &LifecycleEvent,
sequence: u64,
mode: LifecycleExecutionMode,
pid: u32,
) -> LifecycleMessage {
let (kind, state, context) = match event {
LifecycleEvent::ProcessStarted { context } => (
LifecycleEventKind::ProcessStarted,
None,
Some(context.clone()),
),
LifecycleEvent::StateChanged { state, context } => (
LifecycleEventKind::StateChanged,
Some(*state),
Some(context.clone()),
),
LifecycleEvent::SessionIdentified { session_id } => (
LifecycleEventKind::SessionIdentified,
None,
Some(LifecycleContext {
session_id: Some(session_id.clone()),
..Default::default()
}),
),
LifecycleEvent::ProcessStopping => (LifecycleEventKind::ProcessStopping, None, None),
};
LifecycleMessage {
protocol_version: LIFECYCLE_PROTOCOL_VERSION,
sequence,
kind,
mode,
pid,
state,
context: context.filter(|ctx| !ctx.is_empty()),
}
}
#[derive(Debug, Default)]
pub struct LifecycleStateTracker {
last: Option<(LifecycleState, LifecycleContext)>,
}
impl LifecycleStateTracker {
pub fn should_emit(&mut self, state: LifecycleState, context: &LifecycleContext) -> bool {
if self
.last
.as_ref()
.is_some_and(|(last_state, last_ctx)| *last_state == state && last_ctx == context)
{
return false;
}
self.last = Some((state, context.clone()));
true
}
}
pub trait LifecyclePublisher: Send + Sync {
fn publish(&self, event: LifecycleEvent);
}
#[derive(Clone, Default)]
pub struct LifecycleHandle {
publisher: Option<Arc<dyn LifecyclePublisher>>,
}
impl std::fmt::Debug for LifecycleHandle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LifecycleHandle")
.field("enabled", &self.publisher.is_some())
.finish()
}
}
impl LifecycleHandle {
pub fn disabled() -> Self {
Self::default()
}
pub fn from_publisher(publisher: Arc<dyn LifecyclePublisher>) -> Self {
Self {
publisher: Some(publisher),
}
}
pub fn is_enabled(&self) -> bool {
self.publisher.is_some()
}
pub fn publish(&self, event: LifecycleEvent) {
if let Some(publisher) = &self.publisher {
publisher.publish(event);
}
}
pub fn publish_state(&self, state: LifecycleState, context: LifecycleContext) {
self.publish(LifecycleEvent::StateChanged { state, context });
}
}
pub struct LifecycleDispatcher {
sender: Mutex<Option<mpsc::Sender<LifecycleEvent>>>,
tracker: Mutex<LifecycleStateTracker>,
dropped: AtomicU64,
drop_warned: AtomicBool,
worker: Mutex<Option<JoinHandle<()>>>,
shutdown_timeout: Duration,
}
impl LifecyclePublisher for LifecycleDispatcher {
fn publish(&self, event: LifecycleEvent) {
if let LifecycleEvent::StateChanged { state, context } = &event {
let mut tracker = match self.tracker.lock() {
Ok(tracker) => tracker,
Err(poisoned) => poisoned.into_inner(),
};
if !tracker.should_emit(*state, context) {
return;
}
}
let sender = {
let guard = match self.sender.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
guard.clone()
};
let Some(sender) = sender else {
return;
};
if sender.try_send(event).is_err() {
let dropped = self.dropped.fetch_add(1, Ordering::Relaxed) + 1;
if !self.drop_warned.swap(true, Ordering::Relaxed) {
warn!(
"External lifecycle adapter is not keeping up; dropping lifecycle messages (dropped={dropped})"
);
} else {
debug!("Dropped lifecycle message (total dropped={dropped})");
}
}
}
}
impl LifecycleDispatcher {
pub fn dropped_messages(&self) -> u64 {
self.dropped.load(Ordering::Relaxed)
}
async fn shutdown(&self) {
self.publish(LifecycleEvent::ProcessStopping);
{
let mut guard = match self.sender.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
guard.take();
}
let worker = {
let mut guard = match self.worker.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
guard.take()
};
if let Some(worker) = worker {
match tokio::time::timeout(self.shutdown_timeout, worker).await {
Ok(Ok(())) => {}
Ok(Err(err)) => debug!("Lifecycle adapter worker ended abnormally: {}", err),
Err(_) => {
warn!(
"External lifecycle adapter did not shut down within {:?}; terminating it",
self.shutdown_timeout
);
}
}
}
let dropped = self.dropped_messages();
if dropped > 0 {
warn!(
"External lifecycle adapter could not keep up; {} lifecycle message(s) were dropped",
dropped
);
}
}
}
pub struct LifecycleIntegration {
dispatcher: Option<Arc<LifecycleDispatcher>>,
}
impl LifecycleIntegration {
pub fn disabled() -> Self {
Self { dispatcher: None }
}
pub fn start(
config: Option<&LifecycleIntegrationConfig>,
mode: LifecycleExecutionMode,
) -> Self {
let Some(config) = config else {
return Self::disabled();
};
if !config.is_enabled() {
debug!("External lifecycle integration is configured but disabled");
return Self::disabled();
}
if let Err(err) = config.validate() {
warn!("External lifecycle integration disabled: {}", err);
return Self::disabled();
}
let (program, args) = config
.command
.split_first()
.expect("validated non-empty argv");
let mut command = Command::new(program);
command
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.kill_on_drop(true);
let mut child = match command.spawn() {
Ok(child) => child,
Err(err) => {
warn!(
"External lifecycle adapter `{}` could not be started ({}); continuing without lifecycle reporting. Check `lifecycle_integration.command` in your cflx configuration.",
program, err
);
return Self::disabled();
}
};
let Some(mut stdin) = child.stdin.take() else {
warn!("External lifecycle adapter stdin was unavailable; continuing without lifecycle reporting");
return Self::disabled();
};
let (tx, mut rx) = mpsc::channel::<LifecycleEvent>(config.queue_capacity());
let write_timeout = Duration::from_millis(config.write_timeout_ms());
let shutdown_timeout = Duration::from_millis(config.shutdown_timeout_ms());
let pid = std::process::id();
let worker = tokio::spawn(async move {
let mut sequence: u64 = 0;
let mut writable = true;
while let Some(event) = rx.recv().await {
if !writable {
continue;
}
sequence += 1;
let line = build_message(&event, sequence, mode, pid).to_jsonl();
if line.is_empty() {
continue;
}
match tokio::time::timeout(write_timeout, stdin.write_all(line.as_bytes())).await {
Ok(Ok(())) => match tokio::time::timeout(write_timeout, stdin.flush()).await {
Ok(Ok(())) => {}
Ok(Err(err)) => {
warn!("External lifecycle adapter flush failed ({}); disabling lifecycle reporting", err);
writable = false;
}
Err(_) => {
warn!("External lifecycle adapter stopped reading; disabling lifecycle reporting");
writable = false;
}
},
Ok(Err(err)) => {
warn!(
"External lifecycle adapter write failed ({}); disabling lifecycle reporting",
err
);
writable = false;
}
Err(_) => {
warn!("External lifecycle adapter stopped reading; disabling lifecycle reporting");
writable = false;
}
}
}
let _ = stdin.shutdown().await;
drop(stdin);
let _ = child.wait().await;
});
Self {
dispatcher: Some(Arc::new(LifecycleDispatcher {
sender: Mutex::new(Some(tx)),
tracker: Mutex::new(LifecycleStateTracker::default()),
dropped: AtomicU64::new(0),
drop_warned: AtomicBool::new(false),
worker: Mutex::new(Some(worker)),
shutdown_timeout,
})),
}
}
pub fn handle(&self) -> LifecycleHandle {
match &self.dispatcher {
Some(dispatcher) => {
LifecycleHandle::from_publisher(dispatcher.clone() as Arc<dyn LifecyclePublisher>)
}
None => LifecycleHandle::disabled(),
}
}
#[allow(dead_code)]
pub fn is_enabled(&self) -> bool {
self.dispatcher.is_some()
}
pub async fn shutdown(&self) {
if let Some(dispatcher) = &self.dispatcher {
dispatcher.shutdown().await;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn message(event: &LifecycleEvent, sequence: u64) -> LifecycleMessage {
build_message(event, sequence, LifecycleExecutionMode::Tui, 4242)
}
#[test]
fn process_started_message_carries_version_sequence_mode_and_pid() {
let event = LifecycleEvent::ProcessStarted {
context: LifecycleContext::workspace("/repo"),
};
let built = message(&event, 1);
assert_eq!(built.protocol_version, LIFECYCLE_PROTOCOL_VERSION);
assert_eq!(built.sequence, 1);
assert_eq!(built.kind, LifecycleEventKind::ProcessStarted);
assert_eq!(built.mode, LifecycleExecutionMode::Tui);
assert_eq!(built.pid, 4242);
assert_eq!(built.state, None);
assert_eq!(
built.context.and_then(|ctx| ctx.workspace),
Some("/repo".to_string())
);
}
#[test]
fn state_changed_message_carries_semantic_state_and_change_id() {
let event = LifecycleEvent::StateChanged {
state: LifecycleState::Blocked,
context: LifecycleContext::workspace("/repo")
.with_change_id(Some("change-a".to_string())),
};
let built = message(&event, 7);
assert_eq!(built.kind, LifecycleEventKind::StateChanged);
assert_eq!(built.state, Some(LifecycleState::Blocked));
let context = built.context.expect("context present");
assert_eq!(context.change_id.as_deref(), Some("change-a"));
}
#[test]
fn process_stopping_message_has_no_state_or_context() {
let built = message(&LifecycleEvent::ProcessStopping, 9);
assert_eq!(built.kind, LifecycleEventKind::ProcessStopping);
assert_eq!(built.state, None);
assert_eq!(built.context, None);
}
#[test]
fn session_identified_message_reports_session_only() {
let built = message(
&LifecycleEvent::SessionIdentified {
session_id: "session-1".to_string(),
},
3,
);
assert_eq!(built.kind, LifecycleEventKind::SessionIdentified);
let context = built.context.expect("context present");
assert_eq!(context.session_id.as_deref(), Some("session-1"));
assert_eq!(context.workspace, None);
assert_eq!(context.change_id, None);
}
#[test]
fn serialized_line_is_single_compact_json_object() {
let line = message(
&LifecycleEvent::StateChanged {
state: LifecycleState::Working,
context: LifecycleContext::workspace("/repo"),
},
2,
)
.to_jsonl();
assert!(line.ends_with('\n'), "line must be newline terminated");
assert_eq!(line.matches('\n').count(), 1, "one message per line");
let parsed: serde_json::Value =
serde_json::from_str(line.trim_end()).expect("line must be valid JSON");
assert_eq!(parsed["protocol_version"], 1);
assert_eq!(parsed["kind"], "state_changed");
assert_eq!(parsed["state"], "working");
assert_eq!(parsed["mode"], "tui");
assert_eq!(parsed["sequence"], 2);
}
#[test]
fn serialized_payload_exposes_only_allowed_fields() {
let line = message(
&LifecycleEvent::StateChanged {
state: LifecycleState::Idle,
context: LifecycleContext::workspace("/repo")
.with_change_id(Some("change-a".to_string())),
},
5,
)
.to_jsonl();
let parsed: serde_json::Value = serde_json::from_str(line.trim_end()).expect("valid JSON");
let object = parsed.as_object().expect("object payload");
let mut keys: Vec<&str> = object.keys().map(String::as_str).collect();
keys.sort_unstable();
assert_eq!(
keys,
vec![
"context",
"kind",
"mode",
"pid",
"protocol_version",
"sequence",
"state"
]
);
let context = object["context"].as_object().expect("context object");
let mut context_keys: Vec<&str> = context.keys().map(String::as_str).collect();
context_keys.sort_unstable();
assert_eq!(context_keys, vec!["change_id", "workspace"]);
}
#[test]
fn empty_context_is_omitted_from_payload() {
let built = message(
&LifecycleEvent::ProcessStarted {
context: LifecycleContext::default(),
},
1,
);
assert_eq!(built.context, None);
let parsed: serde_json::Value =
serde_json::from_str(built.to_jsonl().trim_end()).expect("valid JSON");
assert!(parsed.get("context").is_none());
}
#[test]
fn state_tracker_suppresses_unchanged_states() {
let mut tracker = LifecycleStateTracker::default();
let context = LifecycleContext::workspace("/repo");
assert!(tracker.should_emit(LifecycleState::Idle, &context));
assert!(!tracker.should_emit(LifecycleState::Idle, &context));
assert!(tracker.should_emit(LifecycleState::Working, &context));
assert!(!tracker.should_emit(LifecycleState::Working, &context));
assert!(tracker.should_emit(LifecycleState::Idle, &context));
}
#[test]
fn state_tracker_treats_context_change_as_a_new_state() {
let mut tracker = LifecycleStateTracker::default();
let base = LifecycleContext::workspace("/repo");
let with_change =
LifecycleContext::workspace("/repo").with_change_id(Some("change-a".to_string()));
assert!(tracker.should_emit(LifecycleState::Working, &base));
assert!(tracker.should_emit(LifecycleState::Working, &with_change));
assert!(!tracker.should_emit(LifecycleState::Working, &with_change));
}
#[test]
fn disabled_handle_is_a_noop() {
let handle = LifecycleHandle::disabled();
assert!(!handle.is_enabled());
handle.publish(LifecycleEvent::ProcessStopping);
}
#[test]
fn integration_without_configuration_is_disabled() {
let integration = LifecycleIntegration::start(None, LifecycleExecutionMode::Run);
assert!(!integration.is_enabled());
assert!(!integration.handle().is_enabled());
}
#[test]
fn integration_with_disabled_configuration_is_disabled() {
let config = LifecycleIntegrationConfig {
enabled: Some(false),
command: vec!["adapter".to_string()],
..Default::default()
};
let integration = LifecycleIntegration::start(Some(&config), LifecycleExecutionMode::Run);
assert!(!integration.is_enabled());
}
#[test]
fn integration_with_invalid_configuration_is_disabled() {
let config = LifecycleIntegrationConfig {
enabled: Some(true),
command: Vec::new(),
..Default::default()
};
let integration = LifecycleIntegration::start(Some(&config), LifecycleExecutionMode::Tui);
assert!(!integration.is_enabled());
}
}