use crate::tui::plan::{PlanDocument, PlanStatus};
use std::path::{Path, PathBuf};
use uuid::Uuid;
pub async fn session_dir(session_id: Uuid) -> PathBuf {
if let Some(pool) = crate::db::global_pool()
&& let Some(dir) = project_session_dir(session_id, pool).await
{
return dir;
}
crate::config::opencrabs_home().join("session")
}
async fn project_session_dir(session_id: Uuid, pool: &crate::db::Pool) -> Option<PathBuf> {
use crate::db::repository::{ProjectRepository, SessionRepository};
let session = SessionRepository::new(pool.clone())
.find_by_id(session_id)
.await
.ok()??;
let project_id = session.project_id?;
let project = ProjectRepository::new(pool.clone())
.find_by_id(project_id)
.await
.ok()??;
Some(
crate::services::ProjectService::projects_dir()
.join(crate::services::file::slugify_project_name(&project.name))
.join("session"),
)
}
fn legacy_session_dir() -> PathBuf {
crate::config::opencrabs_home()
.join("agents")
.join("session")
}
pub async fn archive_dir(session_id: Uuid) -> PathBuf {
session_dir(session_id).await.join("archive")
}
pub async fn plan_json_path(session_id: Uuid) -> PathBuf {
session_dir(session_id)
.await
.join(format!(".opencrabs_plan_{session_id}.json"))
}
pub async fn plan_md_path(session_id: Uuid) -> PathBuf {
session_dir(session_id)
.await
.join(format!(".opencrabs_plan_{session_id}.md"))
}
pub async fn pre_init_marker_path(session_id: Uuid) -> PathBuf {
session_dir(session_id)
.await
.join(format!(".opencrabs_plan_{session_id}.preinit"))
}
async fn pre_init_marker_read_path(session_id: Uuid) -> PathBuf {
let resolved = pre_init_marker_path(session_id).await;
if resolved.exists() {
return resolved;
}
let legacy = legacy_session_dir().join(format!(".opencrabs_plan_{session_id}.preinit"));
if legacy.exists() { legacy } else { resolved }
}
fn pre_init_marker_for(json_path: &Path) -> PathBuf {
json_path.with_extension("preinit")
}
async fn plan_autonomy_marker_path(session_id: Uuid) -> PathBuf {
session_dir(session_id)
.await
.join(format!(".opencrabs_autonomy_{session_id}"))
}
pub async fn is_plan_autonomy(session_id: Uuid) -> bool {
if plan_autonomy_marker_path(session_id).await.exists() {
return true;
}
legacy_session_dir()
.join(format!(".opencrabs_autonomy_{session_id}"))
.exists()
}
pub async fn set_plan_autonomy(session_id: Uuid, enabled: bool) -> std::io::Result<()> {
let marker = plan_autonomy_marker_path(session_id).await;
if enabled {
if let Some(dir) = marker.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(&marker, b"")
} else {
for p in [
marker,
legacy_session_dir().join(format!(".opencrabs_autonomy_{session_id}")),
] {
if p.exists()
&& let Err(e) = std::fs::remove_file(&p)
{
tracing::warn!("Failed to clear plan-autonomy marker {}: {e}", p.display());
}
}
Ok(())
}
}
pub async fn plan_json_read_path(session_id: Uuid) -> PathBuf {
let resolved = plan_json_path(session_id).await;
if resolved.exists() {
return resolved;
}
let legacy = legacy_session_dir().join(format!(".opencrabs_plan_{session_id}.json"));
if legacy.exists() { legacy } else { resolved }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PlanModeState {
NoPlan,
PreInitEditing,
PostInitEditing,
Active,
}
impl PlanModeState {
pub fn is_editing(&self) -> bool {
matches!(
self,
PlanModeState::PreInitEditing | PlanModeState::PostInitEditing
)
}
}
pub async fn plan_mode_state(session_id: Uuid) -> PlanModeState {
let json = plan_json_read_path(session_id).await;
let plan = load_plan_from_path(&json);
let md_exists = md_path_for(&json).exists();
let state = plan_mode_state_of(plan.as_ref(), md_exists);
if state == PlanModeState::NoPlan && pre_init_marker_read_path(session_id).await.exists() {
return PlanModeState::PreInitEditing;
}
state
}
pub fn plan_mode_state_of(plan: Option<&PlanDocument>, md_exists: bool) -> PlanModeState {
let Some(plan) = plan else {
return PlanModeState::NoPlan;
};
match plan.status {
PlanStatus::Active => PlanModeState::Active,
PlanStatus::Editing if plan.pre_init_editing && !md_exists => PlanModeState::PreInitEditing,
PlanStatus::Editing if md_exists => PlanModeState::PostInitEditing,
PlanStatus::Editing => PlanModeState::NoPlan,
}
}
pub async fn load_plan(session_id: Uuid) -> Option<PlanDocument> {
load_plan_from_path(&plan_json_read_path(session_id).await)
}
pub const MAX_PLAN_FILE_SIZE: u64 = 10 * 1024 * 1024;
pub fn load_plan_from_path(path: &Path) -> Option<PlanDocument> {
if let Ok(meta) = std::fs::metadata(path)
&& meta.len() > MAX_PLAN_FILE_SIZE
{
tracing::warn!(
"Plan file too large ({} bytes) at {}; refusing to load",
meta.len(),
path.display()
);
return None;
}
let content = std::fs::read_to_string(path).ok()?;
let raw: serde_json::Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(e) => {
tracing::warn!("Unreadable plan JSON at {}: {e}", path.display());
return None;
}
};
let raw_status = raw.get("status").and_then(|s| s.as_str()).unwrap_or("");
match raw_status {
"Completed" => {
if let Err(e) = archive_plan_files(path) {
tracing::warn!("Failed to archive completed plan: {e}");
}
return None;
}
"Cancelled" => {
remove_plan_files(path);
return None;
}
_ => {}
}
let mut plan: PlanDocument = match serde_json::from_value(raw) {
Ok(p) => p,
Err(e) => {
tracing::warn!("Failed to parse plan JSON at {}: {e}", path.display());
return None;
}
};
if plan.status == PlanStatus::Editing
&& !plan.pre_init_editing
&& !plan.tasks.is_empty()
&& !md_path_for(path).exists()
{
plan.status = PlanStatus::Active;
}
Some(plan)
}
pub async fn save_plan(plan: &PlanDocument) -> std::io::Result<()> {
let dir = session_dir(plan.session_id).await;
std::fs::create_dir_all(&dir)?;
let path = plan_json_path(plan.session_id).await;
let json = serde_json::to_string_pretty(plan)
.map_err(|e| std::io::Error::other(format!("serialize plan: {e}")))?;
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, &json)?;
std::fs::rename(&tmp, &path)?;
let marker = dir.join(format!(".opencrabs_plan_{}.preinit", plan.session_id));
if marker.exists()
&& let Err(e) = std::fs::remove_file(&marker)
{
tracing::warn!("Failed to clear pre-init marker {}: {e}", marker.display());
}
Ok(())
}
pub async fn set_pre_init_editing(session_id: Uuid) -> std::io::Result<()> {
match plan_mode_state(session_id).await {
PlanModeState::PostInitEditing | PlanModeState::Active => {
return Err(std::io::Error::other(
"a plan is already live for this session",
));
}
PlanModeState::NoPlan | PlanModeState::PreInitEditing => {}
}
let dir = session_dir(session_id).await;
std::fs::create_dir_all(&dir)?;
std::fs::write(
dir.join(format!(".opencrabs_plan_{session_id}.preinit")),
b"",
)
}
pub async fn is_pre_init_editing(session_id: Uuid) -> bool {
plan_mode_state(session_id).await == PlanModeState::PreInitEditing
}
pub async fn archive_plan(session_id: Uuid) -> std::io::Result<()> {
archive_plan_files(&plan_json_read_path(session_id).await)
}
fn archive_plan_files(json_path: &Path) -> std::io::Result<()> {
let dir = json_path
.parent()
.map(|p| p.join("archive"))
.unwrap_or_else(|| PathBuf::from("archive"));
std::fs::create_dir_all(&dir)?;
let ts = chrono::Utc::now().format("%Y%m%d-%H%M%S");
let stem = json_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("plan")
.trim_start_matches('.')
.to_string();
if json_path.exists() {
std::fs::rename(json_path, dir.join(format!("{stem}-{ts}.json")))?;
}
let md = md_path_for(json_path);
if md.exists() {
std::fs::rename(&md, dir.join(format!("{stem}-{ts}.md")))?;
}
let marker = pre_init_marker_for(json_path);
if marker.exists()
&& let Err(e) = std::fs::remove_file(&marker)
{
tracing::warn!("Failed to remove pre-init marker {}: {e}", marker.display());
}
Ok(())
}
pub async fn discard_plan(session_id: Uuid) {
remove_plan_files(&plan_json_read_path(session_id).await);
}
fn remove_plan_files(json_path: &Path) {
if json_path.exists()
&& let Err(e) = std::fs::remove_file(json_path)
{
tracing::warn!("Failed to remove plan JSON {}: {e}", json_path.display());
}
let md = md_path_for(json_path);
if md.exists()
&& let Err(e) = std::fs::remove_file(&md)
{
tracing::warn!("Failed to remove plan markdown {}: {e}", md.display());
}
let marker = pre_init_marker_for(json_path);
if marker.exists()
&& let Err(e) = std::fs::remove_file(&marker)
{
tracing::warn!("Failed to remove pre-init marker {}: {e}", marker.display());
}
}
fn md_path_for(json_path: &Path) -> PathBuf {
json_path.with_extension("md")
}
pub async fn create_design_md(session_id: Uuid, title: &str) -> std::io::Result<PathBuf> {
let dir = session_dir(session_id).await;
std::fs::create_dir_all(&dir)?;
let path = plan_md_path(session_id).await;
let scaffold = format!(
"# {title}\n\n\
## Context\n\
- **Problem:** \n\
- **Target state:** \n\
- **Intent:** \n\n\
## Implementation steps\n\
1. \n"
);
std::fs::write(&path, scaffold)?;
Ok(path)
}
pub async fn sync_md_to_json(session_id: Uuid) -> Vec<String> {
let json = plan_json_read_path(session_id).await;
let md = md_path_for(&json);
let Ok(body) = std::fs::read_to_string(&md) else {
return Vec::new();
};
let Some(mut plan) = load_plan_from_path(&json) else {
return Vec::new();
};
if plan.status != PlanStatus::Editing {
return Vec::new();
}
plan.description = body.clone();
plan.updated_at = chrono::Utc::now();
if let Err(e) = save_plan(&plan).await {
tracing::warn!("Failed to mirror plan .md into JSON description: {e}");
}
template_section_warnings(&body)
}
pub fn template_section_warnings(md: &str) -> Vec<String> {
let mut warnings = Vec::new();
if !md.contains("## Context") {
warnings.push("missing required `## Context` section".to_string());
}
for label in ["**Problem:**", "**Target state:**", "**Intent:**"] {
let filled = md.lines().any(|l| {
l.split_once(label)
.is_some_and(|(_, rest)| !rest.trim().is_empty())
});
if !filled {
warnings.push(format!("`{label}` needs non-empty text after the label"));
}
}
if !md.contains("## Implementation steps") {
warnings.push("missing required `## Implementation steps` section".to_string());
} else {
let has_step = md.lines().any(|l| {
let t = l.trim_start();
let rest = t.trim_start_matches(|c: char| c.is_ascii_digit());
t.starts_with(|c: char| c.is_ascii_digit())
&& rest.starts_with('.')
&& !rest.trim_start_matches('.').trim().is_empty()
});
if !has_step {
warnings.push("`## Implementation steps` needs at least one numbered step".to_string());
}
}
warnings
}