From fef76fda572287cc210f322ac6b2fb60f36b0b55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Wed, 5 Aug 2026 18:24:14 +0100 Subject: [PATCH] feat: prevent host sleep during active tasks (#2264) --- apps/backend/go.mod | 1 + apps/backend/go.sum | 2 + apps/backend/internal/backendapp/main.go | 1 + .../system/sleepinhibition/goleak_test.go | 11 + .../system/sleepinhibition/handler.go | 46 ++ .../system/sleepinhibition/handler_test.go | 82 ++++ .../sleepinhibition/inhibitor_darwin.go | 108 +++++ .../sleepinhibition/inhibitor_darwin_test.go | 142 ++++++ .../system/sleepinhibition/inhibitor_linux.go | 223 ++++++++++ .../sleepinhibition/inhibitor_linux_test.go | 159 +++++++ .../system/sleepinhibition/inhibitor_other.go | 15 + .../sleepinhibition/inhibitor_windows.go | 102 +++++ .../sleepinhibition/inhibitor_windows_test.go | 91 ++++ .../system/sleepinhibition/service.go | 418 ++++++++++++++++++ .../system/sleepinhibition/service_test.go | 396 +++++++++++++++++ .../internal/system/sleepinhibition/store.go | 44 ++ .../system/sleepinhibition/store_test.go | 67 +++ .../internal/system/sleepinhibition/types.go | 98 ++++ apps/backend/internal/system/system.go | 76 +++- .../internal/system/system_routes_test.go | 76 ++++ .../components/settings/general-settings.tsx | 2 + .../sleep-inhibition-settings.test.tsx | 171 +++++++ .../settings/sleep-inhibition-settings.tsx | 316 +++++++++++++ .../settings/mobile-general-settings.spec.ts | 81 ++++ .../settings/settings-manual-save.spec.ts | 70 +++ .../settings/use-sleep-inhibition-settings.ts | 70 +++ apps/web/lib/api/domains/settings-api.test.ts | 39 +- apps/web/lib/api/domains/settings-api.ts | 5 + .../lib/api/domains/sleep-inhibition-api.ts | 27 ++ apps/web/lib/state/default-state.ts | 2 + apps/web/lib/state/hydration/hydrator.ts | 1 + apps/web/lib/state/slices/index.ts | 1 + .../state/slices/settings/settings-slice.ts | 28 ++ apps/web/lib/state/slices/settings/types.ts | 13 + apps/web/lib/state/store-overrides.ts | 1 + apps/web/lib/state/store-reexports.ts | 1 + apps/web/lib/state/store.ts | 5 + apps/web/lib/types/system.ts | 22 + apps/web/src/locales/en/settings.json | 23 + apps/web/src/locales/pseudo/settings.json | 23 + apps/web/src/locales/zh-cn/chat.json | 13 - apps/web/src/locales/zh-cn/settings.json | 25 +- docs/plans/task-sleep-inhibition/plan.md | 168 +++++++ .../task-01-lifecycle-service.md | 64 +++ .../task-02-native-platform-inhibitors.md | 75 ++++ .../task-03-system-api-wiring.md | 63 +++ .../task-04-task-actions-card.md | 74 ++++ .../task-05-settings-e2e.md | 73 +++ .../task-06-public-documentation.md | 62 +++ docs/public/operations.md | 29 ++ docs/specs/INDEX.md | 1 + docs/specs/platform/task-sleep-inhibition.md | 108 +++++ 52 files changed, 3775 insertions(+), 39 deletions(-) create mode 100644 apps/backend/internal/system/sleepinhibition/goleak_test.go create mode 100644 apps/backend/internal/system/sleepinhibition/handler.go create mode 100644 apps/backend/internal/system/sleepinhibition/handler_test.go create mode 100644 apps/backend/internal/system/sleepinhibition/inhibitor_darwin.go create mode 100644 apps/backend/internal/system/sleepinhibition/inhibitor_darwin_test.go create mode 100644 apps/backend/internal/system/sleepinhibition/inhibitor_linux.go create mode 100644 apps/backend/internal/system/sleepinhibition/inhibitor_linux_test.go create mode 100644 apps/backend/internal/system/sleepinhibition/inhibitor_other.go create mode 100644 apps/backend/internal/system/sleepinhibition/inhibitor_windows.go create mode 100644 apps/backend/internal/system/sleepinhibition/inhibitor_windows_test.go create mode 100644 apps/backend/internal/system/sleepinhibition/service.go create mode 100644 apps/backend/internal/system/sleepinhibition/service_test.go create mode 100644 apps/backend/internal/system/sleepinhibition/store.go create mode 100644 apps/backend/internal/system/sleepinhibition/store_test.go create mode 100644 apps/backend/internal/system/sleepinhibition/types.go create mode 100644 apps/web/components/settings/sleep-inhibition-settings.test.tsx create mode 100644 apps/web/components/settings/sleep-inhibition-settings.tsx create mode 100644 apps/web/hooks/domains/settings/use-sleep-inhibition-settings.ts create mode 100644 apps/web/lib/api/domains/sleep-inhibition-api.ts create mode 100644 docs/plans/task-sleep-inhibition/plan.md create mode 100644 docs/plans/task-sleep-inhibition/task-01-lifecycle-service.md create mode 100644 docs/plans/task-sleep-inhibition/task-02-native-platform-inhibitors.md create mode 100644 docs/plans/task-sleep-inhibition/task-03-system-api-wiring.md create mode 100644 docs/plans/task-sleep-inhibition/task-04-task-actions-card.md create mode 100644 docs/plans/task-sleep-inhibition/task-05-settings-e2e.md create mode 100644 docs/plans/task-sleep-inhibition/task-06-public-documentation.md create mode 100644 docs/specs/platform/task-sleep-inhibition.md diff --git a/apps/backend/go.mod b/apps/backend/go.mod index 6ce999482f..123f3e98d5 100644 --- a/apps/backend/go.mod +++ b/apps/backend/go.mod @@ -8,6 +8,7 @@ require ( github.com/coder/acp-go-sdk v0.13.5 github.com/creack/pty v1.1.24 github.com/gin-gonic/gin v1.9.1 + github.com/godbus/dbus/v5 v5.1.0 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/hashicorp/go-plugin v1.8.0 diff --git a/apps/backend/go.sum b/apps/backend/go.sum index 7c4bbc1462..1ee5728090 100644 --- a/apps/backend/go.sum +++ b/apps/backend/go.sum @@ -127,6 +127,8 @@ github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpv github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= diff --git a/apps/backend/internal/backendapp/main.go b/apps/backend/internal/backendapp/main.go index 361b3325f9..ff46fe0eab 100644 --- a/apps/backend/internal/backendapp/main.go +++ b/apps/backend/internal/backendapp/main.go @@ -829,6 +829,7 @@ func startGatewayAndServe( }, systemsvc.Wiring{ OrchestratorShutdown: func() { _ = orchestratorSvc.Stop() }, MessageQueue: orchestratorSvc.GetMessageQueue(), + TaskSessions: repos.Task, }) storageComposition, err := provideStorageComposition( cfg, dbPool, systemSvc.Jobs, lifecycleMgr, services.WorktreeMgr, services.Task, diff --git a/apps/backend/internal/system/sleepinhibition/goleak_test.go b/apps/backend/internal/system/sleepinhibition/goleak_test.go new file mode 100644 index 0000000000..a8b8b0a3ed --- /dev/null +++ b/apps/backend/internal/system/sleepinhibition/goleak_test.go @@ -0,0 +1,11 @@ +package sleepinhibition + +import ( + "testing" + + "go.uber.org/goleak" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} diff --git a/apps/backend/internal/system/sleepinhibition/handler.go b/apps/backend/internal/system/sleepinhibition/handler.go new file mode 100644 index 0000000000..11f6c367b9 --- /dev/null +++ b/apps/backend/internal/system/sleepinhibition/handler.go @@ -0,0 +1,46 @@ +package sleepinhibition + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" +) + +func RegisterRoutes(read, admin *gin.RouterGroup, service *Service) { + read.GET("/sleep-inhibition", handleGet(service)) + admin.PATCH("/sleep-inhibition", handleUpdate(service)) +} + +func handleGet(service *Service) gin.HandlerFunc { + return func(ctx *gin.Context) { + response, err := service.Get(ctx.Request.Context()) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load sleep inhibition settings"}) + return + } + ctx.JSON(http.StatusOK, response) + } +} + +func handleUpdate(service *Service) gin.HandlerFunc { + return func(ctx *gin.Context) { + var request struct { + Enabled *bool `json:"enabled"` + } + if err := ctx.ShouldBindJSON(&request); err != nil || request.Enabled == nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "enabled must be a boolean"}) + return + } + response, err := service.Update(ctx.Request.Context(), Settings{Enabled: *request.Enabled}) + if err != nil { + if errors.Is(err, ErrInvalidPersisted) { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load sleep inhibition settings"}) + return + } + ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save sleep inhibition settings"}) + return + } + ctx.JSON(http.StatusOK, response) + } +} diff --git a/apps/backend/internal/system/sleepinhibition/handler_test.go b/apps/backend/internal/system/sleepinhibition/handler_test.go new file mode 100644 index 0000000000..d12feb29c9 --- /dev/null +++ b/apps/backend/internal/system/sleepinhibition/handler_test.go @@ -0,0 +1,82 @@ +package sleepinhibition + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/task/models" +) + +func TestHandlerRejectsMalformedPatchAndReturnsReconciledResponse(t *testing.T) { + gin.SetMode(gin.TestMode) + service := NewService(NewStore(&memoryRawStore{}), emptySessionReader{}, &fakeInhibitor{platform: PlatformOther}, bus.NewMemoryEventBus(nil), nil) + router := gin.New() + router.Use(func(ctx *gin.Context) { + authn.SetOnGin(ctx, authn.Identity{UserID: "admin", Role: authn.RoleAdmin}) + ctx.Next() + }) + g := router.Group("/api") + admin := g.Group("", authn.RequireAdmin()) + RegisterRoutes(g, admin, service) + + bad := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPatch, "/api/sleep-inhibition", nil) + router.ServeHTTP(bad, request) + if bad.Code != http.StatusBadRequest { + t.Fatalf("malformed patch status = %d, want 400", bad.Code) + } + + good := httptest.NewRecorder() + request = httptest.NewRequest(http.MethodPatch, "/api/sleep-inhibition", newJSONBuffer(`{"enabled":true}`)) + request.Header.Set("Content-Type", "application/json") + router.ServeHTTP(good, request) + if good.Code != http.StatusOK { + t.Fatalf("valid patch status = %d, body=%s", good.Code, good.Body.String()) + } + if got := good.Body.String(); !containsAll(got, `"enabled":true`, `"active":false`, `"unsupported_platform"`) { + t.Fatalf("reconciled response = %s", got) + } +} + +func TestHandlerAllowsAuthenticatedRead(t *testing.T) { + gin.SetMode(gin.TestMode) + service := NewService(NewStore(&memoryRawStore{}), emptySessionReader{}, &fakeInhibitor{platform: PlatformLinux, supported: true}, bus.NewMemoryEventBus(nil), nil) + router := gin.New() + router.Use(func(ctx *gin.Context) { + authn.SetOnGin(ctx, authn.Identity{UserID: "member", Role: authn.RoleMember}) + ctx.Next() + }) + g := router.Group("/api") + admin := g.Group("", authn.RequireAdmin()) + RegisterRoutes(g, admin, service) + + response := httptest.NewRecorder() + router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/sleep-inhibition", nil)) + if response.Code != http.StatusOK { + t.Fatalf("member GET status = %d, body=%s", response.Code, response.Body.String()) + } +} + +type emptySessionReader struct{} + +func (emptySessionReader) ListActiveTaskSessions(context.Context) ([]*models.TaskSession, error) { + return nil, nil +} + +func newJSONBuffer(value string) *bytes.Buffer { return bytes.NewBufferString(value) } + +func containsAll(value string, fragments ...string) bool { + for _, fragment := range fragments { + if !strings.Contains(value, fragment) { + return false + } + } + return true +} diff --git a/apps/backend/internal/system/sleepinhibition/inhibitor_darwin.go b/apps/backend/internal/system/sleepinhibition/inhibitor_darwin.go new file mode 100644 index 0000000000..faa34155ec --- /dev/null +++ b/apps/backend/internal/system/sleepinhibition/inhibitor_darwin.go @@ -0,0 +1,108 @@ +//go:build darwin + +package sleepinhibition + +import ( + "context" + "errors" + "os" + "os/exec" + "strconv" + "sync" +) + +type darwinProcess interface { + Start() error + Wait() error + Kill() error +} + +type darwinProcessFactory func(name string, args ...string) darwinProcess + +type darwinInhibitor struct { + newProcess darwinProcessFactory +} + +func NewPlatformInhibitor() Inhibitor { + return newDarwinInhibitor(func(name string, args ...string) darwinProcess { + return &darwinCommand{cmd: exec.Command(name, args...)} + }) +} + +func newDarwinInhibitor(newProcess darwinProcessFactory) Inhibitor { + return &darwinInhibitor{newProcess: newProcess} +} + +func (i *darwinInhibitor) Platform() Platform { return PlatformDarwin } +func (i *darwinInhibitor) Supported() bool { return true } + +func (i *darwinInhibitor) Acquire(_ context.Context) (Lease, error) { + process := i.newProcess("/usr/bin/caffeinate", "-i", "-w", strconv.Itoa(os.Getpid())) + if err := process.Start(); err != nil { + return nil, NewIssueError(IssueRequestFailed, err) + } + return newDarwinLease(process), nil +} + +type darwinCommand struct{ cmd *exec.Cmd } + +func (c *darwinCommand) Start() error { return c.cmd.Start() } +func (c *darwinCommand) Wait() error { return c.cmd.Wait() } +func (c *darwinCommand) Kill() error { + if c.cmd.Process == nil { + return nil + } + return c.cmd.Process.Kill() +} + +type darwinLease struct { + process darwinProcess + waitDone chan error + done chan error + releaseOnce sync.Once + waitOnce sync.Once + mu sync.Mutex + released bool + killErr error + waitErr error +} + +func newDarwinLease(process darwinProcess) *darwinLease { + lease := &darwinLease{ + process: process, + waitDone: make(chan error, 1), + done: make(chan error, 1), + } + go func() { + err := process.Wait() + lease.mu.Lock() + if lease.released { + // A process exit caused by our Release is normal. Keep unexpected + // exits visible when they happen before release is requested. + err = nil + } + lease.mu.Unlock() + lease.waitDone <- err + lease.done <- err + close(lease.done) + }() + return lease +} + +func (l *darwinLease) Release() error { + l.releaseOnce.Do(func() { + l.mu.Lock() + l.released = true + l.mu.Unlock() + if err := l.process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) { + l.killErr = err + } + }) + l.waitOnce.Do(func() { l.waitErr = <-l.waitDone }) + if l.killErr != nil { + return l.killErr + } + return l.waitErr +} + +func (l *darwinLease) Done() <-chan error { return l.done } diff --git a/apps/backend/internal/system/sleepinhibition/inhibitor_darwin_test.go b/apps/backend/internal/system/sleepinhibition/inhibitor_darwin_test.go new file mode 100644 index 0000000000..abb591dd75 --- /dev/null +++ b/apps/backend/internal/system/sleepinhibition/inhibitor_darwin_test.go @@ -0,0 +1,142 @@ +//go:build darwin + +package sleepinhibition + +import ( + "context" + "errors" + "os" + "reflect" + "strconv" + "sync" + "testing" + "time" +) + +func TestDarwinInhibitorStartsCaffeinateWithSystemSleepFlags(t *testing.T) { + process := newFakeDarwinProcess(errors.New("signal: killed")) + var name string + var args []string + inhibitor := newDarwinInhibitor(func(gotName string, gotArgs ...string) darwinProcess { + name = gotName + args = append([]string(nil), gotArgs...) + return process + }) + + lease, err := inhibitor.Acquire(context.Background()) + if err != nil { + t.Fatalf("acquire: %v", err) + } + if name != "/usr/bin/caffeinate" { + t.Fatalf("command = %q", name) + } + wantArgs := []string{"-i", "-w", strconv.Itoa(os.Getpid())} + if !reflect.DeepEqual(args, wantArgs) { + t.Fatalf("args = %#v, want %#v", args, wantArgs) + } + if err := lease.Release(); err != nil { + t.Fatalf("release: %v", err) + } + if err := lease.Release(); err != nil { + t.Fatalf("repeated release: %v", err) + } + if got := process.killCount(); got != 1 { + t.Fatalf("kill count = %d, want 1", got) + } + select { + case err := <-lease.Done(): + if err != nil { + t.Fatalf("intentional release reported error: %v", err) + } + case <-time.After(time.Second): + t.Fatal("lease did not complete after release") + } +} + +func TestDarwinInhibitorMapsStartFailure(t *testing.T) { + inhibitor := newDarwinInhibitor(func(string, ...string) darwinProcess { + process := newFakeDarwinProcess(nil) + process.startErr = errors.New("start failed") + return process + }) + _, err := inhibitor.Acquire(context.Background()) + if err == nil || IssueFromError(err) != IssueRequestFailed { + t.Fatalf("acquire error = %v, want request_failed", err) + } +} + +func TestDarwinLeaseReportsUnexpectedExit(t *testing.T) { + process := newFakeDarwinProcess(errors.New("caffeinate exited")) + lease, err := newDarwinInhibitor(func(string, ...string) darwinProcess { return process }).Acquire(context.Background()) + if err != nil { + t.Fatalf("acquire: %v", err) + } + process.exit() + + select { + case err := <-lease.Done(): + if err == nil || !errors.Is(err, process.waitErr) { + t.Fatalf("unexpected exit error = %v", err) + } + case <-time.After(time.Second): + t.Fatal("lease did not report unexpected exit") + } + if err := lease.Release(); err == nil { + t.Fatal("release after unexpected exit returned nil") + } +} + +type fakeDarwinProcess struct { + mu sync.Mutex + startErr error + waitErr error + killErr error + waitCh chan struct{} + exited bool + kills int + once sync.Once +} + +func newFakeDarwinProcess(waitErr error) *fakeDarwinProcess { + return &fakeDarwinProcess{waitErr: waitErr, waitCh: make(chan struct{})} +} + +func (p *fakeDarwinProcess) Start() error { + p.mu.Lock() + defer p.mu.Unlock() + return p.startErr +} + +func (p *fakeDarwinProcess) Wait() error { + <-p.waitCh + p.mu.Lock() + defer p.mu.Unlock() + return p.waitErr +} + +func (p *fakeDarwinProcess) Kill() error { + p.mu.Lock() + p.kills++ + if p.exited { + p.mu.Unlock() + return os.ErrProcessDone + } + p.exited = true + killErr := p.killErr + p.mu.Unlock() + p.once.Do(func() { close(p.waitCh) }) + return killErr +} + +func (p *fakeDarwinProcess) exit() { + p.mu.Lock() + p.exited = true + p.mu.Unlock() + p.once.Do(func() { close(p.waitCh) }) +} + +func (p *fakeDarwinProcess) killCount() int { + p.mu.Lock() + defer p.mu.Unlock() + return p.kills +} diff --git a/apps/backend/internal/system/sleepinhibition/inhibitor_linux.go b/apps/backend/internal/system/sleepinhibition/inhibitor_linux.go new file mode 100644 index 0000000000..33e05db345 --- /dev/null +++ b/apps/backend/internal/system/sleepinhibition/inhibitor_linux.go @@ -0,0 +1,223 @@ +//go:build linux + +package sleepinhibition + +import ( + "context" + "errors" + "os" + "sync" + + "github.com/godbus/dbus/v5" + "golang.org/x/sys/unix" +) + +const ( + login1Name = "org.freedesktop.login1" + login1Path = dbus.ObjectPath("/org/freedesktop/login1") + login1Interface = "org.freedesktop.login1.Manager" +) + +type linuxDBus interface { + Object(destination string, path dbus.ObjectPath) linuxDBusObject + Close() error +} + +type linuxDBusObject interface { + Call(method string, flags dbus.Flags, args ...interface{}) *dbus.Call + CallWithContext(ctx context.Context, method string, flags dbus.Flags, args ...interface{}) *dbus.Call +} + +type linuxDBusOpener func() (linuxDBus, error) + +type linuxInhibitor struct { + open linuxDBusOpener +} + +func NewPlatformInhibitor() Inhibitor { + return newLinuxInhibitor(func() (linuxDBus, error) { + connection, err := dbus.ConnectSystemBus() + if err != nil { + return nil, err + } + return realLinuxDBus{connection: connection}, nil + }) +} + +func newLinuxInhibitor(open linuxDBusOpener) Inhibitor { return &linuxInhibitor{open: open} } + +func (i *linuxInhibitor) Platform() Platform { return PlatformLinux } +func (i *linuxInhibitor) Supported() bool { return true } + +func (i *linuxInhibitor) Probe(ctx context.Context) error { + connection, err := i.open() + if err != nil { + return NewIssueError(IssueSystemServiceUnavailable, err) + } + defer func() { _ = connection.Close() }() + call := connection.Object(login1Name, login1Path).CallWithContext(ctx, "org.freedesktop.DBus.Peer.Ping", 0) + if call.Err != nil { + return NewIssueError(IssueSystemServiceUnavailable, call.Err) + } + return nil +} + +func (i *linuxInhibitor) Acquire(ctx context.Context) (Lease, error) { + connection, err := i.open() + if err != nil { + return nil, NewIssueError(IssueSystemServiceUnavailable, err) + } + call := connection.Object(login1Name, login1Path).CallWithContext( + ctx, + login1Interface+".Inhibit", + 0, + "sleep", + "Kandev", + "A Kandev task is running", + "block", + ) + if call.Err != nil { + _ = connection.Close() + return nil, NewIssueError(IssueRequestFailed, call.Err) + } + var fd dbus.UnixFD + if err := call.Store(&fd); err != nil { + _ = connection.Close() + return nil, NewIssueError(IssueRequestFailed, err) + } + file := os.NewFile(uintptr(fd), "kandev-sleep-inhibition") + if file == nil { + _ = connection.Close() + return nil, NewIssueError(IssueRequestFailed, os.ErrInvalid) + } + return newLinuxLease(connection, file), nil +} + +type linuxLease struct { + connection linuxDBus + file *os.File + fd int + done chan error + stop chan struct{} + monitorDone chan struct{} + releaseErr error + releaseOnce sync.Once + completionOnce sync.Once +} + +func newLinuxLease(connection linuxDBus, file *os.File) *linuxLease { + lease := &linuxLease{ + connection: connection, + file: file, + fd: int(file.Fd()), + done: make(chan error, 1), + stop: make(chan struct{}), + monitorDone: make(chan struct{}), + } + go lease.monitor() + return lease +} + +func (l *linuxLease) Release() error { + l.releaseOnce.Do(func() { + close(l.stop) + if err := l.file.Close(); err != nil { + l.releaseErr = err + } + <-l.monitorDone + if err := l.connection.Close(); err != nil && l.releaseErr == nil { + l.releaseErr = err + } + l.complete(l.releaseErr) + }) + return l.releaseErr +} + +func (l *linuxLease) Done() <-chan error { return l.done } + +func (l *linuxLease) complete(err error) { + l.completionOnce.Do(func() { + l.done <- err + close(l.done) + }) +} + +func (l *linuxLease) monitor() { + defer close(l.monitorDone) + fd := l.fd + // The descriptor returned by logind is a pipe. Non-blocking mode makes the + // EOF check safe if the platform reports readable data before HUP. + _ = unix.SetNonblock(fd, true) + pollFDs := []unix.PollFd{{ + Fd: int32(fd), + Events: unix.POLLIN | unix.POLLERR | unix.POLLHUP | unix.POLLNVAL, + }} + for { + _, err := unix.Poll(pollFDs, 100) + if err != nil { + if errors.Is(err, unix.EINTR) { + continue + } + if l.stopped() { + return + } + l.unexpectedLoss(err) + return + } + if l.stopped() { + return + } + revents := pollFDs[0].Revents + if revents&(unix.POLLERR|unix.POLLHUP|unix.POLLNVAL) != 0 { + l.unexpectedLoss(errors.New("logind inhibitor descriptor closed")) + return + } + if revents&unix.POLLIN == 0 { + continue + } + var buffer [1]byte + n, readErr := unix.Read(fd, buffer[:]) + if n == 0 { + l.unexpectedLoss(errors.New("logind inhibitor descriptor reached EOF")) + return + } + if readErr != nil && !errors.Is(readErr, unix.EAGAIN) && !errors.Is(readErr, unix.EWOULDBLOCK) { + l.unexpectedLoss(readErr) + return + } + } +} + +func (l *linuxLease) stopped() bool { + select { + case <-l.stop: + return true + default: + return false + } +} + +func (l *linuxLease) unexpectedLoss(err error) { + if l.stopped() { + return + } + l.complete(NewIssueError(IssueRequestFailed, err)) +} + +type realLinuxDBus struct{ connection *dbus.Conn } + +func (c realLinuxDBus) Object(destination string, path dbus.ObjectPath) linuxDBusObject { + return realLinuxDBusObject{object: c.connection.Object(destination, path)} +} + +func (c realLinuxDBus) Close() error { return c.connection.Close() } + +type realLinuxDBusObject struct{ object dbus.BusObject } + +func (o realLinuxDBusObject) Call(method string, flags dbus.Flags, args ...interface{}) *dbus.Call { + return o.object.Call(method, flags, args...) +} + +func (o realLinuxDBusObject) CallWithContext(ctx context.Context, method string, flags dbus.Flags, args ...interface{}) *dbus.Call { + return o.object.CallWithContext(ctx, method, flags, args...) +} diff --git a/apps/backend/internal/system/sleepinhibition/inhibitor_linux_test.go b/apps/backend/internal/system/sleepinhibition/inhibitor_linux_test.go new file mode 100644 index 0000000000..b826beda55 --- /dev/null +++ b/apps/backend/internal/system/sleepinhibition/inhibitor_linux_test.go @@ -0,0 +1,159 @@ +//go:build linux + +package sleepinhibition + +import ( + "context" + "errors" + "os" + "testing" + "time" + + "github.com/godbus/dbus/v5" +) + +func TestLinuxInhibitorRequestsOnlySystemSleep(t *testing.T) { + readFile, writeFile, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + defer func() { _ = readFile.Close() }() + defer func() { _ = writeFile.Close() }() + + connection := &fakeLinuxDBus{ + object: &fakeLinuxDBusObject{call: &dbus.Call{Body: []interface{}{dbus.UnixFD(writeFile.Fd())}}}, + } + inhibitor := newLinuxInhibitor(func() (linuxDBus, error) { return connection, nil }) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + lease, err := inhibitor.Acquire(ctx) + if err != nil { + t.Fatalf("acquire: %v", err) + } + if connection.object.method != login1Interface+".Inhibit" { + t.Fatalf("method = %q", connection.object.method) + } + if connection.object.context != ctx { + t.Fatal("acquire did not propagate its context") + } + if got := connection.object.args; len(got) != 4 || got[0] != "sleep" || got[1] != "Kandev" || got[2] != "A Kandev task is running" || got[3] != "block" { + t.Fatalf("inhibit args = %#v", got) + } + if err := lease.Release(); err != nil { + t.Fatalf("release: %v", err) + } + if !connection.closed { + t.Fatal("release did not close the D-Bus connection") + } +} + +func TestLinuxInhibitorMapsSystemBusFailure(t *testing.T) { + inhibitor := newLinuxInhibitor(func() (linuxDBus, error) { + return nil, errors.New("no system bus") + }) + + _, err := inhibitor.Acquire(context.Background()) + if err == nil || IssueFromError(err) != IssueSystemServiceUnavailable { + t.Fatalf("acquire error = %v, want system service unavailable", err) + } +} + +func TestLinuxInhibitorProbesLogin1(t *testing.T) { + connection := &fakeLinuxDBus{ + object: &fakeLinuxDBusObject{call: &dbus.Call{}}, + } + inhibitor := newLinuxInhibitor(func() (linuxDBus, error) { return connection, nil }) + prober, ok := inhibitor.(capabilityProber) + if !ok { + t.Fatal("linux inhibitor does not expose capability probe") + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := prober.Probe(ctx); err != nil { + t.Fatalf("probe: %v", err) + } + if connection.object.method != "org.freedesktop.DBus.Peer.Ping" { + t.Fatalf("probe method = %q", connection.object.method) + } + if connection.object.context != ctx { + t.Fatal("probe did not propagate its context") + } + if !connection.closed { + t.Fatal("probe did not close the D-Bus connection") + } +} + +func TestLinuxInhibitorMapsProbeFailure(t *testing.T) { + connection := &fakeLinuxDBus{ + object: &fakeLinuxDBusObject{call: &dbus.Call{Err: errors.New("name has no owner")}}, + } + inhibitor := newLinuxInhibitor(func() (linuxDBus, error) { return connection, nil }) + prober, ok := inhibitor.(capabilityProber) + if !ok { + t.Fatal("linux inhibitor does not expose capability probe") + } + + if err := prober.Probe(context.Background()); err == nil || IssueFromError(err) != IssueSystemServiceUnavailable { + t.Fatalf("probe error = %v, want system service unavailable", err) + } + if !connection.closed { + t.Fatal("failed probe did not close the D-Bus connection") + } +} + +func TestLinuxLeaseReportsRemoteDescriptorLoss(t *testing.T) { + readFile, writeFile, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + defer func() { _ = readFile.Close() }() + + connection := &fakeLinuxDBus{object: &fakeLinuxDBusObject{call: &dbus.Call{}}} + lease := newLinuxLease(connection, writeFile) + if err := readFile.Close(); err != nil { + t.Fatalf("close remote descriptor: %v", err) + } + + select { + case err := <-lease.Done(): + if IssueFromError(err) != IssueRequestFailed { + t.Fatalf("remote loss error = %v, want request_failed", err) + } + case <-time.After(time.Second): + t.Fatal("lease did not report remote descriptor loss") + } + if err := lease.Release(); err != nil { + t.Fatalf("release after remote loss: %v", err) + } +} + +type fakeLinuxDBus struct { + object *fakeLinuxDBusObject + closed bool +} + +func (c *fakeLinuxDBus) Object(string, dbus.ObjectPath) linuxDBusObject { return c.object } +func (c *fakeLinuxDBus) Close() error { + c.closed = true + return nil +} + +type fakeLinuxDBusObject struct { + call *dbus.Call + method string + args []interface{} + context context.Context +} + +func (o *fakeLinuxDBusObject) Call(method string, _ dbus.Flags, args ...interface{}) *dbus.Call { + o.method = method + o.args = args + return o.call +} + +func (o *fakeLinuxDBusObject) CallWithContext(ctx context.Context, method string, _ dbus.Flags, args ...interface{}) *dbus.Call { + o.context = ctx + return o.Call(method, 0, args...) +} diff --git a/apps/backend/internal/system/sleepinhibition/inhibitor_other.go b/apps/backend/internal/system/sleepinhibition/inhibitor_other.go new file mode 100644 index 0000000000..a61d28f382 --- /dev/null +++ b/apps/backend/internal/system/sleepinhibition/inhibitor_other.go @@ -0,0 +1,15 @@ +//go:build !darwin && !windows && !linux + +package sleepinhibition + +import "context" + +type unsupportedInhibitor struct{} + +func NewPlatformInhibitor() Inhibitor { return unsupportedInhibitor{} } + +func (unsupportedInhibitor) Platform() Platform { return PlatformOther } +func (unsupportedInhibitor) Supported() bool { return false } +func (unsupportedInhibitor) Acquire(context.Context) (Lease, error) { + return nil, ErrUnsupported +} diff --git a/apps/backend/internal/system/sleepinhibition/inhibitor_windows.go b/apps/backend/internal/system/sleepinhibition/inhibitor_windows.go new file mode 100644 index 0000000000..13d6efd0ad --- /dev/null +++ b/apps/backend/internal/system/sleepinhibition/inhibitor_windows.go @@ -0,0 +1,102 @@ +//go:build windows + +package sleepinhibition + +import ( + "context" + "runtime" + "sync" + + "golang.org/x/sys/windows" +) + +const ( + esContinuous uint32 = 0x80000000 + esSystemRequired uint32 = 0x00000001 + esInhibitionState = esContinuous | esSystemRequired +) + +type windowsStateCaller func(state uint32) (uint32, error) + +type windowsInhibitor struct { + call windowsStateCaller +} + +func NewPlatformInhibitor() Inhibitor { + proc := windows.NewLazySystemDLL("kernel32.dll").NewProc("SetThreadExecutionState") + return newWindowsInhibitor(func(state uint32) (uint32, error) { + result, _, err := proc.Call(uintptr(state)) + if result == 0 { + return 0, err + } + return uint32(result), nil + }) +} + +func newWindowsInhibitor(call windowsStateCaller) Inhibitor { + return &windowsInhibitor{call: call} +} + +func (i *windowsInhibitor) Platform() Platform { return PlatformWindows } +func (i *windowsInhibitor) Supported() bool { return true } + +func (i *windowsInhibitor) Acquire(ctx context.Context) (Lease, error) { + result := make(chan windowsAcquireResult, 1) + handoff := make(chan struct{}) + abort := make(chan struct{}) + go func() { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + if _, err := i.call(esInhibitionState); err != nil { + select { + case result <- windowsAcquireResult{err: NewIssueError(IssueRequestFailed, err)}: + case <-ctx.Done(): + } + return + } + lease := &windowsLease{release: make(chan struct{}), done: make(chan error, 1)} + result <- windowsAcquireResult{lease: lease} + select { + case <-handoff: + <-lease.release + case <-abort: + } + _, err := i.call(esContinuous) + lease.done <- err + close(lease.done) + }() + + select { + case response := <-result: + if response.err != nil { + return nil, response.err + } + close(handoff) + return response.lease, nil + case <-ctx.Done(): + close(abort) + return nil, ctx.Err() + } +} + +type windowsAcquireResult struct { + lease Lease + err error +} + +type windowsLease struct { + releaseOnce sync.Once + release chan struct{} + done chan error +} + +func (l *windowsLease) Release() error { + l.releaseOnce.Do(func() { close(l.release) }) + err, ok := <-l.done + if !ok { + return nil + } + return err +} + +func (l *windowsLease) Done() <-chan error { return l.done } diff --git a/apps/backend/internal/system/sleepinhibition/inhibitor_windows_test.go b/apps/backend/internal/system/sleepinhibition/inhibitor_windows_test.go new file mode 100644 index 0000000000..914a5504a0 --- /dev/null +++ b/apps/backend/internal/system/sleepinhibition/inhibitor_windows_test.go @@ -0,0 +1,91 @@ +//go:build windows + +package sleepinhibition + +import ( + "context" + "errors" + "sync" + "testing" + "time" +) + +func TestWindowsLeaseReleaseClearsExecutionStateOnce(t *testing.T) { + recorder := &windowsStateRecorder{} + inhibitor := newWindowsInhibitor(recorder.call) + + lease, err := inhibitor.Acquire(context.Background()) + if err != nil { + t.Fatalf("acquire: %v", err) + } + if err := lease.Release(); err != nil { + t.Fatalf("release: %v", err) + } + if err := lease.Release(); err != nil { + t.Fatalf("second release: %v", err) + } + + if got := recorder.states(); len(got) != 2 || got[0] != esInhibitionState || got[1] != esContinuous { + t.Fatalf("execution-state calls = %#v, want [%#x %#x]", got, esInhibitionState, esContinuous) + } +} + +func TestWindowsLeaseIgnoresContextCancellationAfterHandoff(t *testing.T) { + recorder := &windowsStateRecorder{} + inhibitor := newWindowsInhibitor(recorder.call) + ctx, cancel := context.WithCancel(context.Background()) + + lease, err := inhibitor.Acquire(ctx) + if err != nil { + t.Fatalf("acquire: %v", err) + } + cancel() + select { + case <-lease.Done(): + t.Fatal("request cancellation ended an owned lease") + case <-time.After(100 * time.Millisecond): + } + if got := recorder.states(); len(got) != 1 || got[0] != esInhibitionState { + t.Fatalf("execution-state calls after cancellation = %#v", got) + } + if err := lease.Release(); err != nil { + t.Fatalf("release: %v", err) + } +} + +func TestWindowsLeaseReportsResetError(t *testing.T) { + resetErr := errors.New("reset failed") + recorder := &windowsStateRecorder{resetErr: resetErr} + inhibitor := newWindowsInhibitor(recorder.call) + + lease, err := inhibitor.Acquire(context.Background()) + if err != nil { + t.Fatalf("acquire: %v", err) + } + if err := lease.Release(); !errors.Is(err, resetErr) { + t.Fatalf("release error = %v, want %v", err, resetErr) + } +} + +type windowsStateRecorder struct { + mu sync.Mutex + called []uint32 + resetErr error +} + +func (r *windowsStateRecorder) call(state uint32) (uint32, error) { + r.mu.Lock() + r.called = append(r.called, state) + resetErr := r.resetErr + r.mu.Unlock() + if state == esContinuous && resetErr != nil { + return 0, resetErr + } + return state, nil +} + +func (r *windowsStateRecorder) states() []uint32 { + r.mu.Lock() + defer r.mu.Unlock() + return append([]uint32(nil), r.called...) +} diff --git a/apps/backend/internal/system/sleepinhibition/service.go b/apps/backend/internal/system/sleepinhibition/service.go new file mode 100644 index 0000000000..59c3828c44 --- /dev/null +++ b/apps/backend/internal/system/sleepinhibition/service.go @@ -0,0 +1,418 @@ +package sleepinhibition + +import ( + "context" + "errors" + "sync" + "time" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/orchestrator/sessionstate" + "go.uber.org/zap" +) + +const defaultReconcileInterval = 30 * time.Second + +type Option func(*Service) + +func WithReconcileInterval(interval time.Duration) Option { + return func(service *Service) { + if interval > 0 { + service.reconcileInterval = interval + } + } +} + +type Service struct { + store *Store + sessions SessionReader + inhibitor Inhibitor + eventBus bus.EventBus + logger *logger.Logger + + reconcileInterval time.Duration + reconcileMu sync.Mutex + mu sync.Mutex + status Status + lease Lease + leaseGeneration uint64 + cancel context.CancelFunc + done chan struct{} + signal chan struct{} + subscription bus.Subscription + wg sync.WaitGroup +} + +// leaseWatch binds a lease completion channel to the generation that owned it. +// A lease can be released and replaced before its completion notification is +// delivered. The generation prevents that stale notification from clearing the +// replacement lease. +type leaseWatch struct { + generation uint64 + done <-chan error +} + +func NewService(store *Store, sessions SessionReader, inhibitor Inhibitor, eventBus bus.EventBus, log *logger.Logger, options ...Option) *Service { + service := &Service{ + store: store, + sessions: sessions, + inhibitor: inhibitor, + eventBus: eventBus, + logger: log, + reconcileInterval: defaultReconcileInterval, + status: statusFor(inhibitor), + } + for _, option := range options { + option(service) + } + return service +} + +func (s *Service) Start(ctx context.Context) error { + if s.store == nil || s.sessions == nil || s.inhibitor == nil || s.eventBus == nil { + return errors.New("sleep inhibition service dependencies are unavailable") + } + + s.mu.Lock() + if s.cancel != nil { + s.mu.Unlock() + return nil + } + runCtx, cancel := context.WithCancel(ctx) + s.cancel = cancel + s.done = make(chan struct{}) + s.signal = make(chan struct{}, 1) + s.mu.Unlock() + + subscription, err := s.eventBus.Subscribe(events.TaskSessionStateChanged, s.handleSessionEvent) + if err != nil { + cancel() + s.mu.Lock() + s.cancel = nil + s.done = nil + s.signal = nil + s.mu.Unlock() + return err + } + + s.mu.Lock() + s.subscription = subscription + done := s.done + s.wg.Add(1) + s.mu.Unlock() + go s.run(runCtx, done) + s.signalReconcile() + return nil +} + +func (s *Service) Stop() { + s.mu.Lock() + cancel := s.cancel + done := s.done + subscription := s.subscription + s.mu.Unlock() + if subscription != nil { + _ = subscription.Unsubscribe() + } + if cancel != nil { + cancel() + } + if done != nil { + <-done + } + s.wg.Wait() + + s.releaseLease(context.Background()) + s.mu.Lock() + s.cancel = nil + s.done = nil + s.signal = nil + s.subscription = nil + s.mu.Unlock() +} + +func (s *Service) Get(ctx context.Context) (Response, error) { + settings, err := s.loadSettings(ctx) + if err != nil { + if IsInvalidPersisted(err) { + s.warn("Ignoring invalid persisted task sleep inhibition settings", err) + settings = Settings{} + } else { + return Response{}, err + } + } + s.mu.Lock() + status := s.status + s.mu.Unlock() + return Response{Settings: settings, Status: status}, nil +} + +func (s *Service) Update(ctx context.Context, settings Settings) (Response, error) { + if s.store == nil { + return Response{}, errors.New("sleep inhibition settings store is unavailable") + } + if err := s.store.Save(ctx, settings); err != nil { + return Response{}, err + } + s.reconcile(ctx) + return s.Get(ctx) +} + +func (s *Service) handleSessionEvent(context.Context, *bus.Event) error { + s.signalReconcile() + return nil +} + +func (s *Service) signalReconcile() { + s.mu.Lock() + signal := s.signal + s.mu.Unlock() + if signal == nil { + return + } + select { + case signal <- struct{}{}: + default: + } +} + +func (s *Service) run(ctx context.Context, done chan struct{}) { + defer s.wg.Done() + defer close(done) + ticker := time.NewTicker(s.reconcileInterval) + defer ticker.Stop() + var watch leaseWatch + for { + watch = s.reconcile(ctx) + select { + case <-ctx.Done(): + return + case <-s.signalChannel(): + case <-ticker.C: + case err, ok := <-watch.done: + s.handleLeaseExit(watch, err, ok) + } + } +} + +func (s *Service) signalChannel() <-chan struct{} { + s.mu.Lock() + defer s.mu.Unlock() + return s.signal +} + +func (s *Service) reconcile(ctx context.Context) leaseWatch { + s.reconcileMu.Lock() + defer s.reconcileMu.Unlock() + + settings, err := s.loadSettings(ctx) + if err != nil { + if IsInvalidPersisted(err) { + s.warn("Ignoring invalid persisted task sleep inhibition settings", err) + settings = Settings{} + } else { + s.warn("Unable to load task sleep inhibition settings", err) + return s.currentLeaseWatch() + } + } + + if !settings.Enabled { + s.clearLeaseStatus(ctx) + return leaseWatch{} + } + + if !s.probeCapability(ctx) { + return s.currentLeaseWatch() + } + + working, err := s.hasWorkingSession(ctx) + if err != nil { + s.warn("Unable to read active task sessions for sleep inhibition", err) + return s.currentLeaseWatch() + } + + if !working { + s.clearLeaseStatus(ctx) + return leaseWatch{} + } + + s.mu.Lock() + lease := s.lease + s.mu.Unlock() + if lease != nil { + s.setStatus(func(status *Status) { + status.Active = true + status.Issue = "" + }) + return s.currentLeaseWatch() + } + + if !s.inhibitor.Supported() { + s.setStatus(func(status *Status) { + status.Active = false + status.Issue = IssueUnsupportedPlatform + }) + return leaseWatch{} + } + + lease, err = s.inhibitor.Acquire(ctx) + if err != nil { + issue := IssueFromError(err) + s.warn("Unable to acquire task sleep inhibition", err, zap.String("issue", string(issue))) + s.setStatus(func(status *Status) { + status.Active = false + status.Issue = issue + }) + return leaseWatch{} + } + s.mu.Lock() + s.lease = lease + s.leaseGeneration++ + generation := s.leaseGeneration + s.mu.Unlock() + s.setStatus(func(status *Status) { + status.Active = true + status.Issue = "" + }) + return leaseWatch{generation: generation, done: lease.Done()} +} + +type capabilityProber interface { + Probe(context.Context) error +} + +func (s *Service) probeCapability(ctx context.Context) bool { + prober, ok := s.inhibitor.(capabilityProber) + if !ok { + return true + } + if err := prober.Probe(ctx); err != nil { + s.mu.Lock() + lease := s.lease + s.mu.Unlock() + if lease == nil { + s.setStatus(func(status *Status) { + status.Active = false + status.Issue = IssueFromError(err) + }) + } + s.warn("Unable to probe task sleep inhibition service", err) + return false + } + return true +} + +func (s *Service) clearLeaseStatus(ctx context.Context) { + s.releaseLease(ctx) + s.setStatus(func(status *Status) { + status.Active = false + if status.Supported { + status.Issue = "" + } else { + status.Issue = IssueUnsupportedPlatform + } + }) +} + +func (s *Service) hasWorkingSession(ctx context.Context) (bool, error) { + sessions, err := s.sessions.ListActiveTaskSessions(ctx) + if err != nil { + return false, err + } + for _, session := range sessions { + if session != nil && sessionstate.IsWorking(session.State) { + return true, nil + } + } + return false, nil +} + +func (s *Service) currentLeaseWatch() leaseWatch { + s.mu.Lock() + defer s.mu.Unlock() + if s.lease == nil { + return leaseWatch{} + } + return leaseWatch{generation: s.leaseGeneration, done: s.lease.Done()} +} + +func (s *Service) handleLeaseExit(watch leaseWatch, err error, ok bool) { + s.mu.Lock() + if s.lease == nil || watch.generation != s.leaseGeneration { + s.mu.Unlock() + return + } + lease := s.lease + s.lease = nil + s.leaseGeneration++ + s.status.Active = false + switch { + case err != nil: + s.status.Issue = IssueFromError(err) + case ok: + s.status.Issue = IssueRequestFailed + default: + s.status.Issue = IssueRequestFailed + } + s.mu.Unlock() + if releaseErr := lease.Release(); releaseErr != nil { + s.warn("Unable to release ended task sleep inhibition lease", releaseErr) + } + if err != nil { + s.warn("Task sleep inhibition lease ended unexpectedly", err) + } + s.signalReconcile() +} + +func (s *Service) releaseLease(ctx context.Context) { + s.mu.Lock() + lease := s.lease + if lease != nil { + s.lease = nil + s.leaseGeneration++ + } + s.mu.Unlock() + if lease == nil { + return + } + if err := lease.Release(); err != nil { + s.warn("Unable to release task sleep inhibition", err) + } + _ = ctx +} + +func (s *Service) setStatus(update func(*Status)) { + s.mu.Lock() + defer s.mu.Unlock() + update(&s.status) +} + +func (s *Service) loadSettings(ctx context.Context) (Settings, error) { + if s.store == nil { + return Settings{}, errors.New("sleep inhibition settings store is unavailable") + } + return s.store.Load(ctx) +} + +func (s *Service) warn(message string, err error, fields ...zap.Field) { + if s.logger == nil { + return + } + if err != nil { + fields = append([]zap.Field{zap.Error(err)}, fields...) + } + s.logger.Warn(message, fields...) +} + +func statusFor(inhibitor Inhibitor) Status { + if inhibitor == nil { + return Status{Platform: PlatformOther, Supported: false} + } + status := Status{Platform: inhibitor.Platform(), Supported: inhibitor.Supported()} + if !status.Supported { + status.Issue = IssueUnsupportedPlatform + } + return status +} diff --git a/apps/backend/internal/system/sleepinhibition/service_test.go b/apps/backend/internal/system/sleepinhibition/service_test.go new file mode 100644 index 0000000000..e5c6584aea --- /dev/null +++ b/apps/backend/internal/system/sleepinhibition/service_test.go @@ -0,0 +1,396 @@ +package sleepinhibition + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/task/models" +) + +func TestServiceReconcilesOneSharedLeaseAcrossWorkingSessions(t *testing.T) { + reader := &fakeSessionReader{} + inhibitor := &fakeInhibitor{platform: PlatformLinux, supported: true} + service := newTestService(t, reader, inhibitor) + + if err := service.Start(context.Background()); err != nil { + t.Fatalf("start: %v", err) + } + defer service.Stop() + if _, err := service.Update(context.Background(), Settings{Enabled: true}); err != nil { + t.Fatalf("enable: %v", err) + } + + reader.set(session(models.TaskSessionStateStarting)) + publishSessionEvent(t, service) + waitFor(t, func() bool { return inhibitor.acquireCount() == 1 }) + + reader.set( + session(models.TaskSessionStateStarting), + session(models.TaskSessionStateRunning), + ) + publishSessionEvent(t, service) + time.Sleep(10 * time.Millisecond) + if got := inhibitor.acquireCount(); got != 1 { + t.Fatalf("acquire count with two working sessions = %d, want 1", got) + } + + reader.set(session(models.TaskSessionStateWaitingForInput)) + publishSessionEvent(t, service) + waitFor(t, func() bool { return inhibitor.releaseCount() == 1 }) + + response, err := service.Update(context.Background(), Settings{Enabled: false}) + if err != nil { + t.Fatalf("disable: %v", err) + } + if response.Settings.Enabled { + t.Fatal("disabled response retained enabled setting") + } + if response.Status.Active { + t.Fatal("disabled response retained active lease") + } +} + +func TestServiceStartupAndUpdateAcquireOnlyWhenWorking(t *testing.T) { + reader := &fakeSessionReader{} + reader.set(session(models.TaskSessionStateRunning)) + inhibitor := &fakeInhibitor{platform: PlatformLinux, supported: true} + service := newTestService(t, reader, inhibitor) + + if _, err := service.Update(context.Background(), Settings{Enabled: true}); err != nil { + t.Fatalf("enable before start: %v", err) + } + if inhibitor.acquireCount() != 1 { + t.Fatalf("synchronous update acquire count = %d, want 1", inhibitor.acquireCount()) + } + service.Stop() + if inhibitor.releaseCount() != 1 { + t.Fatalf("shutdown release count = %d, want 1", inhibitor.releaseCount()) + } +} + +func TestServiceRetriesAfterUnexpectedLeaseExitAndAcquireFailure(t *testing.T) { + reader := &fakeSessionReader{} + reader.set(session(models.TaskSessionStateRunning)) + inhibitor := &fakeInhibitor{ + platform: PlatformLinux, + supported: true, + acquireErr: NewIssueError(IssueRequestFailed, errors.New("temporary")), + } + service := newTestService(t, reader, inhibitor, WithReconcileInterval(5*time.Millisecond)) + if _, err := service.Update(context.Background(), Settings{Enabled: true}); err != nil { + t.Fatalf("enable: %v", err) + } + response, err := service.Get(context.Background()) + if err != nil { + t.Fatalf("get failed state: %v", err) + } + if response.Status.Issue != IssueRequestFailed { + t.Fatalf("failed acquire issue = %q, want %q", response.Status.Issue, IssueRequestFailed) + } + + inhibitor.setAcquireErr(nil) + if err := service.Start(context.Background()); err != nil { + t.Fatalf("start: %v", err) + } + defer service.Stop() + waitFor(t, func() bool { return inhibitor.acquireCount() >= 2 }) + lease := inhibitor.lastLease() + lease.fail(errors.New("helper exited")) + waitFor(t, func() bool { return inhibitor.acquireCount() >= 3 }) + response, err = service.Get(context.Background()) + if err != nil { + t.Fatalf("get after lease retry: %v", err) + } + if !response.Status.Active { + t.Fatalf("lease was not active after retry: %#v", response.Status) + } +} + +func TestServiceKeepsLeaseWhenRepositoryReadFails(t *testing.T) { + reader := &fakeSessionReader{} + reader.set(session(models.TaskSessionStateRunning)) + inhibitor := &fakeInhibitor{platform: PlatformLinux, supported: true} + service := newTestService(t, reader, inhibitor) + if _, err := service.Update(context.Background(), Settings{Enabled: true}); err != nil { + t.Fatalf("enable: %v", err) + } + if !inhibitor.hasLease() { + t.Fatal("expected lease after enabling with working session") + } + + reader.setError(errors.New("database unavailable")) + if _, err := service.Update(context.Background(), Settings{Enabled: true}); err != nil { + t.Fatalf("update with read failure: %v", err) + } + response, err := service.Get(context.Background()) + if err != nil { + t.Fatalf("get after read failure: %v", err) + } + if !response.Status.Active || inhibitor.releaseCount() != 0 { + t.Fatalf("read failure changed active lease: response=%#v releases=%d", response, inhibitor.releaseCount()) + } +} + +func TestServiceReportsCapabilityFailureBeforeAWorkingSessionExists(t *testing.T) { + reader := &fakeSessionReader{} + inhibitor := &fakeInhibitor{ + platform: PlatformLinux, + supported: true, + probeErr: NewIssueError(IssueSystemServiceUnavailable, errors.New("logind unavailable")), + } + service := newTestService(t, reader, inhibitor) + + response, err := service.Update(context.Background(), Settings{Enabled: true}) + if err != nil { + t.Fatalf("enable: %v", err) + } + if response.Status.Active { + t.Fatal("capability failure reported an active lease") + } + if response.Status.Issue != IssueSystemServiceUnavailable { + t.Fatalf("capability issue = %q, want %q", response.Status.Issue, IssueSystemServiceUnavailable) + } +} + +func TestServiceClassifiesUnexpectedLeaseErrors(t *testing.T) { + service := newTestService(t, &fakeSessionReader{}, &fakeInhibitor{platform: PlatformLinux, supported: true}) + lease := newFakeLease(&fakeInhibitor{platform: PlatformLinux, supported: true}) + service.mu.Lock() + service.lease = lease + service.leaseGeneration = 1 + service.mu.Unlock() + + service.handleLeaseExit( + leaseWatch{generation: 1, done: lease.Done()}, + NewIssueError(IssueSystemServiceUnavailable, errors.New("logind stopped")), + true, + ) + response, err := service.Get(context.Background()) + if err != nil { + t.Fatalf("get: %v", err) + } + if response.Status.Issue != IssueSystemServiceUnavailable { + t.Fatalf("lease issue = %q, want %q", response.Status.Issue, IssueSystemServiceUnavailable) + } +} + +func TestServiceIgnoresStaleLeaseCompletionAfterReplacement(t *testing.T) { + reader := &fakeSessionReader{} + reader.set(session(models.TaskSessionStateRunning)) + inhibitor := &fakeInhibitor{platform: PlatformLinux, supported: true} + service := newTestService(t, reader, inhibitor) + + if _, err := service.Update(context.Background(), Settings{Enabled: true}); err != nil { + t.Fatalf("enable: %v", err) + } + first := inhibitor.lastLease() + service.mu.Lock() + firstWatch := leaseWatch{generation: service.leaseGeneration, done: first.Done()} + service.mu.Unlock() + + service.clearLeaseStatus(context.Background()) + if _, err := service.Update(context.Background(), Settings{Enabled: true}); err != nil { + t.Fatalf("re-enable: %v", err) + } + second := inhibitor.lastLease() + if second == first { + t.Fatal("re-enable reused the released lease") + } + + // Deliver the completion from the first lease after the replacement is + // already active. It must not clear or release the replacement lease. + service.handleLeaseExit(firstWatch, errors.New("stale lease completion"), true) + + service.mu.Lock() + current := service.lease + status := service.status + service.mu.Unlock() + if current != second { + t.Fatal("stale lease completion discarded the replacement lease") + } + if !status.Active { + t.Fatalf("stale lease completion marked replacement inactive: %#v", status) + } + if got := inhibitor.releaseCount(); got != 1 { + t.Fatalf("stale lease completion released %d leases, want only the first", got) + } +} + +func newTestService(t *testing.T, reader *fakeSessionReader, inhibitor *fakeInhibitor, options ...Option) *Service { + t.Helper() + eventBus := bus.NewMemoryEventBus(logger.Default()) + service := NewService(NewStore(&memoryRawStore{}), reader, inhibitor, eventBus, nil, options...) + t.Cleanup(service.Stop) + return service +} + +func publishSessionEvent(t *testing.T, service *Service) { + t.Helper() + if err := service.eventBus.Publish( + context.Background(), + events.TaskSessionStateChanged, + bus.NewEvent(events.TaskSessionStateChanged, "sleep-inhibition-test", nil), + ); err != nil { + t.Fatalf("publish session event: %v", err) + } +} + +func waitFor(t *testing.T, predicate func() bool) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if predicate() { + return + } + time.Sleep(5 * time.Millisecond) + } + t.Fatal("timed out waiting for condition") +} + +func session(state models.TaskSessionState) *models.TaskSession { + return &models.TaskSession{State: state} +} + +type memoryRawStore struct { + mu sync.Mutex + raw []byte + found bool +} + +func (s *memoryRawStore) Get(context.Context, string) ([]byte, bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + return append([]byte(nil), s.raw...), s.found, nil +} + +func (s *memoryRawStore) Save(_ context.Context, _ string, raw []byte) error { + s.mu.Lock() + defer s.mu.Unlock() + s.raw = append([]byte(nil), raw...) + s.found = true + return nil +} + +type fakeSessionReader struct { + mu sync.Mutex + sessions []*models.TaskSession + err error +} + +func (r *fakeSessionReader) ListActiveTaskSessions(context.Context) ([]*models.TaskSession, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.err != nil { + return nil, r.err + } + return append([]*models.TaskSession(nil), r.sessions...), nil +} + +func (r *fakeSessionReader) set(sessions ...*models.TaskSession) { + r.mu.Lock() + r.sessions = sessions + r.err = nil + r.mu.Unlock() +} + +func (r *fakeSessionReader) setError(err error) { + r.mu.Lock() + r.err = err + r.mu.Unlock() +} + +type fakeInhibitor struct { + mu sync.Mutex + platform Platform + supported bool + probeErr error + acquireErr error + leases []*fakeLease + acquires int + releases int +} + +func (i *fakeInhibitor) Platform() Platform { return i.platform } +func (i *fakeInhibitor) Supported() bool { return i.supported } +func (i *fakeInhibitor) Probe(context.Context) error { + i.mu.Lock() + defer i.mu.Unlock() + return i.probeErr +} + +func (i *fakeInhibitor) Acquire(context.Context) (Lease, error) { + i.mu.Lock() + defer i.mu.Unlock() + i.acquires++ + if i.acquireErr != nil { + return nil, i.acquireErr + } + lease := newFakeLease(i) + i.leases = append(i.leases, lease) + return lease, nil +} + +func (i *fakeInhibitor) setAcquireErr(err error) { + i.mu.Lock() + i.acquireErr = err + i.mu.Unlock() +} + +func (i *fakeInhibitor) acquireCount() int { + i.mu.Lock() + defer i.mu.Unlock() + return i.acquires +} + +func (i *fakeInhibitor) releaseCount() int { + i.mu.Lock() + defer i.mu.Unlock() + return i.releases +} + +func (i *fakeInhibitor) hasLease() bool { + i.mu.Lock() + defer i.mu.Unlock() + return len(i.leases) > 0 +} + +func (i *fakeInhibitor) lastLease() *fakeLease { + i.mu.Lock() + defer i.mu.Unlock() + return i.leases[len(i.leases)-1] +} + +type fakeLease struct { + inhibitor *fakeInhibitor + done chan error + once sync.Once +} + +func newFakeLease(inhibitor *fakeInhibitor) *fakeLease { + return &fakeLease{inhibitor: inhibitor, done: make(chan error, 1)} +} + +func (l *fakeLease) Release() error { + l.once.Do(func() { + l.inhibitor.mu.Lock() + l.inhibitor.releases++ + l.inhibitor.mu.Unlock() + close(l.done) + }) + return nil +} + +func (l *fakeLease) Done() <-chan error { return l.done } + +func (l *fakeLease) fail(err error) { + l.once.Do(func() { + l.done <- err + close(l.done) + }) +} diff --git a/apps/backend/internal/system/sleepinhibition/store.go b/apps/backend/internal/system/sleepinhibition/store.go new file mode 100644 index 0000000000..8f61a837e1 --- /dev/null +++ b/apps/backend/internal/system/sleepinhibition/store.go @@ -0,0 +1,44 @@ +package sleepinhibition + +import ( + "context" + "encoding/json" + "fmt" +) + +type RawStore interface { + Get(ctx context.Context, key string) ([]byte, bool, error) + Save(ctx context.Context, key string, value []byte) error +} + +type Store struct { + raw RawStore +} + +func NewStore(raw RawStore) *Store { return &Store{raw: raw} } + +func (s *Store) Load(ctx context.Context) (Settings, error) { + raw, found, err := s.raw.Get(ctx, SettingsKey) + if err != nil { + return Settings{}, fmt.Errorf("load task sleep inhibition settings: %w", err) + } + if !found { + return Settings{}, nil + } + var settings Settings + if err := json.Unmarshal(raw, &settings); err != nil { + return Settings{}, fmt.Errorf("%w: decode JSON: %v", ErrInvalidPersisted, err) + } + return settings, nil +} + +func (s *Store) Save(ctx context.Context, settings Settings) error { + raw, err := json.Marshal(settings) + if err != nil { + return fmt.Errorf("encode task sleep inhibition settings: %w", err) + } + if err := s.raw.Save(ctx, SettingsKey, raw); err != nil { + return fmt.Errorf("save task sleep inhibition settings: %w", err) + } + return nil +} diff --git a/apps/backend/internal/system/sleepinhibition/store_test.go b/apps/backend/internal/system/sleepinhibition/store_test.go new file mode 100644 index 0000000000..5c2470b051 --- /dev/null +++ b/apps/backend/internal/system/sleepinhibition/store_test.go @@ -0,0 +1,67 @@ +package sleepinhibition + +import ( + "context" + "testing" + + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" + + "github.com/kandev/kandev/internal/db" + systemsettings "github.com/kandev/kandev/internal/system/settings" +) + +func TestStoreDefaultsToDisabledWhenAbsent(t *testing.T) { + store := newSleepInhibitionStore(t) + + settings, err := store.Load(context.Background()) + if err != nil { + t.Fatalf("load missing settings: %v", err) + } + if settings != (Settings{}) { + t.Fatalf("missing settings = %#v, want disabled", settings) + } +} + +func TestStoreRoundTripsSettings(t *testing.T) { + store := newSleepInhibitionStore(t) + want := Settings{Enabled: true} + + if err := store.Save(context.Background(), want); err != nil { + t.Fatalf("save: %v", err) + } + got, err := store.Load(context.Background()) + if err != nil { + t.Fatalf("load: %v", err) + } + if got != want { + t.Fatalf("round trip = %#v, want %#v", got, want) + } +} + +func TestStoreMalformedValueReturnsTypedError(t *testing.T) { + store := newSleepInhibitionStore(t) + if err := store.raw.Save(context.Background(), SettingsKey, []byte(`{"enabled":"yes"}`)); err != nil { + t.Fatalf("seed malformed setting: %v", err) + } + + _, err := store.Load(context.Background()) + if err == nil || !IsInvalidPersisted(err) { + t.Fatalf("malformed load error = %v, want invalid persisted error", err) + } +} + +func newSleepInhibitionStore(t *testing.T) *Store { + t.Helper() + conn, err := sqlx.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + conn.SetMaxOpenConns(1) + t.Cleanup(func() { _ = conn.Close() }) + raw, err := systemsettings.NewStore(db.NewPool(conn, conn)) + if err != nil { + t.Fatalf("new system settings store: %v", err) + } + return NewStore(raw) +} diff --git a/apps/backend/internal/system/sleepinhibition/types.go b/apps/backend/internal/system/sleepinhibition/types.go new file mode 100644 index 0000000000..45b6f164ec --- /dev/null +++ b/apps/backend/internal/system/sleepinhibition/types.go @@ -0,0 +1,98 @@ +package sleepinhibition + +import ( + "context" + "errors" + "fmt" + + "github.com/kandev/kandev/internal/task/models" +) + +const SettingsKey = "task_sleep_inhibition" + +type IssueCode string + +const ( + IssueUnsupportedPlatform IssueCode = "unsupported_platform" + IssueSystemServiceUnavailable IssueCode = "system_service_unavailable" + IssueRequestFailed IssueCode = "request_failed" +) + +type Platform string + +const ( + PlatformDarwin Platform = "darwin" + PlatformWindows Platform = "windows" + PlatformLinux Platform = "linux" + PlatformOther Platform = "other" +) + +var ( + ErrInvalidPersisted = errors.New("invalid persisted task sleep inhibition settings") + ErrUnsupported = errors.New("sleep inhibition is unsupported on this platform") +) + +type IssueError struct { + Code IssueCode + Err error +} + +func (e *IssueError) Error() string { + if e == nil || e.Err == nil { + return string(e.Code) + } + return fmt.Sprintf("%s: %v", e.Code, e.Err) +} + +func (e *IssueError) Unwrap() error { return e.Err } + +func NewIssueError(code IssueCode, err error) error { + return &IssueError{Code: code, Err: err} +} + +func IsInvalidPersisted(err error) bool { return errors.Is(err, ErrInvalidPersisted) } + +func IssueFromError(err error) IssueCode { + if err == nil { + return "" + } + var issue *IssueError + if errors.As(err, &issue) && issue != nil { + return issue.Code + } + if errors.Is(err, ErrUnsupported) { + return IssueUnsupportedPlatform + } + return IssueRequestFailed +} + +type Settings struct { + Enabled bool `json:"enabled"` +} + +type Status struct { + Platform Platform `json:"platform"` + Supported bool `json:"supported"` + Active bool `json:"active"` + Issue IssueCode `json:"issue,omitempty"` +} + +type Response struct { + Settings Settings `json:"settings"` + Status Status `json:"status"` +} + +type SessionReader interface { + ListActiveTaskSessions(ctx context.Context) ([]*models.TaskSession, error) +} + +type Lease interface { + Release() error + Done() <-chan error +} + +type Inhibitor interface { + Platform() Platform + Supported() bool + Acquire(ctx context.Context) (Lease, error) +} diff --git a/apps/backend/internal/system/system.go b/apps/backend/internal/system/system.go index 9b70853f32..81aacf7c4b 100644 --- a/apps/backend/internal/system/system.go +++ b/apps/backend/internal/system/system.go @@ -33,6 +33,7 @@ import ( "github.com/kandev/kandev/internal/system/queuesettings" "github.com/kandev/kandev/internal/system/restart" systemsettings "github.com/kandev/kandev/internal/system/settings" + "github.com/kandev/kandev/internal/system/sleepinhibition" "github.com/kandev/kandev/internal/system/storage" "github.com/kandev/kandev/internal/system/updates" "go.uber.org/zap" @@ -48,32 +49,34 @@ type BuildInfo struct { BuildTime string } -// Wiring holds the runtime hooks the destructive endpoints need — -// currently only OrchestratorShutdown, which stops in-flight agent -// executions before factory reset wipes their backing data. The frontend -// dialog prompts the user to relaunch after a successful reset/restore; -// no in-process re-exec is performed. +// Wiring supplies the runtime hooks and repositories owned by the wider +// application. OrchestratorShutdown stops in-flight agent executions before +// destructive resets; TaskSessions is the authoritative session reader used +// by the install-wide sleep-inhibition service. type Wiring struct { OrchestratorShutdown func() MessageQueue queuesettings.Target + TaskSessions sleepinhibition.SessionReader } // Service exposes the composed system sub-services. Each field is // addressable so the cmd/kandev wiring can attach callbacks (Restart) // after construction. type Service struct { - Info *info.Service - Jobs *jobs.Tracker - Disk *disk.Service - Database *database.Service - Backups *backups.Service - LogBundles *logbundle.Service - FrontendErrors *frontenderrors.Service - Metrics *metrics.Service - MessageQueue *queuesettings.Service - Updates *updates.Service - Restart restart.Manager - Storage *storage.Handler + logger *logger.Logger + Info *info.Service + Jobs *jobs.Tracker + Disk *disk.Service + Database *database.Service + Backups *backups.Service + LogBundles *logbundle.Service + FrontendErrors *frontenderrors.Service + Metrics *metrics.Service + MessageQueue *queuesettings.Service + SleepInhibition *sleepinhibition.Service + Updates *updates.Service + Restart restart.Manager + Storage *storage.Handler // StorageRuntime owns the scheduler, reconciliation, and durable cleanup worker. StorageRuntime *storage.Runtime } @@ -105,6 +108,7 @@ func Provide(cfg *config.Config, log *logger.Logger, pool *db.Pool, eventBus bus } var metricsSvc *metrics.Service var queueSettingsSvc *queuesettings.Service + var sleepInhibitionSvc *sleepinhibition.Service updatesOpts := []updates.Option{updates.WithHomeDir(homeDir), updates.WithJobs(tracker)} if settingsStore != nil { metricsStore := metrics.NewStore(settingsStore) @@ -114,6 +118,15 @@ func Provide(cfg *config.Config, log *logger.Logger, pool *db.Pool, eventBus bus queuesettings.NewStore(settingsStore), wiring.MessageQueue, nil, log, ) } + if wiring.TaskSessions != nil { + sleepInhibitionSvc = sleepinhibition.NewService( + sleepinhibition.NewStore(settingsStore), + wiring.TaskSessions, + sleepinhibition.NewPlatformInhibitor(), + eventBus, + log, + ) + } updatesOpts = append(updatesOpts, updates.WithSettingsStore(settingsStore)) } @@ -123,6 +136,7 @@ func Provide(cfg *config.Config, log *logger.Logger, pool *db.Pool, eventBus bus } return &Service{ + logger: log, Info: info.NewService(build.Version, build.Commit, build.BuildTime), Jobs: tracker, Disk: disk.NewService(homeDir, tracker, log), @@ -132,11 +146,12 @@ func Provide(cfg *config.Config, log *logger.Logger, pool *db.Pool, eventBus bus HomeDir: homeDir, Version: build.Version, Commit: build.Commit, BuildTime: build.BuildTime, Log: log, }), - FrontendErrors: frontenderrors.New(log, nil), - Metrics: metricsSvc, - MessageQueue: queueSettingsSvc, - Updates: updatesSvc, - Restart: restart.NewManagerFromEnv(), + FrontendErrors: frontenderrors.New(log, nil), + Metrics: metricsSvc, + MessageQueue: queueSettingsSvc, + SleepInhibition: sleepInhibitionSvc, + Updates: updatesSvc, + Restart: restart.NewManagerFromEnv(), } } @@ -186,6 +201,9 @@ func (s *Service) RegisterRoutes(router *gin.Engine, log *logger.Logger) { if s.MessageQueue != nil { queuesettings.RegisterRoutes(g, admin, s.MessageQueue) } + if s.SleepInhibition != nil { + sleepinhibition.RegisterRoutes(g, admin, s.SleepInhibition) + } g.GET("/updates", updates.HandleGet(s.Updates)) admin.POST("/updates/check", updates.HandleCheck(s.Updates)) @@ -199,8 +217,8 @@ func (s *Service) RegisterRoutes(router *gin.Engine, log *logger.Logger) { log.Debug("Registered System routes (HTTP)") } -// StartBackground kicks off the updates poller goroutine. The poller -// stops when ctx is cancelled. +// StartBackground starts the System-owned pollers and reconciliation loops. +// They stop when the application cleanup path calls StopBackground. func (s *Service) StartBackground(ctx context.Context) { if s.LogBundles != nil { s.LogBundles.Start(ctx) @@ -211,10 +229,20 @@ func (s *Service) StartBackground(ctx context.Context) { if s.StorageRuntime != nil { _ = s.StorageRuntime.Start(ctx) } + if s.SleepInhibition != nil { + if err := s.SleepInhibition.Start(ctx); err != nil { + if s.logger != nil { + s.logger.Warn("failed to start sleep inhibition service", zap.Error(err)) + } + } + } } // StopBackground joins owned storage background workers. func (s *Service) StopBackground() { + if s.SleepInhibition != nil { + s.SleepInhibition.Stop() + } if s.LogBundles != nil { s.LogBundles.Stop() } diff --git a/apps/backend/internal/system/system_routes_test.go b/apps/backend/internal/system/system_routes_test.go index 0ea3618e03..1934025ebb 100644 --- a/apps/backend/internal/system/system_routes_test.go +++ b/apps/backend/internal/system/system_routes_test.go @@ -11,8 +11,11 @@ import ( "github.com/gin-gonic/gin" "github.com/kandev/kandev/internal/auth/authn" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/system/frontenderrors" "github.com/kandev/kandev/internal/system/queuesettings" + "github.com/kandev/kandev/internal/system/sleepinhibition" + "github.com/kandev/kandev/internal/task/models" "go.uber.org/zap" ) @@ -79,6 +82,44 @@ func TestRegisterRoutesMessageQueueSettingsPermissions(t *testing.T) { } } +func TestRegisterRoutesSleepInhibitionPermissions(t *testing.T) { + gin.SetMode(gin.TestMode) + log, err := logger.NewFromZap(zap.NewNop()) + if err != nil { + t.Fatal(err) + } + service := sleepinhibition.NewService( + sleepinhibition.NewStore(&systemSleepRawStore{}), + systemSleepReader{}, + systemSleepInhibitor{}, + bus.NewMemoryEventBus(nil), + nil, + ) + + memberRouter := systemRouterForRole(authn.RoleMember) + (&Service{SleepInhibition: service}).RegisterRoutes(memberRouter, log) + getResponse := httptest.NewRecorder() + memberRouter.ServeHTTP(getResponse, httptest.NewRequest( + http.MethodGet, "/api/v1/system/sleep-inhibition", nil, + )) + if getResponse.Code != http.StatusOK { + t.Fatalf("member GET status = %d, want 200; body=%s", getResponse.Code, getResponse.Body.String()) + } + patchResponse := httptest.NewRecorder() + memberRouter.ServeHTTP(patchResponse, sleepInhibitionPatchRequest(true)) + if patchResponse.Code != http.StatusForbidden { + t.Fatalf("member PATCH status = %d, want 403; body=%s", patchResponse.Code, patchResponse.Body.String()) + } + + adminRouter := systemRouterForRole(authn.RoleAdmin) + (&Service{SleepInhibition: service}).RegisterRoutes(adminRouter, log) + adminResponse := httptest.NewRecorder() + adminRouter.ServeHTTP(adminResponse, sleepInhibitionPatchRequest(true)) + if adminResponse.Code != http.StatusOK || !bytes.Contains(adminResponse.Body.Bytes(), []byte(`"enabled":true`)) { + t.Fatalf("admin PATCH status=%d body=%s", adminResponse.Code, adminResponse.Body.String()) + } +} + func systemRouterForRole(role authn.Role) *gin.Engine { router := gin.New() router.Use(func(ctx *gin.Context) { @@ -95,6 +136,12 @@ func queueSettingsPatchRequest(max int) *http.Request { return request } +func sleepInhibitionPatchRequest(enabled bool) *http.Request { + request := httptest.NewRequest(http.MethodPatch, "/api/v1/system/sleep-inhibition", bytes.NewBufferString(fmt.Sprintf(`{"enabled":%t}`, enabled))) + request.Header.Set("Content-Type", "application/json") + return request +} + type queueSettingsRawStore struct { raw []byte found bool @@ -116,3 +163,32 @@ type queueSettingsTarget struct { func (t *queueSettingsTarget) MaxPerSession() int { return t.max } func (t *queueSettingsTarget) SetMaxPerSession(n int) { t.max = n } + +type systemSleepRawStore struct { + raw []byte + found bool +} + +func (s *systemSleepRawStore) Get(context.Context, string) ([]byte, bool, error) { + return s.raw, s.found, nil +} + +func (s *systemSleepRawStore) Save(_ context.Context, _ string, value []byte) error { + s.raw = append([]byte(nil), value...) + s.found = true + return nil +} + +type systemSleepReader struct{} + +func (systemSleepReader) ListActiveTaskSessions(context.Context) ([]*models.TaskSession, error) { + return nil, nil +} + +type systemSleepInhibitor struct{} + +func (systemSleepInhibitor) Platform() sleepinhibition.Platform { return sleepinhibition.PlatformOther } +func (systemSleepInhibitor) Supported() bool { return false } +func (systemSleepInhibitor) Acquire(context.Context) (sleepinhibition.Lease, error) { + return nil, sleepinhibition.ErrUnsupported +} diff --git a/apps/web/components/settings/general-settings.tsx b/apps/web/components/settings/general-settings.tsx index 614ebeff49..200c382a03 100644 --- a/apps/web/components/settings/general-settings.tsx +++ b/apps/web/components/settings/general-settings.tsx @@ -38,6 +38,7 @@ import { buildPluginShortcutEntries } from "@/lib/keyboard/plugin-shortcuts"; import { usePlugins } from "@/hooks/domains/plugins/use-plugins"; import { StartupPageSettingsCard } from "@/components/settings/startup-page-settings-card"; import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/general"; +import { SleepInhibitionSettings } from "@/components/settings/sleep-inhibition-settings"; function ThemeSettingsCard({ theme, @@ -266,6 +267,7 @@ export function TaskActionsSettings() { + diff --git a/apps/web/components/settings/sleep-inhibition-settings.test.tsx b/apps/web/components/settings/sleep-inhibition-settings.test.tsx new file mode 100644 index 0000000000..49a5f9b5f2 --- /dev/null +++ b/apps/web/components/settings/sleep-inhibition-settings.test.tsx @@ -0,0 +1,171 @@ +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { SettingsSaveContributor } from "./settings-save-provider"; +import type { SleepInhibitionResponse } from "@/lib/types/system"; +import { StateProvider } from "@/components/state-provider"; + +const fetchSettingsMock = vi.fn(); +const updateSettingsMock = vi.fn(); +let saveContributor: SettingsSaveContributor | null = null; +let currentRole: "admin" | "member" | undefined = "admin"; +const TEST_SWITCH_ID = "sleep-inhibition-switch"; + +vi.mock("@/lib/api/domains/settings-api", () => ({ + fetchSleepInhibitionSettings: (...args: unknown[]) => fetchSettingsMock(...args), + updateSleepInhibitionSettings: (...args: unknown[]) => updateSettingsMock(...args), +})); + +vi.mock("./settings-save-provider", () => ({ + useSettingsSaveContributor: (contributor: SettingsSaveContributor) => { + saveContributor = contributor; + }, +})); + +import { SleepInhibitionSettings } from "./sleep-inhibition-settings"; + +function response( + enabled = false, + status: SleepInhibitionResponse["status"] = { + platform: "linux", + supported: true, + active: false, + }, +): SleepInhibitionResponse { + return { settings: { enabled }, status }; +} + +function renderSettings() { + return render( + + + , + ); +} + +beforeEach(() => { + fetchSettingsMock.mockReset(); + updateSettingsMock.mockReset(); + fetchSettingsMock.mockResolvedValue(response()); + currentRole = "admin"; + saveContributor = null; +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); + vi.clearAllMocks(); +}); + +describe("SleepInhibitionSettings", () => { + it("explains the host sleep controls from an accessible info button", async () => { + renderSettings(); + + const infoButton = await screen.findByRole("button", { + name: "How host sleep prevention works", + }); + fireEvent.focus(infoButton); + + const tooltip = (await screen.findAllByRole("tooltip"))[0]; + expect(tooltip).toBeTruthy(); + expect(tooltip?.textContent).toContain("macOS runs"); + expect(tooltip?.textContent).toContain("/usr/bin/caffeinate -i -w "); + expect(tooltip?.textContent).toContain("Windows calls"); + expect(tooltip?.textContent).toContain("Linux asks systemd-logind"); + }); + + it("stages an admin edit until the shared save contributor runs", async () => { + updateSettingsMock.mockResolvedValueOnce( + response(true, { platform: "linux", supported: true, active: true }), + ); + renderSettings(); + + const toggle = await screen.findByTestId(TEST_SWITCH_ID); + fireEvent.click(toggle); + expect(updateSettingsMock).not.toHaveBeenCalled(); + expect(saveContributor?.isDirty).toBe(true); + if (!saveContributor) throw new Error("expected save contributor"); + + await act(async () => saveContributor?.save(saveContributor.revision)); + + expect(updateSettingsMock).toHaveBeenCalledWith({ enabled: true }); + await waitFor(() => expect(saveContributor?.isDirty).toBe(false)); + expect(screen.getByTestId("sleep-inhibition-status").textContent).toContain("Active"); + }); + + it("keeps members read-only while preserving the configured value", async () => { + currentRole = "member"; + fetchSettingsMock.mockResolvedValueOnce( + response(true, { + platform: "other", + supported: false, + active: false, + issue: "unsupported_platform", + }), + ); + renderSettings(); + + const toggle = await screen.findByTestId(TEST_SWITCH_ID); + expect(toggle).toHaveProperty("disabled", true); + expect(toggle.getAttribute("data-state")).toBe("checked"); + expect(screen.getByTestId("sleep-inhibition-status").textContent).toContain("Unavailable"); + expect(saveContributor?.isDirty).toBe(false); + }); + + it("reports failed saves without clearing the dirty draft", async () => { + updateSettingsMock.mockRejectedValueOnce(new Error("offline")); + renderSettings(); + fireEvent.click(await screen.findByTestId(TEST_SWITCH_ID)); + if (!saveContributor) throw new Error("expected save contributor"); + + await act(async () => { + await expect(saveContributor?.save(saveContributor.revision)).rejects.toThrow("offline"); + }); + + expect(screen.getByText("Failed to save host sleep settings.")).toBeTruthy(); + expect(saveContributor?.isDirty).toBe(true); + }); + + it("refreshes runtime status without replacing an unsaved draft", async () => { + vi.useFakeTimers(); + fetchSettingsMock.mockResolvedValueOnce( + response(false, { platform: "linux", supported: true, active: false }), + ); + renderSettings(); + + await act(async () => { + await Promise.resolve(); + }); + const toggle = screen.getByTestId(TEST_SWITCH_ID); + fireEvent.click(toggle); + expect(toggle.getAttribute("data-state")).toBe("checked"); + + fetchSettingsMock.mockResolvedValueOnce( + response(false, { platform: "linux", supported: true, active: true }), + ); + await act(async () => { + vi.advanceTimersByTime(15_000); + await Promise.resolve(); + }); + + expect(screen.getByTestId("sleep-inhibition-status").textContent).toContain("Active"); + expect(toggle.getAttribute("data-state")).toBe("checked"); + expect(saveContributor?.isDirty).toBe(true); + }); +}); diff --git a/apps/web/components/settings/sleep-inhibition-settings.tsx b/apps/web/components/settings/sleep-inhibition-settings.tsx new file mode 100644 index 0000000000..38cd3154be --- /dev/null +++ b/apps/web/components/settings/sleep-inhibition-settings.tsx @@ -0,0 +1,316 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { Trans, useTranslation } from "react-i18next"; +import { Alert, AlertDescription } from "@kandev/ui/alert"; +import { Button } from "@kandev/ui/button"; +import { CardContent, CardDescription, CardHeader, CardTitle } from "@kandev/ui/card"; +import { + Drawer, + DrawerContent, + DrawerDescription, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@kandev/ui/drawer"; +import { Label } from "@kandev/ui/label"; +import { Spinner } from "@kandev/ui/spinner"; +import { Switch } from "@kandev/ui/switch"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip"; +import { IconAlertCircle, IconInfoCircle } from "@tabler/icons-react"; +import { useAppStore } from "@/components/state-provider"; +import { SettingsCard } from "./settings-card"; +import { useSettingsSaveContributor } from "./settings-save-provider"; +import { useTouchDrawer } from "@/hooks/use-compact-task-chrome"; +import { useSleepInhibitionSettings } from "@/hooks/domains/settings/use-sleep-inhibition-settings"; +import type { SleepInhibitionResponse } from "@/lib/types/system"; + +const MACOS_COMMAND = "/usr/bin/caffeinate -i -w "; +const WINDOWS_API = "SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED)"; +const LINUX_METHOD = + 'org.freedesktop.login1.Manager.Inhibit("sleep", "Kandev", "A Kandev task is running", "block")'; +const CODE_CLASS = "break-all rounded bg-muted px-1 py-0.5 font-mono text-[0.7rem]"; + +function statusMessageKey(response: SleepInhibitionResponse): string { + if (response.status.issue === "unsupported_platform") + return "settings:sleepInhibitionUnsupported"; + if (response.status.issue === "system_service_unavailable") { + return "settings:sleepInhibitionServiceUnavailable"; + } + if (response.status.issue === "request_failed") return "settings:sleepInhibitionRequestFailed"; + if (response.status.active) return "settings:sleepInhibitionActive"; + return "settings:sleepInhibitionAvailable"; +} + +type SleepInhibitionState = { + snapshot: SleepInhibitionResponse | null; + draft: boolean | null; + setDraft: (value: boolean) => void; + loading: boolean; + loadFailed: boolean; + saveFailed: boolean; + isDirty: boolean; + isAdmin: boolean; + canEdit: boolean; + saved: boolean | undefined; + reload: () => Promise; +}; + +function useSleepInhibitionState(): SleepInhibitionState { + const { t } = useTranslation(); + const role = useAppStore((state) => state.auth.user?.role); + const remote = useSleepInhibitionSettings(); + const snapshot = remote.response; + const [draft, setDraftState] = useState(null); + const [saveFailed, setSaveFailed] = useState(false); + const isAdmin = role === undefined || role === "admin"; + const saved = snapshot?.settings.enabled; + const lastSaved = useRef(undefined); + + useEffect(() => { + if (saved === undefined) return; + setDraftState((current) => + current === null || current === lastSaved.current ? saved : current, + ); + lastSaved.current = saved; + }, [saved]); + + const draftValue = draft ?? saved ?? false; + const isDirty = saved !== undefined && draft !== null && draft !== saved; + const loading = remote.loading || !remote.loaded; + const canEdit = isAdmin && !loading && snapshot !== null; + + useSettingsSaveContributor({ + id: "general-task-sleep-inhibition", + order: 30, + revision: draftValue ? "enabled" : "disabled", + isDirty, + canSave: canEdit, + invalidReason: !isAdmin ? t("settings:sleepInhibitionAdminOnly") : undefined, + save: async () => { + if (!canEdit) throw new Error(t("settings:sleepInhibitionAdminOnly")); + const submitted = draft ?? saved ?? false; + setSaveFailed(false); + try { + const response = await remote.save({ enabled: submitted }); + setDraftState((current) => (current === submitted ? response.settings.enabled : current)); + } catch (error) { + setSaveFailed(true); + throw error; + } + }, + discard: () => { + if (saved !== undefined) setDraftState(saved); + setSaveFailed(false); + }, + }); + + return { + snapshot, + draft, + setDraft: setDraftState, + loading, + loadFailed: remote.error, + saveFailed, + isDirty, + isAdmin, + canEdit, + saved, + reload: remote.refresh, + }; +} + +function SleepInhibitionLoadError({ onRetry }: { onRetry: () => void }) { + const { t } = useTranslation(); + return ( + + + + + {t("settings:sleepInhibitionLoadFailed")} + + + + + ); +} + +function SleepInhibitionInfoDetails() { + const { t } = useTranslation(); + return ( + <> +
    +
  • + + + + + +
  • +
  • + + + +
  • +
  • + + + +
  • +
+

{t("settings:sleepInhibitionInfoRelease")}

+ + ); +} + +function SleepInhibitionInfoTooltipContent() { + const { t } = useTranslation(); + return ( +
+

{t("settings:sleepInhibitionInfoTitle")}

+

{t("settings:sleepInhibitionInfoTrigger")}

+ +
+ ); +} + +function SleepInhibitionInfoTooltip() { + const { t } = useTranslation(); + const usesTouchDrawer = useTouchDrawer(); + const [open, setOpen] = useState(false); + const button = ( + + ); + const trigger = usesTouchDrawer ? ( + {button} + ) : ( + + + {button} + + + + + + ); + + return ( + + {trigger} + + + {t("settings:sleepInhibitionInfoTitle")} + {t("settings:sleepInhibitionInfoTrigger")} + +
+ +
+
+
+ ); +} + +function SleepInhibitionCard({ state }: { state: SleepInhibitionState }) { + const { t } = useTranslation(); + const snapshot = state.snapshot; + if (!snapshot) return null; + return ( + + + + {t("settings:sleepInhibitionTitle")} + + + {t("settings:sleepInhibitionDescription")} + + +
+
+ +

+ {t("settings:sleepInhibitionSwitchHint")} +

+
+ +
+ +
+

+ {t("settings:sleepInhibitionStatusLabel")}: {t(statusMessageKey(snapshot))} +

+

+ {t("settings:sleepInhibitionCaveat")} +

+
+ + {!state.isAdmin && ( +

{t("settings:sleepInhibitionAdminOnly")}

+ )} + {state.saveFailed && ( + + + {t("settings:sleepInhibitionSaveFailed")} + + )} +
+
+ ); +} + +export function SleepInhibitionSettings() { + const { t } = useTranslation(); + const state = useSleepInhibitionState(); + + if (state.loading && !state.snapshot) { + return ( + + + + {t("settings:sleepInhibitionLoading")} + + + ); + } + if (state.loadFailed && !state.snapshot) { + return void state.reload()} />; + } + return ; +} diff --git a/apps/web/e2e/tests/settings/mobile-general-settings.spec.ts b/apps/web/e2e/tests/settings/mobile-general-settings.spec.ts index 4fd17a3a43..b162722987 100644 --- a/apps/web/e2e/tests/settings/mobile-general-settings.spec.ts +++ b/apps/web/e2e/tests/settings/mobile-general-settings.spec.ts @@ -1,6 +1,87 @@ import { test, expect } from "../../fixtures/test-base"; test.describe("Mobile general settings", () => { + test("keeps host sleep inhibition reachable and contained on a phone viewport", async ({ + testPage, + apiClient, + prCapture, + }) => { + await testPage.setViewportSize({ width: 390, height: 844 }); + const initialResponse = await apiClient.rawRequest("GET", "/api/v1/system/sleep-inhibition"); + expect(initialResponse.ok).toBe(true); + const initial = (await initialResponse.json()) as { settings: { enabled: boolean } }; + const next = !initial.settings.enabled; + + try { + await testPage.goto("/settings/general/task-actions"); + const card = testPage.getByTestId("sleep-inhibition-settings"); + const toggle = card.getByRole("switch", { name: "Prevent idle system sleep" }); + await expect(card).toBeVisible(); + const info = card.getByRole("button", { name: "How host sleep prevention works" }); + await info.click(); + const infoDrawer = testPage.getByRole("dialog", { + name: "How Kandev prevents host sleep", + }); + await expect(infoDrawer).toBeVisible(); + await expect(infoDrawer).toContainText("/usr/bin/caffeinate -i -w"); + await expect(infoDrawer).toContainText("systemd-logind"); + expect(await testPage.evaluate(() => document.documentElement.scrollWidth)).toBe( + await testPage.evaluate(() => document.documentElement.clientWidth), + ); + await prCapture.screenshot("sleep-inhibition-mobile-info", { + caption: "Mobile host sleep prevention details in the info drawer", + }); + const releaseCopy = infoDrawer.getByText( + "The request does not keep the display awake or override an explicit user sleep action.", + ); + await releaseCopy.scrollIntoViewIfNeeded(); + const releaseBox = await releaseCopy.boundingBox(); + expect(releaseBox).not.toBeNull(); + expect(releaseBox!.y + releaseBox!.height).toBeLessThanOrEqual(844); + await testPage.keyboard.press("Escape"); + await expect(infoDrawer).not.toBeVisible(); + await toggle.click(); + await expect(toggle).toHaveAttribute("data-settings-dirty", "true"); + const controlRow = card.getByTestId("sleep-inhibition-control-row"); + const controlRowBox = await controlRow.boundingBox(); + expect(controlRowBox).not.toBeNull(); + expect(controlRowBox!.height).toBeGreaterThanOrEqual(44); + + const floatingSave = testPage.getByTestId("settings-floating-save"); + await expect(floatingSave).toBeVisible(); + await card.scrollIntoViewIfNeeded(); + const cardContent = card.locator('[data-slot="card-content"]'); + const [cardContentBox, saveBox] = await Promise.all([ + cardContent.boundingBox(), + floatingSave.boundingBox(), + ]); + expect(cardContentBox).not.toBeNull(); + expect(saveBox).not.toBeNull(); + expect(cardContentBox!.y + cardContentBox!.height).toBeLessThanOrEqual(saveBox!.y + 2); + expect(await testPage.evaluate(() => document.documentElement.scrollWidth)).toBe( + await testPage.evaluate(() => document.documentElement.clientWidth), + ); + await testPage.waitForTimeout(1_000); + await testPage + .locator("[data-sonner-toast], [data-testid='toast-message']") + .evaluateAll((toasts) => { + for (const toast of toasts) (toast as HTMLElement).style.display = "none"; + }); + await prCapture.screenshot("sleep-inhibition-mobile-draft", { + caption: "Mobile Task Actions sleep inhibition card above Save changes", + }); + + await floatingSave.getByRole("button", { name: "Save changes" }).click(); + await expect(floatingSave).not.toBeVisible({ timeout: 15_000 }); + const savedResponse = await apiClient.rawRequest("GET", "/api/v1/system/sleep-inhibition"); + expect(((await savedResponse.json()) as typeof initial).settings.enabled).toBe(next); + } finally { + await apiClient.rawRequest("PATCH", "/api/v1/system/sleep-inhibition", { + enabled: initial.settings.enabled, + }); + } + }); + test("keeps the full Transcript Navigation card above the floating Save action", async ({ testPage, prCapture, diff --git a/apps/web/e2e/tests/settings/settings-manual-save.spec.ts b/apps/web/e2e/tests/settings/settings-manual-save.spec.ts index eb979aa93e..aa3ef68308 100644 --- a/apps/web/e2e/tests/settings/settings-manual-save.spec.ts +++ b/apps/web/e2e/tests/settings/settings-manual-save.spec.ts @@ -4,6 +4,7 @@ import type { ApiClient } from "../../helpers/api-client"; const APPEARANCE_PATH = "/settings/general/appearance"; const TERMINAL_PATH = "/settings/general/terminal"; const NOTIFICATIONS_PATH = "/settings/general/notifications"; +const TASK_ACTIONS_PATH = "/settings/general/task-actions"; const CLARIFICATION_REQUESTED = "session.clarification_requested"; const PROVIDER_NAME = "E2E semantic notifications"; @@ -25,6 +26,75 @@ async function seedNotificationProvider( } test.describe("Settings manual save", () => { + test("persists host sleep inhibition only when Save changes is pressed", async ({ + testPage, + apiClient, + prCapture, + }) => { + const initialResponse = await apiClient.rawRequest("GET", "/api/v1/system/sleep-inhibition"); + expect(initialResponse.ok).toBe(true); + const initial = (await initialResponse.json()) as { + settings: { enabled: boolean }; + }; + const next = !initial.settings.enabled; + + try { + await testPage.goto(TASK_ACTIONS_PATH); + const card = testPage.getByTestId("sleep-inhibition-settings"); + const toggle = card.getByRole("switch", { name: "Prevent idle system sleep" }); + await expect(card).toBeVisible(); + await expect(card).toContainText("Container, Kubernetes, remote-executor"); + const info = card.getByRole("button", { name: "How host sleep prevention works" }); + await info.hover(); + const infoTooltip = testPage.getByRole("tooltip"); + await expect(infoTooltip).toContainText("/usr/bin/caffeinate -i -w"); + await expect(infoTooltip).toContainText("SetThreadExecutionState"); + await expect(infoTooltip).toContainText("systemd-logind"); + await testPage.waitForTimeout(500); + await prCapture.screenshot("sleep-inhibition-desktop-info", { + caption: "Desktop host sleep prevention details in the hover tooltip", + }); + if (initial.settings.enabled) await expect(toggle).toBeChecked(); + else await expect(toggle).not.toBeChecked(); + + await toggle.click(); + await expect(card).toHaveAttribute("data-settings-dirty", "true"); + const beforeSaveResponse = await apiClient.rawRequest( + "GET", + "/api/v1/system/sleep-inhibition", + ); + expect(((await beforeSaveResponse.json()) as typeof initial).settings.enabled).toBe( + initial.settings.enabled, + ); + + const floatingSave = testPage.getByTestId("settings-floating-save"); + await testPage.waitForTimeout(1_000); + await testPage + .locator("[data-sonner-toast], [data-testid='toast-message']") + .evaluateAll((toasts) => { + for (const toast of toasts) (toast as HTMLElement).style.display = "none"; + }); + await prCapture.screenshot("sleep-inhibition-desktop-draft", { + caption: "Task Actions sleep inhibition setting with Save changes pending", + }); + await floatingSave.getByRole("button", { name: "Save changes" }).click(); + await expect(floatingSave).not.toBeVisible({ timeout: 15_000 }); + + const savedResponse = await apiClient.rawRequest("GET", "/api/v1/system/sleep-inhibition"); + expect(((await savedResponse.json()) as typeof initial).settings.enabled).toBe(next); + await testPage.reload(); + const reloadedToggle = testPage + .getByTestId("sleep-inhibition-settings") + .getByRole("switch", { name: "Prevent idle system sleep" }); + if (next) await expect(reloadedToggle).toBeChecked(); + else await expect(reloadedToggle).not.toBeChecked(); + } finally { + await apiClient.rawRequest("PATCH", "/api/v1/system/sleep-inhibition", { + enabled: initial.settings.enabled, + }); + } + }); + test("keeps Appearance changes local and guards dirty navigation", async ({ testPage, apiClient, diff --git a/apps/web/hooks/domains/settings/use-sleep-inhibition-settings.ts b/apps/web/hooks/domains/settings/use-sleep-inhibition-settings.ts new file mode 100644 index 0000000000..721cf0f653 --- /dev/null +++ b/apps/web/hooks/domains/settings/use-sleep-inhibition-settings.ts @@ -0,0 +1,70 @@ +"use client"; + +import { useCallback, useEffect, useRef } from "react"; +import { useAppStore } from "@/components/state-provider"; +import { + fetchSleepInhibitionSettings, + updateSleepInhibitionSettings, +} from "@/lib/api/domains/settings-api"; +import type { SleepInhibitionSettings } from "@/lib/types/system"; + +const STATUS_POLL_INTERVAL_MS = 15_000; + +/** + * Loads the install-wide setting into the settings domain and refreshes the + * runtime status while the page is open. The response is separate from the + * user-settings slice because it belongs to the backend host, not a user. + */ +export function useSleepInhibitionSettings() { + const response = useAppStore((state) => state.sleepInhibition.response); + const loaded = useAppStore((state) => state.sleepInhibition.loaded); + const loading = useAppStore((state) => state.sleepInhibition.loading); + const error = useAppStore((state) => state.sleepInhibition.error); + const setResponse = useAppStore((state) => state.setSleepInhibition); + const setLoading = useAppStore((state) => state.setSleepInhibitionLoading); + const setError = useAppStore((state) => state.setSleepInhibitionError); + const inFlight = useRef(false); + + const refresh = useCallback(async () => { + if (inFlight.current) return; + inFlight.current = true; + setLoading(true); + setError(false); + try { + setResponse(await fetchSleepInhibitionSettings()); + } catch { + setError(true); + } finally { + inFlight.current = false; + setLoading(false); + } + }, [setError, setLoading, setResponse]); + + useEffect(() => { + if (!loaded) void refresh(); + }, [loaded, refresh]); + + useEffect(() => { + if (!loaded) return; + const poll = () => { + if (document.visibilityState === "visible") void refresh(); + }; + const interval = window.setInterval(poll, STATUS_POLL_INTERVAL_MS); + document.addEventListener("visibilitychange", poll); + return () => { + window.clearInterval(interval); + document.removeEventListener("visibilitychange", poll); + }; + }, [loaded, refresh]); + + const save = useCallback( + async (settings: SleepInhibitionSettings) => { + const next = await updateSleepInhibitionSettings(settings); + setResponse(next); + return next; + }, + [setResponse], + ); + + return { response, loaded, loading, error, refresh, save }; +} diff --git a/apps/web/lib/api/domains/settings-api.test.ts b/apps/web/lib/api/domains/settings-api.test.ts index 448da69046..d5a25b3e7e 100644 --- a/apps/web/lib/api/domains/settings-api.test.ts +++ b/apps/web/lib/api/domains/settings-api.test.ts @@ -4,7 +4,12 @@ vi.mock("@/lib/config", () => ({ getBackendConfig: () => ({ apiBaseUrl: "http://api.test" }), })); -import { fetchMessageQueueSettings, updateMessageQueueSettings } from "./settings-api"; +import { + fetchMessageQueueSettings, + fetchSleepInhibitionSettings, + updateMessageQueueSettings, + updateSleepInhibitionSettings, +} from "./settings-api"; const BASE = "http://api.test/api/v1/system/message-queue/settings"; type FetchInput = Parameters[0]; @@ -66,3 +71,35 @@ describe("message queue settings api", () => { expect(lastCall().init?.body).toBe(JSON.stringify({ max_per_session: 25 })); }); }); + +describe("sleep inhibition settings api", () => { + it("fetches the install-wide setting without cache", async () => { + fetchSpy.mockResolvedValueOnce( + jsonResponse({ + settings: { enabled: false }, + status: { platform: "linux", supported: true, active: false }, + }), + ); + + const response = await fetchSleepInhibitionSettings(); + + expect(lastCall().url).toBe("http://api.test/api/v1/system/sleep-inhibition"); + expect(lastCall().init?.cache).toBe("no-store"); + expect(response.settings.enabled).toBe(false); + }); + + it("PATCHes the configured enabled value", async () => { + fetchSpy.mockResolvedValueOnce( + jsonResponse({ + settings: { enabled: true }, + status: { platform: "linux", supported: true, active: true }, + }), + ); + + await updateSleepInhibitionSettings({ enabled: true }); + + expect(lastCall().url).toBe("http://api.test/api/v1/system/sleep-inhibition"); + expect(lastCall().init?.method).toBe("PATCH"); + expect(lastCall().init?.body).toBe(JSON.stringify({ enabled: true })); + }); +}); diff --git a/apps/web/lib/api/domains/settings-api.ts b/apps/web/lib/api/domains/settings-api.ts index 73f922856e..3584dd5a12 100644 --- a/apps/web/lib/api/domains/settings-api.ts +++ b/apps/web/lib/api/domains/settings-api.ts @@ -27,6 +27,11 @@ import type { SystemMetricsSettingsResponse, } from "@/lib/types/system"; +export { + fetchSleepInhibitionSettings, + updateSleepInhibitionSettings, +} from "./sleep-inhibition-api"; + // User settings export async function fetchUserSettings(options?: ApiRequestOptions) { return fetchJsonWithRetry("/api/v1/user/settings", options); diff --git a/apps/web/lib/api/domains/sleep-inhibition-api.ts b/apps/web/lib/api/domains/sleep-inhibition-api.ts new file mode 100644 index 0000000000..a9e6574d68 --- /dev/null +++ b/apps/web/lib/api/domains/sleep-inhibition-api.ts @@ -0,0 +1,27 @@ +import { fetchJson, fetchJsonWithRetry, type ApiRequestOptions } from "../client"; +import type { SleepInhibitionResponse, SleepInhibitionSettings } from "@/lib/types/system"; + +const SLEEP_INHIBITION_PATH = "/api/v1/system/sleep-inhibition"; + +export async function fetchSleepInhibitionSettings( + options?: ApiRequestOptions, +): Promise { + return fetchJsonWithRetry(SLEEP_INHIBITION_PATH, { + ...options, + cache: "no-store", + }); +} + +export async function updateSleepInhibitionSettings( + payload: SleepInhibitionSettings, + options?: ApiRequestOptions, +): Promise { + return fetchJson(SLEEP_INHIBITION_PATH, { + ...options, + init: { + ...(options?.init ?? {}), + method: "PATCH", + body: JSON.stringify(payload), + }, + }); +} diff --git a/apps/web/lib/state/default-state.ts b/apps/web/lib/state/default-state.ts index 4da45440b6..54ef615033 100644 --- a/apps/web/lib/state/default-state.ts +++ b/apps/web/lib/state/default-state.ts @@ -40,6 +40,7 @@ export const defaultState = { secrets: defaultSettingsState.secrets, notificationProviders: defaultSettingsState.notificationProviders, settingsData: defaultSettingsState.settingsData, + sleepInhibition: defaultSettingsState.sleepInhibition, userSettings: defaultSettingsState.userSettings, messages: defaultSessionState.messages, turns: defaultSessionState.turns, @@ -270,6 +271,7 @@ export function mergeInitialState(initialState?: HydrationState): DefaultState { ...initialState.notificationProviders, }, settingsData: { ...defaultState.settingsData, ...initialState.settingsData }, + sleepInhibition: { ...defaultState.sleepInhibition, ...initialState.sleepInhibition }, userSettings: { ...defaultState.userSettings, ...initialState.userSettings }, messages: { ...defaultState.messages, ...initialState.messages }, turns: { ...defaultState.turns, ...initialState.turns }, diff --git a/apps/web/lib/state/hydration/hydrator.ts b/apps/web/lib/state/hydration/hydrator.ts index 6d874e44d2..a64f0c908f 100644 --- a/apps/web/lib/state/hydration/hydrator.ts +++ b/apps/web/lib/state/hydration/hydrator.ts @@ -77,6 +77,7 @@ function hydrateSettings(draft: Draft, state: HydrationState): void { mergeWithLoading(draft.prompts, state.prompts); mergeWithLoading(draft.notificationProviders, state.notificationProviders); if (state.settingsData) deepMerge(draft.settingsData, state.settingsData); + if (state.sleepInhibition) deepMerge(draft.sleepInhibition, state.sleepInhibition); if (state.userSettings && !draft.userSettings.loaded) { deepMerge(draft.userSettings, state.userSettings); bridgeSidebarViewsFromUserSettings(draft, state.userSettings); diff --git a/apps/web/lib/state/slices/index.ts b/apps/web/lib/state/slices/index.ts index cd3dd8ff5a..d837a5b4b6 100644 --- a/apps/web/lib/state/slices/index.ts +++ b/apps/web/lib/state/slices/index.ts @@ -119,6 +119,7 @@ export type { SecretsState, NotificationProvidersState, SettingsDataState, + SleepInhibitionStoreState, UserSettingsState, } from "./settings/types"; export type { diff --git a/apps/web/lib/state/slices/settings/settings-slice.ts b/apps/web/lib/state/slices/settings/settings-slice.ts index 33cb634887..a8201b088a 100644 --- a/apps/web/lib/state/slices/settings/settings-slice.ts +++ b/apps/web/lib/state/slices/settings/settings-slice.ts @@ -22,6 +22,7 @@ export const defaultSettingsState: SettingsSliceState = { loading: false, }, settingsData: { executorsLoaded: false, agentsLoaded: false }, + sleepInhibition: { response: null, loaded: false, loading: false, error: false }, userSettings: createDefaultUserSettings(), }; @@ -251,6 +252,32 @@ function createCoreActions( }; } +function createSleepInhibitionActions( + set: ImmerSet, +): Pick< + SettingsSlice, + "setSleepInhibition" | "setSleepInhibitionLoading" | "setSleepInhibitionError" +> { + return { + setSleepInhibition: (response) => + set((draft) => { + draft.sleepInhibition.response = response; + draft.sleepInhibition.loaded = true; + draft.sleepInhibition.loading = false; + draft.sleepInhibition.error = false; + }), + setSleepInhibitionLoading: (loading) => + set((draft) => { + draft.sleepInhibition.loading = loading; + }), + setSleepInhibitionError: (error) => + set((draft) => { + draft.sleepInhibition.error = error; + if (error) draft.sleepInhibition.loaded = true; + }), + }; +} + function createSecretAndSpriteActions( set: ImmerSet, ): Pick< @@ -334,6 +361,7 @@ export const createSettingsSlice: StateCreator< > = (set) => ({ ...defaultSettingsState, ...createCoreActions(set), + ...createSleepInhibitionActions(set), ...createInstallJobActions(set), ...createAgentUpdateJobActions(set), ...createSecretAndSpriteActions(set), diff --git a/apps/web/lib/state/slices/settings/types.ts b/apps/web/lib/state/slices/settings/types.ts index 34410b12a0..947aaf131e 100644 --- a/apps/web/lib/state/slices/settings/types.ts +++ b/apps/web/lib/state/slices/settings/types.ts @@ -24,6 +24,7 @@ import type { SidebarTaskPrefsState } from "@/lib/state/slices/ui/types"; import type { SecretListItem } from "@/lib/types/http-secrets"; import type { SpritesStatus, SpritesInstance } from "@/lib/types/http-sprites"; import type { TasksListGroup, TasksListSort } from "@/lib/tasks/tasks-list-options"; +import type { SleepInhibitionResponse } from "@/lib/types/system"; export type ExecutorsState = { items: Executor[]; @@ -167,6 +168,14 @@ export type SettingsDataState = { agentsLoaded: boolean; }; +/** Install-wide sleep-inhibition settings and runtime status. */ +export type SleepInhibitionStoreState = { + response: SleepInhibitionResponse | null; + loaded: boolean; + loading: boolean; + error: boolean; +}; + export type UserSettingsState = { workspaceId: string | null; kanbanViewMode: string | null; @@ -266,6 +275,7 @@ export type SettingsSliceState = { sprites: SpritesState; notificationProviders: NotificationProvidersState; settingsData: SettingsDataState; + sleepInhibition: SleepInhibitionStoreState; userSettings: UserSettingsState; }; @@ -304,6 +314,9 @@ export type SettingsSliceActions = { setNotificationProviders: (state: NotificationProvidersState) => void; setNotificationProvidersLoading: (loading: boolean) => void; setSettingsData: (next: Partial) => void; + setSleepInhibition: (response: SleepInhibitionResponse) => void; + setSleepInhibitionLoading: (loading: boolean) => void; + setSleepInhibitionError: (error: boolean) => void; setUserSettings: (settings: UserSettingsState) => void; bumpAgentProfilesVersion: () => void; }; diff --git a/apps/web/lib/state/store-overrides.ts b/apps/web/lib/state/store-overrides.ts index 48bd113222..682e691083 100644 --- a/apps/web/lib/state/store-overrides.ts +++ b/apps/web/lib/state/store-overrides.ts @@ -24,6 +24,7 @@ export function buildStateOverrides(m: DefaultState) { secrets: m.secrets, notificationProviders: m.notificationProviders, settingsData: m.settingsData, + sleepInhibition: m.sleepInhibition, userSettings: m.userSettings, messages: m.messages, turns: m.turns, diff --git a/apps/web/lib/state/store-reexports.ts b/apps/web/lib/state/store-reexports.ts index 2fb0225982..f1be6fcaad 100644 --- a/apps/web/lib/state/store-reexports.ts +++ b/apps/web/lib/state/store-reexports.ts @@ -29,6 +29,7 @@ export type { SecretsState, NotificationProvidersState, SettingsDataState, + SleepInhibitionStoreState, UserSettingsState, MessagesState, TurnsState, diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts index 7f15a1c7a2..1d30e52224 100644 --- a/apps/web/lib/state/store.ts +++ b/apps/web/lib/state/store.ts @@ -63,6 +63,7 @@ import { type SecretsState, type NotificationProvidersState, type SettingsDataState, + type SleepInhibitionStoreState, type UserSettingsState, type ProcessStatusEntry, type Worktree, @@ -125,6 +126,7 @@ export type AppState = KanbanSlice & { sprites: (typeof defaultSettingsState)["sprites"]; notificationProviders: (typeof defaultSettingsState)["notificationProviders"]; settingsData: (typeof defaultSettingsState)["settingsData"]; + sleepInhibition: (typeof defaultSettingsState)["sleepInhibition"]; userSettings: (typeof defaultSettingsState)["userSettings"]; // Session slice @@ -294,6 +296,9 @@ export type AppState = KanbanSlice & { removeSpritesInstance: (name: string) => void; setNotificationProviders: (state: NotificationProvidersState) => void; setNotificationProvidersLoading: (loading: boolean) => void; + setSleepInhibition: (response: NonNullable) => void; + setSleepInhibitionLoading: (loading: boolean) => void; + setSleepInhibitionError: (error: boolean) => void; setUserSettings: (settings: UserSettingsState) => void; setTerminalOutput: (terminalId: string, data: string) => void; appendShellOutput: (sessionId: string, data: string) => void; diff --git a/apps/web/lib/types/system.ts b/apps/web/lib/types/system.ts index f47a400cd3..353d7ebbfe 100644 --- a/apps/web/lib/types/system.ts +++ b/apps/web/lib/types/system.ts @@ -202,6 +202,28 @@ export interface MessageQueueSettingsResponse { effective: MessageQueueEffectiveSettings; } +export type SleepInhibitionPlatform = "darwin" | "windows" | "linux" | "other"; +export type SleepInhibitionIssue = + | "unsupported_platform" + | "system_service_unavailable" + | "request_failed"; + +export interface SleepInhibitionSettings { + enabled: boolean; +} + +export interface SleepInhibitionStatus { + platform: SleepInhibitionPlatform; + supported: boolean; + active: boolean; + issue?: SleepInhibitionIssue; +} + +export interface SleepInhibitionResponse { + settings: SleepInhibitionSettings; + status: SleepInhibitionStatus; +} + export interface SystemMetricSample { id: SystemMetricId | string; label: string; diff --git a/apps/web/src/locales/en/settings.json b/apps/web/src/locales/en/settings.json index ba97c2870d..0e5bc3e378 100644 --- a/apps/web/src/locales/en/settings.json +++ b/apps/web/src/locales/en/settings.json @@ -370,6 +370,29 @@ "systemDefault": "System default", "systemLoad1Min": "System load (1 min)", "taskActions": "Task Actions", + "sleepInhibitionTitle": "Prevent host sleep while tasks run", + "sleepInhibitionInfoLabel": "How host sleep prevention works", + "sleepInhibitionInfoTitle": "How Kandev prevents host sleep", + "sleepInhibitionInfoTrigger": "The backend holds one sleep-prevention request while any task is starting or running.", + "sleepInhibitionInfoMacos": "macOS runs <0>{{command}}. The <1>{{idleOption}} option prevents idle system sleep. The <2>{{waitOption}} option stops the helper when the backend exits.", + "sleepInhibitionInfoWindows": "Windows calls <0>{{api}} with system-sleep flags. Kandev clears the request when no working task remains.", + "sleepInhibitionInfoLinux": "Linux asks systemd-logind through D-Bus with <0>{{method}}. Kandev keeps the returned file open until no working task remains.", + "sleepInhibitionInfoRelease": "The request does not keep the display awake or override an explicit user sleep action.", + "sleepInhibitionDescription": "Keep the Kandev backend host available while a task is starting or running.", + "sleepInhibitionSwitchLabel": "Prevent idle system sleep", + "sleepInhibitionSwitchHint": "Off by default. This may use extra battery or power and affects only the backend host.", + "sleepInhibitionCaveat": "Container, Kubernetes, remote-executor, and always-on deployments normally leave this disabled. It does not keep the display awake or override an explicit user sleep action.", + "sleepInhibitionStatusLabel": "Host sleep status", + "sleepInhibitionAvailable": "Available; no request is active.", + "sleepInhibitionActive": "Active while a task is starting or running.", + "sleepInhibitionUnsupported": "Unavailable on this backend platform.", + "sleepInhibitionServiceUnavailable": "The host sleep service is unavailable.", + "sleepInhibitionRequestFailed": "The host sleep request failed; tasks continue normally.", + "sleepInhibitionAdminOnly": "Only administrators can change this install-wide setting.", + "sleepInhibitionLoading": "Loading host sleep settings…", + "sleepInhibitionLoadFailed": "Host sleep settings could not be loaded.", + "sleepInhibitionRetry": "Retry loading host sleep settings", + "sleepInhibitionSaveFailed": "Failed to save host sleep settings.", "taskOverview": "Task overview", "taskWorkbenchLayoutProfilesAndDefaults": "Task workbench layout profiles and defaults", "terminal": "Terminal", diff --git a/apps/web/src/locales/pseudo/settings.json b/apps/web/src/locales/pseudo/settings.json index 407da6fe28..f6b1fd2f3e 100644 --- a/apps/web/src/locales/pseudo/settings.json +++ b/apps/web/src/locales/pseudo/settings.json @@ -370,6 +370,29 @@ "systemDefault": "Śŷśţēḿ ďēƒàũĺţ", "systemLoad1Min": "Śŷśţēḿ ĺōàď (1 ḿĩń)", "taskActions": "Ţàśķ Àćţĩōńś", + "sleepInhibitionTitle": "Ƥŕēvēńţ ĥōśţ śĺēēƥ ŵĥĩĺē ţàśķś ŕũń", + "sleepInhibitionInfoLabel": "Ĥōŵ ĥōśţ śĺēēƥ ƥŕēvēńţĩōń ŵōŕķś", + "sleepInhibitionInfoTitle": "Ĥōŵ Ķàńďēv ƥŕēvēńţś ĥōśţ śĺēēƥ", + "sleepInhibitionInfoTrigger": "Ţĥē ƀàćķēńď ĥōĺďś ōńē śĺēēƥ-ƥŕēvēńţĩōń ŕēqũēśţ ŵĥĩĺē àńŷ ţàśķ ĩś śţàŕţĩńĝ ōŕ ŕũńńĩńĝ.", + "sleepInhibitionInfoMacos": "ḿàćŌŚ ŕũńś <0>{{command}}. Ţĥē <1>{{idleOption}} ōƥţĩōń ƥŕēvēńţś ĩďĺē śŷśţēḿ śĺēēƥ. Ţĥē <2>{{waitOption}} ōƥţĩōń śţōƥś ţĥē ĥēĺƥēŕ ŵĥēń ţĥē ƀàćķēńď ēxĩţś.", + "sleepInhibitionInfoWindows": "Ŵĩńďōŵś ćàĺĺś <0>{{api}} ŵĩţĥ śŷśţēḿ-śĺēēƥ ƒĺàĝś. Ķàńďēv ćĺēàŕś ţĥē ŕēqũēśţ ŵĥēń ńō ŵōŕķĩńĝ ţàśķ ŕēḿàĩńś.", + "sleepInhibitionInfoLinux": "Ĺĩńũx àśķś śŷśţēḿď-ĺōĝĩńď ţĥŕōũĝĥ Ď-Ɓũś ŵĩţĥ <0>{{method}}. Ķàńďēv ķēēƥś ţĥē ŕēţũŕńēď ƒĩĺē ōƥēń ũńţĩĺ ńō ŵōŕķĩńĝ ţàśķ ŕēḿàĩńś.", + "sleepInhibitionInfoRelease": "Ţĥē ŕēqũēśţ ďōēś ńōţ ķēēƥ ţĥē ďĩśƥĺàŷ àŵàķē ōŕ ōvēŕŕĩďē àń ēxƥĺĩćĩţ ũśēŕ śĺēēƥ àćţĩōń.", + "sleepInhibitionDescription": "Ķēēƥ ţĥē Ķàńďēv ƀàćķēńď ĥōśţ àvàĩĺàƀĺē ŵĥĩĺē à ţàśķ ĩś śţàŕţĩńĝ ōŕ ŕũńńĩńĝ.", + "sleepInhibitionSwitchLabel": "Ƥŕēvēńţ ĩďĺē śŷśţēḿ śĺēēƥ", + "sleepInhibitionSwitchHint": "Ōƒƒ ƀŷ ďēƒàũĺţ. Ţĥĩś ḿàŷ ũśē ēxţŕà ƀàţţēŕŷ ōŕ ƥōŵēŕ àńď àƒƒēćţś ōńĺŷ ţĥē ƀàćķēńď ĥōśţ.", + "sleepInhibitionCaveat": "Ćōńţàĩńēŕ, Ķũƀēŕńēţēś, ŕēḿōţē-ēxēćũţōŕ, àńď àĺŵàŷś-ōń ďēƥĺōŷḿēńţś ńōŕḿàĺĺŷ ĺēàvē ţĥĩś ďĩśàƀĺēď. Ĩţ ďōēś ńōţ ķēēƥ ţĥē ďĩśƥĺàŷ àŵàķē ōŕ ōvēŕŕĩďē àń ēxƥĺĩćĩţ ũśēŕ śĺēēƥ àćţĩōń.", + "sleepInhibitionStatusLabel": "Ĥōśţ śĺēēƥ śţàţũś", + "sleepInhibitionAvailable": "Àvàĩĺàƀĺē; ńō ŕēqũēśţ ĩś àćţĩvē.", + "sleepInhibitionActive": "Àćţĩvē ŵĥĩĺē à ţàśķ ĩś śţàŕţĩńĝ ōŕ ŕũńńĩńĝ.", + "sleepInhibitionUnsupported": "Ũńàvàĩĺàƀĺē ōń ţĥĩś ƀàćķēńď ƥĺàţƒōŕḿ.", + "sleepInhibitionServiceUnavailable": "Ţĥē ĥōśţ śĺēēƥ śēŕvĩćē ĩś ũńàvàĩĺàƀĺē.", + "sleepInhibitionRequestFailed": "Ţĥē ĥōśţ śĺēēƥ ŕēqũēśţ ƒàĩĺēď; ţàśķś ćōńţĩńũē ńōŕḿàĺĺŷ.", + "sleepInhibitionAdminOnly": "Ōńĺŷ àďḿĩńĩśţŕàţōŕś ćàń ćĥàńĝē ţĥĩś ĩńśţàĺĺ-ŵĩďē śēţţĩńĝ.", + "sleepInhibitionLoading": "Ĺōàďĩńĝ ĥōśţ śĺēēƥ śēţţĩńĝś…", + "sleepInhibitionLoadFailed": "Ĥōśţ śĺēēƥ śēţţĩńĝś ćōũĺď ńōţ ƀē ĺōàďēď.", + "sleepInhibitionRetry": "Ŕēţŕŷ ĺōàďĩńĝ ĥōśţ śĺēēƥ śēţţĩńĝś", + "sleepInhibitionSaveFailed": "Ƒàĩĺēď ţō śàvē ĥōśţ śĺēēƥ śēţţĩńĝś.", "taskOverview": "Ţàśķ ōvēŕvĩēŵ", "taskWorkbenchLayoutProfilesAndDefaults": "Ţàśķ ŵōŕķƀēńćĥ ĺàŷōũţ ƥŕōƒĩĺēś àńď ďēƒàũĺţś", "terminal": "Ţēŕḿĩńàĺ", diff --git a/apps/web/src/locales/zh-cn/chat.json b/apps/web/src/locales/zh-cn/chat.json index f3e694da66..288219486e 100644 --- a/apps/web/src/locales/zh-cn/chat.json +++ b/apps/web/src/locales/zh-cn/chat.json @@ -1,17 +1,4 @@ { - "attachmentDeliveryFile": "文件", - "attachmentDeliveryMode": "附件传递方式", - "attachmentDeliveryPathDescription": "上传到工作区,以便代理读取或编辑该文件。", - "attachmentDeliveryPrompt": "提示词", - "attachmentDeliveryPromptDescription": "作为提示词上下文发送以供视觉理解。代理不会获得文件路径。", - "attachmentFallbackName": "附件", - "attachmentMessageAlt": "附件 {{index}}", - "attachmentPreviewAlt": "预览", - "attachmentPreviewFullAlt": "全尺寸预览", - "attachmentUploadFailed": "附件上传失败", - "attachmentUploadPending": "正在上传附件…", - "attachmentUploadPendingSubmit": "请先完成附件上传再发送。", - "retryAttachmentUpload": "重试附件上传", "collapseMessage": "折叠消息", "collapseQueuedMessages": "折叠排队中的消息", "clearAll": "全部清除", diff --git a/apps/web/src/locales/zh-cn/settings.json b/apps/web/src/locales/zh-cn/settings.json index 9a312b022d..1d70fd3cab 100644 --- a/apps/web/src/locales/zh-cn/settings.json +++ b/apps/web/src/locales/zh-cn/settings.json @@ -619,5 +619,28 @@ "changelogTopbarLabel": "有新发布时显示通知", "changelogTopbarDescription": "启用后,有新版本发布时顶栏会显示闪光图标", "changelogResetSeenLabel": "重置已查看发布", - "changelogResetSeenDescription": "清除上次查看的版本,使顶栏通知再次出现" + "changelogResetSeenDescription": "清除上次查看的版本,使顶栏通知再次出现", + "sleepInhibitionTitle": "任务运行时防止主机休眠", + "sleepInhibitionInfoLabel": "主机休眠防护工作方式", + "sleepInhibitionInfoTitle": "Kandev 如何防止主机休眠", + "sleepInhibitionInfoTrigger": "当任一任务正在启动或运行时,后端会保持一个防休眠请求。", + "sleepInhibitionInfoMacos": "macOS 运行 <0>{{command}}。<1>{{idleOption}} 选项可防止系统空闲休眠。<2>{{waitOption}} 选项会在后端退出时停止辅助进程。", + "sleepInhibitionInfoWindows": "Windows 使用带系统休眠标志的 <0>{{api}}。没有正在工作的任务时,Kandev 会清除该请求。", + "sleepInhibitionInfoLinux": "Linux 通过 D-Bus 使用 <0>{{method}} 请求 systemd-logind。没有正在工作的任务时,Kandev 会关闭返回的文件。", + "sleepInhibitionInfoRelease": "该请求不会让显示器保持唤醒,也不会覆盖用户明确执行的休眠操作。", + "sleepInhibitionDescription": "任务正在启动或运行时,让 Kandev 后端主机保持可用。", + "sleepInhibitionSwitchLabel": "防止系统空闲休眠", + "sleepInhibitionSwitchHint": "默认关闭。此功能可能增加电池或电力消耗,并且只影响后端主机。", + "sleepInhibitionCaveat": "容器、Kubernetes、远程执行器和常开部署通常应保持关闭。它不会让显示器保持唤醒,也不会覆盖用户明确执行的休眠操作。", + "sleepInhibitionStatusLabel": "主机休眠状态", + "sleepInhibitionAvailable": "可用;当前没有活动请求。", + "sleepInhibitionActive": "任务正在启动或运行时处于活动状态。", + "sleepInhibitionUnsupported": "此后端平台不可用。", + "sleepInhibitionServiceUnavailable": "主机休眠服务不可用。", + "sleepInhibitionRequestFailed": "主机休眠请求失败;任务会正常继续。", + "sleepInhibitionAdminOnly": "只有管理员可以更改此安装范围的设置。", + "sleepInhibitionLoading": "正在加载主机休眠设置…", + "sleepInhibitionLoadFailed": "无法加载主机休眠设置。", + "sleepInhibitionRetry": "重试加载主机休眠设置", + "sleepInhibitionSaveFailed": "保存主机休眠设置失败。" } diff --git a/docs/plans/task-sleep-inhibition/plan.md b/docs/plans/task-sleep-inhibition/plan.md new file mode 100644 index 0000000000..574f88f3aa --- /dev/null +++ b/docs/plans/task-sleep-inhibition/plan.md @@ -0,0 +1,168 @@ +--- +spec: docs/specs/platform/task-sleep-inhibition.md +created: 2026-08-04 +status: done +--- + +# Implementation Plan: Task Sleep Inhibition + +## Overview + +Add an install-wide, disabled-by-default setting that drives one backend-owned native sleep-inhibition lease whenever the authoritative session repository contains a `STARTING` or `RUNNING` session. Build the event-driven lifecycle and native OS adapters first, expose them through the existing System settings API and composition, then add the Task Actions card, focused desktop/mobile E2E coverage, and public operational documentation. + +--- + +## Backend + +### Sleep-inhibition lifecycle + +Create `apps/backend/internal/system/sleepinhibition/` with: + +- `types.go`: `Settings`, `Status`, `Response`, stable issue codes, `SessionReader`, `Inhibitor`, and `Lease` contracts. `Lease` must expose release and unexpected-termination notification so a dead native request cannot remain reported as active. +- `store.go`: a typed adapter over `internal/system/settings.Store` using key `task_sleep_inhibition`; an absent value resolves to `{enabled:false}` and malformed persisted JSON returns a typed error that the service logs and treats as disabled. +- `service.go`: one owned reconciliation loop with explicit `Start(ctx)` / `Stop()` and `sync.WaitGroup` drain. Subscribe to `events.TaskSessionStateChanged`, coalesce event signals through a buffered channel, and periodically reconcile to repair missed events and retry failed acquisitions. Each reconcile loads the configured setting, calls `ListActiveTaskSessions`, filters through `orchestrator/sessionstate.IsWorking`, and transitions a single shared lease. `Update` persists and synchronously reconciles before returning the response. Shutdown always releases the lease. + +The loop, rather than event-payload ordering, owns the truth: session events are only wakeups; the repository is authoritative. A failed settings/session read leaves the last successfully reconciled lease unchanged. A failed native acquire leaves tasks unaffected, records a stable issue code plus structured warning, and retries without a tight loop. + +### Native platform implementations + +Add build-tagged adapters behind `NewPlatformInhibitor`: + +- `inhibitor_darwin.go`: start `/usr/bin/caffeinate -i -w ` without a shell. `-i` prevents idle system sleep while allowing display sleep, and `-w` bounds the helper to the backend process. Release terminates and joins the child; unexpected exit closes `Lease.Done()` with an error. +- `inhibitor_windows.go`: call `SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED)` through `golang.org/x/sys/windows` on a goroutine locked to one OS thread. Clear on the same thread with `SetThreadExecutionState(ES_CONTINUOUS)`, then join it. Do not request `ES_DISPLAY_REQUIRED` or away mode. +- `inhibitor_linux.go`: use `github.com/godbus/dbus/v5` to call `org.freedesktop.login1.Manager.Inhibit("sleep", "Kandev", "A Kandev task is running", "block")`; retain the returned Unix file descriptor for the lease and close it on release. Missing/denied system bus or logind maps to `system_service_unavailable` or `request_failed`. +- `inhibitor_other.go`: report `unsupported_platform` without side effects. + +Inject OS-call/process seams so lifecycle behavior is unit-testable on the host CI platform. Add compile-only checks for Darwin and Windows implementations. + +### System settings API and application wiring + +Extend `apps/backend/internal/system/system.go`: + +- Add the task-session reader to `system.Wiring` and a `SleepInhibition *sleepinhibition.Service` field. +- Construct the service from the shared install-wide settings store and event bus. +- Start it in `StartBackground` after the orchestrator has completed startup recovery, and stop/join it from `StopBackground`. +- Register `GET /api/v1/system/sleep-inhibition` on the authenticated System group and `PATCH /api/v1/system/sleep-inhibition` on its admin subgroup. + +Pass `repos.Task` through `apps/backend/internal/backendapp/main.go`. Add handler validation and the same read-vs-admin permission coverage used by `queuesettings` in `apps/backend/internal/system/system_routes_test.go`. A supported/active status is runtime observation only and must not become another persisted or environment-controlled setting. + +--- + +## Frontend + +### Task Actions setting card + +Create `apps/web/components/settings/sleep-inhibition-settings.tsx` and mount it in `TaskActionsSettings` in `apps/web/components/settings/general-settings.tsx`. + +- Fetch and patch the dedicated System endpoint through `apps/web/lib/api/domains/settings-api.ts`; define the wire types in `apps/web/lib/types/system.ts`. +- Use a local saved/draft snapshot and `useSettingsSaveContributor`; do not mutate the shared user-settings slice because this preference is install-wide. +- Only admins can edit. Preserve the configured value on unsupported hosts and show localized status for available/inactive, active, unsupported platform, unavailable system service, and request failure. +- Explain beside the switch that the default is off, it may consume additional battery/power, it affects only the backend host, and container/Kubernetes or always-on deployments normally leave it disabled. +- Add all copy to `apps/web/src/locales/en/settings.json` and regenerate `apps/web/src/locales/pseudo/settings.json`. + +The existing Task Actions page and `SettingsCard` are the desktop and mobile entry points. This is an inline card with the page's existing single scroll owner; it needs no new drawer, route, or viewport branch. Reuse the existing `min-h-11` label/switch row, keep the status/caveat text wrapping, and let the shared floating save control own persistence. + +--- + +## Tests + +- **What:** absent settings default off; valid values round-trip; malformed persisted data degrades to disabled. + - **File:** `apps/backend/internal/system/sleepinhibition/store_test.go` + - **How:** table-driven tests with the real System settings store on a temporary SQLite database. +- **What:** disabled sessions never acquire; the first `STARTING`/`RUNNING` session acquires once; multiple working sessions share the lease; the last settled session, setting disable, and shutdown release it. + - **File:** `apps/backend/internal/system/sleepinhibition/service_test.go` + - **How:** injected fake session reader/inhibitor and `testing/synctest` or explicit channels; include startup reconciliation, event coalescing, periodic repair, unexpected lease exit, acquisition failure/retry, and read-error behavior. +- **What:** native adapters request only system-sleep inhibition and release their owned resource. + - **Files:** `apps/backend/internal/system/sleepinhibition/inhibitor_linux_test.go` plus build-tagged Darwin/Windows tests where host-independent seams permit. + - **How:** fake D-Bus caller/process/syscall seams; compile-only Darwin/Windows package checks. Do not attempt to suspend a CI host. +- **What:** GET is readable, PATCH is admin-only, malformed requests fail, and PATCH returns reconciled configured/runtime status. + - **Files:** `apps/backend/internal/system/sleepinhibition/handler_test.go`, `apps/backend/internal/system/system_routes_test.go` + - **How:** Gin handler/service tests plus the existing auth route harness. +- **What:** the card participates in shared manual save, preserves remote snapshots, disables mutation for members, renders capability/failure states, and rejects failed saves. + - **File:** `apps/web/components/settings/sleep-inhibition-settings.test.tsx` + - **How:** Vitest/Testing Library with mocked API and settings-save contributor. + +## E2E Tests + +- **Scenario:** GIVEN the install-wide setting is at its default, WHEN an admin toggles it on, THEN the backend remains unchanged until **Save changes**, the saved value survives reload, and teardown restores the prior value. + - **File:** `apps/web/e2e/tests/settings/settings-manual-save.spec.ts` + - **What to verify:** visible host/container caveat, dirty card and floating save state, persisted `settings.enabled`, and configured value after reload. Native host inhibition remains covered by injected backend tests. +- **Scenario:** GIVEN a phone viewport on Task Actions, WHEN the admin edits the setting, THEN the full card, status, switch, and save action remain reachable without horizontal overflow or overlap. + - **File:** `apps/web/e2e/tests/settings/mobile-general-settings.spec.ts` + - **What to verify:** same saved outcome, 44px control-row geometry, card/save clearance, and document containment under the `mobile-chrome` project. + +## Public documentation + +Add a concise how-to/reference section to `docs/public/operations.md` covering where to enable the setting, its disabled default, `STARTING`/`RUNNING` boundary, display/user-sleep exclusions, macOS/Windows/Linux support, and why Docker/Kubernetes/remote executors cannot inhibit their physical hosts. Keep the settings card self-contained; the public page owns operational troubleshooting and deployment guidance. + +--- + +## Verification Results + +- Backend lifecycle, native adapters, and system wiring passed: + `cd apps/backend && go test ./internal/system/sleepinhibition -count=1`, + `cd apps/backend && go test -race ./internal/system/sleepinhibition -count=1`, + and `cd apps/backend && go test ./internal/system/... ./internal/backendapp -run + 'Test.*(SleepInhibition|SystemRoutes)' -count=1` (19 packages). +- Darwin and Windows compile-only artifacts passed with `CGO_ENABLED=0 GOOS=darwin/windows + GOARCH=amd64 go test -c`; both artifacts were written to a temporary directory outside + the repository. +- Web unit tests passed (2 files, 7 tests), targeted ESLint passed with zero warnings, + TypeScript reported no errors, and i18n pseudo/check/ratchet validation passed (four + pre-existing orphan catalog entries remain). +- Managed production-build E2E passed for the desktop manual-save scenario and the + `mobile-chrome` containment scenario (1 test each). Both tests restore the initial + install-wide setting in `finally` blocks. +- Fresh synthetic screenshots were captured, inspected, and compressed at + `apps/web/.pr-assets/settings-manual-save--sleep-inhibition-desktop-draft.png` and + `apps/web/.pr-assets/mobile-general-settings--sleep-inhibition-mobile-draft.png`; the + ignored asset manifest contains both entries and no credentials or personal data. +- Public documentation validation passed: 58 validator tests and + `Validated 41 published docs pages.` +- PR review remediation passed: lease completions now carry a generation, Linux + leases monitor logind descriptor loss, Darwin release normalizes intentional + termination and has build-tagged lifecycle tests, and the web card reads the + install-wide response from the settings-domain store with bounded polling. +- Focused remediation checks passed: `go test -race + ./internal/system/sleepinhibition` (17 tests), Darwin and Windows compile-only + checks with `CGO_ENABLED=0 GOOS=darwin/windows GOARCH=amd64 go test -c`, and + Vitest for the sleep-inhibition card plus settings slice (9 tests), including + remote-status refresh without draft replacement. +- `pnpm run typecheck`, targeted ESLint, `pnpm run i18n:check`, + `pnpm run i18n:ratchet`, and `git diff --check` passed. The branch was merged + with current `origin/main`; `apps/web/src/locales/zh-cn/chat.json` retains one + valid attachment catalog entry for each key. + +--- + +## Implementation Waves And Parallel Candidates + +Wave 1: + +- [x] [task-01-lifecycle-service](task-01-lifecycle-service.md) + +Wave 2: + +- [x] [task-02-native-platform-inhibitors](task-02-native-platform-inhibitors.md) + +Wave 3: + +- [x] [task-03-system-api-wiring](task-03-system-api-wiring.md) + +Wave 4: + +- [x] [task-04-task-actions-card](task-04-task-actions-card.md) +- [x] [task-06-public-documentation](task-06-public-documentation.md) — parallel-safe with task 04 because it touches only `docs/public/operations.md`; user authorization is still required for subagents. + +Wave 5: + +- [x] [task-05-settings-e2e](task-05-settings-e2e.md) + +The default execution order is sequential in the primary conversation. Wave labels do not authorize subagents. + +## Open risks + +- Linux support depends on access to the host system D-Bus and `systemd-logind`; containers and some non-systemd distributions will correctly report unavailable rather than controlling the node. +- Windows execution-state requirements are thread-owned, so acquisition and clearing must remain on the same locked OS thread. +- macOS helper lifecycle must be fully joined on disable and shutdown so repeated task runs do not leak `caffeinate` processes. +- Session-state publication has had historical gaps; the repository-backed periodic reconciliation is required to avoid holding a power assertion indefinitely after a missed settle event. diff --git a/docs/plans/task-sleep-inhibition/task-01-lifecycle-service.md b/docs/plans/task-sleep-inhibition/task-01-lifecycle-service.md new file mode 100644 index 0000000000..277d32ebe2 --- /dev/null +++ b/docs/plans/task-sleep-inhibition/task-01-lifecycle-service.md @@ -0,0 +1,64 @@ +--- +id: "01-lifecycle-service" +title: "Sleep-inhibition lifecycle service" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/platform/task-sleep-inhibition.md" +--- + +# Task 01: Sleep-inhibition lifecycle service + +## Acceptance + +- The typed install-wide setting defaults to disabled and round-trips through the shared System settings store. +- One owned reconciliation loop acquires exactly one injected lease only while enabled and at least one authoritative session satisfies `sessionstate.IsWorking`. +- Disable, final settle, unexpected lease exit, retry, startup recovery, repository errors, and graceful shutdown follow the spec without blocking task execution or leaking goroutines. + +## Verification + +```bash +cd apps/backend && go test ./internal/system/sleepinhibition -run 'Test(Store|Service)' +``` + +## Files likely touched + +- `apps/backend/internal/system/sleepinhibition/types.go` +- `apps/backend/internal/system/sleepinhibition/store.go` +- `apps/backend/internal/system/sleepinhibition/service.go` +- `apps/backend/internal/system/sleepinhibition/store_test.go` +- `apps/backend/internal/system/sleepinhibition/service_test.go` + +## Dependencies + +None. + +## Parallelism + +Sequential; this task establishes the shared contracts consumed by every later task. + +## Inputs + +- Spec sections: What, Data model, State machine, Failure modes, Persistence guarantees. +- Plan sections: Sleep-inhibition lifecycle, Tests. +- Existing patterns: `apps/backend/internal/system/queuesettings/`, `apps/backend/internal/system/settings/store.go`, `apps/backend/internal/orchestrator/sessionstate/sessionstate.go`, and the goroutine ownership rules in `apps/backend/AGENTS.md`. + +## Risks + +- Event callbacks must only signal reconciliation; synchronous repository/native work in the memory bus publish path would delay task state transitions. +- Reconcile retries must be bounded and cancellation-aware. + +## Output contract + +Report the implemented contracts, files changed, exact test results, blockers/risks, and synchronized task/plan status in the primary conversation. + +## Results + +- Implemented typed settings persistence, event-coalesced reconciliation, retry, + lease lifecycle, shutdown release, and repository-authoritative session + filtering in `internal/system/sleepinhibition/`. +- Focused tests passed: + `cd apps/backend && go test ./internal/system/sleepinhibition -count=1`. +- Race-focused service tests passed: + `cd apps/backend && go test ./internal/system/sleepinhibition -race -run 'TestService' -count=1`. diff --git a/docs/plans/task-sleep-inhibition/task-02-native-platform-inhibitors.md b/docs/plans/task-sleep-inhibition/task-02-native-platform-inhibitors.md new file mode 100644 index 0000000000..053715e487 --- /dev/null +++ b/docs/plans/task-sleep-inhibition/task-02-native-platform-inhibitors.md @@ -0,0 +1,75 @@ +--- +id: "02-native-platform-inhibitors" +title: "Native platform inhibitors" +status: done +wave: 2 +depends_on: ["01-lifecycle-service"] +plan: "plan.md" +spec: "../../specs/platform/task-sleep-inhibition.md" +--- + +# Task 02: Native platform inhibitors + +## Acceptance + +- Darwin, Windows, Linux, and fallback build-tagged implementations satisfy the lifecycle service's `Inhibitor`/`Lease` contracts without requesting display wakefulness. +- Each supported implementation releases and joins its owned OS resource, and unexpected helper/request failure reaches `Lease.Done()`. +- Linux system-bus/logind absence and unsupported platforms map to stable non-fatal issue codes; Darwin and Windows implementations compile from Linux CI. + +## Verification + +```bash +cd apps/backend && go test ./internal/system/sleepinhibition +``` + +```bash +tmp_dir=$(mktemp -d) && trap 'rm -rf "$tmp_dir"' EXIT && cd apps/backend && CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go test -c ./internal/system/sleepinhibition -o "$tmp_dir/sleepinhibition-darwin.test" && CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go test -c ./internal/system/sleepinhibition -o "$tmp_dir/sleepinhibition-windows.test.exe" +``` + +## Files likely touched + +- `apps/backend/internal/system/sleepinhibition/inhibitor.go` +- `apps/backend/internal/system/sleepinhibition/inhibitor_darwin.go` +- `apps/backend/internal/system/sleepinhibition/inhibitor_linux.go` +- `apps/backend/internal/system/sleepinhibition/inhibitor_windows.go` +- `apps/backend/internal/system/sleepinhibition/inhibitor_other.go` +- `apps/backend/internal/system/sleepinhibition/inhibitor_linux_test.go` +- `apps/backend/internal/system/sleepinhibition/inhibitor_darwin_test.go` +- `apps/backend/internal/system/sleepinhibition/inhibitor_windows_test.go` +- `apps/backend/go.mod` +- `apps/backend/go.sum` + +## Dependencies + +Task 01. + +## Parallelism + +Sequential; it implements shared interfaces and changes the Go module graph. + +## Inputs + +- Spec section: Failure modes. +- Plan section: Native platform implementations. +- Platform contracts: macOS `caffeinate -i`, Windows `SetThreadExecutionState`, and `org.freedesktop.login1.Manager.Inhibit`. + +## Risks + +- Windows acquire/release calls must execute on the same locked OS thread. +- macOS child cleanup must handle both requested termination and prior unexpected exit without double-waiting. +- Do not make native API availability a prerequisite for backend startup or task execution. + +## Output contract + +Report adapters, dependency changes, cross-platform compile evidence and artifact cleanup, files changed, blockers/risks, and synchronized task/plan status. + +## Results + +- Added build-tagged macOS `caffeinate -i -w`, Windows thread-owned execution + state, Linux logind D-Bus, and unsupported-platform adapters with injected + seams. +- Linux adapter tests passed: + `cd apps/backend && go test ./internal/system/sleepinhibition -count=1`. +- Compile-only Darwin and Windows artifacts were produced successfully with + `CGO_ENABLED=0 GOOS=darwin/windows go test -c`; temporary artifacts were + written outside the repository. diff --git a/docs/plans/task-sleep-inhibition/task-03-system-api-wiring.md b/docs/plans/task-sleep-inhibition/task-03-system-api-wiring.md new file mode 100644 index 0000000000..7a4ed6d40a --- /dev/null +++ b/docs/plans/task-sleep-inhibition/task-03-system-api-wiring.md @@ -0,0 +1,63 @@ +--- +id: "03-system-api-wiring" +title: "System API wiring" +status: done +wave: 3 +depends_on: ["01-lifecycle-service", "02-native-platform-inhibitors"] +plan: "plan.md" +spec: "../../specs/platform/task-sleep-inhibition.md" +--- + +# Task 03: System API wiring + +## Acceptance + +- GET returns persisted settings plus reconciled platform status; PATCH validates a boolean, requires admin, persists, and returns post-reconcile status. +- System composition starts sleep inhibition after orchestrator recovery, passes the authoritative task-session repository, and releases/joins it during cleanup. +- Route, startup-default, malformed-body, and read-versus-admin permission tests pass without changing existing System endpoints. + +## Verification + +```bash +cd apps/backend && go test ./internal/system/sleepinhibition ./internal/system ./internal/backendapp -run 'Test.*(SleepInhibition|SystemRoutes)' +``` + +## Files likely touched + +- `apps/backend/internal/system/sleepinhibition/handler.go` +- `apps/backend/internal/system/sleepinhibition/handler_test.go` +- `apps/backend/internal/system/system.go` +- `apps/backend/internal/system/system_routes_test.go` +- `apps/backend/internal/backendapp/main.go` +- `apps/backend/internal/backendapp/system_sleep_inhibition_test.go` + +## Dependencies + +Tasks 01 and 02. + +## Parallelism + +Sequential; this task consumes both the core service and platform factory and changes shared application composition. + +## Inputs + +- Spec sections: API surface, Permissions, Persistence guarantees. +- Plan section: System settings API and application wiring. +- Existing patterns: `internal/system/queuesettings/`, `internal/system/system.go`, and `backendapp/main.go` System-pages construction. + +## Risks + +- The System service is constructed after orchestrator startup; preserve that ordering so stale recovered session states are reconciled before the first lease decision. +- Admin middleware belongs only on PATCH; authenticated members must retain read access to status. + +## Output contract + +Report route shapes, startup/shutdown wiring, files changed, exact tests, blockers/risks, and synchronized task/plan status. + +## Results + +- Added authenticated GET and admin-only PATCH routes, strict boolean request + validation, shared-store composition, startup reconciliation, and shutdown + lease release. +- Focused route/system checks passed: + `cd apps/backend && go test ./internal/system/... ./internal/backendapp -run 'Test.*(SleepInhibition|SystemRoutes)' -count=1`. diff --git a/docs/plans/task-sleep-inhibition/task-04-task-actions-card.md b/docs/plans/task-sleep-inhibition/task-04-task-actions-card.md new file mode 100644 index 0000000000..318944e2ab --- /dev/null +++ b/docs/plans/task-sleep-inhibition/task-04-task-actions-card.md @@ -0,0 +1,74 @@ +--- +id: "04-task-actions-card" +title: "Task Actions sleep setting" +status: done +wave: 4 +depends_on: ["03-system-api-wiring"] +plan: "plan.md" +spec: "../../specs/platform/task-sleep-inhibition.md" +--- + +# Task 04: Task Actions sleep setting + +## Acceptance + +- Task Actions renders a localized, self-documenting install-wide sleep-prevention card with saved/draft state in the shared floating-save workflow. +- Administrators can save while members are read-only; configured, active, unsupported, unavailable-service, request-failed, loading, and save-failure states are distinguishable. +- The unchanged settings-page mobile composition keeps wrapping copy and controls within its single scroll surface, with no desktop-only interaction. + +## Verification + +```bash +cd apps && pnpm install --frozen-lockfile +``` + +```bash +cd apps && pnpm --filter @kandev/web test -- components/settings/sleep-inhibition-settings.test.tsx +``` + +```bash +cd apps/web && pnpm run typecheck && pnpm run i18n:check && pnpm run i18n:ratchet +``` + +## Files likely touched + +- `apps/web/components/settings/sleep-inhibition-settings.tsx` +- `apps/web/components/settings/sleep-inhibition-settings.test.tsx` +- `apps/web/components/settings/general-settings.tsx` +- `apps/web/lib/api/domains/settings-api.ts` +- `apps/web/lib/types/system.ts` +- `apps/web/src/locales/en/settings.json` +- `apps/web/src/locales/pseudo/settings.json` + +## Dependencies + +Task 03. + +## Parallelism + +Parallel-safe only with Task 06: the frontend and public-doc file sets are disjoint and neither changes schemas, generated contracts, lockfiles, or package configuration. Execution remains sequential unless the user explicitly authorizes subagents. + +## Inputs + +- Spec sections: What, API surface, Permissions, Scenarios. +- Plan section: Task Actions setting card. +- Mobile exemplar: existing cards in `TaskActionsSettings` and `apps/web/e2e/tests/settings/mobile-general-settings.spec.ts`; no new overlay or navigation surface is introduced. + +## Risks + +- Do not place this install-wide value in the user-settings Zustand slice. +- Saving an enabled value on an unsupported host is valid; the configured switch and inactive capability warning must not contradict each other. + +## Output contract + +Report UI/API behavior, mobile contract, localization updates, files changed, exact tests, blockers/risks, and synchronized task/plan status. + +## Results + +- Added the localized install-wide Task Actions card, API wire types/client, and + local saved/draft contributor state without mutating the shared user-settings slice. +- Members retain the configured value but cannot edit; admins participate in the + shared floating Save changes flow. Capability, active, unavailable, unsupported, + loading, and save-failure states are rendered from the dedicated System response. +- Focused Vitest passed (2 files, 7 tests), targeted ESLint passed with zero warnings, + TypeScript reported no errors, and pseudo-locale/i18n checks and ratchet passed. diff --git a/docs/plans/task-sleep-inhibition/task-05-settings-e2e.md b/docs/plans/task-sleep-inhibition/task-05-settings-e2e.md new file mode 100644 index 0000000000..c73a990525 --- /dev/null +++ b/docs/plans/task-sleep-inhibition/task-05-settings-e2e.md @@ -0,0 +1,73 @@ +--- +id: "05-settings-e2e" +title: "Sleep setting E2E coverage" +status: done +wave: 5 +depends_on: ["03-system-api-wiring", "04-task-actions-card"] +plan: "plan.md" +spec: "../../specs/platform/task-sleep-inhibition.md" +--- + +# Task 05: Sleep setting E2E coverage + +## Acceptance + +- Desktop E2E proves default/configured value, manual-save timing, persistence after reload, visible deployment caveat, and cleanup restoration through the real UI and API. +- Mobile E2E proves the same saved outcome plus control/card containment, floating-save clearance, a 44px control row, and no document horizontal overflow. +- Focused managed-runner commands discover and pass the intended desktop and `mobile-chrome` tests against fresh production builds. + +## Verification + +```bash +cd apps && pnpm install --frozen-lockfile +``` + +```bash +cd apps/web && pnpm e2e:run tests/settings/settings-manual-save.spec.ts -- --grep "persists host sleep inhibition only when Save changes is pressed" +``` + +```bash +cd apps/web && pnpm e2e:run --project mobile-chrome tests/settings/mobile-general-settings.spec.ts -- --grep "host sleep inhibition" +``` + +## Files likely touched + +- `apps/web/e2e/tests/settings/settings-manual-save.spec.ts` +- `apps/web/e2e/tests/settings/mobile-general-settings.spec.ts` + +## Dependencies + +Tasks 03 and 04. + +## Parallelism + +Sequential after backend and frontend behavior exist. + +## Inputs + +- Spec scenarios: default off, save/reload, unavailable container host, phone viewport. +- Plan section: E2E Tests. +- E2E guidance: real UI assertions, baseline restore in `afterEach`/`finally`, production-build managed runner, and `mobile-*.spec.ts` routing. This existing mobile file already matches the `mobile-chrome` project. + +## Risks + +- E2E must not attempt to suspend or inhibit the CI host; native acquisition is covered through backend seams. +- Always restore the install-wide setting because the worker-scoped backend persists it across tests. + +## Output contract + +Report discovered test counts, exact commands/outcomes, screenshots or other artifacts if produced, cleanup evidence, files changed, blockers/risks, and synchronized task/plan status. + +## Results + +- Desktop managed runner passed: + `cd apps/web && pnpm e2e:run tests/settings/settings-manual-save.spec.ts -- --grep + "persists host sleep inhibition only when Save changes is pressed"` (1 passed). +- Mobile managed runner passed: + `cd apps/web && pnpm e2e:run --project mobile-chrome + tests/settings/mobile-general-settings.spec.ts -- --grep "host sleep inhibition"` + (1 passed). +- The tests cover default/configured state, save timing, reload persistence, the host + caveat, 44px control-row geometry, save clearance, horizontal containment, and + `finally` restoration of the install-wide setting. Capture-enabled reruns also passed + and produced the two ignored PR assets recorded in the plan verification results. diff --git a/docs/plans/task-sleep-inhibition/task-06-public-documentation.md b/docs/plans/task-sleep-inhibition/task-06-public-documentation.md new file mode 100644 index 0000000000..59052160fb --- /dev/null +++ b/docs/plans/task-sleep-inhibition/task-06-public-documentation.md @@ -0,0 +1,62 @@ +--- +id: "06-public-documentation" +title: "Host sleep documentation" +status: done +wave: 4 +depends_on: ["03-system-api-wiring"] +plan: "plan.md" +spec: "../../specs/platform/task-sleep-inhibition.md" +--- + +# Task 06: Host sleep documentation + +## Acceptance + +- `docs/public/operations.md` explains how to enable the setting, its disabled default and admin/install scope, and the exact `STARTING`/`RUNNING` lifecycle boundary. +- The section distinguishes system sleep from display/explicit sleep and clearly states the backend-host, Linux logind, container/Kubernetes, and remote-executor limitations. +- Public-doc validators pass and the page remains primarily an operations how-to/reference. + +## Verification + +```bash +rg -n "sleep|Task Actions|Kubernetes|logind" docs/public/operations.md docs/specs/platform/task-sleep-inhibition.md +``` + +```bash +node --test scripts/validate-public-docs.test.mjs && node scripts/validate-public-docs.mjs +``` + +## Files likely touched + +- `docs/public/operations.md` + +## Dependencies + +Task 03 establishes the final endpoint/status implementation to document. + +## Parallelism + +Parallel-safe with Task 04 only: it owns a single public-doc file disjoint from frontend implementation. Execution remains sequential unless the user explicitly authorizes subagents. + +## Inputs + +- Spec in full. +- Plan section: Public documentation. +- Docs-maintainer guidance; `docs/public/operations.md` is the existing deployment/operations owner. + +## Risks + +- Do not imply that enabling inside a container controls the physical node or that Kandev can override lid-close/user/administrator power policy. + +## Output contract + +Report the updated section and its Diataxis classification, exact validation results, files changed, blockers/risks, and synchronized task/plan status. + +## Results + +- Added the operations how-to/reference section describing the admin path, disabled + default, `STARTING`/`RUNNING` boundary, display and explicit-sleep exclusions, + macOS/Windows/Linux support, system-service failures, and container/Kubernetes/ + remote-executor limitations. +- Public-doc validation passed: `node --test scripts/validate-public-docs.test.mjs` + (58 passed) and `node scripts/validate-public-docs.mjs` (`Validated 41 published docs pages.`). diff --git a/docs/public/operations.md b/docs/public/operations.md index 263039bd02..08d277f8b9 100644 --- a/docs/public/operations.md +++ b/docs/public/operations.md @@ -27,6 +27,35 @@ Kandev does not currently provide a user-login boundary for the web application, See [Desktop app](desktop-app.md), [CLI](cli.md), [Run as a service](run-as-a-service.md), [Docker](docker.md), and [Kubernetes](k8s.md) for mode-specific prerequisites and commands. +## Prevent host sleep during active tasks + +Administrators can open **Settings > General > Task Actions** and enable +**Prevent host sleep while tasks run**. The install-wide setting is off by +default and is saved with the Kandev database. It applies only to the machine +running the backend; it does not change executor or node power policy. + +When enabled, Kandev holds one host sleep-prevention request while any task +session is `STARTING` or `RUNNING`. It releases the request when the last such +session reaches `WAITING_FOR_INPUT`, `IDLE`, or a terminal state, when an +administrator disables the setting, and during backend shutdown. Missed session +events are repaired by periodic reconciliation against the session repository. + +The request prevents idle system sleep but does not keep the display awake or +override an explicit user sleep action, lid-close behavior, shutdown, low-power +emergency, or another administrator/platform policy. It is supported through +macOS `caffeinate`, Windows execution state, and Linux `systemd-logind` over the +system D-Bus. A missing or denied Linux system bus, an unavailable service, or +another native failure is shown in the setting status and never blocks task +execution. + +Do not enable this setting by default for Docker, Kubernetes, remote-executor, +or always-on deployments. A backend inside a container or pod cannot inhibit +sleep on the physical node, and an SSH or other remote executor runs on a +different host. Those deployments should use their normal host power and +availability policy instead. Enabling the preference is safe when moving the +database between hosts: Kandev reacquires a native request only after startup +if the new backend can provide it and a working task still exists. + ## Health and readiness Use the top-level readiness endpoint for supervisors and probes: diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index d7f25a85d3..6a2e678567 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -38,6 +38,7 @@ Product-wide capabilities that are not tied to a single feature area. | Spec | Status | |---|---| | [background-work-liveness](platform/background-work-liveness.md) | shipped | +| [task-sleep-inhibition](platform/task-sleep-inhibition.md) | building | | [i18n](platform/i18n.md) | building | | [plugins](plugins/spec.md) | draft | | [plugins — authoring experience](plugins/authoring-experience.md) | draft | diff --git a/docs/specs/platform/task-sleep-inhibition.md b/docs/specs/platform/task-sleep-inhibition.md new file mode 100644 index 0000000000..29dd756fa6 --- /dev/null +++ b/docs/specs/platform/task-sleep-inhibition.md @@ -0,0 +1,108 @@ +--- +status: building +created: 2026-08-04 +owner: kandev +--- + +# Prevent Host Sleep During Active Tasks + +## Why + +Long-running local tasks can be interrupted when the computer hosting Kandev enters idle sleep. Operators need an explicit way to keep that host awake during active agent work without imposing a power-management policy on servers, containers, Kubernetes installations, or always-on machines that do not need it. + +## What + +- Settings > General > Task Actions exposes an install-wide **Prevent host sleep while tasks run** setting to administrators. +- The setting is disabled by default. Missing or older persisted settings resolve to disabled. +- When enabled, Kandev requests that the operating system prevent idle system sleep while at least one task session is `STARTING` or `RUNNING`. +- Kandev releases the request after the last session leaves `STARTING` or `RUNNING`, when the setting is disabled, and during graceful backend shutdown. +- `WAITING_FOR_INPUT`, `IDLE`, `CREATED`, and terminal session states do not keep the host awake. +- The request keeps the host system available for task execution but does not keep the display awake and does not override an explicit user sleep action, lid close, shutdown, low-power emergency, or platform policy. +- The setting affects only the machine running the Kandev backend. It does not inhibit a Kubernetes node, container host, SSH executor, Docker executor, Sprites runtime, or another remote machine from inside an isolated Kandev deployment. +- The Task Actions card explains the host boundary, the power/battery tradeoff, the disabled default, and that containerized/server deployments should normally leave the setting off. +- The same card reports whether the current backend platform can provide the request and whether a request is currently active. Unsupported or failed inhibition never blocks task execution. + +## Data model + +The existing install-wide `settings` table stores key `task_sleep_inhibition` with this JSON value: + +```json +{"enabled": false} +``` + +`enabled` is a non-null boolean. An absent key resolves to `false`. Runtime capability, activity, and error status are process-local observations and are not persisted. + +## API surface + +`GET /api/v1/system/sleep-inhibition` returns: + +```json +{ + "settings": { "enabled": false }, + "status": { + "platform": "linux", + "supported": false, + "active": false, + "issue": "system_service_unavailable" + } +} +``` + +- `platform` is `darwin`, `windows`, `linux`, or `other`. +- `issue` is omitted when no issue is present. Stable values are `unsupported_platform`, `system_service_unavailable`, and `request_failed`. +- `active` is true only while Kandev currently holds the native sleep-prevention request. + +`PATCH /api/v1/system/sleep-inhibition` accepts `{ "enabled": boolean }` and returns the same response shape after persisting and reconciling the setting. Reads are available to authenticated users; mutation requires the admin role. Enabling an unavailable implementation is allowed so the saved preference survives deployment moves, but the response remains inactive and reports an issue. + +## State machine + +| Saved setting | Working sessions (`STARTING` or `RUNNING`) | Native request | Observable status | +| --- | ---: | --- | --- | +| Disabled | Any | Released | `active=false` | +| Enabled | 0 | Released | `active=false` | +| Enabled | 1 or more | Acquired once and shared | `active=true` | +| Enabled | 1 or more, platform unavailable/fails | Not acquired | `active=false`, `issue` set | + +Session-state events trigger reconciliation against the authoritative session repository. A periodic reconciliation also repairs a missed event and retries a failed native request while work remains active. Multiple working sessions never create multiple native requests. + +## Permissions + +- Any authenticated user may read the configured value and current status. +- Only an administrator may change the install-wide setting. +- With authentication disabled, the existing synthetic administrator retains current single-user behavior. + +## Failure modes + +- On macOS, Kandev requests idle-system-sleep prevention without requesting display wakefulness. If the native facility cannot start or exits unexpectedly, tasks continue and status reports `request_failed`. +- On Windows, Kandev holds `ES_CONTINUOUS | ES_SYSTEM_REQUIRED` on one locked OS thread and clears it with `ES_CONTINUOUS` on release. A failed call leaves tasks running and reports `request_failed`. +- On Linux, Kandev obtains a blocking `sleep` inhibitor from `systemd-logind` over the system bus and holds its returned file descriptor. A missing system bus, missing logind service, container isolation, or denied policy reports `system_service_unavailable` or `request_failed` without affecting tasks. +- Other operating systems report `unsupported_platform` and never attempt inhibition. +- A settings-store or session-query error leaves the last successfully reconciled native request unchanged, records a warning, and retries during the next reconciliation. Shutdown always attempts release. + +## Persistence guarantees + +The enabled value survives backend restarts in the install-wide settings store. Native requests do not survive process exit. After startup reconciliation, Kandev reacquires a request only when the saved setting is enabled and an authoritative session is still `STARTING` or `RUNNING`; otherwise it remains released. + +## Scenarios + +- **GIVEN** a new or upgraded installation, **WHEN** an administrator opens Task Actions, **THEN** sleep prevention is off and no native request is held. +- **GIVEN** sleep prevention is off, **WHEN** a task session enters `STARTING` and then `RUNNING`, **THEN** no native request is held. +- **GIVEN** sleep prevention is on with no working sessions, **WHEN** the first session enters `STARTING`, **THEN** Kandev acquires one native request. +- **GIVEN** one native request and two working sessions, **WHEN** one session settles, **THEN** the request remains active for the other session. +- **GIVEN** sleep prevention is active, **WHEN** the last working session enters `WAITING_FOR_INPUT`, `IDLE`, or a terminal state, **THEN** Kandev releases the native request. +- **GIVEN** a working session and active native request, **WHEN** an administrator disables the setting, **THEN** Kandev releases the request immediately after saving. +- **GIVEN** the setting is enabled in Docker or Kubernetes without access to host power management, **WHEN** a task runs, **THEN** the task continues normally while the settings status reports that inhibition is unavailable. +- **GIVEN** an enabled setting and a working session across backend recovery, **WHEN** startup reconciliation completes, **THEN** Kandev reacquires the request only if the session still has a working state. +- **GIVEN** a phone-sized settings viewport, **WHEN** an administrator edits and saves the setting, **THEN** the same capability is available without horizontal overflow or the floating save control covering the card. + +## Out of scope + +- Waking a machine that is already asleep or scheduling wake timers. +- Keeping the display, screen saver, or keyboard backlight awake. +- Overriding explicit suspend, lid-close, shutdown, battery, thermal, or administrator policies. +- Preventing sleep on executor hosts or Kubernetes nodes from an isolated backend. +- A runtime feature flag or environment-variable override for this preference. + +## Implementation plan + +[Task sleep inhibition plan](../../plans/task-sleep-inhibition/plan.md)