use anyhow::{anyhow, Context, Result};
use chrono::Local;
use std::time::{Duration, Instant};
use crate::worker::claude::ClaudeCli;
use crate::worker::config::WorkerConfig;
use crate::worker::supabase::{AuditAdvice, SupabaseClient};
const TICK_INTERVAL_SECS: u64 = 30;
const HALF_HOUR: Duration = Duration::from_secs(30 * 60);
const NEW_ADVICE_TRIGGER: usize = 5;
const MIN_AGE_SECS: i64 = 5 * 60; const MAX_PER_CYCLE: usize = 30;
pub fn run(cfg: WorkerConfig) -> Result<()> {
let supabase = match make_supabase(&cfg) {
Some(s) => s,
None => {
log_line("[audit] Supabase not configured — thread idle");
return Ok(());
}
};
let claude = ClaudeCli::new(cfg.claude_bin.clone());
log_line("[audit] thread up");
let mut last_run = Instant::now() - HALF_HOUR;
let mut last_seen_count = 0usize;
let mut last_heartbeat = Instant::now() - Duration::from_secs(60);
loop {
std::thread::sleep(Duration::from_secs(TICK_INTERVAL_SECS));
if last_heartbeat.elapsed() >= Duration::from_secs(30) {
let _ = supabase.heartbeat("audit");
last_heartbeat = Instant::now();
}
let pending = match supabase.list_pending_actionable_advice(MIN_AGE_SECS, MAX_PER_CYCLE) {
Ok(p) => p,
Err(e) => {
log_line(&format!("[audit] count err: {}", e));
continue;
}
};
let new_since_last = pending.len().saturating_sub(last_seen_count);
let elapsed = last_run.elapsed();
let hourly_due = elapsed >= HALF_HOUR;
let accumulation_due = new_since_last >= NEW_ADVICE_TRIGGER;
if (!hourly_due && !accumulation_due) || pending.is_empty() {
continue;
}
let trigger = if hourly_due { "30min" } else { "accumulation" };
log_line(&format!(
"[audit] {} trigger ({} pending older than {}s)",
trigger,
pending.len(),
MIN_AGE_SECS
));
match run_once(&supabase, &claude, &cfg.advice_locale, &pending) {
Ok(summary) => log_line(&format!("[audit] {}", summary)),
Err(e) => log_line(&format!("[audit] cycle err: {}", e)),
}
last_run = Instant::now();
last_seen_count = supabase
.list_pending_actionable_advice(MIN_AGE_SECS, MAX_PER_CYCLE)
.map(|p| p.len())
.unwrap_or(0);
}
}
pub fn run_task(cfg: &WorkerConfig) -> Result<String> {
let supabase = make_supabase(cfg)
.ok_or_else(|| anyhow!("Supabase not configured (supabase_url/supabase_key)"))?;
let claude = ClaudeCli::new(cfg.claude_bin.clone());
let pending = supabase.list_pending_actionable_advice(MIN_AGE_SECS, MAX_PER_CYCLE)?;
if pending.is_empty() {
return Ok("nothing to audit (no pending actionable advice older than 5min)".into());
}
run_once(&supabase, &claude, &cfg.advice_locale, &pending)
}
fn make_supabase(cfg: &WorkerConfig) -> Option<SupabaseClient> {
let (url, key) = match (&cfg.supabase_url, &cfg.supabase_key) {
(Some(u), Some(k)) if !u.is_empty() && !k.is_empty() => (u.as_str(), k.as_str()),
_ => return None,
};
let client_id = cfg
.client_id
.as_deref()
.filter(|s| !s.is_empty())
.unwrap_or("unknown");
SupabaseClient::new(url, key, client_id).ok()
}
fn run_once(
supabase: &SupabaseClient,
claude: &ClaudeCli,
locale: &str,
items: &[AuditAdvice],
) -> Result<String> {
let mut memory_lines = String::new();
if let Ok(mems) = supabase.list_memories(&["constraint", "strong"], None, None, &[]) {
for m in mems.iter().take(40) {
let proj = m.project.as_deref().unwrap_or("-");
memory_lines.push_str(&format!("- [{}/{}] {}\n", m.priority, proj, m.text));
}
}
if memory_lines.is_empty() {
memory_lines.push_str("(no strong memories registered)\n");
}
let prompt = build_prompt(items, &memory_lines, locale);
let raw = claude
.ask_json(&prompt, Duration::from_secs(120))
.context("claude audit call")?;
let verdicts = raw
.get("verdicts")
.and_then(|v| v.as_array())
.ok_or_else(|| anyhow!("no verdicts[] in response"))?;
let mut kept = 0usize;
let mut confirmed = 0usize;
let mut errors = 0usize;
for v in verdicts {
let id = match v.get("id").and_then(|x| x.as_i64()) {
Some(id) => id,
None => {
errors += 1;
continue;
}
};
let action = v.get("action").and_then(|x| x.as_str()).unwrap_or("keep");
match action {
"keep" => kept += 1,
"confirm" => {
if let Err(e) = supabase.ack_event(id, "audit") {
log_line(&format!("[audit] confirm #{} err: {}", id, e));
errors += 1;
} else {
confirmed += 1;
if let Some(reason) = v.get("reason").and_then(|x| x.as_str()) {
log_line(&format!("[audit] confirmed #{}: {}", id, reason));
}
}
}
_ => kept += 1,
}
}
Ok(format!(
"{} reviewed → keep {}, confirm(noise) {} (errors {})",
items.len(),
kept,
confirmed,
errors,
))
}
fn build_prompt(items: &[AuditAdvice], memory_lines: &str, locale: &str) -> String {
let mut listing = String::new();
for it in items {
listing.push_str(&format!(
"#{} [{}] project={}\n paths: {}\n {}\n\n",
it.id,
it.severity,
it.project,
it.paths.join(", "),
it.text
));
}
format!(
r#"You are the audit pass over the Devist advice inbox.
Each item below is an unconfirmed piece of advice older than {min_age}s.
Decide for each: was it valuable enough to raise, given the
current strong/constraint user memories? Auto-confirm the noise.
============================================================
USER STRONG / CONSTRAINT MEMORIES
============================================================
{memory_lines}
============================================================
THE BAR — what counts as valuable advice
============================================================
A piece of advice is valuable IFF acting on it would meaningfully
improve the code. The user already sees the diff; they don't need it
narrated. Apply the same MUST / MUST NOT used at generation time:
MUST (keep):
1. Concrete bug / risk with a specific file location + line/symbol
2. Code-doc or code-code drift with both sides cited
3. Cross-cutting invariant violated (security, RLS, secrets)
4. Concrete refactor opportunity with a clear, measurable win
MUST NOT (confirm as noise):
- Anything about temp / swap / build artifacts
(*.tmp.*, *~, 4913, *.timestamp-*.mjs, tsbuildinfo, SMOKE_*.md)
- ".gitignore should include X" — noise unless X is secret-bearing
- "Consider adding tests" — generic, not a concrete invariant gap
- "Documentation might be stale" — only valid with both sides cited
- Restating what the user just changed
- Anything contradicting a strong/constraint memory above
(those are explicit user decisions; don't fight them)
- Duplicates / near-duplicates of other items in the same batch
(keep the most specific one, confirm the rest)
============================================================
INPUT — pending advice
============================================================
{listing}
============================================================
OUTPUT — STRICT JSON, no markdown fences
============================================================
{{
"verdicts": [
{{"id": <int>, "action": "keep|confirm", "reason": "<short, in {locale}>"}}
]
}}
Every item in the input MUST appear exactly once in `verdicts`.
`reason` is required for `action=confirm` (briefly explain which MUST NOT
category it violated, or which memory it duplicates). `reason` is
optional for `action=keep`.
"#,
min_age = MIN_AGE_SECS,
memory_lines = memory_lines,
listing = listing,
locale = locale,
)
}
fn log_line(msg: &str) {
let now = Local::now().format("%Y-%m-%d %H:%M:%S");
println!("{} {}", now, msg);
}