Files
LazyPM/pkg/task/metrics.go
2026-03-03 13:39:20 +01:00

327 lines
6.6 KiB
Go

package task
import (
"fmt"
"log/slog"
"regexp"
"sort"
"strings"
"sync"
"time"
"github.com/LazyBachelor/LazyPM/internal/models"
)
type taskRunCollector struct {
mu sync.Mutex
run models.TaskRunMetrics
logger *slog.Logger
}
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),
},
logger: logger,
}
}
func (c *taskRunCollector) appendLog(entry models.TaskLogEntry) {
entry.Timestamp = time.Now()
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 {
case "error":
result = "failed"
case "warn":
result = "warning"
}
c.mu.Lock()
defer c.mu.Unlock()
c.appendLog(models.TaskLogEntry{
Level: level,
Message: message,
Source: "system",
Action: normalizeAction(message),
Result: result,
})
}
func (c *taskRunCollector) recordUserAction(raw string) {
source, actionText, target, result := normalizeUserAction(raw)
c.mu.Lock()
defer c.mu.Unlock()
c.appendLog(models.TaskLogEntry{
Level: "user_action",
Message: raw,
Source: source,
Action: normalizeAction(actionText),
Target: target,
Result: result,
})
}
func (c *taskRunCollector) recordQuestionnaire(completed bool, userQuit bool, answers map[string]any) {
c.mu.Lock()
defer c.mu.Unlock()
c.run.QuestionnaireCompleted = completed
c.run.QuestionnaireUserQuit = userQuit
if len(answers) > 0 {
c.run.QuestionnaireAnswers = answers
}
result := "completed"
if userQuit {
result = "user_quit"
} else if !completed {
result = "incomplete"
}
c.appendLog(models.TaskLogEntry{
Level: "questionnaire",
Message: "questionnaire finished",
Source: "system",
Action: "questionnaire_finish",
Result: result,
})
if len(answers) == 0 {
return
}
keys := make([]string, 0, len(answers))
for k := range answers {
keys = append(keys, k)
}
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) {
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.appendLog(models.TaskLogEntry{
Level: "validation",
Message: fmt.Sprintf("validation attempt %d", attempt),
Source: "system",
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.run.LastValidationMessage = feedback.Message
c.mu.Unlock()
}
func (c *taskRunCollector) setCompleted(completed bool) {
c.mu.Lock()
c.run.Completed = completed
c.mu.Unlock()
}
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)
if trimmed == "" {
return "unknown", "unknown_action", "", "unknown"
}
if event, ok := models.DecodeActionEvent(trimmed); ok {
source = strings.TrimSpace(event.Source)
if source == "" {
source = "unknown"
}
actionText = stripSourcePrefix(
strings.TrimSpace(event.Action),
source,
)
target = strings.TrimSpace(event.Target)
result = strings.TrimSpace(event.Result)
if result == "" {
result = inferActionResult(actionText)
}
return
}
lower := strings.ToLower(trimmed)
if strings.HasPrefix(lower, "web request:") {
rest := strings.TrimSpace(trimmed[len("web request:"):])
return "web", "request", rest, "ok"
}
if strings.HasPrefix(lower, "repl command:") {
cmd := strings.TrimSpace(trimmed[len("repl command:"):])
return "repl", "run_command", cmd, "ok"
}
return "unknown", trimmed, "", inferActionResult(trimmed)
}
func stripSourcePrefix(actionText, source string) string {
actionText = strings.TrimSpace(actionText)
if actionText == "" || source == "" {
return actionText
}
lowerSource := strings.ToLower(source)
lowerAction := strings.ToLower(actionText)
if strings.HasPrefix(lowerAction, lowerSource+" ") {
return strings.TrimSpace(actionText[len(source):])
}
if strings.HasPrefix(lowerAction, lowerSource+"_") {
return strings.TrimSpace(actionText[len(source)+1:])
}
return actionText
}
func inferActionResult(actionText string) string {
lower := strings.ToLower(actionText)
switch {
case strings.Contains(lower, "failed"):
return "failed"
case strings.Contains(lower, "canceled"):
return "canceled"
case strings.Contains(lower, "started"):
return "started"
case strings.Contains(lower, "submitted"):
return "submitted"
case strings.Contains(lower, "requested"):
return "requested"
default:
return "ok"
}
}
func normalizeAction(input string) string {
lower := strings.ToLower(strings.TrimSpace(input))
if lower == "" {
return "unknown_action"
}
clean := strings.Trim(
nonWord.ReplaceAllString(lower, "_"),
"_",
)
if clean == "" {
return "unknown_action"
}
return clean
}