wsb-daemon 0.248.6265

wsb's daemon: the workbench, the GitHub mirror's schedule and peer continuity, for every workspace on the machine
use wsb::prelude::*;
use axum::{extract::Query, http::StatusCode, Json};
use std::{cmp::Reverse, fs, path::PathBuf};

/// `~/.claude/plans` — where the plans are written, on whichever machine this
/// is running. It was a literal `/Users/…` path, which resolves on exactly one
/// operating system.
fn plans_dir() -> Result<PathBuf, (StatusCode, Json<serde_json::Value>)> {
    crate::web::home_dir()
        .map(|home| home.join(".claude").join("plans"))
        .map_err(|e| {
            (
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(serde_json::json!({ "error": format!("cannot locate the plans directory: {e}") })),
            )
        })
}

#[derive(Debug, Serialize)]
pub struct PlanEntry {
    pub name: String,
    /// Unix timestamp (seconds) of file creation / birth time.
    /// Falls back to last-modified if birth time is unavailable (Linux).
    pub created_at: u64,
}

#[derive(Debug, Serialize)]
pub struct PlansListResponse {
    pub plans: Vec<PlanEntry>,
}

#[derive(Debug, Deserialize)]
pub struct PlanContentQuery {
    pub name: String,
}

#[derive(Debug, Serialize)]
pub struct PlanContentResponse {
    pub name: String,
    pub content: String,
}

pub async fn list_plans() -> Result<Json<PlansListResponse>, (StatusCode, Json<serde_json::Value>)>
{
    let dir = plans_dir()?;
    let mut plans = tokio::task::spawn_blocking(move || {
        let read = fs::read_dir(&dir).map_err(|e| {
            (
                StatusCode::INTERNAL_SERVER_ERROR,
                Json(
                    serde_json::json!({ "error": format!("failed to read plans directory: {e}") }),
                ),
            )
        })?;

        let plans: Vec<PlanEntry> = read
            .filter_map(|entry| {
                let entry = entry.ok()?;
                let path = entry.path();
                if path.extension().and_then(|e| e.to_str()) != Some("md") {
                    return None;
                }
                let name = path.file_name()?.to_str()?.to_owned();
                let meta = fs::metadata(&path).ok()?;
                let created_at = meta
                    .created()
                    .or_else(|_| meta.modified())
                    .ok()
                    .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
                    .map(|d| d.as_secs())
                    .unwrap_or(0);
                Some(PlanEntry { name, created_at })
            })
            .collect();

        Ok(plans)
    })
    .await
    .map_err(|e| {
        (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(serde_json::json!({ "error": format!("list_plans task panicked: {e}") })),
        )
    })??;

    plans.sort_by_key(|p| Reverse(p.created_at));

    Ok(Json(PlansListResponse { plans }))
}

pub async fn get_plan_content(
    Query(query): Query<PlanContentQuery>,
) -> Result<Json<PlanContentResponse>, (StatusCode, Json<serde_json::Value>)> {
    // Reject any name that contains path separators or traversal sequences.
    if query.name.contains('/') || query.name.contains("..") {
        return Err((
            StatusCode::BAD_REQUEST,
            Json(serde_json::json!({ "error": "invalid plan name" })),
        ));
    }

    let dir = plans_dir()?;
    let name = query.name;
    let content = tokio::task::spawn_blocking(move || {
        let path = dir.join(&name);
        fs::read_to_string(&path)
            .map_err(|e| {
                (
                    StatusCode::NOT_FOUND,
                    Json(serde_json::json!({ "error": format!("plan not found: {e}") })),
                )
            })
            .map(|content| (name, content))
    })
    .await
    .map_err(|e| {
        (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(serde_json::json!({ "error": format!("get_plan_content task panicked: {e}") })),
        )
    })??;

    Ok(Json(PlanContentResponse {
        name: content.0,
        content: content.1,
    }))
}