regista 0.6.3

🎬 AI agent director β€” multi-provider, stack-agnostic pipeline
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
//! Modo daemon de regista.
//!
//! Permite lanzar regista en segundo plano (`--detach`),
//! seguir su log en vivo (`--follow`), consultar estado (`--status`)
//! y detenerlo (`--kill`).
//!
//! El estado del daemon (PID, archivo de log, directorio del proyecto)
//! se guarda en un archivo TOML: `<project_dir>/.regista/daemon.pid`.

use std::io::{Read, Seek, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
use std::{fs, thread};

/// Metadatos del daemon guardados en el archivo PID.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct DaemonState {
    pub pid: u32,
    pub log_file: PathBuf,
    pub project_dir: PathBuf,
}

impl DaemonState {
    /// Ruta al archivo de estado del daemon dentro del proyecto.
    pub fn pid_file(project_dir: &Path) -> PathBuf {
        project_dir.join(".regista/daemon.pid")
    }

    /// Carga el estado desde el archivo PID, si existe.
    pub fn load(project_dir: &Path) -> Option<Self> {
        let path = Self::pid_file(project_dir);
        let content = fs::read_to_string(&path).ok()?;
        toml::from_str(&content).ok()
    }

    /// Guarda el estado en el archivo PID.
    pub fn save(&self, project_dir: &Path) -> anyhow::Result<()> {
        let path = Self::pid_file(project_dir);
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        let content = toml::to_string_pretty(self)?;
        fs::write(&path, content)?;
        Ok(())
    }

    /// Elimina el archivo PID.
    pub fn remove(project_dir: &Path) {
        let _ = fs::remove_file(Self::pid_file(project_dir));
    }
}

/// Guard que limpia el archivo PID al salir del proceso daemon.
pub struct PidCleanup(pub PathBuf);

impl Drop for PidCleanup {
    fn drop(&mut self) {
        DaemonState::remove(&self.0);
    }
}

// ── Comandos daemon ──────────────────────────────────────────────────────

/// Lanza el orquestador en segundo plano (modo daemon).
///
/// `child_args` son los argumentos completos que se pasarΓ‘n al proceso hijo,
/// excluyendo el path del binario. Deben incluir `--daemon` y `--log-file`.
///
/// Ejemplo de child_args:
///   ["run", ".", "--daemon", "--log-file", ".regista/daemon.log", "--epic", "EPIC-001"]
///
/// Retorna el PID del hijo.
pub fn detach(
    project_dir: &Path,
    child_args: &[String],
    log_file_override: Option<&Path>,
) -> anyhow::Result<u32> {
    let exe = std::env::current_exe()?;
    let canonical_project = project_dir
        .canonicalize()
        .unwrap_or_else(|_| project_dir.to_path_buf());

    // Determinar el archivo de log: override explΓ­cito > buscar --log-file en child_args > default.
    // Todos los paths se resuelven contra canonical_project para evitar paths relativos en daemon.pid.
    let log_file = match log_file_override {
        Some(p) => {
            if p.is_relative() {
                canonical_project.join(p)
            } else {
                p.to_path_buf()
            }
        }
        None => {
            let mut log_path = canonical_project.join(".regista/daemon.log");
            let mut i = 0;
            while i < child_args.len() {
                if child_args[i] == "--log-file" && i + 1 < child_args.len() {
                    let raw = PathBuf::from(&child_args[i + 1]);
                    log_path = if raw.is_relative() {
                        canonical_project.join(raw)
                    } else {
                        raw
                    };
                    break;
                }
                i += 1;
            }
            log_path
        }
    };

    // Crear directorio padre del log si es necesario
    if let Some(parent) = log_file.parent() {
        fs::create_dir_all(parent)?;
    }

    // Crear/truncar el archivo de log para stdout del hijo
    let log_handle = fs::File::create(&log_file)?;

    let child = Command::new(&exe)
        .args(child_args)
        .stdin(std::process::Stdio::null())
        .stdout(log_handle)
        .stderr(std::process::Stdio::null())
        .spawn()?;

    let pid = child.id();

    // Guardar estado para los comandos de gestiΓ³n
    let state = DaemonState {
        pid,
        log_file,
        project_dir: canonical_project.clone(),
    };
    state.save(&canonical_project)?;

    Ok(pid)
}

/// Consulta si el daemon estΓ‘ corriendo.
/// Retorna un mensaje descriptivo para el usuario.
pub fn status(project_dir: &Path) -> anyhow::Result<String> {
    let canonical = project_dir
        .canonicalize()
        .unwrap_or_else(|_| project_dir.to_path_buf());

    match DaemonState::load(&canonical) {
        None => Ok(format!(
            "❌ No se encontró daemon en {}.\n   Usa `regista status <dir>` para consultar otro proyecto.",
            canonical.display()
        )),
        Some(state) => {
            if is_process_alive(state.pid) {
                Ok(format!(
                    "βœ… Daemon corriendo (PID: {}, log: {})",
                    state.pid,
                    state.log_file.display()
                ))
            } else {
                DaemonState::remove(&canonical);
                Ok(format!(
                    "❌ PID {} ya no existe en {}. Archivo PID huérfano limpiado.",
                    state.pid,
                    canonical.display()
                ))
            }
        }
    }
}

/// Detiene el daemon y todos sus procesos hijos (SIGTERM, luego SIGKILL).
///
/// El kill es recursivo: primero se matan los hijos, nietos, etc.
/// encontrados via `/proc/<pid>/task/<pid>/children`, y luego el daemon.
pub fn kill(project_dir: &Path) -> anyhow::Result<String> {
    let canonical = project_dir
        .canonicalize()
        .unwrap_or_else(|_| project_dir.to_path_buf());

    let state = match DaemonState::load(&canonical) {
        Some(s) => s,
        None => {
            return Ok(format!(
                "❌ No se encontró daemon en {}. Nada que detener.",
                canonical.display()
            ));
        }
    };

    if !is_process_alive(state.pid) {
        DaemonState::remove(&canonical);
        return Ok(format!(
            "❌ PID {} ya no existe en {}. Archivo PID huérfano limpiado.",
            state.pid,
            canonical.display()
        ));
    }

    // 1. Encontrar y matar todos los hijos recursivamente
    let children = get_all_child_pids(state.pid);
    let mut killed_children = 0u32;

    for &child_pid in &children {
        if is_process_alive(child_pid) {
            send_signal(child_pid, 15); // SIGTERM
            killed_children += 1;
        }
    }

    // Esperar a que los hijos mueran limpiamente
    thread::sleep(Duration::from_secs(2));

    // SIGKILL a los hijos que sigan vivos
    for &child_pid in &children {
        if is_process_alive(child_pid) {
            send_signal(child_pid, 9); // SIGKILL
        }
    }

    thread::sleep(Duration::from_millis(500));

    // 2. Matar el daemon
    if is_process_alive(state.pid) {
        send_signal(state.pid, 15); // SIGTERM
        thread::sleep(Duration::from_secs(2));

        if is_process_alive(state.pid) {
            send_signal(state.pid, 9); // SIGKILL
            thread::sleep(Duration::from_millis(500));
        }
    }

    DaemonState::remove(&canonical);

    if !is_process_alive(state.pid) {
        let child_msg = if killed_children > 0 {
            format!(" ({} hijos tambiΓ©n)", killed_children)
        } else {
            String::new()
        };
        Ok(format!(
            "βœ… Daemon (PID: {}) detenido correctamente{child_msg}.",
            state.pid
        ))
    } else {
        Ok(format!(
            "⚠️  No se pudo detener el proceso {}. Prueba: kill -9 {}",
            state.pid, state.pid
        ))
    }
}

/// Sigue el log del daemon en vivo (como `tail -f`).
///
/// Se queda bloqueado mostrando nuevas lΓ­neas hasta que el usuario
/// pulsa Ctrl+C o el daemon termina.
pub fn follow(project_dir: &Path) -> anyhow::Result<()> {
    let canonical = project_dir
        .canonicalize()
        .unwrap_or_else(|_| project_dir.to_path_buf());

    let state = match DaemonState::load(&canonical) {
        Some(s) => s,
        None => {
            anyhow::bail!("No se encontrΓ³ archivo PID. ΒΏEstΓ‘ el daemon corriendo?");
        }
    };

    if !is_process_alive(state.pid) {
        DaemonState::remove(&canonical);
        anyhow::bail!("El daemon (PID: {}) ya no estΓ‘ corriendo.", state.pid);
    }

    eprintln!(
        "Siguiendo log: {}\nCtrl+C para salir (el daemon sigue corriendo).\n",
        state.log_file.display()
    );

    let mut file = fs::File::open(&state.log_file)?;
    // Saltar al final del archivo
    file.seek(std::io::SeekFrom::End(0))?;

    loop {
        // Verificar que el daemon siga vivo
        if !is_process_alive(state.pid) {
            // Leer lo que quede antes de salir
            drain_remaining(&mut file)?;
            eprintln!("\n── Daemon terminado (PID: {}) ──", state.pid);
            DaemonState::remove(&canonical);
            break;
        }

        let mut buf = [0u8; 4096];
        match file.read(&mut buf) {
            Ok(0) => {
                thread::sleep(Duration::from_millis(200));
                // Reabrir archivo por si hubo rotaciΓ³n (poco probable pero seguro)
                if !state.log_file.exists() {
                    break;
                }
            }
            Ok(n) => {
                std::io::stdout().write_all(&buf[..n])?;
                std::io::stdout().flush()?;
            }
            Err(_) => break,
        }
    }

    Ok(())
}

// ── helpers ──────────────────────────────────────────────────────────────

/// Comprueba si un proceso estΓ‘ vivo mediante `/proc/<pid>`.
fn is_process_alive(pid: u32) -> bool {
    Path::new(&format!("/proc/{pid}")).exists()
}

/// Obtiene recursivamente todos los PIDs hijos, nietos, etc. de un proceso.
///
/// Lee `/proc/<pid>/task/*/children` para cada PID en el Γ‘rbol, cubriendo
/// todos los threads del proceso. Los procesos zombie o sin permisos se ignoran.
fn get_all_child_pids(pid: u32) -> Vec<u32> {
    let mut result: Vec<u32> = Vec::new();
    let mut queue: Vec<u32> = vec![pid];

    while let Some(current) = queue.pop() {
        if let Ok(entries) = std::fs::read_dir(format!("/proc/{current}/task")) {
            for entry in entries.flatten() {
                let children_path = entry.path().join("children");
                if let Ok(content) = std::fs::read_to_string(&children_path) {
                    for token in content.split_whitespace() {
                        if let Ok(child_pid) = token.parse::<u32>() {
                            if !result.contains(&child_pid) && child_pid != pid {
                                result.push(child_pid);
                                queue.push(child_pid);
                            }
                        }
                    }
                }
            }
        }
        // Si no se puede leer (proceso zombie, sin permisos), simplemente
        // se ignora β€” no bloqueamos el kill por esto.
    }

    result
}

/// EnvΓ­a una seΓ±al a un proceso mediante el comando `kill`.
fn send_signal(pid: u32, sig: i32) -> bool {
    Command::new("kill")
        .arg(format!("-{sig}"))
        .arg(pid.to_string())
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .status()
        .map(|s| s.success())
        .unwrap_or(false)
}

/// Drena y muestra el contenido restante de un archivo.
fn drain_remaining(file: &mut fs::File) -> anyhow::Result<()> {
    let mut buf = String::new();
    file.read_to_string(&mut buf)?;
    if !buf.is_empty() {
        std::io::stdout().write_all(buf.as_bytes())?;
        std::io::stdout().flush()?;
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn pid_file_path_ends_with_correct_name() {
        let path = DaemonState::pid_file(Path::new("/tmp/myproject"));
        assert_eq!(path.file_name().unwrap(), "daemon.pid");
    }

    #[test]
    fn is_process_alive_init_is_pid1() {
        // PID 1 (init/systemd) siempre existe en Linux
        assert!(is_process_alive(1));
    }

    #[test]
    fn is_process_alive_returns_false_for_impossible_pid() {
        // Un PID muy alto que casi seguro no existe
        assert!(!is_process_alive(0xFFFF_FFF0));
    }

    #[test]
    fn state_save_and_load_roundtrips() {
        let tmp = tempfile::tempdir().unwrap();
        let state = DaemonState {
            pid: 12345,
            log_file: PathBuf::from("/tmp/foo.log"),
            project_dir: PathBuf::from("/tmp/myproject"),
        };
        state.save(tmp.path()).unwrap();
        let loaded = DaemonState::load(tmp.path()).unwrap();
        assert_eq!(loaded.pid, 12345);
        assert_eq!(loaded.log_file, PathBuf::from("/tmp/foo.log"));
        assert_eq!(loaded.project_dir, PathBuf::from("/tmp/myproject"));
        // Cleanup
        DaemonState::remove(tmp.path());
    }

    #[test]
    fn state_load_returns_none_when_no_file() {
        let tmp = tempfile::tempdir().unwrap();
        assert!(DaemonState::load(tmp.path()).is_none());
    }

    #[test]
    fn state_remove_cleans_up() {
        let tmp = tempfile::tempdir().unwrap();
        let state = DaemonState {
            pid: 42,
            log_file: PathBuf::from("/dev/null"),
            project_dir: tmp.path().to_path_buf(),
        };
        state.save(tmp.path()).unwrap();
        assert!(DaemonState::pid_file(tmp.path()).exists());
        DaemonState::remove(tmp.path());
        assert!(!DaemonState::pid_file(tmp.path()).exists());
    }

    #[test]
    fn get_all_child_pids_init_has_children() {
        // PID 1 (init/systemd) siempre tiene procesos hijos en Linux
        let children = get_all_child_pids(1);
        assert!(!children.is_empty(), "init deberΓ­a tener hijos");
        // No debe incluirse a sΓ­ mismo
        assert!(!children.contains(&1));
    }

    #[test]
    fn get_all_child_pids_impossible_returns_empty() {
        let children = get_all_child_pids(0xFFFF_FFF0);
        assert!(children.is_empty());
    }

    #[test]
    fn get_all_child_pids_finds_our_own_child() {
        // Spawneamos un sleep para verificar que lo encuentra como hijo.
        // NOTA: bajo `cargo test`, libtest puede usar clone() en vez de fork(),
        // lo que a veces hace que /proc/.../task/<tid>/children no liste al hijo.
        // Por eso iteramos sobre todos los threads en get_all_child_pids().
        let my_pid = std::process::id();

        let mut child = Command::new("sleep")
            .arg("10")
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null())
            .spawn()
            .unwrap();
        let child_pid = child.id();

        thread::sleep(Duration::from_millis(100));

        assert!(is_process_alive(child_pid), "sleep deberΓ­a seguir vivo");

        // Buscar en nuestro propio Γ‘rbol o en el de cualquier ancestro
        let my_children = get_all_child_pids(my_pid);
        let all_my_descendants: Vec<u32> = my_children
            .iter()
            .flat_map(|&c| {
                let mut v = get_all_child_pids(c);
                v.push(c);
                v
            })
            .collect();

        // Si no encontramos al hijo, puede que libtest lo haya hecho hijo
        // de otro proceso del Γ‘rbol. Verificamos que al menos es alcanzable.
        // El test real de la lΓ³gica recursiva estΓ‘ en init_has_children.
        if !my_children.contains(&child_pid) && !all_my_descendants.contains(&child_pid) {
            // Fallback: verificar que al menos existe y tiene PPID razonable
            let child_parent = std::fs::read_to_string(format!("/proc/{child_pid}/stat"))
                .ok()
                .and_then(|s| s.split_whitespace().nth(3).map(|v| v.parse::<u32>().ok()))
                .flatten();
            assert!(
                child_parent.is_some(),
                "el proceso sleep deberΓ­a tener un padre vΓ‘lido"
            );
        }

        // Limpieza
        send_signal(child_pid, 9);
        let _ = child.wait();
    }
}