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