@@ -239,6 +239,75 @@ pub async fn delete(pool: &PgPool, agent_id: Uuid) -> Result<(), String> {
239239 } )
240240}
241241
242+ /// Delete agents whose deployment is gone (or soft-deleted) and that show no
243+ /// sign of life within the retention window.
244+ ///
245+ /// "No sign of life" means both:
246+ /// - `last_heartbeat` is NULL or older than the window, AND
247+ /// - no `audit_log` row references the agent (by id or deployment_hash)
248+ /// within the same window.
249+ ///
250+ /// The second condition protects agents that are alive but failing
251+ /// authentication — `last_heartbeat` only advances on successful `wait`/`report`,
252+ /// while `audit_log` captures `auth_failure` entries.
253+ #[ tracing:: instrument( name = "Sweep dead agents" , skip( pool) ) ]
254+ pub async fn sweep_dead ( pool : & PgPool , retention_days : i32 ) -> Result < u64 , String > {
255+ let result = sqlx:: query (
256+ r#"
257+ DELETE FROM agents a
258+ WHERE NOT EXISTS (
259+ SELECT 1 FROM deployment d
260+ WHERE d.deployment_hash = a.deployment_hash
261+ AND d.deleted IS NOT TRUE
262+ )
263+ AND (a.last_heartbeat IS NULL
264+ OR a.last_heartbeat < NOW() - make_interval(days => $1))
265+ AND NOT EXISTS (
266+ SELECT 1 FROM audit_log l
267+ WHERE (l.agent_id = a.id OR l.deployment_hash = a.deployment_hash)
268+ AND l.created_at > NOW() - make_interval(days => $1)
269+ )
270+ "# ,
271+ )
272+ . bind ( retention_days)
273+ . execute ( pool)
274+ . await
275+ . map_err ( |err| {
276+ tracing:: error!( "Failed to sweep dead agents: {:?}" , err) ;
277+ format ! ( "Database error: {}" , err)
278+ } ) ?;
279+
280+ Ok ( result. rows_affected ( ) )
281+ }
282+
283+ /// Delete agents whose `deployment_hash` is structurally invalid.
284+ ///
285+ /// A valid hash matches `deployment_<uuid>` (36-char UUID with hyphens).
286+ /// Rows with an invalid hash can never be matched by their own agent — the
287+ /// lookup in `fetch_by_deployment_hash` will never find them. Among the 18
288+ /// currently broken rows, 17 store a raw agent token (86-char base64url)
289+ /// instead of a hash, leaking the secret in plaintext.
290+ ///
291+ /// Returns its own count separate from `sweep_dead` so the caller can log
292+ /// exactly how many malformed rows were removed.
293+ #[ tracing:: instrument( name = "Sweep malformed agent rows" , skip( pool) ) ]
294+ pub async fn sweep_malformed ( pool : & PgPool ) -> Result < u64 , String > {
295+ let result = sqlx:: query (
296+ r#"
297+ DELETE FROM agents
298+ WHERE deployment_hash !~ '^deployment_[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'
299+ "# ,
300+ )
301+ . execute ( pool)
302+ . await
303+ . map_err ( |err| {
304+ tracing:: error!( "Failed to sweep malformed agents: {:?}" , err) ;
305+ format ! ( "Database error: {}" , err)
306+ } ) ?;
307+
308+ Ok ( result. rows_affected ( ) )
309+ }
310+
242311pub async fn log_audit (
243312 pool : & PgPool ,
244313 audit_log : models:: AuditLog ,
0 commit comments