#![allow(dead_code)]
use anyhow::{anyhow, Context, Result};
use chrono::Local;
use serde_json::Value;
use std::thread;
use std::time::Duration;
use crate::worker::claude::ClaudeCli;
use crate::worker::config::WorkerConfig;
use crate::worker::supabase::{JobRow, SupabaseClient};
const POLL_INTERVAL_SECS: u64 = 5;
const CLAUDE_TIMEOUT_SECS: u64 = 90;
pub fn run(cfg: WorkerConfig) -> Result<()> {
let client_id = cfg
.client_id
.clone()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "unknown".into());
let (url, key) = match (&cfg.supabase_url, &cfg.supabase_key) {
(Some(u), Some(k)) if !u.is_empty() && !k.is_empty() => (u.clone(), k.clone()),
_ => {
log_line("[jobs-worker] disabled (Supabase not configured)");
return Ok(());
}
};
let supabase = SupabaseClient::new(&url, &key, &client_id)?;
let claude = ClaudeCli::new(&cfg.claude_bin);
log_line("[jobs-worker] thread up");
loop {
let _ = supabase.heartbeat("jobs");
match supabase.claim_next_pending_job() {
Ok(Some(job)) => {
log_line(&format!(
"[jobs-worker] claimed job #{} kind={} scope={}",
job.id, job.kind, job.scope
));
let fallback_locale = supabase
.get_user_locale()
.unwrap_or_else(|| cfg.advice_locale.clone());
let outcome = match job.kind.as_str() {
"generate_rules" => handle_generate_rules(&job, &claude, &fallback_locale),
other => Err(anyhow!("Unknown job kind: {}", other)),
};
match outcome {
Ok(output) => match supabase.complete_job(job.id, output) {
Ok(()) => log_line(&format!("[jobs-worker] done #{}", job.id)),
Err(e) => {
log_line(&format!("[jobs-worker] complete err #{}: {}", job.id, e))
}
},
Err(e) => {
let msg = e.to_string();
log_line(&format!("[jobs-worker] fail #{}: {}", job.id, msg));
let _ = supabase.fail_job(job.id, &msg);
}
}
}
Ok(None) => {
}
Err(e) => log_line(&format!("[jobs-worker] poll err: {}", e)),
}
thread::sleep(Duration::from_secs(POLL_INTERVAL_SECS));
}
}
fn handle_generate_rules(job: &JobRow, claude: &ClaudeCli, default_locale: &str) -> Result<Value> {
let intent = job
.input
.get("intent")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string();
let current = job
.input
.get("current")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let locale = job
.input
.get("locale")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.unwrap_or(default_locale)
.to_string();
if intent.is_empty() {
return Err(anyhow!("intent is empty"));
}
let prompt = build_prompt(&job.scope, &intent, ¤t, &locale);
let result = claude
.ask_json(&prompt, Duration::from_secs(CLAUDE_TIMEOUT_SECS))
.context("claude generate_rules")?;
if result.get("draft").and_then(|v| v.as_str()).is_none() {
return Err(anyhow!("Claude response missing 'draft' field"));
}
Ok(result)
}
fn build_prompt(scope: &str, intent: &str, current: &str, locale: &str) -> String {
format!(
r#"You are generating Markdown content for a `rules.md` file used by the
devist worker. The contents will be injected verbatim into the prompt
the worker sends to Claude when generating advice on code changes.
The "draft" field MUST be written in language code `{locale}`. The
"explain" field MUST also be written in `{locale}`. JSON keys stay English.
Scope: {scope}
(scope is "global" — applies to all projects — or "project:<name>".)
User intent (natural language description of what they want the rules to enforce):
---
{intent}
---
Current rules.md content for this scope (may be empty):
---
{current}
---
Output STRICT JSON, no markdown fences, no commentary outside the JSON. Schema:
{{
"draft": "<the new full rules.md content as a single markdown string>",
"explain": "<one or two sentences describing what changed and why>"
}}
Guidelines:
- Be concise. The rules go into every advice prompt — token cost matters.
- Use sections like `## Tone`, `## Focus`, `## Skip` when natural.
- Preserve the user's intent literally where they were specific.
- If `current` had relevant content, MERGE rather than replace — don't drop existing rules unless the intent contradicts them.
- Markdown only inside `draft`. No HTML, no code fences inside the markdown unless the user asked for examples.
"#
)
}
fn log_line(msg: &str) {
let now = Local::now().format("%Y-%m-%d %H:%M:%S");
println!("{} {}", now, msg);
}