feat(library): expose the library over the API

POST /api/library/runs harvests now; GET /api/library/items lists what
the library holds. Thin wrappers — the work stays in crate::library — so
a run can be started by a person, a schedule or the UI rather than only
from an integration test.

The response reports `healthy` explicitly rather than leaving a caller to
infer it from an empty `shelved` list. A quiet week and a broken run both
shelve zero papers, and collapsing those two is the exact ambiguity that
cost most of this week.

Failure reasons go to the log, not the response body: they can carry the
remote URL and raw git stderr.

AppState gains an optional blob store (the shelf), wired from the server
binary where storage is already constructed. Optional because AppState::new
is used by tests that never touch blobs; a route that needs it fails
loudly rather than the constructor demanding it everywhere.

393 tests, clippy clean.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Omar Sobh
2026-08-03 10:21:00 -07:00
co-authored by Claude Opus 5
parent 09c6496725
commit 107f0dbced
4 changed files with 168 additions and 1 deletions
+153
View File
@@ -0,0 +1,153 @@
//! 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<Vec<String>>,
/// 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<usize>,
}
#[derive(Serialize)]
pub struct RunResponse {
pub candidates: usize,
pub already_had: usize,
pub shelved: Vec<String>,
pub failed: Vec<Value>,
pub notes: Vec<String>,
pub branch: String,
pub pushed: bool,
pub error: Option<String>,
/// 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<AppState>,
Authed(user): Authed,
Json(req): Json<RunRequest>,
) -> Result<Json<RunResponse>, 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,
None,
)
.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,
error: out.error,
}))
}
#[derive(Deserialize)]
pub struct ListQuery {
#[serde(default)]
pub kind: Option<String>,
#[serde(default)]
pub limit: Option<i64>,
}
/// `(source_id, title, url, note path)` as stored.
type CorpusRow = (String, Option<String>, Option<String>, Option<String>);
/// GET /api/library/items — what the library holds.
pub async fn list(
State(state): State<AppState>,
Authed(user): Authed,
Query(q): Query<ListQuery>,
) -> Result<Json<Vec<Value>>, ApiError> {
let limit = q.limit.unwrap_or(100).clamp(1, 500);
let kind = q.kind.unwrap_or_else(|| "source".to_string());
let rows: Vec<CorpusRow> = 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(),
))
}
+1
View File
@@ -13,6 +13,7 @@ pub mod gateway;
pub mod health;
pub mod identity;
pub mod level_up;
pub mod library;
pub mod missions;
pub mod nodes;
pub mod oauth;