master
John Lauer 1.3.10: newbie panel (say strip + LEDs + getting started) is one closable block with a Run-a-demo-fetch button; empty libraries auto-seed 3 sample wiki bundles; detail pane auto-selects a sample; cf sweep (README, page title, sites pack); sites skills get Parent-skill lines + rich triggers; dashboard-flow playbook is pup-always cae1bf5 1mo ago
//! Background jobs with a live console, for the settings modal's Install /
//! Update rows (settings-layout: "silent multi-minute buttons are forbidden").
//! `POST /api/jobs {action}` starts one; `GET /api/jobs/<id>` streams its lines.

use serde_json::{json, Value};
use std::collections::HashMap;
use std::io::{BufRead, BufReader};
use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex};

pub struct Job {
    pub id: u64,
    pub action: String,
    pub state: String, // running | done | failed
    pub exit_code: Option<i32>,
    pub lines: Vec<String>,
    pub started: u64,
    pub ended: Option<u64>,
}

static JOBS: Mutex<Option<HashMap<u64, Arc<Mutex<Job>>>>> = Mutex::new(None);
static NEXT_ID: Mutex<u64> = Mutex::new(1);

fn now() -> u64 {
    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}

fn registry() -> std::sync::MutexGuard<'static, Option<HashMap<u64, Arc<Mutex<Job>>>>> {
    let mut g = JOBS.lock().unwrap();
    if g.is_none() {
        *g = Some(HashMap::new());
    }
    g
}

/// The shell command for an action, or None when the action is unknown.
pub fn command_for(action: &str, body: &Value) -> Option<(String, Vec<String>)> {
    let sh = |script: &str| Some(("sh".to_string(), vec!["-c".to_string(), format!("export PATH=\"$HOME/.local/bin:$PATH\"; {script}")]));
    match action {
        "install_tools" => sh("adom-chip-fetcher doctor --fix 2>&1"),
        "install_skill" => sh("adom-chip-fetcher install 2>&1"),
        "refresh_status" => sh("adom-chip-fetcher doctor 2>&1; true"),
        "demo_fetch" => sh("adom-chip-fetcher demo-fetch 2>&1"),
        "import_logins" => {
            let hosts = body
                .get("hostFilter")
                .cloned()
                .unwrap_or_else(|| json!(crate::vault::DEFAULT_VENDOR_HOSTS));
            let args = json!({"hostFilter": hosts}).to_string();
            let thread = crate::bridge::ai_thread();
            let script = format!(
                "echo 'Scanning the browser profiles on your desktop (read only)...'; \
                 adom-bridge --ai-thread {t} pup_import_scan '{{}}' 2>&1; echo; \
                 echo 'Importing vendor logins into pup'\"'\"'s OS-keychain vault. Approve the Adom Bridge consent dialog and the Windows UAC prompt on your desktop.'; \
                 adom-bridge --ai-thread {t} pup_import_browser_logins '{a}' 2>&1",
                t = shell_quote(&thread),
                a = args.replace('\'', "'\\''")
            );
            sh(&script)
        }
        _ => None,
    }
}

fn shell_quote(s: &str) -> String {
    format!("'{}'", s.replace('\'', "'\\''"))
}

pub fn start(action: &str, body: &Value) -> Result<u64, String> {
    let (prog, args) = command_for(action, body).ok_or_else(|| format!("unknown job action '{action}'"))?;
    let id = {
        let mut n = NEXT_ID.lock().unwrap();
        let id = *n;
        *n += 1;
        id
    };
    let job = Arc::new(Mutex::new(Job {
        id,
        action: action.to_string(),
        state: "running".into(),
        exit_code: None,
        lines: vec![format!("$ {}", args.last().cloned().unwrap_or_default())],
        started: now(),
        ended: None,
    }));
    registry().as_mut().unwrap().insert(id, job.clone());
    crate::dock::console_push("info", &format!("job {id} {action} started"), "jobs");
    std::thread::spawn(move || {
        let child = Command::new(&prog).args(&args).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn();
        let mut child = match child {
            Ok(c) => c,
            Err(e) => {
                let mut j = job.lock().unwrap();
                j.lines.push(format!("ERROR: could not start: {e}"));
                j.state = "failed".into();
                j.ended = Some(now());
                return;
            }
        };
        let stdout = child.stdout.take();
        let stderr = child.stderr.take();
        let j2 = job.clone();
        let t_err = std::thread::spawn(move || {
            if let Some(e) = stderr {
                for line in BufReader::new(e).lines().map_while(Result::ok) {
                    let mut j = j2.lock().unwrap();
                    j.lines.push(line);
                    if j.lines.len() > 2000 {
                        j.lines.remove(0);
                    }
                }
            }
        });
        if let Some(o) = stdout {
            for line in BufReader::new(o).lines().map_while(Result::ok) {
                let mut j = job.lock().unwrap();
                j.lines.push(line);
                if j.lines.len() > 2000 {
                    j.lines.remove(0);
                }
            }
        }
        let _ = t_err.join();
        let status = child.wait();
        let mut j = job.lock().unwrap();
        let code = status.ok().and_then(|s| s.code());
        j.exit_code = code;
        j.state = if code == Some(0) { "done".into() } else { "failed".into() };
        j.ended = Some(now());
        j.lines.push(format!("[exit {}]", code.map(|c| c.to_string()).unwrap_or_else(|| "?".into())));
        crate::dock::console_push("info", &format!("job {} {} {}", j.id, j.action, j.state), "jobs");
    });
    Ok(id)
}

pub fn get_json(id: u64) -> Option<String> {
    let reg = registry();
    let job = reg.as_ref().unwrap().get(&id)?.clone();
    let j = job.lock().unwrap();
    Some(
        json!({
            "ok": true, "id": j.id, "action": j.action, "state": j.state, "exit_code": j.exit_code,
            "lines": j.lines, "started": j.started, "ended": j.ended,
        })
        .to_string(),
    )
}

pub fn list_json() -> String {
    let reg = registry();
    let items: Vec<Value> = reg
        .as_ref()
        .unwrap()
        .values()
        .map(|j| {
            let j = j.lock().unwrap();
            json!({"id": j.id, "action": j.action, "state": j.state, "started": j.started, "ended": j.ended})
        })
        .collect();
    json!({"ok": true, "jobs": items}).to_string()
}