refactor to use Tasker interface instead of Task

add tasker register for registering tasks for extiensibility
This commit is contained in:
Robin Olsen
2026-02-18 13:07:23 +01:00
parent 89f8596884
commit 23747fb799
9 changed files with 276 additions and 300 deletions

View File

@@ -46,7 +46,7 @@ func runStartCmd(cmd *cobra.Command, args []string) error {
}
defer cleanup()
tasks := initTasks(svc)
surveyTasks := initTasks(svc)
if cmd.Flags().Changed("interface") {
if _, ok := interfaces[interfaceType]; !ok {
@@ -58,10 +58,10 @@ func runStartCmd(cmd *cobra.Command, args []string) error {
}
if cmd.Flags().Changed("stage") {
if stage < 1 || stage > len(tasks) {
if stage < 1 || stage > len(surveyTasks) {
return fmt.Errorf("invalid stage")
}
if err := runTask(cmd.Context(), tasks[stage-1], interfaces[interfaceType]); err != nil {
if err := runTask(cmd.Context(), surveyTasks[stage-1], interfaces[interfaceType]); err != nil {
return err
}
return nil
@@ -71,8 +71,6 @@ func runStartCmd(cmd *cobra.Command, args []string) error {
return returnIfUserQuit(err, "failed to run intro")
}
surveyTasks := initTasks(svc)
if err := taskLoop(cmd.Context(), surveyTasks, interfaces); err != nil {
return returnIfUserQuit(err, "task loop failed")
}

View File

@@ -3,12 +3,13 @@ package main
import (
"context"
"github.com/LazyBachelor/LazyPM/cmd/survey/tasks"
"github.com/LazyBachelor/LazyPM/internal/service"
"github.com/LazyBachelor/LazyPM/pkg/cli/repl"
"github.com/LazyBachelor/LazyPM/pkg/task"
"github.com/LazyBachelor/LazyPM/pkg/tui"
"github.com/LazyBachelor/LazyPM/pkg/web"
_ "github.com/LazyBachelor/LazyPM/cmd/survey/tasks"
)
func initializeServices(ctx context.Context) (*service.Services, func(), error) {
@@ -21,12 +22,6 @@ func initializeServices(ctx context.Context) (*service.Services, func(), error)
return service.NewServices(ctx, config)
}
func initTasks(svc *service.Services) []*task.Task {
return []*task.Task{
tasks.NewCreateIssueTask(svc).Init(),
}
}
func initInterfaces() map[string]task.Interface {
return map[string]task.Interface{
"repl": repl.NewRepl(),
@@ -34,3 +29,17 @@ func initInterfaces() map[string]task.Interface {
"web": web.NewWeb(),
}
}
func initTasks(svc *service.Services) []task.Tasker {
var taskers []task.Tasker
for _, name := range task.List() {
t, err := task.Get(name, svc)
if err != nil {
continue
}
taskers = append(taskers, t)
}
return taskers
}

View File

@@ -10,56 +10,11 @@ import (
"github.com/LazyBachelor/LazyPM/pkg/task"
)
func runTask(ctx context.Context, t *task.Task, i task.Interface) error {
t.SetInterface(i)
t.SetInterfaceType(tasks.InterfaceToType(i))
doneChan := make(chan bool, 1)
quitChan := make(chan bool, 1)
feedbackChan := make(chan task.ValidationFeedback, 10)
if validated, ok := i.(task.ValidatedInterface); ok {
validated.SetChannels(feedbackChan, quitChan)
}
if err := t.Initialize(ctx); err != nil {
return fmt.Errorf("failed to initialize task: %w", err)
}
if err := t.IntroduceTask(); err != nil {
return returnIfUserQuit(err, "failed to display task introduction screen")
}
t.SetChannels(feedbackChan, doneChan, quitChan)
go t.StartValidationLoop(ctx)
interfaceDone := make(chan error, 1)
go func() {
interfaceDone <- t.StartInterface(ctx, t.Config)
}()
select {
case <-doneChan:
close(quitChan)
<-interfaceDone
fmt.Println("Task completed successfully!")
case err := <-interfaceDone:
close(quitChan)
if err != nil {
return returnIfUserQuit(err, "failed to start task interface")
}
fmt.Println("Task incomplete - you exited early")
}
if err := t.StartQuestionnaire(); err != nil {
return returnIfUserQuit(err, "failed to start questionnaire")
}
return nil
func runTask(ctx context.Context, t task.Tasker, i task.Interface) error {
return task.RunTask(ctx, t, i, tasks.InterfaceToType(i))
}
func taskLoop(ctx context.Context, surveyTasks []*task.Task, interfaces map[string]task.Interface) error {
func taskLoop(ctx context.Context, surveyTasks []task.Tasker, interfaces map[string]task.Interface) error {
var ifaceNames []string
for name := range interfaces {
ifaceNames = append(ifaceNames, name)
@@ -69,11 +24,11 @@ func taskLoop(ctx context.Context, surveyTasks []*task.Task, interfaces map[stri
ifaceNames[i], ifaceNames[j] = ifaceNames[j], ifaceNames[i]
})
for i, task := range surveyTasks {
for i, t := range surveyTasks {
idx := i % len(ifaceNames)
selected := interfaces[ifaceNames[idx]]
if err := runTask(ctx, task, selected); err != nil {
if err := runTask(ctx, t, selected); err != nil {
return err
}
}
@@ -81,7 +36,7 @@ func taskLoop(ctx context.Context, surveyTasks []*task.Task, interfaces map[stri
}
func returnIfUserQuit(err error, msg string) error {
if errors.Is(err, ErrUserQuit) {
if errors.Is(err, task.ErrUserQuit) {
return nil
}
return fmt.Errorf("%s: %w", msg, err)

View File

@@ -0,0 +1,71 @@
package tasks
import (
"context"
"github.com/LazyBachelor/LazyPM/internal/service"
"github.com/LazyBachelor/LazyPM/pkg/task"
taskui "github.com/LazyBachelor/LazyPM/pkg/task/ui"
"github.com/charmbracelet/huh"
)
const codingDescription = `You are tasked with writing a simple function.
Write a function that takes two integers and returns their sum.
The function should be named "Add" and be part of the "coding" package.`
func init() {
task.Register("coding_task", func(svc *service.Services) task.Tasker {
return NewCodingTask(svc)
})
}
type CodingTask struct {
svc *service.Services
}
func NewCodingTask(svc *service.Services) *CodingTask {
return &CodingTask{svc: svc}
}
func (t *CodingTask) Config() task.TaskConfig {
return task.TaskConfig{
IssuePrefix: "pm",
BeadsDBPath: "./.pm/db.db",
StatisticsStoragePath: "./.pm/coding-stats.json",
WebAddress: "localhost:8080",
}
}
func (t *CodingTask) Details() taskui.TaskDetails {
return taskui.TaskDetails{
Title: "Coding Task",
Description: codingDescription,
TimeToComplete: "10m",
Difficulty: "Easy",
}
}
func (t *CodingTask) Questions(interfaceType task.InterfaceType) taskui.Questions {
return taskui.Questions{
huh.NewGroup(huh.NewConfirm().Title("Did you complete the coding task?")),
huh.NewGroup(
huh.NewSelect[int]().
Options(
huh.NewOption("Very easy", 1),
huh.NewOption("Easy", 2),
huh.NewOption("Moderate", 3),
huh.NewOption("Hard", 4),
).
Title("How difficult was the task?"),
),
}
}
func (t *CodingTask) Setup(ctx context.Context) error {
return nil
}
func (t *CodingTask) Validate(ctx context.Context) (bool, error) {
return true, nil
}

View File

@@ -7,7 +7,7 @@ import (
"github.com/LazyBachelor/LazyPM/internal/models"
"github.com/LazyBachelor/LazyPM/internal/service"
"github.com/LazyBachelor/LazyPM/pkg/task"
ui "github.com/LazyBachelor/LazyPM/pkg/task/ui"
taskui "github.com/LazyBachelor/LazyPM/pkg/task/ui"
"github.com/charmbracelet/huh"
)
@@ -17,23 +17,18 @@ This task will test your ability to use the issue creation workflow effectively.
Assign this task to yourself and start creating the issue.
Make sure to fill out all the necessary details, including the title, description, and assignee.`
func init() {
task.Register("create_issue", func(svc *service.Services) task.Tasker {
return NewCreateIssueTask(svc)
})
}
type CreateIssueTask struct {
*task.Task
svc *service.Services
}
func NewCreateIssueTask(svc *service.Services) *CreateIssueTask {
return &CreateIssueTask{
svc: svc,
}
}
func (t *CreateIssueTask) Init() *task.Task {
task := task.NewTask(t.svc, t.Details(), t.QuestionsFunc())
task.SetConfigFunc(t.Config)
task.SetDbStateFunc(t.DbStateFunc)
task.SetValidateFunc(t.ValidateFunc)
return task
return &CreateIssueTask{svc: svc}
}
func (t *CreateIssueTask) Config() task.TaskConfig {
@@ -45,65 +40,47 @@ func (t *CreateIssueTask) Config() task.TaskConfig {
}
}
func (t *CreateIssueTask) Details() ui.TaskDetails {
return ui.TaskDetails{
func (t *CreateIssueTask) Details() taskui.TaskDetails {
return taskui.TaskDetails{
Title: "Create Issue Task",
Description: "Create a new issue in the project management system to test the issue creation workflow.",
Description: "Create a new issue in the project management system...",
TimeToComplete: "15m",
Difficulty: "Hard",
}
}
func (t *CreateIssueTask) QuestionsFunc() task.QuestionsFunc {
return func(interfaceType task.InterfaceType) ui.Questions {
questions := ui.Questions{}
// Add an extra question for TUI
if interfaceType == task.InterfaceTUI {
questions = append(questions,
huh.NewGroup(
huh.NewConfirm().Title("Did you complete?"),
),
)
}
questions = append(questions,
huh.NewGroup(
huh.NewConfirm().Title("Was this good"),
),
huh.NewGroup(
huh.NewSelect[int]().Options(
func (t *CreateIssueTask) Questions(interfaceType task.InterfaceType) taskui.Questions {
return taskui.Questions{
huh.NewGroup(huh.NewConfirm().Title("Was this good")),
huh.NewGroup(
huh.NewSelect[int]().
Options(
huh.NewOption("Very good", 1),
huh.NewOption("Very Bad", 2),
).Title("How good was it?"),
),
)
return questions
).
Title("How good was it?"),
),
}
}
func (t *CreateIssueTask) DbStateFunc(ctx context.Context) error {
func (t *CreateIssueTask) Setup(ctx context.Context) error {
// Clear existing issues to ensure a clean state for the task
if err := t.svc.DeleteIssues(); err != nil {
return err
}
issue := models.Issue{
ID: "pm-abc",
Title: "Create A New Issue", Description: description,
IssueType: models.TypeTask, Status: models.StatusOpen,
ID: "pm-abc",
Title: "Create A New Issue",
Description: description,
IssueType: models.TypeTask,
Status: models.StatusOpen,
}
if err := t.svc.Beads.CreateIssue(ctx, &issue, ""); err != nil {
return err
}
return nil
return t.svc.Beads.CreateIssue(ctx, &issue, "")
}
func (t *CreateIssueTask) ValidateFunc(ctx context.Context) (ok bool, errorMsg error) {
// Fetches issues, indexed with latest first
func (t *CreateIssueTask) Validate(ctx context.Context) (bool, error) {
issues, err := t.svc.Beads.SearchIssues(ctx, "", models.IssueFilter{})
if err != nil {
return false, err

32
pkg/task/register.go Normal file
View File

@@ -0,0 +1,32 @@
package task
import (
"fmt"
"github.com/LazyBachelor/LazyPM/internal/service"
)
var registry = make(map[string]func(*service.Services) Tasker)
func Register(name string, constructor func(*service.Services) Tasker) {
if _, exists := registry[name]; exists {
panic(fmt.Sprintf("task %q already registered", name))
}
registry[name] = constructor
}
func Get(name string, svc *service.Services) (Tasker, error) {
constructor, ok := registry[name]
if !ok {
return nil, fmt.Errorf("task %q not found", name)
}
return constructor(svc), nil
}
func List() []string {
names := make([]string, 0, len(registry))
for name := range registry {
names = append(names, name)
}
return names
}

111
pkg/task/runner.go Normal file
View File

@@ -0,0 +1,111 @@
package task
import (
"context"
"fmt"
"time"
taskui "github.com/LazyBachelor/LazyPM/pkg/task/ui"
tea "github.com/charmbracelet/bubbletea"
)
var ErrUserQuit = fmt.Errorf("user quit")
// RunTask orchestrates the complete task execution flow:
// 1. Setup the task
// 2. Show task intro screen
// 3. Run the interface
// 4. Start validation loop in background
// 5. Show questionnaire when done
func RunTask(ctx context.Context, t Tasker, i Interface, ifaceType InterfaceType) error {
doneChan := make(chan bool, 1)
quitChan := make(chan bool, 1)
feedbackChan := make(chan ValidationFeedback, 10)
if validated, ok := i.(ValidatedInterface); ok {
validated.SetChannels(feedbackChan, quitChan)
}
// Setup task
if err := t.Setup(ctx); err != nil {
return fmt.Errorf("failed to setup task: %w", err)
}
// Show task intro
detailsScreen := taskui.NewTaskModel(t.Details())
model, err := tea.NewProgram(detailsScreen, tea.WithAltScreen()).Run()
if err != nil {
return err
}
if m, ok := model.(interface{ GetUserQuit() bool }); ok && m.GetUserQuit() {
return ErrUserQuit
}
// Start validation loop
go startValidationLoop(ctx, t, feedbackChan, doneChan, quitChan)
// Run interface
interfaceDone := make(chan error, 1)
go func() {
interfaceDone <- i.Run(ctx, t.Config())
}()
select {
case <-doneChan:
close(quitChan)
<-interfaceDone
fmt.Println("Task completed successfully!")
case err := <-interfaceDone:
close(quitChan)
if err != nil {
return fmt.Errorf("failed to start task interface: %w", err)
}
fmt.Println("Task incomplete - you exited early")
}
// Show questionnaire
questions := t.Questions(ifaceType)
questionare := taskui.NewQuestionnaireModel(questions)
model, err = tea.NewProgram(questionare, tea.WithAltScreen()).Run()
if err != nil {
return err
}
if m, ok := model.(interface{ GetUserQuit() bool }); ok && m.GetUserQuit() {
return ErrUserQuit
}
return nil
}
func startValidationLoop(ctx context.Context, t Tasker, feedbackChan chan ValidationFeedback, doneChan chan bool, quitChan chan bool) {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
ok, err := t.Validate(ctx)
feedback := ValidationFeedback{
Success: ok,
}
if ok {
feedback.Message = "Task completed successfully!"
feedbackChan <- feedback
doneChan <- true
return
} else {
if err != nil {
feedback.Message = err.Error()
} else {
feedback.Message = "Task not yet complete"
}
feedbackChan <- feedback
}
case <-quitChan:
return
case <-ctx.Done():
return
}
}
}

View File

@@ -1,170 +0,0 @@
package task
import (
"context"
"fmt"
"time"
"github.com/LazyBachelor/LazyPM/internal/service"
taskui "github.com/LazyBachelor/LazyPM/pkg/task/ui"
tea "github.com/charmbracelet/bubbletea"
)
type Tasker interface {
Init() *Task
Config() TaskConfig
Details() taskui.TaskDetails
QuestionsFunc() QuestionsFunc
InterfaceType() Interface
ValidateFunc(context.Context) (ok bool, errorMsg error)
DbStateFunc(context.Context) error
}
type Task struct {
svc *service.Services
Config TaskConfig
Interface Interface
InterfaceType InterfaceType
details taskui.TaskDetails
questionsFunc QuestionsFunc
validateFunc ValidateFunc
dbStateFunc DbStateFunc
feedbackChan chan ValidationFeedback
doneChan chan bool
quitChan chan bool
}
func NewTask(svc *service.Services, details taskui.TaskDetails, questionsFunc QuestionsFunc) *Task {
return &Task{
details: details,
questionsFunc: questionsFunc,
svc: svc,
}
}
func (t *Task) IntroduceTask() error {
if t.details == (taskui.TaskDetails{}) {
return fmt.Errorf("details is not set")
}
detailsScreen := taskui.NewTaskModel(t.details)
model, err := tea.NewProgram(detailsScreen, tea.WithAltScreen()).Run()
if err != nil {
return err
}
if m, ok := model.(interface{ GetUserQuit() bool }); ok && m.GetUserQuit() {
return ErrUserQuit
}
return nil
}
func (t *Task) StartInterface(ctx context.Context, cfg TaskConfig) error {
if t.Interface == nil {
return fmt.Errorf("interfaceType is not set")
}
return t.Interface.Run(ctx, cfg)
}
func (t *Task) Initialize(ctx context.Context) error {
if t.dbStateFunc == nil {
return fmt.Errorf("dbStateFunc is not set")
}
return t.dbStateFunc(ctx)
}
func (t *Task) Validate(ctx context.Context) (bool, error) {
if t.validateFunc == nil {
return false, fmt.Errorf("validateFunc is not set")
}
return t.validateFunc(ctx)
}
func (t *Task) StartQuestionnaire() error {
if t.questionsFunc == nil {
return fmt.Errorf("questionsFunc is not set")
}
questions := t.questionsFunc(t.InterfaceType)
if questions == nil {
return fmt.Errorf("questions is nil")
}
questionare := taskui.NewQuestionnaireModel(questions)
model, err := tea.NewProgram(questionare, tea.WithAltScreen()).Run()
if err != nil {
return err
}
if m, ok := model.(interface{ GetUserQuit() bool }); ok && m.GetUserQuit() {
return ErrUserQuit
}
return nil
}
func (t *Task) SetConfigFunc(fn ConfigFunc) {
t.Config = fn()
}
func (t *Task) SetInterface(interfaceType Interface) {
t.Interface = interfaceType
}
func (t *Task) SetInterfaceType(interfaceType InterfaceType) {
t.InterfaceType = interfaceType
}
func (t *Task) SetDbStateFunc(fn DbStateFunc) {
t.dbStateFunc = fn
}
func (t *Task) SetValidateFunc(fn ValidateFunc) {
t.validateFunc = fn
}
func (t *Task) SetChannels(feedbackChan chan ValidationFeedback, doneChan chan bool, quitChan chan bool) {
t.feedbackChan = feedbackChan
t.doneChan = doneChan
t.quitChan = quitChan
}
func (t *Task) StartValidationLoop(ctx context.Context) {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
ok, err := t.Validate(ctx)
feedback := ValidationFeedback{
Timestamp: time.Now(),
}
if ok {
feedback.Success = true
feedback.Message = "Task completed successfully!"
t.feedbackChan <- feedback
t.doneChan <- true
return
} else {
feedback.Success = false
if err != nil {
feedback.Message = err.Error()
} else {
feedback.Message = "Task not yet complete"
}
t.feedbackChan <- feedback
}
case <-t.quitChan:
return
case <-ctx.Done():
return
}
}
}

View File

@@ -2,15 +2,11 @@ package task
import (
"context"
"errors"
"time"
"github.com/LazyBachelor/LazyPM/internal/service"
taskui "github.com/LazyBachelor/LazyPM/pkg/task/ui"
)
var ErrUserQuit = errors.New("user quit")
type TaskConfig = service.Config
type Interface interface {
@@ -25,20 +21,17 @@ const (
InterfaceWeb InterfaceType = "web"
)
type ConfigFunc func() TaskConfig
type ValidateFunc func(context.Context) (ok bool, err error)
type DbStateFunc func(context.Context) error
type QuestionsFunc func(InterfaceType) taskui.Questions
type ValidationFeedback struct {
Success bool
Message string
Timestamp time.Time
type Tasker interface {
Config() TaskConfig
Details() taskui.TaskDetails
Questions(InterfaceType) taskui.Questions
Setup(context.Context) error
Validate(context.Context) (bool, error)
}
type ValidationObserver interface {
OnValidationUpdate(feedback ValidationFeedback)
OnTaskComplete()
type ValidationFeedback struct {
Success bool
Message string
}
type ValidatedInterface interface {