feat: notifications (#24)

This commit is contained in:
Carlos Florêncio
2026-01-16 23:14:09 +00:00
committed by GitHub
parent 2e108b040f
commit 72427602db
25 changed files with 2032 additions and 119 deletions
+33
View File
@@ -41,6 +41,10 @@ import (
agentsettingshandlers "github.com/kandev/kandev/internal/agent/settings/handlers"
settingsstore "github.com/kandev/kandev/internal/agent/settings/store"
"github.com/kandev/kandev/internal/agent/worktree"
notificationcontroller "github.com/kandev/kandev/internal/notifications/controller"
notificationhandlers "github.com/kandev/kandev/internal/notifications/handlers"
notificationservice "github.com/kandev/kandev/internal/notifications/service"
notificationstore "github.com/kandev/kandev/internal/notifications/store"
// Orchestrator packages
"github.com/kandev/kandev/internal/orchestrator"
@@ -210,6 +214,14 @@ func main() {
_ = userRepo.Close()
}()
notificationRepo, err := notificationstore.NewSQLiteRepository(dbPath)
if err != nil {
log.Fatal("Failed to initialize notification store", zap.Error(err), zap.String("db_path", dbPath))
}
defer func() {
_ = notificationRepo.Close()
}()
discoveryRegistry, err := discovery.LoadRegistry()
if err != nil {
log.Fatal("Failed to load agent discovery config", zap.Error(err))
@@ -218,6 +230,8 @@ func main() {
userSvc := userservice.NewService(userRepo, eventBus, log)
userCtrl := usercontroller.NewController(userSvc)
var notificationSvc *notificationservice.Service
var notificationCtrl *notificationcontroller.Controller
taskSvc := taskservice.NewService(
taskRepo,
@@ -462,6 +476,22 @@ func main() {
gateways.RegisterTaskNotifications(ctx, eventBus, gateway.Hub, log)
gateways.RegisterUserNotifications(ctx, eventBus, gateway.Hub, log)
notificationSvc = notificationservice.NewService(notificationRepo, taskRepo, gateway.Hub, log)
notificationCtrl = notificationcontroller.NewController(notificationSvc)
if eventBus != nil {
_, err = eventBus.Subscribe(events.TaskSessionStateChanged, func(ctx context.Context, event *bus.Event) error {
data := event.Data
taskID, _ := data["task_id"].(string)
sessionID, _ := data["task_session_id"].(string)
newState, _ := data["new_state"].(string)
notificationSvc.HandleTaskSessionStateChanged(ctx, taskID, sessionID, newState)
return nil
})
if err != nil {
log.Error("Failed to subscribe to task session notifications", zap.Error(err))
}
}
// Set up historical logs provider for task subscriptions
// Uses messages instead of execution logs - all agent messages are now messages
gateway.Hub.SetHistoricalLogsProvider(func(ctx context.Context, taskID string) ([]*ws.Message, error) {
@@ -781,6 +811,9 @@ func main() {
userhandlers.RegisterRoutes(router, gateway.Dispatcher, userCtrl, log)
log.Info("Registered User handlers (HTTP + WebSocket)")
notificationhandlers.RegisterRoutes(router, notificationCtrl, log)
log.Info("Registered Notification handlers (HTTP)")
// Health check (simple HTTP for load balancers/monitoring)
router.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
@@ -0,0 +1,94 @@
package controller
import (
"context"
"strings"
"github.com/kandev/kandev/internal/notifications/dto"
"github.com/kandev/kandev/internal/notifications/models"
"github.com/kandev/kandev/internal/notifications/service"
userstore "github.com/kandev/kandev/internal/user/store"
)
type Controller struct {
service *service.Service
}
func NewController(svc *service.Service) *Controller {
return &Controller{service: svc}
}
func (c *Controller) ListProviders(ctx context.Context) (dto.NotificationProvidersResponse, error) {
userID := userstore.DefaultUserID
providers, subscriptions, err := c.service.ListProviders(ctx, userID)
if err != nil {
return dto.NotificationProvidersResponse{}, err
}
result := make([]dto.NotificationProviderDTO, 0, len(providers))
for _, provider := range providers {
events := subscriptions[provider.ID]
result = append(result, dto.FromProvider(provider, events))
}
return dto.NotificationProvidersResponse{
Providers: result,
AppriseAvailable: c.service.AppriseAvailable(),
Events: c.service.AvailableEvents(),
}, nil
}
func (c *Controller) CreateProvider(ctx context.Context, req dto.UpsertProviderRequest) (dto.NotificationProviderDTO, error) {
userID := userstore.DefaultUserID
name := strings.TrimSpace(req.Name)
if name == "" {
name = defaultNameForType(req.Type)
}
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
}
providerType := models.ProviderType(req.Type)
config := req.Config
provider, err := c.service.CreateProvider(ctx, userID, name, providerType, config, enabled, req.Events)
if err != nil {
return dto.NotificationProviderDTO{}, err
}
return dto.FromProvider(provider, req.Events), nil
}
func (c *Controller) UpdateProvider(ctx context.Context, providerID string, req dto.UpdateProviderRequest) (dto.NotificationProviderDTO, error) {
var providerType *models.ProviderType
if req.Type != nil {
t := models.ProviderType(*req.Type)
providerType = &t
}
updates := service.ProviderUpdate{
Name: req.Name,
Enabled: req.Enabled,
Type: providerType,
Config: req.Config,
Events: req.Events,
}
provider, err := c.service.UpdateProvider(ctx, providerID, updates)
if err != nil {
return dto.NotificationProviderDTO{}, err
}
userID := userstore.DefaultUserID
_, subscriptions, err := c.service.ListProviders(ctx, userID)
if err != nil {
return dto.NotificationProviderDTO{}, err
}
return dto.FromProvider(provider, subscriptions[provider.ID]), nil
}
func (c *Controller) DeleteProvider(ctx context.Context, providerID string) error {
return c.service.DeleteProvider(ctx, providerID)
}
func defaultNameForType(providerType string) string {
switch providerType {
case string(models.ProviderTypeApprise):
return "Apprise"
default:
return "Notifications"
}
}
@@ -0,0 +1,53 @@
package dto
import (
"time"
"github.com/kandev/kandev/internal/notifications/models"
)
type NotificationProviderDTO struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Config map[string]interface{} `json:"config"`
Enabled bool `json:"enabled"`
Events []string `json:"events"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type NotificationProvidersResponse struct {
Providers []NotificationProviderDTO `json:"providers"`
AppriseAvailable bool `json:"apprise_available"`
Events []string `json:"events"`
}
type UpsertProviderRequest struct {
Name string `json:"name"`
Type string `json:"type"`
Config map[string]interface{} `json:"config,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
Events []string `json:"events,omitempty"`
}
type UpdateProviderRequest struct {
Name *string `json:"name,omitempty"`
Type *string `json:"type,omitempty"`
Config map[string]interface{} `json:"config,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
Events *[]string `json:"events,omitempty"`
}
func FromProvider(provider *models.Provider, events []string) NotificationProviderDTO {
return NotificationProviderDTO{
ID: provider.ID,
Name: provider.Name,
Type: string(provider.Type),
Config: provider.Config,
Enabled: provider.Enabled,
Events: events,
CreatedAt: provider.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: provider.UpdatedAt.UTC().Format(time.RFC3339),
}
}
@@ -0,0 +1,80 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/notifications/controller"
"github.com/kandev/kandev/internal/notifications/dto"
"go.uber.org/zap"
)
type Handlers struct {
controller *controller.Controller
logger *logger.Logger
}
func RegisterRoutes(router *gin.Engine, ctrl *controller.Controller, log *logger.Logger) {
h := &Handlers{
controller: ctrl,
logger: log.WithFields(zap.String("component", "notification-handlers")),
}
api := router.Group("/api/v1")
api.GET("/notification-providers", h.httpListProviders)
api.POST("/notification-providers", h.httpCreateProvider)
api.PATCH("/notification-providers/:id", h.httpUpdateProvider)
api.DELETE("/notification-providers/:id", h.httpDeleteProvider)
}
func (h *Handlers) httpListProviders(c *gin.Context) {
resp, err := h.controller.ListProviders(c.Request.Context())
if err != nil {
h.logger.Error("failed to list notification providers", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list notification providers"})
return
}
c.JSON(http.StatusOK, resp)
}
func (h *Handlers) httpCreateProvider(c *gin.Context) {
var body dto.UpsertProviderRequest
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
resp, err := h.controller.CreateProvider(c.Request.Context(), body)
if err != nil {
h.logger.Error("failed to create notification provider", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, resp)
}
func (h *Handlers) httpUpdateProvider(c *gin.Context) {
providerID := c.Param("id")
var body dto.UpdateProviderRequest
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
resp, err := h.controller.UpdateProvider(c.Request.Context(), providerID, body)
if err != nil {
h.logger.Error("failed to update notification provider", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, resp)
}
func (h *Handlers) httpDeleteProvider(c *gin.Context) {
providerID := c.Param("id")
if err := h.controller.DeleteProvider(c.Request.Context(), providerID); err != nil {
h.logger.Error("failed to delete notification provider", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete notification provider"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
@@ -0,0 +1,40 @@
package models
import "time"
type ProviderType string
const (
ProviderTypeLocal ProviderType = "local"
ProviderTypeApprise ProviderType = "apprise"
)
type Provider struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Name string `json:"name"`
Type ProviderType `json:"type"`
Config map[string]interface{} `json:"config"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Subscription struct {
ID string `json:"id"`
UserID string `json:"user_id"`
ProviderID string `json:"provider_id"`
EventType string `json:"event_type"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Delivery struct {
ID string `json:"id"`
UserID string `json:"user_id"`
ProviderID string `json:"provider_id"`
EventType string `json:"event_type"`
TaskSessionID string `json:"task_session_id"`
CreatedAt time.Time `json:"created_at"`
}
@@ -0,0 +1,83 @@
package providers
import (
"context"
"fmt"
"os/exec"
"strings"
"time"
)
type AppriseProvider struct{}
func NewAppriseProvider() *AppriseProvider {
return &AppriseProvider{}
}
func (p *AppriseProvider) Available() bool {
_, err := exec.LookPath("apprise")
return err == nil
}
func (p *AppriseProvider) Validate(config map[string]interface{}) error {
_, err := parseAppriseURLs(config)
return err
}
func (p *AppriseProvider) Send(ctx context.Context, message Message) error {
if !p.Available() {
return fmt.Errorf("apprise not installed")
}
urls, err := parseAppriseURLs(message.Config)
if err != nil {
return err
}
if len(urls) == 0 {
return fmt.Errorf("apprise urls not configured")
}
timeoutCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
args := []string{"-t", message.Title, "-b", message.Body}
args = append(args, urls...)
cmd := exec.CommandContext(timeoutCtx, "apprise", args...)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("apprise failed: %w (%s)", err, strings.TrimSpace(string(output)))
}
return nil
}
func parseAppriseURLs(config map[string]interface{}) ([]string, error) {
if config == nil {
return nil, fmt.Errorf("apprise config missing")
}
raw, ok := config["urls"]
if !ok {
return nil, fmt.Errorf("apprise urls missing")
}
switch value := raw.(type) {
case []string:
return value, nil
case []interface{}:
urls := make([]string, 0, len(value))
for _, item := range value {
text, ok := item.(string)
if ok && strings.TrimSpace(text) != "" {
urls = append(urls, strings.TrimSpace(text))
}
}
return urls, nil
case string:
parts := strings.Split(value, "\n")
var urls []string
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
urls = append(urls, part)
}
}
return urls, nil
default:
return nil, fmt.Errorf("apprise urls must be a list of strings")
}
}
@@ -0,0 +1,42 @@
package providers
import (
"context"
"fmt"
gatewayws "github.com/kandev/kandev/internal/gateway/websocket"
ws "github.com/kandev/kandev/pkg/websocket"
)
type LocalProvider struct {
hub *gatewayws.Hub
}
func NewLocalProvider(hub *gatewayws.Hub) *LocalProvider {
return &LocalProvider{hub: hub}
}
func (p *LocalProvider) Available() bool {
return p.hub != nil
}
func (p *LocalProvider) Validate(_ map[string]interface{}) error {
return nil
}
func (p *LocalProvider) Send(_ context.Context, message Message) error {
if p.hub == nil {
return fmt.Errorf("websocket hub not available")
}
msg, err := ws.NewNotification(ws.ActionTaskSessionWaitingForInput, map[string]interface{}{
"task_id": message.TaskID,
"task_session_id": message.TaskSessionID,
"title": message.Title,
"body": message.Body,
})
if err != nil {
return err
}
p.hub.BroadcastToUser(message.UserID, msg)
return nil
}
@@ -0,0 +1,21 @@
package providers
import (
"context"
)
type Message struct {
EventType string
Title string
Body string
TaskID string
TaskSessionID string
UserID string
Config map[string]interface{}
}
type Provider interface {
Available() bool
Validate(config map[string]interface{}) error
Send(ctx context.Context, message Message) error
}
@@ -0,0 +1,288 @@
package service
import (
"context"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/kandev/kandev/internal/common/logger"
gatewayws "github.com/kandev/kandev/internal/gateway/websocket"
"github.com/kandev/kandev/internal/notifications/models"
"github.com/kandev/kandev/internal/notifications/providers"
notificationstore "github.com/kandev/kandev/internal/notifications/store"
"github.com/kandev/kandev/internal/task/repository"
userstore "github.com/kandev/kandev/internal/user/store"
"go.uber.org/zap"
)
const (
EventTaskSessionWaitingForInput = "task_session.waiting_for_input"
)
var ErrProviderNotFound = errors.New("notification provider not found")
type Service struct {
repo notificationstore.Repository
taskRepo repository.Repository
hub *gatewayws.Hub
logger *logger.Logger
providers map[models.ProviderType]providers.Provider
}
func NewService(repo notificationstore.Repository, taskRepo repository.Repository, hub *gatewayws.Hub, log *logger.Logger) *Service {
providerMap := map[models.ProviderType]providers.Provider{
models.ProviderTypeLocal: providers.NewLocalProvider(hub),
models.ProviderTypeApprise: providers.NewAppriseProvider(),
}
return &Service{
repo: repo,
taskRepo: taskRepo,
hub: hub,
logger: log.WithFields(zap.String("component", "notifications-service")),
providers: providerMap,
}
}
func (s *Service) AppriseAvailable() bool {
provider := s.providers[models.ProviderTypeApprise]
if provider == nil {
return false
}
return provider.Available()
}
func (s *Service) AvailableEvents() []string {
return []string{EventTaskSessionWaitingForInput}
}
func (s *Service) ListProviders(ctx context.Context, userID string) ([]*models.Provider, map[string][]string, error) {
if err := s.ensureDefaultLocalProvider(ctx, userID); err != nil {
return nil, nil, err
}
providers, err := s.repo.ListProvidersByUser(ctx, userID)
if err != nil {
return nil, nil, err
}
subscriptions := make(map[string][]string, len(providers))
for _, provider := range providers {
subs, err := s.repo.ListSubscriptionsByProvider(ctx, provider.ID)
if err != nil {
return nil, nil, err
}
for _, sub := range subs {
if sub.Enabled {
subscriptions[provider.ID] = append(subscriptions[provider.ID], sub.EventType)
}
}
}
return providers, subscriptions, nil
}
func (s *Service) CreateProvider(ctx context.Context, userID, name string, providerType models.ProviderType, config map[string]interface{}, enabled bool, events []string) (*models.Provider, error) {
if err := s.validateProvider(providerType, config); err != nil {
return nil, err
}
if err := s.validateEvents(events); err != nil {
return nil, err
}
provider := &models.Provider{
UserID: userID,
Name: name,
Type: providerType,
Config: config,
Enabled: enabled,
}
if err := s.repo.CreateProvider(ctx, provider); err != nil {
return nil, err
}
if err := s.repo.ReplaceSubscriptions(ctx, provider.ID, userID, events); err != nil {
return nil, err
}
return provider, nil
}
func (s *Service) UpdateProvider(ctx context.Context, providerID string, updates ProviderUpdate) (*models.Provider, error) {
provider, err := s.repo.GetProvider(ctx, providerID)
if err != nil {
return nil, ErrProviderNotFound
}
if updates.Name != nil {
provider.Name = strings.TrimSpace(*updates.Name)
}
if updates.Enabled != nil {
provider.Enabled = *updates.Enabled
}
if updates.Config != nil {
provider.Config = updates.Config
}
if updates.Type != nil {
provider.Type = *updates.Type
}
if err := s.validateProvider(provider.Type, provider.Config); err != nil {
return nil, err
}
if err := s.repo.UpdateProvider(ctx, provider); err != nil {
return nil, err
}
if updates.Events != nil {
if err := s.validateEvents(*updates.Events); err != nil {
return nil, err
}
if err := s.repo.ReplaceSubscriptions(ctx, provider.ID, provider.UserID, *updates.Events); err != nil {
return nil, err
}
}
return provider, nil
}
func (s *Service) DeleteProvider(ctx context.Context, providerID string) error {
return s.repo.DeleteProvider(ctx, providerID)
}
type ProviderUpdate struct {
Name *string
Enabled *bool
Type *models.ProviderType
Config map[string]interface{}
Events *[]string
}
func (s *Service) HandleTaskSessionStateChanged(ctx context.Context, taskID, sessionID, newState string) {
if newState != "WAITING_FOR_INPUT" {
return
}
userID := userstore.DefaultUserID
providers, subscriptions, err := s.ListProviders(ctx, userID)
if err != nil {
s.logger.Error("failed to load notification providers", zap.Error(err))
return
}
title, body := s.buildWaitingForInputMessage(ctx, taskID)
for _, provider := range providers {
if !provider.Enabled {
continue
}
events := subscriptions[provider.ID]
if !containsEvent(events, EventTaskSessionWaitingForInput) {
continue
}
delivery := &models.Delivery{
UserID: userID,
ProviderID: provider.ID,
EventType: EventTaskSessionWaitingForInput,
TaskSessionID: sessionID,
}
inserted, err := s.repo.InsertDelivery(ctx, delivery)
if err != nil {
s.logger.Error("failed to record notification delivery", zap.Error(err))
continue
}
if !inserted {
continue
}
if err := s.dispatchProvider(ctx, provider, waitingForInputPayload{
TaskID: taskID,
TaskSessionID: sessionID,
Title: title,
Body: body,
}); err != nil {
s.logger.Warn("notification delivery failed", zap.String("provider_id", provider.ID), zap.Error(err))
_ = s.repo.DeleteDelivery(ctx, provider.ID, EventTaskSessionWaitingForInput, sessionID)
}
}
}
type waitingForInputPayload struct {
TaskID string
TaskSessionID string
Title string
Body string
}
func (s *Service) dispatchProvider(ctx context.Context, provider *models.Provider, payload waitingForInputPayload) error {
adapter := s.providers[provider.Type]
if adapter == nil {
return fmt.Errorf("unknown provider type: %s", provider.Type)
}
return adapter.Send(ctx, providers.Message{
EventType: EventTaskSessionWaitingForInput,
Title: payload.Title,
Body: payload.Body,
TaskID: payload.TaskID,
TaskSessionID: payload.TaskSessionID,
UserID: userstore.DefaultUserID,
Config: provider.Config,
})
}
func (s *Service) buildWaitingForInputMessage(ctx context.Context, taskID string) (string, string) {
title := "Task needs your input"
body := "An agent is waiting for your input."
if taskID == "" || s.taskRepo == nil {
return title, body
}
task, err := s.taskRepo.GetTask(ctx, taskID)
if err != nil || task == nil {
return title, body
}
if task.Title != "" {
body = fmt.Sprintf("An agent is waiting for your input on \"%s\".", task.Title)
}
return title, body
}
func (s *Service) ensureDefaultLocalProvider(ctx context.Context, userID string) error {
providers, err := s.repo.ListProvidersByUser(ctx, userID)
if err != nil {
return err
}
for _, provider := range providers {
if provider.Type == models.ProviderTypeLocal {
return nil
}
}
provider := &models.Provider{
ID: uuid.New().String(),
UserID: userID,
Name: "Desktop Notifications",
Type: models.ProviderTypeLocal,
Config: map[string]interface{}{},
Enabled: true,
}
if err := s.repo.CreateProvider(ctx, provider); err != nil {
return err
}
return s.repo.ReplaceSubscriptions(ctx, provider.ID, userID, []string{EventTaskSessionWaitingForInput})
}
func (s *Service) validateProvider(providerType models.ProviderType, config map[string]interface{}) error {
adapter := s.providers[providerType]
if adapter == nil {
return fmt.Errorf("unsupported provider type: %s", providerType)
}
return adapter.Validate(config)
}
func (s *Service) validateEvents(events []string) error {
allowed := map[string]struct{}{
EventTaskSessionWaitingForInput: {},
}
for _, event := range events {
if _, ok := allowed[event]; !ok {
return fmt.Errorf("unsupported event type: %s", event)
}
}
return nil
}
func containsEvent(events []string, target string) bool {
for _, event := range events {
if event == target {
return true
}
}
return false
}
@@ -0,0 +1,23 @@
package store
import (
"context"
"github.com/kandev/kandev/internal/notifications/models"
)
type Repository interface {
CreateProvider(ctx context.Context, provider *models.Provider) error
UpdateProvider(ctx context.Context, provider *models.Provider) error
GetProvider(ctx context.Context, id string) (*models.Provider, error)
ListProvidersByUser(ctx context.Context, userID string) ([]*models.Provider, error)
DeleteProvider(ctx context.Context, id string) error
ListSubscriptionsByProvider(ctx context.Context, providerID string) ([]*models.Subscription, error)
ReplaceSubscriptions(ctx context.Context, providerID, userID string, events []string) error
InsertDelivery(ctx context.Context, delivery *models.Delivery) (bool, error)
DeleteDelivery(ctx context.Context, providerID, eventType, taskSessionID string) error
Close() error
}
@@ -0,0 +1,334 @@
package store
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"github.com/google/uuid"
_ "github.com/mattn/go-sqlite3"
"github.com/kandev/kandev/internal/notifications/models"
)
type SQLiteRepository struct {
db *sql.DB
}
var _ Repository = (*SQLiteRepository)(nil)
func NewSQLiteRepository(dbPath string) (*SQLiteRepository, error) {
normalizedPath := normalizeSQLitePath(dbPath)
if err := ensureSQLiteDir(normalizedPath); err != nil {
return nil, fmt.Errorf("failed to prepare database path: %w", err)
}
if err := ensureSQLiteFile(normalizedPath); err != nil {
return nil, fmt.Errorf("failed to create database file: %w", err)
}
dsn := fmt.Sprintf("file:%s?_foreign_keys=on&_mode=rwc", normalizedPath)
db, err := sql.Open("sqlite3", dsn)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
repo := &SQLiteRepository{db: db}
if err := repo.initSchema(); err != nil {
if closeErr := db.Close(); closeErr != nil {
return nil, fmt.Errorf("failed to close database after schema error: %w", closeErr)
}
return nil, fmt.Errorf("failed to initialize schema: %w", err)
}
return repo, nil
}
func (r *SQLiteRepository) Close() error {
return r.db.Close()
}
func ensureSQLiteDir(dbPath string) error {
dir := filepath.Dir(dbPath)
if dir == "." || dir == "" {
return nil
}
return os.MkdirAll(dir, 0o755)
}
func ensureSQLiteFile(dbPath string) error {
file, err := os.OpenFile(dbPath, os.O_RDWR|os.O_CREATE, 0o644)
if err != nil {
return err
}
return file.Close()
}
func normalizeSQLitePath(dbPath string) string {
if dbPath == "" {
return dbPath
}
abs, err := filepath.Abs(dbPath)
if err != nil {
return dbPath
}
return abs
}
func (r *SQLiteRepository) initSchema() error {
schema := `
CREATE TABLE IF NOT EXISTS notification_providers (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL,
config TEXT DEFAULT '{}',
enabled INTEGER NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS notification_subscriptions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
provider_id TEXT NOT NULL,
event_type TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE(provider_id, event_type),
FOREIGN KEY (provider_id) REFERENCES notification_providers(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS notification_deliveries (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
provider_id TEXT NOT NULL,
event_type TEXT NOT NULL,
task_session_id TEXT NOT NULL,
created_at DATETIME NOT NULL,
UNIQUE(provider_id, event_type, task_session_id)
);
CREATE INDEX IF NOT EXISTS idx_notification_providers_user_id ON notification_providers(user_id);
CREATE INDEX IF NOT EXISTS idx_notification_subscriptions_provider_id ON notification_subscriptions(provider_id);
CREATE INDEX IF NOT EXISTS idx_notification_subscriptions_user_id ON notification_subscriptions(user_id);
CREATE INDEX IF NOT EXISTS idx_notification_deliveries_session_id ON notification_deliveries(task_session_id);
`
_, err := r.db.Exec(schema)
return err
}
func (r *SQLiteRepository) CreateProvider(ctx context.Context, provider *models.Provider) error {
provider.ID = uuid.New().String()
now := time.Now().UTC()
provider.CreatedAt = now
provider.UpdatedAt = now
if provider.Config == nil {
provider.Config = map[string]interface{}{}
}
configJSON, err := json.Marshal(provider.Config)
if err != nil {
return fmt.Errorf("failed to serialize provider config: %w", err)
}
_, err = r.db.ExecContext(ctx, `
INSERT INTO notification_providers (id, user_id, name, type, config, enabled, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, provider.ID, provider.UserID, provider.Name, provider.Type, string(configJSON), boolToInt(provider.Enabled), provider.CreatedAt, provider.UpdatedAt)
return err
}
func (r *SQLiteRepository) UpdateProvider(ctx context.Context, provider *models.Provider) error {
provider.UpdatedAt = time.Now().UTC()
if provider.Config == nil {
provider.Config = map[string]interface{}{}
}
configJSON, err := json.Marshal(provider.Config)
if err != nil {
return fmt.Errorf("failed to serialize provider config: %w", err)
}
_, err = r.db.ExecContext(ctx, `
UPDATE notification_providers
SET name = ?, type = ?, config = ?, enabled = ?, updated_at = ?
WHERE id = ?
`, provider.Name, provider.Type, string(configJSON), boolToInt(provider.Enabled), provider.UpdatedAt, provider.ID)
return err
}
func (r *SQLiteRepository) GetProvider(ctx context.Context, id string) (*models.Provider, error) {
row := r.db.QueryRowContext(ctx, `
SELECT id, user_id, name, type, config, enabled, created_at, updated_at
FROM notification_providers
WHERE id = ?
`, id)
return scanProvider(row)
}
func (r *SQLiteRepository) ListProvidersByUser(ctx context.Context, userID string) ([]*models.Provider, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, user_id, name, type, config, enabled, created_at, updated_at
FROM notification_providers
WHERE user_id = ?
ORDER BY created_at ASC
`, userID)
if err != nil {
return nil, err
}
defer func() {
_ = rows.Close()
}()
var providers []*models.Provider
for rows.Next() {
provider, err := scanProvider(rows)
if err != nil {
return nil, err
}
providers = append(providers, provider)
}
if err := rows.Err(); err != nil {
return nil, err
}
return providers, nil
}
func (r *SQLiteRepository) DeleteProvider(ctx context.Context, id string) error {
_, err := r.db.ExecContext(ctx, `DELETE FROM notification_providers WHERE id = ?`, id)
return err
}
func (r *SQLiteRepository) ListSubscriptionsByProvider(ctx context.Context, providerID string) ([]*models.Subscription, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, user_id, provider_id, event_type, enabled, created_at, updated_at
FROM notification_subscriptions
WHERE provider_id = ?
ORDER BY created_at ASC
`, providerID)
if err != nil {
return nil, err
}
defer func() {
_ = rows.Close()
}()
var subs []*models.Subscription
for rows.Next() {
sub, err := scanSubscription(rows)
if err != nil {
return nil, err
}
subs = append(subs, sub)
}
if err := rows.Err(); err != nil {
return nil, err
}
return subs, nil
}
func (r *SQLiteRepository) ReplaceSubscriptions(ctx context.Context, providerID, userID string, events []string) error {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `DELETE FROM notification_subscriptions WHERE provider_id = ?`, providerID); err != nil {
_ = tx.Rollback()
return err
}
now := time.Now().UTC()
for _, eventType := range events {
subID := uuid.New().String()
if _, err := tx.ExecContext(ctx, `
INSERT INTO notification_subscriptions (id, user_id, provider_id, event_type, enabled, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, subID, userID, providerID, eventType, boolToInt(true), now, now); err != nil {
_ = tx.Rollback()
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
return nil
}
func (r *SQLiteRepository) InsertDelivery(ctx context.Context, delivery *models.Delivery) (bool, error) {
delivery.ID = uuid.New().String()
delivery.CreatedAt = time.Now().UTC()
result, err := r.db.ExecContext(ctx, `
INSERT OR IGNORE INTO notification_deliveries (id, user_id, provider_id, event_type, task_session_id, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`, delivery.ID, delivery.UserID, delivery.ProviderID, delivery.EventType, delivery.TaskSessionID, delivery.CreatedAt)
if err != nil {
return false, err
}
rows, err := result.RowsAffected()
if err != nil {
return false, err
}
return rows > 0, nil
}
func (r *SQLiteRepository) DeleteDelivery(ctx context.Context, providerID, eventType, taskSessionID string) error {
_, err := r.db.ExecContext(ctx, `
DELETE FROM notification_deliveries
WHERE provider_id = ? AND event_type = ? AND task_session_id = ?
`, providerID, eventType, taskSessionID)
return err
}
func scanProvider(scanner interface{ Scan(dest ...any) error }) (*models.Provider, error) {
provider := &models.Provider{}
var configJSON string
var enabledInt int
if err := scanner.Scan(
&provider.ID,
&provider.UserID,
&provider.Name,
&provider.Type,
&configJSON,
&enabledInt,
&provider.CreatedAt,
&provider.UpdatedAt,
); err != nil {
return nil, err
}
provider.Enabled = enabledInt == 1
if configJSON != "" && configJSON != "{}" {
if err := json.Unmarshal([]byte(configJSON), &provider.Config); err != nil {
return nil, fmt.Errorf("failed to deserialize provider config: %w", err)
}
} else {
provider.Config = map[string]interface{}{}
}
return provider, nil
}
func scanSubscription(scanner interface{ Scan(dest ...any) error }) (*models.Subscription, error) {
sub := &models.Subscription{}
var enabledInt int
if err := scanner.Scan(
&sub.ID,
&sub.UserID,
&sub.ProviderID,
&sub.EventType,
&enabledInt,
&sub.CreatedAt,
&sub.UpdatedAt,
); err != nil {
return nil, err
}
sub.Enabled = enabledInt == 1
return sub, nil
}
func boolToInt(value bool) int {
if value {
return 1
}
return 0
}
+1
View File
@@ -124,6 +124,7 @@ const (
ActionMessageAdded = "message.added"
ActionMessageUpdated = "message.updated"
ActionTaskSessionStateChanged = "task_session.state_changed"
ActionTaskSessionWaitingForInput = "task_session.waiting_for_input"
ActionInputRequested = "input.requested"
ActionRepositoryCreated = "repository.created"
ActionRepositoryUpdated = "repository.updated"
+13 -1
View File
@@ -16,7 +16,8 @@ export type BackendMessageType =
| 'board.deleted'
| 'column.created'
| 'column.updated'
| 'column.deleted';
| 'column.deleted'
| 'task_session.waiting_for_input';
export type BackendMessage<T extends BackendMessageType, P> = {
id?: string;
@@ -77,6 +78,13 @@ export type SystemErrorPayload = {
code?: string;
};
export type TaskSessionWaitingForInputPayload = {
task_id: string;
task_session_id: string;
title: string;
body: string;
};
export type WorkspacePayload = {
id: string;
name: string;
@@ -125,4 +133,8 @@ export type BackendMessageMap = {
'column.created': BackendMessage<'column.created', ColumnPayload>;
'column.updated': BackendMessage<'column.updated', ColumnPayload>;
'column.deleted': BackendMessage<'column.deleted', ColumnPayload>;
'task_session.waiting_for_input': BackendMessage<
'task_session.waiting_for_input',
TaskSessionWaitingForInputPayload
>;
};
@@ -0,0 +1,27 @@
import { NotificationsSettings } from '@/components/settings/notifications-settings';
import { listNotificationProviders } from '@/lib/http';
import type { NotificationProvider } from '@/lib/types/http';
export default async function GeneralNotificationsPage() {
let providers: NotificationProvider[] | undefined = [];
let events: string[] = [];
let appriseAvailable = true;
try {
const response = await listNotificationProviders({ cache: 'no-store' });
providers = response.providers ?? [];
events = response.events ?? [];
appriseAvailable = response.apprise_available ?? true;
} catch {
providers = undefined;
events = [];
appriseAvailable = true;
}
return (
<NotificationsSettings
initialProviders={providers}
initialEvents={events}
initialAppriseAvailable={appriseAvailable}
/>
);
}
+2 -105
View File
@@ -2,7 +2,7 @@
import { useState, useSyncExternalStore } from 'react';
import { useTheme } from 'next-themes';
import { IconPalette, IconCode, IconBell, IconServer } from '@tabler/icons-react';
import { IconPalette, IconCode, IconServer } from '@tabler/icons-react';
import { Card, CardContent, CardHeader, CardTitle } from '@kandev/ui/card';
import { Label } from '@kandev/ui/label';
import { Input } from '@kandev/ui/input';
@@ -11,7 +11,7 @@ import { Separator } from '@kandev/ui/separator';
import { SettingsSection } from '@/components/settings/settings-section';
import { SETTINGS_DATA } from '@/lib/settings/dummy-data';
import { getBackendConfig } from '@/lib/config';
import type { Theme, Editor, Notifications } from '@/lib/settings/types';
import type { Theme, Editor } from '@/lib/settings/types';
export default function GeneralSettingsPage() {
const { theme: currentTheme, setTheme } = useTheme();
@@ -19,9 +19,6 @@ export default function GeneralSettingsPage() {
const [customEditorCommand, setCustomEditorCommand] = useState<string>(
SETTINGS_DATA.general.customEditorCommand || ''
);
const [notifications, setNotifications] = useState<Notifications>(
SETTINGS_DATA.general.notifications
);
const [backendUrl] = useState<string>(() => getBackendConfig().apiBaseUrl);
const mounted = useSyncExternalStore(
() => () => undefined,
@@ -29,10 +26,6 @@ export default function GeneralSettingsPage() {
() => false
);
const handleNotificationToggle = (key: keyof Notifications) => {
setNotifications((prev) => ({ ...prev, [key]: !prev[key] }));
};
const displayBackendUrl = backendUrl.replace(/^https?:\/\//, '').replace(/\/$/, '');
if (!mounted) {
@@ -128,102 +121,6 @@ export default function GeneralSettingsPage() {
<Separator />
<SettingsSection
icon={<IconBell className="h-5 w-5" />}
title="Notifications"
description="Control what notifications you receive"
>
<Card>
<CardContent className="pt-6">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label htmlFor="task-updates" className="text-base font-medium">
Task Updates
</Label>
<p className="text-sm text-muted-foreground">
Get notified when tasks change status
</p>
</div>
<button
id="task-updates"
role="switch"
aria-checked={notifications.taskUpdates}
onClick={() => handleNotificationToggle('taskUpdates')}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
notifications.taskUpdates ? 'bg-primary' : 'bg-muted'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-background transition-transform ${
notifications.taskUpdates ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<Label htmlFor="agent-completion" className="text-base font-medium">
Agent Completion
</Label>
<p className="text-sm text-muted-foreground">
Get notified when agents complete tasks
</p>
</div>
<button
id="agent-completion"
role="switch"
aria-checked={notifications.agentCompletion}
onClick={() => handleNotificationToggle('agentCompletion')}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
notifications.agentCompletion ? 'bg-primary' : 'bg-muted'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-background transition-transform ${
notifications.agentCompletion ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<Label htmlFor="errors" className="text-base font-medium">
Errors
</Label>
<p className="text-sm text-muted-foreground">
Get notified about errors and failures
</p>
</div>
<button
id="errors"
role="switch"
aria-checked={notifications.errors}
onClick={() => handleNotificationToggle('errors')}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
notifications.errors ? 'bg-primary' : 'bg-muted'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-background transition-transform ${
notifications.errors ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
</div>
</CardContent>
</Card>
</SettingsSection>
<Separator />
<SettingsSection
icon={<IconServer className="h-5 w-5" />}
title="Backend Connection"
@@ -0,0 +1,695 @@
'use client';
import { useEffect, useMemo, useState, type FormEvent } from 'react';
import { IconBell, IconRefresh } from '@tabler/icons-react';
import { Button } from '@kandev/ui/button';
import { Checkbox } from '@kandev/ui/checkbox';
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@kandev/ui/hover-card';
import { Input } from '@kandev/ui/input';
import { Separator } from '@kandev/ui/separator';
import { Textarea } from '@kandev/ui/textarea';
import { SettingsPageTemplate } from '@/components/settings/settings-page-template';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import {
createNotificationProvider,
deleteNotificationProvider,
listNotificationProviders,
updateNotificationProvider,
} from '@/lib/http';
import { useRequest } from '@/lib/http/use-request';
import { DEFAULT_NOTIFICATION_EVENTS, EVENT_LABELS } from '@/lib/notifications/events';
import type { NotificationProvider } from '@/lib/types/http';
type ProviderUpdatePayload = {
enabled?: boolean;
events?: string[];
config?: NotificationProvider['config'];
name?: string;
};
function formatAppriseUrls(value: unknown): string {
if (Array.isArray(value)) {
return value.filter((item): item is string => typeof item === 'string').join('\n');
}
if (typeof value === 'string') {
return value;
}
return '';
}
function parseAppriseUrls(value: string): string[] {
return value
.split('\n')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
}
function extractUrls(config: NotificationProvider['config']): string[] {
if (!config) {
return [];
}
const urls = config.urls;
if (Array.isArray(urls)) {
return urls.filter((item): item is string => typeof item === 'string');
}
if (typeof urls === 'string') {
return parseAppriseUrls(urls);
}
return [];
}
function normalizeEvents(events?: string[]): string {
if (!Array.isArray(events)) {
return '';
}
return [...events].sort().join('|');
}
type NotificationsSettingsProps = {
initialProviders?: NotificationProvider[];
initialEvents?: string[];
initialAppriseAvailable?: boolean;
};
type AppriseFormMode = 'create' | 'edit';
function buildAppriseEdits(providers: NotificationProvider[]) {
const urls: Record<string, string> = {};
const names: Record<string, string> = {};
for (const provider of providers) {
if (provider.type !== 'apprise') {
continue;
}
urls[provider.id] = formatAppriseUrls(provider.config?.urls);
names[provider.id] = provider.name;
}
return { urls, names };
}
export function NotificationsSettings({
initialProviders,
initialEvents,
initialAppriseAvailable,
}: NotificationsSettingsProps) {
const [providers, setProviders] = useState<NotificationProvider[]>(() => initialProviders ?? []);
const [baselineProviders, setBaselineProviders] = useState<NotificationProvider[]>(
() => initialProviders ?? []
);
const [notificationEvents, setNotificationEvents] = useState<string[]>(
() => initialEvents ?? []
);
const [appriseAvailable, setAppriseAvailable] = useState(
() => initialAppriseAvailable ?? true
);
const [isProvidersLoading, setIsProvidersLoading] = useState(false);
const [appriseName, setAppriseName] = useState('');
const [appriseUrls, setAppriseUrls] = useState('');
const [appriseEdits, setAppriseEdits] = useState<Record<string, string>>(() => {
return buildAppriseEdits(initialProviders ?? []).urls;
});
const [appriseNameEdits, setAppriseNameEdits] = useState<Record<string, string>>(() => {
return buildAppriseEdits(initialProviders ?? []).names;
});
const [showAppriseForm, setShowAppriseForm] = useState(false);
const [appriseFormMode, setAppriseFormMode] = useState<AppriseFormMode>('create');
const [activeAppriseId, setActiveAppriseId] = useState<string | null>(null);
const [pendingDeletes, setPendingDeletes] = useState<Set<string>>(new Set());
const [notificationPermission, setNotificationPermission] = useState<
NotificationPermission | 'unsupported'
>('default');
const saveRequest = useRequest(async () => {
const updates: Array<Promise<NotificationProvider>> = [];
for (const provider of providers) {
const baseline = baselineProviders.find((item) => item.id === provider.id);
if (!baseline) {
continue;
}
const payload = buildProviderUpdate(provider, baseline);
if (!payload) {
continue;
}
updates.push(updateNotificationProvider(provider.id, payload));
}
const deletions = Array.from(pendingDeletes);
for (const providerId of deletions) {
await deleteNotificationProvider(providerId);
}
const updated = await Promise.all(updates);
if (updated.length === 0) {
setBaselineProviders(providers);
setPendingDeletes(new Set());
return [] as NotificationProvider[];
}
const updatedById = new Map(updated.map((provider) => [provider.id, provider]));
const nextProviders = providers.map((provider) => updatedById.get(provider.id) ?? provider);
setProviders(nextProviders);
setBaselineProviders(nextProviders);
setAppriseEdits((prev) => {
const next = { ...prev };
for (const provider of nextProviders) {
if (provider.type === 'apprise') {
next[provider.id] = formatAppriseUrls(provider.config?.urls);
}
}
return next;
});
setAppriseNameEdits((prev) => {
const next = { ...prev };
for (const provider of nextProviders) {
if (provider.type === 'apprise') {
next[provider.id] = provider.name;
}
}
return next;
});
setPendingDeletes(new Set());
return updated;
});
useEffect(() => {
if (typeof Notification === 'undefined') {
setNotificationPermission('unsupported');
} else {
setNotificationPermission(Notification.permission);
}
}, []);
useEffect(() => {
if (initialProviders) {
return;
}
const fetchProviders = async () => {
setIsProvidersLoading(true);
try {
const response = await listNotificationProviders();
const loaded = response.providers ?? [];
setProviders(loaded);
setBaselineProviders(loaded);
setNotificationEvents(response.events ?? []);
setAppriseAvailable(response.apprise_available ?? true);
const edits = buildAppriseEdits(loaded);
setAppriseEdits(edits.urls);
setAppriseNameEdits(edits.names);
} catch (error) {
console.error('[NotificationsSettings] Failed to load notification providers', error);
} finally {
setIsProvidersLoading(false);
}
};
fetchProviders();
}, [initialProviders]);
const appriseProviders = providers.filter((provider) => provider.type === 'apprise');
const tableProviders = useMemo(() => {
return [...providers].sort((left, right) => {
if (left.type === right.type) {
return left.name.localeCompare(right.name);
}
if (left.type === 'local') {
return -1;
}
if (right.type === 'local') {
return 1;
}
return left.type.localeCompare(right.type);
});
}, [providers]);
const tableEvents = useMemo(() => {
if (notificationEvents.length > 0) {
return notificationEvents;
}
const eventSet = new Set<string>();
for (const provider of providers) {
for (const event of provider.events ?? []) {
eventSet.add(event);
}
}
return eventSet.size ? Array.from(eventSet) : DEFAULT_NOTIFICATION_EVENTS;
}, [notificationEvents, providers]);
const isDirty =
pendingDeletes.size > 0 ||
providers.some((provider) => {
const baseline = baselineProviders.find((item) => item.id === provider.id);
if (!baseline) {
return false;
}
if (buildProviderUpdate(provider, baseline)) {
return true;
}
if (provider.type === 'apprise') {
const currentValue = appriseEdits[provider.id] ?? formatAppriseUrls(provider.config?.urls);
const baselineValue = formatAppriseUrls(baseline.config?.urls);
const nameValue = appriseNameEdits[provider.id] ?? provider.name;
return currentValue !== baselineValue || nameValue !== baseline.name;
}
return false;
});
const handleRequestPermission = async () => {
if (typeof Notification === 'undefined') {
setNotificationPermission('unsupported');
return;
}
const result = await Notification.requestPermission();
setNotificationPermission(result);
};
const handleRefreshPermission = () => {
if (typeof Notification === 'undefined') {
setNotificationPermission('unsupported');
return;
}
setNotificationPermission(Notification.permission);
};
const handleTestNotification = async () => {
if (typeof Notification === 'undefined') {
return;
}
let permission = Notification.permission;
if (permission === 'default') {
permission = await Notification.requestPermission();
setNotificationPermission(permission);
}
if (permission !== 'granted') {
return;
}
new Notification('Test notification', {
body: 'If you can read this, browser notifications are working.',
});
};
const updateProviderState = (providerId: string, updater: (provider: NotificationProvider) => NotificationProvider) => {
setProviders((prev) => prev.map((provider) => (provider.id === providerId ? updater(provider) : provider)));
};
const handleToggleEvent = (provider: NotificationProvider, eventType: string) => {
const currentEvents = provider.events ?? [];
const hasEvent = currentEvents.includes(eventType);
const nextEvents = hasEvent
? currentEvents.filter((event) => event !== eventType)
: [...currentEvents, eventType];
updateProviderState(provider.id, (current) => ({ ...current, events: nextEvents }));
};
const handleCreateAppriseProvider = async () => {
const urls = parseAppriseUrls(appriseUrls);
if (urls.length === 0) {
return;
}
const defaultEvents =
notificationEvents.length > 0 ? notificationEvents : DEFAULT_NOTIFICATION_EVENTS;
try {
const created = await createNotificationProvider({
name: appriseName.trim() || 'Apprise',
type: 'apprise',
config: { urls },
enabled: true,
events: defaultEvents,
});
setProviders((prev) => [...prev, created]);
setBaselineProviders((prev) => [...prev, created]);
setAppriseEdits((prev) => ({ ...prev, [created.id]: urls.join('\n') }));
setAppriseNameEdits((prev) => ({ ...prev, [created.id]: created.name }));
setAppriseUrls('');
setShowAppriseForm(false);
} catch (error) {
console.error('[NotificationsSettings] Failed to create apprise provider', error);
}
};
const handleDeleteProvider = (providerId: string) => {
setPendingDeletes((prev) => new Set(prev).add(providerId));
setProviders((prev) => prev.filter((provider) => provider.id !== providerId));
};
const handleSave = async () => {
try {
await saveRequest.run();
} catch (error) {
console.error('[NotificationsSettings] Failed to save notifications', error);
}
};
const handleAppriseEdit = (providerId: string, value: string) => {
setAppriseEdits((prev) => ({ ...prev, [providerId]: value }));
const urls = parseAppriseUrls(value);
updateProviderState(providerId, (provider) => ({
...provider,
config: { ...provider.config, urls },
}));
};
const handleAppriseNameEdit = (providerId: string, value: string) => {
setAppriseNameEdits((prev) => ({ ...prev, [providerId]: value }));
updateProviderState(providerId, (provider) => ({
...provider,
name: value,
}));
};
const handleTextareaInput = (event: FormEvent<HTMLTextAreaElement>) => {
const target = event.currentTarget;
target.style.height = 'auto';
target.style.height = `${target.scrollHeight}px`;
};
const openAppriseForm = (mode: AppriseFormMode, provider?: NotificationProvider) => {
setAppriseFormMode(mode);
setActiveAppriseId(provider?.id ?? null);
if (provider) {
setAppriseName(appriseNameEdits[provider.id] ?? provider.name);
setAppriseUrls(appriseEdits[provider.id] ?? formatAppriseUrls(provider.config?.urls));
} else {
setAppriseName('');
setAppriseUrls('');
}
setShowAppriseForm(true);
};
const closeAppriseForm = () => {
setShowAppriseForm(false);
setActiveAppriseId(null);
};
return (
<SettingsPageTemplate
title="Notifications"
description="Configure providers and choose which events should alert you."
isDirty={isDirty}
saveStatus={saveRequest.status}
onSave={handleSave}
>
<div className="space-y-4">
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-base font-medium">Desktop Notifications</div>
<p className="text-sm text-muted-foreground">
Notify this device when an agent needs your input.
</p>
</div>
<div className="flex items-center gap-2">
<Button
title='Enable desktop notifications'
variant="default"
size="sm"
onClick={handleRequestPermission}
disabled={
notificationPermission === 'granted' ||
notificationPermission === 'unsupported'
}
className={
notificationPermission === 'granted'
? 'bg-emerald-500 text-white hover:bg-emerald-500'
: undefined
}
>
{notificationPermission === 'granted' ? 'Enabled' : 'Enable'}
</Button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={handleRefreshPermission}>
<IconRefresh className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Refresh permission status</TooltipContent>
</Tooltip>
</TooltipProvider>
<HoverCard>
<HoverCardTrigger asChild>
<Button
title="Send test notification"
variant="outline"
className='cursor-pointer'
size="icon"
onClick={() => {
void handleTestNotification();
}}
>
<IconBell className="h-4 w-4" />
</Button>
</HoverCardTrigger>
<HoverCardContent side="top" className="text-sm">
If you do not see notifications, check your OS settings and allow this browser.
</HoverCardContent>
</HoverCard>
</div>
</div>
{notificationPermission === 'denied' && (
<p className="text-sm text-amber-600">
Notifications are blocked in your browser. Enable them in site settings, then click
Refresh.
</p>
)}
{notificationPermission === 'unsupported' && (
<p className="text-sm text-amber-600">
This browser does not support desktop notifications.
</p>
)}
</div>
<Separator className='my-4' />
<div className="space-y-4">
<div>
<div className="text-base font-medium">External Providers</div>
<p className="text-sm text-muted-foreground">
Configure external providers for remote notifications.
</p>
</div>
{!appriseAvailable && (
<p className="text-sm text-muted-foreground">
Apprise is not installed yet. You can add it later to enable remote notifications.{' '}
<a
className="underline"
href="https://github.com/caronc/apprise?tab=readme-ov-file#installation"
target="_blank"
rel="noreferrer"
>
View installation instructions
</a>
.
</p>
)}
{appriseProviders.length === 0 && (
<p className="text-sm text-muted-foreground">No Apprise providers configured yet.</p>
)}
{appriseProviders.map((provider) => {
const isEditing = appriseFormMode === 'edit' && activeAppriseId === provider.id;
return (
<div key={provider.id} className="rounded-lg border border-muted p-4 space-y-3">
{isEditing ? (
<AppriseProviderForm
mode="edit"
name={appriseName}
urls={appriseUrls}
onNameChange={(value) => {
setAppriseName(value);
handleAppriseNameEdit(provider.id, value);
}}
onUrlsChange={(value) => {
setAppriseUrls(value);
handleAppriseEdit(provider.id, value);
}}
onSubmit={async () => {
closeAppriseForm();
}}
onCancel={closeAppriseForm}
onInput={handleTextareaInput}
/>
) : (
<div className="flex items-center justify-between gap-4">
<div className="space-y-1 flex-1">
<div className="font-medium">{provider.name}</div>
<div className="text-xs text-muted-foreground">Apprise</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="cursor-pointer"
onClick={() => openAppriseForm('edit', provider)}
>
Edit
</Button>
<Button
variant="outline"
size="sm"
className="cursor-pointer"
onClick={() => handleDeleteProvider(provider.id)}
>
Remove
</Button>
</div>
</div>
)}
</div>
);
})}
{appriseAvailable && (
<div className="space-y-3">
<Button
variant="outline"
className="cursor-pointer"
onClick={() => openAppriseForm('create')}
>
Add Apprise Provider
</Button>
{showAppriseForm && appriseFormMode === 'create' && (
<AppriseProviderForm
mode="create"
name={appriseName}
urls={appriseUrls}
onNameChange={(value) => setAppriseName(value)}
onUrlsChange={(value) => setAppriseUrls(value)}
onSubmit={async () => {
await handleCreateAppriseProvider();
closeAppriseForm();
}}
onCancel={closeAppriseForm}
onInput={handleTextareaInput}
/>
)}
</div>
)}
</div>
<Separator className='my-4' />
<div className="space-y-4">
<div>
<div className="text-base font-medium">Notification Events</div>
<p className="text-sm text-muted-foreground">
Select which providers should receive each notification type.
</p>
</div>
{isProvidersLoading && (
<p className="text-sm text-muted-foreground">Loading providers...</p>
)}
{!isProvidersLoading && tableProviders.length === 0 && (
<p className="text-sm text-muted-foreground">No providers configured yet.</p>
)}
{tableProviders.length > 0 && (
<div className="overflow-auto rounded-lg border border-muted">
<table className="min-w-full text-sm">
<thead className="bg-muted/40">
<tr>
<th className="px-4 py-3 text-left font-medium">Notification type</th>
{tableProviders.map((provider) => (
<th key={provider.id} className="px-4 py-3 text-center font-medium">
{provider.name}
</th>
))}
</tr>
</thead>
<tbody>
{tableEvents.map((eventType) => {
const meta = EVENT_LABELS[eventType] ?? {
title: eventType,
description: 'Notify when this event occurs.',
};
return (
<tr key={eventType} className="border-t border-muted">
<td className="px-4 py-3">
<div className="font-medium">{meta.title}</div>
<div className="text-xs text-muted-foreground">{meta.description}</div>
</td>
{tableProviders.map((provider) => (
<td key={provider.id} className="px-4 py-3 text-center">
<div className="flex justify-center">
<Checkbox
checked={(provider.events ?? []).includes(eventType)}
onCheckedChange={() => handleToggleEvent(provider, eventType)}
/>
</div>
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</SettingsPageTemplate>
);
}
type AppriseProviderFormProps = {
mode: AppriseFormMode;
name: string;
urls: string;
onNameChange: (value: string) => void;
onUrlsChange: (value: string) => void;
onSubmit: () => void | Promise<void>;
onCancel: () => void;
onInput: (event: FormEvent<HTMLTextAreaElement>) => void;
};
function AppriseProviderForm({
mode,
name,
urls,
onNameChange,
onUrlsChange,
onSubmit,
onCancel,
onInput,
}: AppriseProviderFormProps) {
return (
<div className="rounded-lg border border-dashed border-muted p-4 space-y-3">
<div className="text-base font-medium">Apprise Provider</div>
<Input
value={name}
onChange={(event) => onNameChange(event.target.value)}
placeholder="Provider name"
/>
<Textarea
value={urls}
onChange={(event) => onUrlsChange(event.target.value)}
onInput={onInput}
placeholder="Service URL(s)"
rows={1}
className="min-h-0 h-auto"
/>
<div className="flex items-center gap-2">
<Button className="cursor-pointer" onClick={onSubmit}>
{mode === 'create' ? 'Add provider' : 'Done'}
</Button>
<Button variant="ghost" className="cursor-pointer" onClick={onCancel}>
Cancel
</Button>
</div>
</div>
);
}
function buildProviderUpdate(
provider: NotificationProvider,
baseline: NotificationProvider
): ProviderUpdatePayload | null {
const updates: ProviderUpdatePayload = {};
if (provider.enabled !== baseline.enabled) {
updates.enabled = provider.enabled;
}
if (normalizeEvents(provider.events) !== normalizeEvents(baseline.events)) {
updates.events = provider.events;
}
if (provider.name !== baseline.name) {
updates.name = provider.name;
}
if (provider.type === 'apprise') {
const urls = extractUrls(provider.config);
const baselineUrls = extractUrls(baseline.config);
if (urls.join('|') !== baselineUrls.join('|')) {
updates.config = { ...provider.config, urls };
}
}
return Object.keys(updates).length ? updates : null;
}
@@ -7,6 +7,7 @@ import {
IconFolder,
IconServer,
IconRobot,
IconBell,
IconChevronRight,
IconCpu,
IconPlug,
@@ -62,14 +63,38 @@ export function SettingsAppSidebar() {
<SidebarGroupContent>
<SidebarMenu>
{/* General */}
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={pathname === '/settings/general'}>
<Link href="/settings/general">
<IconSettings className="h-4 w-4" />
<span>General</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
<Collapsible defaultOpen className="group/collapsible">
<SidebarMenuItem>
<SidebarMenuButton asChild tooltip="General">
<Link href="/settings/general">
<IconSettings className="h-4 w-4" />
<span>General</span>
</Link>
</SidebarMenuButton>
<CollapsibleTrigger asChild>
<SidebarMenuAction className="data-[state=open]:rotate-90">
<IconChevronRight className="h-4 w-4" />
<span className="sr-only">Toggle</span>
</SidebarMenuAction>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
<SidebarMenuSubItem>
<SidebarMenuSubButton
asChild
size="sm"
isActive={pathname === '/settings/general/notifications'}
>
<Link href="/settings/general/notifications">
<IconBell className="h-4 w-4" />
<span>Notifications</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
{/* Workspaces */}
<Collapsible defaultOpen className="group/collapsible">
@@ -0,0 +1,55 @@
'use client';
import { Card, CardContent } from '@kandev/ui/card';
import { Separator } from '@kandev/ui/separator';
import { UnsavedChangesBadge, UnsavedSaveButton } from '@/components/settings/unsaved-indicator';
type SettingsPageTemplateProps = {
title: string;
description?: string;
isDirty: boolean;
saveStatus: 'idle' | 'loading' | 'success' | 'error';
onSave: () => void;
children: React.ReactNode;
deleteSection?: React.ReactNode;
};
export function SettingsPageTemplate({
title,
description,
isDirty,
saveStatus,
onSave,
children,
deleteSection,
}: SettingsPageTemplateProps) {
return (
<div className="space-y-8">
<div className="flex items-start justify-between">
<div>
<h2 className="text-2xl font-bold">{title}</h2>
{description && (
<p className="text-sm text-muted-foreground mt-1">{description}</p>
)}
</div>
<div className="flex items-center gap-3">
{isDirty && <UnsavedChangesBadge />}
<UnsavedSaveButton
isDirty={isDirty}
isLoading={saveStatus === 'loading'}
status={saveStatus}
onClick={onSave}
/>
</div>
</div>
<Separator />
<Card className={isDirty ? 'border border-yellow-500/60' : 'border'}>
<CardContent className="">{children}</CardContent>
</Card>
{deleteSection}
</div>
);
}
@@ -21,17 +21,19 @@ export function UnsavedSaveButton({ isDirty, isLoading, status, onClick }: Unsav
return (
<Button
type="button"
size="sm"
size="lg"
variant={isDirty ? 'secondary' : 'outline'}
onClick={onClick}
disabled={isLoading}
className={isDirty ? warningButtonClass : undefined}
className={isDirty ? warningButtonClass : 'cursor-pointer'}
>
{isDirty && <IconAlertCircle className="h-4 w-4 mr-2" />}
Save
<span className="ml-2">
<RequestIndicator status={status} />
</span>
{status !== 'idle' && (
<span className="ml-2">
<RequestIndicator status={status} />
</span>
)}
</Button>
);
}
+46
View File
@@ -10,6 +10,8 @@ import type {
ListWorkspacesResponse,
ListMessagesResponse,
RepositoryBranchesResponse,
NotificationProvidersResponse,
NotificationProvider,
Task,
TaskSessionsResponse,
UserSettingsResponse,
@@ -163,6 +165,50 @@ export async function updateUserSettings(
});
}
export async function listNotificationProviders(options?: ApiRequestOptions) {
return fetchJson<NotificationProvidersResponse>('/api/v1/notification-providers', options);
}
export async function createNotificationProvider(
payload: {
name: string;
type: NotificationProvider['type'];
config?: NotificationProvider['config'];
enabled?: boolean;
events?: string[];
},
options?: ApiRequestOptions
) {
return fetchJson<NotificationProvider>('/api/v1/notification-providers', {
...options,
init: { method: 'POST', body: JSON.stringify(payload), ...(options?.init ?? {}) },
});
}
export async function updateNotificationProvider(
providerId: string,
payload: Partial<{
name: string;
type: NotificationProvider['type'];
config: NotificationProvider['config'];
enabled: boolean;
events: string[];
}>,
options?: ApiRequestOptions
) {
return fetchJson<NotificationProvider>(`/api/v1/notification-providers/${providerId}`, {
...options,
init: { method: 'PATCH', body: JSON.stringify(payload), ...(options?.init ?? {}) },
});
}
export async function deleteNotificationProvider(providerId: string, options?: ApiRequestOptions) {
return fetchJson<void>(`/api/v1/notification-providers/${providerId}`, {
...options,
init: { method: 'DELETE', ...(options?.init ?? {}) },
});
}
export async function fetchTask(taskId: string, options?: ApiRequestOptions) {
return fetchJson<Task>(`/api/v1/tasks/${taskId}`, options);
}
+11
View File
@@ -0,0 +1,11 @@
export const NOTIFICATION_EVENT_TASK_SESSION_WAITING_FOR_INPUT =
'task_session.waiting_for_input';
export const DEFAULT_NOTIFICATION_EVENTS = [NOTIFICATION_EVENT_TASK_SESSION_WAITING_FOR_INPUT];
export const EVENT_LABELS: Record<string, { title: string; description: string }> = {
[NOTIFICATION_EVENT_TASK_SESSION_WAITING_FOR_INPUT]: {
title: 'A session has finished',
description: 'Notify when an agent is waiting for your reply.',
},
};
+9
View File
@@ -21,6 +21,7 @@ export type BackendMessageType =
| 'message.added'
| 'message.updated'
| 'task_session.state_changed'
| 'task_session.waiting_for_input'
| 'executor.created'
| 'executor.updated'
| 'executor.deleted'
@@ -148,6 +149,13 @@ export type TaskSessionStateChangedPayload = {
new_state: string;
};
export type TaskSessionWaitingForInputPayload = {
task_id: string;
task_session_id: string;
title: string;
body: string;
};
export type FileInfo = {
path: string;
status: 'modified' | 'added' | 'deleted' | 'untracked' | 'renamed';
@@ -255,6 +263,7 @@ export type BackendMessageMap = {
'message.added': BackendMessage<'message.added', MessageAddedPayload>;
'message.updated': BackendMessage<'message.updated', MessageAddedPayload>;
'task_session.state_changed': BackendMessage<'task_session.state_changed', TaskSessionStateChangedPayload>;
'task_session.waiting_for_input': BackendMessage<'task_session.waiting_for_input', TaskSessionWaitingForInputPayload>;
'executor.created': BackendMessage<'executor.created', ExecutorPayload>;
'executor.updated': BackendMessage<'executor.updated', ExecutorPayload>;
'executor.deleted': BackendMessage<'executor.deleted', ExecutorPayload>;
+19
View File
@@ -140,6 +140,25 @@ export type TaskSessionsResponse = {
total: number;
};
export type NotificationProviderType = 'local' | 'apprise';
export type NotificationProvider = {
id: string;
name: string;
type: NotificationProviderType;
config: Record<string, unknown>;
enabled: boolean;
events: string[];
created_at: string;
updated_at: string;
};
export type NotificationProvidersResponse = {
providers: NotificationProvider[];
apprise_available: boolean;
events: string[];
};
export type User = {
id: string;
email: string;
+21
View File
@@ -0,0 +1,21 @@
import type { StoreApi } from 'zustand';
import { NOTIFICATION_EVENT_TASK_SESSION_WAITING_FOR_INPUT } from '@/lib/notifications/events';
import type { AppState } from '@/lib/state/store';
import type { WsHandlers } from '@/lib/ws/handlers/types';
export function registerNotificationsHandlers(_store: StoreApi<AppState>): WsHandlers {
void _store;
return {
[NOTIFICATION_EVENT_TASK_SESSION_WAITING_FOR_INPUT]: (message) => {
if (typeof Notification === 'undefined') {
return;
}
if (Notification.permission !== 'granted') {
return;
}
const title = message.payload.title || 'Task needs your input';
const body = message.payload.body || 'An agent is waiting for your input.';
new Notification(title, { body });
},
};
}
+2
View File
@@ -5,6 +5,7 @@ import { registerTaskSessionHandlers } from '@/lib/ws/handlers/agent-session';
import { registerBoardsHandlers } from '@/lib/ws/handlers/boards';
import { registerColumnsHandlers } from '@/lib/ws/handlers/columns';
import { registerMessagesHandlers } from '@/lib/ws/handlers/messages';
import { registerNotificationsHandlers } from '@/lib/ws/handlers/notifications';
import { registerDiffsHandlers } from '@/lib/ws/handlers/diffs';
import { registerEnvironmentsHandlers } from '@/lib/ws/handlers/environments';
import { registerExecutorsHandlers } from '@/lib/ws/handlers/executors';
@@ -31,6 +32,7 @@ export function registerWsHandlers(store: StoreApi<AppState>) {
...registerTerminalsHandlers(store),
...registerDiffsHandlers(store),
...registerMessagesHandlers(store),
...registerNotificationsHandlers(store),
...registerGitStatusHandlers(store),
...registerSystemEventsHandlers(store),
};