From 5234847772b6f2ab991a35a4506c75a2d2e68795 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Tue, 23 Jun 2026 08:50:07 -0500 Subject: [PATCH 1/3] feat(remote): phone-first mobile console + push notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make TaskYou usable on a walk. Two pieces, both zero-dependency and always shipped from a plain `go build`: Mobile console (GET /m) - Self-contained HTML/CSS/vanilla-JS page embedded in the binary, driving the existing JSON API. No Node build, no React — `ty serve` over Tailscale now gives a usable phone UI instead of a cramped SSH/TUI session. - Phone-first board: "Needs you" (blocked) surfaced first, then Running, Queue, Recent. Tap a task to reply to a blocked agent, approve/continue, retry a finished task, or read the latest output. Polls every 5s; deep-links to a task. Push notifications (internal/notify) - Opt-in pushes when a task blocks, needs sign-in, completes, or fails — the moments where a walking user must act. ntfy (free iOS/Android app, no account) or arbitrary webhook (Slack/Discord/Telegram/Zapier). - Notifications deep-link into the mobile console (/m?task=N) so a tap opens the task ready to reply. - Wired into the events emitter (daemon executor + web routine runs), settings, and a new `ty notify` command (`ty notify test`). Config via `ty settings`: notify_enabled / notify_provider / notify_target / notify_events / notify_url. Tests: mobile route + console contents, notifier providers/filtering/deep-links, emitter fan-out to push (and no-notifier safety). Lint clean (golangci-lint v2.8.0). Co-Authored-By: Claude Opus 4.8 --- cmd/task/completion.go | 5 + cmd/task/completion_test.go | 4 +- cmd/task/main.go | 93 +++++++++++- internal/events/events.go | 39 ++++- internal/events/notify_test.go | 47 ++++++ internal/executor/executor.go | 3 + internal/notify/notify.go | 267 +++++++++++++++++++++++++++++++++ internal/notify/notify_test.go | 185 +++++++++++++++++++++++ internal/web/mobile.go | 22 +++ internal/web/mobile.html | 257 +++++++++++++++++++++++++++++++ internal/web/mobile_test.go | 44 ++++++ internal/web/routines.go | 5 +- internal/web/server.go | 5 + 13 files changed, 970 insertions(+), 6 deletions(-) create mode 100644 internal/events/notify_test.go create mode 100644 internal/notify/notify.go create mode 100644 internal/notify/notify_test.go create mode 100644 internal/web/mobile.go create mode 100644 internal/web/mobile.html create mode 100644 internal/web/mobile_test.go diff --git a/cmd/task/completion.go b/cmd/task/completion.go index 934045e8..bfd3e0d5 100644 --- a/cmd/task/completion.go +++ b/cmd/task/completion.go @@ -115,6 +115,11 @@ 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", }, cobra.ShellCompDirectiveNoFileComp } return nil, cobra.ShellCompDirectiveNoFileComp diff --git a/cmd/task/completion_test.go b/cmd/task/completion_test.go index 532dd9ef..db18a9f7 100644 --- a/cmd/task/completion_test.go +++ b/cmd/task/completion_test.go @@ -84,8 +84,8 @@ func TestCompleteSettingKeys(t *testing.T) { if directive != cobra.ShellCompDirectiveNoFileComp { t.Errorf("expected NoFileComp directive") } - if len(completions) != 3 { - t.Errorf("expected 3 setting keys, got %d", len(completions)) + if len(completions) != 8 { + t.Errorf("expected 8 setting keys, got %d", len(completions)) } // After first arg, no more completions diff --git a/cmd/task/main.go b/cmd/task/main.go index 80934be6..80a0cc29 100644 --- a/cmd/task/main.go +++ b/cmd/task/main.go @@ -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" ) @@ -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 } @@ -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 ' to change settings")) }, @@ -2354,7 +2378,12 @@ Available settings: anthropic_api_key API key for ghost text autocomplete (uses Anthropic API 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)`, + 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)`, Args: cobra.ExactArgs(2), Run: func(cmd *cobra.Command, args []string) { key := args[0] @@ -2377,9 +2406,21 @@ 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: + // Free-form strings; no validation. default: fmt.Println(errorStyle.Render("Unknown setting: " + key)) - fmt.Println(dimStyle.Render("Available: anthropic_api_key, autocomplete_enabled, idle_suspend_timeout")) + fmt.Println(dimStyle.Render("Available: anthropic_api_key, autocomplete_enabled, idle_suspend_timeout, notify_enabled, notify_provider, notify_target, notify_events, notify_url")) return } @@ -2402,6 +2443,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://: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", diff --git a/internal/events/events.go b/internal/events/events.go index 7aa9b3e7..ae0baf23 100644 --- a/internal/events/events.go +++ b/internal/events/events.go @@ -12,6 +12,7 @@ import ( "time" "github.com/bborn/workflow/internal/db" + "github.com/bborn/workflow/internal/notify" ) // Event types for task lifecycle @@ -45,6 +46,7 @@ type Event struct { // Emitter handles event emission via hooks. type Emitter struct { hooksDir string + notifier *notify.Notifier wg sync.WaitGroup } @@ -53,11 +55,18 @@ 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() { @@ -67,9 +76,37 @@ func (e *Emitter) Emit(event Event) { go func() { defer e.wg.Done() e.runHook(event) + e.runNotify(event) }() } +// runNotify pushes the event to the user's phone if a notifier is configured. +// Best-effort: delivery errors are swallowed, like hooks. +func (e *Emitter) runNotify(event Event) { + if e.notifier == nil { + return + } + key := notify.EventKey(event.Type) + if key == "" { + return + } + 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 + } + } + _ = e.notifier.Notify(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. diff --git a/internal/events/notify_test.go b/internal/events/notify_test.go new file mode 100644 index 00000000..cb054df1 --- /dev/null +++ b/internal/events/notify_test.go @@ -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 +} diff --git a/internal/executor/executor.go b/internal/executor/executor.go index e34d8e55..e35d42a1 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -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. @@ -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 @@ -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 diff --git a/internal/notify/notify.go b/internal/notify/notify.go new file mode 100644 index 00000000..cecd15d1 --- /dev/null +++ b/internal/notify/notify.go @@ -0,0 +1,267 @@ +// Package notify sends push notifications for task lifecycle events so you can +// step away from the keyboard and still know when an agent needs you. +// +// It is deliberately self-contained (stdlib only) and config-driven: nothing is +// sent unless the user opts in via settings. Two delivery providers are +// supported: +// +// - "ntfy" — POST the message to an ntfy topic URL (ntfy.sh or self-hosted). +// ntfy has free iOS/Android apps, so this is the fastest path to a push on +// your phone with no accounts or API keys. +// - "webhook" — POST a JSON payload to an arbitrary URL (Slack/Discord/ +// Telegram bridges, Zapier, your own endpoint, etc.). +// +// Notifications carry a deep link back into the mobile console (GET /m) so a tap +// opens the task and you can reply, retry, or approve from a phone. +package notify + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +// Setting keys (stored in the task database settings table). +const ( + SettingEnabled = "notify_enabled" // "true" to enable push notifications + SettingProvider = "notify_provider" // "ntfy" (default) or "webhook" + SettingTarget = "notify_target" // ntfy topic URL or webhook URL + SettingEvents = "notify_events" // comma list of event keys to notify on + SettingURL = "notify_url" // base URL for deep links into the console +) + +// ProviderNtfy and ProviderWebhook are the supported delivery providers. +const ( + ProviderNtfy = "ntfy" + ProviderWebhook = "webhook" +) + +// DefaultEvents is the set of event keys that fire a notification when the user +// hasn't customized notify_events. These are the moments where a walking user +// actually needs to act; created/started/updated are intentionally excluded as +// too noisy. +const DefaultEvents = "blocked,auth_required,completed,failed" + +// SettingsStore is the minimal slice of the database the notifier needs. *db.DB +// satisfies it; defining it here keeps this package free of a db import. +type SettingsStore interface { + GetSetting(key string) (string, error) +} + +// Notification is a provider-agnostic description of something worth a push. +type Notification struct { + Event string // short event key, e.g. "blocked", "completed" + TaskID int64 + Title string // task title + Status string // task status + Project string + Message string // reason / summary, optional +} + +// Notifier delivers notifications using settings read fresh on each send, so +// toggling config takes effect immediately without a restart. +type Notifier struct { + store SettingsStore + client *http.Client +} + +// New creates a Notifier backed by the given settings store. +func New(store SettingsStore) *Notifier { + return &Notifier{ + store: store, + client: &http.Client{Timeout: 10 * time.Second}, + } +} + +// EventKey maps a full events.* type string ("task.blocked") to the short key +// used in settings and notification copy ("blocked"). Unknown/irrelevant types +// return "" and are never notified. +func EventKey(eventType string) string { + switch eventType { + case "task.blocked": + return "blocked" + case "task.auth_required": + return "auth_required" + case "task.completed": + return "completed" + case "task.failed": + return "failed" + case "task.created": + return "created" + case "task.started": + return "started" + case "task.worktree_ready": + return "worktree_ready" + default: + return "" + } +} + +// Enabled reports whether notifications are switched on and a target is set. +func (n *Notifier) Enabled() bool { + if n == nil || n.store == nil { + return false + } + if v, _ := n.store.GetSetting(SettingEnabled); v != "true" { + return false + } + target, _ := n.store.GetSetting(SettingTarget) + return strings.TrimSpace(target) != "" +} + +// ShouldNotify reports whether the given short event key is in the user's +// configured set (or the default set when unconfigured). +func (n *Notifier) ShouldNotify(eventKey string) bool { + if eventKey == "" { + return false + } + configured, _ := n.store.GetSetting(SettingEvents) + if strings.TrimSpace(configured) == "" { + configured = DefaultEvents + } + for _, e := range strings.Split(configured, ",") { + if strings.TrimSpace(e) == eventKey { + return true + } + } + return false +} + +// Notify sends a push for the notification if notifications are enabled and the +// event is in scope. It is best-effort: errors are returned for callers that +// want them (e.g. `ty notify test`) but the event pipeline ignores them. +func (n *Notifier) Notify(note Notification) error { + if !n.Enabled() || !n.ShouldNotify(note.Event) { + return nil + } + return n.send(note) +} + +// Test sends a verification notification, bypassing the event filter but still +// requiring a target to be configured. Used by `ty notify test`. +func (n *Notifier) Test() error { + target, _ := n.store.GetSetting(SettingTarget) + if strings.TrimSpace(target) == "" { + return fmt.Errorf("notify_target is not set") + } + return n.send(Notification{ + Event: "completed", + Title: "TaskYou test notification", + Status: "done", + Message: "If you can see this on your phone, you're all set. Go for that walk.", + }) +} + +// send dispatches to the configured provider regardless of enablement; used by +// the test command to verify delivery. +func (n *Notifier) send(note Notification) error { + target, _ := n.store.GetSetting(SettingTarget) + target = strings.TrimSpace(target) + if target == "" { + return fmt.Errorf("notify_target is not set") + } + provider, _ := n.store.GetSetting(SettingProvider) + switch strings.TrimSpace(provider) { + case "", ProviderNtfy: + return n.sendNtfy(target, note) + case ProviderWebhook: + return n.sendWebhook(target, note) + default: + return fmt.Errorf("unknown notify_provider %q (use %q or %q)", provider, ProviderNtfy, ProviderWebhook) + } +} + +// linkFor builds a deep link into the mobile console for the task, or "" when no +// base URL is configured. +func (n *Notifier) linkFor(taskID int64) string { + base, _ := n.store.GetSetting(SettingURL) + base = strings.TrimSpace(base) + if base == "" { + base, _ = n.store.GetSetting("server_url") + base = strings.TrimSpace(base) + } + if base == "" { + return "" + } + return fmt.Sprintf("%s/m?task=%d", strings.TrimRight(base, "/"), taskID) +} + +func (n *Notifier) sendNtfy(target string, note Notification) error { + title, priority, tag := decorate(note) + body := note.Title + if note.Message != "" { + body = fmt.Sprintf("%s\n%s", note.Title, note.Message) + } + + req, err := http.NewRequest(http.MethodPost, target, strings.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Title", title) + req.Header.Set("Priority", priority) + req.Header.Set("Tags", tag) + if link := n.linkFor(note.TaskID); link != "" { + req.Header.Set("Click", link) + } + + resp, err := n.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + return fmt.Errorf("ntfy responded %s", resp.Status) + } + return nil +} + +func (n *Notifier) sendWebhook(target string, note Notification) error { + payload := map[string]interface{}{ + "event": note.Event, + "task_id": note.TaskID, + "title": note.Title, + "status": note.Status, + "project": note.Project, + "message": note.Message, + "url": n.linkFor(note.TaskID), + } + data, err := json.Marshal(payload) + if err != nil { + return err + } + req, err := http.NewRequest(http.MethodPost, target, bytes.NewReader(data)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := n.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + return fmt.Errorf("webhook responded %s", resp.Status) + } + return nil +} + +// decorate returns the title, ntfy priority, and ntfy tag (emoji) for an event. +func decorate(note Notification) (title, priority, tag string) { + id := fmt.Sprintf("#%d", note.TaskID) + switch note.Event { + case "blocked": + return fmt.Sprintf("%s needs you", id), "high", "bell" + case "auth_required": + return fmt.Sprintf("%s needs sign-in", id), "high", "lock" + case "failed": + return fmt.Sprintf("%s failed", id), "high", "x" + case "completed": + return fmt.Sprintf("%s done", id), "default", "white_check_mark" + default: + return fmt.Sprintf("%s %s", id, note.Event), "default", "loudspeaker" + } +} diff --git a/internal/notify/notify_test.go b/internal/notify/notify_test.go new file mode 100644 index 00000000..918769ac --- /dev/null +++ b/internal/notify/notify_test.go @@ -0,0 +1,185 @@ +package notify + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// mapStore is an in-memory SettingsStore for tests. +type mapStore map[string]string + +func (m mapStore) GetSetting(key string) (string, error) { return m[key], nil } + +func TestEventKey(t *testing.T) { + cases := map[string]string{ + "task.blocked": "blocked", + "task.auth_required": "auth_required", + "task.completed": "completed", + "task.failed": "failed", + "task.updated": "", // not notifiable + "nonsense": "", + } + for in, want := range cases { + if got := EventKey(in); got != want { + t.Errorf("EventKey(%q) = %q, want %q", in, got, want) + } + } +} + +func TestEnabledRequiresFlagAndTarget(t *testing.T) { + if New(mapStore{SettingEnabled: "true"}).Enabled() { + t.Error("Enabled() should be false without a target") + } + if New(mapStore{SettingTarget: "https://ntfy.sh/x"}).Enabled() { + t.Error("Enabled() should be false when not switched on") + } + if !New(mapStore{SettingEnabled: "true", SettingTarget: "https://ntfy.sh/x"}).Enabled() { + t.Error("Enabled() should be true with flag + target") + } +} + +func TestShouldNotifyDefaultsAndCustom(t *testing.T) { + def := New(mapStore{}) + if !def.ShouldNotify("blocked") || !def.ShouldNotify("completed") { + t.Error("default events should include blocked and completed") + } + if def.ShouldNotify("created") { + t.Error("default events should not include created") + } + custom := New(mapStore{SettingEvents: "created, blocked"}) + if !custom.ShouldNotify("created") || !custom.ShouldNotify("blocked") { + t.Error("custom event list not honored") + } + if custom.ShouldNotify("completed") { + t.Error("completed should be excluded by custom list") + } + if def.ShouldNotify("") { + t.Error("empty event key must never notify") + } +} + +func TestNotifyDisabledSendsNothing(t *testing.T) { + var hits int + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits++ })) + defer srv.Close() + + // Enabled flag off -> no send even with a valid target. + n := New(mapStore{SettingTarget: srv.URL}) + if err := n.Notify(Notification{Event: "blocked", TaskID: 1, Title: "x"}); err != nil { + t.Fatalf("Notify returned error: %v", err) + } + if hits != 0 { + t.Fatalf("expected no HTTP calls when disabled, got %d", hits) + } +} + +func TestNotifyNtfySetsHeadersAndLink(t *testing.T) { + var gotTitle, gotPriority, gotClick, gotBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotTitle = r.Header.Get("Title") + gotPriority = r.Header.Get("Priority") + gotClick = r.Header.Get("Click") + b, _ := io.ReadAll(r.Body) + gotBody = string(b) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + n := New(mapStore{ + SettingEnabled: "true", + SettingTarget: srv.URL, + SettingURL: "http://host:8080", + }) + err := n.Notify(Notification{Event: "blocked", TaskID: 42, Title: "Fix bug", Message: "needs input"}) + if err != nil { + t.Fatalf("Notify error: %v", err) + } + if !strings.Contains(gotTitle, "#42") { + t.Errorf("title missing task id: %q", gotTitle) + } + if gotPriority != "high" { + t.Errorf("blocked should be high priority, got %q", gotPriority) + } + if gotClick != "http://host:8080/m?task=42" { + t.Errorf("unexpected deep link: %q", gotClick) + } + if !strings.Contains(gotBody, "Fix bug") || !strings.Contains(gotBody, "needs input") { + t.Errorf("body missing content: %q", gotBody) + } +} + +func TestNotifyLinkFallsBackToServerURL(t *testing.T) { + var gotClick string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotClick = r.Header.Get("Click") + })) + defer srv.Close() + + n := New(mapStore{ + SettingEnabled: "true", + SettingTarget: srv.URL, + "server_url": "http://fallback:9999/", + }) + _ = n.Notify(Notification{Event: "completed", TaskID: 7, Title: "done"}) + if gotClick != "http://fallback:9999/m?task=7" { + t.Errorf("expected server_url fallback link, got %q", gotClick) + } +} + +func TestNotifyWebhookPostsJSON(t *testing.T) { + var payload map[string]interface{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if ct := r.Header.Get("Content-Type"); ct != "application/json" { + t.Errorf("expected json content type, got %q", ct) + } + _ = json.NewDecoder(r.Body).Decode(&payload) + })) + defer srv.Close() + + n := New(mapStore{ + SettingEnabled: "true", + SettingProvider: ProviderWebhook, + SettingTarget: srv.URL, + SettingURL: "http://host", + }) + err := n.Notify(Notification{Event: "failed", TaskID: 5, Title: "boom", Status: "failed", Project: "p"}) + if err != nil { + t.Fatalf("Notify error: %v", err) + } + if payload["event"] != "failed" || payload["title"] != "boom" || payload["project"] != "p" { + t.Errorf("unexpected webhook payload: %#v", payload) + } + if payload["url"] != "http://host/m?task=5" { + t.Errorf("webhook url wrong: %v", payload["url"]) + } +} + +func TestTestBypassesEventFilterButRequiresTarget(t *testing.T) { + if err := New(mapStore{}).Test(); err == nil { + t.Error("Test() should error without a target") + } + + var hit bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hit = true })) + defer srv.Close() + + // Even with an event list that excludes 'completed', Test() still sends. + n := New(mapStore{SettingTarget: srv.URL, SettingEvents: "blocked"}) + if err := n.Test(); err != nil { + t.Fatalf("Test() error: %v", err) + } + if !hit { + t.Error("Test() should have sent a notification") + } +} + +func TestUnknownProviderErrors(t *testing.T) { + n := New(mapStore{SettingTarget: "http://x", SettingProvider: "carrier-pigeon"}) + if err := n.send(Notification{Event: "blocked"}); err == nil { + t.Error("expected error for unknown provider") + } +} diff --git a/internal/web/mobile.go b/internal/web/mobile.go new file mode 100644 index 00000000..f05213a4 --- /dev/null +++ b/internal/web/mobile.go @@ -0,0 +1,22 @@ +package web + +import ( + _ "embed" + "net/http" +) + +// mobileHTML is the self-contained mobile console. Unlike the React UI (which is +// only embedded behind a build tag + Node build), this page is always available +// from a plain `go build`, so `ty serve` over Tailscale gives you a usable phone +// interface with zero extra steps. It is pure HTML/CSS/vanilla-JS and drives the +// existing JSON API. +// +//go:embed mobile.html +var mobileHTML []byte + +// handleMobile serves the mobile console at /m. +func (s *Server) handleMobile(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + _, _ = w.Write(mobileHTML) +} diff --git a/internal/web/mobile.html b/internal/web/mobile.html new file mode 100644 index 00000000..759f6673 --- /dev/null +++ b/internal/web/mobile.html @@ -0,0 +1,257 @@ + + + + + + +TaskYou — remote + + + +
+

TaskYou

+ connecting… +
+
Loading…
+ +
+
+ + +
+
+
+ +
+ + + + diff --git a/internal/web/mobile_test.go b/internal/web/mobile_test.go new file mode 100644 index 00000000..1fdb4ead --- /dev/null +++ b/internal/web/mobile_test.go @@ -0,0 +1,44 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestHandleMobileServesConsole(t *testing.T) { + s := &Server{} + req := httptest.NewRequest(http.MethodGet, "/m", nil) + rec := httptest.NewRecorder() + + s.handleMobile(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") { + t.Errorf("content-type = %q, want text/html", ct) + } + body := rec.Body.String() + for _, want := range []string{"TaskYou", "/api/board", "/api/tasks/", "Needs you"} { + if !strings.Contains(body, want) { + t.Errorf("mobile console missing %q", want) + } + } +} + +func TestMobileRouteRegistered(t *testing.T) { + s := New(Config{Addr: ":0"}) + srv := httptest.NewServer(s.srv.Handler) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/m") + if err != nil { + t.Fatalf("GET /m: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET /m status = %d, want 200", resp.StatusCode) + } +} diff --git a/internal/web/routines.go b/internal/web/routines.go index d98dcf54..c167d96b 100644 --- a/internal/web/routines.go +++ b/internal/web/routines.go @@ -10,6 +10,7 @@ import ( "github.com/bborn/workflow/internal/db" "github.com/bborn/workflow/internal/events" "github.com/bborn/workflow/internal/hooks" + "github.com/bborn/workflow/internal/notify" "github.com/bborn/workflow/internal/routine" ) @@ -159,9 +160,11 @@ func (s *Server) handleRunRoutine(w http.ResponseWriter, r *http.Request) { } } + emitter := events.New(hooks.DefaultHooksDir()) + emitter.SetNotifier(notify.New(s.db)) runner := &routine.Runner{ DB: s.db, - Emitter: events.New(hooks.DefaultHooksDir()), + Emitter: emitter, } go func() { ctx, cancel := context.WithTimeout(context.Background(), rt.Timeout+time.Minute) diff --git a/internal/web/server.go b/internal/web/server.go index c369ab15..809641d4 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -150,6 +150,11 @@ func New(cfg Config) *Server { // Status mux.HandleFunc("GET /api/status", s.handleStatus) + // Mobile console — a zero-build, phone-first page that always ships, so + // `ty serve` is usable from a phone over Tailscale without the React UI. + mux.HandleFunc("GET /m", s.handleMobile) + mux.HandleFunc("GET /m/", s.handleMobile) + // Embedded web UI (same React app as the desktop shell); /api/* patterns // are more specific and keep precedence. mux.Handle("GET /", ui.Handler()) From a97f397108ee3c12f8a895eb2b4c07121a59232d Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein <bruno.bornsztein@gmail.com> Date: Tue, 23 Jun 2026 17:21:13 -0500 Subject: [PATCH 2/3] feat(remote): render Markdown in the mobile console task summary Agent summaries are written in Markdown, but the mobile console showed them raw (literal **bold** and - bullets). Add a tiny, dependency-free, XSS-safe Markdown renderer (escape-first; bold/italic/inline-code/links + heading, bullet/ordered list, fenced-code, and paragraph blocks) and use it for the summary. Logs stay verbatim monospace. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- internal/web/mobile.html | 82 ++++++++++++++++++++++++++++++++++++- internal/web/mobile_test.go | 3 +- 2 files changed, 82 insertions(+), 3 deletions(-) diff --git a/internal/web/mobile.html b/internal/web/mobile.html index 759f6673..63de067b 100644 --- a/internal/web/mobile.html +++ b/internal/web/mobile.html @@ -55,7 +55,21 @@ white-space: pre-wrap; word-break: break-word; color: #a9b1d6; max-height: 45vh; overflow-y: auto; margin: 12px 0; } .summary { background: var(--card); border: 1px solid var(--line); border-radius: 10px; - padding: 11px 12px; margin: 12px 0; white-space: pre-wrap; } + padding: 11px 12px; margin: 12px 0; } + .summary > :first-child { margin-top: 0; } + .summary > :last-child { margin-bottom: 0; } + .summary p { margin: 0 0 8px; } + .summary ul, .summary ol { margin: 6px 0; padding-left: 20px; } + .summary li { margin: 3px 0; } + .summary h1, .summary h2, .summary h3, .summary h4 { font-size: 14px; margin: 12px 0 4px; + letter-spacing: .2px; } + .summary strong { color: #fff; font-weight: 700; } + .summary a { color: var(--accent); } + .summary code { background: #0e0f17; border: 1px solid var(--line); border-radius: 5px; + padding: 1px 5px; font: 12.5px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; } + .summary pre { background: #0e0f17; border: 1px solid var(--line); border-radius: 8px; + padding: 10px; overflow-x: auto; margin: 8px 0; } + .summary pre code { background: none; border: none; padding: 0; } textarea { width: 100%; background: var(--card); color: var(--fg); border: 1px solid var(--line); border-radius: 10px; padding: 11px; font: inherit; resize: vertical; min-height: 84px; } @@ -106,6 +120,70 @@ <h1>TaskYou</h1> return (s || "").replace(/[&<>"']/g, c => ( { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } +// mdInline renders inline Markdown (bold/italic/code/links) for one line. +// The whole string is HTML-escaped first; the only tags emitted are the ones +// added here, so it stays XSS-safe. Splitting on `code` spans keeps their +// contents from being re-formatted. +function mdInline(t) { + return esc(t).split(/(`[^`]+`)/).map(seg => { + if (seg.length > 1 && seg[0] === "`" && seg[seg.length - 1] === "`") { + return "<code>" + seg.slice(1, -1) + "</code>"; + } + return seg + .replace(/\[([^\]]+)\]\(((?:https?:\/\/|mailto:|\/)[^\s)]+)\)/g, + (m, txt, u) => `<a href="${u}" target="_blank" rel="noopener">${txt}</a>`) + .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>") + .replace(/__([^_]+)__/g, "<strong>$1</strong>") + .replace(/\*([^*\n]+)\*/g, "<em>$1</em>") + .replace(/(^|[^a-zA-Z0-9_])_([^_\n]+)_/g, "$1<em>$2</em>"); + }).join(""); +} +// md renders a safe subset of block Markdown: headings, bullet/ordered lists, +// fenced code, and paragraphs. Agent summaries are written in Markdown. +function md(src) { + if (!src) return ""; + const lines = String(src).replace(/\r\n/g, "\n").split("\n"); + const out = []; + let listType = null, listItems = [], para = [], i = 0; + const flushList = () => { + if (listType) { out.push(`<${listType}>${listItems.join("")}</${listType}>`); listItems = []; listType = null; } + }; + const flushPara = () => { + if (para.length) { out.push("<p>" + para.map(mdInline).join("<br>") + "</p>"); para = []; } + }; + while (i < lines.length) { + const line = lines[i]; + if (/^```/.test(line)) { + flushPara(); flushList(); + const buf = []; i++; + while (i < lines.length && !/^```/.test(lines[i])) { buf.push(lines[i]); i++; } + i++; // skip closing fence + out.push("<pre><code>" + esc(buf.join("\n")) + "</code></pre>"); + continue; + } + const heading = line.match(/^(#{1,6})\s+(.*)$/); + if (heading) { + flushPara(); flushList(); + const lvl = Math.min(heading[1].length, 4); + out.push(`<h${lvl}>${mdInline(heading[2])}</h${lvl}>`); i++; continue; + } + const ul = line.match(/^\s*[-*+]\s+(.*)$/); + const ol = line.match(/^\s*\d+\.\s+(.*)$/); + if (ul || ol) { + flushPara(); + const t = ul ? "ul" : "ol"; + if (listType && listType !== t) flushList(); + listType = t; + listItems.push("<li>" + mdInline((ul || ol)[1]) + "</li>"); + i++; continue; + } + if (line.trim() === "") { flushPara(); flushList(); i++; continue; } + flushList(); + para.push(line); i++; + } + flushPara(); flushList(); + return out.join(""); +} function toast(msg) { const t = document.getElementById("toast"); t.textContent = msg; t.classList.add("show"); @@ -207,7 +285,7 @@ <h2>${esc(t.title)}</h2> ${esc(t.project || "")} ${t.branch_name ? "· " + esc(t.branch_name) : ""} </div> ${t.pr_url ? `<a class="pr" href="${esc(t.pr_url)}" target="_blank" rel="noopener">View PR #${t.pr_number || ""} ↗</a>` : ""} - ${t.summary ? `<div class="summary">${esc(t.summary)}</div>` : ""} + ${t.summary ? `<div class="summary">${md(t.summary)}</div>` : ""} ${actions} <div class="section-title" style="margin-left:0">Latest output</div> <div class="logs" id="logs">${esc(logs) || "No output yet."}</div>`; diff --git a/internal/web/mobile_test.go b/internal/web/mobile_test.go index 1fdb4ead..99850d8d 100644 --- a/internal/web/mobile_test.go +++ b/internal/web/mobile_test.go @@ -21,7 +21,8 @@ func TestHandleMobileServesConsole(t *testing.T) { t.Errorf("content-type = %q, want text/html", ct) } body := rec.Body.String() - for _, want := range []string{"<title>TaskYou", "/api/board", "/api/tasks/", "Needs you"} { + for _, want := range []string{"<title>TaskYou", "/api/board", "/api/tasks/", "Needs you", + "function md(", "function mdInline(", "<strong>"} { if !strings.Contains(body, want) { t.Errorf("mobile console missing %q", want) } From e0a1894a349000ab1ce28c4f0078271985602c69 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein <bruno.bornsztein@gmail.com> Date: Fri, 26 Jun 2026 17:11:38 -0500 Subject: [PATCH 3/3] feat(notify): one-tap unblock action button + close the CLI-exit delivery race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the two wins from PR #621 onto the mobile-console notifier: 1. One-tap unblock. ntfy pushes for actionable events (blocked/auth_required) now carry an Actions header: an http button that POSTs the canned reply to the existing POST /api/tasks/{id}/input (resuming the agent without opening anything), plus a view button deep-linking into the mobile console (/m) for a custom reply — console and one-tap, together. Reply text is configurable via notify_reply (default "continue"); validated live against ntfy.sh. 2. CLI-exit delivery race fix. The notifier read its settings inside the async delivery goroutine, but short-lived CLI/MCP commands defer db.Close() the instant Run returns — before PersistentPostRun flushes the emitter wait group — so the goroutine hit a closed DB, saw notifications as disabled, and silently dropped the push (daemon/web runs were unaffected; their DB stays open). Notifier.Prepare now reads settings + builds the request synchronously and returns a network-only delivery closure; events.Emit calls it before spawning the goroutine. Existing header/webhook behavior and tests are unchanged; adds tests for the action button (default + custom reply, and no action when no base URL is set). --- cmd/task/completion.go | 1 + cmd/task/main.go | 5 +- internal/events/events.go | 23 +++-- internal/notify/notify.go | 152 +++++++++++++++++++++++++-------- internal/notify/notify_test.go | 63 ++++++++++++++ 5 files changed, 200 insertions(+), 44 deletions(-) diff --git a/cmd/task/completion.go b/cmd/task/completion.go index e4a400e7..c7823d13 100644 --- a/cmd/task/completion.go +++ b/cmd/task/completion.go @@ -120,6 +120,7 @@ func completeSettingKeys(cmd *cobra.Command, args []string, toComplete string) ( "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 diff --git a/cmd/task/main.go b/cmd/task/main.go index 95e68880..a3e9dd9f 100644 --- a/cmd/task/main.go +++ b/cmd/task/main.go @@ -2384,6 +2384,7 @@ Available settings: 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), @@ -2418,7 +2419,7 @@ Available settings: fmt.Println(errorStyle.Render("Value must be 'ntfy' or 'webhook'")) return } - case notify.SettingTarget, notify.SettingEvents, notify.SettingURL: + 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 { @@ -2432,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, notify_enabled, notify_provider, notify_target, notify_events, notify_url, 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 } diff --git a/internal/events/events.go b/internal/events/events.go index ae0baf23..386cb815 100644 --- a/internal/events/events.go +++ b/internal/events/events.go @@ -72,23 +72,32 @@ func (e *Emitter) Emit(event Event) { 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) - e.runNotify(event) + if deliver != nil { + _ = deliver() + } }() } -// runNotify pushes the event to the user's phone if a notifier is configured. -// Best-effort: delivery errors are swallowed, like hooks. -func (e *Emitter) runNotify(event Event) { +// 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 + return nil } key := notify.EventKey(event.Type) if key == "" { - return + return nil } note := notify.Notification{ Event: key, @@ -104,7 +113,7 @@ func (e *Emitter) runNotify(event Event) { note.Message = event.Task.Summary } } - _ = e.notifier.Notify(note) + return e.notifier.Prepare(note) } // Wait blocks until all in-flight hooks have completed. diff --git a/internal/notify/notify.go b/internal/notify/notify.go index cecd15d1..77958b2e 100644 --- a/internal/notify/notify.go +++ b/internal/notify/notify.go @@ -12,7 +12,11 @@ // Telegram bridges, Zapier, your own endpoint, etc.). // // Notifications carry a deep link back into the mobile console (GET /m) so a tap -// opens the task and you can reply, retry, or approve from a phone. +// opens the task and you can reply, retry, or approve from a phone. For events +// where you must act (blocked/auth_required), ntfy pushes also carry a one-tap +// action button that POSTs straight to the existing input API +// (POST /api/tasks/{id}/input), so you can unblock an agent without even opening +// the console. package notify import ( @@ -31,6 +35,7 @@ const ( SettingTarget = "notify_target" // ntfy topic URL or webhook URL SettingEvents = "notify_events" // comma list of event keys to notify on SettingURL = "notify_url" // base URL for deep links into the console + SettingReply = "notify_reply" // canned reply sent by the one-tap action ) // ProviderNtfy and ProviderWebhook are the supported delivery providers. @@ -45,6 +50,10 @@ const ( // too noisy. const DefaultEvents = "blocked,auth_required,completed,failed" +// DefaultReply is the canned reply the one-tap action sends to a blocked agent +// when notify_reply is unset. +const DefaultReply = "continue" + // SettingsStore is the minimal slice of the database the notifier needs. *db.DB // satisfies it; defining it here keeps this package free of a db import. type SettingsStore interface { @@ -100,6 +109,12 @@ func EventKey(eventType string) string { } } +// actionable reports whether an event is one the user can resolve with a +// one-tap reply (so we attach an action button). +func actionable(eventKey string) bool { + return eventKey == "blocked" || eventKey == "auth_required" +} + // Enabled reports whether notifications are switched on and a target is set. func (n *Notifier) Enabled() bool { if n == nil || n.store == nil { @@ -130,14 +145,34 @@ func (n *Notifier) ShouldNotify(eventKey string) bool { return false } +// Prepare reads all settings and builds the outbound request synchronously — +// while the caller's database handle is guaranteed open — and returns a closure +// that performs the (slow, network-bound) send, or nil when there is nothing to +// send. The returned closure touches no database, so it is safe to run after the +// caller has closed its DB. This is what the events emitter uses so that a +// deferred db.Close() in a short-lived CLI/MCP command can't race the async +// delivery and silently drop the push. +func (n *Notifier) Prepare(note Notification) func() error { + if !n.Enabled() || !n.ShouldNotify(note.Event) { + return nil + } + req, err := n.build(note) + return func() error { + if err != nil { + return err + } + return n.do(req) + } +} + // Notify sends a push for the notification if notifications are enabled and the // event is in scope. It is best-effort: errors are returned for callers that // want them (e.g. `ty notify test`) but the event pipeline ignores them. func (n *Notifier) Notify(note Notification) error { - if !n.Enabled() || !n.ShouldNotify(note.Event) { - return nil + if deliver := n.Prepare(note); deliver != nil { + return deliver() } - return n.send(note) + return nil } // Test sends a verification notification, bypassing the event filter but still @@ -155,28 +190,50 @@ func (n *Notifier) Test() error { }) } -// send dispatches to the configured provider regardless of enablement; used by +// send builds and dispatches synchronously, regardless of enablement; used by // the test command to verify delivery. func (n *Notifier) send(note Notification) error { + req, err := n.build(note) + if err != nil { + return err + } + return n.do(req) +} + +// do executes a prepared request and maps non-2xx responses to errors. +func (n *Notifier) do(req *http.Request) error { + resp, err := n.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + return fmt.Errorf("notify: provider responded %s", resp.Status) + } + return nil +} + +// build constructs the provider request for a notification (no network I/O). +func (n *Notifier) build(note Notification) (*http.Request, error) { target, _ := n.store.GetSetting(SettingTarget) target = strings.TrimSpace(target) if target == "" { - return fmt.Errorf("notify_target is not set") + return nil, fmt.Errorf("notify_target is not set") } provider, _ := n.store.GetSetting(SettingProvider) switch strings.TrimSpace(provider) { case "", ProviderNtfy: - return n.sendNtfy(target, note) + return n.buildNtfy(target, note) case ProviderWebhook: - return n.sendWebhook(target, note) + return n.buildWebhook(target, note) default: - return fmt.Errorf("unknown notify_provider %q (use %q or %q)", provider, ProviderNtfy, ProviderWebhook) + return nil, fmt.Errorf("unknown notify_provider %q (use %q or %q)", provider, ProviderNtfy, ProviderWebhook) } } -// linkFor builds a deep link into the mobile console for the task, or "" when no -// base URL is configured. -func (n *Notifier) linkFor(taskID int64) string { +// baseURL returns the externally reachable console base URL (no trailing slash), +// preferring notify_url and falling back to server_url. Empty when neither set. +func (n *Notifier) baseURL() string { base, _ := n.store.GetSetting(SettingURL) base = strings.TrimSpace(base) if base == "" { @@ -186,10 +243,20 @@ func (n *Notifier) linkFor(taskID int64) string { if base == "" { return "" } - return fmt.Sprintf("%s/m?task=%d", strings.TrimRight(base, "/"), taskID) + return strings.TrimRight(base, "/") } -func (n *Notifier) sendNtfy(target string, note Notification) error { +// linkFor builds a deep link into the mobile console for the task, or "" when no +// base URL is configured. +func (n *Notifier) linkFor(taskID int64) string { + base := n.baseURL() + if base == "" { + return "" + } + return fmt.Sprintf("%s/m?task=%d", base, taskID) +} + +func (n *Notifier) buildNtfy(target string, note Notification) (*http.Request, error) { title, priority, tag := decorate(note) body := note.Title if note.Message != "" { @@ -198,7 +265,7 @@ func (n *Notifier) sendNtfy(target string, note Notification) error { req, err := http.NewRequest(http.MethodPost, target, strings.NewReader(body)) if err != nil { - return err + return nil, err } req.Header.Set("Title", title) req.Header.Set("Priority", priority) @@ -206,19 +273,43 @@ func (n *Notifier) sendNtfy(target string, note Notification) error { if link := n.linkFor(note.TaskID); link != "" { req.Header.Set("Click", link) } + if h := n.actionsHeader(note); h != "" { + req.Header.Set("Actions", h) + } + return req, nil +} - resp, err := n.client.Do(req) - if err != nil { - return err +// actionsHeader builds the ntfy "Actions" header for events the user can resolve +// in one tap. The http action POSTs the canned reply to the existing input API, +// which types it into the agent's session and resumes it; the view action opens +// the task in the mobile console for a custom reply. Returns "" when the event +// isn't actionable or no reachable base URL is configured. +func (n *Notifier) actionsHeader(note Notification) string { + if !actionable(note.Event) || note.TaskID == 0 { + return "" } - defer resp.Body.Close() - if resp.StatusCode >= 300 { - return fmt.Errorf("ntfy responded %s", resp.Status) + base := n.baseURL() + if base == "" { + return "" } - return nil + reply, _ := n.store.GetSetting(SettingReply) + reply = strings.TrimSpace(reply) + if reply == "" { + reply = DefaultReply + } + // JSON-encode the body so the reply value is properly escaped. + payload, _ := json.Marshal(map[string]string{"message": reply}) + + inputURL := fmt.Sprintf("%s/api/tasks/%d/input", base, note.TaskID) + httpAction := fmt.Sprintf( + "http, %q, %s, method=POST, headers.Content-Type=application/json, body='%s', clear=true", + "Reply "+reply, inputURL, string(payload), + ) + viewAction := fmt.Sprintf("view, %q, %s", "Open task", n.linkFor(note.TaskID)) + return httpAction + "; " + viewAction } -func (n *Notifier) sendWebhook(target string, note Notification) error { +func (n *Notifier) buildWebhook(target string, note Notification) (*http.Request, error) { payload := map[string]interface{}{ "event": note.Event, "task_id": note.TaskID, @@ -230,23 +321,14 @@ func (n *Notifier) sendWebhook(target string, note Notification) error { } data, err := json.Marshal(payload) if err != nil { - return err + return nil, err } req, err := http.NewRequest(http.MethodPost, target, bytes.NewReader(data)) if err != nil { - return err + return nil, err } req.Header.Set("Content-Type", "application/json") - - resp, err := n.client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode >= 300 { - return fmt.Errorf("webhook responded %s", resp.Status) - } - return nil + return req, nil } // decorate returns the title, ntfy priority, and ntfy tag (emoji) for an event. diff --git a/internal/notify/notify_test.go b/internal/notify/notify_test.go index 918769ac..774c4bda 100644 --- a/internal/notify/notify_test.go +++ b/internal/notify/notify_test.go @@ -183,3 +183,66 @@ func TestUnknownProviderErrors(t *testing.T) { t.Error("expected error for unknown provider") } } + +func TestNtfyAttachesOneTapActionForBlocked(t *testing.T) { + var gotActions string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotActions = r.Header.Get("Actions") + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + n := New(mapStore{ + SettingEnabled: "true", + SettingTarget: srv.URL, + SettingURL: "http://host:8080", + }) + if err := n.Notify(Notification{Event: "blocked", TaskID: 42, Title: "Fix bug"}); err != nil { + t.Fatalf("Notify error: %v", err) + } + // One-tap reply must POST the canned reply to the existing input endpoint, + // and a view action must deep-link into the console. + if !strings.Contains(gotActions, "http,") { + t.Errorf("missing http action: %q", gotActions) + } + if !strings.Contains(gotActions, "http://host:8080/api/tasks/42/input") { + t.Errorf("action does not target input API: %q", gotActions) + } + if !strings.Contains(gotActions, "method=POST") || !strings.Contains(gotActions, `{"message":"continue"}`) { + t.Errorf("action missing POST/body: %q", gotActions) + } + if !strings.Contains(gotActions, "http://host:8080/m?task=42") { + t.Errorf("missing view deep link: %q", gotActions) + } +} + +func TestNtfyCustomReplyAndNoActionWhenNoBaseURL(t *testing.T) { + // Custom reply is honored. + var gotActions string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotActions = r.Header.Get("Actions") + })) + defer srv.Close() + n := New(mapStore{ + SettingEnabled: "true", + SettingTarget: srv.URL, + SettingURL: "http://host", + SettingReply: "yes go ahead", + }) + _ = n.Notify(Notification{Event: "auth_required", TaskID: 7, Title: "sign in"}) + if !strings.Contains(gotActions, `{"message":"yes go ahead"}`) { + t.Errorf("custom reply not honored: %q", gotActions) + } + + // With no base URL configured, there's no reachable endpoint, so no action. + var gotActions2 string + srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotActions2 = r.Header.Get("Actions") + })) + defer srv2.Close() + n2 := New(mapStore{SettingEnabled: "true", SettingTarget: srv2.URL}) + _ = n2.Notify(Notification{Event: "blocked", TaskID: 1, Title: "x"}) + if gotActions2 != "" { + t.Errorf("expected no action without a base URL, got %q", gotActions2) + } +}