mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-16 22:59:04 +02:00
Main introduced executeAndDrain/mergeUsage refactor. Resolve by keeping main's refactored structure and re-applying EnvRoot to the switch/case in runTask. Rename newTestDaemon → newGCTestDaemon to avoid collision with the helper added in daemon_test.go on main. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
301 lines
8.7 KiB
Go
301 lines
8.7 KiB
Go
package daemon
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/multica-ai/multica/server/internal/daemon/execenv"
|
|
)
|
|
|
|
// newGCTestDaemon creates a minimal Daemon for GC testing with a mock HTTP server.
|
|
func newGCTestDaemon(t *testing.T, handler http.Handler) *Daemon {
|
|
t.Helper()
|
|
srv := httptest.NewServer(handler)
|
|
t.Cleanup(srv.Close)
|
|
|
|
root := t.TempDir()
|
|
cfg := Config{
|
|
WorkspacesRoot: root,
|
|
GCEnabled: true,
|
|
GCInterval: 1 * time.Hour,
|
|
GCTTL: 5 * 24 * time.Hour,
|
|
GCOrphanTTL: 30 * 24 * time.Hour,
|
|
}
|
|
d := New(cfg, slog.Default())
|
|
d.client = NewClient(srv.URL)
|
|
d.client.SetToken("test-token")
|
|
return d
|
|
}
|
|
|
|
// createTaskDir creates a task directory with optional GC metadata.
|
|
func createTaskDir(t *testing.T, root, wsID, dirName string, meta *execenv.GCMeta) string {
|
|
t.Helper()
|
|
taskDir := filepath.Join(root, wsID, dirName)
|
|
if err := os.MkdirAll(taskDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if meta != nil {
|
|
data, _ := json.Marshal(meta)
|
|
if err := os.WriteFile(filepath.Join(taskDir, ".gc_meta.json"), data, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
return taskDir
|
|
}
|
|
|
|
func TestShouldCleanTaskDir_DoneIssueOverTTL(t *testing.T) {
|
|
t.Parallel()
|
|
issueID := "11111111-1111-1111-1111-111111111111"
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc(fmt.Sprintf("/api/daemon/issues/%s/gc-check", issueID), func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"status": "done",
|
|
"updated_at": time.Now().Add(-10 * 24 * time.Hour), // 10 days ago
|
|
})
|
|
})
|
|
|
|
d := newGCTestDaemon(t, mux)
|
|
taskDir := createTaskDir(t, d.cfg.WorkspacesRoot, "ws1", "task1", &execenv.GCMeta{
|
|
IssueID: issueID,
|
|
WorkspaceID: "ws1",
|
|
CompletedAt: time.Now().Add(-10 * 24 * time.Hour),
|
|
})
|
|
|
|
action := d.shouldCleanTaskDir(context.Background(), taskDir)
|
|
if action != gcActionClean {
|
|
t.Fatalf("expected gcActionClean, got %d", action)
|
|
}
|
|
}
|
|
|
|
func TestShouldCleanTaskDir_CanceledIssueOverTTL(t *testing.T) {
|
|
t.Parallel()
|
|
issueID := "22222222-2222-2222-2222-222222222222"
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc(fmt.Sprintf("/api/daemon/issues/%s/gc-check", issueID), func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"status": "canceled",
|
|
"updated_at": time.Now().Add(-6 * 24 * time.Hour),
|
|
})
|
|
})
|
|
|
|
d := newGCTestDaemon(t, mux)
|
|
taskDir := createTaskDir(t, d.cfg.WorkspacesRoot, "ws1", "task2", &execenv.GCMeta{
|
|
IssueID: issueID,
|
|
WorkspaceID: "ws1",
|
|
CompletedAt: time.Now(),
|
|
})
|
|
|
|
action := d.shouldCleanTaskDir(context.Background(), taskDir)
|
|
if action != gcActionClean {
|
|
t.Fatalf("expected gcActionClean, got %d", action)
|
|
}
|
|
}
|
|
|
|
func TestShouldCleanTaskDir_OpenIssueSkipped(t *testing.T) {
|
|
t.Parallel()
|
|
issueID := "33333333-3333-3333-3333-333333333333"
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc(fmt.Sprintf("/api/daemon/issues/%s/gc-check", issueID), func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"status": "in_progress",
|
|
"updated_at": time.Now().Add(-30 * 24 * time.Hour),
|
|
})
|
|
})
|
|
|
|
d := newGCTestDaemon(t, mux)
|
|
taskDir := createTaskDir(t, d.cfg.WorkspacesRoot, "ws1", "task3", &execenv.GCMeta{
|
|
IssueID: issueID,
|
|
WorkspaceID: "ws1",
|
|
CompletedAt: time.Now(),
|
|
})
|
|
|
|
action := d.shouldCleanTaskDir(context.Background(), taskDir)
|
|
if action != gcActionSkip {
|
|
t.Fatalf("expected gcActionSkip for open issue, got %d", action)
|
|
}
|
|
}
|
|
|
|
func TestShouldCleanTaskDir_DoneButRecentSkipped(t *testing.T) {
|
|
t.Parallel()
|
|
issueID := "44444444-4444-4444-4444-444444444444"
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc(fmt.Sprintf("/api/daemon/issues/%s/gc-check", issueID), func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"status": "done",
|
|
"updated_at": time.Now().Add(-1 * 24 * time.Hour), // 1 day ago, within TTL
|
|
})
|
|
})
|
|
|
|
d := newGCTestDaemon(t, mux)
|
|
taskDir := createTaskDir(t, d.cfg.WorkspacesRoot, "ws1", "task4", &execenv.GCMeta{
|
|
IssueID: issueID,
|
|
WorkspaceID: "ws1",
|
|
CompletedAt: time.Now(),
|
|
})
|
|
|
|
action := d.shouldCleanTaskDir(context.Background(), taskDir)
|
|
if action != gcActionSkip {
|
|
t.Fatalf("expected gcActionSkip for recently-done issue, got %d", action)
|
|
}
|
|
}
|
|
|
|
func TestShouldCleanTaskDir_NoMetaRecentSkipped(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
d := newGCTestDaemon(t, http.NewServeMux())
|
|
// No meta, fresh directory — should skip.
|
|
taskDir := createTaskDir(t, d.cfg.WorkspacesRoot, "ws1", "task5", nil)
|
|
|
|
action := d.shouldCleanTaskDir(context.Background(), taskDir)
|
|
if action != gcActionSkip {
|
|
t.Fatalf("expected gcActionSkip for recent orphan, got %d", action)
|
|
}
|
|
}
|
|
|
|
func TestShouldCleanTaskDir_NoMetaOldOrphan(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
d := newGCTestDaemon(t, http.NewServeMux())
|
|
d.cfg.GCOrphanTTL = 0 // treat all orphans as expired
|
|
taskDir := createTaskDir(t, d.cfg.WorkspacesRoot, "ws1", "task6", nil)
|
|
|
|
action := d.shouldCleanTaskDir(context.Background(), taskDir)
|
|
if action != gcActionOrphan {
|
|
t.Fatalf("expected gcActionOrphan, got %d", action)
|
|
}
|
|
}
|
|
|
|
func TestShouldCleanTaskDir_APIErrorSkipped(t *testing.T) {
|
|
t.Parallel()
|
|
issueID := "55555555-5555-5555-5555-555555555555"
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc(fmt.Sprintf("/api/daemon/issues/%s/gc-check", issueID), func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
})
|
|
|
|
d := newGCTestDaemon(t, mux)
|
|
taskDir := createTaskDir(t, d.cfg.WorkspacesRoot, "ws1", "task7", &execenv.GCMeta{
|
|
IssueID: issueID,
|
|
WorkspaceID: "ws1",
|
|
CompletedAt: time.Now(),
|
|
})
|
|
|
|
action := d.shouldCleanTaskDir(context.Background(), taskDir)
|
|
if action != gcActionSkip {
|
|
t.Fatalf("expected gcActionSkip on API error, got %d", action)
|
|
}
|
|
}
|
|
|
|
func TestShouldCleanTaskDir_Issue404OldOrphan(t *testing.T) {
|
|
t.Parallel()
|
|
issueID := "66666666-6666-6666-6666-666666666666"
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc(fmt.Sprintf("/api/daemon/issues/%s/gc-check", issueID), func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
w.Write([]byte(`{"error":"issue not found"}`))
|
|
})
|
|
|
|
d := newGCTestDaemon(t, mux)
|
|
d.cfg.GCOrphanTTL = 0 // treat orphans as immediately eligible
|
|
taskDir := createTaskDir(t, d.cfg.WorkspacesRoot, "ws1", "task8", &execenv.GCMeta{
|
|
IssueID: issueID,
|
|
WorkspaceID: "ws1",
|
|
CompletedAt: time.Now(),
|
|
})
|
|
|
|
action := d.shouldCleanTaskDir(context.Background(), taskDir)
|
|
if action != gcActionOrphan {
|
|
t.Fatalf("expected gcActionOrphan for deleted issue, got %d", action)
|
|
}
|
|
}
|
|
|
|
func TestCleanTaskDir_RemovesDirectory(t *testing.T) {
|
|
t.Parallel()
|
|
d := newGCTestDaemon(t, http.NewServeMux())
|
|
taskDir := createTaskDir(t, d.cfg.WorkspacesRoot, "ws1", "doomed", nil)
|
|
|
|
if _, err := os.Stat(taskDir); err != nil {
|
|
t.Fatal("task dir should exist before cleanup")
|
|
}
|
|
|
|
d.cleanTaskDir(taskDir)
|
|
|
|
if _, err := os.Stat(taskDir); !os.IsNotExist(err) {
|
|
t.Fatal("task dir should be removed after cleanup")
|
|
}
|
|
}
|
|
|
|
func TestGcWorkspace_CleansEmptyWorkspaceDir(t *testing.T) {
|
|
t.Parallel()
|
|
issueID := "77777777-7777-7777-7777-777777777777"
|
|
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc(fmt.Sprintf("/api/daemon/issues/%s/gc-check", issueID), func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"status": "done",
|
|
"updated_at": time.Now().Add(-10 * 24 * time.Hour),
|
|
})
|
|
})
|
|
|
|
d := newGCTestDaemon(t, mux)
|
|
wsDir := filepath.Join(d.cfg.WorkspacesRoot, "ws-empty")
|
|
createTaskDir(t, d.cfg.WorkspacesRoot, "ws-empty", "only-task", &execenv.GCMeta{
|
|
IssueID: issueID,
|
|
WorkspaceID: "ws-empty",
|
|
CompletedAt: time.Now(),
|
|
})
|
|
|
|
d.gcWorkspace(context.Background(), wsDir)
|
|
|
|
if _, err := os.Stat(wsDir); !os.IsNotExist(err) {
|
|
t.Fatal("empty workspace dir should be removed after all tasks cleaned")
|
|
}
|
|
}
|
|
|
|
func TestIsBareRepo(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
t.Run("valid bare repo", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
os.WriteFile(filepath.Join(dir, "HEAD"), []byte("ref: refs/heads/main"), 0o644)
|
|
os.MkdirAll(filepath.Join(dir, "objects"), 0o755)
|
|
if !isBareRepo(dir) {
|
|
t.Fatal("expected isBareRepo=true for dir with HEAD + objects/")
|
|
}
|
|
})
|
|
|
|
t.Run("HEAD only", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
os.WriteFile(filepath.Join(dir, "HEAD"), []byte("ref: refs/heads/main"), 0o644)
|
|
if isBareRepo(dir) {
|
|
t.Fatal("expected isBareRepo=false for dir with only HEAD")
|
|
}
|
|
})
|
|
|
|
t.Run("empty dir", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if isBareRepo(dir) {
|
|
t.Fatal("expected isBareRepo=false for empty dir")
|
|
}
|
|
})
|
|
}
|