add metrics to tasks

This commit is contained in:
Robin Olsen
2026-03-02 23:09:49 +01:00
parent 35557431da
commit 5ebe95b62f
6 changed files with 151 additions and 12 deletions

View File

@@ -73,10 +73,12 @@ func BaseQuestions(interfaceType InterfaceType) Questions {
return Questions{
huh.NewGroup(
huh.NewConfirm().
Key("task_completed").
Title("Did you complete the task?"),
),
huh.NewGroup(
huh.NewSelect[int]().Value(&taskRating).
Key("task_difficulty").
Options(
huh.NewOption("Very easy", 1),
huh.NewOption("Easy", 2),

View File

@@ -51,22 +51,35 @@ func (t *CodingTask) Questions(interfaceType InterfaceType) (questions Questions
return BaseQuestions(interfaceType).
With(
ReplQuestion(interfaceType,
huh.NewConfirm().Title("Question only for REPL interface")),
huh.NewConfirm().Key("repl_experience").Title("Question only for REPL interface")),
).
With(
WebQuestion(interfaceType,
huh.NewInput().Title("Question only for Web interface")),
huh.NewInput().Key("web_feedback").Title("Question only for Web interface")),
).
With(
TUIQuestion(interfaceType,
huh.NewConfirm().Title("Question only for TUI interface")),
huh.NewConfirm().Key("tui_experience").Title("Question only for TUI interface")),
).
With(
Question(
huh.NewConfirm().Title("One last question for all interfaces!")),
huh.NewConfirm().Key("final_confirmation").Title("One last question for all interfaces!")),
)
}
func (t *CodingTask) QuestionnaireKeys(interfaceType InterfaceType) []string {
keys := []string{"task_completed", "task_difficulty", "final_confirmation"}
switch interfaceType {
case InterfaceTypeREPL:
keys = append(keys, "repl_experience")
case InterfaceTypeWeb:
keys = append(keys, "web_feedback")
case InterfaceTypeTUI:
keys = append(keys, "tui_experience")
}
return keys
}
func (t *CodingTask) Setup(ctx context.Context) error {
if err := ClearIssues(t.app); err != nil {
return err

View File

@@ -42,6 +42,11 @@ func (t *CreateIssueTask) Questions(interfaceType InterfaceType) Questions {
return BaseQuestions(interfaceType)
}
func (t *CreateIssueTask) QuestionnaireKeys(interfaceType InterfaceType) []string {
_ = interfaceType
return []string{"task_completed", "task_difficulty"}
}
func (t *CreateIssueTask) Setup(ctx context.Context) error {
if err := ClearIssues(t.app); err != nil {
return err

View File

@@ -47,6 +47,7 @@ func (t *GitTask) Questions(interfaceType InterfaceType) Questions {
return BaseQuestions(interfaceType).With(
huh.NewGroup(
huh.NewSelect[string]().Title("What Git Interface did you use?").
Key("git_interface_used").
Options(
huh.Option[string]{Value: "cli", Key: "Command Line Interface"},
huh.Option[string]{Value: "tui", Key: "Terminal User Interface"},
@@ -56,6 +57,11 @@ func (t *GitTask) Questions(interfaceType InterfaceType) Questions {
)
}
func (t *GitTask) QuestionnaireKeys(interfaceType InterfaceType) []string {
_ = interfaceType
return []string{"task_completed", "task_difficulty", "git_interface_used"}
}
func (t *GitTask) Setup(ctx context.Context) error {
if err := ClearIssues(t.app); err != nil {
return err

View File

@@ -13,17 +13,19 @@ type Questions = models.Questions
type QuestionnaireModel struct {
Questions
form *huh.Form
keys []string
width, height int
userQuit bool
}
func NewQuestionnaireModel(questions Questions) *QuestionnaireModel {
func NewQuestionnaireModel(questions Questions, keys []string) *QuestionnaireModel {
form := huh.NewForm(questions...).
WithTheme(style.HuhCenterTheme()).WithLayout(huh.LayoutGrid(1, 1))
return &QuestionnaireModel{
Questions: questions,
form: form,
keys: keys,
}
}
@@ -72,3 +74,26 @@ func (q *QuestionnaireModel) SetSize(width, height int) {
func (q QuestionnaireModel) GetUserQuit() bool {
return q.userQuit
}
func (q QuestionnaireModel) GetCompleted() bool {
return q.form != nil && q.form.State == huh.StateCompleted
}
func (q QuestionnaireModel) GetAnswers() map[string]any {
if q.form == nil || len(q.keys) == 0 {
return nil
}
answers := make(map[string]any)
for _, key := range q.keys {
if key == "" {
continue
}
answers[key] = q.form.Get(key)
}
if len(answers) == 0 {
return nil
}
return answers
}

View File

@@ -3,6 +3,7 @@ package task
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/LazyBachelor/LazyPM/internal/models"
@@ -20,6 +21,10 @@ type InterfaceType = models.InterfaceType
type ValidatedInterface = models.ValidatedInterface
type ValidationFeedback = models.ValidationFeedback
type QuestionnaireKeysProvider interface {
QuestionnaireKeys(InterfaceType) []string
}
var ErrUserQuit = models.ErrUserQuit
// RunTask orchestrates the complete task execution flow:
@@ -28,7 +33,53 @@ var ErrUserQuit = models.ErrUserQuit
// 3. Run the interface
// 4. Start validation loop in background
// 5. Show questionnaire when done
func RunTask(ctx context.Context, t Tasker, i Interface, iType InterfaceType) error {
func RunTask(ctx context.Context, app *App, t Tasker, i Interface, iType InterfaceType) (runErr error) {
config := t.Config()
details := t.Details()
var logger *slog.Logger
if app != nil {
logger = app.Logger
}
collector := newTaskRunCollector(details.Title, iType, logger)
collector.log("info", "task run started")
config = config.WithActionLogger(func(action string) {
collector.recordUserAction(action)
})
defer func() {
if runErr != nil {
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)
quitChan := make(chan bool, 1)
feedbackChan := make(chan ValidationFeedback, 10)
@@ -38,60 +89,94 @@ func RunTask(ctx context.Context, t Tasker, i Interface, iType InterfaceType) er
}
// Setup task
collector.log("info", "setting up task")
if err := t.Setup(ctx); err != nil {
return fmt.Errorf("failed to setup task: %w", err)
}
collector.log("info", "task setup completed")
// Show task intro
detailsScreen := NewTaskModel(t.Details())
collector.log("info", "showing task intro")
detailsScreen := NewTaskModel(details)
model, err := tea.NewProgram(detailsScreen, tea.WithAltScreen()).Run()
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
go startValidationLoop(ctx, t, feedbackChan, doneChan, quitChan)
collector.log("info", "starting validation loop")
go startValidationLoop(ctx, t, feedbackChan, doneChan, quitChan, collector.recordValidation)
// Run interface
collector.log("info", "starting task interface")
interfaceDone := make(chan error, 1)
go func() {
interfaceDone <- i.Run(ctx, t.Config())
interfaceDone <- i.Run(ctx, config)
}()
select {
case <-doneChan:
close(quitChan)
collector.setCompleted(true)
collector.log("info", "task validation completed")
if err := <-interfaceDone; err != nil {
fmt.Printf("warning: interface error after task completion: %v\n", err)
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:
close(quitChan)
if err != nil {
collector.log("error", fmt.Sprintf("task interface failed: %v", 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
collector.log("info", "showing post-task questionnaire")
questions := t.Questions(iType)
questionare := NewQuestionnaireModel(questions)
questionnaireKeys := []string{}
if provider, ok := t.(QuestionnaireKeysProvider); ok {
questionnaireKeys = provider.QuestionnaireKeys(iType)
}
questionare := NewQuestionnaireModel(questions, questionnaireKeys)
model, err = tea.NewProgram(questionare, tea.WithAltScreen()).Run()
if err != nil {
return err
}
questionnaireCompleted := false
questionnaireAnswers := map[string]any(nil)
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
}
collector.recordQuestionnaire(questionnaireCompleted, false, questionnaireAnswers)
collector.log("info", "task run finished")
return nil
}
func startValidationLoop(ctx context.Context, t Tasker, feedbackChan chan ValidationFeedback, doneChan chan bool, quitChan chan bool) {
func startValidationLoop(ctx context.Context, t Tasker, feedbackChan chan ValidationFeedback, doneChan chan bool, quitChan chan bool, onFeedback func(ValidationFeedback)) {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
@@ -99,6 +184,9 @@ func startValidationLoop(ctx context.Context, t Tasker, feedbackChan chan Valida
select {
case <-ticker.C:
feedback := t.Validate(ctx)
if onFeedback != nil {
onFeedback(feedback)
}
if feedback.Success {
feedback.Message = "Task completed successfully!"
feedbackChan <- feedback