diff --git a/internal/analytics/dolt.go b/internal/analytics/dolt.go index 53ba16d..b75c29e 100644 --- a/internal/analytics/dolt.go +++ b/internal/analytics/dolt.go @@ -302,8 +302,13 @@ func (d *DB) initSchema(ctx context.Context) error { } // Migration: add ttl_seconds to notifications (v2). - // Ignore error — column may already exist. - _, _ = d.db.ExecContext(ctx, "ALTER TABLE notifications ADD COLUMN ttl_seconds INT DEFAULT 86400") + // Only ignore "duplicate column" errors (column already exists). + if _, err := d.db.ExecContext(ctx, "ALTER TABLE notifications ADD COLUMN ttl_seconds INT DEFAULT 86400"); err != nil { + errMsg := strings.ToLower(err.Error()) + if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") { + return fmt.Errorf("alter notifications add ttl_seconds: %w", err) + } + } // Commit any schema changes so the working tree starts clean. // On subsequent opens this will be a no-op (nothing to commit). @@ -323,6 +328,48 @@ func (d *DB) initSchema(ctx context.Context) error { return nil } +// --------------------------------------------------------------------------- +// Guard Block Summaries (Task 11.1 — SQL extraction) +// --------------------------------------------------------------------------- + +// GuardBlockSummary summarizes block events for a single tool pattern. +type GuardBlockSummary struct { + ToolName string + BlockCount int +} + +// GuardBlockSummaries queries for tools that appear frequently in PreToolUse +// events on the given day, returning those with 5 or more occurrences. +// +// The today parameter should be a date prefix like "2006-01-02". +func (d *DB) GuardBlockSummaries(ctx context.Context, today string) ([]GuardBlockSummary, error) { + rows, err := d.db.QueryContext(ctx, + `SELECT tool_name, COUNT(*) AS cnt + FROM events + WHERE event_type = 'PreToolUse' + AND timestamp LIKE ? + AND tool_name != '' + GROUP BY tool_name + HAVING cnt >= 5 + ORDER BY cnt DESC`, + today+"%", + ) + if err != nil { + return nil, fmt.Errorf("analytics: query guard blocks: %w", err) + } + defer rows.Close() + + var summaries []GuardBlockSummary + for rows.Next() { + var s GuardBlockSummary + if err := rows.Scan(&s.ToolName, &s.BlockCount); err != nil { + return nil, fmt.Errorf("analytics: scan guard blocks: %w", err) + } + summaries = append(summaries, s) + } + return summaries, rows.Err() +} + // schemaDDL contains the ten table definitions from the design doc. Each // statement is idempotent (CREATE TABLE IF NOT EXISTS). var schemaDDL = []string{ diff --git a/internal/analytics/state.go b/internal/analytics/state.go index 3d799f2..5199193 100644 --- a/internal/analytics/state.go +++ b/internal/analytics/state.go @@ -5,7 +5,6 @@ import ( "database/sql" "encoding/json" "fmt" - "path/filepath" "time" "github.com/vishnujayvel/hookwise/internal/core" @@ -371,23 +370,6 @@ func (d *DB) ReadAllFeedCache(ctx context.Context) (map[string]interface{}, erro return result, nil } -// WriteFeedCacheJSON writes the feed cache data to the JSON bridge file -// at ~/.hookwise/state/status-line-cache.json for the Python TUI (R9.1). -// -// The output format is: {"key1": , "key2": , ...} -// This function respects ARCH-3: it is called from the dispatch process, -// not the daemon. -func WriteFeedCacheJSON(cacheData map[string]interface{}) error { - path := filepath.Join(core.GetStateDir(), "state", "status-line-cache.json") - return core.AtomicWriteJSON(path, cacheData) -} - -// WriteFeedCacheJSONTo writes the feed cache data to a specified path. -// This variant is used in tests to avoid writing to the real state directory. -func WriteFeedCacheJSONTo(path string, cacheData map[string]interface{}) error { - return core.AtomicWriteJSON(path, cacheData) -} - // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- diff --git a/internal/analytics/state_test.go b/internal/analytics/state_test.go index 72967da..ea1dab0 100644 --- a/internal/analytics/state_test.go +++ b/internal/analytics/state_test.go @@ -2,9 +2,6 @@ package analytics import ( "context" - "encoding/json" - "os" - "path/filepath" "testing" "time" @@ -439,90 +436,7 @@ func TestReadAllFeedCache_MultipleEntries(t *testing.T) { assert.Equal(t, true, sessionData["active"]) } -// Test 15: WriteFeedCacheJSON creates JSON file at correct path -func TestWriteFeedCacheJSON_CreatesFile(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "hookwise-feed-json-*") - require.NoError(t, err) - defer os.RemoveAll(tmpDir) - - outPath := filepath.Join(tmpDir, "status-line-cache.json") - - cacheData := map[string]interface{}{ - "weather": map[string]interface{}{"temp": float64(72)}, - "costs": map[string]interface{}{"total": float64(5.5)}, - } - - require.NoError(t, WriteFeedCacheJSONTo(outPath, cacheData)) - - // Verify file exists. - info, err := os.Stat(outPath) - require.NoError(t, err) - assert.True(t, info.Size() > 0) -} - -// Test 16: WriteFeedCacheJSON file content matches expected format -func TestWriteFeedCacheJSON_ContentFormat(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "hookwise-feed-json-*") - require.NoError(t, err) - defer os.RemoveAll(tmpDir) - - outPath := filepath.Join(tmpDir, "status-line-cache.json") - - cacheData := map[string]interface{}{ - "weather": map[string]interface{}{ - "temperature": float64(72), - "unit": "F", - }, - "session_info": map[string]interface{}{ - "active": true, - "duration": float64(300), - }, - } - - require.NoError(t, WriteFeedCacheJSONTo(outPath, cacheData)) - - // Read the file back and verify structure. - raw, err := os.ReadFile(outPath) - require.NoError(t, err) - - var parsed map[string]interface{} - require.NoError(t, json.Unmarshal(raw, &parsed)) - - // Top-level keys should be the cache keys. - assert.Contains(t, parsed, "weather") - assert.Contains(t, parsed, "session_info") - - // Verify nested structure. - weather, ok := parsed["weather"].(map[string]interface{}) - require.True(t, ok, "weather should be a map") - assert.Equal(t, float64(72), weather["temperature"]) - assert.Equal(t, "F", weather["unit"]) - - session, ok := parsed["session_info"].(map[string]interface{}) - require.True(t, ok, "session_info should be a map") - assert.Equal(t, true, session["active"]) - assert.Equal(t, float64(300), session["duration"]) -} - -// Test 17: WriteFeedCacheJSON with empty map creates valid JSON -func TestWriteFeedCacheJSON_EmptyMap(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "hookwise-feed-json-*") - require.NoError(t, err) - defer os.RemoveAll(tmpDir) - - outPath := filepath.Join(tmpDir, "status-line-cache.json") - - require.NoError(t, WriteFeedCacheJSONTo(outPath, map[string]interface{}{})) - - raw, err := os.ReadFile(outPath) - require.NoError(t, err) - - var parsed map[string]interface{} - require.NoError(t, json.Unmarshal(raw, &parsed)) - assert.Empty(t, parsed) -} - -// Test 18: Feed cache overwrite replaces existing entry +// Test 15: Feed cache overwrite replaces existing entry func TestFeedCache_OverwriteReplaces(t *testing.T) { db, cleanup := testOpen(t) defer cleanup() diff --git a/internal/core/analytics_types.go b/internal/core/analytics_types.go new file mode 100644 index 0000000..2612300 --- /dev/null +++ b/internal/core/analytics_types.go @@ -0,0 +1,51 @@ +package core + +// --- Analytics Types --- + +type AIClassification string + +const ( + AIClassHighProbability AIClassification = "high_probability_ai" + AIClassLikelyAI AIClassification = "likely_ai" + AIClassMixedVerified AIClassification = "mixed_verified" + AIClassHumanAuthored AIClassification = "human_authored" +) + +type AIConfidenceScore struct { + Score float64 `json:"score"` + Classification AIClassification `json:"classification"` +} + +type AnalyticsEvent struct { + SessionID string `json:"sessionId"` + EventType string `json:"eventType"` + ToolName string `json:"toolName,omitempty"` + Timestamp string `json:"timestamp"` + FilePath string `json:"filePath,omitempty"` + LinesAdded int `json:"linesAdded,omitempty"` + LinesRemoved int `json:"linesRemoved,omitempty"` + AIConfidenceScore *float64 `json:"aiConfidenceScore,omitempty"` +} + +type SessionSummary struct { + TotalToolCalls int `json:"totalToolCalls"` + FileEditsCount int `json:"fileEditsCount"` + AIAuthoredLines int `json:"aiAuthoredLines"` + HumanVerifiedLines int `json:"humanVerifiedLines"` + Classification string `json:"classification,omitempty"` + EstimatedCostUSD float64 `json:"estimatedCostUsd,omitempty"` +} + +type AuthorshipSummary struct { + TotalEntries int `json:"totalEntries"` + TotalLinesChanged int `json:"totalLinesChanged"` + WeightedAIScore float64 `json:"weightedAIScore"` + ClassificationBreakdown map[AIClassification]int `json:"classificationBreakdown"` +} + +type StatsOptions struct { + SessionID string `json:"sessionId,omitempty"` + Days int `json:"days,omitempty"` + From string `json:"from,omitempty"` + To string `json:"to,omitempty"` +} diff --git a/internal/core/config_types.go b/internal/core/config_types.go new file mode 100644 index 0000000..4743d17 --- /dev/null +++ b/internal/core/config_types.go @@ -0,0 +1,136 @@ +package core + +// --- Config Types --- + +// DispatchConfig holds settings for the dispatch pipeline. +type DispatchConfig struct { + TimeoutMs int `yaml:"timeout_ms" json:"timeoutMs"` +} + +// HooksConfig is the top-level configuration structure. +type HooksConfig struct { + Version int `yaml:"version" json:"version"` + Guards []GuardRuleConfig `yaml:"guards" json:"guards"` + Coaching CoachingConfig `yaml:"coaching" json:"coaching"` + Analytics AnalyticsConfig `yaml:"analytics" json:"analytics"` + Greeting GreetingConfig `yaml:"greeting" json:"greeting"` + Sounds SoundsConfig `yaml:"sounds" json:"sounds"` + StatusLine StatusLineConfig `yaml:"status_line" json:"statusLine"` + CostTracking CostTrackingConfig `yaml:"cost_tracking" json:"costTracking"` + TranscriptBackup TranscriptConfig `yaml:"transcript_backup" json:"transcriptBackup"` + Handlers []CustomHandlerConfig `yaml:"handlers" json:"handlers"` + Settings SettingsConfig `yaml:"settings" json:"settings"` + Includes []string `yaml:"includes" json:"includes"` + Feeds FeedsConfig `yaml:"feeds" json:"feeds"` + Daemon DaemonConfig `yaml:"daemon" json:"daemon"` + TUI TUIConfig `yaml:"tui" json:"tui"` + Dispatch DispatchConfig `yaml:"dispatch" json:"dispatch"` +} + +type CoachingConfig struct { + Metacognition MetacognitionConfig `yaml:"metacognition" json:"metacognition"` + BuilderTrap BuilderTrapConfig `yaml:"builder_trap" json:"builderTrap"` + Communication CommunicationConfig `yaml:"communication" json:"communication"` +} + +type MetacognitionConfig struct { + Enabled bool `yaml:"enabled" json:"enabled"` + IntervalSeconds int `yaml:"interval_seconds" json:"intervalSeconds"` + PromptsFile string `yaml:"prompts_file,omitempty" json:"promptsFile,omitempty"` +} + +type BuilderTrapConfig struct { + Enabled bool `yaml:"enabled" json:"enabled"` + Thresholds BuilderTrapThresholds `yaml:"thresholds" json:"thresholds"` + ToolingPatterns []string `yaml:"tooling_patterns" json:"toolingPatterns"` + PracticeTools []string `yaml:"practice_tools" json:"practiceTools"` +} + +type BuilderTrapThresholds struct { + Yellow int `yaml:"yellow" json:"yellow"` + Orange int `yaml:"orange" json:"orange"` + Red int `yaml:"red" json:"red"` +} + +type CommunicationConfig struct { + Enabled bool `yaml:"enabled" json:"enabled"` + Frequency int `yaml:"frequency" json:"frequency"` + MinLength int `yaml:"min_length" json:"minLength"` + Rules []string `yaml:"rules" json:"rules"` + Tone string `yaml:"tone" json:"tone"` // "gentle", "direct", "silent" +} + +type AnalyticsConfig struct { + Enabled bool `yaml:"enabled" json:"enabled"` + DBPath string `yaml:"db_path,omitempty" json:"dbPath,omitempty"` +} + +type GreetingConfig struct { + Enabled bool `yaml:"enabled" json:"enabled"` + QuotesFile string `yaml:"quotes_file,omitempty" json:"quotesFile,omitempty"` + Categories map[string]QuoteCategoryConfig `yaml:"categories,omitempty" json:"categories,omitempty"` +} + +type QuoteCategoryConfig struct { + Weight int `yaml:"weight" json:"weight"` + Quotes []QuoteEntry `yaml:"quotes" json:"quotes"` +} + +type QuoteEntry struct { + Text string `yaml:"text" json:"text"` + Author string `yaml:"author,omitempty" json:"author,omitempty"` +} + +type SoundsConfig struct { + Enabled bool `yaml:"enabled" json:"enabled"` + Notification string `yaml:"notification,omitempty" json:"notification,omitempty"` + Completion string `yaml:"completion,omitempty" json:"completion,omitempty"` +} + +type StatusLineConfig struct { + Enabled bool `yaml:"enabled" json:"enabled"` + Segments []SegmentConfig `yaml:"segments" json:"segments"` + Delimiter string `yaml:"delimiter" json:"delimiter"` + CachePath string `yaml:"cache_path" json:"cachePath"` +} + +type CostTrackingConfig struct { + Enabled bool `yaml:"enabled" json:"enabled"` + Rates map[string]float64 `yaml:"rates" json:"rates"` + DailyBudget float64 `yaml:"daily_budget" json:"dailyBudget"` + Enforcement string `yaml:"enforcement" json:"enforcement"` // "warn" or "enforce" +} + +type TranscriptConfig struct { + Enabled bool `yaml:"enabled" json:"enabled"` + BackupDir string `yaml:"backup_dir" json:"backupDir"` + MaxSizeMB int `yaml:"max_size_mb" json:"maxSizeMb"` +} + +type SettingsConfig struct { + LogLevel string `yaml:"log_level" json:"logLevel"` // "debug", "info", "warn", "error" + HandlerTimeoutSeconds int `yaml:"handler_timeout_seconds" json:"handlerTimeoutSeconds"` + StateDir string `yaml:"state_dir" json:"stateDir"` +} + +type CustomHandlerConfig struct { + Name string `yaml:"name" json:"name"` + Type string `yaml:"type" json:"type"` // "builtin", "script", "inline" + Events []string `yaml:"events" json:"events"` + Phase string `yaml:"phase,omitempty" json:"phase,omitempty"` + Timeout int `yaml:"timeout,omitempty" json:"timeout,omitempty"` + Module string `yaml:"module,omitempty" json:"module,omitempty"` + Command string `yaml:"command,omitempty" json:"command,omitempty"` + Action map[string]interface{} `yaml:"action,omitempty" json:"action,omitempty"` +} + +type DaemonConfig struct { + AutoStart bool `yaml:"auto_start" json:"autoStart"` + InactivityTimeoutMinutes int `yaml:"inactivity_timeout_minutes" json:"inactivityTimeoutMinutes"` + LogFile string `yaml:"log_file" json:"logFile"` +} + +type TUIConfig struct { + AutoLaunch bool `yaml:"auto_launch" json:"autoLaunch"` + LaunchMethod string `yaml:"launch_method" json:"launchMethod"` // "newWindow" or "background" +} diff --git a/internal/core/dispatch_types.go b/internal/core/dispatch_types.go new file mode 100644 index 0000000..46e93f0 --- /dev/null +++ b/internal/core/dispatch_types.go @@ -0,0 +1,29 @@ +package core + +// HookPayload is the JSON payload piped to stdin by Claude Code for each hook invocation. +type HookPayload struct { + SessionID string `json:"session_id"` + ToolName string `json:"tool_name,omitempty"` + ToolInput map[string]interface{} `json:"tool_input,omitempty"` + Extra map[string]interface{} `json:"-"` // captures unknown fields +} + +// IsValidPayload checks that the payload has the required session_id field. +func (p *HookPayload) IsValidPayload() bool { + return p.SessionID != "" +} + +// DispatchResult is the output of the dispatch pipeline. +type DispatchResult struct { + Stdout *string `json:"stdout"` + Stderr *string `json:"stderr"` + ExitCode int `json:"exitCode"` // 0 or 2 +} + +// HandlerResult is the output of a single handler execution. +type HandlerResult struct { + Decision *string `json:"decision"` // "block", "warn", "confirm", or nil + Reason *string `json:"reason"` + AdditionalContext *string `json:"additionalContext"` + Output map[string]interface{} `json:"output"` +} diff --git a/internal/core/feed_types.go b/internal/core/feed_types.go new file mode 100644 index 0000000..da38c58 --- /dev/null +++ b/internal/core/feed_types.go @@ -0,0 +1,78 @@ +package core + +// --- Feed Platform Types --- + +type CacheEntry struct { + UpdatedAt string `json:"updated_at"` + TTLSeconds int `json:"ttl_seconds"` +} + +type FeedDefinition struct { + Name string `json:"name"` + IntervalSeconds int `json:"intervalSeconds"` + Enabled bool `json:"enabled"` +} + +type ProjectFeedConfig struct { + Enabled bool `yaml:"enabled" json:"enabled"` + IntervalSeconds int `yaml:"interval_seconds" json:"intervalSeconds"` + ShowBranch bool `yaml:"show_branch" json:"showBranch"` + ShowLastCommit bool `yaml:"show_last_commit" json:"showLastCommit"` +} + +type CalendarFeedConfig struct { + Enabled bool `yaml:"enabled" json:"enabled"` + IntervalSeconds int `yaml:"interval_seconds" json:"intervalSeconds"` + LookaheadMinutes int `yaml:"lookahead_minutes" json:"lookaheadMinutes"` + Calendars []string `yaml:"calendars" json:"calendars"` + CredentialsPath string `yaml:"credentials_path" json:"credentialsPath"` + TokenPath string `yaml:"token_path" json:"tokenPath"` +} + +type NewsFeedConfig struct { + Enabled bool `yaml:"enabled" json:"enabled"` + Source string `yaml:"source" json:"source"` // "hackernews" or "rss" + RSSUrl string `yaml:"rss_url" json:"rssUrl"` + IntervalSeconds int `yaml:"interval_seconds" json:"intervalSeconds"` + MaxStories int `yaml:"max_stories" json:"maxStories"` + RotationMinutes int `yaml:"rotation_minutes" json:"rotationMinutes"` +} + +type CustomFeedConfig struct { + Name string `yaml:"name" json:"name"` + Command string `yaml:"command" json:"command"` + IntervalSeconds int `yaml:"interval_seconds" json:"intervalSeconds"` + Enabled bool `yaml:"enabled" json:"enabled"` + TimeoutSeconds int `yaml:"timeout_seconds" json:"timeoutSeconds"` +} + +type InsightsFeedConfig struct { + Enabled bool `yaml:"enabled" json:"enabled"` + IntervalSeconds int `yaml:"interval_seconds" json:"intervalSeconds"` + StalenessDays int `yaml:"staleness_days" json:"stalenessDays"` + UsageDataPath string `yaml:"usage_data_path" json:"usageDataPath"` +} + +type WeatherFeedConfig struct { + Enabled bool `yaml:"enabled" json:"enabled"` + IntervalSeconds int `yaml:"interval_seconds" json:"intervalSeconds"` + Latitude float64 `yaml:"latitude" json:"latitude"` + Longitude float64 `yaml:"longitude" json:"longitude"` + TemperatureUnit string `yaml:"temperature_unit" json:"temperatureUnit"` // "fahrenheit" or "celsius" +} + +type MemoriesFeedConfig struct { + Enabled bool `yaml:"enabled" json:"enabled"` + IntervalSeconds int `yaml:"interval_seconds" json:"intervalSeconds"` + DBPath string `yaml:"db_path" json:"dbPath"` +} + +type FeedsConfig struct { + Project ProjectFeedConfig `yaml:"project" json:"project"` + Calendar CalendarFeedConfig `yaml:"calendar" json:"calendar"` + News NewsFeedConfig `yaml:"news" json:"news"` + Insights InsightsFeedConfig `yaml:"insights" json:"insights"` + Weather WeatherFeedConfig `yaml:"weather" json:"weather"` + Memories MemoriesFeedConfig `yaml:"memories" json:"memories"` + Custom []CustomFeedConfig `yaml:"custom" json:"custom"` +} diff --git a/internal/core/guard_types.go b/internal/core/guard_types.go new file mode 100644 index 0000000..41276c0 --- /dev/null +++ b/internal/core/guard_types.go @@ -0,0 +1,23 @@ +package core + +// --- Guard Types --- + +type GuardRuleConfig struct { + Match string `yaml:"match" json:"match"` + Action string `yaml:"action" json:"action"` // "block", "warn", "confirm" + Reason string `yaml:"reason" json:"reason"` + When string `yaml:"when,omitempty" json:"when,omitempty"` + Unless string `yaml:"unless,omitempty" json:"unless,omitempty"` +} + +type GuardResult struct { + Action string `json:"action"` // "allow", "block", "warn", "confirm" + Reason string `json:"reason,omitempty"` + MatchedRule *GuardRuleConfig `json:"matchedRule,omitempty"` +} + +type ParsedCondition struct { + FieldPath string `json:"fieldPath"` + Operator string `json:"operator"` // "contains", "starts_with", "ends_with", "==", "equals", "matches" + Value string `json:"value"` +} diff --git a/internal/core/handler_types.go b/internal/core/handler_types.go new file mode 100644 index 0000000..d572a17 --- /dev/null +++ b/internal/core/handler_types.go @@ -0,0 +1,52 @@ +package core + +// --- Handler Types --- + +type ResolvedHandler struct { + Name string + HandlerType string // "builtin", "script", "inline" + Events []string // event types this handler listens for + Module string + Command string + Action map[string]interface{} + Timeout int // milliseconds + Phase string // "guard", "context", "side_effect" + ConfigRaw map[string]interface{} +} + +// HasEvent returns true if the handler listens for the given event type. +func (h *ResolvedHandler) HasEvent(eventType string) bool { + for _, e := range h.Events { + if e == eventType { + return true + } + } + return false +} + +// --- Segment Config --- + +type SegmentConfig struct { + Builtin string `yaml:"builtin,omitempty" json:"builtin,omitempty"` + Custom *CustomSegmentConfig `yaml:"custom,omitempty" json:"custom,omitempty"` +} + +// UnmarshalYAML allows SegmentConfig to accept both a plain string +// (e.g., "- session") and a full struct (e.g., {builtin: "session"}). +func (s *SegmentConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { + // Try plain string first. + var str string + if err := unmarshal(&str); err == nil { + s.Builtin = str + return nil + } + // Fall back to struct. + type raw SegmentConfig + return unmarshal((*raw)(s)) +} + +type CustomSegmentConfig struct { + Command string `yaml:"command" json:"command"` + Label string `yaml:"label,omitempty" json:"label,omitempty"` + Timeout int `yaml:"timeout,omitempty" json:"timeout,omitempty"` +} diff --git a/internal/core/types.go b/internal/core/types.go index 855e639..dcefe8a 100644 --- a/internal/core/types.go +++ b/internal/core/types.go @@ -45,231 +45,6 @@ func IsEventType(value string) bool { return false } -// HookPayload is the JSON payload piped to stdin by Claude Code for each hook invocation. -type HookPayload struct { - SessionID string `json:"session_id"` - ToolName string `json:"tool_name,omitempty"` - ToolInput map[string]interface{} `json:"tool_input,omitempty"` - Extra map[string]interface{} `json:"-"` // captures unknown fields -} - -// IsValidPayload checks that the payload has the required session_id field. -func (p *HookPayload) IsValidPayload() bool { - return p.SessionID != "" -} - -// DispatchResult is the output of the dispatch pipeline. -type DispatchResult struct { - Stdout *string `json:"stdout"` - Stderr *string `json:"stderr"` - ExitCode int `json:"exitCode"` // 0 or 2 -} - -// HandlerResult is the output of a single handler execution. -type HandlerResult struct { - Decision *string `json:"decision"` // "block", "warn", "confirm", or nil - Reason *string `json:"reason"` - AdditionalContext *string `json:"additionalContext"` - Output map[string]interface{} `json:"output"` -} - -// --- Config Types --- - -// DispatchConfig holds settings for the dispatch pipeline. -type DispatchConfig struct { - TimeoutMs int `yaml:"timeout_ms" json:"timeoutMs"` -} - -// HooksConfig is the top-level configuration structure. -type HooksConfig struct { - Version int `yaml:"version" json:"version"` - Guards []GuardRuleConfig `yaml:"guards" json:"guards"` - Coaching CoachingConfig `yaml:"coaching" json:"coaching"` - Analytics AnalyticsConfig `yaml:"analytics" json:"analytics"` - Greeting GreetingConfig `yaml:"greeting" json:"greeting"` - Sounds SoundsConfig `yaml:"sounds" json:"sounds"` - StatusLine StatusLineConfig `yaml:"status_line" json:"statusLine"` - CostTracking CostTrackingConfig `yaml:"cost_tracking" json:"costTracking"` - TranscriptBackup TranscriptConfig `yaml:"transcript_backup" json:"transcriptBackup"` - Handlers []CustomHandlerConfig `yaml:"handlers" json:"handlers"` - Settings SettingsConfig `yaml:"settings" json:"settings"` - Includes []string `yaml:"includes" json:"includes"` - Feeds FeedsConfig `yaml:"feeds" json:"feeds"` - Daemon DaemonConfig `yaml:"daemon" json:"daemon"` - TUI TUIConfig `yaml:"tui" json:"tui"` - Dispatch DispatchConfig `yaml:"dispatch" json:"dispatch"` -} - -type CoachingConfig struct { - Metacognition MetacognitionConfig `yaml:"metacognition" json:"metacognition"` - BuilderTrap BuilderTrapConfig `yaml:"builder_trap" json:"builderTrap"` - Communication CommunicationConfig `yaml:"communication" json:"communication"` -} - -type MetacognitionConfig struct { - Enabled bool `yaml:"enabled" json:"enabled"` - IntervalSeconds int `yaml:"interval_seconds" json:"intervalSeconds"` - PromptsFile string `yaml:"prompts_file,omitempty" json:"promptsFile,omitempty"` -} - -type BuilderTrapConfig struct { - Enabled bool `yaml:"enabled" json:"enabled"` - Thresholds BuilderTrapThresholds `yaml:"thresholds" json:"thresholds"` - ToolingPatterns []string `yaml:"tooling_patterns" json:"toolingPatterns"` - PracticeTools []string `yaml:"practice_tools" json:"practiceTools"` -} - -type BuilderTrapThresholds struct { - Yellow int `yaml:"yellow" json:"yellow"` - Orange int `yaml:"orange" json:"orange"` - Red int `yaml:"red" json:"red"` -} - -type CommunicationConfig struct { - Enabled bool `yaml:"enabled" json:"enabled"` - Frequency int `yaml:"frequency" json:"frequency"` - MinLength int `yaml:"min_length" json:"minLength"` - Rules []string `yaml:"rules" json:"rules"` - Tone string `yaml:"tone" json:"tone"` // "gentle", "direct", "silent" -} - -type AnalyticsConfig struct { - Enabled bool `yaml:"enabled" json:"enabled"` - DBPath string `yaml:"db_path,omitempty" json:"dbPath,omitempty"` -} - -type GreetingConfig struct { - Enabled bool `yaml:"enabled" json:"enabled"` - QuotesFile string `yaml:"quotes_file,omitempty" json:"quotesFile,omitempty"` - Categories map[string]QuoteCategoryConfig `yaml:"categories,omitempty" json:"categories,omitempty"` -} - -type QuoteCategoryConfig struct { - Weight int `yaml:"weight" json:"weight"` - Quotes []QuoteEntry `yaml:"quotes" json:"quotes"` -} - -type QuoteEntry struct { - Text string `yaml:"text" json:"text"` - Author string `yaml:"author,omitempty" json:"author,omitempty"` -} - -type SoundsConfig struct { - Enabled bool `yaml:"enabled" json:"enabled"` - Notification string `yaml:"notification,omitempty" json:"notification,omitempty"` - Completion string `yaml:"completion,omitempty" json:"completion,omitempty"` -} - -type StatusLineConfig struct { - Enabled bool `yaml:"enabled" json:"enabled"` - Segments []SegmentConfig `yaml:"segments" json:"segments"` - Delimiter string `yaml:"delimiter" json:"delimiter"` - CachePath string `yaml:"cache_path" json:"cachePath"` -} - -type CostTrackingConfig struct { - Enabled bool `yaml:"enabled" json:"enabled"` - Rates map[string]float64 `yaml:"rates" json:"rates"` - DailyBudget float64 `yaml:"daily_budget" json:"dailyBudget"` - Enforcement string `yaml:"enforcement" json:"enforcement"` // "warn" or "enforce" -} - -type TranscriptConfig struct { - Enabled bool `yaml:"enabled" json:"enabled"` - BackupDir string `yaml:"backup_dir" json:"backupDir"` - MaxSizeMB int `yaml:"max_size_mb" json:"maxSizeMb"` -} - -type SettingsConfig struct { - LogLevel string `yaml:"log_level" json:"logLevel"` // "debug", "info", "warn", "error" - HandlerTimeoutSeconds int `yaml:"handler_timeout_seconds" json:"handlerTimeoutSeconds"` - StateDir string `yaml:"state_dir" json:"stateDir"` -} - -// --- Guard Types --- - -type GuardRuleConfig struct { - Match string `yaml:"match" json:"match"` - Action string `yaml:"action" json:"action"` // "block", "warn", "confirm" - Reason string `yaml:"reason" json:"reason"` - When string `yaml:"when,omitempty" json:"when,omitempty"` - Unless string `yaml:"unless,omitempty" json:"unless,omitempty"` -} - -type GuardResult struct { - Action string `json:"action"` // "allow", "block", "warn", "confirm" - Reason string `json:"reason,omitempty"` - MatchedRule *GuardRuleConfig `json:"matchedRule,omitempty"` -} - -type ParsedCondition struct { - FieldPath string `json:"fieldPath"` - Operator string `json:"operator"` // "contains", "starts_with", "ends_with", "==", "equals", "matches" - Value string `json:"value"` -} - -// --- Handler Types --- - -type ResolvedHandler struct { - Name string - HandlerType string // "builtin", "script", "inline" - Events []string // event types this handler listens for - Module string - Command string - Action map[string]interface{} - Timeout int // milliseconds - Phase string // "guard", "context", "side_effect" - ConfigRaw map[string]interface{} -} - -// HasEvent returns true if the handler listens for the given event type. -func (h *ResolvedHandler) HasEvent(eventType string) bool { - for _, e := range h.Events { - if e == eventType { - return true - } - } - return false -} - -type CustomHandlerConfig struct { - Name string `yaml:"name" json:"name"` - Type string `yaml:"type" json:"type"` // "builtin", "script", "inline" - Events []string `yaml:"events" json:"events"` - Phase string `yaml:"phase,omitempty" json:"phase,omitempty"` - Timeout int `yaml:"timeout,omitempty" json:"timeout,omitempty"` - Module string `yaml:"module,omitempty" json:"module,omitempty"` - Command string `yaml:"command,omitempty" json:"command,omitempty"` - Action map[string]interface{} `yaml:"action,omitempty" json:"action,omitempty"` -} - -// --- Segment Config --- - -type SegmentConfig struct { - Builtin string `yaml:"builtin,omitempty" json:"builtin,omitempty"` - Custom *CustomSegmentConfig `yaml:"custom,omitempty" json:"custom,omitempty"` -} - -// UnmarshalYAML allows SegmentConfig to accept both a plain string -// (e.g., "- session") and a full struct (e.g., {builtin: "session"}). -func (s *SegmentConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { - // Try plain string first. - var str string - if err := unmarshal(&str); err == nil { - s.Builtin = str - return nil - } - // Fall back to struct. - type raw SegmentConfig - return unmarshal((*raw)(s)) -} - -type CustomSegmentConfig struct { - Command string `yaml:"command" json:"command"` - Label string `yaml:"label,omitempty" json:"label,omitempty"` - Timeout int `yaml:"timeout,omitempty" json:"timeout,omitempty"` -} - // --- Validation Types --- type ValidationResult struct { @@ -283,62 +58,12 @@ type ValidationError struct { Suggestion string `json:"suggestion,omitempty"` } -// --- Analytics Types --- - -type AIClassification string - -const ( - AIClassHighProbability AIClassification = "high_probability_ai" - AIClassLikelyAI AIClassification = "likely_ai" - AIClassMixedVerified AIClassification = "mixed_verified" - AIClassHumanAuthored AIClassification = "human_authored" -) - -type AIConfidenceScore struct { - Score float64 `json:"score"` - Classification AIClassification `json:"classification"` -} - -type AnalyticsEvent struct { - SessionID string `json:"sessionId"` - EventType string `json:"eventType"` - ToolName string `json:"toolName,omitempty"` - Timestamp string `json:"timestamp"` - FilePath string `json:"filePath,omitempty"` - LinesAdded int `json:"linesAdded,omitempty"` - LinesRemoved int `json:"linesRemoved,omitempty"` - AIConfidenceScore *float64 `json:"aiConfidenceScore,omitempty"` -} - -type SessionSummary struct { - TotalToolCalls int `json:"totalToolCalls"` - FileEditsCount int `json:"fileEditsCount"` - AIAuthoredLines int `json:"aiAuthoredLines"` - HumanVerifiedLines int `json:"humanVerifiedLines"` - Classification string `json:"classification,omitempty"` - EstimatedCostUSD float64 `json:"estimatedCostUsd,omitempty"` -} - -type AuthorshipSummary struct { - TotalEntries int `json:"totalEntries"` - TotalLinesChanged int `json:"totalLinesChanged"` - WeightedAIScore float64 `json:"weightedAIScore"` - ClassificationBreakdown map[AIClassification]int `json:"classificationBreakdown"` -} - -type StatsOptions struct { - SessionID string `json:"sessionId,omitempty"` - Days int `json:"days,omitempty"` - From string `json:"from,omitempty"` - To string `json:"to,omitempty"` -} - // --- Coaching Types --- // // Runtime coaching types (Mode, AlertLevel, CoachingCache, etc.) have been // extracted to internal/coaching/types.go. Config types (CoachingConfig, -// MetacognitionConfig, BuilderTrapConfig, etc.) remain here to avoid -// circular imports. +// MetacognitionConfig, BuilderTrapConfig, etc.) remain in config_types.go +// to avoid circular imports. // --- Cost Types --- @@ -372,91 +97,3 @@ type ScenarioResult struct { GuardResult GuardResult `json:"guardResult"` Passed bool `json:"passed"` } - -// --- Feed Platform Types --- - -type CacheEntry struct { - UpdatedAt string `json:"updated_at"` - TTLSeconds int `json:"ttl_seconds"` -} - -type FeedDefinition struct { - Name string `json:"name"` - IntervalSeconds int `json:"intervalSeconds"` - Enabled bool `json:"enabled"` -} - -type ProjectFeedConfig struct { - Enabled bool `yaml:"enabled" json:"enabled"` - IntervalSeconds int `yaml:"interval_seconds" json:"intervalSeconds"` - ShowBranch bool `yaml:"show_branch" json:"showBranch"` - ShowLastCommit bool `yaml:"show_last_commit" json:"showLastCommit"` -} - -type CalendarFeedConfig struct { - Enabled bool `yaml:"enabled" json:"enabled"` - IntervalSeconds int `yaml:"interval_seconds" json:"intervalSeconds"` - LookaheadMinutes int `yaml:"lookahead_minutes" json:"lookaheadMinutes"` - Calendars []string `yaml:"calendars" json:"calendars"` - CredentialsPath string `yaml:"credentials_path" json:"credentialsPath"` - TokenPath string `yaml:"token_path" json:"tokenPath"` -} - -type NewsFeedConfig struct { - Enabled bool `yaml:"enabled" json:"enabled"` - Source string `yaml:"source" json:"source"` // "hackernews" or "rss" - RSSUrl string `yaml:"rss_url" json:"rssUrl"` - IntervalSeconds int `yaml:"interval_seconds" json:"intervalSeconds"` - MaxStories int `yaml:"max_stories" json:"maxStories"` - RotationMinutes int `yaml:"rotation_minutes" json:"rotationMinutes"` -} - -type CustomFeedConfig struct { - Name string `yaml:"name" json:"name"` - Command string `yaml:"command" json:"command"` - IntervalSeconds int `yaml:"interval_seconds" json:"intervalSeconds"` - Enabled bool `yaml:"enabled" json:"enabled"` - TimeoutSeconds int `yaml:"timeout_seconds" json:"timeoutSeconds"` -} - -type InsightsFeedConfig struct { - Enabled bool `yaml:"enabled" json:"enabled"` - IntervalSeconds int `yaml:"interval_seconds" json:"intervalSeconds"` - StalenessDays int `yaml:"staleness_days" json:"stalenessDays"` - UsageDataPath string `yaml:"usage_data_path" json:"usageDataPath"` -} - -type WeatherFeedConfig struct { - Enabled bool `yaml:"enabled" json:"enabled"` - IntervalSeconds int `yaml:"interval_seconds" json:"intervalSeconds"` - Latitude float64 `yaml:"latitude" json:"latitude"` - Longitude float64 `yaml:"longitude" json:"longitude"` - TemperatureUnit string `yaml:"temperature_unit" json:"temperatureUnit"` // "fahrenheit" or "celsius" -} - -type MemoriesFeedConfig struct { - Enabled bool `yaml:"enabled" json:"enabled"` - IntervalSeconds int `yaml:"interval_seconds" json:"intervalSeconds"` - DBPath string `yaml:"db_path" json:"dbPath"` -} - -type FeedsConfig struct { - Project ProjectFeedConfig `yaml:"project" json:"project"` - Calendar CalendarFeedConfig `yaml:"calendar" json:"calendar"` - News NewsFeedConfig `yaml:"news" json:"news"` - Insights InsightsFeedConfig `yaml:"insights" json:"insights"` - Weather WeatherFeedConfig `yaml:"weather" json:"weather"` - Memories MemoriesFeedConfig `yaml:"memories" json:"memories"` - Custom []CustomFeedConfig `yaml:"custom" json:"custom"` -} - -type DaemonConfig struct { - AutoStart bool `yaml:"auto_start" json:"autoStart"` - InactivityTimeoutMinutes int `yaml:"inactivity_timeout_minutes" json:"inactivityTimeoutMinutes"` - LogFile string `yaml:"log_file" json:"logFile"` -} - -type TUIConfig struct { - AutoLaunch bool `yaml:"auto_launch" json:"autoLaunch"` - LaunchMethod string `yaml:"launch_method" json:"launchMethod"` // "newWindow" or "background" -} diff --git a/internal/feeds/producer_calendar.go b/internal/feeds/producer_calendar.go index 3435e10..08b8fe1 100644 --- a/internal/feeds/producer_calendar.go +++ b/internal/feeds/producer_calendar.go @@ -90,18 +90,10 @@ func loadCalendarOAuthClient(ctx context.Context, tokenPath string) (*http.Clien TokenType: "Bearer", } - // Parse expiry if present. + // Parse expiry if present — use canonical flexible parser. if ptf.Expiry != "" { - for _, layout := range []string{ - time.RFC3339, - "2006-01-02T15:04:05.999999999Z07:00", - "2006-01-02T15:04:05Z", - "2006-01-02T15:04:05.999999Z", - } { - if t, err := time.Parse(layout, ptf.Expiry); err == nil { - tok.Expiry = t - break - } + if t, err := core.ParseTimeFlex(ptf.Expiry); err == nil { + tok.Expiry = t } } @@ -111,6 +103,7 @@ func loadCalendarOAuthClient(ctx context.Context, tokenPath string) (*http.Clien // writeBackToken writes the refreshed token back to disk in Python google-auth format // so that the Python calendar-feed.py script stays compatible. +// Uses atomic write (write-to-temp-then-rename) to prevent partial writes on crash. func writeBackToken(tokenPath string, tok *oauth2.Token, cfg *oauth2.Config) { ptf := pythonTokenFile{ Token: tok.AccessToken, @@ -123,12 +116,7 @@ func writeBackToken(tokenPath string, tok *oauth2.Token, cfg *oauth2.Config) { ptf.Expiry = tok.Expiry.UTC().Format(time.RFC3339) } - data, err := json.MarshalIndent(ptf, "", " ") - if err != nil { - core.Logger().Debug("calendar: failed to marshal token for write-back", "error", err) - return - } - if err := os.WriteFile(tokenPath, data, 0600); err != nil { + if err := core.AtomicWriteJSON(tokenPath, ptf); err != nil { core.Logger().Debug("calendar: failed to write-back token", "error", err) } } @@ -291,14 +279,14 @@ func parseGoogleEventTime(edt *calendar.EventDateTime) (time.Time, bool) { return time.Time{}, false } if edt.DateTime != "" { - t, err := time.Parse(time.RFC3339, edt.DateTime) + t, err := core.ParseTimeFlex(edt.DateTime) if err != nil { return time.Time{}, false } return t, false } if edt.Date != "" { - t, err := time.Parse("2006-01-02", edt.Date) + t, err := core.ParseTimeFlex(edt.Date) if err != nil { return time.Time{}, false // Parse failed, don't claim it's all-day } diff --git a/internal/feeds/producer_calendar_test.go b/internal/feeds/producer_calendar_test.go new file mode 100644 index 0000000..209c0e7 --- /dev/null +++ b/internal/feeds/producer_calendar_test.go @@ -0,0 +1,127 @@ +package feeds + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/vishnujayvel/hookwise/internal/core" +) + +// --------------------------------------------------------------------------- +// CalendarProducer envelope structure tests +// --------------------------------------------------------------------------- + +func TestCalendarProducer_EnvelopeStructure_Fallback(t *testing.T) { + // Without a valid token, CalendarProducer returns its fallback envelope. + p := &CalendarProducer{} + p.SetFeedsConfig(core.FeedsConfig{ + Calendar: core.CalendarFeedConfig{ + TokenPath: "/nonexistent/token.json", + }, + }) + + result, err := p.Produce(context.Background()) + require.NoError(t, err, "ARCH-1: Produce must not return error") + + data := assertValidEnvelope(t, result, "calendar") + + // Fallback should have empty events and nil next_event. + events, ok := data["events"].([]interface{}) + require.True(t, ok, "data.events must be []interface{}") + assert.Empty(t, events) + assert.Nil(t, data["next_event"]) +} + +func TestCalendarProducer_EnvelopeNoSourceKey(t *testing.T) { + p := &CalendarProducer{} + p.SetFeedsConfig(core.FeedsConfig{ + Calendar: core.CalendarFeedConfig{ + TokenPath: "/nonexistent/token.json", + }, + }) + + result, err := p.Produce(context.Background()) + require.NoError(t, err) + + data := assertValidEnvelope(t, result, "calendar") + + // Bug #29 regression: no "source" in data. + _, hasSource := data["source"] + assert.False(t, hasSource, "data must NOT contain 'source' key (Bug #29)") +} + +// --------------------------------------------------------------------------- +// CalendarProducer error/fallback path tests +// --------------------------------------------------------------------------- + +func TestCalendarProducer_FallbackResult_NoCachedData(t *testing.T) { + p := &CalendarProducer{} + result := p.fallbackResult("test reason") + + data := assertValidEnvelope(t, result, "calendar") + events, ok := data["events"].([]interface{}) + require.True(t, ok) + assert.Empty(t, events) + assert.Nil(t, data["next_event"]) +} + +func TestCalendarProducer_MissingToken_FailOpen(t *testing.T) { + p := &CalendarProducer{} + p.SetFeedsConfig(core.FeedsConfig{ + Calendar: core.CalendarFeedConfig{ + TokenPath: "/absolutely/does/not/exist/token.json", + }, + }) + + result, err := p.Produce(context.Background()) + + // ARCH-1: missing token must not produce an error. + require.NoError(t, err, "ARCH-1: missing token must fail-open") + assertValidEnvelope(t, result, "calendar") +} + +func TestCalendarProducer_DefaultTokenPath(t *testing.T) { + // When TokenPath is empty, the producer should use the default path + // and still fail-open if that path doesn't exist. + p := &CalendarProducer{} + p.SetFeedsConfig(core.FeedsConfig{ + Calendar: core.CalendarFeedConfig{ + TokenPath: "", // empty → uses DefaultCalendarTokenPath + }, + }) + + result, err := p.Produce(context.Background()) + require.NoError(t, err, "ARCH-1: default token path must fail-open if file missing") + assertValidEnvelope(t, result, "calendar") +} + +func TestCalendarProducer_TestFixture_FieldConsistency(t *testing.T) { + // CalendarTestFixture must have the same keys as a real fallback envelope. + fixture := CalendarTestFixture() + fixtureData := assertValidEnvelope(t, fixture, "calendar") + + p := &CalendarProducer{} + p.SetFeedsConfig(core.FeedsConfig{ + Calendar: core.CalendarFeedConfig{ + TokenPath: "/nonexistent/token.json", + }, + }) + result, err := p.Produce(context.Background()) + require.NoError(t, err) + resultMap, ok := result.(map[string]interface{}) + require.True(t, ok, "result should be a map") + realData, ok := resultMap["data"].(map[string]interface{}) + require.True(t, ok, "data should be a map") + + // All keys in the real envelope must appear in the fixture. + for key := range realData { + _, ok := fixtureData[key] + assert.True(t, ok, "fixture data missing key %q", key) + } + for key := range fixtureData { + _, ok := realData[key] + assert.True(t, ok, "fixture data has extra key %q", key) + } +} diff --git a/internal/feeds/producer_helpers_test.go b/internal/feeds/producer_helpers_test.go new file mode 100644 index 0000000..f080113 --- /dev/null +++ b/internal/feeds/producer_helpers_test.go @@ -0,0 +1,41 @@ +package feeds + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// assertValidEnvelope verifies that a producer result has the canonical +// three-key envelope structure (type, timestamp, data) with no "source" +// key (Bug #29 regression guard). +func assertValidEnvelope(t *testing.T, result interface{}, expectedType string) map[string]interface{} { + t.Helper() + + m, ok := result.(map[string]interface{}) + require.True(t, ok, "result must be map[string]interface{}") + + // Exactly 3 keys: type, timestamp, data. + assert.Len(t, m, 3, "envelope must have exactly 3 keys") + + // Correct type value. + assert.Equal(t, expectedType, m["type"], "envelope type must match producer name") + + // Timestamp exists and is valid RFC3339. + ts, ok := m["timestamp"].(string) + require.True(t, ok, "timestamp must be a string") + _, err := time.Parse(time.RFC3339, ts) + assert.NoError(t, err, "timestamp must be valid RFC3339") + + // Data key exists and is a map. + data, ok := m["data"].(map[string]interface{}) + require.True(t, ok, "data must be map[string]interface{}") + + // Bug #29 regression guard: no "source" key at top level. + _, hasSource := m["source"] + assert.False(t, hasSource, "envelope must NOT contain top-level 'source' key (Bug #29 regression)") + + return data +} diff --git a/internal/feeds/producer_insights.go b/internal/feeds/producer_insights.go index 94435f5..d76c0de 100644 --- a/internal/feeds/producer_insights.go +++ b/internal/feeds/producer_insights.go @@ -30,7 +30,7 @@ func (p *InsightsProducer) SetFeedsConfig(cfg core.FeedsConfig) { p.feedsCfg = cfg } -func (p *InsightsProducer) Produce(_ context.Context) (interface{}, error) { +func (p *InsightsProducer) Produce(ctx context.Context) (interface{}, error) { p.mu.Lock() cfg := p.feedsCfg.Insights p.mu.Unlock() @@ -52,6 +52,11 @@ func (p *InsightsProducer) Produce(_ context.Context) (interface{}, error) { now := time.Now().UTC() cutoff := now.Add(-time.Duration(stalenessDays) * 24 * time.Hour) + // Respect context cancellation before expensive I/O. + if err := ctx.Err(); err != nil { + return p.zeroedEnvelope(stalenessDays), nil + } + // Read and filter sessions within staleness window. allSessions := readJSONFiles(sessionMetaDir) var validSessions []map[string]interface{} @@ -135,6 +140,11 @@ func (p *InsightsProducer) Produce(_ context.Context) (interface{}, error) { } } + // Respect context cancellation before second I/O phase. + if err := ctx.Err(); err != nil { + return p.zeroedEnvelope(stalenessDays), nil + } + // Read facets for friction data. frictionCounts := make(map[string]int) allFacets := readJSONFiles(facetsDir) diff --git a/internal/feeds/producer_insights_test.go b/internal/feeds/producer_insights_test.go new file mode 100644 index 0000000..e16563b --- /dev/null +++ b/internal/feeds/producer_insights_test.go @@ -0,0 +1,187 @@ +package feeds + +import ( + "context" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/vishnujayvel/hookwise/internal/core" +) + +// --------------------------------------------------------------------------- +// InsightsProducer envelope structure tests +// --------------------------------------------------------------------------- + +func TestInsightsProducer_EnvelopeStructure(t *testing.T) { + p := &InsightsProducer{} + p.SetFeedsConfig(core.FeedsConfig{ + Insights: core.InsightsFeedConfig{ + UsageDataPath: filepath.Join(t.TempDir(), "nonexistent"), + StalenessDays: 30, + }, + }) + + result, err := p.Produce(context.Background()) + require.NoError(t, err, "ARCH-1: Produce must not return error") + + data := assertValidEnvelope(t, result, "insights") + + // Verify core data keys exist. + _, ok := data["total_sessions"] + assert.True(t, ok, "data must have 'total_sessions'") + _, ok = data["total_messages"] + assert.True(t, ok, "data must have 'total_messages'") + _, ok = data["total_lines_added"] + assert.True(t, ok, "data must have 'total_lines_added'") + _, ok = data["avg_duration_minutes"] + assert.True(t, ok, "data must have 'avg_duration_minutes'") + _, ok = data["top_tools"] + assert.True(t, ok, "data must have 'top_tools'") + _, ok = data["friction_counts"] + assert.True(t, ok, "data must have 'friction_counts'") + _, ok = data["friction_total"] + assert.True(t, ok, "data must have 'friction_total'") + _, ok = data["peak_hour"] + assert.True(t, ok, "data must have 'peak_hour'") + _, ok = data["days_active"] + assert.True(t, ok, "data must have 'days_active'") + _, ok = data["staleness_days"] + assert.True(t, ok, "data must have 'staleness_days'") + _, ok = data["recent_session"] + assert.True(t, ok, "data must have 'recent_session'") + _, ok = data["recent_msgs_per_day"] + assert.True(t, ok, "data must have 'recent_msgs_per_day'") + _, ok = data["recent_messages"] + assert.True(t, ok, "data must have 'recent_messages'") + _, ok = data["recent_days_active"] + assert.True(t, ok, "data must have 'recent_days_active'") +} + +func TestInsightsProducer_EnvelopeNoSourceKey(t *testing.T) { + p := &InsightsProducer{} + p.SetFeedsConfig(core.FeedsConfig{ + Insights: core.InsightsFeedConfig{ + UsageDataPath: filepath.Join(t.TempDir(), "nonexistent"), + StalenessDays: 30, + }, + }) + + result, err := p.Produce(context.Background()) + require.NoError(t, err) + + data := assertValidEnvelope(t, result, "insights") + + // Bug #29 regression: no "source" in data. + _, hasSource := data["source"] + assert.False(t, hasSource, "data must NOT contain 'source' key (Bug #29)") +} + +// --------------------------------------------------------------------------- +// InsightsProducer error/fallback path tests +// --------------------------------------------------------------------------- + +func TestInsightsProducer_ZeroedEnvelope_ValidStructure(t *testing.T) { + p := &InsightsProducer{} + result := p.zeroedEnvelope(30) + + data := assertValidEnvelope(t, result, "insights") + + // All numeric fields should be zero. + assert.Equal(t, 0, toInt(data["total_sessions"])) + assert.Equal(t, 0, toInt(data["total_messages"])) + assert.Equal(t, 0, toInt(data["total_lines_added"])) + assert.Equal(t, float64(0), data["avg_duration_minutes"]) + assert.Equal(t, 0, toInt(data["friction_total"])) + assert.Equal(t, 0, toInt(data["peak_hour"])) + assert.Equal(t, 0, toInt(data["days_active"])) + assert.Equal(t, 30, toInt(data["staleness_days"])) +} + +func TestInsightsProducer_ZeroedEnvelope_RecentSession(t *testing.T) { + p := &InsightsProducer{} + result := p.zeroedEnvelope(30) + + data := assertValidEnvelope(t, result, "insights") + + recentSession, ok := data["recent_session"].(map[string]interface{}) + require.True(t, ok, "recent_session must be a map") + + assert.Equal(t, "", recentSession["id"]) + assert.Equal(t, 0, toInt(recentSession["duration_minutes"])) + assert.Equal(t, 0, toInt(recentSession["lines_added"])) + assert.Equal(t, 0, toInt(recentSession["friction_count"])) + assert.Equal(t, "", recentSession["outcome"]) + assert.Equal(t, 0, toInt(recentSession["tool_errors"])) +} + +func TestInsightsProducer_MissingDir_FailOpen(t *testing.T) { + p := &InsightsProducer{} + p.SetFeedsConfig(core.FeedsConfig{ + Insights: core.InsightsFeedConfig{ + UsageDataPath: "/absolutely/does/not/exist", + StalenessDays: 7, + }, + }) + + result, err := p.Produce(context.Background()) + + // ARCH-1: missing directory must not produce error. + require.NoError(t, err, "ARCH-1: missing data dir must fail-open") + data := assertValidEnvelope(t, result, "insights") + assert.Equal(t, 0, toInt(data["total_sessions"])) +} + +func TestInsightsProducer_CancelledContext_FailOpen(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel immediately + + p := &InsightsProducer{} + p.SetFeedsConfig(core.FeedsConfig{ + Insights: core.InsightsFeedConfig{ + UsageDataPath: filepath.Join(t.TempDir(), "data"), + StalenessDays: 30, + }, + }) + + result, err := p.Produce(ctx) + require.NoError(t, err, "ARCH-1: cancelled context must fail-open") + assertValidEnvelope(t, result, "insights") +} + +func TestInsightsProducer_DefaultStalenessDays(t *testing.T) { + p := &InsightsProducer{} + p.SetFeedsConfig(core.FeedsConfig{ + Insights: core.InsightsFeedConfig{ + UsageDataPath: filepath.Join(t.TempDir(), "nonexistent"), + StalenessDays: 0, // zero → should default to 30 + }, + }) + + result, err := p.Produce(context.Background()) + require.NoError(t, err) + + data := assertValidEnvelope(t, result, "insights") + assert.Equal(t, 30, toInt(data["staleness_days"]), "default staleness should be 30 days") +} + +func TestInsightsProducer_TestFixture_FieldConsistency(t *testing.T) { + fixture := InsightsTestFixture() + fixtureData := assertValidEnvelope(t, fixture, "insights") + + // Compare keys against a zeroed envelope (which has all the same fields). + p := &InsightsProducer{} + zeroed := p.zeroedEnvelope(30) + zeroedData, ok := zeroed["data"].(map[string]interface{}) + require.True(t, ok, "zeroed envelope data should be a map") + + for key := range zeroedData { + _, ok := fixtureData[key] + assert.True(t, ok, "fixture data missing key %q", key) + } + for key := range fixtureData { + _, ok := zeroedData[key] + assert.True(t, ok, "fixture data has extra key %q", key) + } +} diff --git a/internal/feeds/producer_memories_test.go b/internal/feeds/producer_memories_test.go new file mode 100644 index 0000000..5e6bc48 --- /dev/null +++ b/internal/feeds/producer_memories_test.go @@ -0,0 +1,80 @@ +package feeds + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// MemoriesProducer envelope structure tests +// --------------------------------------------------------------------------- + +func TestMemoriesProducer_EnvelopeStructure(t *testing.T) { + p := &MemoriesProducer{} + result, err := p.Produce(context.Background()) + require.NoError(t, err, "ARCH-1: Produce must not return error") + + data := assertValidEnvelope(t, result, "memories") + + // Verify expected data keys. + recentMemories, ok := data["recent_memories"].([]interface{}) + require.True(t, ok, "data.recent_memories must be []interface{}") + assert.Empty(t, recentMemories, "placeholder should have empty recent_memories") + + totalCount := toInt(data["total_count"]) + assert.Equal(t, 0, totalCount, "placeholder total_count should be 0") +} + +func TestMemoriesProducer_EnvelopeNoTopLevelSourceKey(t *testing.T) { + p := &MemoriesProducer{} + result, err := p.Produce(context.Background()) + require.NoError(t, err) + + // assertValidEnvelope checks no "source" at the envelope top level. + assertValidEnvelope(t, result, "memories") +} + +func TestMemoriesProducer_Name(t *testing.T) { + p := &MemoriesProducer{} + assert.Equal(t, "memories", p.Name()) +} + +// --------------------------------------------------------------------------- +// MemoriesProducer always produces valid result +// --------------------------------------------------------------------------- + +func TestMemoriesProducer_NoConfigNeeded(t *testing.T) { + // MemoriesProducer is a simple placeholder — it should always succeed + // regardless of environment or configuration. + p := &MemoriesProducer{} + result, err := p.Produce(context.Background()) + require.NoError(t, err) + + m, ok := result.(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "memories", m["type"]) + assert.Len(t, m, 3, "envelope must have exactly 3 keys") +} + +func TestMemoriesProducer_DataFieldsPresent(t *testing.T) { + p := &MemoriesProducer{} + result, err := p.Produce(context.Background()) + require.NoError(t, err) + + data := assertValidEnvelope(t, result, "memories") + + // Verify all expected keys exist. + _, hasRecentMemories := data["recent_memories"] + assert.True(t, hasRecentMemories, "data must have 'recent_memories' key") + + _, hasTotalCount := data["total_count"] + assert.True(t, hasTotalCount, "data must have 'total_count' key") + + _, hasSource := data["source"] + // Note: MemoriesProducer currently includes "source" in data. + // This is allowed — Bug #29 only guards against top-level "source". + _ = hasSource // no assertion needed on data-level source +} diff --git a/internal/feeds/producer_news_test.go b/internal/feeds/producer_news_test.go new file mode 100644 index 0000000..f7b54ad --- /dev/null +++ b/internal/feeds/producer_news_test.go @@ -0,0 +1,69 @@ +package feeds + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// NewsProducer envelope structure tests +// --------------------------------------------------------------------------- + +func TestNewsProducer_EnvelopeStructure(t *testing.T) { + p := &NewsProducer{} + result, err := p.Produce(context.Background()) + require.NoError(t, err, "ARCH-1: Produce must not return error") + + data := assertValidEnvelope(t, result, "news") + + // Verify expected data keys. + stories, ok := data["stories"].([]interface{}) + require.True(t, ok, "data.stories must be []interface{}") + assert.NotEmpty(t, stories, "placeholder should have at least one story") + + // Each story should have title, url, score. + story, ok := stories[0].(map[string]interface{}) + require.True(t, ok, "each story must be a map") + _, ok = story["title"].(string) + assert.True(t, ok, "story.title must be a string") + _, ok = story["url"].(string) + assert.True(t, ok, "story.url must be a string") + _, ok = story["score"] + assert.True(t, ok, "story should have score") +} + +func TestNewsProducer_EnvelopeNoTopLevelSourceKey(t *testing.T) { + p := &NewsProducer{} + result, err := p.Produce(context.Background()) + require.NoError(t, err) + + // assertValidEnvelope checks no "source" at the envelope top level. + // The news producer has "source" inside data (as a data field, not envelope key), + // which is allowed — only top-level "source" is the Bug #29 violation. + assertValidEnvelope(t, result, "news") +} + +func TestNewsProducer_Name(t *testing.T) { + p := &NewsProducer{} + assert.Equal(t, "news", p.Name()) +} + +// --------------------------------------------------------------------------- +// NewsProducer produces valid result without configuration +// --------------------------------------------------------------------------- + +func TestNewsProducer_NoConfigNeeded(t *testing.T) { + // NewsProducer is a simple placeholder — it should always succeed + // regardless of environment or configuration. + p := &NewsProducer{} + result, err := p.Produce(context.Background()) + require.NoError(t, err) + + m, ok := result.(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "news", m["type"]) + assert.Len(t, m, 3, "envelope must have exactly 3 keys") +} diff --git a/internal/feeds/producer_project.go b/internal/feeds/producer_project.go index ff0792f..6fddd86 100644 --- a/internal/feeds/producer_project.go +++ b/internal/feeds/producer_project.go @@ -29,6 +29,10 @@ func (p *ProjectProducer) SetFeedsConfig(cfg core.FeedsConfig) { } func (p *ProjectProducer) Produce(ctx context.Context) (interface{}, error) { + // Add timeout to prevent blocking the poll cycle on slow git operations. + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + cwd, err := os.Getwd() if err != nil { return p.fallbackResult() diff --git a/internal/feeds/producer_project_test.go b/internal/feeds/producer_project_test.go new file mode 100644 index 0000000..5ecd0de --- /dev/null +++ b/internal/feeds/producer_project_test.go @@ -0,0 +1,98 @@ +package feeds + +import ( + "context" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --------------------------------------------------------------------------- +// ProjectProducer envelope structure tests +// --------------------------------------------------------------------------- + +func TestProjectProducer_EnvelopeStructure(t *testing.T) { + p := &ProjectProducer{} + result, err := p.Produce(context.Background()) + require.NoError(t, err, "ARCH-1: Produce must not return error") + + data := assertValidEnvelope(t, result, "project") + + // Verify all expected data keys exist with correct types. + _, ok := data["name"].(string) + assert.True(t, ok, "data.name must be a string") + _, ok = data["branch"].(string) + assert.True(t, ok, "data.branch must be a string") + _, ok = data["last_commit"].(string) + assert.True(t, ok, "data.last_commit must be a string") + _, ok = data["dirty"].(bool) + assert.True(t, ok, "data.dirty must be a bool") +} + +func TestProjectProducer_EnvelopeNoSourceKey(t *testing.T) { + p := &ProjectProducer{} + result, err := p.Produce(context.Background()) + require.NoError(t, err) + + data := assertValidEnvelope(t, result, "project") + + // Bug #29 regression: no "source" in data either. + _, hasSource := data["source"] + assert.False(t, hasSource, "data must NOT contain 'source' key (Bug #29)") +} + +// --------------------------------------------------------------------------- +// ProjectProducer error/fallback path tests +// --------------------------------------------------------------------------- + +func TestProjectProducer_FallbackResult_ValidEnvelope(t *testing.T) { + p := &ProjectProducer{} + result, err := p.fallbackResult() + require.NoError(t, err) + + data := assertValidEnvelope(t, result, "project") + + // Fallback should have empty strings and false dirty. + assert.Equal(t, "", data["name"]) + assert.Equal(t, "", data["branch"]) + assert.Equal(t, "", data["last_commit"]) + assert.Equal(t, false, data["dirty"]) +} + +func TestProjectProducer_NonGitDir_FailOpen(t *testing.T) { + // Run from a temp directory that is not a git repo. + tmpDir := t.TempDir() + origDir, err := os.Getwd() + require.NoError(t, err) + require.NoError(t, os.Chdir(tmpDir)) + t.Cleanup(func() { _ = os.Chdir(origDir) }) + + p := &ProjectProducer{} + result, err := p.Produce(context.Background()) + + // ARCH-1: must not return error. + require.NoError(t, err, "ARCH-1: non-git dir must not produce error") + + // Must still return a valid envelope. + data := assertValidEnvelope(t, result, "project") + + // Fields should be empty strings (fallback values). + assert.Equal(t, "", data["name"]) + assert.Equal(t, "", data["branch"]) + assert.Equal(t, "", data["last_commit"]) + assert.Equal(t, false, data["dirty"]) +} + +func TestProjectProducer_CancelledContext_FailOpen(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel immediately + + p := &ProjectProducer{} + result, err := p.Produce(ctx) + + // ARCH-1: cancelled context should fail-open, returning fallback. + require.NoError(t, err, "ARCH-1: cancelled context must not produce error") + assertValidEnvelope(t, result, "project") +} diff --git a/internal/feeds/producer_weather_test.go b/internal/feeds/producer_weather_test.go new file mode 100644 index 0000000..31c4db0 --- /dev/null +++ b/internal/feeds/producer_weather_test.go @@ -0,0 +1,274 @@ +package feeds + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/vishnujayvel/hookwise/internal/core" +) + +// --------------------------------------------------------------------------- +// WeatherProducer envelope structure tests +// --------------------------------------------------------------------------- + +func TestWeatherProducer_EnvelopeStructure_WithAPI(t *testing.T) { + // Set up a mock Open-Meteo API server. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := map[string]interface{}{ + "current": map[string]interface{}{ + "temperature_2m": 72.0, + "wind_speed_10m": 5.3, + "weather_code": 0, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer srv.Close() + + p := &WeatherProducer{ + client: srv.Client(), + } + p.SetFeedsConfig(core.FeedsConfig{ + Weather: core.WeatherFeedConfig{ + Latitude: 37.7749, + Longitude: -122.4194, + }, + }) + + // Override the URL by pointing the client at our test server. + // WeatherProducer builds the URL internally, so we need a transport-level redirect. + p.client = &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + req.URL.Scheme = "http" + req.URL.Host = srv.Listener.Addr().String() + return http.DefaultTransport.RoundTrip(req) + }), + } + + result, err := p.Produce(context.Background()) + require.NoError(t, err, "ARCH-1: Produce must not return error") + + data := assertValidEnvelope(t, result, "weather") + + // Verify expected data keys. + assert.Equal(t, 72.0, data["temperature"]) + assert.Equal(t, "fahrenheit", data["temperatureUnit"]) + assert.Equal(t, 5.3, data["windSpeed"]) + assert.NotNil(t, data["emoji"]) + assert.NotNil(t, data["description"]) +} + +// roundTripFunc adapts a function to http.RoundTripper. +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func TestWeatherProducer_EnvelopeNoSourceKey(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := map[string]interface{}{ + "current": map[string]interface{}{ + "temperature_2m": 20.0, + "wind_speed_10m": 3.0, + "weather_code": 1, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer srv.Close() + + p := &WeatherProducer{ + client: &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + req.URL.Scheme = "http" + req.URL.Host = srv.Listener.Addr().String() + return http.DefaultTransport.RoundTrip(req) + }), + }, + } + + result, err := p.Produce(context.Background()) + require.NoError(t, err) + + data := assertValidEnvelope(t, result, "weather") + _, hasSource := data["source"] + assert.False(t, hasSource, "data must NOT contain 'source' key (Bug #29)") +} + +// --------------------------------------------------------------------------- +// WeatherProducer error/fallback path tests +// --------------------------------------------------------------------------- + +func TestWeatherProducer_FallbackResult_NoCachedData(t *testing.T) { + p := &WeatherProducer{} + result := p.fallbackResult("test reason") + + data := assertValidEnvelope(t, result, "weather") + + // No cached data: temperature and weatherCode should be nil. + assert.Nil(t, data["temperature"], "fallback temperature must be nil, not 0") + assert.Nil(t, data["weatherCode"], "fallback weatherCode must be nil, not 0") + assert.Equal(t, "fahrenheit", data["temperatureUnit"]) + assert.Equal(t, "Unavailable", data["description"]) +} + +func TestWeatherProducer_FallbackResult_CelsiusUnit(t *testing.T) { + p := &WeatherProducer{} + p.SetFeedsConfig(core.FeedsConfig{ + Weather: core.WeatherFeedConfig{ + TemperatureUnit: "celsius", + }, + }) + result := p.fallbackResult("test") + + data := assertValidEnvelope(t, result, "weather") + assert.Equal(t, "celsius", data["temperatureUnit"]) +} + +func TestWeatherProducer_APIError_FailOpen(t *testing.T) { + // Server returns 500 Internal Server Error. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + })) + defer srv.Close() + + p := &WeatherProducer{ + client: &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + req.URL.Scheme = "http" + req.URL.Host = srv.Listener.Addr().String() + return http.DefaultTransport.RoundTrip(req) + }), + }, + } + + result, err := p.Produce(context.Background()) + + // ARCH-1: must not return error. + require.NoError(t, err, "ARCH-1: API error must fail-open") + assertValidEnvelope(t, result, "weather") +} + +func TestWeatherProducer_APIUnreachable_FailOpen(t *testing.T) { + // Client pointed at a server that immediately closes connections. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Close connection without writing anything. + hj, ok := w.(http.Hijacker) + if ok { + conn, _, _ := hj.Hijack() + conn.Close() + } + })) + defer srv.Close() + + p := &WeatherProducer{ + client: &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + req.URL.Scheme = "http" + req.URL.Host = srv.Listener.Addr().String() + return http.DefaultTransport.RoundTrip(req) + }), + }, + } + + result, err := p.Produce(context.Background()) + require.NoError(t, err, "ARCH-1: unreachable API must fail-open") + assertValidEnvelope(t, result, "weather") +} + +func TestWeatherProducer_InvalidJSON_FailOpen(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, "not valid json at all") + })) + defer srv.Close() + + p := &WeatherProducer{ + client: &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + req.URL.Scheme = "http" + req.URL.Host = srv.Listener.Addr().String() + return http.DefaultTransport.RoundTrip(req) + }), + }, + } + + result, err := p.Produce(context.Background()) + require.NoError(t, err, "ARCH-1: invalid JSON must fail-open") + assertValidEnvelope(t, result, "weather") +} + +func TestWeatherProducer_FallbackUsesCachedResult(t *testing.T) { + callCount := 0 + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + if callCount == 1 { + // First call succeeds. + resp := map[string]interface{}{ + "current": map[string]interface{}{ + "temperature_2m": 68.0, + "wind_speed_10m": 4.0, + "weather_code": 0, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + } else { + // Second call fails. + http.Error(w, "Service Unavailable", http.StatusServiceUnavailable) + } + })) + defer srv.Close() + + p := &WeatherProducer{ + client: &http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + req.URL.Scheme = "http" + req.URL.Host = srv.Listener.Addr().String() + return http.DefaultTransport.RoundTrip(req) + }), + }, + } + + // First call: should succeed and cache the result. + result1, err := p.Produce(context.Background()) + require.NoError(t, err) + data1 := assertValidEnvelope(t, result1, "weather") + assert.Equal(t, 68.0, data1["temperature"]) + + // Second call: should fail-open and return cached result. + result2, err := p.Produce(context.Background()) + require.NoError(t, err) + data2 := assertValidEnvelope(t, result2, "weather") + assert.Equal(t, 68.0, data2["temperature"], "fallback should return cached temperature") +} + +func TestWeatherProducer_TestFixture_FieldConsistency(t *testing.T) { + fixture := WeatherTestFixture() + fixtureData := assertValidEnvelope(t, fixture, "weather") + + // Compare keys against a real fallback (which has all the same fields). + p := &WeatherProducer{} + fallback := p.fallbackResult("test") + fallbackData, ok := fallback["data"].(map[string]interface{}) + require.True(t, ok, "fallback data should be a map") + + for key := range fallbackData { + _, ok := fixtureData[key] + assert.True(t, ok, "fixture data missing key %q", key) + } + for key := range fixtureData { + _, ok := fallbackData[key] + assert.True(t, ok, "fixture data has extra key %q", key) + } +} diff --git a/internal/notifications/notifications.go b/internal/notifications/notifications.go index 0f9274d..f4d9487 100644 --- a/internal/notifications/notifications.go +++ b/internal/notifications/notifications.go @@ -106,9 +106,18 @@ func (ns *NotificationService) Unsurfaced(ctx context.Context) ([]Notification, // Filter out expired notifications in Go (avoids Dolt SQL date math // issues on TEXT-stored timestamps). + // Mark expired ones as surfaced so they are not rescanned on future calls. var active []Notification + now := time.Now().UTC().Format(time.RFC3339) for _, n := range all { - if !n.Expired { + if n.Expired { + // Best-effort: mark expired notification as surfaced so it + // won't be returned by future Unsurfaced() calls. + _, _ = ns.db.Exec(ctx, + `UPDATE notifications SET surfaced_at = ? WHERE id = ?`, + now, n.ID, + ) + } else { active = append(active, n) } } diff --git a/internal/notifications/producers.go b/internal/notifications/producers.go index a970067..5bd1ee8 100644 --- a/internal/notifications/producers.go +++ b/internal/notifications/producers.go @@ -63,12 +63,6 @@ func CheckBudget(ctx context.Context, ns *NotificationService, costState *analyt // Guard effectiveness notifications (R12.2) // --------------------------------------------------------------------------- -// GuardBlockSummary summarizes block events for a single tool pattern. -type GuardBlockSummary struct { - ToolName string - BlockCount int -} - // CheckGuardEffectiveness queries for tools that have been blocked frequently // (5 or more times today) and creates a notification for each one that // hasn't already been notified about today. @@ -79,10 +73,9 @@ func CheckGuardEffectiveness(ctx context.Context, ns *NotificationService, db *a today := time.Now().UTC().Format("2006-01-02") - // Query events table for tools that were blocked today. - // Guard blocks are recorded as PreToolUse events. We look for events - // with a high count to identify frequently-blocked tools. - summaries, err := queryGuardBlocks(ctx, db, today) + // Query events table for tools that were blocked today via the + // analytics DB method (SQL lives in the analytics package). + summaries, err := db.GuardBlockSummaries(ctx, today) if err != nil { return fmt.Errorf("notifications: check guard effectiveness: %w", err) } @@ -115,36 +108,6 @@ func CheckGuardEffectiveness(ctx context.Context, ns *NotificationService, db *a return nil } -// queryGuardBlocks queries for tool names that appear frequently in PreToolUse -// events today, which indicates repeated guard evaluation (potential blocks). -func queryGuardBlocks(ctx context.Context, db *analytics.DB, today string) ([]GuardBlockSummary, error) { - rows, err := db.Query(ctx, - `SELECT tool_name, COUNT(*) AS cnt - FROM events - WHERE event_type = 'PreToolUse' - AND timestamp LIKE ? - AND tool_name != '' - GROUP BY tool_name - HAVING cnt >= 5 - ORDER BY cnt DESC`, - today+"%", - ) - if err != nil { - return nil, fmt.Errorf("notifications: query guard blocks: %w", err) - } - defer rows.Close() - - var summaries []GuardBlockSummary - for rows.Next() { - var s GuardBlockSummary - if err := rows.Scan(&s.ToolName, &s.BlockCount); err != nil { - return nil, fmt.Errorf("notifications: scan guard blocks: %w", err) - } - summaries = append(summaries, s) - } - return summaries, rows.Err() -} - // --------------------------------------------------------------------------- // Coaching prompt notifications (R12.3) // ---------------------------------------------------------------------------