anvilsign in

collin/anvil

1//! Periodic background jobs: repository scanning, disk usage caching, etc.
2//!
3//! Jobs run on configurable intervals and log errors without affecting the
4//! server. The runner is spawned in `anvil-cli/src/main.rs::serve()` alongside
5//! the HTTP server and CI worker.
6
7use std::{
8 path::Path,
9 time::Duration,
10};
11
12use async_trait::async_trait;
13use tokio::time::interval;
14
15use crate::{
16 App,
17 Result,
18 language,
19 models::Repository,
20 preview_images,
21 repos,
22 usage,
23};
24
25/// A periodic task to be run on an interval.
26// `async_trait` marks the boxed future it desugars `run` to as `#[must_use]`,
27// and the `Result` inside it carries its own — which clippy reads as one
28// `must_use` too many. Neither is ours to remove, and the trait must stay
29// `dyn`-compatible (the runner holds `Box<dyn PeriodicJob>`), which a native
30// `async fn` in a trait is not.
31#[allow(clippy::double_must_use)]
32#[async_trait]
33pub trait PeriodicJob: Send + Sync {
34 /// Run the job once. Errors are logged but do not stop the runner.
35 async fn run(&self, app: &App) -> Result<()>;
36
37 /// Human-readable name for logging.
38 fn name(&self) -> &str;
39}
40
41/// Spawns a background task that runs jobs on their configured intervals.
42/// The task runs until the app shuts down.
43pub async fn spawn_runner(app: App, jobs: Vec<(Duration, Box<dyn PeriodicJob>)>) {
44 tokio::spawn(async move {
45 let mut tasks = Vec::new();
46 for (interval_duration, job) in jobs {
47 let mut ticker = interval(interval_duration);
48 let app = app.clone();
49 tasks.push(tokio::spawn(async move {
50 loop {
51 ticker.tick().await;
52 if let Err(e) = job.run(&app).await {
53 tracing::error!(
54 job = job.name(),
55 error = %e,
56 "periodic job failed"
57 );
58 }
59 }
60 }));
61 }
62 // All tasks are spawned and run forever; if any exits, others continue.
63 // We could join them here with `futures::future::join_all(tasks).await`,
64 // but detaching is fine — tasks clean up automatically on shutdown.
65 });
66}
67
68/// Job that detects programming languages in repositories.
69pub struct LanguageDetectionJob;
70
71#[async_trait::async_trait]
72impl PeriodicJob for LanguageDetectionJob {
73 async fn run(&self, app: &App) -> Result<()> {
74 let repos_dir = app.config.repositories_dir();
75 let mut db = app.db.clone();
76 let repos_list = Repository::all().exec(&mut db).await?;
77 let repo_count = repos_list.len();
78
79 for repo in repos_list {
80 let repo_path = repos_dir.join(repo.owner_id.to_string()).join(&repo.name);
81 if repo_path.exists()
82 && let Ok(detection) = language::detect_from_git(&repo_path, &repo.default_branch)
83 {
84 let _ =
85 repos::update_languages(&app.db, repo.id, &detection.primary, &detection.json)
86 .await;
87 }
88 }
89 tracing::info!("language detection job completed for {} repos", repo_count);
90 Ok(())
91 }
92
93 fn name(&self) -> &str {
94 "language_detection"
95 }
96}
97
98/// Job that extracts preview images from repository READMEs.
99pub struct PreviewImageJob;
100
101#[async_trait::async_trait]
102impl PeriodicJob for PreviewImageJob {
103 async fn run(&self, app: &App) -> Result<()> {
104 let repos_dir = app.config.repositories_dir();
105 let mut db = app.db.clone();
106 let repos_list = Repository::all().exec(&mut db).await?;
107
108 let mut updated = 0;
109 for repo in repos_list {
110 let repo_git_path = repos_dir
111 .join(repo.owner_id.to_string())
112 .join(&repo.name)
113 .join(".git");
114 if repo_git_path.exists() {
115 // Try to extract preview image from README in the repository
116 if let Some(image_url) = extract_readme_image(&repo_git_path).await {
117 // TODO: download and store the image, or just reference it
118 // For now, we'll just store the URL as a hash placeholder
119 if !image_url.is_empty() {
120 let _ = repos::update_preview_image(&app.db, repo.id, &image_url).await;
121 updated += 1;
122 }
123 }
124 }
125 }
126 tracing::info!("preview image job completed: {} repos updated", updated);
127 Ok(())
128 }
129
130 fn name(&self) -> &str {
131 "preview_images"
132 }
133}
134
135/// Job that caches disk usage for the admin dashboard.
136pub struct DiskUsageCacheJob;
137
138#[async_trait::async_trait]
139impl PeriodicJob for DiskUsageCacheJob {
140 async fn run(&self, app: &App) -> Result<()> {
141 // Compute disk usage and cache it
142 if let Ok(usage_data) = usage::compute(app).await {
143 let json = serde_json::to_string(&usage_data)?;
144 // Store in admin_cache table
145 crate::admin_cache::upsert(&app.db, "disk_usage", &json).await?;
146 tracing::info!("disk usage cache job completed");
147 }
148 Ok(())
149 }
150
151 fn name(&self) -> &str {
152 "disk_usage_cache"
153 }
154}
155
156/// Trigger language detection for a single repository, updating its LOC percentages.
157/// This is called immediately after a push to keep metadata current without waiting for
158/// the periodic job interval. Spawned as a background task so it doesn't block the push response.
159pub fn trigger_repo_indexing(app: App, repo_id: i64, repo_path: std::path::PathBuf) {
160 tokio::spawn(async move {
161 let repo_git_path = repo_path.join(".git");
162 if repo_git_path.exists()
163 && let Ok(detection) = language::detect(&repo_git_path).await
164 && let Err(e) =
165 repos::update_languages(&app.db, repo_id, &detection.primary, &detection.json).await
166 {
167 tracing::warn!(repo_id = repo_id, error = %e, "failed to update languages after push");
168 }
169 });
170}
171
172/// Job that drops expired repo-secret unlocks from memory.
173///
174/// [`crate::secrets::Vault`] already treats an expired entry as sealed on
175/// every read; this only stops the plaintext from sitting in the process's
176/// memory until something happens to look at it.
177pub struct SecretVaultSweepJob;
178
179#[async_trait::async_trait]
180impl PeriodicJob for SecretVaultSweepJob {
181 async fn run(&self, app: &App) -> Result<()> {
182 app.vault.sweep();
183 Ok(())
184 }
185
186 fn name(&self) -> &str {
187 "secret_vault_sweep"
188 }
189}
190
191/// Try to extract the first image URL from a repository's README file.
192/// Scans the repository for a README file, reads it, and returns the first image URL found.
193async fn extract_readme_image(repo_path: &Path) -> Option<String> {
194 // Find README file in the repository (case-insensitive)
195 let readme_names = [
196 "README.md",
197 "readme.md",
198 "README.MD",
199 "README.rst",
200 "README.txt",
201 ];
202
203 for readme_name in &readme_names {
204 let readme_path = repo_path.parent()?.join(readme_name);
205 if readme_path.exists()
206 && let Ok(content) = std::fs::read_to_string(&readme_path)
207 && let Some(url) = preview_images::extract_preview_url(&content)
208 {
209 return Some(url);
210 }
211 }
212
213 None
214}