`corpus_items.mission_id` has existed since the table landed and nothing could populate it. `POST /api/library/runs` now accepts `missionId`, which is the seam the wizard needs: a mission-driven run is the same run, tagged. `corpus::contributed()` answers the question a continuous mission has to be able to answer — did THIS run add anything new. Because `record` never reassigns mission_id on conflict, the mission that first found a source keeps the credit, so a rerun cannot inflate its own count by re-recording what an earlier run already held. The test asserts exactly that: two missions see the same paper, the finder reports 1 and the rerun reports 0. This is the check the 0030-0044 generation of continuous research did not have. It could run weekly forever and every run looked like success. The test also earned its FK: the first version attributed to a bare UUID and the database refused it. Attribution to a mission that does not exist is not attribution, so the test now seeds real mission rows. 400 tests, clippy clean. Co-Authored-By: Claude Opus 5 <[email protected]>
163 lines
5.1 KiB
Rust
163 lines
5.1 KiB
Rust
//! 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>,
|
|
/// 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<uuid::Uuid>,
|
|
}
|
|
|
|
#[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 merged: bool,
|
|
pub merge_reason: String,
|
|
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,
|
|
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<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(),
|
|
))
|
|
}
|