harnessd 0.1.0

The harness daemon: API server (axum WS + REST), agent runtime host, and CLI (init/pair/doctor).
//! REST handlers (design doc §5.3). CRUD + operability; the live stream is the WS.

use crate::server::AppState;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::Json;
use serde::{Deserialize, Serialize};

/// `GET /v1/health` — per-subsystem status the iOS health screen surfaces (§9).
pub async fn health(State(st): State<AppState>) -> impl IntoResponse {
    let providers: Vec<ProviderHealth> = st
        .runtime
        .validate_providers()
        .into_iter()
        .map(|(id, res)| ProviderHealth {
            id,
            // M1 only checks that the backend *builds* (key present, kind supported).
            // A live reachability probe is added alongside the MCP hub in M2.
            status: if res.is_ok() { "ok" } else { "error" }.to_string(),
            detail: res.err().map(|e| e.to_string()),
        })
        .collect();

    let workspace_ok = st.runtime.workspace_writable();
    let all_ok = providers.iter().all(|p| p.status == "ok") && workspace_ok;
    let body = HealthResponse {
        status: if all_ok { "ok" } else { "degraded" }.to_string(),
        version: st.version.to_string(),
        uptime_secs: st.uptime_secs(),
        tools: st.runtime.tool_count(),
        presets: st.runtime.preset_count(),
        workspace_ok,
        providers,
    };
    Json(body)
}

#[derive(Serialize)]
pub struct HealthResponse {
    status: String,
    version: String,
    uptime_secs: u64,
    /// Tools available across all sources.
    tools: usize,
    /// Presets loaded.
    presets: usize,
    /// Whether the sandboxed workspace is writable.
    workspace_ok: bool,
    providers: Vec<ProviderHealth>,
}

#[derive(Serialize)]
pub struct ProviderHealth {
    id: String,
    status: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    detail: Option<String>,
}

/// `GET /v1/version`.
pub async fn version(State(st): State<AppState>) -> impl IntoResponse {
    Json(
        serde_json::json!({ "daemon": format!("harnessd/{}", st.version), "protocol": harness_proto::PROTOCOL_VERSION }),
    )
}

/// `GET /v1/sessions` — list sessions, most recent first.
pub async fn list_sessions(State(st): State<AppState>) -> Result<Json<Vec<SessionDto>>, ApiError> {
    let sessions = st.runtime.store().list_sessions()?;
    Ok(Json(sessions.into_iter().map(SessionDto::from).collect()))
}

#[derive(Serialize)]
pub struct SessionDto {
    id: String,
    title: String,
    provider: String,
    tool_policy: String,
    created_at: String,
    last_seq: u64,
    last_activity: String,
}

impl From<harness_core::store::SessionSummary> for SessionDto {
    fn from(s: harness_core::store::SessionSummary) -> Self {
        SessionDto {
            id: s.id,
            title: s.title,
            provider: s.provider,
            tool_policy: s.tool_policy,
            created_at: s.created_at,
            last_seq: s.last_seq,
            last_activity: s.last_activity,
        }
    }
}

#[derive(Deserialize)]
pub struct CreateSessionBody {
    #[serde(default = "default_title")]
    title: String,
    #[serde(default)]
    provider: Option<String>,
    /// Optional preset id; binds the session to that preset's tool policy (§7.1).
    #[serde(default)]
    preset: Option<String>,
}
fn default_title() -> String {
    "New session".to_string()
}

/// `POST /v1/sessions` — create a session (provider + preset optional).
pub async fn create_session(
    State(st): State<AppState>,
    Json(body): Json<CreateSessionBody>,
) -> Result<Json<serde_json::Value>, ApiError> {
    let id = st.runtime.create_session_with_preset(
        &body.title,
        body.provider.as_deref(),
        body.preset.as_deref(),
    )?;
    Ok(Json(serde_json::json!({ "id": id })))
}

#[derive(Deserialize)]
pub struct EventsQuery {
    #[serde(default)]
    from_seq: u64,
}

/// `GET /v1/sessions/:id/events?from_seq=N` — backfill without a WebSocket.
pub async fn session_events(
    State(st): State<AppState>,
    Path(id): Path<String>,
    Query(q): Query<EventsQuery>,
) -> Result<Json<Vec<harness_proto::EventEnvelope>>, ApiError> {
    let events = st.runtime.store().events_since(&id, q.from_seq)?;
    Ok(Json(events))
}

/// `GET /v1/providers` — providers (config + runtime overlay) with credential status.
pub async fn list_providers(State(st): State<AppState>) -> impl IntoResponse {
    let main = st.runtime.config().roles.main.clone();
    let providers: Vec<_> = st
        .runtime
        .all_providers()
        .iter()
        .map(|p| {
            serde_json::json!({
                "id": p.id,
                "kind": p.kind,
                "model": p.model,
                "enabled": p.enabled,
                "has_key": st.runtime.provider_has_key(p),
                "is_main": main.as_deref() == Some(p.id.as_str()),
            })
        })
        .collect();
    Json(providers)
}

#[derive(Deserialize)]
pub struct AddProviderBody {
    id: String,
    kind: harness_core::config::ProviderKind,
    #[serde(default)]
    model: String,
    #[serde(default)]
    base_url: Option<String>,
    #[serde(default)]
    command: Option<String>,
    #[serde(default)]
    api_key: Option<String>,
}

/// `POST /v1/providers` — add or update a provider from a client (the TUI provider
/// screen). An `api_key`, if present, is stored in the secret store (never echoed back).
pub async fn add_provider(
    State(st): State<AppState>,
    Json(body): Json<AddProviderBody>,
) -> Result<Json<serde_json::Value>, ApiError> {
    let cfg = harness_core::config::ProviderConfig {
        id: body.id.clone(),
        kind: body.kind,
        model: body.model,
        api_key_env: None,
        base_url: body.base_url,
        command: body.command,
        extra_args: Vec::new(),
        enabled: true,
    };
    st.runtime.add_or_update_provider(cfg, body.api_key)?;
    Ok(Json(serde_json::json!({ "id": body.id, "ok": true })))
}

#[derive(Deserialize)]
pub struct SetKeyBody {
    api_key: String,
}

/// `POST /v1/providers/:id/key` — set/replace a provider's API key (secret store).
pub async fn set_provider_key(
    State(st): State<AppState>,
    Path(id): Path<String>,
    Json(body): Json<SetKeyBody>,
) -> Result<Json<serde_json::Value>, ApiError> {
    st.runtime.set_provider_key(&id, &body.api_key)?;
    Ok(Json(serde_json::json!({ "id": id, "ok": true })))
}

/// `GET /v1/presets` — the preset catalog with enabled/schedule state (§7.1).
pub async fn list_presets(State(st): State<AppState>) -> impl IntoResponse {
    let presets: Vec<_> = st
        .runtime
        .presets()
        .iter()
        .map(|p| {
            serde_json::json!({
                "id": p.meta.id,
                "name": p.meta.name,
                "description": p.meta.description,
                "scheduled": p.schedule.enabled,
            })
        })
        .collect();
    Json(presets)
}

/// `GET /v1/presets/:id/requirements` — the connect-this-service checklist (§7.2).
pub async fn preset_requirements(
    State(st): State<AppState>,
    Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, ApiError> {
    let preset = st
        .runtime
        .preset(&id)
        .ok_or_else(|| harness_core::CoreError::Config(format!("unknown preset `{id}`")))?;
    let reqs = preset.requirements(&st.runtime.available_namespaces());
    Ok(Json(serde_json::json!({
        "id": preset.meta.id,
        "name": preset.meta.name,
        "requirements": reqs,
    })))
}

/// `POST /v1/pair/start` — mint a one-time 8-digit pairing code (5-min TTL, §8). Only
/// the code's hash is stored. Normally invoked via `harnessd pair`, but exposed for
/// clients that drive their own "add device" flow.
pub async fn pair_start(State(st): State<AppState>) -> Result<Json<serde_json::Value>, ApiError> {
    let code = crate::auth::generate_code();
    let expires_at = (time::OffsetDateTime::now_utc() + time::Duration::minutes(5))
        .format(&time::format_description::well_known::Rfc3339)
        .unwrap_or_default();
    st.runtime
        .store()
        .add_pairing_code(&crate::auth::hash(&code), &expires_at)?;
    Ok(Json(serde_json::json!({
        "code": code,
        "expires_at": expires_at,
    })))
}

#[derive(Deserialize)]
pub struct PairCompleteBody {
    code: String,
    #[serde(default = "default_device_name")]
    device_name: String,
}
fn default_device_name() -> String {
    "device".to_string()
}

/// `POST /v1/pair/complete` — exchange a valid pairing code for a long-lived device
/// token (§8). The code is consumed (single-use); only the token's hash is stored, so
/// the token value is returned exactly once here and never recoverable afterward.
pub async fn pair_complete(
    State(st): State<AppState>,
    Json(body): Json<PairCompleteBody>,
) -> Result<Json<serde_json::Value>, ApiError> {
    let now = time::OffsetDateTime::now_utc()
        .format(&time::format_description::well_known::Rfc3339)
        .unwrap_or_default();
    let ok = st
        .runtime
        .store()
        .consume_pairing_code(&crate::auth::hash(&body.code), &now)?;
    if !ok {
        return Err(
            harness_core::CoreError::Config("invalid or expired pairing code".into()).into(),
        );
    }
    let token = crate::auth::random_hex(32);
    st.runtime
        .store()
        .add_device(&crate::auth::hash(&token), &body.device_name)?;
    Ok(Json(serde_json::json!({ "device_token": token })))
}

/// Maps a [`harness_core::CoreError`] onto an HTTP response with a stable code.
pub struct ApiError(harness_core::CoreError);

impl From<harness_core::CoreError> for ApiError {
    fn from(e: harness_core::CoreError) -> Self {
        ApiError(e)
    }
}

impl IntoResponse for ApiError {
    fn into_response(self) -> axum::response::Response {
        use harness_core::CoreError::*;
        let status = match &self.0 {
            UnknownSession(_) | UnknownProvider(_) => StatusCode::NOT_FOUND,
            Config(_) => StatusCode::BAD_REQUEST,
            _ => StatusCode::INTERNAL_SERVER_ERROR,
        };
        let body =
            Json(serde_json::json!({ "code": self.0.code(), "message": self.0.to_string() }));
        (status, body).into_response()
    }
}