//! The paper library: trigger a run, see what it holds. //! //! Thin on purpose. The work lives in [`crate::library`]; this exposes it so //! a run can be started by a person, a schedule, or the UI rather than only //! from an integration test. use axum::extract::{Query, State}; use axum::Json; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use crate::{ApiError, AppState, Authed}; /// Default corpus + repo. Single-operator deployment, so these are constants /// rather than another table to keep in sync; a second library becomes a /// request field the day one exists. const DEFAULT_CORPUS: &str = "valhalla-vault"; const DEFAULT_VAULT_URL: &str = "https://git.redclaw.dev/redclaw/valhalla-vault.git"; #[derive(Deserialize)] pub struct RunRequest { /// arXiv queries. Omitted → the topics this project is actually working on. #[serde(default)] pub topics: Option>, /// Papers per topic. Clamped, because a broad first run against an empty /// library can otherwise pull hundreds of PDFs in one go. #[serde(default)] pub per_topic: Option, /// Attribute this run to a mission, so the mission can later be asked /// what it contributed. `corpus_items.mission_id` has existed since the /// table landed; without this field nothing could ever populate it. #[serde(default, rename = "missionId")] pub mission_id: Option, } #[derive(Serialize)] pub struct RunResponse { pub candidates: usize, pub already_had: usize, pub shelved: Vec, pub failed: Vec, pub notes: Vec, pub branch: String, pub pushed: bool, pub merged: bool, pub merge_reason: String, pub error: Option, /// A run that errored on nothing. Reported explicitly so a caller does not /// have to infer health from an empty `shelved` list — a quiet week and a /// broken run both shelve zero papers. pub healthy: bool, } /// POST /api/library/runs — harvest now. pub async fn run( State(state): State, Authed(user): Authed, Json(req): Json, ) -> Result, ApiError> { let blobs = state .blobs .clone() .ok_or_else(|| { eprintln!("library: blob storage is not configured; cannot shelve PDFs"); ApiError::Internal })?; let topics = req .topics .filter(|t| !t.is_empty()) .unwrap_or_else(crate::library::default_topics); let per_topic = req.per_topic.unwrap_or(5).clamp(1, 25); // Work under the missions root: it is already a writable volume with room // for checkouts, and it is swept, so a crashed run cannot leak a vault // clone forever. let work_root = std::env::temp_dir().join("clawmates-library"); let out = crate::library::run_to_vault( &state.pool, &blobs, user.workspace_id.as_uuid(), DEFAULT_CORPUS, DEFAULT_VAULT_URL, &work_root, &topics, per_topic, req.mission_id, ) .await .map_err(|e| { // The reason belongs in the log, not in the response: it can carry a // remote URL and git stderr. eprintln!("library: run failed: {e}"); ApiError::Internal })?; Ok(Json(RunResponse { candidates: out.harvest.candidates, already_had: out.harvest.already_had, shelved: out.harvest.shelved.clone(), failed: out .harvest .failed .iter() .map(|(id, why)| json!({ "source_id": id, "error": why })) .collect(), notes: out.harvest.notes_written.clone(), healthy: out.harvest.healthy(), branch: out.branch, pushed: out.pushed, merged: out.merged, merge_reason: out.merge_reason, error: out.error, })) } #[derive(Deserialize)] pub struct ListQuery { #[serde(default)] pub kind: Option, #[serde(default)] pub limit: Option, } /// `(source_id, title, url, note path)` as stored. type CorpusRow = (String, Option, Option, Option); /// GET /api/library/items — what the library holds. pub async fn list( State(state): State, Authed(user): Authed, Query(q): Query, ) -> Result>, ApiError> { let limit = q.limit.unwrap_or(100).clamp(1, 500); let kind = q.kind.unwrap_or_else(|| "source".to_string()); let rows: Vec = sqlx::query_as( "SELECT source_id, title, url, path FROM corpus_items WHERE workspace_id = $1 AND corpus_id = $2 AND kind = $3 ORDER BY first_seen_at DESC LIMIT $4", ) .bind(user.workspace_id.as_uuid()) .bind(DEFAULT_CORPUS) .bind(kind) .bind(limit) .fetch_all(&state.pool) .await .map_err(|e| { eprintln!("library: list corpus: {e}"); ApiError::Internal })?; Ok(Json( rows.into_iter() .map(|(source_id, title, url, path)| { json!({ "sourceId": source_id, "title": title, "url": url, "notePath": path }) }) .collect(), )) }