#![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 outcome = match job.kind.as_str() {
"generate_rules" => handle_generate_rules(&job, &claude),
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) -> 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();
if intent.is_empty() {
return Err(anyhow!("intent is empty"));
}
let prompt = build_prompt(&job.scope, &intent, ¤t);
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) -> 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.
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.
- If the intent is in Korean, write the rules in Korean too.
- 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);
}