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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions cmd/task/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,12 @@ func completeSettingKeys(cmd *cobra.Command, args []string, toComplete string) (
"anthropic_api_key\tAPI key for ghost text autocomplete",
"autocomplete_enabled\tEnable/disable ghost text (true/false)",
"idle_suspend_timeout\tIdle timeout before suspending (e.g. 6h)",
"notify_enabled\tPush to your phone on task events (true/false)",
"notify_provider\tDelivery method: ntfy or webhook",
"notify_target\tntfy topic URL or webhook URL",
"notify_events\tEvents to notify on (comma list)",
"notify_url\tBase URL for notification deep links",
"notify_reply\tCanned reply for the one-tap unblock action",
"http_api_port\tPort for the daemon-hosted HTTP API (default 8080)",
"http_api_disabled\tDisable the daemon-hosted HTTP API (true/false)",
}, cobra.ShellCompDirectiveNoFileComp
Expand Down
4 changes: 2 additions & 2 deletions cmd/task/completion_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,8 @@ func TestCompleteSettingKeys(t *testing.T) {
if directive != cobra.ShellCompDirectiveNoFileComp {
t.Errorf("expected NoFileComp directive")
}
if len(completions) != 5 {
t.Errorf("expected 5 setting keys, got %d", len(completions))
if len(completions) != 10 {
t.Errorf("expected 10 setting keys, got %d", len(completions))
}

// After first arg, no more completions
Expand Down
92 changes: 91 additions & 1 deletion cmd/task/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import (
"github.com/bborn/workflow/internal/github"
"github.com/bborn/workflow/internal/hooks"
"github.com/bborn/workflow/internal/mcp"
"github.com/bborn/workflow/internal/notify"
"github.com/bborn/workflow/internal/ui"
"github.com/bborn/workflow/internal/web"
)
Expand Down Expand Up @@ -84,6 +85,7 @@ func openTaskDB(path string) (*db.DB, error) {
if taskEmitter == nil {
taskEmitter = events.New(hooks.DefaultHooksDir())
}
taskEmitter.SetNotifier(notify.New(database))
database.SetEventEmitter(taskEmitter)
return database, nil
}
Expand Down Expand Up @@ -2339,6 +2341,28 @@ servers programmatically.`,
}
fmt.Printf("idle_suspend_timeout: %s\n", idleTimeout)

// Push notifications
notifyEnabled, _ := database.GetSetting(notify.SettingEnabled)
if notifyEnabled == "" {
notifyEnabled = "false"
}
fmt.Printf("notify_enabled: %s\n", notifyEnabled)
notifyProvider, _ := database.GetSetting(notify.SettingProvider)
if notifyProvider == "" {
notifyProvider = "ntfy (default)"
}
fmt.Printf("notify_provider: %s\n", notifyProvider)
notifyTarget, _ := database.GetSetting(notify.SettingTarget)
if notifyTarget == "" {
notifyTarget = dimStyle.Render("(not set)")
}
fmt.Printf("notify_target: %s\n", notifyTarget)
notifyEvents, _ := database.GetSetting(notify.SettingEvents)
if notifyEvents == "" {
notifyEvents = notify.DefaultEvents + " (default)"
}
fmt.Printf("notify_events: %s\n", notifyEvents)

fmt.Println()
fmt.Println(dimStyle.Render("Use 'task settings set <key> <value>' to change settings"))
},
Expand All @@ -2355,6 +2379,12 @@ Available settings:
directly for speed). Get yours at console.anthropic.com
autocomplete_enabled Enable/disable ghost text autocomplete (true/false)
idle_suspend_timeout How long blocked tasks wait before suspending (e.g. 6h, 30m, 24h)
notify_enabled Push to your phone when a task blocks/finishes (true/false)
notify_provider Delivery method: ntfy (default) or webhook
notify_target ntfy topic URL (e.g. https://ntfy.sh/my-ty) or webhook URL
notify_events Comma list: blocked,auth_required,completed,failed (default)
notify_url Base URL for notification deep links (e.g. http://my-host:8080)
notify_reply Canned reply the one-tap unblock action sends (default: continue)
http_api_port Port the daemon-hosted HTTP API listens on (default 8080)
http_api_disabled Stop the daemon from hosting the HTTP API (true/false)`,
Args: cobra.ExactArgs(2),
Expand All @@ -2379,6 +2409,18 @@ Available settings:
fmt.Println(errorStyle.Render("Invalid duration format. Examples: 6h, 30m, 24h, 1h30m"))
return
}
case notify.SettingEnabled:
if value != "true" && value != "false" {
fmt.Println(errorStyle.Render("Value must be 'true' or 'false'"))
return
}
case notify.SettingProvider:
if value != notify.ProviderNtfy && value != notify.ProviderWebhook {
fmt.Println(errorStyle.Render("Value must be 'ntfy' or 'webhook'"))
return
}
case notify.SettingTarget, notify.SettingEvents, notify.SettingURL, notify.SettingReply:
// Free-form strings; no validation.
case config.SettingHTTPAPIPort:
if p, err := strconv.Atoi(value); err != nil || p < 1 || p > 65535 {
fmt.Println(errorStyle.Render("Value must be a port number between 1 and 65535"))
Expand All @@ -2391,7 +2433,7 @@ Available settings:
}
default:
fmt.Println(errorStyle.Render("Unknown setting: " + key))
fmt.Println(dimStyle.Render("Available: anthropic_api_key, autocomplete_enabled, idle_suspend_timeout, http_api_port, http_api_disabled"))
fmt.Println(dimStyle.Render("Available: anthropic_api_key, autocomplete_enabled, idle_suspend_timeout, notify_enabled, notify_provider, notify_target, notify_events, notify_url, notify_reply, http_api_port, http_api_disabled"))
return
}

Expand All @@ -2414,6 +2456,54 @@ Available settings:
settingsCmd.AddCommand(settingsSetCmd)
rootCmd.AddCommand(settingsCmd)

// Notify command - configure & test push notifications
notifyCmd := &cobra.Command{
Use: "notify",
Short: "Push notifications for task events (so you can walk away)",
Long: `Send a push to your phone when a task blocks, needs sign-in, finishes, or fails.

Quick start with ntfy (free iOS/Android app, no account):
1. Install the ntfy app and subscribe to a hard-to-guess topic, e.g. ty-7f3k9.
2. Configure ty:
ty settings set notify_target https://ntfy.sh/ty-7f3k9
ty settings set notify_enabled true
ty settings set notify_url http://<your-tailscale-host>:8080
3. Verify: ty notify test

Notifications deep-link into the mobile console (ty serve, then open /m) so a tap
opens the task and you can reply, approve, or retry right from your phone.`,
Run: func(cmd *cobra.Command, args []string) {
_ = cmd.Help()
},
}

notifyTestCmd := &cobra.Command{
Use: "test",
Short: "Send a test notification",
Run: func(cmd *cobra.Command, args []string) {
database, err := openTaskDB(db.DefaultPath())
if err != nil {
fmt.Fprintln(os.Stderr, errorStyle.Render("Error: "+err.Error()))
os.Exit(1)
}
defer database.Close()

n := notify.New(database)
if !n.Enabled() {
fmt.Println(errorStyle.Render("Notifications are not enabled."))
fmt.Println(dimStyle.Render("Set notify_target and run: ty settings set notify_enabled true"))
return
}
if err := n.Test(); err != nil {
fmt.Println(errorStyle.Render("Failed to send: " + err.Error()))
return
}
fmt.Println(successStyle.Render("Sent — check your phone."))
},
}
notifyCmd.AddCommand(notifyTestCmd)
rootCmd.AddCommand(notifyCmd)

// Events command - manage event hooks
eventsCmd := &cobra.Command{
Use: "events",
Expand Down
48 changes: 47 additions & 1 deletion internal/events/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"time"

"github.com/bborn/workflow/internal/db"
"github.com/bborn/workflow/internal/notify"
)

// Event types for task lifecycle
Expand Down Expand Up @@ -45,6 +46,7 @@ type Event struct {
// Emitter handles event emission via hooks.
type Emitter struct {
hooksDir string
notifier *notify.Notifier
wg sync.WaitGroup
}

Expand All @@ -53,23 +55,67 @@ func New(hooksDir string) *Emitter {
return &Emitter{hooksDir: hooksDir}
}

// SetNotifier attaches a push notifier so lifecycle events also fan out to the
// user's phone (ntfy/webhook). Safe to pass nil to disable.
func (e *Emitter) SetNotifier(n *notify.Notifier) {
e.notifier = n
}

// Emit triggers a hook script if it exists for the event type.
// Hooks run in a background goroutine — short-lived CLI commands should
// call Wait before exiting so the hook actually runs.
func (e *Emitter) Emit(event Event) {
if e.hooksDir == "" {
// Nothing to dispatch to: no hook scripts and no push notifier.
if e.hooksDir == "" && e.notifier == nil {
return
}
if event.Timestamp.IsZero() {
event.Timestamp = time.Now()
}
// Build the notification synchronously, while the caller's DB handle is
// guaranteed open, and get back a closure that does only the network send.
// Short-lived CLI/MCP commands defer db.Close() the instant Run returns —
// before PersistentPostRun flushes this wait group — so reading settings
// inside the async goroutine would race the close and silently drop pushes.
deliver := e.prepareNotify(event)
e.wg.Add(1)
go func() {
defer e.wg.Done()
e.runHook(event)
if deliver != nil {
_ = deliver()
}
}()
}

// prepareNotify maps an event to a notification and asks the notifier to read
// its settings now (synchronously) and return a send closure, or nil if there's
// nothing to send. The returned closure performs only network I/O.
func (e *Emitter) prepareNotify(event Event) func() error {
if e.notifier == nil {
return nil
}
key := notify.EventKey(event.Type)
if key == "" {
return nil
}
note := notify.Notification{
Event: key,
TaskID: event.TaskID,
Message: event.Message,
}
if event.Task != nil {
note.Title = event.Task.Title
note.Status = event.Task.Status
note.Project = event.Task.Project
// Prefer a distilled summary over a bare status for completed tasks.
if note.Message == "" && key == "completed" && event.Task.Summary != "" {
note.Message = event.Task.Summary
}
}
return e.notifier.Prepare(note)
}

// Wait blocks until all in-flight hooks have completed.
// CLI commands that exit after triggering a state change must call this,
// otherwise the process terminates before the hook goroutine runs.
Expand Down
47 changes: 47 additions & 0 deletions internal/events/notify_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package events

import (
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"

"github.com/bborn/workflow/internal/db"
"github.com/bborn/workflow/internal/notify"
)

type notifyStore map[string]string

func (m notifyStore) GetSetting(key string) (string, error) { return m[key], nil }

func TestEmitterFiresNotificationAndFlushes(t *testing.T) {
var calls int64
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt64(&calls, 1)
}))
defer srv.Close()

store := notifyStore{
notify.SettingEnabled: "true",
notify.SettingTarget: srv.URL,
}
// No hooks dir: isolates the notification path.
e := New("")
e.SetNotifier(notify.New(store))

// Blocked is in the default notify set -> should fire.
e.EmitTaskBlocked(&db.Task{ID: 1, Title: "needs you"}, "waiting")
// Updated is not notifiable -> should not fire.
e.EmitTaskUpdated(&db.Task{ID: 1, Title: "x"}, nil)
e.Wait() // CLI-exit semantics: Wait must flush notifications too.

if got := atomic.LoadInt64(&calls); got != 1 {
t.Fatalf("expected exactly 1 notification, got %d", got)
}
}

func TestEmitterNoNotifierIsSafe(t *testing.T) {
e := New("")
e.EmitTaskCompleted(&db.Task{ID: 2, Title: "ok"})
e.Wait() // must not panic without a notifier configured
}
3 changes: 3 additions & 0 deletions internal/executor/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"github.com/bborn/workflow/internal/events"
"github.com/bborn/workflow/internal/github"
"github.com/bborn/workflow/internal/hooks"
"github.com/bborn/workflow/internal/notify"
)

// TaskEvent represents a change to a task.
Expand Down Expand Up @@ -166,6 +167,7 @@ func New(database *db.DB, cfg *config.Config) *Executor {
}

// Register the events emitter with the database for event emission
eventsEmitter.SetNotifier(notify.New(database))
database.SetEventEmitter(eventsEmitter)

// Register available executors
Expand Down Expand Up @@ -204,6 +206,7 @@ func NewWithLogging(database *db.DB, cfg *config.Config, w io.Writer) *Executor
}

// Register the events emitter with the database for event emission
eventsEmitter.SetNotifier(notify.New(database))
database.SetEventEmitter(eventsEmitter)

// Register available executors
Expand Down
Loading
Loading