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:
co-authored by
Claude Opus 5
parent
09c6496725
commit
107f0dbced
@@ -266,7 +266,7 @@ async fn run() -> Result<(), String> {
|
|||||||
terminals,
|
terminals,
|
||||||
providers: provider_registry,
|
providers: provider_registry,
|
||||||
},
|
},
|
||||||
blob,
|
blob.clone(),
|
||||||
);
|
);
|
||||||
// Durable §15 path: expires overdue approvals and resumes decided runs
|
// Durable §15 path: expires overdue approvals and resumes decided runs
|
||||||
// even if the deciding request's process died mid-flight.
|
// even if the deciding request's process died mid-flight.
|
||||||
@@ -388,6 +388,7 @@ async fn run() -> Result<(), String> {
|
|||||||
.with_broker(PathBuf::from(&config.broker.socket_path))
|
.with_broker(PathBuf::from(&config.broker.socket_path))
|
||||||
.with_oauth(config.oauth.clone())
|
.with_oauth(config.oauth.clone())
|
||||||
.with_billing(config.billing.clone())
|
.with_billing(config.billing.clone())
|
||||||
|
.with_blobs(blob.clone())
|
||||||
.with_file_root(
|
.with_file_root(
|
||||||
(config.storage.backend == cm_config::StorageBackend::Local)
|
(config.storage.backend == cm_config::StorageBackend::Local)
|
||||||
.then(|| PathBuf::from(&config.storage.data_dir)),
|
.then(|| PathBuf::from(&config.storage.data_dir)),
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ pub struct AppState {
|
|||||||
pub file_root: Option<std::path::PathBuf>,
|
pub file_root: Option<std::path::PathBuf>,
|
||||||
/// Live control channels to connected fleet-node daemons.
|
/// Live control channels to connected fleet-node daemons.
|
||||||
pub node_hub: std::sync::Arc<fleet::NodeHub>,
|
pub node_hub: std::sync::Arc<fleet::NodeHub>,
|
||||||
|
/// The shelf. Present once the server wires storage; `None` in the
|
||||||
|
/// bare-`new` path used by tests that never touch blobs.
|
||||||
|
pub blobs: Option<std::sync::Arc<dyn cm_files::BlobStore>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
@@ -82,6 +85,7 @@ impl AppState {
|
|||||||
billing: cm_config::BillingConfig::default(),
|
billing: cm_config::BillingConfig::default(),
|
||||||
file_root: None,
|
file_root: None,
|
||||||
node_hub: std::sync::Arc::new(fleet::NodeHub::new()),
|
node_hub: std::sync::Arc::new(fleet::NodeHub::new()),
|
||||||
|
blobs: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +94,12 @@ impl AppState {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The shelf — where the paper library stores PDFs.
|
||||||
|
pub fn with_blobs(mut self, blobs: std::sync::Arc<dyn cm_files::BlobStore>) -> AppState {
|
||||||
|
self.blobs = Some(blobs);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn with_oauth(mut self, oauth: cm_config::OAuthConfig) -> AppState {
|
pub fn with_oauth(mut self, oauth: cm_config::OAuthConfig) -> AppState {
|
||||||
self.oauth = oauth;
|
self.oauth = oauth;
|
||||||
self
|
self
|
||||||
@@ -315,6 +325,8 @@ pub fn router(state: AppState) -> Router {
|
|||||||
.route("/api/sessions", post(routes::sessions::create))
|
.route("/api/sessions", post(routes::sessions::create))
|
||||||
.route("/api/sessions/history", get(routes::sessions::history))
|
.route("/api/sessions/history", get(routes::sessions::history))
|
||||||
.route("/api/gateway", post(routes::gateway::gateway))
|
.route("/api/gateway", post(routes::gateway::gateway))
|
||||||
|
.route("/api/library/runs", post(routes::library::run))
|
||||||
|
.route("/api/library/items", get(routes::library::list))
|
||||||
.route("/api/routines", get(routes::routines::list))
|
.route("/api/routines", get(routes::routines::list))
|
||||||
.route("/api/routines", post(routes::routines::create))
|
.route("/api/routines", post(routes::routines::create))
|
||||||
.route("/api/routines/runs", get(routes::routines::runs))
|
.route("/api/routines/runs", get(routes::routines::runs))
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ pub mod gateway;
|
|||||||
pub mod health;
|
pub mod health;
|
||||||
pub mod identity;
|
pub mod identity;
|
||||||
pub mod level_up;
|
pub mod level_up;
|
||||||
|
pub mod library;
|
||||||
pub mod missions;
|
pub mod missions;
|
||||||
pub mod nodes;
|
pub mod nodes;
|
||||||
pub mod oauth;
|
pub mod oauth;
|
||||||
|
|||||||
Reference in New Issue
Block a user