scv-server 0.3.10

Authoritative session and tool server for SCV
Documentation
use super::*;
use scv_protocol::{
    DaemonCommand, ProjectResponse, ProjectRunStatus, ProjectStatus, ProjectTaskStatus,
};
use tempfile::TempDir;

fn ledger() -> (TempDir, TempDir, ProjectLedger) {
    let home = tempfile::tempdir().unwrap();
    let workspace = tempfile::tempdir().unwrap();
    let ledger = ProjectLedger::open(&Layout::new(home.path())).unwrap();
    (home, workspace, ledger)
}

fn project_id(response: ProjectResponse) -> String {
    match response {
        ProjectResponse::Created { project } => project.id,
        _ => panic!("expected created response"),
    }
}

fn task_id(response: ProjectResponse) -> String {
    match response {
        ProjectResponse::TaskAdded { task_id, .. } => task_id,
        _ => panic!("expected task response"),
    }
}

fn run_id(response: ProjectResponse) -> String {
    match response {
        ProjectResponse::RunStarted { run_id, .. } => run_id,
        _ => panic!("expected run response"),
    }
}

#[test]
fn project_events_replay_and_dependencies_advance() {
    let (home, workspace, mut ledger) = ledger();
    let project = project_id(
        ledger
            .execute(DaemonCommand::ProjectCreate {
                name: "demo".into(),
                workspace: workspace.path().display().to_string(),
            })
            .unwrap(),
    );
    let first = task_id(
        ledger
            .execute(DaemonCommand::ProjectTaskAdd {
                project: project.clone(),
                title: "first".into(),
                depends_on: vec![],
                max_retries: 0,
            })
            .unwrap(),
    );
    let second = task_id(
        ledger
            .execute(DaemonCommand::ProjectTaskAdd {
                project: project.clone(),
                title: "second".into(),
                depends_on: vec![first.clone()],
                max_retries: 0,
            })
            .unwrap(),
    );
    let run = run_id(
        ledger
            .execute(DaemonCommand::ProjectRunStart {
                project: project.clone(),
                task: first.clone(),
                agent: "fake".into(),
            })
            .unwrap(),
    );
    ledger
        .execute(DaemonCommand::ProjectRunFinish {
            project: project.clone(),
            run,
            status: ProjectRunStatus::Succeeded,
        })
        .unwrap();
    let ProjectResponse::Tasks { tasks, .. } = ledger
        .execute(DaemonCommand::ProjectTasks {
            project: project.clone(),
        })
        .unwrap()
    else {
        panic!("expected tasks")
    };
    assert_eq!(
        tasks.iter().find(|task| task.id == second).unwrap().status,
        ProjectTaskStatus::Ready
    );

    let mut reopened = ProjectLedger::open(&Layout::new(home.path())).unwrap();
    let ProjectResponse::Events { events, .. } = reopened
        .execute(DaemonCommand::ProjectEvents {
            project,
            after: None,
        })
        .unwrap()
    else {
        panic!("expected events")
    };
    assert!(events.len() >= 5);
    assert!(
        events
            .windows(2)
            .all(|window| window[0].sequence < window[1].sequence)
    );
}

#[test]
fn failed_run_retries_until_the_budget_is_exhausted() {
    let (_home, workspace, mut ledger) = ledger();
    let project = project_id(
        ledger
            .execute(DaemonCommand::ProjectCreate {
                name: "retry".into(),
                workspace: workspace.path().display().to_string(),
            })
            .unwrap(),
    );
    let task = task_id(
        ledger
            .execute(DaemonCommand::ProjectTaskAdd {
                project: project.clone(),
                title: "retry me".into(),
                depends_on: vec![],
                max_retries: 1,
            })
            .unwrap(),
    );
    let run = run_id(
        ledger
            .execute(DaemonCommand::ProjectRunStart {
                project: project.clone(),
                task: task.clone(),
                agent: "fake".into(),
            })
            .unwrap(),
    );
    ledger
        .execute(DaemonCommand::ProjectRunFinish {
            project: project.clone(),
            run,
            status: ProjectRunStatus::Failed,
        })
        .unwrap();
    let ProjectResponse::Status { project: summary } = ledger
        .execute(DaemonCommand::ProjectStatus {
            project: project.clone(),
        })
        .unwrap()
    else {
        panic!("expected status")
    };
    assert_eq!(summary.status, ProjectStatus::Active);

    let run = run_id(
        ledger
            .execute(DaemonCommand::ProjectRunStart {
                project: project.clone(),
                task,
                agent: "fake".into(),
            })
            .unwrap(),
    );
    ledger
        .execute(DaemonCommand::ProjectRunFinish {
            project: project.clone(),
            run,
            status: ProjectRunStatus::Failed,
        })
        .unwrap();
    let ProjectResponse::Status { project: summary } = ledger
        .execute(DaemonCommand::ProjectStatus { project })
        .unwrap()
    else {
        panic!("expected status")
    };
    assert_eq!(summary.status, ProjectStatus::Failed);
}

#[test]
fn compaction_keeps_state_and_reports_the_omitted_event_boundary() {
    let (home, workspace, mut ledger) = ledger();
    let project = project_id(
        ledger
            .execute(DaemonCommand::ProjectCreate {
                name: "compact".into(),
                workspace: workspace.path().display().to_string(),
            })
            .unwrap(),
    );
    for index in 0..511 {
        ledger
            .execute(DaemonCommand::ProjectTaskAdd {
                project: project.clone(),
                title: format!("task-{index}"),
                depends_on: vec![],
                max_retries: 0,
            })
            .unwrap();
    }
    assert!(home.path().join("state/projects/snapshot.json").is_file());
    let ProjectResponse::Events {
        events,
        compacted_before,
        ..
    } = ledger
        .execute(DaemonCommand::ProjectEvents {
            project: project.clone(),
            after: None,
        })
        .unwrap()
    else {
        panic!("expected events")
    };
    assert!(events.is_empty());
    assert_eq!(compacted_before, Some(512));

    let mut reopened = ProjectLedger::open(&Layout::new(home.path())).unwrap();
    let ProjectResponse::Status { project: summary } = reopened
        .execute(DaemonCommand::ProjectStatus { project })
        .unwrap()
    else {
        panic!("expected status")
    };
    assert_eq!(summary.task_count, 511);
}

#[test]
fn newline_terminated_corruption_fails_closed_but_a_partial_tail_is_recovered() {
    let home = tempfile::tempdir().unwrap();
    let path = Layout::new(home.path()).projects().join("events.jsonl");
    let _ = ProjectLedger::open(&Layout::new(home.path())).unwrap();
    std::fs::create_dir_all(path.parent().unwrap()).unwrap();
    std::fs::write(&path, b"not-json\n").unwrap();
    assert!(ProjectLedger::open(&Layout::new(home.path())).is_err());
    std::fs::write(&path, b"not-json").unwrap();
    assert!(ProjectLedger::open(&Layout::new(home.path())).is_ok());
}

#[cfg(unix)]
#[test]
fn ledger_files_are_private() {
    use std::os::unix::fs::PermissionsExt;

    let (home, workspace, mut ledger) = ledger();
    ledger
        .execute(DaemonCommand::ProjectCreate {
            name: "private".into(),
            workspace: workspace.path().display().to_string(),
        })
        .unwrap();
    let directory = home.path().join("state/projects");
    let file_mode = std::fs::metadata(directory.join("events.jsonl"))
        .unwrap()
        .permissions()
        .mode()
        & 0o777;
    let directory_mode = std::fs::metadata(directory).unwrap().permissions().mode() & 0o777;
    assert_eq!(file_mode, 0o600);
    assert_eq!(directory_mode, 0o700);
}