devist 0.11.0

Project bootstrap CLI for AI-assisted development. Spin up new projects from templates, manage backends, and keep your codebase comprehensible.
#![allow(dead_code)]
// Worker_jobs queue processor.
//
// Polls the `worker_jobs` table every POLL_INTERVAL for pending rows,
// claims them via `UPDATE WHERE id=X AND status='pending'`, dispatches
// to the appropriate handler, and writes the result back.
//
// Currently handles `kind = 'generate_rules'` only.

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) => {
                // no work
            }
            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, &current);
    let result = claude
        .ask_json(&prompt, Duration::from_secs(CLAUDE_TIMEOUT_SECS))
        .context("claude generate_rules")?;

    // Validate shape
    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);
}