Files
fn_registry/functions/infra/resumable_claude_test.go
T
egutierrez 927437a8d8 feat(infra): grupo claude-fleet — FleetView TUI + orquestacion de Claudes
Sistema FleetView para centralizar la flota de procesos Claude Code vivos en una
sola ventana kitty + tmux (socket aislado -L fleet) con un panel TUI:

- list_claude_fleet (+ tipo claude_fleet): escanea ~/.claude/sessions + goals +
  runtime, valida procesos vivos (anti-PID-reciclado), join por sessionId.
- list_resumable_claudes (+ tipo resumable_claude): sesiones cerradas reanudables.
- wrappers tmux: tmux_new_claude_window (con --resume), tmux_swap_window_into_console
  (preserva ancho del sidebar), tmux_map_claude_panes.
- launch_kittyclaude: comando entrypoint; instala atajos alt+flechas/enter/n/0/k/r,
  mouse on, remain-on-exit off; fija el ancho del sidebar con hooks.
- docs/capabilities/claude-fleet.md + entrada en el INDEX.

Incluye ademas funciones datascience en progreso (excel/duckdb/postgres) y ajustes
varios de docs e infra de otra sesion, agrupados aqui para no perderlos.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 00:04:41 +02:00

173 lines
5.3 KiB
Go

//go:build !windows && linux
package infra
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
)
// writeJSON marshals v and writes it to path, failing the test on error.
func writeJSON(t *testing.T, path string, v any) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir %q: %v", filepath.Dir(path), err)
}
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if err := os.WriteFile(path, b, 0o644); err != nil {
t.Fatalf("write %q: %v", path, err)
}
}
// touch sets the mtime of path to the given unix epoch seconds.
func touch(t *testing.T, path string, epoch int64) {
t.Helper()
mt := time.Unix(epoch, 0)
if err := os.Chtimes(path, mt, mt); err != nil {
t.Fatalf("chtimes %q: %v", path, err)
}
}
func TestListResumableClaudesFrom(t *testing.T) {
t.Run("excluye sesion viva, incluye muertas con goal ordenadas por LastActive", func(t *testing.T) {
dir := t.TempDir()
sessionsDir := filepath.Join(dir, "sessions")
goalsDir := filepath.Join(dir, "goals")
// A LIVE session: real running PID (this test process) + its real
// /proc starttime as procStart, so procIsAlive returns true.
livePID := os.Getpid()
liveStart, ok := procStartTime(livePID)
if !ok {
t.Fatalf("could not read procStartTime for self pid %d", livePID)
}
const liveSession = "11111111-aaaa-bbbb-cccc-000000000001"
writeJSON(t, filepath.Join(sessionsDir, "9001.json"), sessionFile{
PID: livePID,
SessionID: liveSession,
Cwd: "/tmp/live",
ProcStart: liveStart,
Status: "busy",
})
// A goal for the live session: must be EXCLUDED (already in fleet).
liveGoal := filepath.Join(goalsDir, liveSession+".json")
writeJSON(t, liveGoal, goalFile{Goal: "trabajo en curso", Emojis: "🔥", Rename: "vivo"})
touch(t, liveGoal, 5000)
// A DEAD session with a goal: must be INCLUDED. No sessions/ entry,
// so it can never be live.
const deadOld = "22222222-aaaa-bbbb-cccc-000000000002"
oldGoal := filepath.Join(goalsDir, deadOld+".json")
writeJSON(t, oldGoal, goalFile{Goal: "objetivo antiguo", Emojis: "🛠️", Rename: "viejo"})
touch(t, oldGoal, 1000)
// Another DEAD session with a goal, more recent: must come FIRST.
const deadNew = "33333333-aaaa-bbbb-cccc-000000000003"
newGoal := filepath.Join(goalsDir, deadNew+".json")
writeJSON(t, newGoal, goalFile{Goal: "objetivo reciente", Rename: "nuevo"})
touch(t, newGoal, 4000)
// A DEAD session WITHOUT a goal string: must be OMITTED.
const deadEmpty = "44444444-aaaa-bbbb-cccc-000000000004"
emptyGoal := filepath.Join(goalsDir, deadEmpty+".json")
writeJSON(t, emptyGoal, goalFile{Goal: " ", Emojis: "💤"})
touch(t, emptyGoal, 6000)
got, err := ListResumableClaudesFrom(dir)
if err != nil {
t.Fatalf("ListResumableClaudesFrom: %v", err)
}
if len(got) != 2 {
t.Fatalf("got %d resumable, want 2: %+v", len(got), got)
}
// Order by LastActive desc: deadNew (4000) before deadOld (1000).
if got[0].SessionID != deadNew {
t.Errorf("got[0].SessionID = %q, want %q", got[0].SessionID, deadNew)
}
if got[1].SessionID != deadOld {
t.Errorf("got[1].SessionID = %q, want %q", got[1].SessionID, deadOld)
}
// Live session must not appear.
for _, r := range got {
if r.SessionID == liveSession {
t.Errorf("live session %q must be excluded", liveSession)
}
if r.SessionID == deadEmpty {
t.Errorf("session without goal %q must be omitted", deadEmpty)
}
}
// Field mapping for the most-recent record.
if got[0].Goal != "objetivo reciente" {
t.Errorf("got[0].Goal = %q", got[0].Goal)
}
if got[0].Name != "nuevo" {
t.Errorf("got[0].Name = %q, want \"nuevo\"", got[0].Name)
}
if got[0].LastActive != 4000 {
t.Errorf("got[0].LastActive = %d, want 4000", got[0].LastActive)
}
if got[1].Emojis != "🛠️" {
t.Errorf("got[1].Emojis = %q", got[1].Emojis)
}
})
t.Run("dir de goals inexistente retorna slice vacio sin error", func(t *testing.T) {
dir := t.TempDir() // no goals/ subdir
got, err := ListResumableClaudesFrom(dir)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != 0 {
t.Errorf("got %d, want 0", len(got))
}
})
t.Run("cap a 40 resultados mas recientes", func(t *testing.T) {
dir := t.TempDir()
goalsDir := filepath.Join(dir, "goals")
// 50 dead sessions with goals, mtimes 1..50.
for i := 1; i <= 50; i++ {
id := uuidLike(i)
p := filepath.Join(goalsDir, id+".json")
writeJSON(t, p, goalFile{Goal: "objetivo", Rename: id})
touch(t, p, int64(i))
}
got, err := ListResumableClaudesFrom(dir)
if err != nil {
t.Fatalf("ListResumableClaudesFrom: %v", err)
}
if len(got) != 40 {
t.Fatalf("got %d, want 40 (capped)", len(got))
}
// Most recent first: LastActive should be 50 then descending.
if got[0].LastActive != 50 {
t.Errorf("got[0].LastActive = %d, want 50", got[0].LastActive)
}
if got[39].LastActive != 11 {
t.Errorf("got[39].LastActive = %d, want 11", got[39].LastActive)
}
})
}
// uuidLike builds a deterministic, unique filename stem for index i.
func uuidLike(i int) string {
const hex = "0123456789abcdef"
b := []byte("00000000-0000-0000-0000-000000000000")
// Fill the last 3 chars with i (i <= 50 fits in 2 hex digits, keep simple).
b[len(b)-1] = hex[i%16]
b[len(b)-2] = hex[(i/16)%16]
b[len(b)-3] = hex[(i/256)%16]
return string(b)
}