use std::fs;
use std::path::Path;
use std::process::ExitCode;
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use crate::db;
use crate::output::CommandReport;
use crate::paths::state::StateLayout;
use crate::profile::{self, ProfileName};
use crate::repo::marker as repo_marker;
use crate::state::{
protected_write::{self, AppendWriteAuthority, AppendWriteOutcome},
session as session_state,
};
#[derive(Debug, Clone, Deserialize, Serialize)]
pub(crate) struct EscalationStateFile {
pub(crate) schema_version: u32,
pub(crate) entries: Vec<EscalationEntry>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub(crate) struct EscalationEntry {
pub(crate) id: String,
pub(crate) kind: EscalationKind,
pub(crate) reason: String,
pub(crate) created_at_epoch_s: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) session_id: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum EscalationKind {
Blocking,
NonBlocking,
}
impl EscalationKind {
fn as_str(self) -> &'static str {
match self {
Self::Blocking => "blocking",
Self::NonBlocking => "non_blocking",
}
}
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct EscalationView {
pub(crate) path: String,
pub(crate) status: &'static str,
pub(crate) blocking_count: usize,
pub(crate) non_blocking_count: usize,
pub(crate) entries: Vec<EscalationEntry>,
}
#[derive(Serialize)]
pub struct EscalationSetReport {
command: &'static str,
ok: bool,
outcome: AppendWriteOutcome,
profile: String,
path: String,
entry: EscalationEntry,
view: EscalationView,
#[serde(skip_serializing_if = "Option::is_none")]
message: Option<String>,
}
#[derive(Serialize)]
pub struct EscalationClearReport {
command: &'static str,
ok: bool,
outcome: AppendWriteOutcome,
profile: String,
path: String,
cleared: bool,
#[serde(skip_serializing_if = "Option::is_none")]
cleared_id: Option<String>,
view: EscalationView,
#[serde(skip_serializing_if = "Option::is_none")]
message: Option<String>,
}
#[derive(Serialize)]
pub struct EscalationListReport {
command: &'static str,
ok: bool,
profile: String,
path: String,
view: EscalationView,
}
impl CommandReport for EscalationSetReport {
fn exit_code(&self) -> ExitCode {
if self.ok {
ExitCode::SUCCESS
} else {
ExitCode::from(1)
}
}
fn render_text(&self) {
if !self.ok {
println!(
"{}",
self.message
.as_deref()
.unwrap_or("Escalation write was rejected.")
);
return;
}
if self.outcome == AppendWriteOutcome::IdempotentNoop {
println!(
"Escalation {} already matches the recorded entry at {}; treated as idempotent noop.",
self.entry.id, self.path
);
return;
}
println!(
"Recorded {} escalation {} at {}.",
self.entry.kind.as_str(),
self.entry.id,
self.path
);
if let Some(session_id) = &self.entry.session_id {
println!("Bound to session: {session_id}");
}
println!(
"Active escalations: {} blocking, {} non-blocking.",
self.view.blocking_count, self.view.non_blocking_count
);
if self.view.blocking_count > 0 {
println!("Blocking reasons:");
for entry in blocking_entries(&self.view.entries) {
println!("- {}: {}", entry.id, entry.reason);
}
}
}
}
impl CommandReport for EscalationClearReport {
fn exit_code(&self) -> ExitCode {
if self.ok {
ExitCode::SUCCESS
} else {
ExitCode::from(1)
}
}
fn render_text(&self) {
if !self.ok {
println!(
"{}",
self.message
.as_deref()
.unwrap_or("Escalation clear was rejected.")
);
return;
}
if self.outcome == AppendWriteOutcome::IdempotentNoop && self.cleared_id.is_some() {
println!(
"Escalation entry {} is already absent at {}; treated as idempotent noop.",
self.cleared_id.as_deref().unwrap_or("unknown"),
self.path
);
} else if !self.cleared {
println!("No active escalation state found at {}.", self.path);
} else if self.cleared_id.is_some() {
println!("Cleared escalation entry at {}.", self.path);
} else {
println!("Cleared all escalation entries at {}.", self.path);
}
}
}
impl CommandReport for EscalationListReport {
fn exit_code(&self) -> ExitCode {
ExitCode::SUCCESS
}
fn render_text(&self) {
if self.view.entries.is_empty() {
println!("No active escalations.");
return;
}
println!(
"Escalations at {} ({}: {} blocking, {} non-blocking):",
self.path, self.view.status, self.view.blocking_count, self.view.non_blocking_count
);
for entry in &self.view.entries {
let session = entry
.session_id
.as_deref()
.map(|session_id| format!(" session {session_id}"))
.unwrap_or_default();
println!(
"- [{}] {}{}: {}",
entry.kind.as_str(),
entry.id,
session,
entry.reason
);
}
}
}
fn open_escalation_db(layout: &StateLayout) -> Result<db::StateDb> {
let db = db::StateDb::open(&layout.state_db_path())?;
migrate_escalation_json(&db, layout)?;
Ok(db)
}
fn try_open_escalation_db(layout: &StateLayout) -> Result<Option<db::StateDb>> {
if layout.state_db_path().exists() || layout.escalation_state_path().exists() {
open_escalation_db(layout).map(Some)
} else {
Ok(None)
}
}
fn migrate_escalation_json(db: &db::StateDb, layout: &StateLayout) -> Result<()> {
let path = layout.escalation_state_path();
let contents = match fs::read_to_string(&path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e).with_context(|| format!("failed to read {}", path.display())),
};
let state: EscalationStateFile = serde_json::from_str(&contents)
.with_context(|| format!("failed to parse {}", path.display()))?;
for entry in &state.entries {
db::escalation::insert_or_ignore(db.conn(), entry)?;
}
let mut migrated = path.as_os_str().to_owned();
migrated.push(".migrated");
fs::rename(&path, Path::new(&migrated))
.with_context(|| format!("failed to rename {} to .migrated", path.display()))?;
Ok(())
}
pub(crate) fn load_for_layout(layout: &StateLayout) -> Result<Vec<EscalationEntry>> {
match try_open_escalation_db(layout)? {
Some(db) => db::escalation::list(db.conn()),
None => Ok(Vec::new()),
}
}
pub fn set(
repo_root: &Path,
explicit_profile: Option<&str>,
id: Option<&str>,
write_options: protected_write::ExclusiveWriteOptions,
kind: Option<EscalationKind>,
reason: &str,
) -> Result<EscalationSetReport> {
let profile = profile::resolve(explicit_profile)?;
let layout = required_layout(repo_root, &profile)?;
ensure_repo_linked(repo_root)?;
let now = session_state::now_epoch_s()?;
let tracked_session = session_state::load_for_layout(&layout)?;
if let Some(conflict) = protected_write::authorize_append_surface_write(
&layout,
"escalation",
&write_options,
AppendWriteAuthority::OwnerOnly,
)? {
return Ok(EscalationSetReport {
command: "escalation-state-set",
ok: false,
outcome: conflict.outcome,
profile: profile.to_string(),
path: layout.state_db_path().display().to_string(),
entry: EscalationEntry {
id: id.map(str::to_owned).unwrap_or_else(mint_escalation_id),
kind: kind.unwrap_or(EscalationKind::Blocking),
reason: reason.to_owned(),
created_at_epoch_s: now,
session_id: session_state::load_session_id(&layout)?,
},
view: build_view(&layout, &load_for_layout(&layout)?),
message: Some(conflict.message),
});
}
let autonomous_active = tracked_session.as_ref().is_some_and(|state| {
state.lifecycle() == session_state::SessionLifecycle::Autonomous
&& !session_state::is_stale(state, now)
});
if autonomous_active && id.is_none() {
return Ok(EscalationSetReport {
command: "escalation-state-set",
ok: false,
outcome: AppendWriteOutcome::UnsupportedMultiwriter,
profile: profile.to_string(),
path: layout.state_db_path().display().to_string(),
entry: EscalationEntry {
id: mint_escalation_id(),
kind: kind.unwrap_or(EscalationKind::Blocking),
reason: reason.to_owned(),
created_at_epoch_s: now,
session_id: session_state::load_session_id(&layout)?,
},
view: build_view(&layout, &load_for_layout(&layout)?),
message: Some(
"`escalation` requires explicit `--id` for autonomous append semantics".to_owned(),
),
});
}
let db = open_escalation_db(&layout)?;
let session_id = session_state::load_session_id(&layout)?;
let entry_id = id.map(str::to_owned).unwrap_or_else(mint_escalation_id);
let entry = EscalationEntry {
id: entry_id.clone(),
kind: kind.unwrap_or(EscalationKind::Blocking),
reason: reason.to_owned(),
created_at_epoch_s: now,
session_id,
};
let (entry, ok, outcome, message) = match db::escalation::get_by_id(db.conn(), &entry_id)? {
Some(existing)
if existing.kind == entry.kind
&& existing.reason == entry.reason
&& existing.session_id == entry.session_id =>
{
(existing, true, AppendWriteOutcome::IdempotentNoop, None)
}
Some(_) => (
entry,
false,
AppendWriteOutcome::DuplicateIdConflict,
Some(format!(
"`escalation` id `{entry_id}` already exists with different content"
)),
),
None => {
db::escalation::insert(db.conn(), &entry)?;
(entry, true, AppendWriteOutcome::Applied, None)
}
};
let entries = db::escalation::list(db.conn())?;
let view = build_view(&layout, &entries);
Ok(EscalationSetReport {
command: "escalation-state-set",
ok,
outcome,
profile: profile.to_string(),
path: layout.state_db_path().display().to_string(),
entry,
view,
message,
})
}
pub fn clear(
repo_root: &Path,
explicit_profile: Option<&str>,
id: Option<&str>,
write_options: protected_write::ExclusiveWriteOptions,
) -> Result<EscalationClearReport> {
let profile = profile::resolve(explicit_profile)?;
let layout = required_layout(repo_root, &profile)?;
ensure_repo_linked(repo_root)?;
let db_path = layout.state_db_path().display().to_string();
let tracked_session = session_state::load_for_layout(&layout)?;
let autonomous_session = tracked_session
.as_ref()
.is_some_and(|state| state.lifecycle() == session_state::SessionLifecycle::Autonomous);
if autonomous_session && id.is_none() {
return Ok(EscalationClearReport {
command: "escalation-state-clear",
ok: false,
outcome: AppendWriteOutcome::UnsupportedMultiwriter,
profile: profile.to_string(),
path: db_path,
cleared: false,
cleared_id: None,
view: build_view(&layout, &load_for_layout(&layout)?),
message: Some(
"`escalation` clear-all is reserved for explicit session lifecycle transitions; clear by id or use `ccd session-state clear`"
.to_owned(),
),
});
}
if autonomous_session {
if let Some(conflict) = protected_write::authorize_append_surface_write(
&layout,
"escalation",
&write_options,
AppendWriteAuthority::OwnerOrSupervisor,
)? {
return Ok(EscalationClearReport {
command: "escalation-state-clear",
ok: false,
outcome: conflict.outcome,
profile: profile.to_string(),
path: db_path,
cleared: false,
cleared_id: id.map(str::to_owned),
view: build_view(&layout, &load_for_layout(&layout)?),
message: Some(conflict.message),
});
}
}
let Some(db) = try_open_escalation_db(&layout)? else {
return Ok(EscalationClearReport {
command: "escalation-state-clear",
ok: true,
outcome: AppendWriteOutcome::IdempotentNoop,
profile: profile.to_string(),
path: db_path,
cleared: false,
cleared_id: id.map(str::to_owned),
view: build_view(&layout, &[]),
message: None,
});
};
let (cleared, removed_id, outcome, message) = if let Some(id) = id {
let found = db::escalation::delete_by_id(db.conn(), id)?;
if !found {
(
false,
Some(id.to_owned()),
AppendWriteOutcome::IdempotentNoop,
None,
)
} else {
(true, Some(id.to_owned()), AppendWriteOutcome::Applied, None)
}
} else {
let had_entries = !db::escalation::list(db.conn())?.is_empty();
db::escalation::clear_all(db.conn())?;
(
had_entries,
None,
if had_entries {
AppendWriteOutcome::Applied
} else {
AppendWriteOutcome::IdempotentNoop
},
None,
)
};
let entries = db::escalation::list(db.conn())?;
let view = build_view(&layout, &entries);
Ok(EscalationClearReport {
command: "escalation-state-clear",
ok: true,
outcome,
profile: profile.to_string(),
path: db_path,
cleared,
cleared_id: removed_id,
view,
message,
})
}
pub fn list(repo_root: &Path, explicit_profile: Option<&str>) -> Result<EscalationListReport> {
let profile = profile::resolve(explicit_profile)?;
let layout = required_layout(repo_root, &profile)?;
ensure_repo_linked(repo_root)?;
let entries = match try_open_escalation_db(&layout)? {
Some(db) => db::escalation::list(db.conn())?,
None => Vec::new(),
};
let view = build_view(&layout, &entries);
Ok(EscalationListReport {
command: "escalation-state-list",
ok: true,
profile: profile.to_string(),
path: layout.state_db_path().display().to_string(),
view,
})
}
fn blocking_entries(entries: &[EscalationEntry]) -> Vec<&EscalationEntry> {
entries
.iter()
.filter(|e| matches!(e.kind, EscalationKind::Blocking))
.collect()
}
pub(crate) fn build_view(layout: &StateLayout, entries: &[EscalationEntry]) -> EscalationView {
let path = layout.state_db_path();
let blocking_count = entries
.iter()
.filter(|e| matches!(e.kind, EscalationKind::Blocking))
.count();
let non_blocking_count = entries.len() - blocking_count;
let status = if entries.is_empty() {
"empty"
} else if blocking_count > 0 {
"blocked"
} else {
"active"
};
EscalationView {
path: path.display().to_string(),
status,
blocking_count,
non_blocking_count,
entries: entries.to_vec(),
}
}
pub(crate) fn clear_all_for_session_boundary(layout: &StateLayout) -> Result<()> {
if let Some(db) = try_open_escalation_db(layout)? {
db::escalation::clear_all(db.conn())?;
}
Ok(())
}
pub(crate) fn clear_non_blocking_for_stale_reset(layout: &StateLayout) -> Result<()> {
if let Some(db) = try_open_escalation_db(layout)? {
db::escalation::clear_non_blocking(db.conn())?;
}
Ok(())
}
fn mint_escalation_id() -> String {
format!("esc_{}", ulid::Ulid::new())
}
fn required_layout(repo_root: &Path, profile: &ProfileName) -> Result<StateLayout> {
let layout = StateLayout::resolve(repo_root, profile.clone())?;
ensure_profile_exists(&layout)?;
Ok(layout)
}
fn ensure_profile_exists(layout: &StateLayout) -> Result<()> {
let profile_root = layout.profile_root();
if profile_root.is_dir() {
return Ok(());
}
bail!(
"profile `{}` does not exist at {}; bootstrap it before using escalation-state",
layout.profile(),
profile_root.display()
)
}
fn ensure_repo_linked(repo_root: &Path) -> Result<()> {
let Some(_) = repo_marker::load(repo_root)? else {
bail!(
"repo is not linked: {} is missing; run `ccd attach --path {}` or `ccd link --path {}` first",
repo_root.join(repo_marker::MARKER_FILE).display(),
repo_root.display(),
repo_root.display()
);
};
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deserialize_escalation_state() {
let json = r#"{
"schema_version": 1,
"entries": [
{
"id": "esc_01ABC",
"kind": "blocking",
"reason": "need human review",
"created_at_epoch_s": 1000,
"session_id": "ses_01XYZ"
}
]
}"#;
let state: EscalationStateFile = serde_json::from_str(json).unwrap();
assert_eq!(state.schema_version, 1);
assert_eq!(state.entries.len(), 1);
assert_eq!(state.entries[0].kind, EscalationKind::Blocking);
assert_eq!(state.entries[0].session_id.as_deref(), Some("ses_01XYZ"));
}
#[test]
fn deserialize_non_blocking_kind() {
let json = r#"{
"schema_version": 1,
"entries": [
{
"id": "esc_02DEF",
"kind": "non_blocking",
"reason": "informational",
"created_at_epoch_s": 2000
}
]
}"#;
let state: EscalationStateFile = serde_json::from_str(json).unwrap();
assert_eq!(state.entries[0].kind, EscalationKind::NonBlocking);
assert!(state.entries[0].session_id.is_none());
}
#[test]
fn serialize_omits_none_session_id() {
let entry = EscalationEntry {
id: "esc_1".to_owned(),
kind: EscalationKind::Blocking,
reason: "test".to_owned(),
created_at_epoch_s: 1000,
session_id: None,
};
let json = serde_json::to_string(&entry).unwrap();
assert!(!json.contains("session_id"));
}
#[test]
fn build_view_empty_entries() {
let layout = test_layout();
let entries: Vec<EscalationEntry> = vec![];
let view = build_view(&layout, &entries);
assert_eq!(view.status, "empty");
assert_eq!(view.blocking_count, 0);
assert_eq!(view.non_blocking_count, 0);
}
#[test]
fn build_view_with_blocking_entries() {
let layout = test_layout();
let entries = vec![
EscalationEntry {
id: "esc_1".to_owned(),
kind: EscalationKind::Blocking,
reason: "blocked".to_owned(),
created_at_epoch_s: 1000,
session_id: None,
},
EscalationEntry {
id: "esc_2".to_owned(),
kind: EscalationKind::NonBlocking,
reason: "info".to_owned(),
created_at_epoch_s: 2000,
session_id: None,
},
];
let view = build_view(&layout, &entries);
assert_eq!(view.status, "blocked");
assert_eq!(view.blocking_count, 1);
assert_eq!(view.non_blocking_count, 1);
}
#[test]
fn build_view_active_non_blocking_only() {
let layout = test_layout();
let entries = vec![EscalationEntry {
id: "esc_1".to_owned(),
kind: EscalationKind::NonBlocking,
reason: "info".to_owned(),
created_at_epoch_s: 1000,
session_id: None,
}];
let view = build_view(&layout, &entries);
assert_eq!(view.status, "active");
assert_eq!(view.blocking_count, 0);
assert_eq!(view.non_blocking_count, 1);
}
#[test]
fn load_returns_empty_without_creating_db() {
let temp = tempfile::tempdir().unwrap();
let layout = test_layout_at(temp.path());
setup_dirs(&layout);
let entries = load_for_layout(&layout).unwrap();
assert!(entries.is_empty());
assert!(
!layout.state_db_path().exists(),
"read path should not create state.db"
);
}
#[test]
fn migrate_escalation_json_imports_and_renames() {
let temp = tempfile::tempdir().unwrap();
let layout = test_layout_at(temp.path());
setup_dirs(&layout);
fs::write(
layout.escalation_state_path(),
r#"{"schema_version":1,"entries":[{"id":"esc_MIG","kind":"blocking","reason":"test","created_at_epoch_s":1000}]}"#,
).unwrap();
let db = db::StateDb::open(&layout.state_db_path()).unwrap();
migrate_escalation_json(&db, &layout).unwrap();
let entries = db::escalation::list(db.conn()).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].id, "esc_MIG");
assert!(!layout.escalation_state_path().exists());
let mut migrated = layout.escalation_state_path().as_os_str().to_owned();
migrated.push(".migrated");
assert!(Path::new(&migrated).exists());
}
#[test]
fn migrate_escalation_json_is_idempotent() {
let temp = tempfile::tempdir().unwrap();
let layout = test_layout_at(temp.path());
setup_dirs(&layout);
fs::write(
layout.escalation_state_path(),
r#"{"schema_version":1,"entries":[{"id":"esc_IDEM","kind":"blocking","reason":"test","created_at_epoch_s":1000}]}"#,
).unwrap();
let db = db::StateDb::open(&layout.state_db_path()).unwrap();
migrate_escalation_json(&db, &layout).unwrap();
fs::write(
layout.escalation_state_path(),
r#"{"schema_version":1,"entries":[{"id":"esc_IDEM","kind":"blocking","reason":"test","created_at_epoch_s":1000}]}"#,
).unwrap();
migrate_escalation_json(&db, &layout).unwrap();
let entries = db::escalation::list(db.conn()).unwrap();
assert_eq!(entries.len(), 1);
}
#[test]
fn migrate_noop_when_no_legacy_file() {
let temp = tempfile::tempdir().unwrap();
let layout = test_layout_at(temp.path());
setup_dirs(&layout);
let db = db::StateDb::open(&layout.state_db_path()).unwrap();
migrate_escalation_json(&db, &layout).unwrap();
assert!(db::escalation::list(db.conn()).unwrap().is_empty());
}
#[test]
fn session_boundary_clears_db() {
let temp = tempfile::tempdir().unwrap();
let layout = test_layout_at(temp.path());
setup_dirs(&layout);
let db = db::StateDb::open(&layout.state_db_path()).unwrap();
db::escalation::insert(
db.conn(),
&EscalationEntry {
id: "esc_SB".to_owned(),
kind: EscalationKind::Blocking,
reason: "test".to_owned(),
created_at_epoch_s: 1000,
session_id: None,
},
)
.unwrap();
drop(db);
clear_all_for_session_boundary(&layout).unwrap();
let db = db::StateDb::open(&layout.state_db_path()).unwrap();
assert!(db::escalation::list(db.conn()).unwrap().is_empty());
}
#[test]
fn stale_reset_keeps_blocking() {
let temp = tempfile::tempdir().unwrap();
let layout = test_layout_at(temp.path());
setup_dirs(&layout);
let db = db::StateDb::open(&layout.state_db_path()).unwrap();
db::escalation::insert(
db.conn(),
&EscalationEntry {
id: "esc_B".to_owned(),
kind: EscalationKind::Blocking,
reason: "critical".to_owned(),
created_at_epoch_s: 1000,
session_id: None,
},
)
.unwrap();
db::escalation::insert(
db.conn(),
&EscalationEntry {
id: "esc_NB".to_owned(),
kind: EscalationKind::NonBlocking,
reason: "info".to_owned(),
created_at_epoch_s: 2000,
session_id: None,
},
)
.unwrap();
drop(db);
clear_non_blocking_for_stale_reset(&layout).unwrap();
let db = db::StateDb::open(&layout.state_db_path()).unwrap();
let entries = db::escalation::list(db.conn()).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].id, "esc_B");
}
use crate::profile::ProfileName;
fn test_layout() -> StateLayout {
let temp = tempfile::tempdir().unwrap();
let layout = test_layout_at(temp.path());
setup_dirs(&layout);
layout
}
fn test_layout_at(temp: &Path) -> StateLayout {
StateLayout::new(
temp.join(".ccd"),
temp.join("repo/.git/ccd"),
ProfileName::new("main").expect("profile"),
)
}
fn setup_dirs(layout: &StateLayout) {
fs::create_dir_all(layout.clone_profile_root()).expect("clone profile root");
fs::create_dir_all(layout.clone_runtime_state_root()).expect("clone runtime root");
}
}