devist 0.17.2

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
                ));
                // Resolve fallback locale at claim time: user pref → config.
                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),
                    "apply_advice" => {
                        handle_apply_advice(&job, &claude, &cfg.monitor_dir, &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) => {
                // 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, 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, &current, &locale);
    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 handle_apply_advice(
    job: &JobRow,
    claude: &ClaudeCli,
    monitor_dir: &std::path::Path,
    locale: &str,
) -> Result<Value> {
    let project = job
        .input
        .get("project")
        .and_then(|v| v.as_str())
        .unwrap_or("")
        .to_string();
    let advice_text = job
        .input
        .get("advice_text")
        .and_then(|v| v.as_str())
        .unwrap_or("")
        .trim()
        .to_string();
    if project.is_empty() || advice_text.is_empty() {
        return Err(anyhow!("project and advice_text required"));
    }

    let workdir = monitor_dir.join(&project);
    if !workdir.exists() {
        return Err(anyhow!("project directory missing: {}", workdir.display()));
    }

    let prompt = format!(
        r#"You are a coding agent operating inside the directory of project "{project}".
Apply the following advice. Use file edits — read what you need, edit
what you must. Be surgical: only touch files clearly relevant to the
advice. Do not refactor unrelated code, do not add new tools, do not
change formatting beyond what the advice requires.

ADVICE TO APPLY:
---
{advice_text}
---

After making changes, output STRICT JSON only (no commentary, no markdown
fences). Schema:
{{
  "files_changed": ["<repo-relative path>", ...],
  "summary": "<one or two sentences in {locale} describing what you did>",
  "skipped": "<optional, in {locale}, why you didn't do something the advice said>"
}}

If the advice is unclear, ambiguous, or you couldn't safely apply it,
return files_changed: [] and explain in summary.
"#
    );

    let result = claude
        .ask_json_in_dir(&prompt, &workdir, std::time::Duration::from_secs(180))
        .context("claude apply_advice")?;
    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);
}