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
36 changes: 31 additions & 5 deletions internal/cli/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"io"
"log/slog"
"os"
"strings"
"time"

"github.com/labstack/driga/internal/cleanup"
"github.com/labstack/driga/internal/config"
Expand All @@ -21,8 +23,10 @@ import (

// AppCmd opens the desktop app, or focuses the running one.
type AppCmd struct {
NotificationStdin bool `hidden:"" help:"Read a notification target from standard input."`
Notification string `hidden:"" help:"Desktop notification event target."`
CPUProfile string `name:"cpu-profile" type:"path" help:"Write a CPU profile of the app to this file."`
ProfileFor time.Duration `name:"profile-for" default:"2m" help:"How long --cpu-profile records before it stops."`
NotificationStdin bool `hidden:"" help:"Read a notification target from standard input."`
Notification string `hidden:"" help:"Desktop notification event target."`
}

var (
Expand All @@ -47,16 +51,24 @@ var (
func AppLaunch(args []string) bool {
login := len(args) == 0 && os.Getenv("DRIGA_APP") != "1" && openedAtLogin()
app := appLaunch(os.Getenv, login, args)
systemLaunch = app && (len(args) == 0 || len(args) > 1 && (args[1] == "--notification" || args[1] == "--notification-stdin") && os.Getenv("DRIGA_APP") == "1")
systemLaunch = app && (len(args) == 0 || os.Getenv("DRIGA_APP") == "1")
_ = os.Unsetenv("DRIGA_APP")
return app
}

// appLaunch decides app mode: `driga app`, or a bare launch by the system.
func appLaunch(env func(string) string, openedAtLogin bool, args []string) bool {
if len(args) >= 1 && args[0] == "app" && (len(args) == 1 || (args[1] == "--notification" || args[1] == "--notification-stdin")) {
return true
if len(args) > 0 && args[0] == "app" {
if len(args) == 1 {
return true
}
flag, _, _ := strings.Cut(args[1], "=")
switch flag {
case "--notification", "--notification-stdin", "--cpu-profile", "--profile-for":
return true
}
}

return len(args) == 0 && (env("DRIGA_APP") == "1" || openedAtLogin)
}

Expand Down Expand Up @@ -104,9 +116,23 @@ func (c AppCmd) Run(g *Globals) error {
}
args = []string{"app", "--notification", c.Notification}
}
if c.CPUProfile != "" {
if len(args) == 0 {
args = []string{"app"}
}
args = append(args, "--cpu-profile", c.CPUProfile, "--profile-for", c.ProfileFor.String())
}
if handed, err := handOffApp(systemLaunch || c.NotificationStdin, args...); handed || err != nil {
return err
}
if c.CPUProfile != "" {
stopProfile, err := startCPUProfile(c.CPUProfile, c.ProfileFor)
if err != nil {
return err
}
defer stopProfile()
}

restore := appLogging()
defer restore()
// Opened from Finder or at login, an error here would reach no one: the
Expand Down
2 changes: 2 additions & 0 deletions internal/cli/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ func TestAppLaunch(t *testing.T) {
{"command", "1", false, []string{"top"}, false},
{"command at login", "", true, []string{"top"}, false},
{"app", "", false, []string{"app"}, true},
{"profile equals", "", false, []string{"app", "--cpu-profile=/tmp/app.pprof"}, true},
{"profile", "", false, []string{"app", "--cpu-profile", "/tmp/app.pprof"}, true},
{"app help", "", false, []string{"app", "--help"}, false},
{"notification", "", false, []string{"app", "--notification", `{}`}, true},
{"other value", "0", false, nil, false},
Expand Down
48 changes: 46 additions & 2 deletions internal/cli/collect_profile.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
package cli

import (
"encoding/json"
"os"
"runtime"
"runtime/pprof"
"sync"
"time"
)

// startCPUProfile records the collector's CPU use into path for d and writes
// allocations to path + ".alloc", so the live service can be measured in place.
// startCPUProfile records CPU use into path for d, cumulative allocations to
// path + ".alloc", a final collected heap to ".heap", and memory/goroutine
// samples every five seconds to ".mem.jsonl".
// The returned stop is idempotent and flushes early on shutdown.
func startCPUProfile(path string, d time.Duration) (func(), error) {
f, err := os.Create(path)
Expand All @@ -25,13 +28,54 @@ func startCPUProfile(path string, d time.Duration) (func(), error) {
alloc.Close()
return nil, err
}
memory, err := os.Create(path + ".mem.jsonl")
if err != nil {
pprof.StopCPUProfile()
f.Close()
alloc.Close()
return nil, err
}
done, sampled := make(chan struct{}), make(chan struct{})
go func() {
defer close(sampled)
defer memory.Close()
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
sample := func() {
var m runtime.MemStats
runtime.ReadMemStats(&m)
_ = json.NewEncoder(memory).Encode(struct {
At time.Time
HeapAlloc, HeapObjects, TotalAlloc, Sys uint64
NumGC uint32
Goroutines int
}{time.Now(), m.HeapAlloc, m.HeapObjects, m.TotalAlloc, m.Sys, m.NumGC, runtime.NumGoroutine()})
}
sample()
for {
select {
case <-ticker.C:
sample()
case <-done:
sample()
return
}
}
}()
var once sync.Once
stop := func() {
once.Do(func() {
pprof.StopCPUProfile()
close(done)
<-sampled
f.Close()
pprof.Lookup("allocs").WriteTo(alloc, 0)
alloc.Close()
if heap, err := os.Create(path + ".heap"); err == nil {
runtime.GC()
_ = pprof.WriteHeapProfile(heap)
_ = heap.Close()
}
})
}
t := time.AfterFunc(d, stop)
Expand Down
6 changes: 6 additions & 0 deletions internal/cli/collect_profile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ func TestCPUProfileStopsAfterDuration(t *testing.T) {
}
time.Sleep(10 * time.Millisecond)
}
stop()
for _, suffix := range []string{".heap", ".mem.jsonl"} {
if fi, err := os.Stat(path + suffix); err != nil || fi.Size() == 0 {
t.Fatalf("missing %s: %v", suffix, err)
}
}
// A second profile can start, so the first one released the profiler.
again, err := startCPUProfile(filepath.Join(t.TempDir(), "again.pprof"), time.Hour)
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/command_tree_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ func parseCommand(t *testing.T, args ...string) (*Root, *kong.Context, error) {
}
func TestCommandTree(t *testing.T) {
paths := []string{
"", "summary", "flow", "flow 12", "agents", "agents list", "agents show 12", "agents jump 12", "agents kill 12", "projects", "projects list", "projects show app", "projects summarize app other", "projects summarize --all", "inbox", "inbox list", "inbox ack item", "inbox ack --all", "inbox jump item", "orphans", "orphans list", "orphans kill item", "orphans kill --all", "machines", "machines list", "machines show", "machines show local", "machines reset-id", "cleanup", "cleanup list", "cleanup remove item", "cleanup remove --all --kind worktree", "top", "app", "ask what needs me", "mcp", "doctor", "init", "config path", "config show", "config edit", "service run", "service run --serve --listen 127.0.0.1:7411", "service run --scheduled --service-data-dir fixture --service-env DRIGA_CAPTURE_TEXT=titles", "service install", "service start", "service stop", "service restart", "service status", "service logs -f", "service uninstall", "hooks install", "hooks install claude", "hooks uninstall codex", "hooks status", "hooks status all", "fleet peers list", "fleet peers add host:7411 --device-id device -y", "fleet peers remove peer", "fleet viewers list", "fleet viewers revoke viewer", "fleet enroll --allow-viewer viewer --allow-ack -y", "fleet rekey", "fleet id", "fleet status", "version", "hook claude Stop", "feed tail --after-seq 1 -f", "dev agy-schema", "mcp --chat", "mcp --snapshot",
"", "summary", "flow", "flow 12", "agents", "agents list", "agents show 12", "agents jump 12", "agents kill 12", "projects", "projects list", "projects show app", "projects summarize app other", "projects summarize --all", "inbox", "inbox list", "inbox ack item", "inbox ack --all", "inbox jump item", "orphans", "orphans list", "orphans kill item", "orphans kill --all", "machines", "machines list", "machines show", "machines show local", "machines reset-id", "cleanup", "cleanup list", "cleanup remove item", "cleanup remove --all --kind worktree", "top", "app", "app --cpu-profile app.pprof --profile-for 1m", "dev serve --cpu-profile app.pprof --profile-for 1m", "ask what needs me", "mcp", "doctor", "init", "config path", "config show", "config edit", "service run", "service run --serve --listen 127.0.0.1:7411", "service run --scheduled --service-data-dir fixture --service-env DRIGA_CAPTURE_TEXT=titles", "service install", "service start", "service stop", "service restart", "service status", "service logs -f", "service uninstall", "hooks install", "hooks install claude", "hooks uninstall codex", "hooks status", "hooks status all", "fleet peers list", "fleet peers add host:7411 --device-id device -y", "fleet peers remove peer", "fleet viewers list", "fleet viewers revoke viewer", "fleet enroll --allow-viewer viewer --allow-ack -y", "fleet rekey", "fleet id", "fleet status", "version", "hook claude Stop", "feed tail --after-seq 1 -f", "dev agy-schema", "mcp --chat", "mcp --snapshot",
}
for _, path := range paths {
t.Run(path, func(t *testing.T) {
Expand Down
12 changes: 11 additions & 1 deletion internal/cli/dev_serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,20 @@ import (
// dev). It is the app without its window: the same page, data, Chat and
// actions, but no native dialogs, menus, tray or notifications.
type DevServeCmd struct {
Addr string `default:"127.0.0.1:47801" help:"Loopback address to listen on."`
CPUProfile string `name:"cpu-profile" type:"path" help:"Write a CPU profile of the app to this file."`
ProfileFor time.Duration `name:"profile-for" default:"2m" help:"How long --cpu-profile records before it stops."`
Addr string `default:"127.0.0.1:47801" help:"Loopback address to listen on."`
}

func (c DevServeCmd) Run(g *Globals) error {
if c.CPUProfile != "" {
stopProfile, err := startCPUProfile(c.CPUProfile, c.ProfileFor)
if err != nil {
return err
}
defer stopProfile()
}

host, _, err := net.SplitHostPort(c.Addr)
if err != nil {
return invalidUsage("--addr must be host:port")
Expand Down
Loading
Loading