refactor for separation of concern for better readabiliy and maintainability
This commit is contained in:
@@ -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++
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 = "."
|
||||||
|
|||||||
@@ -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
39
pkg/task/lifecsycle.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: normalizeAction(message),
|
||||||
Action: action,
|
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,42 +85,14 @@ 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,
|
Action: normalizeAction(actionText),
|
||||||
Action: normalizeAction(actionText),
|
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,128 +113,123 @@ 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",
|
Action: "questionnaire_finish",
|
||||||
Action: "questionnaire_finish",
|
Result: result,
|
||||||
Result: result,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if len(answers) > 0 {
|
if len(answers) == 0 {
|
||||||
keys := make([]string, 0, len(answers))
|
return
|
||||||
for key := range answers {
|
|
||||||
keys = append(keys, key)
|
|
||||||
}
|
|
||||||
sort.Strings(keys)
|
|
||||||
|
|
||||||
for _, key := range keys {
|
|
||||||
value := answers[key]
|
|
||||||
if value == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
valueText := fmt.Sprintf("%v", value)
|
|
||||||
c.run.Logs = append(c.run.Logs, models.TaskLogEntry{
|
|
||||||
Timestamp: time.Now(),
|
|
||||||
Level: "questionnaire",
|
|
||||||
Message: fmt.Sprintf("questionnaire answer: %s=%s", key, valueText),
|
|
||||||
Source: "system",
|
|
||||||
Action: "questionnaire_answer",
|
|
||||||
Target: key,
|
|
||||||
Result: valueText,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
c.logWithLogger("info", "questionnaire recorded",
|
keys := make([]string, 0, len(answers))
|
||||||
"task", c.run.TaskName,
|
for k := range answers {
|
||||||
"completed", completed,
|
keys = append(keys, k)
|
||||||
"user_quit", userQuit,
|
}
|
||||||
"answers_count", len(answers),
|
sort.Strings(keys)
|
||||||
)
|
|
||||||
|
for _, k := range keys {
|
||||||
|
v := answers[k]
|
||||||
|
if v == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
valueText := fmt.Sprintf("%v", v)
|
||||||
|
|
||||||
|
c.appendLog(models.TaskLogEntry{
|
||||||
|
Level: "questionnaire",
|
||||||
|
Message: fmt.Sprintf("questionnaire answer: %s=%s", k, valueText),
|
||||||
|
Source: "system",
|
||||||
|
Action: "questionnaire_answer",
|
||||||
|
Target: k,
|
||||||
|
Result: valueText,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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", attempt),
|
||||||
Message: fmt.Sprintf("validation attempt %d success=%t", attempt, feedback.Success),
|
Source: "system",
|
||||||
Source: "system",
|
Action: "validate_attempt",
|
||||||
Action: "validate_attempt",
|
Result: result,
|
||||||
Result: validationResult(feedback.Success),
|
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(),
|
c.run.ValidationChecksFailed++
|
||||||
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.Logs = append(c.run.Logs, models.TaskLogEntry{
|
c.appendLog(models.TaskLogEntry{
|
||||||
Timestamp: time.Now(),
|
Level: "validation",
|
||||||
Level: "validation",
|
Message: fmt.Sprintf("validation check: %s", check.Message),
|
||||||
Message: fmt.Sprintf("validation check failed: %s", check.Message),
|
Source: "system",
|
||||||
Source: "system",
|
Action: "validate_check",
|
||||||
Action: "validate_check",
|
Target: check.Message,
|
||||||
Target: check.Message,
|
Result: checkResult,
|
||||||
Result: "failed",
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *taskRunCollector) setError(err error) {
|
||||||
|
if err == nil {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
return "failed"
|
c.mu.Lock()
|
||||||
|
c.run.Error = err.Error()
|
||||||
|
c.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeUserAction(raw string) (source string, actionText string, target string, result string) {
|
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)
|
|
||||||
|
|
||||||
return source, actionText, "", result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func stripSourcePrefix(actionText string, source string) string {
|
func stripSourcePrefix(actionText, source string) string {
|
||||||
actionText = strings.TrimSpace(actionText)
|
actionText = strings.TrimSpace(actionText)
|
||||||
if actionText == "" || source == "" {
|
if actionText == "" || source == "" {
|
||||||
return actionText
|
return actionText
|
||||||
@@ -327,22 +291,21 @@ 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 {
|
||||||
@@ -350,153 +313,15 @@ func normalizeAction(input string) string {
|
|||||||
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
87
pkg/task/metrics_store.go
Normal 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
|
||||||
|
}
|
||||||
72
pkg/task/metrics_summary.go
Normal file
72
pkg/task/metrics_summary.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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 {
|
return ErrUserQuit
|
||||||
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
|
|
||||||
}
|
|
||||||
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!"
|
|
||||||
feedbackChan <- feedback
|
|
||||||
time.Sleep(4 * time.Second)
|
|
||||||
doneChan <- true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
feedback.Message = "Task not completed!"
|
|
||||||
feedbackChan <- feedback
|
|
||||||
case <-quitChan:
|
|
||||||
return
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model, err := tea.NewProgram(NewQuestionnaireModel(questions, keys), tea.WithAltScreen()).Run()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var completed bool
|
||||||
|
var answers map[string]any
|
||||||
|
|
||||||
|
if m, ok := model.(interface {
|
||||||
|
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
45
pkg/task/validation.go
Normal 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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user