927437a8d8
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>
173 lines
5.3 KiB
Go
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)
|
|
}
|