diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-02-08 21:35:45 -1000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-02-08 21:35:45 -1000 |
| commit | 2e2b2187b957e9af78797a67ec5c6874615fae02 (patch) | |
| tree | 1181dbb7e43f5d30cb025fa4d50fd4e7a2c893b3 /internal/notify | |
Initial project: task model, executor, API server, CLI, storage, reporter
Claudomator automation toolkit for Claude Code with:
- Task model with YAML parsing, validation, state machine (49 tests, 0 races)
- SQLite storage for tasks and executions
- Executor pool with bounded concurrency, timeout, cancellation
- REST API + WebSocket for mobile PWA integration
- Webhook/multi-notifier system
- CLI: init, run, serve, list, status commands
- Console, JSON, HTML reporters with cost tracking
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/notify')
| -rw-r--r-- | internal/notify/notify.go | 95 | ||||
| -rw-r--r-- | internal/notify/notify_test.go | 86 |
2 files changed, 181 insertions, 0 deletions
diff --git a/internal/notify/notify.go b/internal/notify/notify.go new file mode 100644 index 0000000..86e641f --- /dev/null +++ b/internal/notify/notify.go @@ -0,0 +1,95 @@ +package notify + +import ( + "bytes" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "time" +) + +// Notifier sends notifications when tasks complete. +type Notifier interface { + Notify(event Event) error +} + +// Event represents a task completion event. +type Event struct { + TaskID string `json:"task_id"` + TaskName string `json:"task_name"` + Status string `json:"status"` + CostUSD float64 `json:"cost_usd"` + Duration string `json:"duration"` + Error string `json:"error,omitempty"` +} + +// WebhookNotifier sends POST requests to a configured URL. +type WebhookNotifier struct { + URL string + client *http.Client + logger *slog.Logger +} + +func NewWebhookNotifier(url string, logger *slog.Logger) *WebhookNotifier { + return &WebhookNotifier{ + URL: url, + client: &http.Client{Timeout: 10 * time.Second}, + logger: logger, + } +} + +func (w *WebhookNotifier) Notify(event Event) error { + body, err := json.Marshal(event) + if err != nil { + return fmt.Errorf("marshaling event: %w", err) + } + + resp, err := w.client.Post(w.URL, "application/json", bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("sending webhook: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return fmt.Errorf("webhook returned %d", resp.StatusCode) + } + return nil +} + +// MultiNotifier fans out to multiple notifiers. +type MultiNotifier struct { + notifiers []Notifier + logger *slog.Logger +} + +func NewMultiNotifier(logger *slog.Logger, notifiers ...Notifier) *MultiNotifier { + return &MultiNotifier{notifiers: notifiers, logger: logger} +} + +func (m *MultiNotifier) Notify(event Event) error { + var lastErr error + for _, n := range m.notifiers { + if err := n.Notify(event); err != nil { + m.logger.Error("notification failed", "error", err) + lastErr = err + } + } + return lastErr +} + +// LogNotifier logs events (useful as a default/fallback). +type LogNotifier struct { + Logger *slog.Logger +} + +func (l *LogNotifier) Notify(event Event) error { + l.Logger.Info("task completed", + "task_id", event.TaskID, + "task_name", event.TaskName, + "status", event.Status, + "cost_usd", event.CostUSD, + "duration", event.Duration, + ) + return nil +} diff --git a/internal/notify/notify_test.go b/internal/notify/notify_test.go new file mode 100644 index 0000000..fcb5345 --- /dev/null +++ b/internal/notify/notify_test.go @@ -0,0 +1,86 @@ +package notify + +import ( + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "testing" +) + +func TestWebhookNotifier_Success(t *testing.T) { + var received Event + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + json.Unmarshal(body, &received) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + notifier := NewWebhookNotifier(server.URL, logger) + + event := Event{ + TaskID: "t-1", + TaskName: "Test", + Status: "COMPLETED", + CostUSD: 0.50, + Duration: "2m30s", + } + + if err := notifier.Notify(event); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if received.TaskID != "t-1" { + t.Errorf("task_id: want 't-1', got %q", received.TaskID) + } + if received.CostUSD != 0.50 { + t.Errorf("cost: want 0.50, got %f", received.CostUSD) + } +} + +func TestWebhookNotifier_ServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + notifier := NewWebhookNotifier(server.URL, logger) + + err := notifier.Notify(Event{TaskID: "t-1", Status: "COMPLETED"}) + if err == nil { + t.Fatal("expected error for 500 response") + } +} + +func TestMultiNotifier_FansOut(t *testing.T) { + var count int + counter := &countingNotifier{count: &count} + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + multi := NewMultiNotifier(logger, counter, counter, counter) + + multi.Notify(Event{TaskID: "t-1"}) + if count != 3 { + t.Errorf("want 3 notifications, got %d", count) + } +} + +func TestLogNotifier_NoError(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + notifier := &LogNotifier{Logger: logger} + if err := notifier.Notify(Event{TaskID: "t-1", Status: "COMPLETED"}); err != nil { + t.Errorf("unexpected error: %v", err) + } +} + +type countingNotifier struct { + count *int +} + +func (c *countingNotifier) Notify(_ Event) error { + *c.count++ + return nil +} |
