carryctx 0.0.3

Persistent project context for coding agents
use crate::domain::session::{SessionState, evaluate_session_transition};
use crate::error::CarryCtxError;
use crate::repository::{EventRepository, NewEvent, NewSession, SessionRecord, SessionRepository};

pub struct StartSessionInput {
    pub project_id: String,
    pub agent_id: String,
    pub task_id: Option<String>,
    pub worktree_id: Option<String>,
    pub branch: Option<String>,
    pub head: Option<String>,
    pub cwd: Option<String>,
    pub provider: Option<String>,
}

pub fn start_session(
    session_repo: &dyn SessionRepository,
    event_repo: &dyn EventRepository,
    input: &StartSessionInput,
    now: &str,
) -> Result<SessionRecord, CarryCtxError> {
    let active_sessions = session_repo.find_active(
        &input.project_id,
        &input.agent_id,
        input.worktree_id.as_deref(),
    )?;

    for s in &active_sessions {
        evaluate_session_transition(s.state, SessionState::Ended)?;
        session_repo.update_state(
            &s.id,
            &s.project_id,
            SessionState::Ended,
            now,
            Some("Auto-ended by new session"),
        )?;
        event_repo.append(&NewEvent {
            id: ulid::Ulid::new().to_string(),
            project_id: input.project_id.clone(),
            event_type: "session.ended".into(),
            actor_agent_id: Some(input.agent_id.clone()),
            session_id: Some(s.id.clone()),
            task_id: s.task_id.clone(),
            payload: serde_json::json!({
                "reason": "superseded",
                "superseded_by": ulid::Ulid::new().to_string()
            }),
            occurred_at: now.to_string(),
        })?;
    }

    let session_id = ulid::Ulid::new().to_string();
    let session = session_repo.create(
        &NewSession {
            id: session_id,
            project_id: input.project_id.clone(),
            agent_id: input.agent_id.clone(),
            task_id: input.task_id.clone(),
            worktree_id: input.worktree_id.clone(),
            branch: input.branch.clone(),
            head: input.head.clone(),
            cwd: input.cwd.clone(),
            provider: input.provider.clone(),
        },
        now,
    )?;

    event_repo.append(&NewEvent {
        id: ulid::Ulid::new().to_string(),
        project_id: input.project_id.clone(),
        event_type: "session.started".into(),
        actor_agent_id: Some(input.agent_id.clone()),
        session_id: Some(session.id.clone()),
        task_id: session.task_id.clone(),
        payload: serde_json::json!({
            "session_id": session.id,
            "agent_id": session.agent_id,
            "task_id": session.task_id,
            "worktree_id": session.worktree_id,
            "provider": session.provider,
        }),
        occurred_at: now.to_string(),
    })?;

    Ok(session)
}

pub struct ResumeSessionInput {
    pub project_id: String,
    pub session_id: String,
    pub agent_id: String,
}

pub fn resume_session(
    session_repo: &dyn SessionRepository,
    event_repo: &dyn EventRepository,
    input: &ResumeSessionInput,
    now: &str,
) -> Result<SessionRecord, CarryCtxError> {
    let session = session_repo
        .find_by_id(&input.project_id, &input.session_id)?
        .ok_or_else(|| {
            CarryCtxError::resource_not_found(format!("Session '{}' not found", input.session_id))
        })?;

    if session.state != SessionState::Active && session.state != SessionState::Paused {
        return Err(CarryCtxError::invalid_arguments(format!(
            "Cannot resume session in state '{:?}'",
            session.state
        )));
    }

    evaluate_session_transition(session.state, SessionState::Active)?;

    session_repo.touch_activity(&session.id, &session.project_id, now)?;

    event_repo.append(&NewEvent {
        id: ulid::Ulid::new().to_string(),
        project_id: input.project_id.clone(),
        event_type: "session.reused".into(),
        actor_agent_id: Some(input.agent_id.clone()),
        session_id: Some(session.id.clone()),
        task_id: session.task_id.clone(),
        payload: serde_json::json!({
            "session_id": session.id,
            "previous_state": session.state,
        }),
        occurred_at: now.to_string(),
    })?;

    let updated = session_repo
        .find_by_id(&input.project_id, &input.session_id)?
        .ok_or_else(|| {
            CarryCtxError::resource_not_found(format!(
                "Session '{}' disappeared after touch",
                input.session_id
            ))
        })?;

    Ok(updated)
}

pub struct EndSessionInput {
    pub project_id: String,
    pub session_id: String,
    pub agent_id: String,
    pub summary: Option<String>,
}

pub fn end_session(
    session_repo: &dyn SessionRepository,
    event_repo: &dyn EventRepository,
    input: &EndSessionInput,
    now: &str,
) -> Result<SessionRecord, CarryCtxError> {
    let session = session_repo
        .find_by_id(&input.project_id, &input.session_id)?
        .ok_or_else(|| {
            CarryCtxError::resource_not_found(format!("Session '{}' not found", input.session_id))
        })?;

    evaluate_session_transition(session.state, SessionState::Ended)?;

    let updated = session_repo.update_state(
        &session.id,
        &session.project_id,
        SessionState::Ended,
        now,
        input.summary.as_deref(),
    )?;

    event_repo.append(&NewEvent {
        id: ulid::Ulid::new().to_string(),
        project_id: input.project_id.clone(),
        event_type: "session.ended".into(),
        actor_agent_id: Some(input.agent_id.clone()),
        session_id: Some(session.id.clone()),
        task_id: session.task_id.clone(),
        payload: serde_json::json!({
            "session_id": session.id,
            "previous_state": session.state,
            "summary": input.summary,
        }),
        occurred_at: now.to_string(),
    })?;

    Ok(updated)
}

pub struct PauseSessionInput {
    pub project_id: String,
    pub session_id: String,
    pub agent_id: String,
}

pub fn pause_session(
    session_repo: &dyn SessionRepository,
    event_repo: &dyn EventRepository,
    input: &PauseSessionInput,
    now: &str,
) -> Result<SessionRecord, CarryCtxError> {
    let session = session_repo
        .find_by_id(&input.project_id, &input.session_id)?
        .ok_or_else(|| {
            CarryCtxError::resource_not_found(format!("Session '{}' not found", input.session_id))
        })?;

    evaluate_session_transition(session.state, SessionState::Paused)?;

    let updated = session_repo.update_state(
        &session.id,
        &session.project_id,
        SessionState::Paused,
        now,
        None,
    )?;

    event_repo.append(&NewEvent {
        id: ulid::Ulid::new().to_string(),
        project_id: input.project_id.clone(),
        event_type: "session.paused".into(),
        actor_agent_id: Some(input.agent_id.clone()),
        session_id: Some(session.id.clone()),
        task_id: session.task_id.clone(),
        payload: serde_json::json!({
            "session_id": session.id,
            "previous_state": session.state,
        }),
        occurred_at: now.to_string(),
    })?;

    Ok(updated)
}

pub fn list_sessions(
    session_repo: &dyn SessionRepository,
    project_id: &str,
) -> Result<Vec<SessionRecord>, CarryCtxError> {
    session_repo.list(project_id)
}

pub fn show_session(
    session_repo: &dyn SessionRepository,
    project_id: &str,
    session_id: &str,
) -> Result<SessionRecord, CarryCtxError> {
    session_repo
        .find_by_id(project_id, session_id)?
        .ok_or_else(|| {
            CarryCtxError::resource_not_found(format!("Session '{}' not found", session_id))
        })
}

pub fn mark_stale_sessions(
    session_repo: &dyn SessionRepository,
    project_id: &str,
    stale_before: &str,
    now: &str,
) -> Result<u64, CarryCtxError> {
    let count = session_repo.mark_overdue_stale(project_id, stale_before, now)?;
    Ok(count)
}