diff --git a/cmd/task/completion.go b/cmd/task/completion.go index e0545af9..c7823d13 100644 --- a/cmd/task/completion.go +++ b/cmd/task/completion.go @@ -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 diff --git a/cmd/task/completion_test.go b/cmd/task/completion_test.go index acd1766f..aa02c6b6 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) != 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 diff --git a/cmd/task/main.go b/cmd/task/main.go index 631e47af..a3e9dd9f 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")) }, @@ -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), @@ -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")) @@ -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 } @@ -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://: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..386cb815 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,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. 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 5e33d4d8..6c027113 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..77958b2e --- /dev/null +++ b/internal/notify/notify.go @@ -0,0 +1,349 @@ +// 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. 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 ( + "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 + SettingReply = "notify_reply" // canned reply sent by the one-tap action +) + +// 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" + +// 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 { + 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 "" + } +} + +// 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 { + 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 +} + +// 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 deliver := n.Prepare(note); deliver != nil { + return deliver() + } + return nil +} + +// 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 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 nil, fmt.Errorf("notify_target is not set") + } + provider, _ := n.store.GetSetting(SettingProvider) + switch strings.TrimSpace(provider) { + case "", ProviderNtfy: + return n.buildNtfy(target, note) + case ProviderWebhook: + return n.buildWebhook(target, note) + default: + return nil, fmt.Errorf("unknown notify_provider %q (use %q or %q)", provider, ProviderNtfy, ProviderWebhook) + } +} + +// 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 == "" { + base, _ = n.store.GetSetting("server_url") + base = strings.TrimSpace(base) + } + if base == "" { + return "" + } + return strings.TrimRight(base, "/") +} + +// 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 != "" { + body = fmt.Sprintf("%s\n%s", note.Title, note.Message) + } + + req, err := http.NewRequest(http.MethodPost, target, strings.NewReader(body)) + if err != nil { + return nil, 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) + } + if h := n.actionsHeader(note); h != "" { + req.Header.Set("Actions", h) + } + return req, nil +} + +// 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 "" + } + base := n.baseURL() + if base == "" { + return "" + } + 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) buildWebhook(target string, note Notification) (*http.Request, 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 nil, err + } + req, err := http.NewRequest(http.MethodPost, target, bytes.NewReader(data)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + return req, 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..774c4bda --- /dev/null +++ b/internal/notify/notify_test.go @@ -0,0 +1,248 @@ +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") + } +} + +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) + } +} 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..63de067b --- /dev/null +++ b/internal/web/mobile.html @@ -0,0 +1,335 @@ + + + + + + +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..99850d8d --- /dev/null +++ b/internal/web/mobile_test.go @@ -0,0 +1,45 @@ +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", + "function md(", "function mdInline(", "<strong>"} { + 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())