//! LLM + Chromium PDF renderer worker — Slice 6. //! //! Watches `mission_artifacts` for rows with `render_pdf_status = //! 'pending'`. For each: //! 1. Read the source MD from `/` on disk //! 2. Call the configured LLM (default: Gemini 2.5 Flash) with a //! "produce styled HTML" prompt anchored to a design-system //! example. LLM writes HTML with inline CSS. //! 3. Print that HTML to PDF via `chromium --headless //! --print-to-pdf` //! 4. Save the PDF alongside the MD, update `rendered_pdf_path` + //! status = 'done' //! //! Graceful degradation: if `GEMINI_API_KEY` is unset or the //! chromium binary isn't on PATH, the worker marks the row `failed` //! with a descriptive error rather than blocking boot. Ops enables //! rendering by wiring both. //! //! The frontend already renders `rendered_pdf_path` as an "Open PDF" //! button on artifact cards (Slice 2). use serde_json::json; use sqlx::PgPool; use std::path::{Path, PathBuf}; use std::time::Duration; const POLL_INTERVAL: Duration = Duration::from_secs(30); const MAX_PARALLEL: usize = 2; const DEFAULT_MODEL: &str = "gemini-2.5-flash"; /// Where per-mission artifacts land on disk. Overridable so dev vs. /// prod can move the tree; matches the pattern in /// `research_container::research_workspace_root`. fn missions_root() -> PathBuf { std::env::var("CLAWMATES_MISSIONS_ROOT") .map(PathBuf::from) .unwrap_or_else(|_| PathBuf::from("/var/lib/clawmates-missions")) } fn chromium_bin() -> String { std::env::var("CHROMIUM_BIN").unwrap_or_else(|_| "chromium".to_string()) } fn renderer_model() -> String { std::env::var("CLAWMATES_PDF_RENDERER_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.to_string()) } /// Spawn the poller. No-op-friendly: if there's nothing pending or /// no rendering pipeline configured, we still tick + observe. pub fn spawn(pool: PgPool) { tokio::spawn(async move { // Small startup delay so migrations + loaders finish first. tokio::time::sleep(Duration::from_secs(8)).await; let mut ticker = tokio::time::interval(POLL_INTERVAL); ticker.tick().await; loop { ticker.tick().await; if let Err(e) = sweep_once(&pool).await { eprintln!("pdf_renderer: sweep failed: {e}"); } } }); } async fn sweep_once(pool: &PgPool) -> Result<(), String> { let pending = cm_db::repo::missions::next_pdf_pending(pool, MAX_PARALLEL as i64) .await .map_err(|e| format!("next_pdf_pending: {e}"))?; for artifact in pending { let pool = pool.clone(); let id = artifact.id; tokio::spawn(async move { match render_one(&pool, &artifact).await { Ok(pdf_path) => { let _ = cm_db::repo::missions::set_pdf_result(&pool, id, Some(&pdf_path), None) .await; eprintln!("pdf_renderer: rendered {id} → {pdf_path}"); } Err(e) => { let _ = cm_db::repo::missions::set_pdf_result(&pool, id, None, Some(&e)).await; eprintln!("pdf_renderer: {id} failed: {e}"); } } }); } Ok(()) } async fn render_one( _pool: &PgPool, artifact: &cm_db::repo::missions::MissionArtifact, ) -> Result { // 1. Locate the source MD on disk. let mission_root = missions_root().join(artifact.mission_id.to_string()); let src_path = mission_root.join(&artifact.path); let md = tokio::fs::read_to_string(&src_path) .await .map_err(|e| format!("read {}: {e}", src_path.display()))?; // 2. LLM → styled HTML. let html = md_to_html_via_llm(&md, artifact.title.as_deref()) .await .map_err(|e| format!("llm render: {e}"))?; // 3. Chromium → PDF. let tmp = tempdir_for(artifact.id)?; let html_path = tmp.join("in.html"); let pdf_path = tmp.join("out.pdf"); tokio::fs::write(&html_path, html) .await .map_err(|e| format!("write {}: {e}", html_path.display()))?; let status = tokio::process::Command::new(chromium_bin()) .args([ "--headless=new", "--disable-gpu", "--no-sandbox", "--hide-scrollbars", &format!("--print-to-pdf={}", pdf_path.display()), "--print-to-pdf-no-header", "--virtual-time-budget=10000", &format!("file://{}", html_path.display()), ]) .stderr(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .status() .await .map_err(|e| format!("spawn chromium: {e}"))?; if !status.success() { return Err(format!("chromium exited {status}")); } // 4. Move next to the source MD so the artifact tree stays self- // contained. Filename derived from the MD path (foo.md → foo.pdf). let out_rel = pdf_sibling(&artifact.path); let out_abs = mission_root.join(&out_rel); if let Some(parent) = out_abs.parent() { tokio::fs::create_dir_all(parent) .await .map_err(|e| format!("mkdir {}: {e}", parent.display()))?; } tokio::fs::copy(&pdf_path, &out_abs) .await .map_err(|e| format!("copy pdf: {e}"))?; // Best-effort tmp cleanup — the temp dir lives under /tmp so the // OS will reap it anyway. let _ = tokio::fs::remove_dir_all(&tmp).await; Ok(out_rel) } /// Ask the configured LLM to turn `md` into a fully self-contained /// styled HTML doc. Uses whichever provider `CLAWMATES_PDF_RENDERER_MODEL` /// resolves to. Defaults to Gemini 2.5 Flash + GEMINI_API_KEY. async fn md_to_html_via_llm(md: &str, title: Option<&str>) -> Result { let model = renderer_model(); // For now we hardcode the Gemini path — anthropic + openai // variants land when the design-system template stabilizes. if !model.starts_with("gemini") { return Err(format!( "renderer model {model} not yet wired (only gemini-* supported in Slice 6)" )); } let api_key = std::env::var("GEMINI_API_KEY").map_err(|_| "GEMINI_API_KEY unset".to_string())?; let system = r#"You are a document typesetter. Given a Markdown source, produce ONE self-contained HTML document that: - Has ALL styles inline in a single