diff --git a/internal/commands/survey/status.go b/internal/commands/survey/status.go index 8df5ac0..c47688b 100644 --- a/internal/commands/survey/status.go +++ b/internal/commands/survey/status.go @@ -4,6 +4,7 @@ import ( "time" "github.com/LazyBachelor/LazyPM/internal/commands/issues" + "github.com/LazyBachelor/LazyPM/internal/models" "github.com/spf13/cobra" ) @@ -24,7 +25,7 @@ func runStatusCmd(cmd *cobra.Command, args []string) error { if app.SubmitChan != nil { select { - case app.SubmitChan <- struct{}{}: + case app.SubmitChan <- models.ValidationTrigger{Source: models.ValidationTriggerStatusCheck}: default: } } diff --git a/internal/models/app.go b/internal/models/app.go index 40b0e94..4b05eb2 100644 --- a/internal/models/app.go +++ b/internal/models/app.go @@ -20,7 +20,7 @@ type App struct { CurrentFeedback *ValidationFeedback ActionLogger func(string) - SubmitChan chan<- struct{} + SubmitChan chan<- ValidationTrigger } func (a *App) LogAction(action string) { diff --git a/internal/models/task.go b/internal/models/task.go index 8eaa938..80f97fb 100644 --- a/internal/models/task.go +++ b/internal/models/task.go @@ -26,10 +26,24 @@ type Check struct { Valid bool } +type ValidationTriggerSource string + +const ( + ValidationTriggerManualSubmit ValidationTriggerSource = "manual_submit" + ValidationTriggerAutoPoll ValidationTriggerSource = "auto_poll" + ValidationTriggerInitCheck ValidationTriggerSource = "init_check" + ValidationTriggerStatusCheck ValidationTriggerSource = "status_check" + ValidationTriggerUnknown ValidationTriggerSource = "unknown" +) + +type ValidationTrigger struct { + Source ValidationTriggerSource +} + type ValidatedInterface interface { Interface SetChannels(feedbackChan chan ValidationFeedback, quitChan chan bool) - SetSubmitChan(submitChan chan<- struct{}) + SetSubmitChan(submitChan chan<- ValidationTrigger) } type TaskDetails struct { diff --git a/pkg/repl/repl.go b/pkg/repl/repl.go index 2c7918e..7e6eca2 100644 --- a/pkg/repl/repl.go +++ b/pkg/repl/repl.go @@ -30,7 +30,7 @@ Type 'status' to check task progress.` type REPL struct { feedbackChan chan ValidationFeedback quitChan chan bool - submitChan chan<- struct{} + submitChan chan<- models.ValidationTrigger app *App currentFeedback ValidationFeedback @@ -135,7 +135,7 @@ replLoop: // Send submit signal to trigger validation after any command if r.submitChan != nil { select { - case r.submitChan <- struct{}{}: + case r.submitChan <- models.ValidationTrigger{Source: models.ValidationTriggerManualSubmit}: default: } } @@ -192,7 +192,7 @@ func (r *REPL) SetChannels(feedbackChan chan task.ValidationFeedback, quitChan c r.quitChan = quitChan } -func (r *REPL) SetSubmitChan(submitChan chan<- struct{}) { +func (r *REPL) SetSubmitChan(submitChan chan<- models.ValidationTrigger) { r.submitChan = submitChan } diff --git a/pkg/task/metrics.go b/pkg/task/metrics.go index ce573e1..de5f692 100644 --- a/pkg/task/metrics.go +++ b/pkg/task/metrics.go @@ -16,6 +16,8 @@ type taskRunCollector struct { mu sync.Mutex run models.TaskRunMetrics logger *slog.Logger + + lastValidationFingerprint string } var nonWord = regexp.MustCompile(`[^a-z0-9]+`) @@ -23,10 +25,12 @@ var nonWord = regexp.MustCompile(`[^a-z0-9]+`) func newTaskRunCollector(taskName string, interfaceType InterfaceType, logger *slog.Logger) *taskRunCollector { return &taskRunCollector{ run: models.TaskRunMetrics{ - TaskName: taskName, - InterfaceType: interfaceType, - StartedAt: time.Now(), - Logs: make([]models.TaskLogEntry, 0, 8), + MetricsVersion: models.CurrentMetricsVersion, + TaskName: taskName, + InterfaceType: interfaceType, + StartedAt: time.Now(), + ValidationSource: models.ValidationTriggerUnknown, + Logs: make([]models.TaskLogEntry, 0, 8), }, logger: logger, } @@ -41,6 +45,16 @@ func (c *taskRunCollector) appendLog(entry models.TaskLogEntry) { return } + if entry.Level == "validation" { + if entry.Action == "validate_check" { + return + } + + if entry.Action == "validate_attempt" && entry.Result == "passed" { + return + } + } + attrs := []any{ "task", c.run.TaskName, "interface", c.run.InterfaceType, @@ -53,6 +67,8 @@ func (c *taskRunCollector) appendLog(entry models.TaskLogEntry) { c.logger.Error(entry.Message, attrs...) case "warn": c.logger.Warn(entry.Message, attrs...) + case "validation": + c.logger.Warn(entry.Message, attrs...) default: c.logger.Info(entry.Message, attrs...) } @@ -81,6 +97,9 @@ func (c *taskRunCollector) log(level, message string) { func (c *taskRunCollector) recordUserAction(raw string) { source, actionText, target, result := normalizeUserAction(raw) + if shouldIgnoreUserAction(source, actionText, target) { + return + } c.mu.Lock() defer c.mu.Unlock() @@ -103,7 +122,16 @@ func (c *taskRunCollector) recordQuestionnaire(completed bool, userQuit bool, an c.run.QuestionnaireUserQuit = userQuit if len(answers) > 0 { - c.run.QuestionnaireAnswers = answers + nonNilAnswers := make(map[string]any, len(answers)) + for k, v := range answers { + if v == nil { + continue + } + nonNilAnswers[k] = v + } + if len(nonNilAnswers) > 0 { + c.run.QuestionnaireAnswers = nonNilAnswers + } } result := "completed" @@ -150,53 +178,119 @@ func (c *taskRunCollector) recordQuestionnaire(completed bool, userQuit bool, an } } -func (c *taskRunCollector) recordValidation(feedback ValidationFeedback) { +func (c *taskRunCollector) recordValidation(feedback ValidationFeedback, source models.ValidationTriggerSource) { c.mu.Lock() - - c.run.ValidationAttempts++ - attempt := c.run.ValidationAttempts - - result := "failed" - if feedback.Success { - result = "passed" - c.run.ValidationSuccesses++ - } else { - c.run.ValidationFailures++ + c.run.ValidationRefreshes++ + if isAutoValidationSource(source) { + c.run.ValidationAutoRefreshes++ } + if source == "" { + source = models.ValidationTriggerUnknown + } + if shouldPromoteValidationSource(c.run.ValidationSource, source) { + c.run.ValidationSource = source + } + fingerprint := validationFingerprint(feedback) + isDuplicateAttempt := fingerprint == c.lastValidationFingerprint - c.appendLog(models.TaskLogEntry{ - Level: "validation", - Message: fmt.Sprintf("validation attempt %d", attempt), - Source: "system", - Action: "validate_attempt", - Result: result, - Attempt: attempt, - }) + if !isDuplicateAttempt { + c.run.ValidationAttempts++ + if source == models.ValidationTriggerManualSubmit { + c.run.ValidationManualAttempts++ + } + attempt := c.run.ValidationAttempts - for _, check := range feedback.Checks { - checkResult := "failed" - if check.Valid { - checkResult = "passed" - c.run.ValidationChecksPassed++ + result := "failed" + if feedback.Success { + result = "passed" + c.run.ValidationSuccesses++ + if c.run.AttemptsToFirstSuccess == 0 { + c.run.AttemptsToFirstSuccess = attempt + c.run.TimeToFirstSuccessMs = time.Since(c.run.StartedAt).Milliseconds() + } + c.run.FailureReasonCode = "" } else { - c.run.ValidationChecksFailed++ + c.run.ValidationFailures++ + c.run.FailureReasonCode = inferFailureReasonCode(feedback) } c.appendLog(models.TaskLogEntry{ Level: "validation", - Message: fmt.Sprintf("validation check: %s", check.Message), + Message: fmt.Sprintf("validation attempt %d", attempt), Source: "system", - Action: "validate_check", - Target: check.Message, - Result: checkResult, + Action: "validate_attempt", + Result: result, Attempt: attempt, }) + + for _, check := range feedback.Checks { + checkResult := "failed" + if check.Valid { + checkResult = "passed" + c.run.ValidationChecksPassed++ + } else { + c.run.ValidationChecksFailed++ + } + + c.appendLog(models.TaskLogEntry{ + Level: "validation", + Message: fmt.Sprintf("validation check: %s", check.Message), + Source: "system", + Action: "validate_check", + Target: check.Message, + Result: checkResult, + Attempt: attempt, + }) + } + + c.lastValidationFingerprint = fingerprint } c.run.LastValidationMessage = feedback.Message c.mu.Unlock() } +func inferFailureReasonCode(feedback ValidationFeedback) string { + for _, check := range feedback.Checks { + if !check.Valid { + return normalizeFailureReason(check.Message) + } + } + + code := normalizeFailureReason(feedback.Message) + if code == "" { + return "validation_failed" + } + + return code +} + +func validationFingerprint(feedback ValidationFeedback) string { + var b strings.Builder + + b.Grow(64 + len(feedback.Checks)*32) + if feedback.Success { + b.WriteString("1") + } else { + b.WriteString("0") + } + b.WriteString("|") + b.WriteString(feedback.Message) + + for _, check := range feedback.Checks { + b.WriteString("|") + if check.Valid { + b.WriteString("1") + } else { + b.WriteString("0") + } + b.WriteString(":") + b.WriteString(check.Message) + } + + return b.String() +} + func (c *taskRunCollector) setCompleted(completed bool) { c.mu.Lock() c.run.Completed = completed @@ -223,11 +317,118 @@ func (c *taskRunCollector) finalize() models.TaskRunMetrics { c.run.DurationMs = c.run.EndedAt.Sub(c.run.StartedAt).Milliseconds() + c.run.RunOutcome = inferRunOutcome(c.run) + final := c.run final.Logs = append([]models.TaskLogEntry(nil), c.run.Logs...) return final } +func shouldIgnoreUserAction(source, actionText, target string) bool { + if source != "web" || actionText != "request" { + return false + } + + parts := strings.Fields(strings.TrimSpace(target)) + if len(parts) < 2 { + return false + } + + path := parts[1] + if path == "/favicon.ico" || path == "/status" || path == "/status/modal" { + return true + } + if strings.HasPrefix(path, "/.well-known/") { + return true + } + if strings.HasSuffix(path, "/dependencies") || strings.HasSuffix(path, "/dependencies/options") { + return true + } + + return false +} + +func isAutoValidationSource(source models.ValidationTriggerSource) bool { + switch source { + case models.ValidationTriggerAutoPoll, models.ValidationTriggerInitCheck, models.ValidationTriggerStatusCheck: + return true + default: + return false + } +} + +func validationSourcePriority(source models.ValidationTriggerSource) int { + switch source { + case models.ValidationTriggerManualSubmit: + return 4 + case models.ValidationTriggerStatusCheck: + return 3 + case models.ValidationTriggerInitCheck: + return 2 + case models.ValidationTriggerAutoPoll: + return 1 + default: + return 0 + } +} + +func shouldPromoteValidationSource(current, incoming models.ValidationTriggerSource) bool { + return validationSourcePriority(incoming) >= validationSourcePriority(current) +} + +func normalizeFailureReason(message string) string { + lower := strings.ToLower(strings.TrimSpace(message)) + + switch { + case lower == "": + return "validation_failed" + case strings.Contains(lower, "address already in use"): + return "interface_port_in_use" + case strings.Contains(lower, "no issues were created"): + return "no_issues_created" + case strings.Contains(lower, "assignee") && strings.Contains(lower, "expected"): + return "assignee_mismatch" + case strings.Contains(lower, "status") && strings.Contains(lower, "expected"): + return "status_mismatch" + case strings.Contains(lower, "priority") && strings.Contains(lower, "expected"): + return "priority_mismatch" + case strings.Contains(lower, "title") && strings.Contains(lower, "expected"): + return "title_mismatch" + case strings.Contains(lower, "description") && strings.Contains(lower, "expected"): + return "description_mismatch" + case strings.Contains(lower, "expected") && strings.Contains(lower, "got"): + return "value_mismatch" + default: + code := normalizeAction(message) + if code == "" || code == "unknown_action" { + return "validation_failed" + } + return code + } +} + +func inferRunOutcome(run models.TaskRunMetrics) models.RunOutcome { + if run.Completed { + return models.RunOutcomeCompleted + } + + if run.QuestionnaireUserQuit { + return models.RunOutcomeUserQuit + } + + lowerErr := strings.ToLower(strings.TrimSpace(run.Error)) + if lowerErr != "" { + if strings.Contains(lowerErr, "address already in use") || strings.Contains(lowerErr, "bind:") { + return models.RunOutcomeInfraError + } + if strings.Contains(lowerErr, "user quit") { + return models.RunOutcomeUserQuit + } + } + + return models.RunOutcomeUserIncomplete +} + func normalizeUserAction(raw string) (source, actionText, target, result string) { trimmed := strings.TrimSpace(raw) if trimmed == "" { diff --git a/pkg/task/runner.go b/pkg/task/runner.go index 6529939..9e4a30f 100644 --- a/pkg/task/runner.go +++ b/pkg/task/runner.go @@ -70,7 +70,7 @@ func (r *TaskRunner) Run(ctx context.Context, t Tasker, i Interface, iType Inter // Validation feedbackChan := make(chan ValidationFeedback, 10) quitChan := make(chan bool, 1) - submitChan := make(chan struct{}, 1) + submitChan := make(chan models.ValidationTrigger, 1) if validated, ok := i.(ValidatedInterface); ok { validated.SetChannels(feedbackChan, quitChan) @@ -82,8 +82,8 @@ func (r *TaskRunner) Run(ctx context.Context, t Tasker, i Interface, iType Inter } engine := &ValidationEngine{task: t} - doneChan, stopChan := engine.Start(ctx, submitChan, func(feedback ValidationFeedback) { - collector.recordValidation(feedback) + doneChan, stopChan := engine.Start(ctx, submitChan, func(feedback ValidationFeedback, source models.ValidationTriggerSource) { + collector.recordValidation(feedback, source) if feedback.Success { collector.setCompleted(true) diff --git a/pkg/task/validation.go b/pkg/task/validation.go index f2957e3..2544569 100644 --- a/pkg/task/validation.go +++ b/pkg/task/validation.go @@ -3,24 +3,30 @@ package task import ( "context" "time" + + "github.com/LazyBachelor/LazyPM/internal/models" ) type ValidationEngine struct { task Tasker } -func (v *ValidationEngine) Start(ctx context.Context, submitChan <-chan struct{}, onFeedback func(ValidationFeedback)) (done <-chan struct{}, stop chan<- struct{}) { +func (v *ValidationEngine) Start(ctx context.Context, submitChan <-chan models.ValidationTrigger, onFeedback func(ValidationFeedback, models.ValidationTriggerSource)) (done <-chan struct{}, stop chan<- struct{}) { doneChan := make(chan struct{}, 1) stopChan := make(chan struct{}, 1) go func() { for { select { - case <-submitChan: + case trigger := <-submitChan: feedback := v.task.Validate(ctx) + source := trigger.Source + if source == "" { + source = models.ValidationTriggerUnknown + } if onFeedback != nil { - onFeedback(feedback) + onFeedback(feedback, source) } if feedback.Success { diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index faafc0a..e10e762 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -15,7 +15,7 @@ type ValidationFeedback = models.ValidationFeedback type Tui struct { feedbackChan chan ValidationFeedback quitChan chan bool - submitChan chan<- struct{} + submitChan chan<- models.ValidationTrigger } func New() *Tui { @@ -51,6 +51,6 @@ func (t *Tui) SetChannels(feedbackChan chan ValidationFeedback, quitChan chan bo t.quitChan = quitChan } -func (t *Tui) SetSubmitChan(submitChan chan<- struct{}) { +func (t *Tui) SetSubmitChan(submitChan chan<- models.ValidationTrigger) { t.submitChan = submitChan } diff --git a/pkg/tui/views/dashboard/model.go b/pkg/tui/views/dashboard/model.go index 4ac5f07..6746a82 100644 --- a/pkg/tui/views/dashboard/model.go +++ b/pkg/tui/views/dashboard/model.go @@ -41,10 +41,10 @@ type Model struct { quitChan chan bool currentFeedback models.ValidationFeedback showComplete bool - submitChan chan<- struct{} + submitChan chan<- models.ValidationTrigger } -func NewDashboard(app *app.App, feedbackChan chan models.ValidationFeedback, quitChan chan bool, submitChan chan<- struct{}) *Model { +func NewDashboard(app *app.App, feedbackChan chan models.ValidationFeedback, quitChan chan bool, submitChan chan<- models.ValidationTrigger) *Model { m := &Model{ header: components.NewHeader("Project Manager Dashboard"), keyMap: defaultDashboardKeyMap, @@ -82,7 +82,7 @@ func NewDashboard(app *app.App, feedbackChan chan models.ValidationFeedback, qui func (m *Model) Init() tea.Cmd { if m.submitChan != nil { - m.submitChan <- struct{}{} + m.submitChan <- models.ValidationTrigger{Source: models.ValidationTriggerInitCheck} m.logAction("tui submitted validation") } return components.ListenForValidation(m.feedbackChan) @@ -117,7 +117,7 @@ func (m *Model) logAction(action string) { func (m *Model) submitValidation() { if m.submitChan != nil { select { - case m.submitChan <- struct{}{}: + case m.submitChan <- models.ValidationTrigger{Source: models.ValidationTriggerManualSubmit}: m.logAction("tui submitted validation") default: } diff --git a/pkg/tui/views/kanban/model.go b/pkg/tui/views/kanban/model.go index f53ce16..5656484 100644 --- a/pkg/tui/views/kanban/model.go +++ b/pkg/tui/views/kanban/model.go @@ -47,10 +47,10 @@ type Model struct { quitChan chan bool currentFeedback models.ValidationFeedback showComplete bool - submitChan chan<- struct{} + submitChan chan<- models.ValidationTrigger } -func NewDashboard(app *app.App, feedbackChan chan models.ValidationFeedback, quitChan chan bool, submitChan chan<- struct{}) *Model { +func NewDashboard(app *app.App, feedbackChan chan models.ValidationFeedback, quitChan chan bool, submitChan chan<- models.ValidationTrigger) *Model { m := &Model{ header: components.NewHeader("Kanban Board"), keyMap: defaultKanbanKeyMap, @@ -118,7 +118,7 @@ func NewDashboard(app *app.App, feedbackChan chan models.ValidationFeedback, qui func (m *Model) Init() tea.Cmd { if m.submitChan != nil { - m.submitChan <- struct{}{} + m.submitChan <- models.ValidationTrigger{Source: models.ValidationTriggerInitCheck} m.logAction("tui submitted validation") } return components.ListenForValidation(m.feedbackChan) @@ -140,7 +140,7 @@ func (m *Model) logAction(action string) { func (m *Model) submitValidation() { if m.submitChan != nil { select { - case m.submitChan <- struct{}{}: + case m.submitChan <- models.ValidationTrigger{Source: models.ValidationTriggerManualSubmit}: m.logAction("tui submitted validation") default: } diff --git a/pkg/tui/views/root.go b/pkg/tui/views/root.go index fc8118d..0158b5d 100644 --- a/pkg/tui/views/root.go +++ b/pkg/tui/views/root.go @@ -14,12 +14,12 @@ type RootModel struct { app *app.App feedbackChan chan models.ValidationFeedback quitChan chan bool - submitChan chan<- struct{} + submitChan chan<- models.ValidationTrigger lastSize tea.WindowSizeMsg hasSize bool } -func NewRootView(app *app.App, feedbackChan chan models.ValidationFeedback, quitChan chan bool, submitChan chan<- struct{}) *RootModel { +func NewRootView(app *app.App, feedbackChan chan models.ValidationFeedback, quitChan chan bool, submitChan chan<- models.ValidationTrigger) *RootModel { initialView := dashboard.NewDashboard(app, feedbackChan, quitChan, submitChan) return &RootModel{ currentView: initialView, diff --git a/pkg/web/handler/task.go b/pkg/web/handler/task.go index 87c02be..8083474 100644 --- a/pkg/web/handler/task.go +++ b/pkg/web/handler/task.go @@ -14,18 +14,23 @@ import ( type ValidationFeedback = models.ValidationFeedback var taskFeedback ValidationFeedback -var submitChan chan<- struct{} +var submitChan chan<- models.ValidationTrigger func SetTaskFeedback(feedback ValidationFeedback) { taskFeedback = feedback } -func SetSubmitChan(ch chan<- struct{}) { +func SetSubmitChan(ch chan<- models.ValidationTrigger) { submitChan = ch } func HandleTaskStatus(w http.ResponseWriter, r *http.Request) { - submitChan <- struct{}{} + if submitChan != nil { + select { + case submitChan <- models.ValidationTrigger{Source: models.ValidationTriggerAutoPoll}: + default: + } + } time.Sleep(100 * time.Millisecond) hx := HTMX(r) diff --git a/pkg/web/server/routes.go b/pkg/web/server/routes.go index 515c41f..7398379 100644 --- a/pkg/web/server/routes.go +++ b/pkg/web/server/routes.go @@ -90,11 +90,21 @@ func shouldLogWebAction(path string) bool { if strings.HasPrefix(path, "/assets/") { return false } + if strings.HasPrefix(path, "/.well-known/") { + return false + } switch path { case "/status": return false + case "/status/modal": + return false + case "/favicon.ico": + return false default: + if strings.HasSuffix(path, "/dependencies") || strings.HasSuffix(path, "/dependencies/options") { + return false + } return true } } diff --git a/pkg/web/web.go b/pkg/web/web.go index 42fcd3a..6c2628a 100644 --- a/pkg/web/web.go +++ b/pkg/web/web.go @@ -27,7 +27,7 @@ type ValidationFeedback = models.ValidationFeedback type Web struct { feedbackChan chan ValidationFeedback quitChan chan bool - submitChan chan<- struct{} + submitChan chan<- models.ValidationTrigger } func New() *Web { @@ -195,6 +195,6 @@ func (w *Web) SetChannels(feedbackChan chan ValidationFeedback, quitChan chan bo w.quitChan = quitChan } -func (w *Web) SetSubmitChan(submitChan chan<- struct{}) { +func (w *Web) SetSubmitChan(submitChan chan<- models.ValidationTrigger) { w.submitChan = submitChan }