Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,11 @@
# Allowed: 0..2562047h47m16.854775807s; zero = no idle minimum.
# DRIGA_CLEANUP_WORKTREE_IDLE=

# cleanup.regenerable: list; default=[node_modules/ target/ .venv/ venv/ dist/ build/ .next/ __pycache__/ .gradle/ .terraform/]; optional. Additional regenerable relative directories (merged with defaults).
# cleanup.build_idle: duration; default=168h; optional. Checkout idle time before its build output is a candidate.
# Allowed: 0..2562047h47m16.854775807s; zero = no idle minimum.
# DRIGA_CLEANUP_BUILD_IDLE=

# cleanup.regenerable: list; default=[]; optional. Extra regenerable directory names, beyond the built-in rules.
# Allowed: file-only; empty = built-in directories.
# File-only structured list; no environment separator.

Expand Down
44 changes: 33 additions & 11 deletions cmd/driga/header_widgets_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"os"
Expand Down Expand Up @@ -78,7 +79,31 @@ func TestHeaderEditReachesApp(t *testing.T) {
t.Fatal(err)
}
defer func() { cancel(); _ = serve.Wait() }()
client := &http.Client{Timeout: 2 * time.Second}
// Poll the cheap page route for startup. Dashboard reads use the test's
// overall deadline: retrying canceled reads can repeatedly discard work on
// a busy host before a complete response reaches the assertions.
client := &http.Client{}
var readyErr error
eventually(t, 20*time.Second, func() bool {
probeCtx, probeCancel := context.WithTimeout(ctx, 2*time.Second)
defer probeCancel()
req, err := http.NewRequestWithContext(probeCtx, http.MethodGet, "http://"+addr+"/app/", nil)
if err != nil {
readyErr = err
return false
}
response, err := client.Do(req)
if err != nil {
readyErr = err
return false
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
readyErr = fmt.Errorf("page: HTTP %d", response.StatusCode)
return false
}
return true
}, func() string { return fmt.Sprintf("%s\nlast readiness error: %v", logs.String(), readyErr) })
open := func() (panel.Fragment, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://"+addr+"/app/api/open", bytes.NewBufferString(`{"id":"header"}`))
if err != nil {
Expand All @@ -93,12 +118,16 @@ func TestHeaderEditReachesApp(t *testing.T) {
defer response.Body.Close()
var f panel.Fragment
if response.StatusCode != 200 {
return f, fmt.Errorf("open: HTTP %d", response.StatusCode)
body, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
return f, fmt.Errorf("open: HTTP %d: %s", response.StatusCode, body)
}
err = json.NewDecoder(response.Body).Decode(&f)
return f, err
}
eventually(t, 20*time.Second, func() bool { f, err := open(); return err == nil && f.ID == "header" && f.Version == 0 }, logs.String)
initial, err := open()
if err != nil || initial.ID != "header" || initial.Version != 0 {
t.Fatalf("initial open: id=%q version=%d error=%v\n%s", initial.ID, initial.Version, err, logs.String())
}
cmd := n.cmd(ctx, "mcp")
cmd.Stderr = &logs
session, err := sdk.NewClient(&sdk.Implementation{Name: "header-e2e", Version: "test"}, nil).Connect(ctx, &sdk.CommandTransport{Command: cmd}, nil)
Expand All @@ -115,14 +144,7 @@ func TestHeaderEditReachesApp(t *testing.T) {
if err != nil || result.IsError {
t.Fatalf("MCP edit: %+v %v\n%s", result, err, logs.String())
}
// One normal app refresh interval, then request exactly what the app polls.
timer := time.NewTimer(5 * time.Second)
defer timer.Stop()
select {
case <-timer.C:
case <-ctx.Done():
t.Fatal(ctx.Err())
}
// The edit has completed; request exactly what the app polls.
f, err := open()
if err != nil {
t.Fatalf("refresh: %v\n%s", err, logs.String())
Expand Down
189 changes: 149 additions & 40 deletions cmd/driga/smoke_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
Expand All @@ -16,6 +17,11 @@ import (
"sync"
"testing"
"time"

"github.com/labstack/driga/internal/cleanup"
"github.com/labstack/driga/internal/cli"
"github.com/labstack/driga/internal/platform"
"github.com/labstack/driga/internal/store"
)

// logBuffer is written by a child process's copier while the test reads it.
Expand Down Expand Up @@ -179,6 +185,23 @@ func TestSmoke(t *testing.T) {
}
local.run("config", "show")
local.run("service", "run", "--once")
// Subsequent reads use this real service's database instead of collecting
// the host again for every command. Fleet exercises this same service below.
write(t, local.path("config.toml"), "roots = ['"+filepath.Dir(repo)+"']\n[fleet]\nserve = true\nlisten = \"127.0.0.1:0\"\n[intake]\notlp = false\n[adapters.copilot]\nenabled = false\n")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var serveLog logBuffer
serve := local.cmd(ctx, "service", "run", "--serve")
serve.Stdout, serve.Stderr = &serveLog, &serveLog
if err := serve.Start(); err != nil {
t.Fatal(err)
}
defer func() { cancel(); _ = serve.Wait() }()
eventually(t, 30*time.Second, func() bool {
probeCtx, stop := context.WithTimeout(ctx, 2*time.Second)
defer stop()
return local.cmd(probeCtx, "fleet", "status").Run() == nil
}, serveLog.String)

if agents := local.data("agents", "--all"); !field(agents, "title", "Smoke title") {
t.Fatalf("session missing from agents: %s", agents)
Expand All @@ -190,58 +213,65 @@ func TestSmoke(t *testing.T) {
local.run("orphans")
local.run("machines", "show")
local.run("doctor")
// Cleanup refuses when a live agent hides its cwd (non-dumpable agents
// on Linux); unreadable non-agent processes are reported as evidence. That is the
// host's state, not a smoke failure, so only that refusal skips the check.
for _, why := range []bool{false, true} {
args := []string{"cleanup", "--json"}
if why {
args = append(args, "--why")
// Exercise the real CLI cleanup handler against the collected fixture and
// actual Git worktree, with deterministic process inspection. Other agents
// running tests on this host must neither delay this check nor make it skip.
t.Run("cleanup", func(t *testing.T) {
fixture := cleanupNode(t, local, repo)
for key, value := range map[string]string{
"HOME": fixture.path("home"), "PATH": "/usr/bin:/bin:/usr/sbin:/sbin",
"DRIGA_DATA_DIR": fixture.path("data"), "DRIGA_SPOOL_DIR": fixture.path("spool"),
"DRIGA_CONFIG": fixture.path("config.toml"),
} {
t.Setenv(key, value)
}
out, errout, err := local.try(args...)
if err != nil && strings.Contains(errout, "cannot inspect cwd") {
t.Logf("cleanup checks skipped: %s", strings.TrimSpace(errout))
break
for _, why := range []bool{false, true} {
var out bytes.Buffer
g := cli.Globals{JSON: true, Stdout: &out, Stderr: &out, CleanupPlatform: &platform.Fake{}}
if err := (cli.CleanupListCmd{Why: why}).Run(&g); err != nil {
t.Fatalf("cleanup why=%t: %v", why, err)
}
var envelope struct {
Schema int `json:"schema"`
Data []cleanup.Candidate `json:"data"`
}
if err := json.Unmarshal(out.Bytes(), &envelope); err != nil || envelope.Schema != 2 {
t.Fatalf("cleanup envelope: %v: %s", err, out.String())
}
found := false
for _, candidate := range envelope.Data {
if candidate.Path != filepath.Join(filepath.Dir(repo), "smoke-topic") {
continue
}
found = true
if !why {
t.Fatalf("unidle worktree offered: %+v", candidate)
}
if candidate.Status != "refused" {
t.Fatalf("--why worktree not refused: %+v", candidate)
}
}
if why && !found {
t.Fatalf("--why lacks refused worktree: %s", out.String())
}
}
if err != nil {
t.Fatalf("cleanup: %v: %s", err, errout)
}
var compact bytes.Buffer
if err := json.Compact(&compact, []byte(out)); err != nil {
t.Fatal(err)
}
cs := compact.String()
if !why && field(cs, "path", "smoke-topic") {
t.Fatalf("unidle worktree offered: %s", cs)
}
if why && (!field(cs, "status", "refused") || !field(cs, "path", "smoke-topic")) {
t.Fatalf("--why lacks refused worktree: %s", cs)
}
}
})
// The agent count includes whatever agents the host is running, so only
// the line's fixed parts are checked.
summary := local.run()
for _, part := range []string{" agents (", " working, ", "inbox ", "orphans ", "snapshot"} {
for _, part := range []string{" agents (", " working, ", "inbox ", "orphans ", "daemon"} {
if !strings.Contains(summary, part) {
t.Fatalf("summary lacks %q: %s", part, summary)
}
}
// Keep one explicit snapshot read through the built binary. The fixture's
// adapter roots are isolated, and no assertion depends on the host's counts.
if snapshot := local.run("summary", "--snapshot"); !strings.Contains(snapshot, "snapshot") {
t.Fatalf("explicit snapshot mode missing: %s", snapshot)
}

// Fleet: the local node serves on loopback; a viewer with an empty HOME
// pairs and must see the served session through the stream.
write(t, local.path("config.toml"), "roots = ['"+filepath.Dir(repo)+"']\n[fleet]\nserve = true\nlisten = \"127.0.0.1:0\"\n[intake]\notlp = false\n[adapters.copilot]\nenabled = false\n")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var serveLog logBuffer
serve := local.cmd(ctx, "service", "run", "--serve")
serve.Stdout, serve.Stderr = &serveLog, &serveLog
if err := serve.Start(); err != nil {
t.Fatal(err)
}
defer func() { cancel(); _ = serve.Wait() }()
eventually(t, 30*time.Second, func() bool {
return local.cmd(context.Background(), "fleet", "status").Run() == nil
}, func() string { return serveLog.String() })
addrBytes, err := os.ReadFile(local.path("data", "fleet", "listen"))
if err != nil {
t.Fatal(err)
Expand Down Expand Up @@ -273,6 +303,85 @@ func TestSmoke(t *testing.T) {
}
}

// Retain the collector's actual fixture attribution while excluding repositories
// discovered from unrelated host processes. Cleanup must inspect only test trees.
func cleanupNode(t *testing.T, source node, repo string) node {
t.Helper()
fixture := newNode(t, source.bin, "cleanup")
write(t, fixture.path("config.toml"), "")
collected, err := store.Open(source.path("data", "driga.db"), store.ReadOnly)
if err != nil {
t.Fatal(err)
}
defer collected.Close()
isolated, err := store.Open(fixture.path("data", "driga.db"), store.ReadWrite)
if err != nil {
t.Fatal(err)
}
defer isolated.Close()
err = collected.Tx(context.Background(), func(from *store.Tx) error {
repos, err := from.Repos()
if err != nil {
return err
}
for _, r := range repos {
if r.Path != repo {
continue
}
worktrees, err := from.Worktrees(r.ID)
if err != nil {
return err
}
if len(worktrees) != 2 {
return fmt.Errorf("fixture collector returned %d worktrees, want 2", len(worktrees))
}
sessions, err := from.Sessions(store.SessionFilter{IncludeEnded: true})
if err != nil {
return err
}
return isolated.Tx(context.Background(), func(to *store.Tx) error {
repoID, err := to.UpsertRepo(r)
if err != nil {
return err
}
worktreeIDs := map[int64]int64{}
for _, w := range worktrees {
if w.Path != repo && w.Path != filepath.Join(filepath.Dir(repo), "smoke-topic") {
return fmt.Errorf("unexpected fixture worktree %q", w.Path)
}
w.RepoID = repoID
id, err := to.UpsertWorktree(w)
if err != nil {
return err
}
worktreeIDs[w.ID] = id
}
for _, s := range sessions {
if s.ExternalID != "smoke-session" || s.Cwd != repo {
continue
}
s.RepoID = &repoID
if s.WorktreeID != nil {
id, ok := worktreeIDs[*s.WorktreeID]
if !ok {
return fmt.Errorf("fixture session references uncollected worktree")
}
s.WorktreeID = &id
}
_, err := to.UpsertSession(s)
return err
}
return fmt.Errorf("fixture session not collected")
})
}
return fmt.Errorf("fixture repository not collected")
})
if err != nil {
t.Fatal(err)
}
return fixture
}

func eventually(t *testing.T, limit time.Duration, ok func() bool, logs func() string) {
t.Helper()
deadline := time.Now().Add(limit)
Expand Down
Loading
Loading