refactor for separation of concern for better readabiliy and maintainability

This commit is contained in:
Robin Olsen
2026-03-03 11:41:07 +01:00
parent e8d9f9d1b6
commit 763f079063
11 changed files with 530 additions and 471 deletions

View File

@@ -83,7 +83,9 @@ func taskLoop(ctx context.Context, application *task.App, surveyTasks map[string
iIdx := idx % len(iNames) iIdx := idx % len(iNames)
selected := interfaces[iNames[iIdx]] selected := interfaces[iNames[iIdx]]
if err := task.RunTask(ctx, application, t, selected, tasks.InterfaceToType(selected)); err != nil { runner := task.NewTaskRunner(application)
if err := runner.Run(ctx, t, selected, tasks.InterfaceToType(selected)); err != nil {
return err return err
} }
idx++ idx++

View File

@@ -16,11 +16,9 @@ type InteractiveInitializer struct{}
func (i InteractiveInitializer) Init(path string) error { func (i InteractiveInitializer) Init(path string) error {
_, err := os.Stat(path) _, err := os.Stat(path)
if err == nil { if err == nil {
// Path exists; assume already initialized.
return nil return nil
} }
if !os.IsNotExist(err) { if !os.IsNotExist(err) {
// Some other filesystem error; propagate it to the caller.
return err return err
} }

View File

@@ -26,7 +26,7 @@ type AppBuilder struct {
func defaultBuilder(ctx context.Context, config Config) *AppBuilder { func defaultBuilder(ctx context.Context, config Config) *AppBuilder {
lifecycle := NewLifecycle() lifecycle := NewLifecycle()
logger := newDefaultLogger(config, lifecycle) logger := defaultLogger(config, lifecycle)
return &AppBuilder{ return &AppBuilder{
ctx: ctx, ctx: ctx,
@@ -37,7 +37,7 @@ func defaultBuilder(ctx context.Context, config Config) *AppBuilder {
} }
} }
func newDefaultLogger(config Config, lifecycle *Lifecycle) *slog.Logger { func defaultLogger(config Config, lifecycle *Lifecycle) *slog.Logger {
statsDir := filepath.Dir(config.StatisticsStoragePath) statsDir := filepath.Dir(config.StatisticsStoragePath)
if statsDir == "" { if statsDir == "" {
statsDir = "." statsDir = "."

View File

@@ -2,7 +2,7 @@ package app
import ( import (
"context" "context"
"errors" "fmt"
"log/slog" "log/slog"
"sync" "sync"
"time" "time"
@@ -37,7 +37,7 @@ func (s *StatisticsService) Save(ctx context.Context) error {
func (s *StatisticsService) GetStatistics() (models.Statistics, error) { func (s *StatisticsService) GetStatistics() (models.Statistics, error) {
if s.storage.Data == nil { if s.storage.Data == nil {
return models.Statistics{}, errors.New("statistics data not initialized") return models.Statistics{}, fmt.Errorf("statistics data not initialized")
} }
return *s.storage.Data, nil return *s.storage.Data, nil
} }
@@ -49,7 +49,7 @@ func (s *StatisticsService) RecordTaskRun(ctx context.Context, run models.TaskRu
defer s.mu.Unlock() defer s.mu.Unlock()
if s.storage.Data == nil { if s.storage.Data == nil {
return errors.New("statistics data not initialized") return fmt.Errorf("statistics data not initialized")
} }
stats := s.storage.Data stats := s.storage.Data

39
pkg/task/lifecsycle.go Normal file
View File

@@ -0,0 +1,39 @@
package task
import (
"log/slog"
"github.com/LazyBachelor/LazyPM/internal/models"
)
type RunLifecycle struct {
collector *taskRunCollector
config Config
details models.TaskDetails
app *App
logger *slog.Logger
metricsStore MetricsStore
}
func NewRunLifecycle(app *App, config Config, details models.TaskDetails, iType InterfaceType, logger *slog.Logger) *RunLifecycle {
collector := newTaskRunCollector(details.Title, iType, logger)
config = config.WithActionLogger(func(action string) {
collector.recordUserAction(action)
})
var store MetricsStore
if config.StatisticsStoragePath != "" {
store = NewFileMetricsStore(config.StatisticsStoragePath, logger)
}
return &RunLifecycle{
collector: collector,
config: config,
details: details,
app: app,
logger: logger,
metricsStore: store,
}
}

View File

@@ -1,11 +1,8 @@
package task package task
import ( import (
"encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
"os"
"path/filepath"
"regexp" "regexp"
"sort" "sort"
"strings" "strings"
@@ -35,32 +32,51 @@ func newTaskRunCollector(taskName string, interfaceType InterfaceType, logger *s
} }
} }
func (c *taskRunCollector) log(level string, message string) { func (c *taskRunCollector) appendLog(entry models.TaskLogEntry) {
action := normalizeAction(message) entry.Timestamp = time.Now()
result := ""
c.run.Logs = append(c.run.Logs, entry)
if c.logger == nil {
return
}
attrs := []any{
"task", c.run.TaskName,
"interface", c.run.InterfaceType,
"action", entry.Action,
"result", entry.Result,
}
switch entry.Level {
case "error":
c.logger.Error(entry.Message, attrs...)
case "warn":
c.logger.Warn(entry.Message, attrs...)
default:
c.logger.Info(entry.Message, attrs...)
}
}
func (c *taskRunCollector) log(level, message string) {
result := "ok"
switch level { switch level {
case "error": case "error":
result = "failed" result = "failed"
case "warn": case "warn":
result = "warning" result = "warning"
default:
result = "ok"
} }
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
c.run.Logs = append(c.run.Logs, models.TaskLogEntry{ c.appendLog(models.TaskLogEntry{
Timestamp: time.Now(),
Level: level, Level: level,
Message: message, Message: message,
Source: "system", Source: "system",
Action: action, Action: normalizeAction(message),
Result: result, Result: result,
}) })
attrs := []any{"action", action, "result", result, "task", c.run.TaskName, "interface", c.run.InterfaceType}
c.logWithLogger(level, message, attrs...)
} }
func (c *taskRunCollector) recordUserAction(raw string) { func (c *taskRunCollector) recordUserAction(raw string) {
@@ -69,8 +85,7 @@ func (c *taskRunCollector) recordUserAction(raw string) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock() defer c.mu.Unlock()
c.run.Logs = append(c.run.Logs, models.TaskLogEntry{ c.appendLog(models.TaskLogEntry{
Timestamp: time.Now(),
Level: "user_action", Level: "user_action",
Message: raw, Message: raw,
Source: source, Source: source,
@@ -78,33 +93,6 @@ func (c *taskRunCollector) recordUserAction(raw string) {
Target: target, Target: target,
Result: result, Result: result,
}) })
c.logWithLogger("info", "user action recorded",
"task", c.run.TaskName,
"interface", c.run.InterfaceType,
"source", source,
"action", normalizeAction(actionText),
"target", target,
"result", result,
)
}
func (c *taskRunCollector) setCompleted(completed bool) {
c.mu.Lock()
defer c.mu.Unlock()
c.run.Completed = completed
}
func (c *taskRunCollector) setError(err error) {
if err == nil {
return
}
c.mu.Lock()
defer c.mu.Unlock()
c.run.Error = err.Error()
} }
func (c *taskRunCollector) recordQuestionnaire(completed bool, userQuit bool, answers map[string]any) { func (c *taskRunCollector) recordQuestionnaire(completed bool, userQuit bool, answers map[string]any) {
@@ -113,6 +101,7 @@ func (c *taskRunCollector) recordQuestionnaire(completed bool, userQuit bool, an
c.run.QuestionnaireCompleted = completed c.run.QuestionnaireCompleted = completed
c.run.QuestionnaireUserQuit = userQuit c.run.QuestionnaireUserQuit = userQuit
if len(answers) > 0 { if len(answers) > 0 {
c.run.QuestionnaireAnswers = answers c.run.QuestionnaireAnswers = answers
} }
@@ -124,8 +113,7 @@ func (c *taskRunCollector) recordQuestionnaire(completed bool, userQuit bool, an
result = "incomplete" result = "incomplete"
} }
c.run.Logs = append(c.run.Logs, models.TaskLogEntry{ c.appendLog(models.TaskLogEntry{
Timestamp: time.Now(),
Level: "questionnaire", Level: "questionnaire",
Message: "questionnaire finished", Message: "questionnaire finished",
Source: "system", Source: "system",
@@ -133,119 +121,115 @@ func (c *taskRunCollector) recordQuestionnaire(completed bool, userQuit bool, an
Result: result, Result: result,
}) })
if len(answers) > 0 { if len(answers) == 0 {
return
}
keys := make([]string, 0, len(answers)) keys := make([]string, 0, len(answers))
for key := range answers { for k := range answers {
keys = append(keys, key) keys = append(keys, k)
} }
sort.Strings(keys) sort.Strings(keys)
for _, key := range keys { for _, k := range keys {
value := answers[key] v := answers[k]
if value == nil { if v == nil {
continue continue
} }
valueText := fmt.Sprintf("%v", value) valueText := fmt.Sprintf("%v", v)
c.run.Logs = append(c.run.Logs, models.TaskLogEntry{
Timestamp: time.Now(), c.appendLog(models.TaskLogEntry{
Level: "questionnaire", Level: "questionnaire",
Message: fmt.Sprintf("questionnaire answer: %s=%s", key, valueText), Message: fmt.Sprintf("questionnaire answer: %s=%s", k, valueText),
Source: "system", Source: "system",
Action: "questionnaire_answer", Action: "questionnaire_answer",
Target: key, Target: k,
Result: valueText, Result: valueText,
}) })
} }
} }
c.logWithLogger("info", "questionnaire recorded",
"task", c.run.TaskName,
"completed", completed,
"user_quit", userQuit,
"answers_count", len(answers),
)
}
func (c *taskRunCollector) recordValidation(feedback ValidationFeedback) { func (c *taskRunCollector) recordValidation(feedback ValidationFeedback) {
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock()
c.run.ValidationAttempts++ c.run.ValidationAttempts++
attempt := c.run.ValidationAttempts attempt := c.run.ValidationAttempts
result := "failed"
if feedback.Success { if feedback.Success {
result = "passed"
c.run.ValidationSuccesses++ c.run.ValidationSuccesses++
} else { } else {
c.run.ValidationFailures++ c.run.ValidationFailures++
} }
c.run.Logs = append(c.run.Logs, models.TaskLogEntry{ c.appendLog(models.TaskLogEntry{
Timestamp: time.Now(),
Level: "validation", Level: "validation",
Message: fmt.Sprintf("validation attempt %d success=%t", attempt, feedback.Success), Message: fmt.Sprintf("validation attempt %d", attempt),
Source: "system", Source: "system",
Action: "validate_attempt", Action: "validate_attempt",
Result: validationResult(feedback.Success), Result: result,
Attempt: attempt, Attempt: attempt,
}) })
c.logWithLogger("info", "validation attempt",
"task", c.run.TaskName,
"interface", c.run.InterfaceType,
"attempt", attempt,
"result", validationResult(feedback.Success),
)
for _, check := range feedback.Checks { for _, check := range feedback.Checks {
checkResult := "failed"
if check.Valid { if check.Valid {
checkResult = "passed"
c.run.ValidationChecksPassed++ c.run.ValidationChecksPassed++
c.run.Logs = append(c.run.Logs, models.TaskLogEntry{ } else {
Timestamp: time.Now(),
Level: "validation",
Message: fmt.Sprintf("validation check passed: %s", check.Message),
Source: "system",
Action: "validate_check",
Target: check.Message,
Result: "passed",
Attempt: attempt,
})
c.logWithLogger("info", "validation check",
"task", c.run.TaskName,
"attempt", attempt,
"result", "passed",
"target", check.Message,
)
continue
}
c.run.ValidationChecksFailed++ c.run.ValidationChecksFailed++
c.run.Logs = append(c.run.Logs, models.TaskLogEntry{ }
Timestamp: time.Now(),
c.appendLog(models.TaskLogEntry{
Level: "validation", Level: "validation",
Message: fmt.Sprintf("validation check failed: %s", check.Message), Message: fmt.Sprintf("validation check: %s", check.Message),
Source: "system", Source: "system",
Action: "validate_check", Action: "validate_check",
Target: check.Message, Target: check.Message,
Result: "failed", Result: checkResult,
Attempt: attempt, Attempt: attempt,
}) })
c.logWithLogger("warn", "validation check",
"task", c.run.TaskName,
"attempt", attempt,
"result", "failed",
"target", check.Message,
)
} }
c.run.LastValidationMessage = feedback.Message c.run.LastValidationMessage = feedback.Message
c.mu.Unlock()
} }
func validationResult(success bool) string { func (c *taskRunCollector) setCompleted(completed bool) {
if success { c.mu.Lock()
return "passed" c.run.Completed = completed
} c.mu.Unlock()
return "failed"
} }
func normalizeUserAction(raw string) (source string, actionText string, target string, result string) { func (c *taskRunCollector) setError(err error) {
if err == nil {
return
}
c.mu.Lock()
c.run.Error = err.Error()
c.mu.Unlock()
}
func (c *taskRunCollector) finalize() models.TaskRunMetrics {
c.mu.Lock()
defer c.mu.Unlock()
if c.run.EndedAt.IsZero() {
c.run.EndedAt = time.Now()
}
c.run.DurationMs =
c.run.EndedAt.Sub(c.run.StartedAt).Milliseconds()
final := c.run
final.Logs = append([]models.TaskLogEntry(nil), c.run.Logs...)
return final
}
func normalizeUserAction(raw string) (source, actionText, target, result string) {
trimmed := strings.TrimSpace(raw) trimmed := strings.TrimSpace(raw)
if trimmed == "" { if trimmed == "" {
return "unknown", "unknown_action", "", "unknown" return "unknown", "unknown_action", "", "unknown"
@@ -256,57 +240,37 @@ func normalizeUserAction(raw string) (source string, actionText string, target s
if source == "" { if source == "" {
source = "unknown" source = "unknown"
} }
actionText = strings.TrimSpace(event.Action)
if actionText == "" { actionText = stripSourcePrefix(
actionText = "unknown_action" strings.TrimSpace(event.Action),
} source,
actionText = stripSourcePrefix(actionText, source) )
target = strings.TrimSpace(event.Target) target = strings.TrimSpace(event.Target)
result = strings.TrimSpace(event.Result) result = strings.TrimSpace(event.Result)
if result == "" { if result == "" {
result = inferActionResult(actionText) result = inferActionResult(actionText)
} }
return source, actionText, target, result return
} }
result = "ok"
lower := strings.ToLower(trimmed) lower := strings.ToLower(trimmed)
if strings.HasPrefix(lower, "web request:") { if strings.HasPrefix(lower, "web request:") {
rest := strings.TrimSpace(trimmed[len("web request:"):]) rest := strings.TrimSpace(trimmed[len("web request:"):])
parts := strings.Fields(rest)
if len(parts) >= 2 {
return "web", "request", strings.ToUpper(parts[0]) + " " + parts[1], "ok"
}
return "web", "request", rest, "ok" return "web", "request", rest, "ok"
} }
if strings.HasPrefix(lower, "repl command:") { if strings.HasPrefix(lower, "repl command:") {
command := strings.TrimSpace(trimmed[len("repl command:"):]) cmd := strings.TrimSpace(trimmed[len("repl command:"):])
return "repl", "run_command", command, "ok" return "repl", "run_command", cmd, "ok"
} }
words := strings.Fields(trimmed) return "unknown", trimmed, "", inferActionResult(trimmed)
if len(words) > 1 {
head := strings.ToLower(words[0])
if head == "tui" || head == "web" || head == "repl" {
source = head
actionText = strings.Join(words[1:], " ")
} else {
source = "unknown"
actionText = trimmed
}
} else {
source = "unknown"
actionText = trimmed
} }
result = inferActionResult(actionText) func stripSourcePrefix(actionText, source string) string {
return source, actionText, "", result
}
func stripSourcePrefix(actionText string, source string) string {
actionText = strings.TrimSpace(actionText) actionText = strings.TrimSpace(actionText)
if actionText == "" || source == "" { if actionText == "" || source == "" {
return actionText return actionText
@@ -327,176 +291,37 @@ func stripSourcePrefix(actionText string, source string) string {
func inferActionResult(actionText string) string { func inferActionResult(actionText string) string {
lower := strings.ToLower(actionText) lower := strings.ToLower(actionText)
if strings.Contains(lower, "failed") {
switch {
case strings.Contains(lower, "failed"):
return "failed" return "failed"
} case strings.Contains(lower, "canceled"):
if strings.Contains(lower, "canceled") {
return "canceled" return "canceled"
} case strings.Contains(lower, "started"):
if strings.Contains(lower, "started") {
return "started" return "started"
} case strings.Contains(lower, "submitted"):
if strings.Contains(lower, "submitted") {
return "submitted" return "submitted"
} case strings.Contains(lower, "requested"):
if strings.Contains(lower, "requested") {
return "requested" return "requested"
} default:
return "ok" return "ok"
} }
}
func normalizeAction(input string) string { func normalizeAction(input string) string {
lower := strings.ToLower(strings.TrimSpace(input)) lower := strings.ToLower(strings.TrimSpace(input))
if lower == "" { if lower == "" {
return "unknown_action" return "unknown_action"
} }
clean := nonWord.ReplaceAllString(lower, "_")
clean = strings.Trim(clean, "_") clean := strings.Trim(
nonWord.ReplaceAllString(lower, "_"),
"_",
)
if clean == "" { if clean == "" {
return "unknown_action" return "unknown_action"
} }
return clean return clean
} }
func (c *taskRunCollector) finalize() models.TaskRunMetrics {
c.mu.Lock()
defer c.mu.Unlock()
if c.run.EndedAt.IsZero() {
c.run.EndedAt = time.Now()
}
c.run.DurationMs = c.run.EndedAt.Sub(c.run.StartedAt).Milliseconds()
finalLogs := make([]models.TaskLogEntry, len(c.run.Logs))
copy(finalLogs, c.run.Logs)
final := c.run
final.Logs = finalLogs
return final
}
func appendTaskMetrics(path string, taskName string, run models.TaskRunMetrics, logger *slog.Logger) error {
if path == "" {
return nil
}
dir := filepath.Dir(path)
if dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("failed to create metrics directory: %w", err)
}
}
metrics := models.TaskMetricsFile{
TaskName: taskName,
Runs: []models.TaskRunMetrics{},
}
if bytes, err := os.ReadFile(path); err == nil {
if len(bytes) > 0 {
if err := json.Unmarshal(bytes, &metrics); err != nil {
return fmt.Errorf("failed to parse metrics file %q: %w", path, err)
}
}
} else if !os.IsNotExist(err) {
return fmt.Errorf("failed to read metrics file %q: %w", path, err)
}
if metrics.TaskName == "" {
metrics.TaskName = taskName
}
run.RunID = len(metrics.Runs) + 1
metrics.Runs = append(metrics.Runs, run)
metrics.Summary = buildTaskStatsSummary(metrics.Runs)
metrics.UpdatedAt = time.Now()
data, err := json.MarshalIndent(metrics, "", " ")
if err != nil {
return fmt.Errorf("failed to encode task metrics: %w", err)
}
if err := os.WriteFile(path, data, 0o644); err != nil {
return fmt.Errorf("failed to write metrics file %q: %w", path, err)
}
if logger != nil {
logger.Info("task metrics persisted",
"path", path,
"task", taskName,
"run_id", run.RunID,
"total_runs", metrics.Summary.TotalRuns,
"completed_runs", metrics.Summary.CompletedRuns,
)
}
return nil
}
func (c *taskRunCollector) logWithLogger(level string, message string, attrs ...any) {
if c.logger == nil {
return
}
switch level {
case "error":
c.logger.Error(message, attrs...)
case "warn":
c.logger.Warn(message, attrs...)
default:
c.logger.Info(message, attrs...)
}
}
func buildTaskStatsSummary(runs []models.TaskRunMetrics) models.TaskStatsSummary {
summary := models.TaskStatsSummary{}
if len(runs) == 0 {
return summary
}
summary.TotalRuns = len(runs)
summary.FirstRunStartedAt = runs[0].StartedAt
for _, run := range runs {
summary.TotalDurationMs += run.DurationMs
summary.ValidationAttempts += run.ValidationAttempts
summary.ValidationSuccesses += run.ValidationSuccesses
summary.ValidationFailures += run.ValidationFailures
summary.ValidationChecksPassed += run.ValidationChecksPassed
summary.ValidationChecksFailed += run.ValidationChecksFailed
if run.Completed {
summary.CompletedRuns++
} else {
summary.IncompleteRuns++
}
if run.QuestionnaireCompleted {
summary.QuestionnairesCompleted++
}
if run.QuestionnaireUserQuit {
summary.QuestionnairesAbandoned++
}
if run.StartedAt.Before(summary.FirstRunStartedAt) {
summary.FirstRunStartedAt = run.StartedAt
}
if run.StartedAt.After(summary.LastRunStartedAt) {
summary.LastRunStartedAt = run.StartedAt
}
if run.EndedAt.After(summary.LastRunEndedAt) {
summary.LastRunEndedAt = run.EndedAt
summary.LastInterfaceType = run.InterfaceType
}
for _, log := range run.Logs {
if log.Level == "user_action" {
summary.TotalUserActions++
}
}
}
summary.AverageDurationMs = summary.TotalDurationMs / int64(summary.TotalRuns)
return summary
}

87
pkg/task/metrics_store.go Normal file
View File

@@ -0,0 +1,87 @@
package task
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"time"
"github.com/LazyBachelor/LazyPM/internal/models"
)
type MetricsStore interface {
Append(ctx context.Context, taskName string, run models.TaskRunMetrics) error
}
type FileMetricsStore struct {
path string
logger *slog.Logger
}
func NewFileMetricsStore(path string, logger *slog.Logger) *FileMetricsStore {
return &FileMetricsStore{
path: path,
logger: logger,
}
}
func (s *FileMetricsStore) Append(ctx context.Context, taskName string, run models.TaskRunMetrics) error {
if s.path == "" {
return nil
}
dir := filepath.Dir(s.path)
if dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create metrics directory: %w", err)
}
}
metrics := models.TaskMetricsFile{
TaskName: taskName,
Runs: []models.TaskRunMetrics{},
}
if bytes, err := os.ReadFile(s.path); err == nil {
if len(bytes) > 0 {
if err := json.Unmarshal(bytes, &metrics); err != nil {
return fmt.Errorf("parse metrics file: %w", err)
}
}
} else if !os.IsNotExist(err) {
return fmt.Errorf("read metrics file: %w", err)
}
if metrics.TaskName == "" {
metrics.TaskName = taskName
}
run.RunID = len(metrics.Runs) + 1
metrics.Runs = append(metrics.Runs, run)
metrics.Summary = buildTaskStatsSummary(metrics.Runs)
metrics.UpdatedAt = time.Now()
data, err := json.MarshalIndent(metrics, "", " ")
if err != nil {
return fmt.Errorf("encode metrics: %w", err)
}
if err := os.WriteFile(s.path, data, 0o644); err != nil {
return fmt.Errorf("write metrics file: %w", err)
}
if s.logger != nil {
s.logger.Info(
"task metrics persisted",
"path", s.path,
"task", taskName,
"run_id", run.RunID,
)
}
return nil
}

View File

@@ -0,0 +1,72 @@
package task
import (
"github.com/LazyBachelor/LazyPM/internal/models"
)
func buildTaskStatsSummary(runs []models.TaskRunMetrics) models.TaskStatsSummary {
var summary models.TaskStatsSummary
if len(runs) == 0 {
return summary
}
summary.TotalRuns = len(runs)
for i, run := range runs {
// First run handling
if i == 0 || run.StartedAt.Before(summary.FirstRunStartedAt) {
summary.FirstRunStartedAt = run.StartedAt
}
if run.StartedAt.After(summary.LastRunStartedAt) {
summary.LastRunStartedAt = run.StartedAt
}
if run.EndedAt.After(summary.LastRunEndedAt) {
summary.LastRunEndedAt = run.EndedAt
summary.LastInterfaceType = run.InterfaceType
}
// Duration
summary.TotalDurationMs += run.DurationMs
// Completion
if run.Completed {
summary.CompletedRuns++
} else {
summary.IncompleteRuns++
}
// Validation
summary.ValidationAttempts += run.ValidationAttempts
summary.ValidationSuccesses += run.ValidationSuccesses
summary.ValidationFailures += run.ValidationFailures
summary.ValidationChecksPassed += run.ValidationChecksPassed
summary.ValidationChecksFailed += run.ValidationChecksFailed
// Questionnaire
if run.QuestionnaireCompleted {
summary.QuestionnairesCompleted++
}
if run.QuestionnaireUserQuit {
summary.QuestionnairesAbandoned++
}
// User actions
for _, log := range run.Logs {
if log.Level == "user_action" {
summary.TotalUserActions++
}
}
}
if summary.TotalRuns > 0 {
summary.AverageDurationMs =
summary.TotalDurationMs / int64(summary.TotalRuns)
}
return summary
}

View File

@@ -4,7 +4,6 @@ import (
"context" "context"
"fmt" "fmt"
"log/slog" "log/slog"
"time"
"github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/internal/models"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
@@ -12,12 +11,9 @@ import (
type App = models.App type App = models.App
type Config = models.Config type Config = models.Config
type Tasker = models.Tasker type Tasker = models.Tasker
type Interface = models.Interface type Interface = models.Interface
type InterfaceType = models.InterfaceType type InterfaceType = models.InterfaceType
type ValidatedInterface = models.ValidatedInterface type ValidatedInterface = models.ValidatedInterface
type ValidationFeedback = models.ValidationFeedback type ValidationFeedback = models.ValidationFeedback
@@ -27,179 +23,174 @@ type QuestionnaireKeysProvider interface {
var ErrUserQuit = models.ErrUserQuit var ErrUserQuit = models.ErrUserQuit
// RunTask orchestrates the complete task execution flow: type TaskRunner struct {
// 1. Setup the task app *App
// 2. Show task intro screen logger *slog.Logger
// 3. Run the interface }
// 4. Start validation loop in background
// 5. Show questionnaire when done
func RunTask(ctx context.Context, app *App, t Tasker, i Interface, iType InterfaceType) (runErr error) {
config := t.Config()
details := t.Details()
func NewTaskRunner(app *App) *TaskRunner {
var logger *slog.Logger var logger *slog.Logger
if app != nil { if app != nil {
logger = app.Logger logger = app.Logger
} }
return &TaskRunner{
app: app,
logger: logger,
}
}
collector := newTaskRunCollector(details.Title, iType, logger) func (r *TaskRunner) Run(ctx context.Context, t Tasker, i Interface, iType InterfaceType) (runErr error) {
collector.log("info", "task run started")
config = config.WithActionLogger(func(action string) { config := t.Config()
collector.recordUserAction(action) details := t.Details()
})
lifecycle := NewRunLifecycle(r.app, config, details, iType, r.logger)
defer func() { defer func() {
if runErr != nil { runErr = lifecycle.Finish(ctx, runErr)
collector.setError(runErr)
collector.log("error", runErr.Error())
}
run := collector.finalize()
if err := appendTaskMetrics(config.StatisticsStoragePath, details.Title, run, logger); err != nil {
if runErr == nil {
runErr = fmt.Errorf("failed to persist task metrics: %w", err)
return
}
if logger != nil {
logger.Warn("failed to persist task metrics", "error", err, "task", details.Title)
}
}
if app != nil && app.Stats != nil {
if err := app.Stats.RecordTaskRun(ctx, run); err != nil {
if runErr == nil {
runErr = fmt.Errorf("failed to update global statistics: %w", err)
return
}
if logger != nil {
logger.Warn("failed to update global statistics", "error", err, "task", details.Title)
}
}
}
}() }()
doneChan := make(chan bool, 1) collector := lifecycle.collector
quitChan := make(chan bool, 1) config = lifecycle.config
collector.log("info", "task run started")
// Setup
if err := t.Setup(ctx); err != nil {
return fmt.Errorf("failed to setup task: %w", err)
}
// Intro screen
if err := runIntro(details); err != nil {
return err
}
// Validation
feedbackChan := make(chan ValidationFeedback, 10) feedbackChan := make(chan ValidationFeedback, 10)
quitChan := make(chan bool, 1)
if validated, ok := i.(ValidatedInterface); ok { if validated, ok := i.(ValidatedInterface); ok {
validated.SetChannels(feedbackChan, quitChan) validated.SetChannels(feedbackChan, quitChan)
} }
// Setup task engine := &ValidationEngine{task: t}
collector.log("info", "setting up task") doneChan, stopChan := engine.Start(ctx, func(feedback ValidationFeedback) {
if err := t.Setup(ctx); err != nil { collector.recordValidation(feedback)
return fmt.Errorf("failed to setup task: %w", err)
}
collector.log("info", "task setup completed")
// Show task intro if feedback.Success {
collector.log("info", "showing task intro") feedback.Message = "Task completed successfully!"
detailsScreen := NewTaskModel(details) } else if feedback.Message == "" {
model, err := tea.NewProgram(detailsScreen, tea.WithAltScreen()).Run() feedback.Message = "Task not completed!"
if err != nil {
return err
} }
if m, ok := model.(interface{ GetUserQuit() bool }); ok && m.GetUserQuit() {
collector.log("info", "user quit during task intro")
return ErrUserQuit
}
collector.log("info", "task intro completed")
// Start validation loop select {
collector.log("info", "starting validation loop") case feedbackChan <- feedback:
go startValidationLoop(ctx, t, feedbackChan, doneChan, quitChan, collector.recordValidation) default:
}
})
// Run interface // Run interface
collector.log("info", "starting task interface") interfaceErr := make(chan error, 1)
interfaceDone := make(chan error, 1)
go func() { go func() {
interfaceDone <- i.Run(ctx, config) interfaceErr <- i.Run(ctx, config)
}() }()
select { select {
case <-doneChan: case <-doneChan:
close(stopChan)
close(quitChan) close(quitChan)
collector.setCompleted(true) collector.setCompleted(true)
collector.log("info", "task validation completed")
if err := <-interfaceDone; err != nil {
if logger != nil {
logger.Warn("interface error after task completion", "error", err, "task", details.Title)
}
collector.log("warn", fmt.Sprintf("interface error after task completion: %v", err))
}
fmt.Println("Task completed successfully!")
case err := <-interfaceDone: case err := <-interfaceErr:
close(stopChan)
close(quitChan) close(quitChan)
if err != nil { if err != nil {
collector.log("error", fmt.Sprintf("task interface failed: %v", err)) return fmt.Errorf("task interface failed: %w", err)
return fmt.Errorf("failed to start task interface: %w", err)
} }
collector.log("info", "task interface exited before completion")
fmt.Println("Task incomplete - you exited early")
} }
// Show questionnaire // Questionnaire
collector.log("info", "showing post-task questionnaire") if err := runQuestionnaire(t, iType, collector); err != nil {
questions := t.Questions(iType) return err
questionnaireKeys := []string{}
if provider, ok := t.(QuestionnaireKeysProvider); ok {
questionnaireKeys = provider.QuestionnaireKeys(iType)
} }
questionare := NewQuestionnaireModel(questions, questionnaireKeys)
model, err = tea.NewProgram(questionare, tea.WithAltScreen()).Run() collector.log("info", "task run finished")
return nil
}
func (r *RunLifecycle) Finish(ctx context.Context, runErr error) error {
if runErr != nil {
r.collector.setError(runErr)
r.collector.log("error", runErr.Error())
}
run := r.collector.finalize()
if r.metricsStore != nil {
if err := r.metricsStore.Append(ctx, r.details.Title, run); err != nil && runErr == nil {
return fmt.Errorf("persist metrics: %w", err)
}
}
if r.app != nil && r.app.Stats != nil {
if err := r.app.Stats.RecordTaskRun(ctx, run); err != nil && runErr == nil {
return fmt.Errorf("update global stats: %w", err)
}
}
return runErr
}
func runIntro(details models.TaskDetails) error {
model, err := tea.NewProgram(NewTaskModel(details), tea.WithAltScreen()).Run()
if err != nil { if err != nil {
return err return err
} }
questionnaireCompleted := false if m, ok := model.(interface{ GetUserQuit() bool }); ok {
questionnaireAnswers := map[string]any(nil) if m.GetUserQuit() {
if m, ok := model.(interface {
GetCompleted() bool
GetAnswers() map[string]any
}); ok {
questionnaireCompleted = m.GetCompleted()
questionnaireAnswers = m.GetAnswers()
}
if m, ok := model.(interface{ GetUserQuit() bool }); ok && m.GetUserQuit() {
collector.recordQuestionnaire(questionnaireCompleted, true, questionnaireAnswers)
collector.log("info", "user quit during questionnaire")
return ErrUserQuit return ErrUserQuit
} }
collector.recordQuestionnaire(questionnaireCompleted, false, questionnaireAnswers) }
collector.log("info", "task run finished")
return nil return nil
} }
func startValidationLoop(ctx context.Context, t Tasker, feedbackChan chan ValidationFeedback, doneChan chan bool, quitChan chan bool, onFeedback func(ValidationFeedback)) { func runQuestionnaire(t Tasker, iType InterfaceType, collector *taskRunCollector) error {
ticker := time.NewTicker(1 * time.Second) questions := t.Questions(iType)
defer ticker.Stop()
for { keys := []string{}
select { if provider, ok := t.(QuestionnaireKeysProvider); ok {
case <-ticker.C: keys = provider.QuestionnaireKeys(iType)
feedback := t.Validate(ctx)
if onFeedback != nil {
onFeedback(feedback)
} }
if feedback.Success {
feedback.Message = "Task completed successfully!" model, err := tea.NewProgram(NewQuestionnaireModel(questions, keys), tea.WithAltScreen()).Run()
feedbackChan <- feedback if err != nil {
time.Sleep(4 * time.Second) return err
doneChan <- true
return
} }
feedback.Message = "Task not completed!"
feedbackChan <- feedback var completed bool
case <-quitChan: var answers map[string]any
return
case <-ctx.Done(): if m, ok := model.(interface {
return GetCompleted() bool
GetAnswers() map[string]any
}); ok {
completed = m.GetCompleted()
answers = m.GetAnswers()
} }
userQuit := false
if m, ok := model.(interface{ GetUserQuit() bool }); ok {
userQuit = m.GetUserQuit()
} }
collector.recordQuestionnaire(completed, userQuit, answers)
if userQuit {
return ErrUserQuit
}
return nil
} }

45
pkg/task/validation.go Normal file
View File

@@ -0,0 +1,45 @@
package task
import (
"context"
"time"
)
type ValidationEngine struct {
task Tasker
}
func (v *ValidationEngine) Start(ctx context.Context, onFeedback func(ValidationFeedback)) (done <-chan struct{}, stop chan<- struct{}) {
doneChan := make(chan struct{}, 1)
stopChan := make(chan struct{}, 1)
go func() {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
feedback := v.task.Validate(ctx)
if onFeedback != nil {
onFeedback(feedback)
}
if feedback.Success {
time.Sleep(3 * time.Second)
doneChan <- struct{}{}
return
}
case <-stopChan:
return
case <-ctx.Done():
return
}
}
}()
return doneChan, stopChan
}