use std::path::Path;
use std::process::ExitCode;
use anyhow::{bail, Result};
use serde::{Deserialize, Serialize};
use crate::db;
use crate::output::CommandReport;
use crate::paths::state::StateLayout;
use crate::profile::{self, ProfileName};
use crate::state::protected_write::{self, ExclusiveWriteOptions};
use crate::state::session;
const EXECUTION_GATES_SCHEMA_VERSION: u32 = 2;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExecutionGateStatus {
Open,
Done,
Blocked,
}
impl ExecutionGateStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Open => "open",
Self::Done => "done",
Self::Blocked => "blocked",
}
}
fn is_done(self) -> bool {
matches!(self, Self::Done)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExecutionGate {
pub text: String,
pub status: ExecutionGateStatus,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExecutionGateStateFile {
pub schema_version: u32,
#[serde(skip_serializing_if = "Option::is_none")]
pub seeded_from: Option<String>,
#[serde(default)]
pub revision: u64,
#[serde(default)]
pub gates: Vec<ExecutionGate>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GateSeedSource {
ImmediateActions,
DefinitionOfDone,
}
impl GateSeedSource {
fn as_seed_label(self) -> &'static str {
match self {
Self::ImmediateActions => "handoff:immediate_actions",
Self::DefinitionOfDone => "handoff:definition_of_done",
}
}
fn display_label(self) -> &'static str {
match self {
Self::ImmediateActions => "handoff immediate actions",
Self::DefinitionOfDone => "handoff definition of done",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ExecutionGateAnchor {
pub index: usize,
pub text: String,
pub status: ExecutionGateStatus,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ExecutionGatesView {
pub path: String,
pub status: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub seeded_from: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub revision: Option<u64>,
pub total_count: usize,
pub unfinished_count: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub attention_anchor: Option<ExecutionGateAnchor>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub gates: Vec<ExecutionGate>,
}
#[derive(Serialize)]
pub struct SessionGatesReport {
command: &'static str,
ok: bool,
profile: String,
path: String,
action: &'static str,
view: ExecutionGatesView,
#[serde(skip_serializing_if = "Option::is_none")]
message: Option<String>,
}
impl CommandReport for SessionGatesReport {
fn exit_code(&self) -> ExitCode {
ExitCode::SUCCESS
}
fn render_text(&self) {
if let Some(message) = &self.message {
println!("{message}");
}
println!("Execution gates: {} ({})", self.view.path, self.view.status);
println!(
"Counts: {} total, {} unfinished",
self.view.total_count, self.view.unfinished_count
);
if let Some(seed) = &self.view.seeded_from {
println!("Seeded from: {seed}");
}
if let Some(anchor) = &self.view.attention_anchor {
println!(
"Attention anchor [{} #{}/{}]: {}",
anchor.status.as_str(),
anchor.index,
self.view.total_count,
anchor.text
);
} else if self.view.total_count == 0 {
println!("No execution gates are active.");
}
}
}
pub fn list(repo_root: &Path, explicit_profile: Option<&str>) -> Result<SessionGatesReport> {
let profile = profile::resolve(explicit_profile)?;
let layout = required_layout(repo_root, &profile)?;
let raw = load_for_layout(&layout)?;
Ok(report(
"session-state-gates-list",
"list",
&profile,
&layout,
raw,
None,
))
}
pub fn replace(
repo_root: &Path,
explicit_profile: Option<&str>,
gates: Vec<String>,
write_options: ExclusiveWriteOptions,
) -> Result<SessionGatesReport> {
let profile = profile::resolve(explicit_profile)?;
let layout = required_layout(repo_root, &profile)?;
let db = require_active_session_db(&layout)?;
let gate_texts = normalize_gate_texts(gates)?;
let state = ExecutionGateStateFile {
schema_version: EXECUTION_GATES_SCHEMA_VERSION,
seeded_from: None,
revision: 0,
gates: gate_texts
.into_iter()
.map(|text| ExecutionGate {
text,
status: ExecutionGateStatus::Open,
})
.collect(),
};
let state = persist_gate_state(&db, &layout, state, &write_options)?;
Ok(report(
"session-state-gates-replace",
"replace",
&profile,
&layout,
Some(state),
Some("stored manual execution gates".to_owned()),
))
}
pub fn seed(
repo_root: &Path,
explicit_profile: Option<&str>,
source: GateSeedSource,
write_options: ExclusiveWriteOptions,
) -> Result<SessionGatesReport> {
let profile = profile::resolve(explicit_profile)?;
let layout = required_layout(repo_root, &profile)?;
let db = require_active_session_db(&layout)?;
let handoff = db::handoff::read(db.conn())?.ok_or_else(|| {
anyhow::anyhow!(
"no workspace-local handoff is available; run `ccd start --path {}` or `ccd handoff write --path {}` first",
repo_root.display(),
repo_root.display()
)
})?;
let gate_texts = match source {
GateSeedSource::ImmediateActions => handoff
.immediate_actions
.into_iter()
.map(|item| item.text)
.collect(),
GateSeedSource::DefinitionOfDone => handoff
.definition_of_done
.into_iter()
.map(|item| item.text)
.collect(),
};
let gate_texts = normalize_gate_texts(gate_texts)?;
let state = ExecutionGateStateFile {
schema_version: EXECUTION_GATES_SCHEMA_VERSION,
seeded_from: Some(source.as_seed_label().to_owned()),
revision: 0,
gates: gate_texts
.into_iter()
.map(|text| ExecutionGate {
text,
status: ExecutionGateStatus::Open,
})
.collect(),
};
let state = persist_gate_state(&db, &layout, state, &write_options)?;
Ok(report(
"session-state-gates-seed",
"seed",
&profile,
&layout,
Some(state),
Some(format!(
"seeded execution gates from {}",
source.display_label()
)),
))
}
pub fn set_status(
repo_root: &Path,
explicit_profile: Option<&str>,
index: usize,
status: ExecutionGateStatus,
write_options: ExclusiveWriteOptions,
) -> Result<SessionGatesReport> {
let profile = profile::resolve(explicit_profile)?;
let layout = required_layout(repo_root, &profile)?;
let db = require_active_session_db(&layout)?;
let mut state = db::session_gates::read(db.conn())?.ok_or_else(|| {
anyhow::anyhow!("no execution gates are active in this clone; seed or replace them first")
})?;
if index == 0 || index > state.gates.len() {
bail!(
"gate index {index} is out of range; current gate count is {}",
state.gates.len()
);
}
state.gates[index - 1].status = status;
let state = persist_gate_state(&db, &layout, state, &write_options)?;
Ok(report(
"session-state-gates-set-status",
"set_status",
&profile,
&layout,
Some(state),
Some(format!("updated gate {index} to {}", status.as_str())),
))
}
pub fn advance(
repo_root: &Path,
explicit_profile: Option<&str>,
write_options: ExclusiveWriteOptions,
) -> Result<SessionGatesReport> {
let profile = profile::resolve(explicit_profile)?;
let layout = required_layout(repo_root, &profile)?;
let db = require_active_session_db(&layout)?;
let mut state = db::session_gates::read(db.conn())?.ok_or_else(|| {
anyhow::anyhow!("no execution gates are active in this clone; seed or replace them first")
})?;
let Some((index, gate)) = state
.gates
.iter_mut()
.enumerate()
.find(|(_, gate)| !gate.status.is_done())
else {
return Ok(report(
"session-state-gates-advance",
"advance",
&profile,
&layout,
Some(state),
Some("all execution gates are already done".to_owned()),
));
};
gate.status = ExecutionGateStatus::Done;
let state = persist_gate_state(&db, &layout, state, &write_options)?;
Ok(report(
"session-state-gates-advance",
"advance",
&profile,
&layout,
Some(state),
Some(format!("marked gate {} done", index + 1)),
))
}
pub fn clear(
repo_root: &Path,
explicit_profile: Option<&str>,
write_options: ExclusiveWriteOptions,
) -> Result<SessionGatesReport> {
let profile = profile::resolve(explicit_profile)?;
let layout = required_layout(repo_root, &profile)?;
let existed = clear_for_layout(&layout, &write_options)?;
Ok(report(
"session-state-gates-clear",
"clear",
&profile,
&layout,
None,
Some(if existed {
"cleared execution gates".to_owned()
} else {
"no execution gates were active".to_owned()
}),
))
}
pub(crate) fn load_for_layout(layout: &StateLayout) -> Result<Option<ExecutionGateStateFile>> {
let Some(db) = try_open_state_db(layout)? else {
return Ok(None);
};
db::session_gates::read(db.conn())
}
pub(crate) fn clear_for_session_boundary(layout: &StateLayout) -> Result<bool> {
let Some(db) = try_open_state_db(layout)? else {
return Ok(false);
};
clear_on_shared_db(&db)
}
pub(crate) fn clear_on_shared_db(shared_db: &db::StateDb) -> Result<bool> {
let existed = db::session_gates::read(shared_db.conn())?.is_some();
if existed {
db::session_gates::delete(shared_db.conn())?;
}
Ok(existed)
}
fn persist_gate_state(
db: &db::StateDb,
layout: &StateLayout,
mut state: ExecutionGateStateFile,
write_options: &ExclusiveWriteOptions,
) -> Result<ExecutionGateStateFile> {
let guard =
protected_write::authorize_owner_surface_write(layout, "execution_gates", write_options)?;
state.revision = match guard.expected_revision {
Some(expected_revision) => {
match db::session_gates::write_if_revision_matches(
db.conn(),
&state,
expected_revision,
)? {
db::session_gates::ExclusiveWriteResult::Applied { revision } => revision,
db::session_gates::ExclusiveWriteResult::RevisionConflict { current_revision } => {
bail!(
"`execution_gates` revision conflict: expected {expected_revision}, current is {current_revision}"
);
}
}
}
None => db::session_gates::write(db.conn(), &state)?,
};
Ok(state)
}
fn clear_for_layout(layout: &StateLayout, write_options: &ExclusiveWriteOptions) -> Result<bool> {
let Some(db) = try_open_state_db(layout)? else {
return Ok(false);
};
let guard =
protected_write::authorize_owner_surface_write(layout, "execution_gates", write_options)?;
match guard.expected_revision {
Some(expected_revision) => {
match db::session_gates::delete_if_revision_matches(db.conn(), expected_revision)? {
db::session_gates::ExclusiveDeleteResult::Applied => Ok(true),
db::session_gates::ExclusiveDeleteResult::IdempotentNoop => Ok(false),
db::session_gates::ExclusiveDeleteResult::RevisionConflict { current_revision } => {
bail!(
"`execution_gates` revision conflict: expected {expected_revision}, current is {current_revision}"
);
}
}
}
None => clear_on_shared_db(&db),
}
}
fn report(
command: &'static str,
action: &'static str,
profile: &ProfileName,
layout: &StateLayout,
raw: Option<ExecutionGateStateFile>,
message: Option<String>,
) -> SessionGatesReport {
SessionGatesReport {
command,
ok: true,
profile: profile.to_string(),
path: layout.state_db_path().display().to_string(),
action,
view: build_view(layout, raw),
message,
}
}
pub(crate) fn build_view(
layout: &StateLayout,
raw: Option<ExecutionGateStateFile>,
) -> ExecutionGatesView {
let path = layout.state_db_path().display().to_string();
let Some(raw) = raw else {
return ExecutionGatesView {
path,
status: "missing",
seeded_from: None,
revision: None,
total_count: 0,
unfinished_count: 0,
attention_anchor: None,
gates: Vec::new(),
};
};
let unfinished_count = raw
.gates
.iter()
.filter(|gate| !gate.status.is_done())
.count();
let attention_anchor = raw.gates.iter().enumerate().find_map(|(index, gate)| {
(!gate.status.is_done()).then(|| ExecutionGateAnchor {
index: index + 1,
text: gate.text.clone(),
status: gate.status,
})
});
ExecutionGatesView {
path,
status: if raw.gates.is_empty() {
"empty"
} else {
"loaded"
},
seeded_from: raw.seeded_from,
revision: Some(raw.revision),
total_count: raw.gates.len(),
unfinished_count,
attention_anchor,
gates: raw.gates,
}
}
fn normalize_gate_texts(gates: Vec<String>) -> Result<Vec<String>> {
let gates: Vec<String> = gates
.into_iter()
.map(|gate| gate.trim().to_owned())
.filter(|gate| !gate.is_empty())
.collect();
if gates.is_empty() {
bail!("at least one non-empty `--gate` entry is required");
}
Ok(gates)
}
fn require_active_session_db(layout: &StateLayout) -> Result<db::StateDb> {
let Some(db) = try_open_state_db(layout)? else {
bail!("{}", active_session_error());
};
let Some(state) = db::session::read(db.conn())? else {
bail!("{}", active_session_error());
};
let now = session::now_epoch_s()?;
if state.session_id.is_none() {
bail!("{}", active_session_error());
}
if session::is_stale(&state, now) && state.lifecycle() == session::SessionLifecycle::Interactive
{
bail!("session telemetry is stale; run `ccd session-state start --path .` before mutating execution gates");
}
Ok(db)
}
fn active_session_error() -> &'static str {
"no active session telemetry is available; run `ccd session-state start --path .` first"
}
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 session-state gates",
layout.profile(),
profile_root.display()
)
}
fn try_open_state_db(layout: &StateLayout) -> Result<Option<db::StateDb>> {
db::StateDb::try_open_for_layout(layout)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::profile::ProfileName;
fn test_layout(temp: &std::path::Path) -> StateLayout {
StateLayout::new(
temp.join(".ccd"),
temp.join("repo/.git/ccd"),
ProfileName::new("main").expect("profile"),
)
}
#[test]
fn build_view_uses_first_unfinished_gate_as_anchor() {
let dir = tempfile::tempdir().unwrap();
let layout = test_layout(dir.path());
let view = build_view(
&layout,
Some(ExecutionGateStateFile {
schema_version: 2,
seeded_from: Some("handoff:immediate_actions".to_owned()),
revision: 3,
gates: vec![
ExecutionGate {
text: "Done".to_owned(),
status: ExecutionGateStatus::Done,
},
ExecutionGate {
text: "Blocked".to_owned(),
status: ExecutionGateStatus::Blocked,
},
ExecutionGate {
text: "Open".to_owned(),
status: ExecutionGateStatus::Open,
},
],
}),
);
assert_eq!(view.status, "loaded");
assert_eq!(view.total_count, 3);
assert_eq!(view.unfinished_count, 2);
let anchor = view.attention_anchor.expect("anchor");
assert_eq!(anchor.index, 2);
assert_eq!(anchor.status, ExecutionGateStatus::Blocked);
assert_eq!(anchor.text, "Blocked");
}
#[test]
fn normalize_gate_texts_rejects_empty_input() {
assert!(normalize_gate_texts(vec![" ".to_owned()]).is_err());
}
}