From 2e2b2187b957e9af78797a67ec5c6874615fae02 Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sun, 8 Feb 2026 21:35:45 -1000 Subject: 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 --- internal/notify/notify.go | 95 ++++++++++++++++++++++++++++++++++++++++++ internal/notify/notify_test.go | 86 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 internal/notify/notify.go create mode 100644 internal/notify/notify_test.go (limited to 'internal/notify') 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 +} -- cgit v1.2.3