feat(workflow): implement workflow system with steps terminology (#102)
This commit is contained in:
@@ -46,6 +46,10 @@ import (
|
||||
usercontroller "github.com/kandev/kandev/internal/user/controller"
|
||||
userhandlers "github.com/kandev/kandev/internal/user/handlers"
|
||||
|
||||
// Workflow packages
|
||||
workflowcontroller "github.com/kandev/kandev/internal/workflow/controller"
|
||||
workflowhandlers "github.com/kandev/kandev/internal/workflow/handlers"
|
||||
|
||||
// API types
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
)
|
||||
@@ -262,7 +266,7 @@ func main() {
|
||||
// ============================================
|
||||
log.Info("Initializing Orchestrator...")
|
||||
|
||||
orchestratorSvc, err := provideOrchestrator(log, eventBus, taskRepo, taskSvc, userSvc, lifecycleMgr, agentRegistry)
|
||||
orchestratorSvc, err := provideOrchestrator(log, eventBus, taskRepo, taskSvc, userSvc, lifecycleMgr, agentRegistry, services.Workflow)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to initialize orchestrator", zap.Error(err))
|
||||
}
|
||||
@@ -500,11 +504,13 @@ func main() {
|
||||
|
||||
workspaceController := taskcontroller.NewWorkspaceController(taskSvc)
|
||||
boardController := taskcontroller.NewBoardController(taskSvc)
|
||||
boardController.SetWorkflowStepLister(services.Workflow)
|
||||
taskController := taskcontroller.NewTaskController(taskSvc)
|
||||
messageController := taskcontroller.NewMessageController(taskSvc)
|
||||
repositoryController := taskcontroller.NewRepositoryController(taskSvc)
|
||||
executorController := taskcontroller.NewExecutorController(taskSvc)
|
||||
environmentController := taskcontroller.NewEnvironmentController(taskSvc)
|
||||
workflowCtrl := workflowcontroller.NewController(services.Workflow)
|
||||
|
||||
// WebSocket endpoint - primary realtime transport
|
||||
gateway.SetupRoutes(router)
|
||||
@@ -527,6 +533,9 @@ func main() {
|
||||
taskhandlers.RegisterProcessRoutes(router, taskSvc, lifecycleMgr, log)
|
||||
log.Debug("Registered Task Service handlers (HTTP + WebSocket)")
|
||||
|
||||
workflowhandlers.RegisterRoutes(router, gateway.Dispatcher, workflowCtrl, log)
|
||||
log.Info("Registered Workflow handlers (HTTP + WebSocket)")
|
||||
|
||||
agentsettingshandlers.RegisterRoutes(router, agentSettingsController, gateway.Hub, log)
|
||||
log.Debug("Registered Agent Settings handlers (HTTP)")
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/lifecycle"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"github.com/kandev/kandev/internal/task/repository"
|
||||
taskservice "github.com/kandev/kandev/internal/task/service"
|
||||
userservice "github.com/kandev/kandev/internal/user/service"
|
||||
workflowservice "github.com/kandev/kandev/internal/workflow/service"
|
||||
)
|
||||
|
||||
func provideOrchestrator(
|
||||
@@ -21,6 +23,7 @@ func provideOrchestrator(
|
||||
userSvc *userservice.Service,
|
||||
lifecycleMgr *lifecycle.Manager,
|
||||
agentRegistry *registry.Registry,
|
||||
workflowSvc *workflowservice.Service,
|
||||
) (*orchestrator.Service, error) {
|
||||
if lifecycleMgr == nil {
|
||||
return nil, errors.New("lifecycle manager is required: configure agent runtime (docker or standalone)")
|
||||
@@ -38,5 +41,74 @@ func provideOrchestrator(
|
||||
|
||||
orchestratorSvc.SetTurnService(newTurnServiceAdapter(taskSvc))
|
||||
|
||||
// Wire workflow step getter for prompt building
|
||||
if workflowSvc != nil {
|
||||
orchestratorSvc.SetWorkflowStepGetter(&orchestratorWorkflowStepGetterAdapter{svc: workflowSvc})
|
||||
}
|
||||
|
||||
return orchestratorSvc, nil
|
||||
}
|
||||
|
||||
// orchestratorWorkflowStepGetterAdapter adapts workflow service to orchestrator's WorkflowStepGetter interface.
|
||||
type orchestratorWorkflowStepGetterAdapter struct {
|
||||
svc *workflowservice.Service
|
||||
}
|
||||
|
||||
// GetStep implements orchestrator.WorkflowStepGetter.
|
||||
func (a *orchestratorWorkflowStepGetterAdapter) GetStep(ctx context.Context, stepID string) (*orchestrator.WorkflowStep, error) {
|
||||
step, err := a.svc.GetStep(ctx, stepID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
onCompleteStepID := ""
|
||||
if step.OnCompleteStepID != nil {
|
||||
onCompleteStepID = *step.OnCompleteStepID
|
||||
}
|
||||
onApprovalStepID := ""
|
||||
if step.OnApprovalStepID != nil {
|
||||
onApprovalStepID = *step.OnApprovalStepID
|
||||
}
|
||||
return &orchestrator.WorkflowStep{
|
||||
ID: step.ID,
|
||||
Name: step.Name,
|
||||
StepType: string(step.StepType),
|
||||
AutoStartAgent: step.AutoStartAgent,
|
||||
PlanMode: step.PlanMode,
|
||||
RequireApproval: step.RequireApproval,
|
||||
PromptPrefix: step.PromptPrefix,
|
||||
PromptSuffix: step.PromptSuffix,
|
||||
OnCompleteStepID: onCompleteStepID,
|
||||
OnApprovalStepID: onApprovalStepID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetSourceStep implements orchestrator.WorkflowStepGetter.
|
||||
func (a *orchestratorWorkflowStepGetterAdapter) GetSourceStep(ctx context.Context, boardID, targetStepID string) (*orchestrator.WorkflowStep, error) {
|
||||
step, err := a.svc.GetSourceStep(ctx, boardID, targetStepID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if step == nil {
|
||||
return nil, nil
|
||||
}
|
||||
onCompleteStepID := ""
|
||||
if step.OnCompleteStepID != nil {
|
||||
onCompleteStepID = *step.OnCompleteStepID
|
||||
}
|
||||
onApprovalStepID := ""
|
||||
if step.OnApprovalStepID != nil {
|
||||
onApprovalStepID = *step.OnApprovalStepID
|
||||
}
|
||||
return &orchestrator.WorkflowStep{
|
||||
ID: step.ID,
|
||||
Name: step.Name,
|
||||
StepType: string(step.StepType),
|
||||
AutoStartAgent: step.AutoStartAgent,
|
||||
PlanMode: step.PlanMode,
|
||||
RequireApproval: step.RequireApproval,
|
||||
PromptPrefix: step.PromptPrefix,
|
||||
PromptSuffix: step.PromptSuffix,
|
||||
OnCompleteStepID: onCompleteStepID,
|
||||
OnApprovalStepID: onApprovalStepID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/discovery"
|
||||
"github.com/kandev/kandev/internal/agent/registry"
|
||||
agentsettingscontroller "github.com/kandev/kandev/internal/agent/settings/controller"
|
||||
@@ -11,6 +13,7 @@ import (
|
||||
promptservice "github.com/kandev/kandev/internal/prompts/service"
|
||||
taskservice "github.com/kandev/kandev/internal/task/service"
|
||||
userservice "github.com/kandev/kandev/internal/user/service"
|
||||
workflowservice "github.com/kandev/kandev/internal/workflow/service"
|
||||
)
|
||||
|
||||
func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories, eventBus bus.EventBus) (*Services, *agentsettingscontroller.Controller, error) {
|
||||
@@ -25,6 +28,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories
|
||||
userSvc := userservice.NewService(repos.User, eventBus, log)
|
||||
editorSvc := editorservice.NewService(repos.Editor, repos.Task, userSvc)
|
||||
promptSvc := promptservice.NewService(repos.Prompts)
|
||||
workflowSvc := workflowservice.NewService(repos.Workflow, log)
|
||||
taskSvc := taskservice.NewService(
|
||||
repos.Task,
|
||||
eventBus,
|
||||
@@ -35,12 +39,48 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories
|
||||
},
|
||||
)
|
||||
|
||||
// Wire workflow step creator to task service for board creation
|
||||
taskSvc.SetWorkflowStepCreator(workflowSvc)
|
||||
|
||||
// Wire workflow step getter to task service for MoveTask
|
||||
taskSvc.SetWorkflowStepGetter(&workflowStepGetterAdapter{svc: workflowSvc})
|
||||
|
||||
return &Services{
|
||||
Task: taskSvc,
|
||||
User: userSvc,
|
||||
Editor: editorSvc,
|
||||
Prompts: promptSvc,
|
||||
Task: taskSvc,
|
||||
User: userSvc,
|
||||
Editor: editorSvc,
|
||||
Prompts: promptSvc,
|
||||
Workflow: workflowSvc,
|
||||
// Notification service is initialized after gateway is available.
|
||||
Notification: nil,
|
||||
}, agentSettingsController, nil
|
||||
}
|
||||
|
||||
// workflowStepGetterAdapter adapts workflow service to task service's WorkflowStepGetter interface.
|
||||
type workflowStepGetterAdapter struct {
|
||||
svc *workflowservice.Service
|
||||
}
|
||||
|
||||
// GetStep implements taskservice.WorkflowStepGetter.
|
||||
func (a *workflowStepGetterAdapter) GetStep(ctx context.Context, stepID string) (*taskservice.WorkflowStep, error) {
|
||||
step, err := a.svc.GetStep(ctx, stepID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &taskservice.WorkflowStep{
|
||||
ID: step.ID,
|
||||
BoardID: step.BoardID,
|
||||
Name: step.Name,
|
||||
StepType: string(step.StepType),
|
||||
Position: step.Position,
|
||||
Color: step.Color,
|
||||
AutoStartAgent: step.AutoStartAgent,
|
||||
PlanMode: step.PlanMode,
|
||||
RequireApproval: step.RequireApproval,
|
||||
PromptPrefix: step.PromptPrefix,
|
||||
PromptSuffix: step.PromptSuffix,
|
||||
AllowManualMove: step.AllowManualMove,
|
||||
OnCompleteStepID: step.OnCompleteStepID,
|
||||
OnApprovalStepID: step.OnApprovalStepID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/persistence"
|
||||
"github.com/kandev/kandev/internal/task/repository"
|
||||
workflowrepository "github.com/kandev/kandev/internal/workflow/repository"
|
||||
|
||||
settingsstore "github.com/kandev/kandev/internal/agent/settings/store"
|
||||
editorstore "github.com/kandev/kandev/internal/editors/store"
|
||||
@@ -59,6 +60,11 @@ func provideRepositories(cfg *config.Config, log *logger.Logger) (*sql.DB, *Repo
|
||||
}
|
||||
cleanups = append(cleanups, cleanup)
|
||||
|
||||
workflowRepo, err := workflowrepository.NewWithDB(dbConn)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
repos := &Repositories{
|
||||
Task: taskRepoImpl,
|
||||
AgentSettings: agentSettingsRepo,
|
||||
@@ -66,6 +72,7 @@ func provideRepositories(cfg *config.Config, log *logger.Logger) (*sql.DB, *Repo
|
||||
Notification: notificationRepo,
|
||||
Editor: editorRepo,
|
||||
Prompts: promptRepo,
|
||||
Workflow: workflowRepo,
|
||||
}
|
||||
return dbConn, repos, cleanups, nil
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import (
|
||||
taskservice "github.com/kandev/kandev/internal/task/service"
|
||||
userservice "github.com/kandev/kandev/internal/user/service"
|
||||
userstore "github.com/kandev/kandev/internal/user/store"
|
||||
workflowrepository "github.com/kandev/kandev/internal/workflow/repository"
|
||||
workflowservice "github.com/kandev/kandev/internal/workflow/service"
|
||||
)
|
||||
|
||||
type Repositories struct {
|
||||
@@ -21,6 +23,7 @@ type Repositories struct {
|
||||
Notification notificationstore.Repository
|
||||
Editor editorstore.Repository
|
||||
Prompts promptstore.Repository
|
||||
Workflow *workflowrepository.Repository
|
||||
}
|
||||
|
||||
type Services struct {
|
||||
@@ -29,4 +32,5 @@ type Services struct {
|
||||
Editor *editorservice.Service
|
||||
Notification *notificationservice.Service
|
||||
Prompts *promptservice.Service
|
||||
Workflow *workflowservice.Service
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/settings/models"
|
||||
"github.com/kandev/kandev/internal/common/sqlite"
|
||||
)
|
||||
|
||||
type sqliteRepository struct {
|
||||
@@ -82,13 +83,13 @@ func (r *sqliteRepository) initSchema() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureColumn(r.db, "agent_profiles", "deleted_at", "DATETIME"); err != nil {
|
||||
if err := sqlite.EnsureColumn(r.db, "agent_profiles", "deleted_at", "DATETIME"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureColumn(r.db, "agent_profiles", "agent_display_name", "TEXT NOT NULL DEFAULT ''"); err != nil {
|
||||
if err := sqlite.EnsureColumn(r.db, "agent_profiles", "agent_display_name", "TEXT NOT NULL DEFAULT ''"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureColumn(r.db, "agent_profiles", "cli_passthrough", "INTEGER NOT NULL DEFAULT 0"); err != nil {
|
||||
if err := sqlite.EnsureColumn(r.db, "agent_profiles", "cli_passthrough", "INTEGER NOT NULL DEFAULT 0"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
@@ -111,7 +112,7 @@ func (r *sqliteRepository) CreateAgent(ctx context.Context, agent *models.Agent)
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO agents (id, name, workspace_id, supports_mcp, mcp_config_path, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, agent.ID, agent.Name, agent.WorkspaceID, boolToInt(agent.SupportsMCP), agent.MCPConfigPath, agent.CreatedAt, agent.UpdatedAt)
|
||||
`, agent.ID, agent.Name, agent.WorkspaceID, sqlite.BoolToInt(agent.SupportsMCP), agent.MCPConfigPath, agent.CreatedAt, agent.UpdatedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -136,7 +137,7 @@ func (r *sqliteRepository) UpdateAgent(ctx context.Context, agent *models.Agent)
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE agents SET workspace_id = ?, supports_mcp = ?, mcp_config_path = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`, agent.WorkspaceID, boolToInt(agent.SupportsMCP), agent.MCPConfigPath, agent.UpdatedAt, agent.ID)
|
||||
`, agent.WorkspaceID, sqlite.BoolToInt(agent.SupportsMCP), agent.MCPConfigPath, agent.UpdatedAt, agent.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -239,7 +240,7 @@ func (r *sqliteRepository) UpsertAgentProfileMcpConfig(ctx context.Context, conf
|
||||
servers_json = excluded.servers_json,
|
||||
meta_json = excluded.meta_json,
|
||||
updated_at = excluded.updated_at
|
||||
`, config.ProfileID, boolToInt(config.Enabled), string(serversJSON), string(metaJSON), config.CreatedAt, config.UpdatedAt)
|
||||
`, config.ProfileID, sqlite.BoolToInt(config.Enabled), string(serversJSON), string(metaJSON), config.CreatedAt, config.UpdatedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -253,8 +254,8 @@ func (r *sqliteRepository) CreateAgentProfile(ctx context.Context, profile *mode
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO agent_profiles (id, agent_id, name, agent_display_name, model, auto_approve, dangerously_skip_permissions, allow_indexing, cli_passthrough, plan, created_at, updated_at, deleted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, profile.ID, profile.AgentID, profile.Name, profile.AgentDisplayName, profile.Model, boolToInt(profile.AutoApprove),
|
||||
boolToInt(profile.DangerouslySkipPermissions), boolToInt(profile.AllowIndexing), boolToInt(profile.CLIPassthrough), profile.Plan, profile.CreatedAt, profile.UpdatedAt, profile.DeletedAt)
|
||||
`, profile.ID, profile.AgentID, profile.Name, profile.AgentDisplayName, profile.Model, sqlite.BoolToInt(profile.AutoApprove),
|
||||
sqlite.BoolToInt(profile.DangerouslySkipPermissions), sqlite.BoolToInt(profile.AllowIndexing), sqlite.BoolToInt(profile.CLIPassthrough), profile.Plan, profile.CreatedAt, profile.UpdatedAt, profile.DeletedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -264,8 +265,8 @@ func (r *sqliteRepository) UpdateAgentProfile(ctx context.Context, profile *mode
|
||||
UPDATE agent_profiles
|
||||
SET name = ?, agent_display_name = ?, model = ?, auto_approve = ?, dangerously_skip_permissions = ?, allow_indexing = ?, cli_passthrough = ?, plan = ?, updated_at = ?
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
`, profile.Name, profile.AgentDisplayName, profile.Model, boolToInt(profile.AutoApprove),
|
||||
boolToInt(profile.DangerouslySkipPermissions), boolToInt(profile.AllowIndexing), boolToInt(profile.CLIPassthrough), profile.Plan, profile.UpdatedAt, profile.ID)
|
||||
`, profile.Name, profile.AgentDisplayName, profile.Model, sqlite.BoolToInt(profile.AutoApprove),
|
||||
sqlite.BoolToInt(profile.DangerouslySkipPermissions), sqlite.BoolToInt(profile.AllowIndexing), sqlite.BoolToInt(profile.CLIPassthrough), profile.Plan, profile.UpdatedAt, profile.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -377,46 +378,3 @@ func scanAgentProfile(scanner interface {
|
||||
profile.CLIPassthrough = cliPassthrough == 1
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func ensureColumn(db *sql.DB, table, column, definition string) error {
|
||||
exists, err := columnExists(db, table, column)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return nil
|
||||
}
|
||||
query := fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, column, definition)
|
||||
_, err = db.Exec(query)
|
||||
return err
|
||||
}
|
||||
|
||||
func columnExists(db *sql.DB, table, column string) (bool, error) {
|
||||
rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer func() {
|
||||
_ = rows.Close()
|
||||
}()
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, ctype string
|
||||
var notnull, pk int
|
||||
var dflt sql.NullString
|
||||
if err := rows.Scan(&cid, &name, &ctype, ¬null, &dflt, &pk); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if name == column {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, rows.Err()
|
||||
}
|
||||
|
||||
func boolToInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -548,8 +548,9 @@ func (wt *WorkspaceTracker) getGitStatus(ctx context.Context) (types.GitStatusUp
|
||||
}
|
||||
}
|
||||
|
||||
// Get file status using git status --porcelain
|
||||
statusCmd := exec.CommandContext(ctx, "git", "status", "--porcelain")
|
||||
// Get file status using git status --porcelain --untracked-files=all
|
||||
// --untracked-files=all shows all files in untracked directories, not just the directory name
|
||||
statusCmd := exec.CommandContext(ctx, "git", "status", "--porcelain", "--untracked-files=all")
|
||||
statusCmd.Dir = wt.workDir
|
||||
statusOut, err := statusCmd.Output()
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// Package sqlite provides common SQLite utility functions.
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// BoolToInt converts a boolean to an integer (for SQLite).
|
||||
func BoolToInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// EnsureColumn adds a column to a table if it doesn't exist.
|
||||
func EnsureColumn(db *sql.DB, table, column, definition string) error {
|
||||
exists, err := ColumnExists(db, table, column)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return nil
|
||||
}
|
||||
query := fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, column, definition)
|
||||
_, err = db.Exec(query)
|
||||
return err
|
||||
}
|
||||
|
||||
// ColumnExists checks if a column exists in a table.
|
||||
func ColumnExists(db *sql.DB, table, column string) (bool, error) {
|
||||
rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer func() {
|
||||
_ = rows.Close()
|
||||
}()
|
||||
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, colType string
|
||||
var notNull int
|
||||
var defaultValue sql.NullString
|
||||
var pk int
|
||||
if err := rows.Scan(&cid, &name, &colType, ¬Null, &defaultValue, &pk); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if name == column {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, rows.Err()
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/sqlite"
|
||||
"github.com/kandev/kandev/internal/editors/discovery"
|
||||
"github.com/kandev/kandev/internal/editors/models"
|
||||
)
|
||||
@@ -232,8 +233,8 @@ func (r *sqliteRepository) UpsertEditors(ctx context.Context, editors []*models.
|
||||
editor.Command,
|
||||
editor.Scheme,
|
||||
configValue,
|
||||
boolToInt(editor.Installed),
|
||||
boolToInt(editor.Enabled),
|
||||
sqlite.BoolToInt(editor.Installed),
|
||||
sqlite.BoolToInt(editor.Enabled),
|
||||
editor.CreatedAt,
|
||||
editor.UpdatedAt,
|
||||
)
|
||||
@@ -264,7 +265,7 @@ func (r *sqliteRepository) CreateEditor(ctx context.Context, editor *models.Edit
|
||||
_, err = r.db.ExecContext(ctx, `
|
||||
INSERT INTO editors (id, type, name, kind, command, scheme, config, installed, enabled, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, editor.ID, editor.Type, editor.Name, editor.Kind, editor.Command, editor.Scheme, configValue, boolToInt(editor.Installed), boolToInt(editor.Enabled), editor.CreatedAt, editor.UpdatedAt)
|
||||
`, editor.ID, editor.Type, editor.Name, editor.Kind, editor.Command, editor.Scheme, configValue, sqlite.BoolToInt(editor.Installed), sqlite.BoolToInt(editor.Enabled), editor.CreatedAt, editor.UpdatedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -281,7 +282,7 @@ func (r *sqliteRepository) UpdateEditor(ctx context.Context, editor *models.Edit
|
||||
UPDATE editors
|
||||
SET name = ?, kind = ?, command = ?, scheme = ?, config = ?, installed = ?, enabled = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`, editor.Name, editor.Kind, editor.Command, editor.Scheme, configValue, boolToInt(editor.Installed), boolToInt(editor.Enabled), editor.UpdatedAt, editor.ID)
|
||||
`, editor.Name, editor.Kind, editor.Command, editor.Scheme, configValue, sqlite.BoolToInt(editor.Installed), sqlite.BoolToInt(editor.Enabled), editor.UpdatedAt, editor.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -318,13 +319,6 @@ func scanEditor(scanner interface{ Scan(dest ...any) error }) (*models.Editor, e
|
||||
return editor, nil
|
||||
}
|
||||
|
||||
func boolToInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type columnDef struct {
|
||||
name string
|
||||
typ string
|
||||
|
||||
@@ -23,13 +23,6 @@ const (
|
||||
BoardDeleted = "board.deleted"
|
||||
)
|
||||
|
||||
// Event types for columns
|
||||
const (
|
||||
ColumnCreated = "column.created"
|
||||
ColumnUpdated = "column.updated"
|
||||
ColumnDeleted = "column.deleted"
|
||||
)
|
||||
|
||||
// Event types for comments
|
||||
const (
|
||||
MessageAdded = "message.added"
|
||||
|
||||
@@ -31,9 +31,6 @@ func RegisterTaskNotifications(ctx context.Context, eventBus bus.EventBus, hub *
|
||||
b.subscribe(eventBus, events.BoardCreated, ws.ActionBoardCreated)
|
||||
b.subscribe(eventBus, events.BoardUpdated, ws.ActionBoardUpdated)
|
||||
b.subscribe(eventBus, events.BoardDeleted, ws.ActionBoardDeleted)
|
||||
b.subscribe(eventBus, events.ColumnCreated, ws.ActionColumnCreated)
|
||||
b.subscribe(eventBus, events.ColumnUpdated, ws.ActionColumnUpdated)
|
||||
b.subscribe(eventBus, events.ColumnDeleted, ws.ActionColumnDeleted)
|
||||
b.subscribe(eventBus, events.TaskCreated, ws.ActionTaskCreated)
|
||||
b.subscribe(eventBus, events.TaskUpdated, ws.ActionTaskUpdated)
|
||||
b.subscribe(eventBus, events.TaskDeleted, ws.ActionTaskDeleted)
|
||||
|
||||
@@ -19,7 +19,6 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/kandev/kandev/internal/worktree"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/db"
|
||||
"github.com/kandev/kandev/internal/events/bus"
|
||||
@@ -28,6 +27,10 @@ import (
|
||||
taskhandlers "github.com/kandev/kandev/internal/task/handlers"
|
||||
"github.com/kandev/kandev/internal/task/repository"
|
||||
taskservice "github.com/kandev/kandev/internal/task/service"
|
||||
"github.com/kandev/kandev/internal/workflow"
|
||||
workflowcontroller "github.com/kandev/kandev/internal/workflow/controller"
|
||||
workflowhandlers "github.com/kandev/kandev/internal/workflow/handlers"
|
||||
"github.com/kandev/kandev/internal/worktree"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
)
|
||||
|
||||
@@ -79,8 +82,13 @@ func NewTestServer(t *testing.T) *TestServer {
|
||||
t.Fatalf("failed to init worktree store: %v", err)
|
||||
}
|
||||
|
||||
// Initialize task service
|
||||
// Initialize workflow service
|
||||
_, workflowSvc, _, err := workflow.Provide(dbConn, log)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Initialize task service and wire workflow step creator
|
||||
taskSvc := taskservice.NewService(taskRepo, eventBus, log, taskservice.RepositoryDiscoveryConfig{})
|
||||
taskSvc.SetWorkflowStepCreator(workflowSvc)
|
||||
|
||||
// Create WebSocket gateway
|
||||
gateway := gateways.NewGateway(log)
|
||||
@@ -96,16 +104,19 @@ func NewTestServer(t *testing.T) *TestServer {
|
||||
// Register handlers (HTTP + WS)
|
||||
workspaceController := taskcontroller.NewWorkspaceController(taskSvc)
|
||||
boardController := taskcontroller.NewBoardController(taskSvc)
|
||||
boardController.SetWorkflowStepLister(workflowSvc)
|
||||
taskController := taskcontroller.NewTaskController(taskSvc)
|
||||
executorController := taskcontroller.NewExecutorController(taskSvc)
|
||||
environmentController := taskcontroller.NewEnvironmentController(taskSvc)
|
||||
repositoryController := taskcontroller.NewRepositoryController(taskSvc)
|
||||
workflowCtrl := workflowcontroller.NewController(workflowSvc)
|
||||
taskhandlers.RegisterWorkspaceRoutes(router, gateway.Dispatcher, workspaceController, log)
|
||||
taskhandlers.RegisterBoardRoutes(router, gateway.Dispatcher, boardController, log)
|
||||
taskhandlers.RegisterTaskRoutes(router, gateway.Dispatcher, taskController, nil, log)
|
||||
taskhandlers.RegisterRepositoryRoutes(router, gateway.Dispatcher, repositoryController, log)
|
||||
taskhandlers.RegisterExecutorRoutes(router, gateway.Dispatcher, executorController, log)
|
||||
taskhandlers.RegisterEnvironmentRoutes(router, gateway.Dispatcher, environmentController, log)
|
||||
workflowhandlers.RegisterRoutes(router, gateway.Dispatcher, workflowCtrl, log)
|
||||
|
||||
// Create test server
|
||||
server := httptest.NewServer(router)
|
||||
@@ -342,9 +353,10 @@ func TestBoardCRUD(t *testing.T) {
|
||||
// Create board
|
||||
t.Run("CreateBoard", func(t *testing.T) {
|
||||
resp, err := client.SendRequest("board-create-1", ws.ActionBoardCreate, map[string]interface{}{
|
||||
"workspace_id": workspaceID,
|
||||
"name": "Test Board",
|
||||
"description": "A test board for integration testing",
|
||||
"workspace_id": workspaceID,
|
||||
"name": "Test Board",
|
||||
"description": "A test board for integration testing",
|
||||
"workflow_template_id": "simple",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ws.MessageTypeResponse, resp.Type)
|
||||
@@ -375,10 +387,10 @@ func TestBoardCRUD(t *testing.T) {
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// COLUMN TESTS
|
||||
// WORKFLOW STEP TESTS
|
||||
// ============================================
|
||||
|
||||
func TestColumnCRUD(t *testing.T) {
|
||||
func TestWorkflowStepList(t *testing.T) {
|
||||
ts := NewTestServer(t)
|
||||
defer ts.Close()
|
||||
|
||||
@@ -387,10 +399,11 @@ func TestColumnCRUD(t *testing.T) {
|
||||
|
||||
workspaceID := createWorkspace(t, client)
|
||||
|
||||
// First create a board
|
||||
// Create a board (workflow steps are created automatically with default template)
|
||||
boardResp, err := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{
|
||||
"workspace_id": workspaceID,
|
||||
"name": "Column Test Board",
|
||||
"workspace_id": workspaceID,
|
||||
"name": "Workflow Step Test Board",
|
||||
"workflow_template_id": "simple",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -399,43 +412,9 @@ func TestColumnCRUD(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
boardID := boardPayload["id"].(string)
|
||||
|
||||
// Create column
|
||||
t.Run("CreateColumn", func(t *testing.T) {
|
||||
resp, err := client.SendRequest("col-create-1", ws.ActionColumnCreate, map[string]interface{}{
|
||||
"board_id": boardID,
|
||||
"name": "To Do",
|
||||
"position": 0,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, ws.MessageTypeResponse, resp.Type)
|
||||
|
||||
var payload map[string]interface{}
|
||||
err = resp.ParsePayload(&payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEmpty(t, payload["id"])
|
||||
assert.Equal(t, "To Do", payload["name"])
|
||||
})
|
||||
|
||||
// Create second column
|
||||
t.Run("CreateSecondColumn", func(t *testing.T) {
|
||||
resp, err := client.SendRequest("col-create-2", ws.ActionColumnCreate, map[string]interface{}{
|
||||
"board_id": boardID,
|
||||
"name": "In Progress",
|
||||
"position": 1,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var payload map[string]interface{}
|
||||
err = resp.ParsePayload(&payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "In Progress", payload["name"])
|
||||
})
|
||||
|
||||
// List columns
|
||||
t.Run("ListColumns", func(t *testing.T) {
|
||||
resp, err := client.SendRequest("col-list-1", ws.ActionColumnList, map[string]interface{}{
|
||||
// List workflow steps
|
||||
t.Run("ListWorkflowSteps", func(t *testing.T) {
|
||||
resp, err := client.SendRequest("step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{
|
||||
"board_id": boardID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
@@ -444,9 +423,15 @@ func TestColumnCRUD(t *testing.T) {
|
||||
err = resp.ParsePayload(&payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
columns, ok := payload["columns"].([]interface{})
|
||||
steps, ok := payload["steps"].([]interface{})
|
||||
require.True(t, ok)
|
||||
assert.Len(t, columns, 2)
|
||||
// Default workflow template creates 4 steps: Todo, In Progress, Review, Done
|
||||
assert.GreaterOrEqual(t, len(steps), 1)
|
||||
|
||||
// Verify first step has expected fields
|
||||
firstStep := steps[0].(map[string]interface{})
|
||||
assert.NotEmpty(t, firstStep["id"])
|
||||
assert.NotEmpty(t, firstStep["name"])
|
||||
})
|
||||
}
|
||||
|
||||
@@ -464,10 +449,11 @@ func TestTaskCRUD(t *testing.T) {
|
||||
workspaceID := createWorkspace(t, client)
|
||||
repositoryID := createRepository(t, client, workspaceID)
|
||||
|
||||
// Create board and column first
|
||||
// Create board (workflow steps are created automatically)
|
||||
boardResp, err := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{
|
||||
"workspace_id": workspaceID,
|
||||
"name": "Task Test Board",
|
||||
"workspace_id": workspaceID,
|
||||
"name": "Task Test Board",
|
||||
"workflow_template_id": "simple",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -476,31 +462,32 @@ func TestTaskCRUD(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
boardID := boardPayload["id"].(string)
|
||||
|
||||
colResp, err := client.SendRequest("col-1", ws.ActionColumnCreate, map[string]interface{}{
|
||||
// Get first workflow step
|
||||
stepResp, err := client.SendRequest("step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{
|
||||
"board_id": boardID,
|
||||
"name": "To Do",
|
||||
"position": 0,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var colPayload map[string]interface{}
|
||||
err = colResp.ParsePayload(&colPayload)
|
||||
var stepListPayload map[string]interface{}
|
||||
err = stepResp.ParsePayload(&stepListPayload)
|
||||
require.NoError(t, err)
|
||||
columnID := colPayload["id"].(string)
|
||||
steps := stepListPayload["steps"].([]interface{})
|
||||
require.NotEmpty(t, steps)
|
||||
workflowStepID := steps[0].(map[string]interface{})["id"].(string)
|
||||
|
||||
var taskID string
|
||||
|
||||
// Create task
|
||||
t.Run("CreateTask", func(t *testing.T) {
|
||||
resp, err := client.SendRequest("task-create-1", ws.ActionTaskCreate, map[string]interface{}{
|
||||
"workspace_id": workspaceID,
|
||||
"board_id": boardID,
|
||||
"column_id": columnID,
|
||||
"title": "Test Task",
|
||||
"description": "A test task for integration testing",
|
||||
"priority": 3, // HIGH priority (1=LOW, 2=MEDIUM, 3=HIGH)
|
||||
"repository_id": repositoryID,
|
||||
"base_branch": "main",
|
||||
"workspace_id": workspaceID,
|
||||
"board_id": boardID,
|
||||
"workflow_step_id": workflowStepID,
|
||||
"title": "Test Task",
|
||||
"description": "A test task for integration testing",
|
||||
"priority": 3, // HIGH priority (1=LOW, 2=MEDIUM, 3=HIGH)
|
||||
"repository_id": repositoryID,
|
||||
"base_branch": "main",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -614,10 +601,11 @@ func TestTaskStateTransitions(t *testing.T) {
|
||||
workspaceID := createWorkspace(t, client)
|
||||
repositoryID := createRepository(t, client, workspaceID)
|
||||
|
||||
// Create board, column, and task
|
||||
// Create board and task (workflow steps are created automatically)
|
||||
boardResp, _ := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{
|
||||
"workspace_id": workspaceID,
|
||||
"name": "State Test Board",
|
||||
"workspace_id": workspaceID,
|
||||
"name": "State Test Board",
|
||||
"workflow_template_id": "simple",
|
||||
})
|
||||
var boardPayload map[string]interface{}
|
||||
if err := boardResp.ParsePayload(&boardPayload); err != nil {
|
||||
@@ -625,25 +613,25 @@ func TestTaskStateTransitions(t *testing.T) {
|
||||
}
|
||||
boardID := boardPayload["id"].(string)
|
||||
|
||||
colResp, _ := client.SendRequest("col-1", ws.ActionColumnCreate, map[string]interface{}{
|
||||
// Get first workflow step
|
||||
stepResp, _ := client.SendRequest("step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{
|
||||
"board_id": boardID,
|
||||
"name": "To Do",
|
||||
"position": 0,
|
||||
})
|
||||
var colPayload map[string]interface{}
|
||||
if err := colResp.ParsePayload(&colPayload); err != nil {
|
||||
t.Fatalf("failed to parse column payload: %v", err)
|
||||
var stepListPayload map[string]interface{}
|
||||
if err := stepResp.ParsePayload(&stepListPayload); err != nil {
|
||||
t.Fatalf("failed to parse step list payload: %v", err)
|
||||
}
|
||||
columnID := colPayload["id"].(string)
|
||||
steps := stepListPayload["steps"].([]interface{})
|
||||
workflowStepID := steps[0].(map[string]interface{})["id"].(string)
|
||||
|
||||
taskResp, _ := client.SendRequest("task-1", ws.ActionTaskCreate, map[string]interface{}{
|
||||
"workspace_id": workspaceID,
|
||||
"board_id": boardID,
|
||||
"column_id": columnID,
|
||||
"title": "State Test Task",
|
||||
"description": "Test state transitions",
|
||||
"repository_id": repositoryID,
|
||||
"base_branch": "main",
|
||||
"workspace_id": workspaceID,
|
||||
"board_id": boardID,
|
||||
"workflow_step_id": workflowStepID,
|
||||
"title": "State Test Task",
|
||||
"description": "Test state transitions",
|
||||
"repository_id": repositoryID,
|
||||
"base_branch": "main",
|
||||
})
|
||||
var taskPayload map[string]interface{}
|
||||
if err := taskResp.ParsePayload(&taskPayload); err != nil {
|
||||
@@ -698,10 +686,11 @@ func TestTaskMove(t *testing.T) {
|
||||
workspaceID := createWorkspace(t, client)
|
||||
repositoryID := createRepository(t, client, workspaceID)
|
||||
|
||||
// Create board with two columns
|
||||
// Create board (workflow steps are created automatically)
|
||||
boardResp, _ := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{
|
||||
"workspace_id": workspaceID,
|
||||
"name": "Move Test Board",
|
||||
"workspace_id": workspaceID,
|
||||
"name": "Move Test Board",
|
||||
"workflow_template_id": "simple",
|
||||
})
|
||||
var boardPayload map[string]interface{}
|
||||
if err := boardResp.ParsePayload(&boardPayload); err != nil {
|
||||
@@ -709,36 +698,27 @@ func TestTaskMove(t *testing.T) {
|
||||
}
|
||||
boardID := boardPayload["id"].(string)
|
||||
|
||||
col1Resp, _ := client.SendRequest("col-1", ws.ActionColumnCreate, map[string]interface{}{
|
||||
// Get workflow steps (at least 2 should exist from default template)
|
||||
stepResp, _ := client.SendRequest("step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{
|
||||
"board_id": boardID,
|
||||
"name": "To Do",
|
||||
"position": 0,
|
||||
})
|
||||
var col1Payload map[string]interface{}
|
||||
if err := col1Resp.ParsePayload(&col1Payload); err != nil {
|
||||
t.Fatalf("failed to parse column payload: %v", err)
|
||||
var stepListPayload map[string]interface{}
|
||||
if err := stepResp.ParsePayload(&stepListPayload); err != nil {
|
||||
t.Fatalf("failed to parse step list payload: %v", err)
|
||||
}
|
||||
col1ID := col1Payload["id"].(string)
|
||||
steps := stepListPayload["steps"].([]interface{})
|
||||
require.GreaterOrEqual(t, len(steps), 2, "Expected at least 2 workflow steps")
|
||||
step1ID := steps[0].(map[string]interface{})["id"].(string)
|
||||
step2ID := steps[1].(map[string]interface{})["id"].(string)
|
||||
|
||||
col2Resp, _ := client.SendRequest("col-2", ws.ActionColumnCreate, map[string]interface{}{
|
||||
"board_id": boardID,
|
||||
"name": "Done",
|
||||
"position": 1,
|
||||
})
|
||||
var col2Payload map[string]interface{}
|
||||
if err := col2Resp.ParsePayload(&col2Payload); err != nil {
|
||||
t.Fatalf("failed to parse column payload: %v", err)
|
||||
}
|
||||
col2ID := col2Payload["id"].(string)
|
||||
|
||||
// Create task in first column
|
||||
// Create task in first step
|
||||
taskResp, _ := client.SendRequest("task-1", ws.ActionTaskCreate, map[string]interface{}{
|
||||
"workspace_id": workspaceID,
|
||||
"board_id": boardID,
|
||||
"column_id": col1ID,
|
||||
"title": "Movable Task",
|
||||
"repository_id": repositoryID,
|
||||
"base_branch": "main",
|
||||
"workspace_id": workspaceID,
|
||||
"board_id": boardID,
|
||||
"workflow_step_id": step1ID,
|
||||
"title": "Movable Task",
|
||||
"repository_id": repositoryID,
|
||||
"base_branch": "main",
|
||||
})
|
||||
var taskPayload map[string]interface{}
|
||||
if err := taskResp.ParsePayload(&taskPayload); err != nil {
|
||||
@@ -746,13 +726,13 @@ func TestTaskMove(t *testing.T) {
|
||||
}
|
||||
taskID := taskPayload["id"].(string)
|
||||
|
||||
// Move task to second column
|
||||
t.Run("MoveToColumn2", func(t *testing.T) {
|
||||
// Move task to second step
|
||||
t.Run("MoveToStep2", func(t *testing.T) {
|
||||
resp, err := client.SendRequest("move-1", ws.ActionTaskMove, map[string]interface{}{
|
||||
"id": taskID,
|
||||
"board_id": boardID,
|
||||
"column_id": col2ID,
|
||||
"position": 0,
|
||||
"id": taskID,
|
||||
"board_id": boardID,
|
||||
"workflow_step_id": step2ID,
|
||||
"position": 0,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -760,10 +740,12 @@ func TestTaskMove(t *testing.T) {
|
||||
err = resp.ParsePayload(&payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, col2ID, payload["column_id"])
|
||||
// Response is {"task": {...}, "workflow_step": {...}}
|
||||
taskData := payload["task"].(map[string]interface{})
|
||||
assert.Equal(t, step2ID, taskData["workflow_step_id"])
|
||||
})
|
||||
|
||||
// Verify task is now in column 2
|
||||
// Verify task is now in step 2
|
||||
t.Run("VerifyMove", func(t *testing.T) {
|
||||
resp, err := client.SendRequest("get-1", ws.ActionTaskGet, map[string]interface{}{
|
||||
"id": taskID,
|
||||
@@ -774,7 +756,7 @@ func TestTaskMove(t *testing.T) {
|
||||
err = resp.ParsePayload(&payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, col2ID, payload["column_id"])
|
||||
assert.Equal(t, step2ID, payload["workflow_step_id"])
|
||||
})
|
||||
}
|
||||
|
||||
@@ -797,8 +779,9 @@ func TestMultipleClients(t *testing.T) {
|
||||
|
||||
// Client 1 creates a board
|
||||
boardResp, err := client1.SendRequest("c1-board-1", ws.ActionBoardCreate, map[string]interface{}{
|
||||
"workspace_id": workspaceID,
|
||||
"name": "Shared Board",
|
||||
"workspace_id": workspaceID,
|
||||
"name": "Shared Board",
|
||||
"workflow_template_id": "simple",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -821,41 +804,41 @@ func TestMultipleClients(t *testing.T) {
|
||||
require.True(t, ok)
|
||||
assert.Len(t, boards, 1)
|
||||
|
||||
// Client 2 creates a column
|
||||
colResp, err := client2.SendRequest("c2-col-1", ws.ActionColumnCreate, map[string]interface{}{
|
||||
"board_id": boardID,
|
||||
"name": "Column by Client 2",
|
||||
"position": 0,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var colPayload map[string]interface{}
|
||||
err = colResp.ParsePayload(&colPayload)
|
||||
require.NoError(t, err)
|
||||
columnID := colPayload["id"].(string)
|
||||
|
||||
// Client 1 can see the column
|
||||
colListResp, err := client1.SendRequest("c1-col-list-1", ws.ActionColumnList, map[string]interface{}{
|
||||
// Client 2 lists workflow steps (created automatically with board)
|
||||
stepResp, err := client2.SendRequest("c2-step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{
|
||||
"board_id": boardID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var colListPayload map[string]interface{}
|
||||
err = colListResp.ParsePayload(&colListPayload)
|
||||
var stepPayload map[string]interface{}
|
||||
err = stepResp.ParsePayload(&stepPayload)
|
||||
require.NoError(t, err)
|
||||
steps := stepPayload["steps"].([]interface{})
|
||||
require.NotEmpty(t, steps)
|
||||
workflowStepID := steps[0].(map[string]interface{})["id"].(string)
|
||||
|
||||
// Client 1 can also see the workflow steps
|
||||
stepListResp, err := client1.SendRequest("c1-step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{
|
||||
"board_id": boardID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
columns, ok := colListPayload["columns"].([]interface{})
|
||||
var stepListPayload map[string]interface{}
|
||||
err = stepListResp.ParsePayload(&stepListPayload)
|
||||
require.NoError(t, err)
|
||||
|
||||
stepsList, ok := stepListPayload["steps"].([]interface{})
|
||||
require.True(t, ok)
|
||||
assert.Len(t, columns, 1)
|
||||
assert.GreaterOrEqual(t, len(stepsList), 1)
|
||||
|
||||
// Client 1 creates a task in the column
|
||||
// Client 1 creates a task in the workflow step
|
||||
taskResp, err := client1.SendRequest("c1-task-1", ws.ActionTaskCreate, map[string]interface{}{
|
||||
"workspace_id": workspaceID,
|
||||
"board_id": boardID,
|
||||
"column_id": columnID,
|
||||
"title": "Task by Client 1",
|
||||
"repository_id": repositoryID,
|
||||
"base_branch": "main",
|
||||
"workspace_id": workspaceID,
|
||||
"board_id": boardID,
|
||||
"workflow_step_id": workflowStepID,
|
||||
"title": "Task by Client 1",
|
||||
"repository_id": repositoryID,
|
||||
"base_branch": "main",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -929,11 +912,8 @@ func TestErrorHandling(t *testing.T) {
|
||||
assert.Equal(t, ws.MessageTypeError, resp.Type)
|
||||
})
|
||||
|
||||
t.Run("CreateColumnWithoutBoard", func(t *testing.T) {
|
||||
resp, err := client.SendRequest("err-5", ws.ActionColumnCreate, map[string]interface{}{
|
||||
"name": "Orphan Column",
|
||||
"position": 0,
|
||||
})
|
||||
t.Run("ListWorkflowStepsWithoutBoard", func(t *testing.T) {
|
||||
resp, err := client.SendRequest("err-5", ws.ActionWorkflowStepList, map[string]interface{}{})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, ws.MessageTypeError, resp.Type)
|
||||
@@ -954,10 +934,11 @@ func TestTaskSubscription(t *testing.T) {
|
||||
workspaceID := createWorkspace(t, client)
|
||||
repositoryID := createRepository(t, client, workspaceID)
|
||||
|
||||
// Create a task first
|
||||
// Create a task first (workflow steps are created automatically with board)
|
||||
boardResp, _ := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{
|
||||
"workspace_id": workspaceID,
|
||||
"name": "Sub Test Board",
|
||||
"workspace_id": workspaceID,
|
||||
"name": "Sub Test Board",
|
||||
"workflow_template_id": "simple",
|
||||
})
|
||||
var boardPayload map[string]interface{}
|
||||
if err := boardResp.ParsePayload(&boardPayload); err != nil {
|
||||
@@ -965,24 +946,24 @@ func TestTaskSubscription(t *testing.T) {
|
||||
}
|
||||
boardID := boardPayload["id"].(string)
|
||||
|
||||
colResp, _ := client.SendRequest("col-1", ws.ActionColumnCreate, map[string]interface{}{
|
||||
// Get first workflow step
|
||||
stepResp, _ := client.SendRequest("step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{
|
||||
"board_id": boardID,
|
||||
"name": "To Do",
|
||||
"position": 0,
|
||||
})
|
||||
var colPayload map[string]interface{}
|
||||
if err := colResp.ParsePayload(&colPayload); err != nil {
|
||||
t.Fatalf("failed to parse column payload: %v", err)
|
||||
var stepListPayload map[string]interface{}
|
||||
if err := stepResp.ParsePayload(&stepListPayload); err != nil {
|
||||
t.Fatalf("failed to parse step list payload: %v", err)
|
||||
}
|
||||
columnID := colPayload["id"].(string)
|
||||
steps := stepListPayload["steps"].([]interface{})
|
||||
workflowStepID := steps[0].(map[string]interface{})["id"].(string)
|
||||
|
||||
taskResp, _ := client.SendRequest("task-1", ws.ActionTaskCreate, map[string]interface{}{
|
||||
"workspace_id": workspaceID,
|
||||
"board_id": boardID,
|
||||
"column_id": columnID,
|
||||
"title": "Subscribable Task",
|
||||
"repository_id": repositoryID,
|
||||
"base_branch": "main",
|
||||
"workspace_id": workspaceID,
|
||||
"board_id": boardID,
|
||||
"workflow_step_id": workflowStepID,
|
||||
"title": "Subscribable Task",
|
||||
"repository_id": repositoryID,
|
||||
"base_branch": "main",
|
||||
})
|
||||
var taskPayload map[string]interface{}
|
||||
if err := taskResp.ParsePayload(&taskPayload); err != nil {
|
||||
@@ -1036,10 +1017,11 @@ func TestConcurrentRequests(t *testing.T) {
|
||||
workspaceID := createWorkspace(t, client)
|
||||
repositoryID := createRepository(t, client, workspaceID)
|
||||
|
||||
// Create board and column
|
||||
// Create board (workflow steps are created automatically)
|
||||
boardResp, _ := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{
|
||||
"workspace_id": workspaceID,
|
||||
"name": "Concurrent Test Board",
|
||||
"workspace_id": workspaceID,
|
||||
"name": "Concurrent Test Board",
|
||||
"workflow_template_id": "simple",
|
||||
})
|
||||
var boardPayload map[string]interface{}
|
||||
if err := boardResp.ParsePayload(&boardPayload); err != nil {
|
||||
@@ -1047,16 +1029,16 @@ func TestConcurrentRequests(t *testing.T) {
|
||||
}
|
||||
boardID := boardPayload["id"].(string)
|
||||
|
||||
colResp, _ := client.SendRequest("col-1", ws.ActionColumnCreate, map[string]interface{}{
|
||||
// Get first workflow step
|
||||
stepResp, _ := client.SendRequest("step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{
|
||||
"board_id": boardID,
|
||||
"name": "To Do",
|
||||
"position": 0,
|
||||
})
|
||||
var colPayload map[string]interface{}
|
||||
if err := colResp.ParsePayload(&colPayload); err != nil {
|
||||
t.Fatalf("failed to parse column payload: %v", err)
|
||||
var stepListPayload map[string]interface{}
|
||||
if err := stepResp.ParsePayload(&stepListPayload); err != nil {
|
||||
t.Fatalf("failed to parse step list payload: %v", err)
|
||||
}
|
||||
columnID := colPayload["id"].(string)
|
||||
steps := stepListPayload["steps"].([]interface{})
|
||||
workflowStepID := steps[0].(map[string]interface{})["id"].(string)
|
||||
|
||||
// Create multiple tasks concurrently
|
||||
numTasks := 10
|
||||
@@ -1076,12 +1058,12 @@ func TestConcurrentRequests(t *testing.T) {
|
||||
"task-"+string(rune('0'+idx)),
|
||||
ws.ActionTaskCreate,
|
||||
map[string]interface{}{
|
||||
"workspace_id": workspaceID,
|
||||
"board_id": boardID,
|
||||
"column_id": columnID,
|
||||
"title": "Concurrent Task " + string(rune('0'+idx)),
|
||||
"repository_id": repositoryID,
|
||||
"base_branch": "main",
|
||||
"workspace_id": workspaceID,
|
||||
"board_id": boardID,
|
||||
"workflow_step_id": workflowStepID,
|
||||
"title": "Concurrent Task " + string(rune('0'+idx)),
|
||||
"repository_id": repositoryID,
|
||||
"base_branch": "main",
|
||||
},
|
||||
)
|
||||
results <- err
|
||||
|
||||
@@ -22,7 +22,6 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/kandev/kandev/internal/worktree"
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/db"
|
||||
"github.com/kandev/kandev/internal/events"
|
||||
@@ -36,6 +35,11 @@ import (
|
||||
taskhandlers "github.com/kandev/kandev/internal/task/handlers"
|
||||
"github.com/kandev/kandev/internal/task/repository"
|
||||
taskservice "github.com/kandev/kandev/internal/task/service"
|
||||
"github.com/kandev/kandev/internal/workflow"
|
||||
workflowcontroller "github.com/kandev/kandev/internal/workflow/controller"
|
||||
workflowhandlers "github.com/kandev/kandev/internal/workflow/handlers"
|
||||
workflowservice "github.com/kandev/kandev/internal/workflow/service"
|
||||
"github.com/kandev/kandev/internal/worktree"
|
||||
"github.com/kandev/kandev/pkg/acp/protocol"
|
||||
v1 "github.com/kandev/kandev/pkg/api/v1"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
@@ -425,6 +429,7 @@ type OrchestratorTestServer struct {
|
||||
Gateway *gateways.Gateway
|
||||
TaskRepo repository.Repository
|
||||
TaskSvc *taskservice.Service
|
||||
WorkflowSvc *workflowservice.Service
|
||||
EventBus bus.EventBus
|
||||
OrchestratorSvc *orchestrator.Service
|
||||
AgentManager *SimulatedAgentManagerClient
|
||||
@@ -489,8 +494,13 @@ func NewOrchestratorTestServer(t *testing.T) *OrchestratorTestServer {
|
||||
t.Fatalf("failed to init worktree store: %v", err)
|
||||
}
|
||||
|
||||
// Initialize task service
|
||||
// Initialize workflow service
|
||||
_, workflowSvc, _, err := workflow.Provide(dbConn, log)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Initialize task service and wire workflow step creator
|
||||
taskSvc := taskservice.NewService(taskRepo, eventBus, log, taskservice.RepositoryDiscoveryConfig{})
|
||||
taskSvc.SetWorkflowStepCreator(workflowSvc)
|
||||
|
||||
// Create simulated agent manager
|
||||
agentManager := NewSimulatedAgentManager(eventBus, log)
|
||||
@@ -528,16 +538,19 @@ func NewOrchestratorTestServer(t *testing.T) *OrchestratorTestServer {
|
||||
// Register handlers (HTTP + WS)
|
||||
workspaceController := taskcontroller.NewWorkspaceController(taskSvc)
|
||||
boardController := taskcontroller.NewBoardController(taskSvc)
|
||||
boardController.SetWorkflowStepLister(workflowSvc)
|
||||
taskController := taskcontroller.NewTaskController(taskSvc)
|
||||
executorController := taskcontroller.NewExecutorController(taskSvc)
|
||||
environmentController := taskcontroller.NewEnvironmentController(taskSvc)
|
||||
repositoryController := taskcontroller.NewRepositoryController(taskSvc)
|
||||
workflowCtrl := workflowcontroller.NewController(workflowSvc)
|
||||
taskhandlers.RegisterWorkspaceRoutes(router, gateway.Dispatcher, workspaceController, log)
|
||||
taskhandlers.RegisterBoardRoutes(router, gateway.Dispatcher, boardController, log)
|
||||
taskhandlers.RegisterTaskRoutes(router, gateway.Dispatcher, taskController, nil, log)
|
||||
taskhandlers.RegisterRepositoryRoutes(router, gateway.Dispatcher, repositoryController, log)
|
||||
taskhandlers.RegisterExecutorRoutes(router, gateway.Dispatcher, executorController, log)
|
||||
taskhandlers.RegisterEnvironmentRoutes(router, gateway.Dispatcher, environmentController, log)
|
||||
workflowhandlers.RegisterRoutes(router, gateway.Dispatcher, workflowCtrl, log)
|
||||
|
||||
// Create test server
|
||||
server := httptest.NewServer(router)
|
||||
@@ -547,6 +560,7 @@ func NewOrchestratorTestServer(t *testing.T) *OrchestratorTestServer {
|
||||
Gateway: gateway,
|
||||
TaskRepo: taskRepo,
|
||||
TaskSvc: taskSvc,
|
||||
WorkflowSvc: workflowSvc,
|
||||
EventBus: eventBus,
|
||||
OrchestratorSvc: orchestratorSvc,
|
||||
AgentManager: agentManager,
|
||||
@@ -579,22 +593,21 @@ func (ts *OrchestratorTestServer) CreateTestTask(t *testing.T, agentProfileID st
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create board first
|
||||
// Create board first (workflow steps are created automatically via default template)
|
||||
defaultTemplateID := "simple"
|
||||
board, err := ts.TaskSvc.CreateBoard(context.Background(), &taskservice.CreateBoardRequest{
|
||||
WorkspaceID: workspace.ID,
|
||||
Name: "Test Board",
|
||||
Description: "Test board for orchestrator",
|
||||
WorkspaceID: workspace.ID,
|
||||
Name: "Test Board",
|
||||
Description: "Test board for orchestrator",
|
||||
WorkflowTemplateID: &defaultTemplateID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create column
|
||||
col, err := ts.TaskSvc.CreateColumn(context.Background(), &taskservice.CreateColumnRequest{
|
||||
BoardID: board.ID,
|
||||
Name: "TODO",
|
||||
Position: 0,
|
||||
State: v1.TaskStateTODO,
|
||||
})
|
||||
// Get first workflow step from board
|
||||
steps, err := ts.WorkflowSvc.ListStepsByBoard(context.Background(), board.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, steps, "board should have workflow steps")
|
||||
workflowStepID := steps[0].ID
|
||||
|
||||
// Create task with agent profile ID
|
||||
repository, err := ts.TaskSvc.CreateRepository(context.Background(), &taskservice.CreateRepositoryRequest{
|
||||
@@ -605,12 +618,12 @@ func (ts *OrchestratorTestServer) CreateTestTask(t *testing.T, agentProfileID st
|
||||
require.NoError(t, err)
|
||||
|
||||
task, err := ts.TaskSvc.CreateTask(context.Background(), &taskservice.CreateTaskRequest{
|
||||
WorkspaceID: workspace.ID,
|
||||
BoardID: board.ID,
|
||||
ColumnID: col.ID,
|
||||
Title: "Test Task",
|
||||
Description: "This is a test task for the orchestrator",
|
||||
Priority: priority,
|
||||
WorkspaceID: workspace.ID,
|
||||
BoardID: board.ID,
|
||||
WorkflowStepID: workflowStepID,
|
||||
Title: "Test Task",
|
||||
Description: "This is a test task for the orchestrator",
|
||||
Priority: priority,
|
||||
Repositories: []taskservice.TaskRepositoryInput{
|
||||
{
|
||||
RepositoryID: repository.ID,
|
||||
@@ -1406,11 +1419,12 @@ func TestOrchestratorEndToEndWorkflow(t *testing.T) {
|
||||
|
||||
workspaceID := createOrchestratorWorkspace(t, client)
|
||||
|
||||
// 1. Create board
|
||||
// 1. Create board with workflow template
|
||||
boardResp, err := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{
|
||||
"workspace_id": workspaceID,
|
||||
"name": "E2E Test Board",
|
||||
"description": "End-to-end test board",
|
||||
"workspace_id": workspaceID,
|
||||
"name": "E2E Test Board",
|
||||
"description": "End-to-end test board",
|
||||
"workflow_template_id": "simple",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -1418,20 +1432,19 @@ func TestOrchestratorEndToEndWorkflow(t *testing.T) {
|
||||
require.NoError(t, boardResp.ParsePayload(&boardPayload))
|
||||
boardID := boardPayload["id"].(string)
|
||||
|
||||
// 2. Create column
|
||||
colResp, err := client.SendRequest("col-1", ws.ActionColumnCreate, map[string]interface{}{
|
||||
// 2. Get first workflow step from board
|
||||
stepResp, err := client.SendRequest("step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{
|
||||
"board_id": boardID,
|
||||
"name": "TODO",
|
||||
"position": 0,
|
||||
"state": "TODO",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
var colPayload map[string]interface{}
|
||||
require.NoError(t, colResp.ParsePayload(&colPayload))
|
||||
colID := colPayload["id"].(string)
|
||||
var stepListPayload map[string]interface{}
|
||||
require.NoError(t, stepResp.ParsePayload(&stepListPayload))
|
||||
steps := stepListPayload["steps"].([]interface{})
|
||||
require.NotEmpty(t, steps, "board should have workflow steps")
|
||||
workflowStepID := steps[0].(map[string]interface{})["id"].(string)
|
||||
|
||||
// 3. Create task with agent_profile_id
|
||||
// 3. Create task with workflow step
|
||||
repoResp, err := client.SendRequest("repo-1", ws.ActionRepositoryCreate, map[string]interface{}{
|
||||
"workspace_id": workspaceID,
|
||||
"name": "Test Repo",
|
||||
@@ -1445,14 +1458,14 @@ func TestOrchestratorEndToEndWorkflow(t *testing.T) {
|
||||
repositoryID := repoPayload["id"].(string)
|
||||
|
||||
taskResp, err := client.SendRequest("task-1", ws.ActionTaskCreate, map[string]interface{}{
|
||||
"workspace_id": workspaceID,
|
||||
"board_id": boardID,
|
||||
"column_id": colID,
|
||||
"title": "Implement feature X",
|
||||
"description": "Create a new feature with tests",
|
||||
"priority": 2,
|
||||
"repository_id": repositoryID,
|
||||
"base_branch": "main",
|
||||
"workspace_id": workspaceID,
|
||||
"board_id": boardID,
|
||||
"workflow_step_id": workflowStepID,
|
||||
"title": "Implement feature X",
|
||||
"description": "Create a new feature with tests",
|
||||
"priority": 2,
|
||||
"repository_id": repositoryID,
|
||||
"base_branch": "main",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
|
||||
@@ -34,16 +34,16 @@ func registerTools(s *server.MCPServer, cfg Config, log *logger.Logger) {
|
||||
listBoardsHandler(cfg, log),
|
||||
)
|
||||
|
||||
// List Columns tool
|
||||
// List Workflow Steps tool
|
||||
s.AddTool(
|
||||
mcp.NewTool("list_columns",
|
||||
mcp.WithDescription("List all columns in a board. Use this to get column IDs for creating tasks."),
|
||||
mcp.NewTool("list_workflow_steps",
|
||||
mcp.WithDescription("List all workflow steps in a board. Use this to get workflow step IDs for creating tasks."),
|
||||
mcp.WithString("board_id",
|
||||
mcp.Required(),
|
||||
mcp.Description("The board ID to list columns from"),
|
||||
mcp.Description("The board ID to list workflow steps from"),
|
||||
),
|
||||
),
|
||||
listColumnsHandler(cfg, log),
|
||||
listWorkflowStepsHandler(cfg, log),
|
||||
)
|
||||
|
||||
// List Tasks tool
|
||||
@@ -70,9 +70,9 @@ func registerTools(s *server.MCPServer, cfg Config, log *logger.Logger) {
|
||||
mcp.Required(),
|
||||
mcp.Description("The board ID"),
|
||||
),
|
||||
mcp.WithString("column_id",
|
||||
mcp.WithString("workflow_step_id",
|
||||
mcp.Required(),
|
||||
mcp.Description("The column ID to place the task in"),
|
||||
mcp.Description("The workflow step ID to place the task in"),
|
||||
),
|
||||
mcp.WithString("title",
|
||||
mcp.Required(),
|
||||
@@ -154,18 +154,18 @@ func listBoardsHandler(cfg Config, log *logger.Logger) server.ToolHandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func listColumnsHandler(cfg Config, log *logger.Logger) server.ToolHandlerFunc {
|
||||
func listWorkflowStepsHandler(cfg Config, log *logger.Logger) server.ToolHandlerFunc {
|
||||
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||
boardID, err := req.RequireString("board_id")
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/api/v1/boards/%s/columns", cfg.KandevURL, boardID)
|
||||
url := fmt.Sprintf("%s/api/v1/boards/%s/workflow/steps", cfg.KandevURL, boardID)
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
log.Error("failed to fetch columns", zap.Error(err))
|
||||
return mcp.NewToolResultError(fmt.Sprintf("Failed to fetch columns: %v", err)), nil
|
||||
log.Error("failed to fetch workflow steps", zap.Error(err))
|
||||
return mcp.NewToolResultError(fmt.Sprintf("Failed to fetch workflow steps: %v", err)), nil
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
@@ -214,7 +214,7 @@ func createTaskHandler(cfg Config, log *logger.Logger) server.ToolHandlerFunc {
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
columnID, err := req.RequireString("column_id")
|
||||
workflowStepID, err := req.RequireString("workflow_step_id")
|
||||
if err != nil {
|
||||
return mcp.NewToolResultError(err.Error()), nil
|
||||
}
|
||||
@@ -224,10 +224,10 @@ func createTaskHandler(cfg Config, log *logger.Logger) server.ToolHandlerFunc {
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"workspace_id": workspaceID,
|
||||
"board_id": boardID,
|
||||
"column_id": columnID,
|
||||
"title": title,
|
||||
"workspace_id": workspaceID,
|
||||
"board_id": boardID,
|
||||
"workflow_step_id": workflowStepID,
|
||||
"title": title,
|
||||
}
|
||||
if desc := req.GetString("description", ""); desc != "" {
|
||||
payload["description"] = desc
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/sqlite"
|
||||
"github.com/kandev/kandev/internal/notifications/models"
|
||||
)
|
||||
|
||||
@@ -103,7 +104,7 @@ func (r *sqliteRepository) CreateProvider(ctx context.Context, provider *models.
|
||||
_, err = r.db.ExecContext(ctx, `
|
||||
INSERT INTO notification_providers (id, user_id, name, type, config, enabled, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, provider.ID, provider.UserID, provider.Name, provider.Type, string(configJSON), boolToInt(provider.Enabled), provider.CreatedAt, provider.UpdatedAt)
|
||||
`, provider.ID, provider.UserID, provider.Name, provider.Type, string(configJSON), sqlite.BoolToInt(provider.Enabled), provider.CreatedAt, provider.UpdatedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -120,7 +121,7 @@ func (r *sqliteRepository) UpdateProvider(ctx context.Context, provider *models.
|
||||
UPDATE notification_providers
|
||||
SET name = ?, type = ?, config = ?, enabled = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`, provider.Name, provider.Type, string(configJSON), boolToInt(provider.Enabled), provider.UpdatedAt, provider.ID)
|
||||
`, provider.Name, provider.Type, string(configJSON), sqlite.BoolToInt(provider.Enabled), provider.UpdatedAt, provider.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -210,7 +211,7 @@ func (r *sqliteRepository) ReplaceSubscriptions(ctx context.Context, providerID,
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO notification_subscriptions (id, user_id, provider_id, event_type, enabled, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, subID, userID, providerID, eventType, boolToInt(true), now, now); err != nil {
|
||||
`, subID, userID, providerID, eventType, sqlite.BoolToInt(true), now, now); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return err
|
||||
}
|
||||
@@ -290,10 +291,3 @@ func scanSubscription(scanner interface{ Scan(dest ...any) error }) (*models.Sub
|
||||
sub.Enabled = enabledInt == 1
|
||||
return sub, nil
|
||||
}
|
||||
|
||||
func boolToInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -59,9 +59,26 @@ func (c *Controller) TriggerTask(ctx context.Context, req dto.TriggerTaskRequest
|
||||
}, nil
|
||||
}
|
||||
|
||||
// StartTask starts task execution
|
||||
// StartTask starts task execution.
|
||||
// If SessionID is provided, resumes the existing session and sends a prompt with workflow step config.
|
||||
// If SessionID is not provided, creates a new session (requires AgentProfileID).
|
||||
func (c *Controller) StartTask(ctx context.Context, req dto.StartTaskRequest) (dto.StartTaskResponse, error) {
|
||||
execution, err := c.service.StartTask(ctx, req.TaskID, req.AgentProfileID, req.ExecutorID, req.Priority, req.Prompt)
|
||||
// If session_id is provided, resume existing session with workflow step config
|
||||
if req.SessionID != "" {
|
||||
err := c.service.StartSessionForWorkflowStep(ctx, req.TaskID, req.SessionID, req.WorkflowStepID)
|
||||
if err != nil {
|
||||
return dto.StartTaskResponse{}, err
|
||||
}
|
||||
return dto.StartTaskResponse{
|
||||
Success: true,
|
||||
TaskID: req.TaskID,
|
||||
TaskSessionID: req.SessionID,
|
||||
State: "RUNNING",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Otherwise, create a new session
|
||||
execution, err := c.service.StartTask(ctx, req.TaskID, req.AgentProfileID, req.ExecutorID, req.Priority, req.Prompt, req.WorkflowStepID)
|
||||
if err != nil {
|
||||
return dto.StartTaskResponse{}, err
|
||||
}
|
||||
|
||||
@@ -40,12 +40,16 @@ type TriggerTaskResponse struct {
|
||||
}
|
||||
|
||||
// StartTaskRequest is the payload for orchestrator.start
|
||||
// If SessionID is provided, the existing session is resumed and prompted with workflow step config.
|
||||
// If SessionID is not provided, a new session is created (requires AgentProfileID).
|
||||
type StartTaskRequest struct {
|
||||
TaskID string `json:"task_id"`
|
||||
AgentProfileID string `json:"agent_profile_id,omitempty"`
|
||||
ExecutorID string `json:"executor_id,omitempty"` // Executor to use (determines runtime: local_pc, local_docker, etc.)
|
||||
SessionID string `json:"session_id,omitempty"` // If provided, resume this existing session instead of creating new
|
||||
AgentProfileID string `json:"agent_profile_id,omitempty"` // Required when creating new session (SessionID not provided)
|
||||
ExecutorID string `json:"executor_id,omitempty"` // Executor to use (determines runtime: local_pc, local_docker, etc.)
|
||||
Priority int `json:"priority,omitempty"`
|
||||
Prompt string `json:"prompt,omitempty"` // Initial prompt to send to the agent
|
||||
Prompt string `json:"prompt,omitempty"` // Initial prompt to send to the agent
|
||||
WorkflowStepID string `json:"workflow_step_id,omitempty"` // If provided, prompt prefix/suffix and plan mode are applied from the step
|
||||
}
|
||||
|
||||
// StartTaskResponse is the response for orchestrator.start
|
||||
|
||||
@@ -100,32 +100,307 @@ func (s *Service) handleAgentReady(ctx context.Context, data watcher.AgentEventD
|
||||
// Complete the current turn
|
||||
s.completeTurnForSession(ctx, data.SessionID)
|
||||
|
||||
// Move session to waiting for input and task to review
|
||||
s.setSessionWaitingForInput(ctx, data.TaskID, data.SessionID)
|
||||
// Check for workflow transition based on session's current step
|
||||
// This handles the case where the agent finishes a step and should move to the next
|
||||
transitioned := s.handleWorkflowTransition(ctx, data.TaskID, data.SessionID)
|
||||
|
||||
// If no workflow transition occurred, move session to waiting for input and task to review
|
||||
if !transitioned {
|
||||
s.setSessionWaitingForInput(ctx, data.TaskID, data.SessionID)
|
||||
}
|
||||
}
|
||||
|
||||
// handleAgentCompleted handles agent completion events
|
||||
func (s *Service) handleAgentCompleted(ctx context.Context, data watcher.AgentEventData) {
|
||||
s.logger.Info("handling agent completed",
|
||||
zap.String("task_id", data.TaskID),
|
||||
zap.String("session_id", data.SessionID),
|
||||
zap.String("agent_execution_id", data.AgentExecutionID))
|
||||
|
||||
// Update scheduler and remove from queue
|
||||
s.scheduler.HandleTaskCompleted(data.TaskID, true)
|
||||
s.scheduler.RemoveTask(data.TaskID)
|
||||
|
||||
// Move task to REVIEW state for user review
|
||||
// The user can then send a follow-up (moves back to IN_PROGRESS) or mark as COMPLETED
|
||||
if err := s.taskRepo.UpdateTaskState(ctx, data.TaskID, v1.TaskStateReview); err != nil {
|
||||
s.logger.Error("failed to update task state to REVIEW",
|
||||
zap.String("task_id", data.TaskID),
|
||||
zap.Error(err))
|
||||
} else {
|
||||
s.logger.Info("task moved to REVIEW state after agent completion",
|
||||
zap.String("task_id", data.TaskID))
|
||||
// Check for workflow transition based on session's current step
|
||||
transitioned := s.handleWorkflowTransition(ctx, data.TaskID, data.SessionID)
|
||||
|
||||
// If no workflow transition occurred, move task to REVIEW state for user review
|
||||
if !transitioned {
|
||||
if err := s.taskRepo.UpdateTaskState(ctx, data.TaskID, v1.TaskStateReview); err != nil {
|
||||
s.logger.Error("failed to update task state to REVIEW",
|
||||
zap.String("task_id", data.TaskID),
|
||||
zap.Error(err))
|
||||
} else {
|
||||
s.logger.Info("task moved to REVIEW state after agent completion",
|
||||
zap.String("task_id", data.TaskID))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleWorkflowTransition checks if the session's current workflow step has an on_complete_step_id
|
||||
// and moves the task to that step if so. Returns true if a transition occurred.
|
||||
func (s *Service) handleWorkflowTransition(ctx context.Context, taskID, sessionID string) bool {
|
||||
if sessionID == "" || s.workflowStepGetter == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Get the session to find its current workflow step
|
||||
session, err := s.repo.GetTaskSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
s.logger.Warn("failed to get session for workflow transition",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Error(err))
|
||||
return false
|
||||
}
|
||||
|
||||
if session.WorkflowStepID == nil || *session.WorkflowStepID == "" {
|
||||
s.logger.Debug("session has no workflow step, skipping transition",
|
||||
zap.String("session_id", sessionID))
|
||||
return false
|
||||
}
|
||||
|
||||
workflowStepID := *session.WorkflowStepID
|
||||
|
||||
// Get the current workflow step
|
||||
currentStep, err := s.workflowStepGetter.GetStep(ctx, workflowStepID)
|
||||
if err != nil {
|
||||
s.logger.Warn("failed to get workflow step for transition",
|
||||
zap.String("workflow_step_id", workflowStepID),
|
||||
zap.Error(err))
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if there's an on_complete transition
|
||||
if currentStep.OnCompleteStepID == "" {
|
||||
s.logger.Debug("workflow step has no on_complete transition",
|
||||
zap.String("step_id", currentStep.ID),
|
||||
zap.String("step_name", currentStep.Name))
|
||||
return false
|
||||
}
|
||||
|
||||
// Get the target step to log its name
|
||||
targetStep, err := s.workflowStepGetter.GetStep(ctx, currentStep.OnCompleteStepID)
|
||||
if err != nil {
|
||||
s.logger.Warn("failed to get target workflow step",
|
||||
zap.String("target_step_id", currentStep.OnCompleteStepID),
|
||||
zap.Error(err))
|
||||
return false
|
||||
}
|
||||
|
||||
// Get the task to update its workflow step
|
||||
task, err := s.repo.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
s.logger.Warn("failed to get task for workflow transition",
|
||||
zap.String("task_id", taskID),
|
||||
zap.Error(err))
|
||||
return false
|
||||
}
|
||||
|
||||
// Update the task's workflow step
|
||||
task.WorkflowStepID = currentStep.OnCompleteStepID
|
||||
task.UpdatedAt = time.Now().UTC()
|
||||
if err := s.repo.UpdateTask(ctx, task); err != nil {
|
||||
s.logger.Error("failed to move task to next workflow step",
|
||||
zap.String("task_id", taskID),
|
||||
zap.String("from_step", currentStep.Name),
|
||||
zap.String("to_step", targetStep.Name),
|
||||
zap.Error(err))
|
||||
return false
|
||||
}
|
||||
|
||||
// Publish task updated event so frontend updates the kanban board
|
||||
if s.eventBus != nil {
|
||||
taskEventData := map[string]interface{}{
|
||||
"task_id": task.ID,
|
||||
"board_id": task.BoardID,
|
||||
"workflow_step_id": task.WorkflowStepID,
|
||||
"title": task.Title,
|
||||
"description": task.Description,
|
||||
"state": string(task.State),
|
||||
"priority": task.Priority,
|
||||
"position": task.Position,
|
||||
}
|
||||
_ = s.eventBus.Publish(ctx, events.TaskUpdated, bus.NewEvent(
|
||||
events.TaskUpdated,
|
||||
"orchestrator",
|
||||
taskEventData,
|
||||
))
|
||||
}
|
||||
|
||||
// Also update the session's workflow step
|
||||
if err := s.repo.UpdateSessionWorkflowStep(ctx, sessionID, currentStep.OnCompleteStepID); err != nil {
|
||||
s.logger.Warn("failed to update session workflow step",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("step_id", currentStep.OnCompleteStepID),
|
||||
zap.Error(err))
|
||||
// Don't return false - task was already moved
|
||||
}
|
||||
|
||||
// If the target step requires approval, set the session's review_status to "pending"
|
||||
var reviewStatus string
|
||||
if targetStep.RequireApproval {
|
||||
reviewStatus = "pending"
|
||||
if err := s.repo.UpdateSessionReviewStatus(ctx, sessionID, reviewStatus); err != nil {
|
||||
s.logger.Warn("failed to set session review status to pending",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Error(err))
|
||||
reviewStatus = "" // Clear on error
|
||||
} else {
|
||||
s.logger.Info("session review status set to pending",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("target_step", targetStep.Name))
|
||||
}
|
||||
}
|
||||
|
||||
// Update session state to WAITING_FOR_INPUT since the agent completed this step
|
||||
// This must happen before publishing the event so the frontend gets the correct state
|
||||
if err := s.repo.UpdateTaskSessionState(ctx, sessionID, models.TaskSessionStateWaitingForInput, ""); err != nil {
|
||||
s.logger.Error("failed to update session state to WAITING_FOR_INPUT during workflow transition",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Error(err))
|
||||
}
|
||||
|
||||
// Publish session updated event with workflow step and review status changes
|
||||
// This allows the frontend to update the session state without a full refresh
|
||||
if s.eventBus != nil {
|
||||
eventData := map[string]interface{}{
|
||||
"task_id": taskID,
|
||||
"session_id": sessionID,
|
||||
"workflow_step_id": targetStep.ID,
|
||||
"new_state": string(models.TaskSessionStateWaitingForInput),
|
||||
}
|
||||
if reviewStatus != "" {
|
||||
eventData["review_status"] = reviewStatus
|
||||
}
|
||||
_ = s.eventBus.Publish(ctx, events.TaskSessionStateChanged, bus.NewEvent(
|
||||
events.TaskSessionStateChanged,
|
||||
"orchestrator",
|
||||
eventData,
|
||||
))
|
||||
}
|
||||
|
||||
s.logger.Info("workflow transition completed",
|
||||
zap.String("task_id", taskID),
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("from_step", currentStep.Name),
|
||||
zap.String("to_step", targetStep.Name))
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// handleReviewStepRollback checks if the session is in a review step with pending status.
|
||||
// If so, it moves the session back to the previous step (the step that led to the review step)
|
||||
// and clears the review status. This handles the case where the user sends a message to
|
||||
// iterate on the work instead of approving.
|
||||
func (s *Service) handleReviewStepRollback(ctx context.Context, taskID, sessionID string) {
|
||||
if sessionID == "" || s.workflowStepGetter == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Get the session to check its current workflow step and review status
|
||||
session, err := s.repo.GetTaskSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
s.logger.Warn("failed to get session for review rollback check",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
// Only proceed if session has a workflow step and pending review status
|
||||
if session.WorkflowStepID == nil || *session.WorkflowStepID == "" {
|
||||
return
|
||||
}
|
||||
if session.ReviewStatus == nil || *session.ReviewStatus != "pending" {
|
||||
return
|
||||
}
|
||||
|
||||
currentStepID := *session.WorkflowStepID
|
||||
|
||||
// Get the current workflow step to check if it requires approval
|
||||
currentStep, err := s.workflowStepGetter.GetStep(ctx, currentStepID)
|
||||
if err != nil {
|
||||
s.logger.Warn("failed to get workflow step for review rollback",
|
||||
zap.String("workflow_step_id", currentStepID),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
// Only proceed if the current step requires approval (is a review step)
|
||||
if !currentStep.RequireApproval {
|
||||
return
|
||||
}
|
||||
|
||||
// Get the task to find the board ID
|
||||
task, err := s.repo.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
s.logger.Warn("failed to get task for review rollback",
|
||||
zap.String("task_id", taskID),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
// Find the source step (the step that has on_complete_step_id pointing to current step)
|
||||
sourceStep, err := s.workflowStepGetter.GetSourceStep(ctx, task.BoardID, currentStepID)
|
||||
if err != nil {
|
||||
s.logger.Warn("failed to get source step for review rollback",
|
||||
zap.String("current_step_id", currentStepID),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
if sourceStep == nil {
|
||||
s.logger.Debug("no source step found for review rollback, staying in current step",
|
||||
zap.String("current_step_id", currentStepID))
|
||||
return
|
||||
}
|
||||
|
||||
// Move session back to the source step
|
||||
if err := s.repo.UpdateSessionWorkflowStep(ctx, sessionID, sourceStep.ID); err != nil {
|
||||
s.logger.Error("failed to move session back to source step",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("source_step_id", sourceStep.ID),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
// Clear the review status
|
||||
if err := s.repo.UpdateSessionReviewStatus(ctx, sessionID, ""); err != nil {
|
||||
s.logger.Warn("failed to clear session review status",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Error(err))
|
||||
}
|
||||
|
||||
// Also move the task to the source step
|
||||
task.WorkflowStepID = sourceStep.ID
|
||||
if err := s.repo.UpdateTask(ctx, task); err != nil {
|
||||
s.logger.Error("failed to move task back to source step",
|
||||
zap.String("task_id", taskID),
|
||||
zap.String("source_step_id", sourceStep.ID),
|
||||
zap.Error(err))
|
||||
}
|
||||
|
||||
// Publish session state change event so frontend updates
|
||||
if s.eventBus != nil {
|
||||
eventData := map[string]interface{}{
|
||||
"task_id": taskID,
|
||||
"session_id": sessionID,
|
||||
"workflow_step_id": sourceStep.ID,
|
||||
"new_state": string(session.State),
|
||||
"review_status": "", // Cleared
|
||||
}
|
||||
_ = s.eventBus.Publish(ctx, events.TaskSessionStateChanged, bus.NewEvent(
|
||||
events.TaskSessionStateChanged,
|
||||
"orchestrator",
|
||||
eventData,
|
||||
))
|
||||
}
|
||||
|
||||
s.logger.Info("session moved back from review step",
|
||||
zap.String("task_id", taskID),
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("from_step", currentStep.Name),
|
||||
zap.String("to_step", sourceStep.Name))
|
||||
}
|
||||
|
||||
// handleAgentFailed handles agent failure events
|
||||
func (s *Service) handleAgentFailed(ctx context.Context, data watcher.AgentEventData) {
|
||||
s.logger.Warn("handling agent failed",
|
||||
|
||||
@@ -204,14 +204,15 @@ func (e *Executor) applyPreferredShellEnv(ctx context.Context, env map[string]st
|
||||
|
||||
// Execute starts agent execution for a task
|
||||
func (e *Executor) Execute(ctx context.Context, task *v1.Task) (*TaskExecution, error) {
|
||||
return e.ExecuteWithProfile(ctx, task, "", "", task.Description)
|
||||
return e.ExecuteWithProfile(ctx, task, "", "", task.Description, "")
|
||||
}
|
||||
|
||||
// ExecuteWithProfile starts agent execution for a task using an explicit agent profile.
|
||||
// The executorID parameter specifies which executor to use (determines runtime: local_pc, local_docker, etc.).
|
||||
// If executorID is empty, falls back to workspace's default executor.
|
||||
// The prompt parameter is the initial prompt to send to the agent.
|
||||
func (e *Executor) ExecuteWithProfile(ctx context.Context, task *v1.Task, agentProfileID string, executorID string, prompt string) (*TaskExecution, error) {
|
||||
// The workflowStepID parameter associates the session with a workflow step for transitions.
|
||||
func (e *Executor) ExecuteWithProfile(ctx context.Context, task *v1.Task, agentProfileID string, executorID string, prompt string, workflowStepID string) (*TaskExecution, error) {
|
||||
if agentProfileID == "" {
|
||||
e.logger.Error("task has no agent_profile_id configured", zap.String("task_id", task.ID))
|
||||
return nil, ErrNoAgentProfileID
|
||||
@@ -324,6 +325,11 @@ func (e *Executor) ExecuteWithProfile(ctx context.Context, task *v1.Task, agentP
|
||||
StartedAt: now,
|
||||
UpdatedAt: now,
|
||||
AgentProfileSnapshot: agentProfileSnapshot,
|
||||
IsPrimary: true, // Set as primary when creating
|
||||
}
|
||||
// Set workflow step ID if provided (for workflow transitions)
|
||||
if workflowStepID != "" {
|
||||
session.WorkflowStepID = &workflowStepID
|
||||
}
|
||||
// Resolve executor configuration
|
||||
execConfig := e.resolveExecutorConfig(ctx, executorID, task.WorkspaceID, metadata)
|
||||
@@ -344,6 +350,16 @@ func (e *Executor) ExecuteWithProfile(ctx context.Context, task *v1.Task, agentP
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Clear primary flag on any other sessions for this task
|
||||
// (the new session was created with IsPrimary=true)
|
||||
if err := e.repo.SetSessionPrimary(ctx, sessionID); err != nil {
|
||||
e.logger.Warn("failed to update primary session flag",
|
||||
zap.String("task_id", task.ID),
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Error(err))
|
||||
// Continue anyway - the session was already created with is_primary=true
|
||||
}
|
||||
|
||||
req.SessionID = sessionID
|
||||
if len(metadata) > 0 {
|
||||
req.Metadata = metadata
|
||||
|
||||
@@ -83,10 +83,12 @@ func (h *Handlers) wsTriggerTask(ctx context.Context, msg *ws.Message) (*ws.Mess
|
||||
|
||||
type wsStartTaskRequest struct {
|
||||
TaskID string `json:"task_id"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
AgentProfileID string `json:"agent_profile_id,omitempty"`
|
||||
ExecutorID string `json:"executor_id,omitempty"`
|
||||
Priority int `json:"priority,omitempty"`
|
||||
Prompt string `json:"prompt,omitempty"`
|
||||
WorkflowStepID string `json:"workflow_step_id,omitempty"`
|
||||
}
|
||||
|
||||
func (h *Handlers) wsStartTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
||||
@@ -97,16 +99,19 @@ func (h *Handlers) wsStartTask(ctx context.Context, msg *ws.Message) (*ws.Messag
|
||||
if req.TaskID == "" {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil)
|
||||
}
|
||||
if req.AgentProfileID == "" {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "agent_profile_id is required", nil)
|
||||
// agent_profile_id is only required when creating a new session (no session_id provided)
|
||||
if req.SessionID == "" && req.AgentProfileID == "" {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "agent_profile_id is required when creating a new session", nil)
|
||||
}
|
||||
|
||||
resp, err := h.controller.StartTask(ctx, dto.StartTaskRequest{
|
||||
TaskID: req.TaskID,
|
||||
SessionID: req.SessionID,
|
||||
AgentProfileID: req.AgentProfileID,
|
||||
ExecutorID: req.ExecutorID,
|
||||
Priority: req.Priority,
|
||||
Prompt: req.Prompt,
|
||||
WorkflowStepID: req.WorkflowStepID,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("failed to start task", zap.String("task_id", req.TaskID), zap.Error(err))
|
||||
|
||||
@@ -72,6 +72,28 @@ type TurnService interface {
|
||||
GetActiveTurn(ctx context.Context, sessionID string) (*models.Turn, error)
|
||||
}
|
||||
|
||||
// WorkflowStep contains workflow step data needed for prompt construction and transitions.
|
||||
type WorkflowStep struct {
|
||||
ID string
|
||||
Name string
|
||||
StepType string
|
||||
AutoStartAgent bool
|
||||
PlanMode bool
|
||||
RequireApproval bool
|
||||
PromptPrefix string
|
||||
PromptSuffix string
|
||||
OnCompleteStepID string // Step to transition to when agent completes
|
||||
OnApprovalStepID string // Step to transition to when user approves
|
||||
}
|
||||
|
||||
// WorkflowStepGetter retrieves workflow step information for prompt building.
|
||||
type WorkflowStepGetter interface {
|
||||
GetStep(ctx context.Context, stepID string) (*WorkflowStep, error)
|
||||
// GetSourceStep finds the step that has on_complete_step_id pointing to the given step.
|
||||
// Used to find the "previous" step when moving back from a review step.
|
||||
GetSourceStep(ctx context.Context, boardID, targetStepID string) (*WorkflowStep, error)
|
||||
}
|
||||
|
||||
// Service is the main orchestrator service
|
||||
type Service struct {
|
||||
config ServiceConfig
|
||||
@@ -93,6 +115,9 @@ type Service struct {
|
||||
// Turn service for managing session turns
|
||||
turnService TurnService
|
||||
|
||||
// Workflow step getter for prompt building
|
||||
workflowStepGetter WorkflowStepGetter
|
||||
|
||||
// Active turns map: sessionID -> turnID
|
||||
activeTurns sync.Map
|
||||
|
||||
@@ -207,6 +232,17 @@ func (s *Service) SetTurnService(turnService TurnService) {
|
||||
s.turnService = turnService
|
||||
}
|
||||
|
||||
// SetWorkflowStepGetter sets the workflow step getter for prompt building.
|
||||
//
|
||||
// When workflow_step_id is provided to StartTask, the orchestrator uses this getter
|
||||
// to retrieve the step's prompt_prefix, prompt_suffix, and plan_mode settings to
|
||||
// build the effective prompt.
|
||||
//
|
||||
// If not set: workflow_step_id in StartTask is ignored and the prompt is used as-is.
|
||||
func (s *Service) SetWorkflowStepGetter(getter WorkflowStepGetter) {
|
||||
s.workflowStepGetter = getter
|
||||
}
|
||||
|
||||
// startTurnForSession starts a new turn for the session and stores it.
|
||||
func (s *Service) startTurnForSession(ctx context.Context, sessionID string) string {
|
||||
if s.turnService == nil {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
@@ -41,14 +42,18 @@ func (s *Service) EnqueueTask(ctx context.Context, task *v1.Task) error {
|
||||
return s.scheduler.EnqueueTask(task)
|
||||
}
|
||||
|
||||
// StartTask manually starts agent execution for a task
|
||||
func (s *Service) StartTask(ctx context.Context, taskID string, agentProfileID string, executorID string, priority int, prompt string) (*executor.TaskExecution, error) {
|
||||
// StartTask manually starts agent execution for a task.
|
||||
// If workflowStepID is provided and workflowStepGetter is set, the prompt will be built
|
||||
// using the step's prompt_prefix + base prompt + prompt_suffix, and plan mode will be
|
||||
// applied if the step has plan_mode enabled.
|
||||
func (s *Service) StartTask(ctx context.Context, taskID string, agentProfileID string, executorID string, priority int, prompt string, workflowStepID string) (*executor.TaskExecution, error) {
|
||||
s.logger.Debug("manually starting task",
|
||||
zap.String("task_id", taskID),
|
||||
zap.String("agent_profile_id", agentProfileID),
|
||||
zap.String("executor_id", executorID),
|
||||
zap.Int("priority", priority),
|
||||
zap.Int("prompt_length", len(prompt)))
|
||||
zap.Int("prompt_length", len(prompt)),
|
||||
zap.String("workflow_step_id", workflowStepID))
|
||||
|
||||
if err := s.taskRepo.UpdateTaskState(ctx, taskID, v1.TaskStateScheduling); err != nil {
|
||||
s.logger.Warn("failed to update task state to SCHEDULING",
|
||||
@@ -76,7 +81,27 @@ func (s *Service) StartTask(ctx context.Context, taskID string, agentProfileID s
|
||||
effectivePrompt = task.Description
|
||||
}
|
||||
|
||||
execution, err := s.executor.ExecuteWithProfile(ctx, task, agentProfileID, executorID, effectivePrompt)
|
||||
// Apply workflow step configuration if provided
|
||||
if workflowStepID != "" && s.workflowStepGetter != nil {
|
||||
step, err := s.workflowStepGetter.GetStep(ctx, workflowStepID)
|
||||
if err != nil {
|
||||
s.logger.Warn("failed to get workflow step for prompt building",
|
||||
zap.String("workflow_step_id", workflowStepID),
|
||||
zap.Error(err))
|
||||
// Continue without step config rather than failing
|
||||
} else {
|
||||
// Build prompt with prefix and suffix
|
||||
effectivePrompt = s.buildWorkflowPrompt(effectivePrompt, step, task.ID)
|
||||
|
||||
s.logger.Debug("applied workflow step prompt config",
|
||||
zap.String("workflow_step_id", workflowStepID),
|
||||
zap.String("step_name", step.Name),
|
||||
zap.Bool("plan_mode", step.PlanMode),
|
||||
zap.Int("effective_prompt_length", len(effectivePrompt)))
|
||||
}
|
||||
}
|
||||
|
||||
execution, err := s.executor.ExecuteWithProfile(ctx, task, agentProfileID, executorID, effectivePrompt, workflowStepID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -99,6 +124,56 @@ func (s *Service) StartTask(ctx context.Context, taskID string, agentProfileID s
|
||||
return execution, nil
|
||||
}
|
||||
|
||||
// SystemTagStart marks the beginning of system-injected content that should be hidden from the UI.
|
||||
const SystemTagStart = "<kandev-system>"
|
||||
|
||||
// SystemTagEnd marks the end of system-injected content that should be hidden from the UI.
|
||||
const SystemTagEnd = "</kandev-system>"
|
||||
|
||||
// buildWorkflowPrompt constructs the effective prompt using workflow step configuration.
|
||||
// It combines: step.PromptPrefix + basePrompt + step.PromptSuffix
|
||||
// If step.PlanMode is true, it also prepends the plan mode prefix.
|
||||
// Placeholders like {task_id} are interpolated with actual values.
|
||||
// System-injected content (prefix, suffix, plan mode) is wrapped in <kandev-system> tags
|
||||
// so it can be stripped when displaying to users.
|
||||
func (s *Service) buildWorkflowPrompt(basePrompt string, step *WorkflowStep, taskID string) string {
|
||||
var parts []string
|
||||
|
||||
// Apply plan mode prefix if enabled (wrapped in system tags)
|
||||
if step.PlanMode {
|
||||
parts = append(parts, wrapSystemContent(PlanModePrefix))
|
||||
}
|
||||
|
||||
// Add prompt prefix if set (with interpolation, wrapped in system tags)
|
||||
if step.PromptPrefix != "" {
|
||||
parts = append(parts, wrapSystemContent(interpolatePrompt(step.PromptPrefix, taskID)))
|
||||
}
|
||||
|
||||
// Add base prompt (user's actual message - not wrapped)
|
||||
parts = append(parts, basePrompt)
|
||||
|
||||
// Add prompt suffix if set (with interpolation, wrapped in system tags)
|
||||
if step.PromptSuffix != "" {
|
||||
parts = append(parts, wrapSystemContent(interpolatePrompt(step.PromptSuffix, taskID)))
|
||||
}
|
||||
|
||||
return strings.Join(parts, "\n\n")
|
||||
}
|
||||
|
||||
// wrapSystemContent wraps content in <kandev-system> tags to mark it as system-injected.
|
||||
func wrapSystemContent(content string) string {
|
||||
return SystemTagStart + content + SystemTagEnd
|
||||
}
|
||||
|
||||
// interpolatePrompt replaces placeholders in prompt templates with actual values.
|
||||
// Supported placeholders:
|
||||
// - {task_id} - the task ID
|
||||
func interpolatePrompt(template string, taskID string) string {
|
||||
result := template
|
||||
result = strings.ReplaceAll(result, "{task_id}", taskID)
|
||||
return result
|
||||
}
|
||||
|
||||
// ResumeTaskSession restarts a specific task session using its stored worktree.
|
||||
func (s *Service) ResumeTaskSession(ctx context.Context, taskID, sessionID string) (*executor.TaskExecution, error) {
|
||||
s.logger.Debug("resuming task session",
|
||||
@@ -135,6 +210,119 @@ func (s *Service) ResumeTaskSession(ctx context.Context, taskID, sessionID strin
|
||||
return execution, nil
|
||||
}
|
||||
|
||||
// StartSessionForWorkflowStep starts an existing session with a workflow step's prompt configuration.
|
||||
// If the session is not running, it will be resumed first. Then a prompt is sent using the
|
||||
// step's prompt_prefix, prompt_suffix, and plan_mode settings combined with the task description.
|
||||
func (s *Service) StartSessionForWorkflowStep(ctx context.Context, taskID, sessionID, workflowStepID string) error {
|
||||
s.logger.Debug("starting session for workflow step",
|
||||
zap.String("task_id", taskID),
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("workflow_step_id", workflowStepID))
|
||||
|
||||
if workflowStepID == "" {
|
||||
return fmt.Errorf("workflow_step_id is required")
|
||||
}
|
||||
if s.workflowStepGetter == nil {
|
||||
return fmt.Errorf("workflow step getter not configured")
|
||||
}
|
||||
|
||||
// Get the workflow step
|
||||
step, err := s.workflowStepGetter.GetStep(ctx, workflowStepID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get workflow step: %w", err)
|
||||
}
|
||||
|
||||
// Get the session
|
||||
session, err := s.repo.GetTaskSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get session: %w", err)
|
||||
}
|
||||
if session.TaskID != taskID {
|
||||
return fmt.Errorf("session does not belong to task")
|
||||
}
|
||||
|
||||
// Get the task to use its description as the base prompt
|
||||
task, err := s.scheduler.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get task: %w", err)
|
||||
}
|
||||
|
||||
// Check if session is in a review step with pending approval
|
||||
// If so, reject the request - user must use Approve button or send a chat message
|
||||
if session.ReviewStatus != nil && *session.ReviewStatus == "pending" {
|
||||
if session.WorkflowStepID != nil && *session.WorkflowStepID != "" {
|
||||
currentStep, err := s.workflowStepGetter.GetStep(ctx, *session.WorkflowStepID)
|
||||
if err == nil && currentStep.RequireApproval {
|
||||
return fmt.Errorf("session is pending approval - use Approve button to proceed or send a message to request changes")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update session's workflow step to the new step
|
||||
if session.WorkflowStepID == nil || *session.WorkflowStepID != workflowStepID {
|
||||
if err := s.repo.UpdateSessionWorkflowStep(ctx, sessionID, workflowStepID); err != nil {
|
||||
s.logger.Warn("failed to update session workflow step",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("workflow_step_id", workflowStepID),
|
||||
zap.Error(err))
|
||||
}
|
||||
// Clear review status when moving to a new step
|
||||
if session.ReviewStatus != nil && *session.ReviewStatus != "" {
|
||||
if err := s.repo.UpdateSessionReviewStatus(ctx, sessionID, ""); err != nil {
|
||||
s.logger.Warn("failed to clear session review status",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build prompt from workflow step configuration
|
||||
effectivePrompt := s.buildWorkflowPrompt(task.Description, step, taskID)
|
||||
|
||||
// Check if session needs to be resumed first
|
||||
// A session in "running" or "waiting_for_input" state can receive prompts directly
|
||||
// Other states (created, starting, completed, failed, cancelled) need resume
|
||||
sessionState := session.State
|
||||
if sessionState != models.TaskSessionStateRunning && sessionState != models.TaskSessionStateWaitingForInput {
|
||||
// Try to resume the session
|
||||
s.logger.Debug("session not running, attempting resume",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("session_state", string(session.State)))
|
||||
|
||||
running, err := s.repo.GetExecutorRunningBySessionID(ctx, sessionID)
|
||||
if err != nil || running == nil || running.ResumeToken == "" || !running.Resumable {
|
||||
return fmt.Errorf("session is not resumable (state: %s)", session.State)
|
||||
}
|
||||
|
||||
if err := validateSessionWorktrees(session); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = s.executor.ResumeSession(ctx, session, true)
|
||||
if err != nil {
|
||||
_ = s.repo.UpdateTaskSessionState(ctx, sessionID, models.TaskSessionStateFailed, err.Error())
|
||||
return fmt.Errorf("failed to resume session: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Debug("session resumed, now sending prompt")
|
||||
}
|
||||
|
||||
// Send the prompt using PromptTask (with planMode from step)
|
||||
_, err = s.PromptTask(ctx, taskID, sessionID, effectivePrompt, "", step.PlanMode)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to prompt session: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Info("session started for workflow step",
|
||||
zap.String("task_id", taskID),
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("workflow_step_id", workflowStepID),
|
||||
zap.String("step_name", step.Name),
|
||||
zap.Bool("plan_mode", step.PlanMode))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetTaskSessionStatus returns the status of a task session including whether it's resumable
|
||||
func (s *Service) GetTaskSessionStatus(ctx context.Context, taskID, sessionID string) (dto.TaskSessionStatusResponse, error) {
|
||||
s.logger.Debug("checking task session status",
|
||||
@@ -261,6 +449,10 @@ func (s *Service) PromptTask(ctx context.Context, taskID, sessionID string, prom
|
||||
effectivePrompt = PlanModePrefix + prompt
|
||||
}
|
||||
|
||||
// Check if session is in a review step - if so, move back to the previous step
|
||||
// This handles the case where the user sends a message to iterate on the work
|
||||
s.handleReviewStepRollback(ctx, taskID, sessionID)
|
||||
|
||||
// Check if model switching is requested
|
||||
if model != "" {
|
||||
// Get the current session to check current model
|
||||
|
||||
@@ -5,17 +5,68 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/kandev/kandev/internal/task/dto"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
"github.com/kandev/kandev/internal/task/service"
|
||||
workflowmodels "github.com/kandev/kandev/internal/workflow/models"
|
||||
)
|
||||
|
||||
// WorkflowStepLister provides access to workflow steps for boards with workflow templates.
|
||||
// This allows the board controller to return workflow steps in snapshots.
|
||||
type WorkflowStepLister interface {
|
||||
ListStepsByBoard(ctx context.Context, boardID string) ([]*workflowmodels.WorkflowStep, error)
|
||||
}
|
||||
|
||||
type BoardController struct {
|
||||
service *service.Service
|
||||
service *service.Service
|
||||
workflowStepLister WorkflowStepLister
|
||||
}
|
||||
|
||||
func NewBoardController(svc *service.Service) *BoardController {
|
||||
return &BoardController{service: svc}
|
||||
}
|
||||
|
||||
// SetWorkflowStepLister sets the workflow step lister for returning workflow steps.
|
||||
func (c *BoardController) SetWorkflowStepLister(lister WorkflowStepLister) {
|
||||
c.workflowStepLister = lister
|
||||
}
|
||||
|
||||
// toWorkflowStepDTO converts a workflow step to a WorkflowStepDTO.
|
||||
func toWorkflowStepDTO(step *workflowmodels.WorkflowStep) dto.WorkflowStepDTO {
|
||||
return dto.WorkflowStepDTO{
|
||||
ID: step.ID,
|
||||
BoardID: step.BoardID,
|
||||
Name: step.Name,
|
||||
StepType: string(step.StepType),
|
||||
Position: step.Position,
|
||||
State: step.TaskState,
|
||||
Color: step.Color,
|
||||
AutoStartAgent: step.AutoStartAgent,
|
||||
PlanMode: step.PlanMode,
|
||||
RequireApproval: step.RequireApproval,
|
||||
PromptPrefix: step.PromptPrefix,
|
||||
PromptSuffix: step.PromptSuffix,
|
||||
AllowManualMove: step.AllowManualMove,
|
||||
CreatedAt: step.CreatedAt,
|
||||
UpdatedAt: step.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// getStepsForBoard returns workflow steps for a board.
|
||||
func (c *BoardController) getStepsForBoard(ctx context.Context, board *models.Board) ([]dto.WorkflowStepDTO, error) {
|
||||
if c.workflowStepLister == nil {
|
||||
return nil, fmt.Errorf("workflow step lister not configured")
|
||||
}
|
||||
steps, err := c.workflowStepLister.ListStepsByBoard(ctx, board.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]dto.WorkflowStepDTO, 0, len(steps))
|
||||
for _, step := range steps {
|
||||
result = append(result, toWorkflowStepDTO(step))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *BoardController) ListBoards(ctx context.Context, req dto.ListBoardsRequest) (dto.ListBoardsResponse, error) {
|
||||
boards, err := c.service.ListBoards(ctx, req.WorkspaceID)
|
||||
if err != nil {
|
||||
@@ -41,9 +92,10 @@ func (c *BoardController) GetBoard(ctx context.Context, req dto.GetBoardRequest)
|
||||
|
||||
func (c *BoardController) CreateBoard(ctx context.Context, req dto.CreateBoardRequest) (dto.BoardDTO, error) {
|
||||
board, err := c.service.CreateBoard(ctx, &service.CreateBoardRequest{
|
||||
WorkspaceID: req.WorkspaceID,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
WorkspaceID: req.WorkspaceID,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
WorkflowTemplateID: req.WorkflowTemplateID,
|
||||
})
|
||||
if err != nil {
|
||||
return dto.BoardDTO{}, err
|
||||
@@ -69,66 +121,12 @@ func (c *BoardController) DeleteBoard(ctx context.Context, req dto.DeleteBoardRe
|
||||
return dto.SuccessResponse{Success: true}, nil
|
||||
}
|
||||
|
||||
func (c *BoardController) ListColumns(ctx context.Context, req dto.ListColumnsRequest) (dto.ListColumnsResponse, error) {
|
||||
columns, err := c.service.ListColumns(ctx, req.BoardID)
|
||||
if err != nil {
|
||||
return dto.ListColumnsResponse{}, err
|
||||
}
|
||||
resp := dto.ListColumnsResponse{
|
||||
Columns: make([]dto.ColumnDTO, 0, len(columns)),
|
||||
Total: len(columns),
|
||||
}
|
||||
for _, column := range columns {
|
||||
resp.Columns = append(resp.Columns, dto.FromColumn(column))
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *BoardController) GetColumn(ctx context.Context, req dto.GetColumnRequest) (dto.ColumnDTO, error) {
|
||||
column, err := c.service.GetColumn(ctx, req.ID)
|
||||
if err != nil {
|
||||
return dto.ColumnDTO{}, err
|
||||
}
|
||||
return dto.FromColumn(column), nil
|
||||
}
|
||||
|
||||
func (c *BoardController) CreateColumn(ctx context.Context, req dto.CreateColumnRequest) (dto.ColumnDTO, error) {
|
||||
column, err := c.service.CreateColumn(ctx, &service.CreateColumnRequest{
|
||||
BoardID: req.BoardID,
|
||||
Name: req.Name,
|
||||
Position: req.Position,
|
||||
State: req.State,
|
||||
Color: req.Color,
|
||||
})
|
||||
if err != nil {
|
||||
return dto.ColumnDTO{}, err
|
||||
}
|
||||
return dto.FromColumn(column), nil
|
||||
}
|
||||
|
||||
func (c *BoardController) UpdateColumn(ctx context.Context, req dto.UpdateColumnRequest) (dto.ColumnDTO, error) {
|
||||
column, err := c.service.UpdateColumn(ctx, req.ID, &service.UpdateColumnRequest{
|
||||
Name: req.Name,
|
||||
Position: req.Position,
|
||||
State: req.State,
|
||||
Color: req.Color,
|
||||
})
|
||||
if err != nil {
|
||||
return dto.ColumnDTO{}, err
|
||||
}
|
||||
return dto.FromColumn(column), nil
|
||||
}
|
||||
|
||||
func (c *BoardController) DeleteColumn(ctx context.Context, req dto.GetColumnRequest) error {
|
||||
return c.service.DeleteColumn(ctx, req.ID)
|
||||
}
|
||||
|
||||
func (c *BoardController) GetSnapshot(ctx context.Context, req dto.GetBoardSnapshotRequest) (dto.BoardSnapshotDTO, error) {
|
||||
board, err := c.service.GetBoard(ctx, req.BoardID)
|
||||
if err != nil {
|
||||
return dto.BoardSnapshotDTO{}, err
|
||||
}
|
||||
columns, err := c.service.ListColumns(ctx, req.BoardID)
|
||||
steps, err := c.getStepsForBoard(ctx, board)
|
||||
if err != nil {
|
||||
return dto.BoardSnapshotDTO{}, err
|
||||
}
|
||||
@@ -137,18 +135,17 @@ func (c *BoardController) GetSnapshot(ctx context.Context, req dto.GetBoardSnaps
|
||||
return dto.BoardSnapshotDTO{}, err
|
||||
}
|
||||
|
||||
resp := dto.BoardSnapshotDTO{
|
||||
Board: dto.FromBoard(board),
|
||||
Columns: make([]dto.ColumnDTO, 0, len(columns)),
|
||||
Tasks: make([]dto.TaskDTO, 0, len(tasks)),
|
||||
// Fetch primary session IDs for all tasks in one query
|
||||
taskDTOs, err := c.convertTasksWithPrimarySessions(ctx, tasks)
|
||||
if err != nil {
|
||||
return dto.BoardSnapshotDTO{}, err
|
||||
}
|
||||
for _, column := range columns {
|
||||
resp.Columns = append(resp.Columns, dto.FromColumn(column))
|
||||
}
|
||||
for _, task := range tasks {
|
||||
resp.Tasks = append(resp.Tasks, dto.FromTask(task))
|
||||
}
|
||||
return resp, nil
|
||||
|
||||
return dto.BoardSnapshotDTO{
|
||||
Board: dto.FromBoard(board),
|
||||
Steps: steps,
|
||||
Tasks: taskDTOs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *BoardController) GetWorkspaceSnapshot(ctx context.Context, req dto.GetWorkspaceBoardSnapshotRequest) (dto.BoardSnapshotDTO, error) {
|
||||
@@ -172,7 +169,7 @@ func (c *BoardController) GetWorkspaceSnapshot(ctx context.Context, req dto.GetW
|
||||
return dto.BoardSnapshotDTO{}, fmt.Errorf("board does not belong to workspace: %s", req.WorkspaceID)
|
||||
}
|
||||
|
||||
columns, err := c.service.ListColumns(ctx, boardID)
|
||||
steps, err := c.getStepsForBoard(ctx, board)
|
||||
if err != nil {
|
||||
return dto.BoardSnapshotDTO{}, err
|
||||
}
|
||||
@@ -181,16 +178,45 @@ func (c *BoardController) GetWorkspaceSnapshot(ctx context.Context, req dto.GetW
|
||||
return dto.BoardSnapshotDTO{}, err
|
||||
}
|
||||
|
||||
resp := dto.BoardSnapshotDTO{
|
||||
Board: dto.FromBoard(board),
|
||||
Columns: make([]dto.ColumnDTO, 0, len(columns)),
|
||||
Tasks: make([]dto.TaskDTO, 0, len(tasks)),
|
||||
// Fetch primary session IDs for all tasks in one query
|
||||
taskDTOs, err := c.convertTasksWithPrimarySessions(ctx, tasks)
|
||||
if err != nil {
|
||||
return dto.BoardSnapshotDTO{}, err
|
||||
}
|
||||
for _, column := range columns {
|
||||
resp.Columns = append(resp.Columns, dto.FromColumn(column))
|
||||
}
|
||||
for _, task := range tasks {
|
||||
resp.Tasks = append(resp.Tasks, dto.FromTask(task))
|
||||
}
|
||||
return resp, nil
|
||||
|
||||
return dto.BoardSnapshotDTO{
|
||||
Board: dto.FromBoard(board),
|
||||
Steps: steps,
|
||||
Tasks: taskDTOs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// convertTasksWithPrimarySessions converts task models to DTOs with primary session IDs.
|
||||
func (c *BoardController) convertTasksWithPrimarySessions(ctx context.Context, tasks []*models.Task) ([]dto.TaskDTO, error) {
|
||||
if len(tasks) == 0 {
|
||||
return []dto.TaskDTO{}, nil
|
||||
}
|
||||
|
||||
// Collect all task IDs
|
||||
taskIDs := make([]string, len(tasks))
|
||||
for i, task := range tasks {
|
||||
taskIDs[i] = task.ID
|
||||
}
|
||||
|
||||
// Fetch primary session IDs in bulk
|
||||
primarySessionMap, err := c.service.GetPrimarySessionIDsForTasks(ctx, taskIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Convert tasks to DTOs with primary session IDs
|
||||
result := make([]dto.TaskDTO, 0, len(tasks))
|
||||
for _, task := range tasks {
|
||||
var primarySessionID *string
|
||||
if sid, ok := primarySessionMap[task.ID]; ok {
|
||||
primarySessionID = &sid
|
||||
}
|
||||
result = append(result, dto.FromTaskWithPrimarySession(task, primarySessionID))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@@ -79,16 +79,16 @@ func (c *TaskController) CreateTask(ctx context.Context, req dto.CreateTaskReque
|
||||
}
|
||||
|
||||
task, err := c.service.CreateTask(ctx, &service.CreateTaskRequest{
|
||||
WorkspaceID: req.WorkspaceID,
|
||||
BoardID: req.BoardID,
|
||||
ColumnID: req.ColumnID,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
Priority: req.Priority,
|
||||
State: req.State,
|
||||
Repositories: repos,
|
||||
Position: req.Position,
|
||||
Metadata: req.Metadata,
|
||||
WorkspaceID: req.WorkspaceID,
|
||||
BoardID: req.BoardID,
|
||||
WorkflowStepID: req.WorkflowStepID,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
Priority: req.Priority,
|
||||
State: req.State,
|
||||
Repositories: repos,
|
||||
Position: req.Position,
|
||||
Metadata: req.Metadata,
|
||||
})
|
||||
if err != nil {
|
||||
return dto.TaskDTO{}, err
|
||||
@@ -131,12 +131,35 @@ func (c *TaskController) DeleteTask(ctx context.Context, req dto.DeleteTaskReque
|
||||
return dto.SuccessResponse{Success: true}, nil
|
||||
}
|
||||
|
||||
func (c *TaskController) MoveTask(ctx context.Context, req dto.MoveTaskRequest) (dto.TaskDTO, error) {
|
||||
task, err := c.service.MoveTask(ctx, req.ID, req.BoardID, req.ColumnID, req.Position)
|
||||
func (c *TaskController) MoveTask(ctx context.Context, req dto.MoveTaskRequest) (dto.MoveTaskResponse, error) {
|
||||
result, err := c.service.MoveTask(ctx, req.ID, req.BoardID, req.WorkflowStepID, req.Position)
|
||||
if err != nil {
|
||||
return dto.TaskDTO{}, err
|
||||
return dto.MoveTaskResponse{}, err
|
||||
}
|
||||
return dto.FromTask(task), nil
|
||||
|
||||
response := dto.MoveTaskResponse{
|
||||
Task: dto.FromTask(result.Task),
|
||||
}
|
||||
|
||||
// Include workflow step info if available
|
||||
if result.WorkflowStep != nil {
|
||||
response.WorkflowStep = dto.WorkflowStepDTO{
|
||||
ID: result.WorkflowStep.ID,
|
||||
BoardID: result.WorkflowStep.BoardID,
|
||||
Name: result.WorkflowStep.Name,
|
||||
StepType: result.WorkflowStep.StepType,
|
||||
Position: result.WorkflowStep.Position,
|
||||
Color: result.WorkflowStep.Color,
|
||||
AutoStartAgent: result.WorkflowStep.AutoStartAgent,
|
||||
PlanMode: result.WorkflowStep.PlanMode,
|
||||
RequireApproval: result.WorkflowStep.RequireApproval,
|
||||
PromptPrefix: result.WorkflowStep.PromptPrefix,
|
||||
PromptSuffix: result.WorkflowStep.PromptSuffix,
|
||||
AllowManualMove: result.WorkflowStep.AllowManualMove,
|
||||
}
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (c *TaskController) UpdateTaskState(ctx context.Context, req dto.UpdateTaskStateRequest) (dto.TaskDTO, error) {
|
||||
@@ -161,3 +184,36 @@ func (c *TaskController) GetSessionCommits(ctx context.Context, sessionID string
|
||||
func (c *TaskController) GetCumulativeDiff(ctx context.Context, sessionID string) (*models.CumulativeDiff, error) {
|
||||
return c.service.GetCumulativeDiff(ctx, sessionID)
|
||||
}
|
||||
|
||||
// ApproveSession approves a session's current step and moves it to the on_approval_step_id
|
||||
func (c *TaskController) ApproveSession(ctx context.Context, req dto.ApproveSessionRequest) (dto.ApproveSessionResponse, error) {
|
||||
result, err := c.service.ApproveSession(ctx, req.SessionID)
|
||||
if err != nil {
|
||||
return dto.ApproveSessionResponse{}, err
|
||||
}
|
||||
|
||||
resp := dto.ApproveSessionResponse{
|
||||
Success: true,
|
||||
Session: dto.FromTaskSession(result.Session),
|
||||
}
|
||||
|
||||
// Include the new workflow step if present
|
||||
if result.WorkflowStep != nil {
|
||||
resp.WorkflowStep = dto.WorkflowStepDTO{
|
||||
ID: result.WorkflowStep.ID,
|
||||
BoardID: result.WorkflowStep.BoardID,
|
||||
Name: result.WorkflowStep.Name,
|
||||
StepType: result.WorkflowStep.StepType,
|
||||
Position: result.WorkflowStep.Position,
|
||||
Color: result.WorkflowStep.Color,
|
||||
AutoStartAgent: result.WorkflowStep.AutoStartAgent,
|
||||
PlanMode: result.WorkflowStep.PlanMode,
|
||||
RequireApproval: result.WorkflowStep.RequireApproval,
|
||||
PromptPrefix: result.WorkflowStep.PromptPrefix,
|
||||
PromptSuffix: result.WorkflowStep.PromptSuffix,
|
||||
AllowManualMove: result.WorkflowStep.AllowManualMove,
|
||||
}
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
@@ -29,17 +29,6 @@ type WorkspaceDTO struct {
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ColumnDTO struct {
|
||||
ID string `json:"id"`
|
||||
BoardID string `json:"board_id"`
|
||||
Name string `json:"name"`
|
||||
Position int `json:"position"`
|
||||
State v1.TaskState `json:"state"`
|
||||
Color string `json:"color"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type RepositoryDTO struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
@@ -96,19 +85,20 @@ type EnvironmentDTO struct {
|
||||
}
|
||||
|
||||
type TaskDTO struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
BoardID string `json:"board_id"`
|
||||
ColumnID string `json:"column_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
State v1.TaskState `json:"state"`
|
||||
Priority int `json:"priority"`
|
||||
Repositories []TaskRepositoryDTO `json:"repositories,omitempty"`
|
||||
Position int `json:"position"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
BoardID string `json:"board_id"`
|
||||
WorkflowStepID string `json:"workflow_step_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
State v1.TaskState `json:"state"`
|
||||
Priority int `json:"priority"`
|
||||
Repositories []TaskRepositoryDTO `json:"repositories,omitempty"`
|
||||
Position int `json:"position"`
|
||||
PrimarySessionID *string `json:"primary_session_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type TaskRepositoryDTO struct {
|
||||
@@ -145,6 +135,10 @@ type TaskSessionDTO struct {
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
// Workflow fields
|
||||
IsPrimary bool `json:"is_primary"`
|
||||
WorkflowStepID *string `json:"workflow_step_id,omitempty"`
|
||||
ReviewStatus *string `json:"review_status,omitempty"`
|
||||
}
|
||||
|
||||
type GetTaskSessionResponse struct {
|
||||
@@ -157,9 +151,9 @@ type ListTaskSessionsResponse struct {
|
||||
}
|
||||
|
||||
type BoardSnapshotDTO struct {
|
||||
Board BoardDTO `json:"board"`
|
||||
Columns []ColumnDTO `json:"columns"`
|
||||
Tasks []TaskDTO `json:"tasks"`
|
||||
Board BoardDTO `json:"board"`
|
||||
Steps []WorkflowStepDTO `json:"steps"`
|
||||
Tasks []TaskDTO `json:"tasks"`
|
||||
}
|
||||
|
||||
type ListMessagesResponse struct {
|
||||
@@ -179,11 +173,6 @@ type ListWorkspacesResponse struct {
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
type ListColumnsResponse struct {
|
||||
Columns []ColumnDTO `json:"columns"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
type ListRepositoriesResponse struct {
|
||||
Repositories []RepositoryDTO `json:"repositories"`
|
||||
Total int `json:"total"`
|
||||
@@ -280,19 +269,6 @@ func FromWorkspace(workspace *models.Workspace) WorkspaceDTO {
|
||||
}
|
||||
}
|
||||
|
||||
func FromColumn(column *models.Column) ColumnDTO {
|
||||
return ColumnDTO{
|
||||
ID: column.ID,
|
||||
BoardID: column.BoardID,
|
||||
Name: column.Name,
|
||||
Position: column.Position,
|
||||
State: column.State,
|
||||
Color: column.Color,
|
||||
CreatedAt: column.CreatedAt,
|
||||
UpdatedAt: column.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func FromRepository(repository *models.Repository) RepositoryDTO {
|
||||
return RepositoryDTO{
|
||||
ID: repository.ID,
|
||||
@@ -365,6 +341,11 @@ func FromLocalRepository(repo service.LocalRepository) LocalRepositoryDTO {
|
||||
}
|
||||
|
||||
func FromTask(task *models.Task) TaskDTO {
|
||||
return FromTaskWithPrimarySession(task, nil)
|
||||
}
|
||||
|
||||
// FromTaskWithPrimarySession converts a task model to a TaskDTO, including the primary session ID.
|
||||
func FromTaskWithPrimarySession(task *models.Task, primarySessionID *string) TaskDTO {
|
||||
// Convert repositories
|
||||
var repositories []TaskRepositoryDTO
|
||||
for _, repo := range task.Repositories {
|
||||
@@ -381,19 +362,20 @@ func FromTask(task *models.Task) TaskDTO {
|
||||
}
|
||||
|
||||
return TaskDTO{
|
||||
ID: task.ID,
|
||||
WorkspaceID: task.WorkspaceID,
|
||||
BoardID: task.BoardID,
|
||||
ColumnID: task.ColumnID,
|
||||
Title: task.Title,
|
||||
Description: task.Description,
|
||||
State: task.State,
|
||||
Priority: task.Priority,
|
||||
Repositories: repositories,
|
||||
Position: task.Position,
|
||||
CreatedAt: task.CreatedAt,
|
||||
UpdatedAt: task.UpdatedAt,
|
||||
Metadata: task.Metadata,
|
||||
ID: task.ID,
|
||||
WorkspaceID: task.WorkspaceID,
|
||||
BoardID: task.BoardID,
|
||||
WorkflowStepID: task.WorkflowStepID,
|
||||
Title: task.Title,
|
||||
Description: task.Description,
|
||||
State: task.State,
|
||||
Priority: task.Priority,
|
||||
Repositories: repositories,
|
||||
Position: task.Position,
|
||||
PrimarySessionID: primarySessionID,
|
||||
CreatedAt: task.CreatedAt,
|
||||
UpdatedAt: task.UpdatedAt,
|
||||
Metadata: task.Metadata,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,6 +400,10 @@ func FromTaskSession(session *models.TaskSession) TaskSessionDTO {
|
||||
StartedAt: session.StartedAt,
|
||||
CompletedAt: session.CompletedAt,
|
||||
UpdatedAt: session.UpdatedAt,
|
||||
// Workflow fields
|
||||
IsPrimary: session.IsPrimary,
|
||||
WorkflowStepID: session.WorkflowStepID,
|
||||
ReviewStatus: session.ReviewStatus,
|
||||
}
|
||||
if len(session.Worktrees) > 0 {
|
||||
result.WorktreeID = session.Worktrees[0].WorktreeID
|
||||
@@ -426,3 +412,42 @@ func FromTaskSession(session *models.TaskSession) TaskSessionDTO {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// WorkflowStepDTO represents a workflow step for API responses
|
||||
type WorkflowStepDTO struct {
|
||||
ID string `json:"id"`
|
||||
BoardID string `json:"board_id"`
|
||||
Name string `json:"name"`
|
||||
StepType string `json:"step_type"`
|
||||
Position int `json:"position"`
|
||||
State v1.TaskState `json:"state"`
|
||||
Color string `json:"color"`
|
||||
AutoStartAgent bool `json:"auto_start_agent"`
|
||||
PlanMode bool `json:"plan_mode"`
|
||||
RequireApproval bool `json:"require_approval"`
|
||||
PromptPrefix string `json:"prompt_prefix,omitempty"`
|
||||
PromptSuffix string `json:"prompt_suffix,omitempty"`
|
||||
AllowManualMove bool `json:"allow_manual_move"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// MoveTaskResponse includes the task and the target workflow step info
|
||||
type MoveTaskResponse struct {
|
||||
Task TaskDTO `json:"task"`
|
||||
WorkflowStep WorkflowStepDTO `json:"workflow_step"`
|
||||
}
|
||||
|
||||
// Session Workflow Review DTOs
|
||||
|
||||
// ApproveSessionRequest is the request to approve a session's current step
|
||||
type ApproveSessionRequest struct {
|
||||
SessionID string `json:"-"` // From URL path
|
||||
}
|
||||
|
||||
// ApproveSessionResponse is the response after approving a session
|
||||
type ApproveSessionResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Session TaskSessionDTO `json:"session"`
|
||||
WorkflowStep WorkflowStepDTO `json:"workflow_step,omitempty"` // New step after approval
|
||||
}
|
||||
|
||||
@@ -42,9 +42,10 @@ type GetBoardRequest struct {
|
||||
}
|
||||
|
||||
type CreateBoardRequest struct {
|
||||
WorkspaceID string
|
||||
Name string
|
||||
Description string
|
||||
WorkspaceID string
|
||||
Name string
|
||||
Description string
|
||||
WorkflowTemplateID *string
|
||||
}
|
||||
|
||||
type UpdateBoardRequest struct {
|
||||
@@ -57,30 +58,6 @@ type DeleteBoardRequest struct {
|
||||
ID string
|
||||
}
|
||||
|
||||
type ListColumnsRequest struct {
|
||||
BoardID string
|
||||
}
|
||||
|
||||
type GetColumnRequest struct {
|
||||
ID string
|
||||
}
|
||||
|
||||
type CreateColumnRequest struct {
|
||||
BoardID string
|
||||
Name string
|
||||
Position int
|
||||
State v1.TaskState
|
||||
Color string
|
||||
}
|
||||
|
||||
type UpdateColumnRequest struct {
|
||||
ID string
|
||||
Name *string
|
||||
Position *int
|
||||
State *v1.TaskState
|
||||
Color *string
|
||||
}
|
||||
|
||||
type ListRepositoriesRequest struct {
|
||||
WorkspaceID string
|
||||
}
|
||||
@@ -263,14 +240,14 @@ type TaskRepositoryInput struct {
|
||||
}
|
||||
|
||||
type CreateTaskRequest struct {
|
||||
WorkspaceID string
|
||||
BoardID string
|
||||
ColumnID string
|
||||
Title string
|
||||
Description string
|
||||
Priority int
|
||||
State *v1.TaskState
|
||||
Repositories []TaskRepositoryInput
|
||||
WorkspaceID string
|
||||
BoardID string
|
||||
WorkflowStepID string
|
||||
Title string
|
||||
Description string
|
||||
Priority int
|
||||
State *v1.TaskState
|
||||
Repositories []TaskRepositoryInput
|
||||
Position int
|
||||
Metadata map[string]interface{}
|
||||
}
|
||||
@@ -310,10 +287,10 @@ type CreateMessageRequest struct {
|
||||
}
|
||||
|
||||
type MoveTaskRequest struct {
|
||||
ID string
|
||||
BoardID string
|
||||
ColumnID string
|
||||
Position int
|
||||
ID string
|
||||
BoardID string
|
||||
WorkflowStepID string
|
||||
Position int
|
||||
}
|
||||
|
||||
type UpdateTaskStateRequest struct {
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/task/controller"
|
||||
"github.com/kandev/kandev/internal/task/dto"
|
||||
v1 "github.com/kandev/kandev/pkg/api/v1"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -38,14 +37,11 @@ func (h *BoardHandlers) registerHTTP(router *gin.Engine) {
|
||||
api.GET("/workspaces/:id/boards", h.httpListBoardsByWorkspace)
|
||||
api.GET("/workspaces/:id/board-snapshot", h.httpGetWorkspaceBoardSnapshot)
|
||||
api.GET("/boards/:id", h.httpGetBoard)
|
||||
api.GET("/boards/:id/columns", h.httpListColumns)
|
||||
api.GET("/boards/:id/snapshot", h.httpGetBoardSnapshot)
|
||||
api.POST("/boards", h.httpCreateBoard)
|
||||
api.PATCH("/boards/:id", h.httpUpdateBoard)
|
||||
api.DELETE("/boards/:id", h.httpDeleteBoard)
|
||||
api.POST("/boards/:id/columns", h.httpCreateColumn)
|
||||
api.PUT("/columns/:id", h.httpUpdateColumn)
|
||||
api.DELETE("/columns/:id", h.httpDeleteColumn)
|
||||
// Column endpoints removed - use workflow steps instead
|
||||
}
|
||||
|
||||
func (h *BoardHandlers) registerWS(dispatcher *ws.Dispatcher) {
|
||||
@@ -54,11 +50,7 @@ func (h *BoardHandlers) registerWS(dispatcher *ws.Dispatcher) {
|
||||
dispatcher.RegisterFunc(ws.ActionBoardGet, h.wsGetBoard)
|
||||
dispatcher.RegisterFunc(ws.ActionBoardUpdate, h.wsUpdateBoard)
|
||||
dispatcher.RegisterFunc(ws.ActionBoardDelete, h.wsDeleteBoard)
|
||||
dispatcher.RegisterFunc(ws.ActionColumnList, h.wsListColumns)
|
||||
dispatcher.RegisterFunc(ws.ActionColumnCreate, h.wsCreateColumn)
|
||||
dispatcher.RegisterFunc(ws.ActionColumnGet, h.wsGetColumn)
|
||||
dispatcher.RegisterFunc(ws.ActionColumnUpdate, h.wsUpdateColumn)
|
||||
dispatcher.RegisterFunc(ws.ActionColumnDelete, h.wsDeleteColumn)
|
||||
// Column WS actions removed - use workflow steps instead
|
||||
}
|
||||
|
||||
// HTTP handlers
|
||||
@@ -97,9 +89,10 @@ func (h *BoardHandlers) httpGetBoard(c *gin.Context) {
|
||||
}
|
||||
|
||||
type httpCreateBoardRequest struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
WorkflowTemplateID *string `json:"workflow_template_id,omitempty"`
|
||||
}
|
||||
|
||||
func (h *BoardHandlers) httpCreateBoard(c *gin.Context) {
|
||||
@@ -113,9 +106,10 @@ func (h *BoardHandlers) httpCreateBoard(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
resp, err := h.controller.CreateBoard(c.Request.Context(), dto.CreateBoardRequest{
|
||||
WorkspaceID: body.WorkspaceID,
|
||||
Name: body.Name,
|
||||
Description: body.Description,
|
||||
WorkspaceID: body.WorkspaceID,
|
||||
Name: body.Name,
|
||||
Description: body.Description,
|
||||
WorkflowTemplateID: body.WorkflowTemplateID,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("failed to create board", zap.Error(err))
|
||||
@@ -157,89 +151,6 @@ func (h *BoardHandlers) httpDeleteBoard(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *BoardHandlers) httpListColumns(c *gin.Context) {
|
||||
resp, err := h.controller.ListColumns(c.Request.Context(), dto.ListColumnsRequest{BoardID: c.Param("id")})
|
||||
if err != nil {
|
||||
handleNotFound(c, h.logger, err, "columns not found")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
type httpCreateColumnRequest struct {
|
||||
Name string `json:"name"`
|
||||
Position int `json:"position"`
|
||||
State v1.TaskState `json:"state"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
func (h *BoardHandlers) httpCreateColumn(c *gin.Context) {
|
||||
var body httpCreateColumnRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
h.logger.Error("failed to bind create column request", zap.Error(err), zap.String("board_id", c.Param("id")))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
if body.Name == "" {
|
||||
h.logger.Error("create column missing name", zap.String("board_id", c.Param("id")))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
state := body.State
|
||||
if state == "" {
|
||||
state = v1.TaskStateTODO
|
||||
}
|
||||
resp, err := h.controller.CreateColumn(c.Request.Context(), dto.CreateColumnRequest{
|
||||
BoardID: c.Param("id"),
|
||||
Name: body.Name,
|
||||
Position: body.Position,
|
||||
State: state,
|
||||
Color: body.Color,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("failed to create column", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create column"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
type httpUpdateColumnRequest struct {
|
||||
Name *string `json:"name"`
|
||||
Position *int `json:"position"`
|
||||
State *v1.TaskState `json:"state"`
|
||||
Color *string `json:"color"`
|
||||
}
|
||||
|
||||
func (h *BoardHandlers) httpUpdateColumn(c *gin.Context) {
|
||||
var body httpUpdateColumnRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
resp, err := h.controller.UpdateColumn(c.Request.Context(), dto.UpdateColumnRequest{
|
||||
ID: c.Param("id"),
|
||||
Name: body.Name,
|
||||
Position: body.Position,
|
||||
State: body.State,
|
||||
Color: body.Color,
|
||||
})
|
||||
if err != nil {
|
||||
handleNotFound(c, h.logger, err, "column not found")
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *BoardHandlers) httpDeleteColumn(c *gin.Context) {
|
||||
if err := h.controller.DeleteColumn(c.Request.Context(), dto.GetColumnRequest{ID: c.Param("id")}); err != nil {
|
||||
h.logger.Error("failed to delete column", zap.Error(err), zap.String("column_id", c.Param("id")))
|
||||
handleNotFound(c, h.logger, err, "column not found")
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *BoardHandlers) httpGetBoardSnapshot(c *gin.Context) {
|
||||
resp, err := h.controller.GetSnapshot(c.Request.Context(), dto.GetBoardSnapshotRequest{BoardID: c.Param("id")})
|
||||
if err != nil {
|
||||
@@ -287,9 +198,10 @@ func (h *BoardHandlers) wsListBoards(ctx context.Context, msg *ws.Message) (*ws.
|
||||
}
|
||||
|
||||
type wsCreateBoardRequest struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
WorkflowTemplateID *string `json:"workflow_template_id,omitempty"`
|
||||
}
|
||||
|
||||
func (h *BoardHandlers) wsCreateBoard(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
||||
@@ -305,9 +217,10 @@ func (h *BoardHandlers) wsCreateBoard(ctx context.Context, msg *ws.Message) (*ws
|
||||
}
|
||||
|
||||
resp, err := h.controller.CreateBoard(ctx, dto.CreateBoardRequest{
|
||||
WorkspaceID: req.WorkspaceID,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
WorkspaceID: req.WorkspaceID,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
WorkflowTemplateID: req.WorkflowTemplateID,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("failed to create board", zap.Error(err))
|
||||
@@ -383,132 +296,3 @@ func (h *BoardHandlers) wsDeleteBoard(ctx context.Context, msg *ws.Message) (*ws
|
||||
}
|
||||
return ws.NewResponse(msg.ID, msg.Action, resp)
|
||||
}
|
||||
|
||||
type wsListColumnsRequest struct {
|
||||
BoardID string `json:"board_id"`
|
||||
}
|
||||
|
||||
func (h *BoardHandlers) wsListColumns(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
||||
var req wsListColumnsRequest
|
||||
if err := msg.ParsePayload(&req); err != nil {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
|
||||
}
|
||||
if req.BoardID == "" {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "board_id is required", nil)
|
||||
}
|
||||
|
||||
resp, err := h.controller.ListColumns(ctx, dto.ListColumnsRequest{BoardID: req.BoardID})
|
||||
if err != nil {
|
||||
h.logger.Error("failed to list columns", zap.Error(err))
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list columns", nil)
|
||||
}
|
||||
return ws.NewResponse(msg.ID, msg.Action, resp)
|
||||
}
|
||||
|
||||
type wsCreateColumnRequest struct {
|
||||
BoardID string `json:"board_id"`
|
||||
Name string `json:"name"`
|
||||
Position int `json:"position"`
|
||||
State string `json:"state,omitempty"`
|
||||
Color string `json:"color,omitempty"`
|
||||
}
|
||||
|
||||
func (h *BoardHandlers) wsCreateColumn(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
||||
var req wsCreateColumnRequest
|
||||
if err := msg.ParsePayload(&req); err != nil {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
|
||||
}
|
||||
if req.BoardID == "" {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "board_id is required", nil)
|
||||
}
|
||||
if req.Name == "" {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "name is required", nil)
|
||||
}
|
||||
|
||||
state := v1.TaskState(req.State)
|
||||
if state == "" {
|
||||
state = v1.TaskStateTODO
|
||||
}
|
||||
|
||||
resp, err := h.controller.CreateColumn(ctx, dto.CreateColumnRequest{
|
||||
BoardID: req.BoardID,
|
||||
Name: req.Name,
|
||||
Position: req.Position,
|
||||
State: state,
|
||||
Color: req.Color,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("failed to create column", zap.Error(err))
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to create column", nil)
|
||||
}
|
||||
return ws.NewResponse(msg.ID, msg.Action, resp)
|
||||
}
|
||||
|
||||
type wsGetColumnRequest struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func (h *BoardHandlers) wsGetColumn(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
||||
var req wsGetColumnRequest
|
||||
if err := msg.ParsePayload(&req); err != nil {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
|
||||
}
|
||||
if req.ID == "" {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil)
|
||||
}
|
||||
|
||||
resp, err := h.controller.GetColumn(ctx, dto.GetColumnRequest{ID: req.ID})
|
||||
if err != nil {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Column not found", nil)
|
||||
}
|
||||
return ws.NewResponse(msg.ID, msg.Action, resp)
|
||||
}
|
||||
|
||||
type wsUpdateColumnRequest struct {
|
||||
ID string `json:"id"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
Position *int `json:"position,omitempty"`
|
||||
State *v1.TaskState `json:"state,omitempty"`
|
||||
Color *string `json:"color,omitempty"`
|
||||
}
|
||||
|
||||
func (h *BoardHandlers) wsUpdateColumn(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
||||
var req wsUpdateColumnRequest
|
||||
if err := msg.ParsePayload(&req); err != nil {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
|
||||
}
|
||||
if req.ID == "" {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil)
|
||||
}
|
||||
|
||||
resp, err := h.controller.UpdateColumn(ctx, dto.UpdateColumnRequest{
|
||||
ID: req.ID,
|
||||
Name: req.Name,
|
||||
Position: req.Position,
|
||||
State: req.State,
|
||||
Color: req.Color,
|
||||
})
|
||||
if err != nil {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Column not found", nil)
|
||||
}
|
||||
return ws.NewResponse(msg.ID, msg.Action, resp)
|
||||
}
|
||||
|
||||
type wsDeleteColumnRequest struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func (h *BoardHandlers) wsDeleteColumn(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
||||
var req wsDeleteColumnRequest
|
||||
if err := msg.ParsePayload(&req); err != nil {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
|
||||
}
|
||||
if req.ID == "" {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil)
|
||||
}
|
||||
if err := h.controller.DeleteColumn(ctx, dto.GetColumnRequest{ID: req.ID}); err != nil {
|
||||
h.logger.Error("failed to delete column (ws)", zap.Error(err), zap.String("column_id", req.ID))
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Column not found", nil)
|
||||
}
|
||||
return ws.NewResponse(msg.ID, msg.Action, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ func handleNotFound(c *gin.Context, log *logger.Logger, err error, fallback stri
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": fallback})
|
||||
return
|
||||
}
|
||||
if isValidationError(err) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
log.Error("request failed", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "request failed"})
|
||||
}
|
||||
@@ -24,3 +28,14 @@ func isNotFound(err error) bool {
|
||||
}
|
||||
return strings.Contains(strings.ToLower(err.Error()), "not found")
|
||||
}
|
||||
|
||||
func isValidationError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "pending approval") ||
|
||||
strings.Contains(msg, "validation") ||
|
||||
strings.Contains(msg, "required") ||
|
||||
strings.Contains(msg, "invalid")
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ func (m *mockRepository) DeleteTask(ctx context.Context, id string) error {
|
||||
func (m *mockRepository) ListTasks(ctx context.Context, boardID string) ([]*models.Task, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockRepository) ListTasksByColumn(ctx context.Context, columnID string) ([]*models.Task, error) {
|
||||
func (m *mockRepository) ListTasksByWorkflowStep(ctx context.Context, workflowStepID string) ([]*models.Task, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockRepository) UpdateTaskState(ctx context.Context, id string, state v1.TaskState) error {
|
||||
@@ -108,24 +108,6 @@ func (m *mockRepository) DeleteBoard(ctx context.Context, id string) error {
|
||||
func (m *mockRepository) ListBoards(ctx context.Context, workspaceID string) ([]*models.Board, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockRepository) CreateColumn(ctx context.Context, column *models.Column) error {
|
||||
return nil
|
||||
}
|
||||
func (m *mockRepository) GetColumn(ctx context.Context, id string) (*models.Column, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockRepository) GetColumnByState(ctx context.Context, boardID string, state v1.TaskState) (*models.Column, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockRepository) UpdateColumn(ctx context.Context, column *models.Column) error {
|
||||
return nil
|
||||
}
|
||||
func (m *mockRepository) DeleteColumn(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
func (m *mockRepository) ListColumns(ctx context.Context, boardID string) ([]*models.Column, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockRepository) CreateMessage(ctx context.Context, message *models.Message) error {
|
||||
return nil
|
||||
}
|
||||
@@ -338,6 +320,21 @@ func (m *mockRepository) GetLatestSessionCommit(ctx context.Context, sessionID s
|
||||
func (m *mockRepository) DeleteSessionCommit(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
func (m *mockRepository) GetPrimarySessionByTaskID(ctx context.Context, taskID string) (*models.TaskSession, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *mockRepository) GetPrimarySessionIDsByTaskIDs(ctx context.Context, taskIDs []string) (map[string]string, error) {
|
||||
return make(map[string]string), nil
|
||||
}
|
||||
func (m *mockRepository) SetSessionPrimary(ctx context.Context, sessionID string) error {
|
||||
return nil
|
||||
}
|
||||
func (m *mockRepository) UpdateSessionWorkflowStep(ctx context.Context, sessionID string, stepID string) error {
|
||||
return nil
|
||||
}
|
||||
func (m *mockRepository) UpdateSessionReviewStatus(ctx context.Context, sessionID string, status string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func newTestService(t *testing.T, scripts map[string][]*models.RepositoryScript) *service.Service {
|
||||
log, err := logger.NewLogger(logger.LoggingConfig{
|
||||
|
||||
@@ -23,7 +23,9 @@ type TaskHandlers struct {
|
||||
}
|
||||
|
||||
type OrchestratorStarter interface {
|
||||
StartTask(ctx context.Context, taskID string, agentProfileID string, executorID string, priority int, prompt string) (*executor.TaskExecution, error)
|
||||
// StartTask starts agent execution for a task.
|
||||
// If workflowStepID is provided, prompt prefix/suffix and plan mode from the step are applied.
|
||||
StartTask(ctx context.Context, taskID string, agentProfileID string, executorID string, priority int, prompt string, workflowStepID string) (*executor.TaskExecution, error)
|
||||
}
|
||||
|
||||
func NewTaskHandlers(ctrl *controller.TaskController, orchestrator OrchestratorStarter, log *logger.Logger) *TaskHandlers {
|
||||
@@ -50,6 +52,9 @@ func (h *TaskHandlers) registerHTTP(router *gin.Engine) {
|
||||
api.PATCH("/tasks/:id", h.httpUpdateTask)
|
||||
api.POST("/tasks/:id/move", h.httpMoveTask)
|
||||
api.DELETE("/tasks/:id", h.httpDeleteTask)
|
||||
|
||||
// Session workflow review endpoints
|
||||
api.POST("/sessions/:id/approve", h.httpApproveSession)
|
||||
}
|
||||
|
||||
func (h *TaskHandlers) registerWS(dispatcher *ws.Dispatcher) {
|
||||
@@ -108,6 +113,19 @@ func (h *TaskHandlers) httpGetTaskSession(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *TaskHandlers) httpApproveSession(c *gin.Context) {
|
||||
resp, err := h.controller.ApproveSession(
|
||||
c.Request.Context(),
|
||||
dto.ApproveSessionRequest{SessionID: c.Param("id")},
|
||||
)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to approve session", zap.String("session_id", c.Param("id")), zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
type httpTaskRepositoryInput struct {
|
||||
RepositoryID string `json:"repository_id"`
|
||||
BaseBranch string `json:"base_branch"`
|
||||
@@ -119,7 +137,7 @@ type httpTaskRepositoryInput struct {
|
||||
type httpCreateTaskRequest struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
BoardID string `json:"board_id"`
|
||||
ColumnID string `json:"column_id"`
|
||||
WorkflowStepID string `json:"workflow_step_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Priority int `json:"priority,omitempty"`
|
||||
@@ -171,16 +189,16 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) {
|
||||
|
||||
reqCtx := c.Request.Context()
|
||||
resp, err := h.controller.CreateTask(reqCtx, dto.CreateTaskRequest{
|
||||
WorkspaceID: body.WorkspaceID,
|
||||
BoardID: body.BoardID,
|
||||
ColumnID: body.ColumnID,
|
||||
Title: body.Title,
|
||||
Description: body.Description,
|
||||
Priority: body.Priority,
|
||||
State: body.State,
|
||||
Repositories: repos,
|
||||
Position: body.Position,
|
||||
Metadata: body.Metadata,
|
||||
WorkspaceID: body.WorkspaceID,
|
||||
BoardID: body.BoardID,
|
||||
WorkflowStepID: body.WorkflowStepID,
|
||||
Title: body.Title,
|
||||
Description: body.Description,
|
||||
Priority: body.Priority,
|
||||
State: body.State,
|
||||
Repositories: repos,
|
||||
Position: body.Position,
|
||||
Metadata: body.Metadata,
|
||||
})
|
||||
if err != nil {
|
||||
handleNotFound(c, h.logger, err, "task not created")
|
||||
@@ -192,13 +210,14 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) {
|
||||
// Launch agent asynchronously so the HTTP request can return immediately.
|
||||
// The frontend will receive WebSocket updates when the agent actually starts.
|
||||
// This allows the "new task" dialog to close right away while setup scripts run.
|
||||
executorID := body.ExecutorID // Capture for goroutine
|
||||
executorID := body.ExecutorID // Capture for goroutine
|
||||
workflowStepID := body.WorkflowStepID // Capture for goroutine
|
||||
go func() {
|
||||
// Use a longer timeout to accommodate setup scripts (which can take minutes for npm install, etc.)
|
||||
startCtx, cancel := context.WithTimeout(context.Background(), constants.AgentLaunchTimeout)
|
||||
defer cancel()
|
||||
// Use task description as the initial prompt
|
||||
execution, err := h.orchestrator.StartTask(startCtx, resp.ID, body.AgentProfileID, executorID, body.Priority, resp.Description)
|
||||
// Use task description as the initial prompt with workflow step config (prompt prefix/suffix, plan mode)
|
||||
execution, err := h.orchestrator.StartTask(startCtx, resp.ID, body.AgentProfileID, executorID, body.Priority, resp.Description, workflowStepID)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to start agent for task (async)", zap.Error(err), zap.String("task_id", resp.ID))
|
||||
return
|
||||
@@ -207,6 +226,7 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) {
|
||||
zap.String("task_id", resp.ID),
|
||||
zap.String("session_id", execution.SessionID),
|
||||
zap.String("executor_id", executorID),
|
||||
zap.String("workflow_step_id", workflowStepID),
|
||||
zap.String("execution_id", execution.AgentExecutionID))
|
||||
}()
|
||||
}
|
||||
@@ -263,9 +283,9 @@ func (h *TaskHandlers) httpUpdateTask(c *gin.Context) {
|
||||
}
|
||||
|
||||
type httpMoveTaskRequest struct {
|
||||
BoardID string `json:"board_id"`
|
||||
ColumnID string `json:"column_id"`
|
||||
Position int `json:"position"`
|
||||
BoardID string `json:"board_id"`
|
||||
WorkflowStepID string `json:"workflow_step_id"`
|
||||
Position int `json:"position"`
|
||||
}
|
||||
|
||||
func (h *TaskHandlers) httpMoveTask(c *gin.Context) {
|
||||
@@ -274,15 +294,15 @@ func (h *TaskHandlers) httpMoveTask(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
||||
return
|
||||
}
|
||||
if body.BoardID == "" || body.ColumnID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "board_id and column_id are required"})
|
||||
if body.BoardID == "" || body.WorkflowStepID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "board_id and workflow_step_id are required"})
|
||||
return
|
||||
}
|
||||
resp, err := h.controller.MoveTask(c.Request.Context(), dto.MoveTaskRequest{
|
||||
ID: c.Param("id"),
|
||||
BoardID: body.BoardID,
|
||||
ColumnID: body.ColumnID,
|
||||
Position: body.Position,
|
||||
ID: c.Param("id"),
|
||||
BoardID: body.BoardID,
|
||||
WorkflowStepID: body.WorkflowStepID,
|
||||
Position: body.Position,
|
||||
})
|
||||
if err != nil {
|
||||
handleNotFound(c, h.logger, err, "task not moved")
|
||||
@@ -349,7 +369,7 @@ func (h *TaskHandlers) wsListTasks(ctx context.Context, msg *ws.Message) (*ws.Me
|
||||
type wsCreateTaskRequest struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
BoardID string `json:"board_id"`
|
||||
ColumnID string `json:"column_id"`
|
||||
WorkflowStepID string `json:"workflow_step_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Priority int `json:"priority,omitempty"`
|
||||
@@ -373,8 +393,8 @@ func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.M
|
||||
if req.BoardID == "" {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "board_id is required", nil)
|
||||
}
|
||||
if req.ColumnID == "" {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "column_id is required", nil)
|
||||
if req.WorkflowStepID == "" {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workflow_step_id is required", nil)
|
||||
}
|
||||
if req.Title == "" {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "title is required", nil)
|
||||
@@ -399,16 +419,16 @@ func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.M
|
||||
}
|
||||
|
||||
resp, err := h.controller.CreateTask(ctx, dto.CreateTaskRequest{
|
||||
WorkspaceID: req.WorkspaceID,
|
||||
BoardID: req.BoardID,
|
||||
ColumnID: req.ColumnID,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
Priority: req.Priority,
|
||||
State: req.State,
|
||||
Repositories: repos,
|
||||
Position: req.Position,
|
||||
Metadata: req.Metadata,
|
||||
WorkspaceID: req.WorkspaceID,
|
||||
BoardID: req.BoardID,
|
||||
WorkflowStepID: req.WorkflowStepID,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
Priority: req.Priority,
|
||||
State: req.State,
|
||||
Repositories: repos,
|
||||
Position: req.Position,
|
||||
Metadata: req.Metadata,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("failed to create task", zap.Error(err))
|
||||
@@ -417,8 +437,8 @@ func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.M
|
||||
|
||||
response := createTaskResponse{TaskDTO: resp}
|
||||
if req.StartAgent && req.AgentProfileID != "" && h.orchestrator != nil {
|
||||
// Use task description as the initial prompt
|
||||
execution, err := h.orchestrator.StartTask(ctx, resp.ID, req.AgentProfileID, req.ExecutorID, req.Priority, resp.Description)
|
||||
// Use task description as the initial prompt with workflow step config (prompt prefix/suffix, plan mode)
|
||||
execution, err := h.orchestrator.StartTask(ctx, resp.ID, req.AgentProfileID, req.ExecutorID, req.Priority, resp.Description, req.WorkflowStepID)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to start agent for task", zap.Error(err))
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to start agent for task", nil)
|
||||
@@ -426,6 +446,7 @@ func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.M
|
||||
h.logger.Info("wsCreateTask started agent",
|
||||
zap.String("task_id", resp.ID),
|
||||
zap.String("executor_id", req.ExecutorID),
|
||||
zap.String("workflow_step_id", req.WorkflowStepID),
|
||||
zap.String("session_id", execution.SessionID))
|
||||
response.TaskSessionID = execution.SessionID
|
||||
response.AgentExecutionID = execution.AgentExecutionID
|
||||
@@ -526,10 +547,10 @@ func (h *TaskHandlers) wsDeleteTask(ctx context.Context, msg *ws.Message) (*ws.M
|
||||
}
|
||||
|
||||
type wsMoveTaskRequest struct {
|
||||
ID string `json:"id"`
|
||||
BoardID string `json:"board_id"`
|
||||
ColumnID string `json:"column_id"`
|
||||
Position int `json:"position"`
|
||||
ID string `json:"id"`
|
||||
BoardID string `json:"board_id"`
|
||||
WorkflowStepID string `json:"workflow_step_id"`
|
||||
Position int `json:"position"`
|
||||
}
|
||||
|
||||
func (h *TaskHandlers) wsMoveTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
||||
@@ -543,15 +564,15 @@ func (h *TaskHandlers) wsMoveTask(ctx context.Context, msg *ws.Message) (*ws.Mes
|
||||
if req.BoardID == "" {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "board_id is required", nil)
|
||||
}
|
||||
if req.ColumnID == "" {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "column_id is required", nil)
|
||||
if req.WorkflowStepID == "" {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workflow_step_id is required", nil)
|
||||
}
|
||||
|
||||
resp, err := h.controller.MoveTask(ctx, dto.MoveTaskRequest{
|
||||
ID: req.ID,
|
||||
BoardID: req.BoardID,
|
||||
ColumnID: req.ColumnID,
|
||||
Position: req.Position,
|
||||
ID: req.ID,
|
||||
BoardID: req.BoardID,
|
||||
WorkflowStepID: req.WorkflowStepID,
|
||||
Position: req.Position,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("failed to move task", zap.Error(err))
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
v1 "github.com/kandev/kandev/pkg/api/v1"
|
||||
)
|
||||
|
||||
// systemTagRegex matches <kandev-system>...</kandev-system> content including the tags.
|
||||
// This is used to strip system-injected content from messages before displaying to users.
|
||||
var systemTagRegex = regexp.MustCompile(`<kandev-system>[\s\S]*?</kandev-system>\s*`)
|
||||
|
||||
// StripSystemContent removes all <kandev-system>...</kandev-system> blocks from text.
|
||||
// This is used to hide workflow step prompt modifications (prefix/suffix) from the frontend UI.
|
||||
func StripSystemContent(text string) string {
|
||||
return systemTagRegex.ReplaceAllString(text, "")
|
||||
}
|
||||
|
||||
// ListMessagesOptions defines pagination options for listing messages
|
||||
type ListMessagesOptions struct {
|
||||
Limit int
|
||||
@@ -16,29 +27,30 @@ type ListMessagesOptions struct {
|
||||
|
||||
// Task represents a task in the database
|
||||
type Task struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
BoardID string `json:"board_id"`
|
||||
ColumnID string `json:"column_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
State v1.TaskState `json:"state"`
|
||||
Priority int `json:"priority"`
|
||||
Position int `json:"position"` // Order within column
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
Repositories []*TaskRepository `json:"repositories,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
BoardID string `json:"board_id"`
|
||||
WorkflowStepID string `json:"workflow_step_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
State v1.TaskState `json:"state"`
|
||||
Priority int `json:"priority"`
|
||||
Position int `json:"position"` // Order within workflow step
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
Repositories []*TaskRepository `json:"repositories,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Board represents a Kanban board
|
||||
type Board struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
WorkflowTemplateID *string `json:"workflow_template_id,omitempty"` // Optional workflow template
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Workspace represents a workspace
|
||||
@@ -54,18 +66,6 @@ type Workspace struct {
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// Column represents a column in a board
|
||||
type Column struct {
|
||||
ID string `json:"id"`
|
||||
BoardID string `json:"board_id"`
|
||||
Name string `json:"name"`
|
||||
Position int `json:"position"`
|
||||
State v1.TaskState `json:"state"` // Maps column to task state
|
||||
Color string `json:"color"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TaskRepository represents a repository associated with a task
|
||||
type TaskRepository struct {
|
||||
ID string `json:"id"`
|
||||
@@ -125,7 +125,9 @@ type Message struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ToAPI converts internal Message to API type
|
||||
// ToAPI converts internal Message to API type.
|
||||
// System-injected content (wrapped in <kandev-system> tags) is stripped from the content
|
||||
// so users don't see workflow step prompt modifications in the UI.
|
||||
func (m *Message) ToAPI() *v1.Message {
|
||||
messageType := string(m.Type)
|
||||
if messageType == "" {
|
||||
@@ -138,7 +140,7 @@ func (m *Message) ToAPI() *v1.Message {
|
||||
TurnID: m.TurnID,
|
||||
AuthorType: string(m.AuthorType),
|
||||
AuthorID: m.AuthorID,
|
||||
Content: m.Content,
|
||||
Content: StripSystemContent(m.Content),
|
||||
Type: messageType,
|
||||
Metadata: m.Metadata,
|
||||
RequestsInput: m.RequestsInput,
|
||||
@@ -218,6 +220,11 @@ type TaskSession struct {
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
// Workflow-related fields
|
||||
IsPrimary bool `json:"is_primary"` // Whether this is the primary session for the task
|
||||
WorkflowStepID *string `json:"workflow_step_id,omitempty"` // Current workflow step ID
|
||||
ReviewStatus *string `json:"review_status,omitempty"` // pending, approved
|
||||
}
|
||||
|
||||
// ToAPI converts internal TaskSession to API type
|
||||
|
||||
@@ -49,19 +49,19 @@ func TestTaskStructInitialization(t *testing.T) {
|
||||
},
|
||||
}
|
||||
task := Task{
|
||||
ID: "task-123",
|
||||
WorkspaceID: "workspace-001",
|
||||
BoardID: "board-456",
|
||||
ColumnID: "column-789",
|
||||
Title: "Test Task",
|
||||
Description: "A test task description",
|
||||
State: v1.TaskStateTODO,
|
||||
Priority: 5,
|
||||
Position: 1,
|
||||
Metadata: map[string]interface{}{"key": "value"},
|
||||
Repositories: repos,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
ID: "task-123",
|
||||
WorkspaceID: "workspace-001",
|
||||
BoardID: "board-456",
|
||||
WorkflowStepID: "workflow-step-789",
|
||||
Title: "Test Task",
|
||||
Description: "A test task description",
|
||||
State: v1.TaskStateTODO,
|
||||
Priority: 5,
|
||||
Position: 1,
|
||||
Metadata: map[string]interface{}{"key": "value"},
|
||||
Repositories: repos,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if task.ID != "task-123" {
|
||||
@@ -73,8 +73,8 @@ func TestTaskStructInitialization(t *testing.T) {
|
||||
if task.BoardID != "board-456" {
|
||||
t.Errorf("expected BoardID board-456, got %s", task.BoardID)
|
||||
}
|
||||
if task.ColumnID != "column-789" {
|
||||
t.Errorf("expected ColumnID column-789, got %s", task.ColumnID)
|
||||
if task.WorkflowStepID != "workflow-step-789" {
|
||||
t.Errorf("expected WorkflowStepID workflow-step-789, got %s", task.WorkflowStepID)
|
||||
}
|
||||
if task.Title != "Test Task" {
|
||||
t.Errorf("expected Title 'Test Task', got %s", task.Title)
|
||||
@@ -130,42 +130,13 @@ func TestBoardStructInitialization(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestColumnStructInitialization(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
column := Column{
|
||||
ID: "column-123",
|
||||
BoardID: "board-456",
|
||||
Name: "To Do",
|
||||
Position: 0,
|
||||
State: v1.TaskStateTODO,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if column.ID != "column-123" {
|
||||
t.Errorf("expected ID column-123, got %s", column.ID)
|
||||
}
|
||||
if column.BoardID != "board-456" {
|
||||
t.Errorf("expected BoardID board-456, got %s", column.BoardID)
|
||||
}
|
||||
if column.Name != "To Do" {
|
||||
t.Errorf("expected Name 'To Do', got %s", column.Name)
|
||||
}
|
||||
if column.Position != 0 {
|
||||
t.Errorf("expected Position 0, got %d", column.Position)
|
||||
}
|
||||
if column.State != v1.TaskStateTODO {
|
||||
t.Errorf("expected State TODO, got %s", column.State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskToAPI(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
task := &Task{
|
||||
ID: "task-123",
|
||||
WorkspaceID: "workspace-001",
|
||||
BoardID: "board-456",
|
||||
ColumnID: "column-789",
|
||||
ID: "task-123",
|
||||
WorkspaceID: "workspace-001",
|
||||
BoardID: "board-456",
|
||||
WorkflowStepID: "step-789",
|
||||
Title: "Test Task",
|
||||
Description: "A test task description",
|
||||
State: v1.TaskStateInProgress,
|
||||
@@ -228,10 +199,10 @@ func TestTaskToAPI(t *testing.T) {
|
||||
func TestTaskToAPIWithEmptyOptionalFields(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
task := &Task{
|
||||
ID: "task-123",
|
||||
WorkspaceID: "workspace-001",
|
||||
BoardID: "board-456",
|
||||
ColumnID: "column-789",
|
||||
ID: "task-123",
|
||||
WorkspaceID: "workspace-001",
|
||||
BoardID: "board-456",
|
||||
WorkflowStepID: "step-789",
|
||||
Title: "Test Task",
|
||||
Description: "A test task description",
|
||||
State: v1.TaskStateTODO,
|
||||
|
||||
@@ -22,9 +22,9 @@ type Repository interface {
|
||||
UpdateTask(ctx context.Context, task *models.Task) error
|
||||
DeleteTask(ctx context.Context, id string) error
|
||||
ListTasks(ctx context.Context, boardID string) ([]*models.Task, error)
|
||||
ListTasksByColumn(ctx context.Context, columnID string) ([]*models.Task, error)
|
||||
ListTasksByWorkflowStep(ctx context.Context, workflowStepID string) ([]*models.Task, error)
|
||||
UpdateTaskState(ctx context.Context, id string, state v1.TaskState) error
|
||||
AddTaskToBoard(ctx context.Context, taskID, boardID, columnID string, position int) error
|
||||
AddTaskToBoard(ctx context.Context, taskID, boardID, workflowStepID string, position int) error
|
||||
RemoveTaskFromBoard(ctx context.Context, taskID, boardID string) error
|
||||
|
||||
// TaskRepository operations
|
||||
@@ -43,14 +43,6 @@ type Repository interface {
|
||||
DeleteBoard(ctx context.Context, id string) error
|
||||
ListBoards(ctx context.Context, workspaceID string) ([]*models.Board, error)
|
||||
|
||||
// Column operations
|
||||
CreateColumn(ctx context.Context, column *models.Column) error
|
||||
GetColumn(ctx context.Context, id string) (*models.Column, error)
|
||||
GetColumnByState(ctx context.Context, boardID string, state v1.TaskState) (*models.Column, error)
|
||||
UpdateColumn(ctx context.Context, column *models.Column) error
|
||||
DeleteColumn(ctx context.Context, id string) error
|
||||
ListColumns(ctx context.Context, boardID string) ([]*models.Column, error)
|
||||
|
||||
// Message operations
|
||||
CreateMessage(ctx context.Context, message *models.Message) error
|
||||
GetMessage(ctx context.Context, id string) (*models.Message, error)
|
||||
@@ -85,6 +77,13 @@ type Repository interface {
|
||||
HasActiveTaskSessionsByRepository(ctx context.Context, repositoryID string) (bool, error)
|
||||
DeleteTaskSession(ctx context.Context, id string) error
|
||||
|
||||
// Workflow-related session operations
|
||||
GetPrimarySessionByTaskID(ctx context.Context, taskID string) (*models.TaskSession, error)
|
||||
GetPrimarySessionIDsByTaskIDs(ctx context.Context, taskIDs []string) (map[string]string, error)
|
||||
SetSessionPrimary(ctx context.Context, sessionID string) error
|
||||
UpdateSessionWorkflowStep(ctx context.Context, sessionID string, stepID string) error
|
||||
UpdateSessionReviewStatus(ctx context.Context, sessionID string, status string) error
|
||||
|
||||
// Task Session Worktree operations
|
||||
CreateTaskSessionWorktree(ctx context.Context, sessionWorktree *models.TaskSessionWorktree) error
|
||||
ListTaskSessionWorktrees(ctx context.Context, sessionID string) ([]*models.TaskSessionWorktree, error)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,8 @@ package sqlite
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
commonsqlite "github.com/kandev/kandev/internal/common/sqlite"
|
||||
)
|
||||
|
||||
// Repository provides SQLite-based task storage operations.
|
||||
@@ -45,47 +47,7 @@ func (r *Repository) DB() *sql.DB {
|
||||
|
||||
// ensureColumn adds a column to a table if it doesn't exist
|
||||
func (r *Repository) ensureColumn(table, column, definition string) error {
|
||||
exists, err := r.columnExists(table, column)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return nil
|
||||
}
|
||||
_, err = r.db.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, column, definition))
|
||||
return err
|
||||
}
|
||||
|
||||
// columnExists checks if a column exists in a table
|
||||
func (r *Repository) columnExists(table, column string) (bool, error) {
|
||||
rows, err := r.db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, colType string
|
||||
var notNull int
|
||||
var defaultValue *string
|
||||
var pk int
|
||||
if err := rows.Scan(&cid, &name, &colType, ¬Null, &defaultValue, &pk); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if name == column {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, rows.Err()
|
||||
}
|
||||
|
||||
// boolToInt converts a boolean to an integer (for SQLite)
|
||||
func boolToInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
return commonsqlite.EnsureColumn(r.db, table, column, definition)
|
||||
}
|
||||
|
||||
// ensureWorkspaceIndexes creates workspace-related indexes
|
||||
@@ -173,23 +135,11 @@ func (r *Repository) initSchema() error {
|
||||
updated_at DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS columns (
|
||||
id TEXT PRIMARY KEY,
|
||||
board_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
position INTEGER DEFAULT 0,
|
||||
state TEXT DEFAULT 'TODO',
|
||||
color TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
FOREIGN KEY (board_id) REFERENCES boards(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
workspace_id TEXT NOT NULL DEFAULT '',
|
||||
board_id TEXT NOT NULL,
|
||||
column_id TEXT NOT NULL,
|
||||
workflow_step_id TEXT NOT NULL DEFAULT '',
|
||||
title TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
state TEXT DEFAULT 'TODO',
|
||||
@@ -198,8 +148,7 @@ func (r *Repository) initSchema() error {
|
||||
metadata TEXT DEFAULT '{}',
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
FOREIGN KEY (board_id) REFERENCES boards(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (column_id) REFERENCES columns(id) ON DELETE CASCADE
|
||||
FOREIGN KEY (board_id) REFERENCES boards(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS task_repositories (
|
||||
@@ -250,10 +199,9 @@ func (r *Repository) initSchema() error {
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_board_id ON tasks(board_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_column_id ON tasks(column_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_workflow_step_id ON tasks(workflow_step_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_task_repositories_task_id ON task_repositories(task_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_task_repositories_repository_id ON task_repositories(repository_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_columns_board_id ON columns(board_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_repositories_workspace_id ON repositories(workspace_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_repository_scripts_repo_id ON repository_scripts(repository_id);
|
||||
`
|
||||
@@ -320,6 +268,9 @@ func (r *Repository) initSchema() error {
|
||||
started_at DATETIME NOT NULL,
|
||||
completed_at DATETIME,
|
||||
updated_at DATETIME NOT NULL,
|
||||
is_primary INTEGER DEFAULT 0,
|
||||
workflow_step_id TEXT DEFAULT '',
|
||||
review_status TEXT DEFAULT '',
|
||||
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
@@ -406,6 +357,9 @@ func (r *Repository) initSchema() error {
|
||||
if err := r.ensureColumn("boards", "workspace_id", "TEXT NOT NULL DEFAULT ''"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.ensureColumn("boards", "workflow_template_id", "TEXT DEFAULT ''"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.ensureColumn("tasks", "workspace_id", "TEXT NOT NULL DEFAULT ''"); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -418,9 +372,7 @@ func (r *Repository) initSchema() error {
|
||||
if err := r.ensureColumn("workspaces", "default_agent_profile_id", "TEXT DEFAULT ''"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.ensureColumn("columns", "color", "TEXT NOT NULL DEFAULT ''"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := r.ensureColumn("environments", "is_system", "INTEGER NOT NULL DEFAULT 0"); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -470,6 +422,15 @@ func (r *Repository) initSchema() error {
|
||||
if err := r.ensureColumn("task_sessions", "state", "TEXT NOT NULL DEFAULT 'CREATED'"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.ensureColumn("task_sessions", "is_primary", "INTEGER DEFAULT 0"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.ensureColumn("task_sessions", "workflow_step_id", "TEXT DEFAULT ''"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.ensureColumn("task_sessions", "review_status", "TEXT DEFAULT ''"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := r.ensureDefaultWorkspace(); err != nil {
|
||||
return err
|
||||
|
||||
@@ -9,21 +9,20 @@ import (
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
v1 "github.com/kandev/kandev/pkg/api/v1"
|
||||
)
|
||||
|
||||
// AddTaskToBoard adds a task to a board with placement
|
||||
func (r *Repository) AddTaskToBoard(ctx context.Context, taskID, boardID, columnID string, position int) error {
|
||||
func (r *Repository) AddTaskToBoard(ctx context.Context, taskID, boardID, workflowStepID string, position int) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
UPDATE tasks SET board_id = ?, column_id = ?, position = ?, updated_at = ? WHERE id = ?
|
||||
`, boardID, columnID, position, time.Now().UTC(), taskID)
|
||||
UPDATE tasks SET board_id = ?, workflow_step_id = ?, position = ?, updated_at = ? WHERE id = ?
|
||||
`, boardID, workflowStepID, position, time.Now().UTC(), taskID)
|
||||
return err
|
||||
}
|
||||
|
||||
// RemoveTaskFromBoard removes a task from a board
|
||||
func (r *Repository) RemoveTaskFromBoard(ctx context.Context, taskID, boardID string) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
UPDATE tasks SET board_id = '', column_id = '', position = 0, updated_at = ? WHERE id = ? AND board_id = ?
|
||||
UPDATE tasks SET board_id = '', workflow_step_id = '', position = 0, updated_at = ? WHERE id = ? AND board_id = ?
|
||||
`, time.Now().UTC(), taskID, boardID)
|
||||
return err
|
||||
}
|
||||
@@ -40,9 +39,9 @@ func (r *Repository) CreateBoard(ctx context.Context, board *models.Board) error
|
||||
board.UpdatedAt = now
|
||||
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO boards (id, workspace_id, name, description, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`, board.ID, board.WorkspaceID, board.Name, board.Description, board.CreatedAt, board.UpdatedAt)
|
||||
INSERT INTO boards (id, workspace_id, name, description, workflow_template_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, board.ID, board.WorkspaceID, board.Name, board.Description, board.WorkflowTemplateID, board.CreatedAt, board.UpdatedAt)
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -50,16 +49,24 @@ func (r *Repository) CreateBoard(ctx context.Context, board *models.Board) error
|
||||
// GetBoard retrieves a board by ID
|
||||
func (r *Repository) GetBoard(ctx context.Context, id string) (*models.Board, error) {
|
||||
board := &models.Board{}
|
||||
var workflowTemplateID sql.NullString
|
||||
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, workspace_id, name, description, created_at, updated_at
|
||||
SELECT id, workspace_id, name, description, workflow_template_id, created_at, updated_at
|
||||
FROM boards WHERE id = ?
|
||||
`, id).Scan(&board.ID, &board.WorkspaceID, &board.Name, &board.Description, &board.CreatedAt, &board.UpdatedAt)
|
||||
`, id).Scan(&board.ID, &board.WorkspaceID, &board.Name, &board.Description, &workflowTemplateID, &board.CreatedAt, &board.UpdatedAt)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("board not found: %s", id)
|
||||
}
|
||||
return board, err
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if workflowTemplateID.Valid {
|
||||
board.WorkflowTemplateID = &workflowTemplateID.String
|
||||
}
|
||||
return board, nil
|
||||
}
|
||||
|
||||
// UpdateBoard updates an existing board
|
||||
@@ -67,8 +74,8 @@ func (r *Repository) UpdateBoard(ctx context.Context, board *models.Board) error
|
||||
board.UpdatedAt = time.Now().UTC()
|
||||
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE boards SET name = ?, description = ?, updated_at = ? WHERE id = ?
|
||||
`, board.Name, board.Description, board.UpdatedAt, board.ID)
|
||||
UPDATE boards SET name = ?, description = ?, workflow_template_id = ?, updated_at = ? WHERE id = ?
|
||||
`, board.Name, board.Description, board.WorkflowTemplateID, board.UpdatedAt, board.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -97,7 +104,7 @@ func (r *Repository) DeleteBoard(ctx context.Context, id string) error {
|
||||
// ListBoards returns all boards
|
||||
func (r *Repository) ListBoards(ctx context.Context, workspaceID string) ([]*models.Board, error) {
|
||||
query := `
|
||||
SELECT id, workspace_id, name, description, created_at, updated_at FROM boards
|
||||
SELECT id, workspace_id, name, description, workflow_template_id, created_at, updated_at FROM boards
|
||||
`
|
||||
var args []interface{}
|
||||
if workspaceID != "" {
|
||||
@@ -115,116 +122,15 @@ func (r *Repository) ListBoards(ctx context.Context, workspaceID string) ([]*mod
|
||||
var result []*models.Board
|
||||
for rows.Next() {
|
||||
board := &models.Board{}
|
||||
err := rows.Scan(&board.ID, &board.WorkspaceID, &board.Name, &board.Description, &board.CreatedAt, &board.UpdatedAt)
|
||||
var workflowTemplateID sql.NullString
|
||||
err := rows.Scan(&board.ID, &board.WorkspaceID, &board.Name, &board.Description, &workflowTemplateID, &board.CreatedAt, &board.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if workflowTemplateID.Valid {
|
||||
board.WorkflowTemplateID = &workflowTemplateID.String
|
||||
}
|
||||
result = append(result, board)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// Column operations
|
||||
|
||||
// CreateColumn creates a new column
|
||||
func (r *Repository) CreateColumn(ctx context.Context, column *models.Column) error {
|
||||
if column.ID == "" {
|
||||
column.ID = uuid.New().String()
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
column.CreatedAt = now
|
||||
column.UpdatedAt = now
|
||||
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO columns (id, board_id, name, position, state, color, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, column.ID, column.BoardID, column.Name, column.Position, column.State, column.Color, column.CreatedAt, column.UpdatedAt)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// GetColumn retrieves a column by ID
|
||||
func (r *Repository) GetColumn(ctx context.Context, id string) (*models.Column, error) {
|
||||
column := &models.Column{}
|
||||
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, board_id, name, position, state, color, created_at, updated_at
|
||||
FROM columns WHERE id = ?
|
||||
`, id).Scan(&column.ID, &column.BoardID, &column.Name, &column.Position, &column.State, &column.Color, &column.CreatedAt, &column.UpdatedAt)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("column not found: %s", id)
|
||||
}
|
||||
return column, err
|
||||
}
|
||||
|
||||
// GetColumnByState retrieves a column by board ID and state
|
||||
func (r *Repository) GetColumnByState(ctx context.Context, boardID string, state v1.TaskState) (*models.Column, error) {
|
||||
column := &models.Column{}
|
||||
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, board_id, name, position, state, color, created_at, updated_at
|
||||
FROM columns WHERE board_id = ? AND state = ?
|
||||
`, boardID, state).Scan(&column.ID, &column.BoardID, &column.Name, &column.Position, &column.State, &column.Color, &column.CreatedAt, &column.UpdatedAt)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("column not found for board %s with state %s", boardID, state)
|
||||
}
|
||||
return column, err
|
||||
}
|
||||
|
||||
// UpdateColumn updates an existing column
|
||||
func (r *Repository) UpdateColumn(ctx context.Context, column *models.Column) error {
|
||||
column.UpdatedAt = time.Now().UTC()
|
||||
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE columns SET name = ?, position = ?, state = ?, color = ?, updated_at = ? WHERE id = ?
|
||||
`, column.Name, column.Position, column.State, column.Color, column.UpdatedAt, column.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("column not found: %s", column.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteColumn deletes a column by ID
|
||||
func (r *Repository) DeleteColumn(ctx context.Context, id string) error {
|
||||
result, err := r.db.ExecContext(ctx, `DELETE FROM columns WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("column not found: %s", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListColumns returns all columns for a board
|
||||
func (r *Repository) ListColumns(ctx context.Context, boardID string) ([]*models.Column, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, board_id, name, position, state, color, created_at, updated_at
|
||||
FROM columns WHERE board_id = ? ORDER BY position
|
||||
`, boardID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var result []*models.Column
|
||||
for rows.Next() {
|
||||
column := &models.Column{}
|
||||
err := rows.Scan(&column.ID, &column.BoardID, &column.Name, &column.Position, &column.State, &column.Color, &column.CreatedAt, &column.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, column)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
commonsqlite "github.com/kandev/kandev/internal/common/sqlite"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
v1 "github.com/kandev/kandev/pkg/api/v1"
|
||||
)
|
||||
|
||||
// ensureDefaultWorkspace creates a default workspace if none exists
|
||||
@@ -63,27 +63,7 @@ func (r *Repository) ensureDefaultWorkspace() error {
|
||||
`, boardID, defaultID, "Dev", "Default development board", now, now); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
type columnSeed struct {
|
||||
name string
|
||||
position int
|
||||
state string
|
||||
color string
|
||||
}
|
||||
columns := []columnSeed{
|
||||
{name: "Todo", position: 0, state: string(v1.TaskStateTODO), color: "bg-neutral-400"},
|
||||
{name: "In Progress", position: 1, state: string(v1.TaskStateInProgress), color: "bg-blue-500"},
|
||||
{name: "Review", position: 2, state: string(v1.TaskStateReview), color: "bg-yellow-500"},
|
||||
{name: "Done", position: 3, state: string(v1.TaskStateCompleted), color: "bg-green-500"},
|
||||
}
|
||||
for _, column := range columns {
|
||||
if _, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO columns (id, board_id, name, position, state, color, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, uuid.New().String(), boardID, column.name, column.position, column.state, column.color, now, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Note: Workflow steps will be created by the workflow repository after it initializes
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,7 +141,7 @@ func (r *Repository) ensureDefaultExecutorsAndEnvironments() error {
|
||||
if _, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO executors (id, name, type, status, is_system, resumable, config, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, executor.id, executor.name, executor.execType, executor.status, boolToInt(executor.isSystem), boolToInt(executor.resumable), string(configJSON), now, now); err != nil {
|
||||
`, executor.id, executor.name, executor.execType, executor.status, commonsqlite.BoolToInt(executor.isSystem), commonsqlite.BoolToInt(executor.resumable), string(configJSON), now, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -182,7 +162,7 @@ func (r *Repository) ensureDefaultExecutorsAndEnvironments() error {
|
||||
if _, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO environments (id, name, kind, is_system, worktree_root, image_tag, dockerfile, build_config, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, models.EnvironmentIDLocal, "Local", models.EnvironmentKindLocalPC, boolToInt(true), "~/kandev", "", "", "{}", now, now); err != nil {
|
||||
`, models.EnvironmentIDLocal, "Local", models.EnvironmentKindLocalPC, commonsqlite.BoolToInt(true), "~/kandev", "", "", "{}", now, now); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
@@ -195,7 +175,7 @@ func (r *Repository) ensureDefaultExecutorsAndEnvironments() error {
|
||||
if _, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO environments (id, name, kind, is_system, worktree_root, image_tag, dockerfile, build_config, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, models.EnvironmentIDLocal, "Local", models.EnvironmentKindLocalPC, boolToInt(true), "~/kandev", "", "", "{}", now, now); err != nil {
|
||||
`, models.EnvironmentIDLocal, "Local", models.EnvironmentKindLocalPC, commonsqlite.BoolToInt(true), "~/kandev", "", "", "{}", now, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
commonsqlite "github.com/kandev/kandev/internal/common/sqlite"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
)
|
||||
|
||||
@@ -29,7 +30,7 @@ func (r *Repository) CreateEnvironment(ctx context.Context, environment *models.
|
||||
_, err = r.db.ExecContext(ctx, `
|
||||
INSERT INTO environments (id, name, kind, is_system, worktree_root, image_tag, dockerfile, build_config, created_at, updated_at, deleted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, environment.ID, environment.Name, environment.Kind, boolToInt(environment.IsSystem), environment.WorktreeRoot, environment.ImageTag, environment.Dockerfile, string(buildConfigJSON), environment.CreatedAt, environment.UpdatedAt, environment.DeletedAt)
|
||||
`, environment.ID, environment.Name, environment.Kind, commonsqlite.BoolToInt(environment.IsSystem), environment.WorktreeRoot, environment.ImageTag, environment.Dockerfile, string(buildConfigJSON), environment.CreatedAt, environment.UpdatedAt, environment.DeletedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -74,7 +75,7 @@ func (r *Repository) UpdateEnvironment(ctx context.Context, environment *models.
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE environments SET name = ?, kind = ?, is_system = ?, worktree_root = ?, image_tag = ?, dockerfile = ?, build_config = ?, updated_at = ?
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
`, environment.Name, environment.Kind, boolToInt(environment.IsSystem), environment.WorktreeRoot, environment.ImageTag, environment.Dockerfile, string(buildConfigJSON), environment.UpdatedAt, environment.ID)
|
||||
`, environment.Name, environment.Kind, commonsqlite.BoolToInt(environment.IsSystem), environment.WorktreeRoot, environment.ImageTag, environment.Dockerfile, string(buildConfigJSON), environment.UpdatedAt, environment.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
commonsqlite "github.com/kandev/kandev/internal/common/sqlite"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
)
|
||||
|
||||
@@ -30,7 +31,7 @@ func (r *Repository) CreateExecutor(ctx context.Context, executor *models.Execut
|
||||
_, err = r.db.ExecContext(ctx, `
|
||||
INSERT INTO executors (id, name, type, status, is_system, resumable, config, created_at, updated_at, deleted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, executor.ID, executor.Name, executor.Type, executor.Status, boolToInt(executor.IsSystem), boolToInt(executor.Resumable), string(configJSON), executor.CreatedAt, executor.UpdatedAt, executor.DeletedAt)
|
||||
`, executor.ID, executor.Name, executor.Type, executor.Status, commonsqlite.BoolToInt(executor.IsSystem), commonsqlite.BoolToInt(executor.Resumable), string(configJSON), executor.CreatedAt, executor.UpdatedAt, executor.DeletedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -75,7 +76,7 @@ func (r *Repository) UpdateExecutor(ctx context.Context, executor *models.Execut
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE executors SET name = ?, type = ?, status = ?, is_system = ?, resumable = ?, config = ?, updated_at = ?
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
`, executor.Name, executor.Type, executor.Status, boolToInt(executor.IsSystem), boolToInt(executor.Resumable), string(configJSON), executor.UpdatedAt, executor.ID)
|
||||
`, executor.Name, executor.Type, executor.Status, commonsqlite.BoolToInt(executor.IsSystem), commonsqlite.BoolToInt(executor.Resumable), string(configJSON), executor.UpdatedAt, executor.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -184,7 +185,7 @@ func (r *Repository) UpsertExecutorRunning(ctx context.Context, running *models.
|
||||
running.ExecutorID,
|
||||
running.Runtime,
|
||||
running.Status,
|
||||
boolToInt(running.Resumable),
|
||||
commonsqlite.BoolToInt(running.Resumable),
|
||||
running.ResumeToken,
|
||||
running.AgentExecutionID,
|
||||
running.ContainerID,
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
commonsqlite "github.com/kandev/kandev/internal/common/sqlite"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
)
|
||||
|
||||
@@ -27,7 +28,7 @@ func (r *Repository) CreateRepository(ctx context.Context, repository *models.Re
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, repository.ID, repository.WorkspaceID, repository.Name, repository.SourceType, repository.LocalPath, repository.Provider,
|
||||
repository.ProviderRepoID, repository.ProviderOwner, repository.ProviderName, repository.DefaultBranch, repository.WorktreeBranchPrefix,
|
||||
boolToInt(repository.PullBeforeWorktree), repository.SetupScript, repository.CleanupScript, repository.DevScript, repository.CreatedAt, repository.UpdatedAt, repository.DeletedAt)
|
||||
commonsqlite.BoolToInt(repository.PullBeforeWorktree), repository.SetupScript, repository.CleanupScript, repository.DevScript, repository.CreatedAt, repository.UpdatedAt, repository.DeletedAt)
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -62,7 +63,7 @@ func (r *Repository) UpdateRepository(ctx context.Context, repository *models.Re
|
||||
provider_name = ?, default_branch = ?, worktree_branch_prefix = ?, pull_before_worktree = ?, setup_script = ?, cleanup_script = ?, dev_script = ?, updated_at = ?
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
`, repository.Name, repository.SourceType, repository.LocalPath, repository.Provider, repository.ProviderRepoID,
|
||||
repository.ProviderOwner, repository.ProviderName, repository.DefaultBranch, repository.WorktreeBranchPrefix, boolToInt(repository.PullBeforeWorktree),
|
||||
repository.ProviderOwner, repository.ProviderName, repository.DefaultBranch, repository.WorktreeBranchPrefix, commonsqlite.BoolToInt(repository.PullBeforeWorktree),
|
||||
repository.SetupScript, repository.CleanupScript, repository.DevScript, repository.UpdatedAt, repository.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -6,10 +6,12 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
commonsqlite "github.com/kandev/kandev/internal/common/sqlite"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
)
|
||||
|
||||
@@ -197,19 +199,20 @@ func (r *Repository) CreateTaskSession(ctx context.Context, session *models.Task
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to serialize repository snapshot: %w", err)
|
||||
}
|
||||
|
||||
_, err = r.db.ExecContext(ctx, `
|
||||
INSERT INTO task_sessions (
|
||||
id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id,
|
||||
repository_id, base_branch,
|
||||
agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot,
|
||||
state, error_message, metadata, started_at, completed_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
state, error_message, metadata, started_at, completed_at, updated_at,
|
||||
is_primary, workflow_step_id, review_status
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, session.ID, session.TaskID, session.AgentExecutionID, session.ContainerID, session.AgentProfileID,
|
||||
session.ExecutorID, session.EnvironmentID, session.RepositoryID, session.BaseBranch,
|
||||
string(agentProfileSnapshotJSON), string(executorSnapshotJSON), string(environmentSnapshotJSON), string(repositorySnapshotJSON),
|
||||
string(session.State), session.ErrorMessage, string(metadataJSON),
|
||||
session.StartedAt, session.CompletedAt, session.UpdatedAt)
|
||||
session.StartedAt, session.CompletedAt, session.UpdatedAt,
|
||||
commonsqlite.BoolToInt(session.IsPrimary), session.WorkflowStepID, session.ReviewStatus)
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -224,12 +227,16 @@ func (r *Repository) GetTaskSession(ctx context.Context, id string) (*models.Tas
|
||||
var environmentSnapshotJSON string
|
||||
var repositorySnapshotJSON string
|
||||
var completedAt sql.NullTime
|
||||
var isPrimary int
|
||||
var workflowStepID sql.NullString
|
||||
var reviewStatus sql.NullString
|
||||
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id,
|
||||
repository_id, base_branch,
|
||||
agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot,
|
||||
state, error_message, metadata, started_at, completed_at, updated_at
|
||||
state, error_message, metadata, started_at, completed_at, updated_at,
|
||||
is_primary, workflow_step_id, review_status
|
||||
FROM task_sessions WHERE id = ?
|
||||
`, id).Scan(
|
||||
&session.ID, &session.TaskID, &session.AgentExecutionID, &session.ContainerID, &session.AgentProfileID,
|
||||
@@ -237,6 +244,7 @@ func (r *Repository) GetTaskSession(ctx context.Context, id string) (*models.Tas
|
||||
&session.RepositoryID, &session.BaseBranch,
|
||||
&agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON,
|
||||
&state, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt,
|
||||
&isPrimary, &workflowStepID, &reviewStatus,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
@@ -247,6 +255,13 @@ func (r *Repository) GetTaskSession(ctx context.Context, id string) (*models.Tas
|
||||
}
|
||||
|
||||
session.State = models.TaskSessionState(state)
|
||||
session.IsPrimary = isPrimary == 1
|
||||
if workflowStepID.Valid {
|
||||
session.WorkflowStepID = &workflowStepID.String
|
||||
}
|
||||
if reviewStatus.Valid {
|
||||
session.ReviewStatus = &reviewStatus.String
|
||||
}
|
||||
if completedAt.Valid {
|
||||
session.CompletedAt = &completedAt.Time
|
||||
}
|
||||
@@ -295,12 +310,16 @@ func (r *Repository) GetTaskSessionByTaskID(ctx context.Context, taskID string)
|
||||
var environmentSnapshotJSON string
|
||||
var repositorySnapshotJSON string
|
||||
var completedAt sql.NullTime
|
||||
var isPrimary int
|
||||
var workflowStepID sql.NullString
|
||||
var reviewStatus sql.NullString
|
||||
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id,
|
||||
repository_id, base_branch,
|
||||
agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot,
|
||||
state, error_message, metadata, started_at, completed_at, updated_at
|
||||
state, error_message, metadata, started_at, completed_at, updated_at,
|
||||
is_primary, workflow_step_id, review_status
|
||||
FROM task_sessions WHERE task_id = ? ORDER BY started_at DESC LIMIT 1
|
||||
`, taskID).Scan(
|
||||
&session.ID, &session.TaskID, &session.AgentExecutionID, &session.ContainerID, &session.AgentProfileID,
|
||||
@@ -308,6 +327,7 @@ func (r *Repository) GetTaskSessionByTaskID(ctx context.Context, taskID string)
|
||||
&session.RepositoryID, &session.BaseBranch,
|
||||
&agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON,
|
||||
&state, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt,
|
||||
&isPrimary, &workflowStepID, &reviewStatus,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
@@ -318,6 +338,13 @@ func (r *Repository) GetTaskSessionByTaskID(ctx context.Context, taskID string)
|
||||
}
|
||||
|
||||
session.State = models.TaskSessionState(state)
|
||||
session.IsPrimary = isPrimary == 1
|
||||
if workflowStepID.Valid {
|
||||
session.WorkflowStepID = &workflowStepID.String
|
||||
}
|
||||
if reviewStatus.Valid {
|
||||
session.ReviewStatus = &reviewStatus.String
|
||||
}
|
||||
if completedAt.Valid {
|
||||
session.CompletedAt = &completedAt.Time
|
||||
}
|
||||
@@ -366,12 +393,16 @@ func (r *Repository) GetActiveTaskSessionByTaskID(ctx context.Context, taskID st
|
||||
var environmentSnapshotJSON string
|
||||
var repositorySnapshotJSON string
|
||||
var completedAt sql.NullTime
|
||||
var isPrimary int
|
||||
var workflowStepID sql.NullString
|
||||
var reviewStatus sql.NullString
|
||||
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id,
|
||||
repository_id, base_branch,
|
||||
agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot,
|
||||
state, error_message, metadata, started_at, completed_at, updated_at
|
||||
state, error_message, metadata, started_at, completed_at, updated_at,
|
||||
is_primary, workflow_step_id, review_status
|
||||
FROM task_sessions
|
||||
WHERE task_id = ? AND state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT')
|
||||
ORDER BY started_at DESC LIMIT 1
|
||||
@@ -381,6 +412,7 @@ func (r *Repository) GetActiveTaskSessionByTaskID(ctx context.Context, taskID st
|
||||
&session.RepositoryID, &session.BaseBranch,
|
||||
&agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON,
|
||||
&state, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt,
|
||||
&isPrimary, &workflowStepID, &reviewStatus,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
@@ -391,6 +423,13 @@ func (r *Repository) GetActiveTaskSessionByTaskID(ctx context.Context, taskID st
|
||||
}
|
||||
|
||||
session.State = models.TaskSessionState(state)
|
||||
session.IsPrimary = isPrimary == 1
|
||||
if workflowStepID.Valid {
|
||||
session.WorkflowStepID = &workflowStepID.String
|
||||
}
|
||||
if reviewStatus.Valid {
|
||||
session.ReviewStatus = &reviewStatus.String
|
||||
}
|
||||
if completedAt.Valid {
|
||||
session.CompletedAt = &completedAt.Time
|
||||
}
|
||||
@@ -459,13 +498,15 @@ func (r *Repository) UpdateTaskSession(ctx context.Context, session *models.Task
|
||||
agent_execution_id = ?, container_id = ?, agent_profile_id = ?, executor_id = ?, environment_id = ?,
|
||||
repository_id = ?, base_branch = ?,
|
||||
agent_profile_snapshot = ?, executor_snapshot = ?, environment_snapshot = ?, repository_snapshot = ?,
|
||||
state = ?, error_message = ?, metadata = ?, completed_at = ?, updated_at = ?
|
||||
state = ?, error_message = ?, metadata = ?, completed_at = ?, updated_at = ?,
|
||||
is_primary = ?, workflow_step_id = ?, review_status = ?
|
||||
WHERE id = ?
|
||||
`, session.AgentExecutionID, session.ContainerID, session.AgentProfileID, session.ExecutorID, session.EnvironmentID,
|
||||
session.RepositoryID, session.BaseBranch,
|
||||
string(agentProfileSnapshotJSON), string(executorSnapshotJSON), string(environmentSnapshotJSON), string(repositorySnapshotJSON),
|
||||
string(session.State), session.ErrorMessage, string(metadataJSON), session.CompletedAt,
|
||||
session.UpdatedAt, session.ID)
|
||||
string(session.State), session.ErrorMessage, string(metadataJSON), session.CompletedAt, session.UpdatedAt,
|
||||
commonsqlite.BoolToInt(session.IsPrimary), session.WorkflowStepID, session.ReviewStatus,
|
||||
session.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -506,7 +547,8 @@ func (r *Repository) ListTaskSessions(ctx context.Context, taskID string) ([]*mo
|
||||
SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id,
|
||||
repository_id, base_branch,
|
||||
agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot,
|
||||
state, error_message, metadata, started_at, completed_at, updated_at
|
||||
state, error_message, metadata, started_at, completed_at, updated_at,
|
||||
is_primary, workflow_step_id, review_status
|
||||
FROM task_sessions WHERE task_id = ? ORDER BY started_at DESC
|
||||
`, taskID)
|
||||
if err != nil {
|
||||
@@ -534,7 +576,8 @@ func (r *Repository) ListActiveTaskSessions(ctx context.Context) ([]*models.Task
|
||||
SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id,
|
||||
repository_id, base_branch,
|
||||
agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot,
|
||||
state, error_message, metadata, started_at, completed_at, updated_at
|
||||
state, error_message, metadata, started_at, completed_at, updated_at,
|
||||
is_primary, workflow_step_id, review_status
|
||||
FROM task_sessions WHERE state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT') ORDER BY started_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
@@ -562,7 +605,8 @@ func (r *Repository) ListActiveTaskSessionsByTaskID(ctx context.Context, taskID
|
||||
SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id,
|
||||
repository_id, base_branch,
|
||||
agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot,
|
||||
state, error_message, metadata, started_at, completed_at, updated_at
|
||||
state, error_message, metadata, started_at, completed_at, updated_at,
|
||||
is_primary, workflow_step_id, review_status
|
||||
FROM task_sessions WHERE task_id = ? AND state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT') ORDER BY started_at DESC
|
||||
`, taskID)
|
||||
if err != nil {
|
||||
@@ -638,6 +682,9 @@ func (r *Repository) scanTaskSessions(ctx context.Context, rows *sql.Rows) ([]*m
|
||||
var environmentSnapshotJSON string
|
||||
var repositorySnapshotJSON string
|
||||
var completedAt sql.NullTime
|
||||
var isPrimary int
|
||||
var workflowStepID sql.NullString
|
||||
var reviewStatus sql.NullString
|
||||
|
||||
err := rows.Scan(
|
||||
&session.ID, &session.TaskID, &session.AgentExecutionID, &session.ContainerID, &session.AgentProfileID,
|
||||
@@ -645,12 +692,20 @@ func (r *Repository) scanTaskSessions(ctx context.Context, rows *sql.Rows) ([]*m
|
||||
&session.RepositoryID, &session.BaseBranch,
|
||||
&agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON,
|
||||
&state, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt,
|
||||
&isPrimary, &workflowStepID, &reviewStatus,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
session.State = models.TaskSessionState(state)
|
||||
session.IsPrimary = isPrimary == 1
|
||||
if workflowStepID.Valid {
|
||||
session.WorkflowStepID = &workflowStepID.String
|
||||
}
|
||||
if reviewStatus.Valid {
|
||||
session.ReviewStatus = &reviewStatus.String
|
||||
}
|
||||
if completedAt.Valid {
|
||||
session.CompletedAt = &completedAt.Time
|
||||
}
|
||||
@@ -787,3 +842,195 @@ func (r *Repository) DeleteTaskSessionWorktreesBySession(ctx context.Context, se
|
||||
return err
|
||||
}
|
||||
|
||||
// GetPrimarySessionByTaskID retrieves the primary session for a task
|
||||
func (r *Repository) GetPrimarySessionByTaskID(ctx context.Context, taskID string) (*models.TaskSession, error) {
|
||||
session := &models.TaskSession{}
|
||||
var state string
|
||||
var metadataJSON string
|
||||
var agentProfileSnapshotJSON string
|
||||
var executorSnapshotJSON string
|
||||
var environmentSnapshotJSON string
|
||||
var repositorySnapshotJSON string
|
||||
var completedAt sql.NullTime
|
||||
var isPrimary int
|
||||
var workflowStepID sql.NullString
|
||||
var reviewStatus sql.NullString
|
||||
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id,
|
||||
repository_id, base_branch,
|
||||
agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot,
|
||||
state, error_message, metadata, started_at, completed_at, updated_at,
|
||||
is_primary, workflow_step_id, review_status
|
||||
FROM task_sessions WHERE task_id = ? AND is_primary = 1 LIMIT 1
|
||||
`, taskID).Scan(
|
||||
&session.ID, &session.TaskID, &session.AgentExecutionID, &session.ContainerID, &session.AgentProfileID,
|
||||
&session.ExecutorID, &session.EnvironmentID,
|
||||
&session.RepositoryID, &session.BaseBranch,
|
||||
&agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON,
|
||||
&state, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt,
|
||||
&isPrimary, &workflowStepID, &reviewStatus,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("no primary session found for task: %s", taskID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
session.State = models.TaskSessionState(state)
|
||||
session.IsPrimary = isPrimary == 1
|
||||
if workflowStepID.Valid {
|
||||
session.WorkflowStepID = &workflowStepID.String
|
||||
}
|
||||
if reviewStatus.Valid {
|
||||
session.ReviewStatus = &reviewStatus.String
|
||||
}
|
||||
if completedAt.Valid {
|
||||
session.CompletedAt = &completedAt.Time
|
||||
}
|
||||
if metadataJSON != "" && metadataJSON != "{}" {
|
||||
if err := json.Unmarshal([]byte(metadataJSON), &session.Metadata); err != nil {
|
||||
return nil, fmt.Errorf("failed to deserialize agent session metadata: %w", err)
|
||||
}
|
||||
}
|
||||
if agentProfileSnapshotJSON != "" && agentProfileSnapshotJSON != "{}" {
|
||||
if err := json.Unmarshal([]byte(agentProfileSnapshotJSON), &session.AgentProfileSnapshot); err != nil {
|
||||
return nil, fmt.Errorf("failed to deserialize agent profile snapshot: %w", err)
|
||||
}
|
||||
}
|
||||
if executorSnapshotJSON != "" && executorSnapshotJSON != "{}" {
|
||||
if err := json.Unmarshal([]byte(executorSnapshotJSON), &session.ExecutorSnapshot); err != nil {
|
||||
return nil, fmt.Errorf("failed to deserialize executor snapshot: %w", err)
|
||||
}
|
||||
}
|
||||
if environmentSnapshotJSON != "" && environmentSnapshotJSON != "{}" {
|
||||
if err := json.Unmarshal([]byte(environmentSnapshotJSON), &session.EnvironmentSnapshot); err != nil {
|
||||
return nil, fmt.Errorf("failed to deserialize environment snapshot: %w", err)
|
||||
}
|
||||
}
|
||||
if repositorySnapshotJSON != "" && repositorySnapshotJSON != "{}" {
|
||||
if err := json.Unmarshal([]byte(repositorySnapshotJSON), &session.RepositorySnapshot); err != nil {
|
||||
return nil, fmt.Errorf("failed to deserialize repository snapshot: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
worktrees, err := r.ListTaskSessionWorktrees(ctx, session.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load session worktrees: %w", err)
|
||||
}
|
||||
session.Worktrees = worktrees
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// GetPrimarySessionIDsByTaskIDs returns a map of task ID to primary session ID for the given task IDs.
|
||||
// Tasks without a primary session are not included in the result.
|
||||
func (r *Repository) GetPrimarySessionIDsByTaskIDs(ctx context.Context, taskIDs []string) (map[string]string, error) {
|
||||
if len(taskIDs) == 0 {
|
||||
return make(map[string]string), nil
|
||||
}
|
||||
|
||||
// Build placeholders for IN clause
|
||||
placeholders := make([]string, len(taskIDs))
|
||||
args := make([]interface{}, len(taskIDs))
|
||||
for i, id := range taskIDs {
|
||||
placeholders[i] = "?"
|
||||
args[i] = id
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`
|
||||
SELECT id, task_id FROM task_sessions
|
||||
WHERE task_id IN (%s) AND is_primary = 1
|
||||
`, strings.Join(placeholders, ","))
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
result := make(map[string]string)
|
||||
for rows.Next() {
|
||||
var sessionID, taskID string
|
||||
if err := rows.Scan(&sessionID, &taskID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[taskID] = sessionID
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// SetSessionPrimary marks a session as primary and clears primary flag on other sessions for the same task
|
||||
func (r *Repository) SetSessionPrimary(ctx context.Context, sessionID string) error {
|
||||
now := time.Now().UTC()
|
||||
|
||||
// First, get the task_id for this session
|
||||
var taskID string
|
||||
err := r.db.QueryRowContext(ctx, `SELECT task_id FROM task_sessions WHERE id = ?`, sessionID).Scan(&taskID)
|
||||
if err == sql.ErrNoRows {
|
||||
return fmt.Errorf("session not found: %s", sessionID)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Clear primary flag on all sessions for this task
|
||||
_, err = r.db.ExecContext(ctx, `
|
||||
UPDATE task_sessions SET is_primary = 0, updated_at = ? WHERE task_id = ?
|
||||
`, now, taskID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set primary flag on the specified session
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE task_sessions SET is_primary = 1, updated_at = ? WHERE id = ?
|
||||
`, now, sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("session not found: %s", sessionID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateSessionWorkflowStep updates the workflow step for a session
|
||||
func (r *Repository) UpdateSessionWorkflowStep(ctx context.Context, sessionID string, stepID string) error {
|
||||
now := time.Now().UTC()
|
||||
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE task_sessions SET workflow_step_id = ?, updated_at = ? WHERE id = ?
|
||||
`, stepID, now, sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("session not found: %s", sessionID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateSessionReviewStatus updates the review status of a session
|
||||
func (r *Repository) UpdateSessionReviewStatus(ctx context.Context, sessionID string, status string) error {
|
||||
now := time.Now().UTC()
|
||||
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE task_sessions SET review_status = ?, updated_at = ? WHERE id = ?
|
||||
`, status, now, sessionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("session not found: %s", sessionID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -33,9 +33,9 @@ func (r *Repository) CreateTask(ctx context.Context, task *models.Task) error {
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO tasks (id, workspace_id, board_id, column_id, title, description, state, priority, position, metadata, created_at, updated_at)
|
||||
INSERT INTO tasks (id, workspace_id, board_id, workflow_step_id, title, description, state, priority, position, metadata, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, task.ID, task.WorkspaceID, task.BoardID, task.ColumnID, task.Title, task.Description, task.State, task.Priority, task.Position, string(metadata), task.CreatedAt, task.UpdatedAt)
|
||||
`, task.ID, task.WorkspaceID, task.BoardID, task.WorkflowStepID, task.Title, task.Description, task.State, task.Priority, task.Position, string(metadata), task.CreatedAt, task.UpdatedAt)
|
||||
if err != nil {
|
||||
if rollbackErr := tx.Rollback(); rollbackErr != nil {
|
||||
return fmt.Errorf("failed to rollback task insert: %w", rollbackErr)
|
||||
@@ -51,9 +51,9 @@ func (r *Repository) GetTask(ctx context.Context, id string) (*models.Task, erro
|
||||
task := &models.Task{}
|
||||
var metadata string
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, workspace_id, board_id, column_id, title, description, state, priority, position, metadata, created_at, updated_at
|
||||
SELECT id, workspace_id, board_id, workflow_step_id, title, description, state, priority, position, metadata, created_at, updated_at
|
||||
FROM tasks WHERE id = ?
|
||||
`, id).Scan(&task.ID, &task.WorkspaceID, &task.BoardID, &task.ColumnID, &task.Title, &task.Description, &task.State, &task.Priority, &task.Position, &metadata, &task.CreatedAt, &task.UpdatedAt)
|
||||
`, id).Scan(&task.ID, &task.WorkspaceID, &task.BoardID, &task.WorkflowStepID, &task.Title, &task.Description, &task.State, &task.Priority, &task.Position, &metadata, &task.CreatedAt, &task.UpdatedAt)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("task not found: %s", id)
|
||||
@@ -76,9 +76,9 @@ func (r *Repository) UpdateTask(ctx context.Context, task *models.Task) error {
|
||||
}
|
||||
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE tasks SET workspace_id = ?, board_id = ?, column_id = ?, title = ?, description = ?, state = ?, priority = ?, position = ?, metadata = ?, updated_at = ?
|
||||
UPDATE tasks SET workspace_id = ?, board_id = ?, workflow_step_id = ?, title = ?, description = ?, state = ?, priority = ?, position = ?, metadata = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`, task.WorkspaceID, task.BoardID, task.ColumnID, task.Title, task.Description, task.State, task.Priority, task.Position, string(metadata), task.UpdatedAt, task.ID)
|
||||
`, task.WorkspaceID, task.BoardID, task.WorkflowStepID, task.Title, task.Description, task.State, task.Priority, task.Position, string(metadata), task.UpdatedAt, task.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -107,7 +107,7 @@ func (r *Repository) DeleteTask(ctx context.Context, id string) error {
|
||||
// ListTasks returns all tasks for a board
|
||||
func (r *Repository) ListTasks(ctx context.Context, boardID string) ([]*models.Task, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, workspace_id, board_id, column_id, title, description, state, priority, position, metadata, created_at, updated_at
|
||||
SELECT id, workspace_id, board_id, workflow_step_id, title, description, state, priority, position, metadata, created_at, updated_at
|
||||
FROM tasks
|
||||
WHERE board_id = ?
|
||||
ORDER BY position
|
||||
@@ -120,13 +120,13 @@ func (r *Repository) ListTasks(ctx context.Context, boardID string) ([]*models.T
|
||||
return r.scanTasks(rows)
|
||||
}
|
||||
|
||||
// ListTasksByColumn returns all tasks in a column
|
||||
func (r *Repository) ListTasksByColumn(ctx context.Context, columnID string) ([]*models.Task, error) {
|
||||
// ListTasksByWorkflowStep returns all tasks in a workflow step
|
||||
func (r *Repository) ListTasksByWorkflowStep(ctx context.Context, workflowStepID string) ([]*models.Task, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, workspace_id, board_id, column_id, title, description, state, priority, position, metadata, created_at, updated_at
|
||||
SELECT id, workspace_id, board_id, workflow_step_id, title, description, state, priority, position, metadata, created_at, updated_at
|
||||
FROM tasks
|
||||
WHERE column_id = ? ORDER BY position
|
||||
`, columnID)
|
||||
WHERE workflow_step_id = ? ORDER BY position
|
||||
`, workflowStepID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -145,7 +145,7 @@ func (r *Repository) scanTasks(rows *sql.Rows) ([]*models.Task, error) {
|
||||
&task.ID,
|
||||
&task.WorkspaceID,
|
||||
&task.BoardID,
|
||||
&task.ColumnID,
|
||||
&task.WorkflowStepID,
|
||||
&task.Title,
|
||||
&task.Description,
|
||||
&task.State,
|
||||
|
||||
@@ -88,30 +88,7 @@ func TestSQLiteRepository_SeedsDefaultWorkspace(t *testing.T) {
|
||||
if boards[0].Name != "Dev" {
|
||||
t.Errorf("expected Dev board, got %s", boards[0].Name)
|
||||
}
|
||||
|
||||
columns, err := repo.ListColumns(ctx, boards[0].ID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to list columns: %v", err)
|
||||
}
|
||||
if len(columns) != 4 {
|
||||
t.Fatalf("expected 4 columns, got %d", len(columns))
|
||||
}
|
||||
expected := map[string]v1.TaskState{
|
||||
"Todo": v1.TaskStateTODO,
|
||||
"In Progress": v1.TaskStateInProgress,
|
||||
"Review": v1.TaskStateReview,
|
||||
"Done": v1.TaskStateCompleted,
|
||||
}
|
||||
for _, column := range columns {
|
||||
expectedState, ok := expected[column.Name]
|
||||
if !ok {
|
||||
t.Errorf("unexpected column name %s", column.Name)
|
||||
continue
|
||||
}
|
||||
if column.State != expectedState {
|
||||
t.Errorf("expected %s state %s, got %s", column.Name, expectedState, column.State)
|
||||
}
|
||||
}
|
||||
// Note: workflow steps are now managed by the workflow repository, not the task repository
|
||||
}
|
||||
|
||||
// Board CRUD tests
|
||||
@@ -209,99 +186,6 @@ func TestSQLiteRepository_ListBoards(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Column CRUD tests
|
||||
|
||||
func TestSQLiteRepository_ColumnCRUD(t *testing.T) {
|
||||
repo, cleanup := createTestSQLiteRepo(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
// First create a workspace and board for foreign key
|
||||
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
|
||||
board := &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Test Board"}
|
||||
_ = repo.CreateBoard(ctx, board)
|
||||
|
||||
// Create column
|
||||
column := &models.Column{BoardID: "board-123", Name: "To Do", Position: 0, State: v1.TaskStateTODO}
|
||||
if err := repo.CreateColumn(ctx, column); err != nil {
|
||||
t.Fatalf("failed to create column: %v", err)
|
||||
}
|
||||
if column.ID == "" {
|
||||
t.Error("expected column ID to be set")
|
||||
}
|
||||
|
||||
// Get
|
||||
retrieved, err := repo.GetColumn(ctx, column.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get column: %v", err)
|
||||
}
|
||||
if retrieved.Name != "To Do" {
|
||||
t.Errorf("expected name 'To Do', got %s", retrieved.Name)
|
||||
}
|
||||
|
||||
// Update
|
||||
column.Name = "Done"
|
||||
column.State = v1.TaskStateCompleted
|
||||
if err := repo.UpdateColumn(ctx, column); err != nil {
|
||||
t.Fatalf("failed to update column: %v", err)
|
||||
}
|
||||
retrieved, _ = repo.GetColumn(ctx, column.ID)
|
||||
if retrieved.Name != "Done" {
|
||||
t.Errorf("expected name 'Done', got %s", retrieved.Name)
|
||||
}
|
||||
|
||||
// Delete
|
||||
if err := repo.DeleteColumn(ctx, column.ID); err != nil {
|
||||
t.Fatalf("failed to delete column: %v", err)
|
||||
}
|
||||
_, err = repo.GetColumn(ctx, column.ID)
|
||||
if err == nil {
|
||||
t.Error("expected column to be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteRepository_ColumnNotFound(t *testing.T) {
|
||||
repo, cleanup := createTestSQLiteRepo(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := repo.GetColumn(ctx, "nonexistent")
|
||||
if err == nil {
|
||||
t.Error("expected error for nonexistent column")
|
||||
}
|
||||
|
||||
err = repo.UpdateColumn(ctx, &models.Column{ID: "nonexistent", Name: "Test"})
|
||||
if err == nil {
|
||||
t.Error("expected error for updating nonexistent column")
|
||||
}
|
||||
|
||||
err = repo.DeleteColumn(ctx, "nonexistent")
|
||||
if err == nil {
|
||||
t.Error("expected error for deleting nonexistent column")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteRepository_ListColumns(t *testing.T) {
|
||||
repo, cleanup := createTestSQLiteRepo(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
|
||||
board := &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Test Board"}
|
||||
_ = repo.CreateBoard(ctx, board)
|
||||
|
||||
_ = repo.CreateColumn(ctx, &models.Column{ID: "col-1", BoardID: "board-123", Name: "To Do"})
|
||||
_ = repo.CreateColumn(ctx, &models.Column{ID: "col-2", BoardID: "board-123", Name: "In Progress"})
|
||||
|
||||
columns, err := repo.ListColumns(ctx, "board-123")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to list columns: %v", err)
|
||||
}
|
||||
if len(columns) != 2 {
|
||||
t.Errorf("expected 2 columns, got %d", len(columns))
|
||||
}
|
||||
}
|
||||
|
||||
// Task CRUD tests
|
||||
|
||||
func TestSQLiteRepository_TaskCRUD(t *testing.T) {
|
||||
@@ -309,23 +193,21 @@ func TestSQLiteRepository_TaskCRUD(t *testing.T) {
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
// Create workspace, board, and column for foreign keys
|
||||
// Create workspace and board for foreign keys
|
||||
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
|
||||
board := &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Test Board"}
|
||||
_ = repo.CreateBoard(ctx, board)
|
||||
column := &models.Column{ID: "col-123", BoardID: "board-123", Name: "To Do"}
|
||||
_ = repo.CreateColumn(ctx, column)
|
||||
|
||||
// Create task
|
||||
// Create task (workflow steps are managed by workflow repository)
|
||||
task := &models.Task{
|
||||
WorkspaceID: "ws-1",
|
||||
BoardID: "board-123",
|
||||
ColumnID: "col-123",
|
||||
Title: "Test Task",
|
||||
Description: "A test task",
|
||||
State: v1.TaskStateTODO,
|
||||
Priority: 5,
|
||||
Metadata: map[string]interface{}{"key": "value"},
|
||||
WorkspaceID: "ws-1",
|
||||
BoardID: "board-123",
|
||||
WorkflowStepID: "step-123",
|
||||
Title: "Test Task",
|
||||
Description: "A test task",
|
||||
State: v1.TaskStateTODO,
|
||||
Priority: 5,
|
||||
Metadata: map[string]interface{}{"key": "value"},
|
||||
}
|
||||
if err := repo.CreateTask(ctx, task); err != nil {
|
||||
t.Fatalf("failed to create task: %v", err)
|
||||
@@ -392,13 +274,11 @@ func TestSQLiteRepository_UpdateTaskState(t *testing.T) {
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
// Create workspace, board, column, and task
|
||||
// Create workspace, board, and task
|
||||
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
|
||||
board := &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Test Board"}
|
||||
_ = repo.CreateBoard(ctx, board)
|
||||
column := &models.Column{ID: "col-123", BoardID: "board-123", Name: "To Do"}
|
||||
_ = repo.CreateColumn(ctx, column)
|
||||
task := &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-123", Title: "Test", State: v1.TaskStateTODO}
|
||||
task := &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Test", State: v1.TaskStateTODO}
|
||||
_ = repo.CreateTask(ctx, task)
|
||||
|
||||
err := repo.UpdateTaskState(ctx, "task-123", v1.TaskStateInProgress)
|
||||
@@ -428,15 +308,13 @@ func TestSQLiteRepository_ListTasks(t *testing.T) {
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
// Create workspace, board, and column
|
||||
// Create workspace and board
|
||||
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
|
||||
board := &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Test Board"}
|
||||
_ = repo.CreateBoard(ctx, board)
|
||||
column := &models.Column{ID: "col-123", BoardID: "board-123", Name: "To Do"}
|
||||
_ = repo.CreateColumn(ctx, column)
|
||||
|
||||
_ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-123", Title: "Task 1"})
|
||||
_ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-123", Title: "Task 2"})
|
||||
_ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Task 1"})
|
||||
_ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Task 2"})
|
||||
|
||||
tasks, err := repo.ListTasks(ctx, "board-123")
|
||||
if err != nil {
|
||||
@@ -447,30 +325,27 @@ func TestSQLiteRepository_ListTasks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteRepository_ListTasksByColumn(t *testing.T) {
|
||||
func TestSQLiteRepository_ListTasksByWorkflowStep(t *testing.T) {
|
||||
repo, cleanup := createTestSQLiteRepo(t)
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
// Create workspace, board, and columns
|
||||
// Create workspace and board
|
||||
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
|
||||
board := &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Test Board"}
|
||||
_ = repo.CreateBoard(ctx, board)
|
||||
col1 := &models.Column{ID: "col-1", BoardID: "board-123", Name: "To Do"}
|
||||
col2 := &models.Column{ID: "col-2", BoardID: "board-123", Name: "Done"}
|
||||
_ = repo.CreateColumn(ctx, col1)
|
||||
_ = repo.CreateColumn(ctx, col2)
|
||||
|
||||
_ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-1", Title: "Task 1"})
|
||||
_ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-1", Title: "Task 2"})
|
||||
_ = repo.CreateTask(ctx, &models.Task{ID: "task-3", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-2", Title: "Task 3"})
|
||||
// Tasks with different workflow steps
|
||||
_ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-1", Title: "Task 1"})
|
||||
_ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-1", Title: "Task 2"})
|
||||
_ = repo.CreateTask(ctx, &models.Task{ID: "task-3", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-2", Title: "Task 3"})
|
||||
|
||||
tasks, err := repo.ListTasksByColumn(ctx, "col-1")
|
||||
tasks, err := repo.ListTasksByWorkflowStep(ctx, "step-1")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to list tasks by column: %v", err)
|
||||
t.Fatalf("failed to list tasks by workflow step: %v", err)
|
||||
}
|
||||
if len(tasks) != 2 {
|
||||
t.Errorf("expected 2 tasks for col-1, got %d", len(tasks))
|
||||
t.Errorf("expected 2 tasks for step-1, got %d", len(tasks))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -484,7 +359,7 @@ func TestSQLiteRepository_Persistence(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open SQLite database: %v", err)
|
||||
}
|
||||
repo1, err := newSQLiteRepositoryWithDB(dbConn1)
|
||||
repo1, err := sqlite.NewWithDB(dbConn1)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create first repository: %v", err)
|
||||
}
|
||||
@@ -504,7 +379,7 @@ func TestSQLiteRepository_Persistence(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open SQLite database: %v", err)
|
||||
}
|
||||
repo2, err := newSQLiteRepositoryWithDB(dbConn2)
|
||||
repo2, err := sqlite.NewWithDB(dbConn2)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create second repository: %v", err)
|
||||
}
|
||||
@@ -558,14 +433,12 @@ func TestSQLiteRepository_MessageCRUD(t *testing.T) {
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
// Create board and column first (required for foreign key constraints)
|
||||
// Create board first (required for foreign key constraints)
|
||||
board := &models.Board{ID: "board-123", Name: "Test Board"}
|
||||
_ = repo.CreateBoard(ctx, board)
|
||||
column := &models.Column{ID: "col-123", BoardID: "board-123", Name: "Test Column"}
|
||||
_ = repo.CreateColumn(ctx, column)
|
||||
|
||||
// Create a task first
|
||||
task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task"}
|
||||
task := &models.Task{ID: "task-123", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Test Task"}
|
||||
_ = repo.CreateTask(ctx, task)
|
||||
sessionID := setupSQLiteTestSession(ctx, repo, task.ID, "session-123")
|
||||
turnID := setupSQLiteTestTurn(ctx, repo, sessionID, task.ID, "turn-123")
|
||||
@@ -633,16 +506,14 @@ func TestSQLiteRepository_ListMessages(t *testing.T) {
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
// Create board and column first (required for foreign key constraints)
|
||||
// Create board first (required for foreign key constraints)
|
||||
board := &models.Board{ID: "board-123", Name: "Test Board"}
|
||||
_ = repo.CreateBoard(ctx, board)
|
||||
column := &models.Column{ID: "col-123", BoardID: "board-123", Name: "Test Column"}
|
||||
_ = repo.CreateColumn(ctx, column)
|
||||
|
||||
// Create tasks
|
||||
task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task"}
|
||||
task := &models.Task{ID: "task-123", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Test Task"}
|
||||
_ = repo.CreateTask(ctx, task)
|
||||
task2 := &models.Task{ID: "task-456", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task 2"}
|
||||
task2 := &models.Task{ID: "task-456", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Test Task 2"}
|
||||
_ = repo.CreateTask(ctx, task2)
|
||||
sessionID := setupSQLiteTestSession(ctx, repo, task.ID, "session-123")
|
||||
session2ID := setupSQLiteTestSession(ctx, repo, task2.ID, "session-456")
|
||||
@@ -670,9 +541,7 @@ func TestSQLiteRepository_ListMessagesPagination(t *testing.T) {
|
||||
|
||||
board := &models.Board{ID: "board-123", Name: "Test Board"}
|
||||
_ = repo.CreateBoard(ctx, board)
|
||||
column := &models.Column{ID: "col-123", BoardID: "board-123", Name: "Test Column"}
|
||||
_ = repo.CreateColumn(ctx, column)
|
||||
task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task"}
|
||||
task := &models.Task{ID: "task-123", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Test Task"}
|
||||
_ = repo.CreateTask(ctx, task)
|
||||
sessionID := setupSQLiteTestSession(ctx, repo, task.ID, "session-123")
|
||||
turnID := setupSQLiteTestTurn(ctx, repo, sessionID, task.ID, "turn-123")
|
||||
@@ -729,14 +598,12 @@ func TestSQLiteRepository_MessageWithRequestsInput(t *testing.T) {
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
// Create board and column first (required for foreign key constraints)
|
||||
// Create board first (required for foreign key constraints)
|
||||
board := &models.Board{ID: "board-123", Name: "Test Board"}
|
||||
_ = repo.CreateBoard(ctx, board)
|
||||
column := &models.Column{ID: "col-123", BoardID: "board-123", Name: "Test Column"}
|
||||
_ = repo.CreateColumn(ctx, column)
|
||||
|
||||
// Create a task
|
||||
task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task"}
|
||||
task := &models.Task{ID: "task-123", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Test Task"}
|
||||
_ = repo.CreateTask(ctx, task)
|
||||
sessionID := setupSQLiteTestSession(ctx, repo, task.ID, "session-123")
|
||||
turnID := setupSQLiteTestTurn(ctx, repo, sessionID, task.ID, "turn-123")
|
||||
@@ -768,12 +635,10 @@ func TestSQLiteRepository_TaskSessionCRUD(t *testing.T) {
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
// Create board, column, and task first (required for foreign key constraints)
|
||||
// Create board and task first (required for foreign key constraints)
|
||||
board := &models.Board{ID: "board-123", Name: "Test Board"}
|
||||
_ = repo.CreateBoard(ctx, board)
|
||||
column := &models.Column{ID: "col-123", BoardID: "board-123", Name: "Test Column"}
|
||||
_ = repo.CreateColumn(ctx, column)
|
||||
task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task"}
|
||||
task := &models.Task{ID: "task-123", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Test Task"}
|
||||
_ = repo.CreateTask(ctx, task)
|
||||
|
||||
// Create agent session
|
||||
@@ -864,12 +729,10 @@ func TestSQLiteRepository_TaskSessionByTaskID(t *testing.T) {
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
// Create board, column, and task
|
||||
// Create board and task
|
||||
board := &models.Board{ID: "board-123", Name: "Test Board"}
|
||||
_ = repo.CreateBoard(ctx, board)
|
||||
column := &models.Column{ID: "col-123", BoardID: "board-123", Name: "Test Column"}
|
||||
_ = repo.CreateColumn(ctx, column)
|
||||
task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task"}
|
||||
task := &models.Task{ID: "task-123", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Test Task"}
|
||||
_ = repo.CreateTask(ctx, task)
|
||||
|
||||
// Create multiple sessions for the same task (simulating session history)
|
||||
@@ -931,14 +794,12 @@ func TestSQLiteRepository_ListTaskSessions(t *testing.T) {
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
// Create board, column, and tasks
|
||||
// Create board and tasks
|
||||
board := &models.Board{ID: "board-123", Name: "Test Board"}
|
||||
_ = repo.CreateBoard(ctx, board)
|
||||
column := &models.Column{ID: "col-123", BoardID: "board-123", Name: "Test Column"}
|
||||
_ = repo.CreateColumn(ctx, column)
|
||||
task1 := &models.Task{ID: "task-1", BoardID: "board-123", ColumnID: "col-123", Title: "Task 1"}
|
||||
task1 := &models.Task{ID: "task-1", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Task 1"}
|
||||
_ = repo.CreateTask(ctx, task1)
|
||||
task2 := &models.Task{ID: "task-2", BoardID: "board-123", ColumnID: "col-123", Title: "Task 2"}
|
||||
task2 := &models.Task{ID: "task-2", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Task 2"}
|
||||
_ = repo.CreateTask(ctx, task2)
|
||||
|
||||
// Create sessions for different tasks
|
||||
@@ -977,12 +838,10 @@ func TestSQLiteRepository_UpdateTaskSessionState(t *testing.T) {
|
||||
defer cleanup()
|
||||
ctx := context.Background()
|
||||
|
||||
// Create board, column, and task
|
||||
// Create board and task
|
||||
board := &models.Board{ID: "board-123", Name: "Test Board"}
|
||||
_ = repo.CreateBoard(ctx, board)
|
||||
column := &models.Column{ID: "col-123", BoardID: "board-123", Name: "Test Column"}
|
||||
_ = repo.CreateColumn(ctx, column)
|
||||
task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task"}
|
||||
task := &models.Task{ID: "task-123", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Test Task"}
|
||||
_ = repo.CreateTask(ctx, task)
|
||||
|
||||
// Create an agent session
|
||||
|
||||
@@ -48,6 +48,35 @@ type TaskExecutionStopper interface {
|
||||
StopTask(ctx context.Context, taskID, reason string, force bool) error
|
||||
}
|
||||
|
||||
// WorkflowStepCreator creates workflow steps from a template for a board.
|
||||
type WorkflowStepCreator interface {
|
||||
CreateStepsFromTemplate(ctx context.Context, boardID, templateID string) error
|
||||
}
|
||||
|
||||
// WorkflowStepGetter retrieves workflow step information.
|
||||
type WorkflowStepGetter interface {
|
||||
GetStep(ctx context.Context, stepID string) (*WorkflowStep, error)
|
||||
}
|
||||
|
||||
// WorkflowStep represents a workflow step (mirrors workflow/models.WorkflowStep).
|
||||
// Defined here to avoid circular dependency with workflow package.
|
||||
type WorkflowStep struct {
|
||||
ID string
|
||||
BoardID string
|
||||
Name string
|
||||
StepType string
|
||||
Position int
|
||||
Color string
|
||||
AutoStartAgent bool
|
||||
PlanMode bool
|
||||
RequireApproval bool
|
||||
PromptPrefix string
|
||||
PromptSuffix string
|
||||
AllowManualMove bool
|
||||
OnCompleteStepID *string
|
||||
OnApprovalStepID *string
|
||||
}
|
||||
|
||||
var (
|
||||
ErrActiveTaskSessions = errors.New("active agent sessions exist")
|
||||
ErrInvalidRepositorySettings = errors.New("invalid repository settings")
|
||||
@@ -74,12 +103,14 @@ func validateExecutorConfig(config map[string]string) error {
|
||||
|
||||
// Service provides task business logic
|
||||
type Service struct {
|
||||
repo repository.Repository
|
||||
eventBus bus.EventBus
|
||||
logger *logger.Logger
|
||||
discoveryConfig RepositoryDiscoveryConfig
|
||||
worktreeCleanup WorktreeCleanup
|
||||
executionStopper TaskExecutionStopper
|
||||
repo repository.Repository
|
||||
eventBus bus.EventBus
|
||||
logger *logger.Logger
|
||||
discoveryConfig RepositoryDiscoveryConfig
|
||||
worktreeCleanup WorktreeCleanup
|
||||
executionStopper TaskExecutionStopper
|
||||
workflowStepCreator WorkflowStepCreator
|
||||
workflowStepGetter WorkflowStepGetter
|
||||
}
|
||||
|
||||
// NewService creates a new task service
|
||||
@@ -102,6 +133,16 @@ func (s *Service) SetExecutionStopper(stopper TaskExecutionStopper) {
|
||||
s.executionStopper = stopper
|
||||
}
|
||||
|
||||
// SetWorkflowStepCreator wires the workflow step creator for board creation.
|
||||
func (s *Service) SetWorkflowStepCreator(creator WorkflowStepCreator) {
|
||||
s.workflowStepCreator = creator
|
||||
}
|
||||
|
||||
// SetWorkflowStepGetter wires the workflow step getter for MoveTask.
|
||||
func (s *Service) SetWorkflowStepGetter(getter WorkflowStepGetter) {
|
||||
s.workflowStepGetter = getter
|
||||
}
|
||||
|
||||
// Request types
|
||||
|
||||
// TaskRepositoryInput for creating/updating task repositories
|
||||
@@ -115,16 +156,16 @@ type TaskRepositoryInput struct {
|
||||
|
||||
// CreateTaskRequest contains the data for creating a new task
|
||||
type CreateTaskRequest struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
BoardID string `json:"board_id"`
|
||||
ColumnID string `json:"column_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Priority int `json:"priority"`
|
||||
State *v1.TaskState `json:"state,omitempty"`
|
||||
Repositories []TaskRepositoryInput `json:"repositories,omitempty"`
|
||||
Position int `json:"position"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
BoardID string `json:"board_id"`
|
||||
WorkflowStepID string `json:"workflow_step_id"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Priority int `json:"priority"`
|
||||
State *v1.TaskState `json:"state,omitempty"`
|
||||
Repositories []TaskRepositoryInput `json:"repositories,omitempty"`
|
||||
Position int `json:"position"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateTaskRequest contains the data for updating a task
|
||||
@@ -140,9 +181,10 @@ type UpdateTaskRequest struct {
|
||||
|
||||
// CreateBoardRequest contains the data for creating a new board
|
||||
type CreateBoardRequest struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
WorkflowTemplateID *string `json:"workflow_template_id,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateBoardRequest contains the data for updating a board
|
||||
@@ -170,23 +212,6 @@ type UpdateWorkspaceRequest struct {
|
||||
DefaultAgentProfileID *string `json:"default_agent_profile_id,omitempty"`
|
||||
}
|
||||
|
||||
// CreateColumnRequest contains the data for creating a new column
|
||||
type CreateColumnRequest struct {
|
||||
BoardID string `json:"board_id"`
|
||||
Name string `json:"name"`
|
||||
Position int `json:"position"`
|
||||
State v1.TaskState `json:"state"`
|
||||
Color string `json:"color"`
|
||||
}
|
||||
|
||||
// UpdateColumnRequest contains the data for updating a column
|
||||
type UpdateColumnRequest struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Position *int `json:"position,omitempty"`
|
||||
State *v1.TaskState `json:"state,omitempty"`
|
||||
Color *string `json:"color,omitempty"`
|
||||
}
|
||||
|
||||
// CreateRepositoryRequest contains the data for creating a new repository
|
||||
type CreateRepositoryRequest struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
@@ -293,16 +318,16 @@ func (s *Service) CreateTask(ctx context.Context, req *CreateTaskRequest) (*mode
|
||||
state = *req.State
|
||||
}
|
||||
task := &models.Task{
|
||||
ID: uuid.New().String(),
|
||||
WorkspaceID: req.WorkspaceID,
|
||||
BoardID: req.BoardID,
|
||||
ColumnID: req.ColumnID,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
State: state,
|
||||
Priority: req.Priority,
|
||||
Position: req.Position,
|
||||
Metadata: req.Metadata,
|
||||
ID: uuid.New().String(),
|
||||
WorkspaceID: req.WorkspaceID,
|
||||
BoardID: req.BoardID,
|
||||
WorkflowStepID: req.WorkflowStepID,
|
||||
Title: req.Title,
|
||||
Description: req.Description,
|
||||
State: state,
|
||||
Priority: req.Priority,
|
||||
Position: req.Position,
|
||||
Metadata: req.Metadata,
|
||||
}
|
||||
|
||||
if err := s.repo.CreateTask(ctx, task); err != nil {
|
||||
@@ -638,6 +663,133 @@ func (s *Service) GetTaskSession(ctx context.Context, sessionID string) (*models
|
||||
return s.repo.GetTaskSession(ctx, sessionID)
|
||||
}
|
||||
|
||||
// GetPrimarySession returns the primary session for a task.
|
||||
func (s *Service) GetPrimarySession(ctx context.Context, taskID string) (*models.TaskSession, error) {
|
||||
return s.repo.GetPrimarySessionByTaskID(ctx, taskID)
|
||||
}
|
||||
|
||||
// GetPrimarySessionIDsForTasks returns a map of task ID to primary session ID for the given task IDs.
|
||||
// Tasks without a primary session are not included in the result.
|
||||
func (s *Service) GetPrimarySessionIDsForTasks(ctx context.Context, taskIDs []string) (map[string]string, error) {
|
||||
return s.repo.GetPrimarySessionIDsByTaskIDs(ctx, taskIDs)
|
||||
}
|
||||
|
||||
// SetPrimarySession sets a session as the primary session for its task.
|
||||
// This will unset any existing primary session for the same task.
|
||||
func (s *Service) SetPrimarySession(ctx context.Context, sessionID string) error {
|
||||
if err := s.repo.SetSessionPrimary(ctx, sessionID); err != nil {
|
||||
s.logger.Error("failed to set primary session",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Error(err))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MoveSessionToStep moves a session to a different workflow step.
|
||||
func (s *Service) MoveSessionToStep(ctx context.Context, sessionID string, stepID string) error {
|
||||
if err := s.repo.UpdateSessionWorkflowStep(ctx, sessionID, stepID); err != nil {
|
||||
s.logger.Error("failed to move session to step",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("step_id", stepID),
|
||||
zap.Error(err))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateSessionReviewStatus updates the review status of a session.
|
||||
func (s *Service) UpdateSessionReviewStatus(ctx context.Context, sessionID string, status string) error {
|
||||
if err := s.repo.UpdateSessionReviewStatus(ctx, sessionID, status); err != nil {
|
||||
s.logger.Error("failed to update session review status",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("status", status),
|
||||
zap.Error(err))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApproveSessionResult contains the result of approving a session
|
||||
type ApproveSessionResult struct {
|
||||
Session *models.TaskSession
|
||||
Task *models.Task
|
||||
WorkflowStep *WorkflowStep
|
||||
}
|
||||
|
||||
// ApproveSession approves a session's current step and moves it to the on_approval_step_id
|
||||
func (s *Service) ApproveSession(ctx context.Context, sessionID string) (*ApproveSessionResult, error) {
|
||||
// Update review status to approved
|
||||
if err := s.repo.UpdateSessionReviewStatus(ctx, sessionID, "approved"); err != nil {
|
||||
return nil, fmt.Errorf("failed to update review status: %w", err)
|
||||
}
|
||||
|
||||
result := &ApproveSessionResult{}
|
||||
|
||||
// Reload session to get updated review status
|
||||
session, err := s.repo.GetTaskSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to reload session: %w", err)
|
||||
}
|
||||
result.Session = session
|
||||
|
||||
// Get the current workflow step to check for on_approval_step_id
|
||||
if session.WorkflowStepID != nil && s.workflowStepGetter != nil {
|
||||
step, err := s.workflowStepGetter.GetStep(ctx, *session.WorkflowStepID)
|
||||
if err != nil {
|
||||
s.logger.Warn("failed to get workflow step for approval transition",
|
||||
zap.String("workflow_step_id", *session.WorkflowStepID),
|
||||
zap.Error(err))
|
||||
} else if step.OnApprovalStepID != nil && *step.OnApprovalStepID != "" {
|
||||
// Move session and task to the on_approval_step_id
|
||||
newStepID := *step.OnApprovalStepID
|
||||
if err := s.repo.UpdateSessionWorkflowStep(ctx, sessionID, newStepID); err != nil {
|
||||
s.logger.Error("failed to move session to approval step",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("step_id", newStepID),
|
||||
zap.Error(err))
|
||||
} else {
|
||||
// Also move the task to the new step
|
||||
task, err := s.repo.GetTask(ctx, session.TaskID)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to get task for approval transition",
|
||||
zap.String("task_id", session.TaskID),
|
||||
zap.Error(err))
|
||||
} else {
|
||||
task.WorkflowStepID = newStepID
|
||||
task.UpdatedAt = time.Now().UTC()
|
||||
if err := s.repo.UpdateTask(ctx, task); err != nil {
|
||||
s.logger.Error("failed to move task to approval step",
|
||||
zap.String("task_id", session.TaskID),
|
||||
zap.String("step_id", newStepID),
|
||||
zap.Error(err))
|
||||
} else {
|
||||
s.publishTaskEvent(ctx, events.TaskUpdated, task, nil)
|
||||
result.Task = task
|
||||
}
|
||||
}
|
||||
|
||||
// Reload session with new step
|
||||
session, _ = s.repo.GetTaskSession(ctx, sessionID)
|
||||
result.Session = session
|
||||
|
||||
// Get the new workflow step for the response
|
||||
newStep, err := s.workflowStepGetter.GetStep(ctx, newStepID)
|
||||
if err == nil {
|
||||
result.WorkflowStep = newStep
|
||||
}
|
||||
|
||||
s.logger.Info("session approved and moved to next step",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("from_step", *session.WorkflowStepID),
|
||||
zap.String("to_step", newStepID))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// UpdateTaskState updates the state of a task, moves it to the matching column,
|
||||
// and publishes a task.state_changed event
|
||||
func (s *Service) UpdateTaskState(ctx context.Context, id string, state v1.TaskState) (*models.Task, error) {
|
||||
@@ -653,41 +805,15 @@ func (s *Service) UpdateTaskState(ctx context.Context, id string, state v1.TaskS
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Find the column that maps to the new state and move the task there
|
||||
// This ensures the Kanban board stays in sync with task state
|
||||
if task.BoardID != "" {
|
||||
column, err := s.repo.GetColumnByState(ctx, task.BoardID, state)
|
||||
if err != nil {
|
||||
s.logger.Warn("no column found for state, task will stay in current column",
|
||||
zap.String("task_id", id),
|
||||
zap.String("state", string(state)),
|
||||
zap.Error(err))
|
||||
} else if column.ID != task.ColumnID {
|
||||
// Move task to the new column (keep it at position 0 - top of the column)
|
||||
if err := s.repo.AddTaskToBoard(ctx, id, task.BoardID, column.ID, 0); err != nil {
|
||||
s.logger.Error("failed to move task to new column",
|
||||
zap.String("task_id", id),
|
||||
zap.String("column_id", column.ID),
|
||||
zap.Error(err))
|
||||
} else {
|
||||
s.logger.Info("task moved to column matching new state",
|
||||
zap.String("task_id", id),
|
||||
zap.String("column_id", column.ID),
|
||||
zap.String("column_name", column.Name),
|
||||
zap.String("state", string(state)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reload task to get updated state and column
|
||||
// Reload task to get updated state
|
||||
task, err = s.repo.GetTask(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Info("task reloaded after column move",
|
||||
s.logger.Info("task state updated",
|
||||
zap.String("task_id", id),
|
||||
zap.String("column_id", task.ColumnID),
|
||||
zap.String("workflow_step_id", task.WorkflowStepID),
|
||||
zap.String("state", string(task.State)))
|
||||
|
||||
s.publishTaskEvent(ctx, events.TaskStateChanged, task, &oldState)
|
||||
@@ -724,24 +850,40 @@ func (s *Service) UpdateTaskMetadata(ctx context.Context, id string, metadata ma
|
||||
return task, nil
|
||||
}
|
||||
|
||||
// MoveTask moves a task to a different column and position
|
||||
func (s *Service) MoveTask(ctx context.Context, id string, boardID string, columnID string, position int) (*models.Task, error) {
|
||||
// MoveTaskResult contains the result of a MoveTask operation.
|
||||
type MoveTaskResult struct {
|
||||
Task *models.Task
|
||||
WorkflowStep *WorkflowStep
|
||||
}
|
||||
|
||||
// MoveTask moves a task to a different workflow step and position
|
||||
func (s *Service) MoveTask(ctx context.Context, id string, boardID string, workflowStepID string, position int) (*MoveTaskResult, error) {
|
||||
task, err := s.repo.GetTask(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get the column to determine the new state
|
||||
column, err := s.repo.GetColumn(ctx, columnID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// Check if task's primary session is in a review step with pending approval
|
||||
// If so, prevent moving forward - user must use Approve button or send a message
|
||||
if task.WorkflowStepID != workflowStepID {
|
||||
primarySession, err := s.repo.GetPrimarySessionByTaskID(ctx, id)
|
||||
if err == nil && primarySession != nil {
|
||||
if primarySession.ReviewStatus != nil && *primarySession.ReviewStatus == "pending" {
|
||||
// Check if current step requires approval
|
||||
if s.workflowStepGetter != nil && primarySession.WorkflowStepID != nil {
|
||||
currentStep, err := s.workflowStepGetter.GetStep(ctx, *primarySession.WorkflowStepID)
|
||||
if err == nil && currentStep.RequireApproval {
|
||||
return nil, fmt.Errorf("task is pending approval - use Approve button to proceed or send a message to request changes")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
oldState := task.State
|
||||
task.BoardID = boardID
|
||||
task.ColumnID = columnID
|
||||
task.WorkflowStepID = workflowStepID
|
||||
task.Position = position
|
||||
task.State = column.State
|
||||
task.UpdatedAt = time.Now().UTC()
|
||||
|
||||
if err := s.repo.UpdateTask(ctx, task); err != nil {
|
||||
@@ -759,10 +901,25 @@ func (s *Service) MoveTask(ctx context.Context, id string, boardID string, colum
|
||||
s.logger.Info("task moved",
|
||||
zap.String("task_id", id),
|
||||
zap.String("board_id", boardID),
|
||||
zap.String("column_id", columnID),
|
||||
zap.String("workflow_step_id", workflowStepID),
|
||||
zap.Int("position", position))
|
||||
|
||||
return task, nil
|
||||
result := &MoveTaskResult{Task: task}
|
||||
|
||||
// Fetch the workflow step info if getter is available
|
||||
if s.workflowStepGetter != nil {
|
||||
step, err := s.workflowStepGetter.GetStep(ctx, workflowStepID)
|
||||
if err != nil {
|
||||
s.logger.Warn("failed to get workflow step for MoveTask response",
|
||||
zap.String("workflow_step_id", workflowStepID),
|
||||
zap.Error(err))
|
||||
// Don't fail the operation, just log and continue
|
||||
} else {
|
||||
result.WorkflowStep = step
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Workspace operations
|
||||
@@ -864,10 +1021,11 @@ func (s *Service) ListWorkspaces(ctx context.Context) ([]*models.Workspace, erro
|
||||
// CreateBoard creates a new board
|
||||
func (s *Service) CreateBoard(ctx context.Context, req *CreateBoardRequest) (*models.Board, error) {
|
||||
board := &models.Board{
|
||||
ID: uuid.New().String(),
|
||||
WorkspaceID: req.WorkspaceID,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
ID: uuid.New().String(),
|
||||
WorkspaceID: req.WorkspaceID,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
WorkflowTemplateID: req.WorkflowTemplateID,
|
||||
}
|
||||
|
||||
if err := s.repo.CreateBoard(ctx, board); err != nil {
|
||||
@@ -875,6 +1033,17 @@ func (s *Service) CreateBoard(ctx context.Context, req *CreateBoardRequest) (*mo
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create workflow steps from template if specified
|
||||
if req.WorkflowTemplateID != nil && *req.WorkflowTemplateID != "" && s.workflowStepCreator != nil {
|
||||
if err := s.workflowStepCreator.CreateStepsFromTemplate(ctx, board.ID, *req.WorkflowTemplateID); err != nil {
|
||||
s.logger.Error("failed to create workflow steps from template",
|
||||
zap.String("board_id", board.ID),
|
||||
zap.String("template_id", *req.WorkflowTemplateID),
|
||||
zap.Error(err))
|
||||
// Don't fail board creation, just log the error
|
||||
}
|
||||
}
|
||||
|
||||
s.publishBoardEvent(ctx, events.BoardCreated, board)
|
||||
s.logger.Info("board created", zap.String("board_id", board.ID), zap.String("name", board.Name))
|
||||
return board, nil
|
||||
@@ -931,95 +1100,6 @@ func (s *Service) ListBoards(ctx context.Context, workspaceID string) ([]*models
|
||||
return s.repo.ListBoards(ctx, workspaceID)
|
||||
}
|
||||
|
||||
// Column operations
|
||||
|
||||
// CreateColumn creates a new column
|
||||
func (s *Service) CreateColumn(ctx context.Context, req *CreateColumnRequest) (*models.Column, error) {
|
||||
color := req.Color
|
||||
if color == "" {
|
||||
color = "bg-neutral-400"
|
||||
}
|
||||
column := &models.Column{
|
||||
ID: uuid.New().String(),
|
||||
BoardID: req.BoardID,
|
||||
Name: req.Name,
|
||||
Position: req.Position,
|
||||
State: req.State,
|
||||
Color: color,
|
||||
}
|
||||
|
||||
if err := s.repo.CreateColumn(ctx, column); err != nil {
|
||||
s.logger.Error("failed to create column", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.publishColumnEvent(ctx, events.ColumnCreated, column)
|
||||
s.logger.Info("column created",
|
||||
zap.String("column_id", column.ID),
|
||||
zap.String("board_id", column.BoardID),
|
||||
zap.String("name", column.Name))
|
||||
return column, nil
|
||||
}
|
||||
|
||||
// GetColumn retrieves a column by ID
|
||||
func (s *Service) GetColumn(ctx context.Context, id string) (*models.Column, error) {
|
||||
return s.repo.GetColumn(ctx, id)
|
||||
}
|
||||
|
||||
// ListColumns returns all columns for a board
|
||||
func (s *Service) ListColumns(ctx context.Context, boardID string) ([]*models.Column, error) {
|
||||
return s.repo.ListColumns(ctx, boardID)
|
||||
}
|
||||
|
||||
// UpdateColumn updates an existing column
|
||||
func (s *Service) UpdateColumn(ctx context.Context, id string, req *UpdateColumnRequest) (*models.Column, error) {
|
||||
column, err := s.repo.GetColumn(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if req.Name != nil {
|
||||
column.Name = *req.Name
|
||||
}
|
||||
if req.Position != nil {
|
||||
column.Position = *req.Position
|
||||
}
|
||||
if req.State != nil {
|
||||
column.State = *req.State
|
||||
}
|
||||
if req.Color != nil {
|
||||
column.Color = *req.Color
|
||||
}
|
||||
column.UpdatedAt = time.Now().UTC()
|
||||
|
||||
if err := s.repo.UpdateColumn(ctx, column); err != nil {
|
||||
s.logger.Error("failed to update column", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.publishColumnEvent(ctx, events.ColumnUpdated, column)
|
||||
s.logger.Info("column updated",
|
||||
zap.String("column_id", column.ID),
|
||||
zap.String("board_id", column.BoardID),
|
||||
zap.String("name", column.Name))
|
||||
return column, nil
|
||||
}
|
||||
|
||||
// DeleteColumn deletes an existing column
|
||||
func (s *Service) DeleteColumn(ctx context.Context, id string) error {
|
||||
column, err := s.repo.GetColumn(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.repo.DeleteColumn(ctx, id); err != nil {
|
||||
s.logger.Error("failed to delete column", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
s.publishColumnEvent(ctx, events.ColumnDeleted, column)
|
||||
s.logger.Info("column deleted", zap.String("column_id", id))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Repository operations
|
||||
|
||||
func (s *Service) CreateRepository(ctx context.Context, req *CreateRepositoryRequest) (*models.Repository, error) {
|
||||
@@ -1417,16 +1497,16 @@ func (s *Service) publishTaskEvent(ctx context.Context, eventType string, task *
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"task_id": task.ID,
|
||||
"board_id": task.BoardID,
|
||||
"column_id": task.ColumnID,
|
||||
"title": task.Title,
|
||||
"description": task.Description,
|
||||
"state": string(task.State),
|
||||
"priority": task.Priority,
|
||||
"position": task.Position,
|
||||
"created_at": task.CreatedAt.Format(time.RFC3339),
|
||||
"updated_at": task.UpdatedAt.Format(time.RFC3339),
|
||||
"task_id": task.ID,
|
||||
"board_id": task.BoardID,
|
||||
"workflow_step_id": task.WorkflowStepID,
|
||||
"title": task.Title,
|
||||
"description": task.Description,
|
||||
"state": string(task.State),
|
||||
"priority": task.Priority,
|
||||
"position": task.Position,
|
||||
"created_at": task.CreatedAt.Format(time.RFC3339),
|
||||
"updated_at": task.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
|
||||
if task.Metadata != nil {
|
||||
@@ -1440,14 +1520,6 @@ func (s *Service) publishTaskEvent(ctx context.Context, eventType string, task *
|
||||
|
||||
event := bus.NewEvent(eventType, "task-service", data)
|
||||
|
||||
// Debug logging for state changes
|
||||
s.logger.Info("publishing task event",
|
||||
zap.String("event_type", eventType),
|
||||
zap.String("task_id", task.ID),
|
||||
zap.String("board_id", task.BoardID),
|
||||
zap.String("column_id", task.ColumnID),
|
||||
zap.String("state", string(task.State)))
|
||||
|
||||
if err := s.eventBus.Publish(ctx, eventType, event); err != nil {
|
||||
s.logger.Error("failed to publish task event",
|
||||
zap.String("event_type", eventType),
|
||||
@@ -1505,31 +1577,6 @@ func (s *Service) publishBoardEvent(ctx context.Context, eventType string, board
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) publishColumnEvent(ctx context.Context, eventType string, column *models.Column) {
|
||||
if s.eventBus == nil || column == nil {
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"id": column.ID,
|
||||
"board_id": column.BoardID,
|
||||
"name": column.Name,
|
||||
"position": column.Position,
|
||||
"state": string(column.State),
|
||||
"color": column.Color,
|
||||
"created_at": column.CreatedAt.Format(time.RFC3339),
|
||||
"updated_at": column.UpdatedAt.Format(time.RFC3339),
|
||||
}
|
||||
|
||||
event := bus.NewEvent(eventType, "task-service", data)
|
||||
if err := s.eventBus.Publish(ctx, eventType, event); err != nil {
|
||||
s.logger.Error("failed to publish column event",
|
||||
zap.String("event_type", eventType),
|
||||
zap.String("column_id", column.ID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) publishExecutorEvent(ctx context.Context, eventType string, executor *models.Executor) {
|
||||
if s.eventBus == nil || executor == nil {
|
||||
return
|
||||
@@ -2064,7 +2111,9 @@ func (s *Service) publishTurnEvent(eventType string, turn *models.Turn) {
|
||||
}))
|
||||
}
|
||||
|
||||
// publishMessageEvent publishes message events to the event bus
|
||||
// publishMessageEvent publishes message events to the event bus.
|
||||
// System-injected content (wrapped in <kandev-system> tags) is stripped from the content
|
||||
// so users don't see workflow step prompt modifications in the UI.
|
||||
func (s *Service) publishMessageEvent(ctx context.Context, eventType string, message *models.Message) {
|
||||
if s.eventBus == nil {
|
||||
s.logger.Warn("publishMessageEvent: eventBus is nil, skipping")
|
||||
@@ -2082,7 +2131,7 @@ func (s *Service) publishMessageEvent(ctx context.Context, eventType string, mes
|
||||
"task_id": message.TaskID,
|
||||
"author_type": string(message.AuthorType),
|
||||
"author_id": message.AuthorID,
|
||||
"content": message.Content,
|
||||
"content": models.StripSystemContent(message.Content),
|
||||
"type": messageType,
|
||||
"requests_input": message.RequestsInput,
|
||||
"created_at": message.CreatedAt.Format(time.RFC3339),
|
||||
|
||||
@@ -105,19 +105,17 @@ func TestService_CreateTask(t *testing.T) {
|
||||
svc, eventBus, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Create board and column first
|
||||
// Create board first
|
||||
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
|
||||
board := &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Test Board"}
|
||||
_ = repo.CreateBoard(ctx, board)
|
||||
column := &models.Column{ID: "col-123", BoardID: "board-123", Name: "To Do", State: v1.TaskStateTODO}
|
||||
_ = repo.CreateColumn(ctx, column)
|
||||
repository := &models.Repository{ID: "repo-123", WorkspaceID: "ws-1", Name: "Test Repo"}
|
||||
_ = repo.CreateRepository(ctx, repository)
|
||||
|
||||
req := &CreateTaskRequest{
|
||||
WorkspaceID: "ws-1",
|
||||
BoardID: "board-123",
|
||||
ColumnID: "col-123",
|
||||
WorkspaceID: "ws-1",
|
||||
BoardID: "board-123",
|
||||
WorkflowStepID: "step-123",
|
||||
Title: "Test Task",
|
||||
Description: "A test task",
|
||||
Priority: 5,
|
||||
@@ -246,8 +244,7 @@ func TestService_UpdateTask(t *testing.T) {
|
||||
|
||||
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
|
||||
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Board"})
|
||||
_ = repo.CreateColumn(ctx, &models.Column{ID: "col-123", BoardID: "board-123", Name: "Column", State: v1.TaskStateTODO})
|
||||
_ = repo.CreateTask(ctx, &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-123", Title: "Original"})
|
||||
_ = repo.CreateTask(ctx, &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Original"})
|
||||
eventBus.ClearEvents()
|
||||
|
||||
newTitle := "Updated Title"
|
||||
@@ -277,8 +274,7 @@ func TestService_DeleteTask(t *testing.T) {
|
||||
|
||||
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
|
||||
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Board"})
|
||||
_ = repo.CreateColumn(ctx, &models.Column{ID: "col-123", BoardID: "board-123", Name: "Column", State: v1.TaskStateTODO})
|
||||
_ = repo.CreateTask(ctx, &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-123", Title: "Test"})
|
||||
_ = repo.CreateTask(ctx, &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Test"})
|
||||
eventBus.ClearEvents()
|
||||
|
||||
err := svc.DeleteTask(ctx, "task-123")
|
||||
@@ -305,9 +301,8 @@ func TestService_ListTasks(t *testing.T) {
|
||||
|
||||
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
|
||||
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Board"})
|
||||
_ = repo.CreateColumn(ctx, &models.Column{ID: "col-123", BoardID: "board-123", Name: "Column", State: v1.TaskStateTODO})
|
||||
_ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-123", Title: "Task 1"})
|
||||
_ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-123", Title: "Task 2"})
|
||||
_ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Task 1"})
|
||||
_ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Task 2"})
|
||||
|
||||
tasks, err := svc.ListTasks(ctx, "board-123")
|
||||
if err != nil {
|
||||
@@ -324,8 +319,7 @@ func TestService_UpdateTaskState(t *testing.T) {
|
||||
|
||||
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
|
||||
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Board"})
|
||||
_ = repo.CreateColumn(ctx, &models.Column{ID: "col-123", BoardID: "board-123", Name: "Column", State: v1.TaskStateTODO})
|
||||
task := &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-123", Title: "Test", State: v1.TaskStateTODO}
|
||||
task := &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Test", State: v1.TaskStateTODO}
|
||||
_ = repo.CreateTask(ctx, task)
|
||||
eventBus.ClearEvents()
|
||||
|
||||
@@ -354,24 +348,16 @@ func TestService_MoveTask(t *testing.T) {
|
||||
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
|
||||
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Board"})
|
||||
|
||||
// Create source column
|
||||
_ = repo.CreateColumn(ctx, &models.Column{ID: "col-123", BoardID: "board-123", Name: "Todo", State: v1.TaskStateTODO})
|
||||
// Create destination column with different state
|
||||
_ = repo.CreateColumn(ctx, &models.Column{ID: "col-done", BoardID: "board-123", Name: "Done", State: v1.TaskStateCompleted})
|
||||
|
||||
task := &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-123", Title: "Test", State: v1.TaskStateTODO}
|
||||
task := &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-todo", Title: "Test", State: v1.TaskStateTODO}
|
||||
_ = repo.CreateTask(ctx, task)
|
||||
eventBus.ClearEvents()
|
||||
|
||||
moved, err := svc.MoveTask(ctx, "task-123", "board-123", "col-done", 0)
|
||||
moved, err := svc.MoveTask(ctx, "task-123", "board-123", "step-done", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to move task: %v", err)
|
||||
}
|
||||
if moved.ColumnID != "col-done" {
|
||||
t.Errorf("expected column 'col-done', got %s", moved.ColumnID)
|
||||
}
|
||||
if moved.State != v1.TaskStateCompleted {
|
||||
t.Errorf("expected state COMPLETED, got %s", moved.State)
|
||||
if moved.Task.WorkflowStepID != "step-done" {
|
||||
t.Errorf("expected workflow step 'step-done', got %s", moved.Task.WorkflowStepID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -472,70 +458,7 @@ func TestService_ListBoards(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Column tests
|
||||
|
||||
func TestService_CreateColumn(t *testing.T) {
|
||||
svc, _, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
|
||||
board := &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Test Board"}
|
||||
_ = repo.CreateBoard(ctx, board)
|
||||
|
||||
req := &CreateColumnRequest{
|
||||
BoardID: "board-123",
|
||||
Name: "To Do",
|
||||
Position: 0,
|
||||
State: v1.TaskStateTODO,
|
||||
}
|
||||
|
||||
column, err := svc.CreateColumn(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create column: %v", err)
|
||||
}
|
||||
if column.ID == "" {
|
||||
t.Error("expected column ID to be set")
|
||||
}
|
||||
if column.Name != "To Do" {
|
||||
t.Errorf("expected name 'To Do', got %s", column.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_GetColumn(t *testing.T) {
|
||||
svc, _, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
|
||||
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Board"})
|
||||
column := &models.Column{ID: "col-123", BoardID: "board-123", Name: "To Do"}
|
||||
_ = repo.CreateColumn(ctx, column)
|
||||
|
||||
retrieved, err := svc.GetColumn(ctx, "col-123")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get column: %v", err)
|
||||
}
|
||||
if retrieved.Name != "To Do" {
|
||||
t.Errorf("expected name 'To Do', got %s", retrieved.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ListColumns(t *testing.T) {
|
||||
svc, _, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
|
||||
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Board"})
|
||||
_ = repo.CreateColumn(ctx, &models.Column{ID: "col-1", BoardID: "board-123", Name: "To Do"})
|
||||
_ = repo.CreateColumn(ctx, &models.Column{ID: "col-2", BoardID: "board-123", Name: "Done"})
|
||||
|
||||
columns, err := svc.ListColumns(ctx, "board-123")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to list columns: %v", err)
|
||||
}
|
||||
if len(columns) != 2 {
|
||||
t.Errorf("expected 2 columns, got %d", len(columns))
|
||||
}
|
||||
}
|
||||
|
||||
// Message tests
|
||||
|
||||
@@ -544,8 +467,7 @@ func setupTestTask(t *testing.T, repo repository.Repository) {
|
||||
ctx := context.Background()
|
||||
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
|
||||
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Board"})
|
||||
_ = repo.CreateColumn(ctx, &models.Column{ID: "col-123", BoardID: "board-123", Name: "Column", State: v1.TaskStateTODO})
|
||||
_ = repo.CreateTask(ctx, &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task"})
|
||||
_ = repo.CreateTask(ctx, &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Test Task"})
|
||||
}
|
||||
|
||||
func setupTestSession(t *testing.T, repo repository.Repository) string {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/sqlite"
|
||||
"github.com/kandev/kandev/internal/user/models"
|
||||
)
|
||||
|
||||
@@ -60,40 +61,7 @@ func (r *sqliteRepository) initSchema() error {
|
||||
}
|
||||
|
||||
func (r *sqliteRepository) ensureUserSettingsColumn() error {
|
||||
exists, err := r.columnExists("users", "settings")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return nil
|
||||
}
|
||||
_, err = r.db.Exec(`ALTER TABLE users ADD COLUMN settings TEXT NOT NULL DEFAULT '{}'`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *sqliteRepository) columnExists(table, column string) (bool, error) {
|
||||
rows, err := r.db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer func() {
|
||||
_ = rows.Close()
|
||||
}()
|
||||
|
||||
for rows.Next() {
|
||||
var cid int
|
||||
var name, colType string
|
||||
var notNull int
|
||||
var defaultValue *string
|
||||
var pk int
|
||||
if err := rows.Scan(&cid, &name, &colType, ¬Null, &defaultValue, &pk); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if name == column {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, rows.Err()
|
||||
return sqlite.EnsureColumn(r.db, "users", "settings", "TEXT NOT NULL DEFAULT '{}'")
|
||||
}
|
||||
|
||||
func (r *sqliteRepository) ensureDefaultUser() error {
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/kandev/kandev/internal/workflow/models"
|
||||
"github.com/kandev/kandev/internal/workflow/service"
|
||||
)
|
||||
|
||||
// Controller handles workflow-related requests
|
||||
type Controller struct {
|
||||
svc *service.Service
|
||||
}
|
||||
|
||||
// NewController creates a new workflow controller
|
||||
func NewController(svc *service.Service) *Controller {
|
||||
return &Controller{svc: svc}
|
||||
}
|
||||
|
||||
// Template responses
|
||||
|
||||
type ListTemplatesResponse struct {
|
||||
Templates []*models.WorkflowTemplate `json:"templates"`
|
||||
}
|
||||
|
||||
type GetTemplateResponse struct {
|
||||
Template *models.WorkflowTemplate `json:"template"`
|
||||
}
|
||||
|
||||
func (c *Controller) ListTemplates(ctx context.Context) (*ListTemplatesResponse, error) {
|
||||
templates, err := c.svc.ListTemplates(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ListTemplatesResponse{Templates: templates}, nil
|
||||
}
|
||||
|
||||
func (c *Controller) GetTemplate(ctx context.Context, id string) (*GetTemplateResponse, error) {
|
||||
template, err := c.svc.GetTemplate(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &GetTemplateResponse{Template: template}, nil
|
||||
}
|
||||
|
||||
// Step responses
|
||||
|
||||
type ListStepsRequest struct {
|
||||
BoardID string `json:"board_id"`
|
||||
}
|
||||
|
||||
type ListStepsResponse struct {
|
||||
Steps []*models.WorkflowStep `json:"steps"`
|
||||
}
|
||||
|
||||
type GetStepResponse struct {
|
||||
Step *models.WorkflowStep `json:"step"`
|
||||
}
|
||||
|
||||
type CreateStepsFromTemplateRequest struct {
|
||||
BoardID string `json:"board_id"`
|
||||
TemplateID string `json:"template_id"`
|
||||
}
|
||||
|
||||
func (c *Controller) ListStepsByBoard(ctx context.Context, req ListStepsRequest) (*ListStepsResponse, error) {
|
||||
steps, err := c.svc.ListStepsByBoard(ctx, req.BoardID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ListStepsResponse{Steps: steps}, nil
|
||||
}
|
||||
|
||||
func (c *Controller) GetStep(ctx context.Context, id string) (*GetStepResponse, error) {
|
||||
step, err := c.svc.GetStep(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &GetStepResponse{Step: step}, nil
|
||||
}
|
||||
|
||||
func (c *Controller) CreateStepsFromTemplate(ctx context.Context, req CreateStepsFromTemplateRequest) error {
|
||||
return c.svc.CreateStepsFromTemplate(ctx, req.BoardID, req.TemplateID)
|
||||
}
|
||||
|
||||
// CreateStepRequest is the request for creating a single workflow step.
|
||||
type CreateStepRequest struct {
|
||||
BoardID string `json:"board_id"`
|
||||
Name string `json:"name"`
|
||||
StepType string `json:"step_type"`
|
||||
Position int `json:"position"`
|
||||
Color string `json:"color"`
|
||||
AutoStartAgent bool `json:"auto_start_agent"`
|
||||
PlanMode bool `json:"plan_mode"`
|
||||
RequireApproval bool `json:"require_approval"`
|
||||
PromptPrefix string `json:"prompt_prefix"`
|
||||
PromptSuffix string `json:"prompt_suffix"`
|
||||
AllowManualMove bool `json:"allow_manual_move"`
|
||||
}
|
||||
|
||||
// CreateStep creates a new workflow step.
|
||||
func (c *Controller) CreateStep(ctx context.Context, req CreateStepRequest) (*GetStepResponse, error) {
|
||||
step := &models.WorkflowStep{
|
||||
BoardID: req.BoardID,
|
||||
Name: req.Name,
|
||||
StepType: models.StepType(req.StepType),
|
||||
Position: req.Position,
|
||||
Color: req.Color,
|
||||
AutoStartAgent: req.AutoStartAgent,
|
||||
PlanMode: req.PlanMode,
|
||||
RequireApproval: req.RequireApproval,
|
||||
PromptPrefix: req.PromptPrefix,
|
||||
PromptSuffix: req.PromptSuffix,
|
||||
AllowManualMove: req.AllowManualMove,
|
||||
}
|
||||
if err := c.svc.CreateStep(ctx, step); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &GetStepResponse{Step: step}, nil
|
||||
}
|
||||
|
||||
// UpdateStepRequest is the request for updating a workflow step.
|
||||
type UpdateStepRequest struct {
|
||||
ID string `json:"id"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
StepType *string `json:"step_type,omitempty"`
|
||||
Position *int `json:"position,omitempty"`
|
||||
Color *string `json:"color,omitempty"`
|
||||
AutoStartAgent *bool `json:"auto_start_agent,omitempty"`
|
||||
PlanMode *bool `json:"plan_mode,omitempty"`
|
||||
RequireApproval *bool `json:"require_approval,omitempty"`
|
||||
PromptPrefix *string `json:"prompt_prefix,omitempty"`
|
||||
PromptSuffix *string `json:"prompt_suffix,omitempty"`
|
||||
AllowManualMove *bool `json:"allow_manual_move,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateStep updates an existing workflow step.
|
||||
func (c *Controller) UpdateStep(ctx context.Context, req UpdateStepRequest) (*GetStepResponse, error) {
|
||||
step, err := c.svc.GetStep(ctx, req.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.Name != nil {
|
||||
step.Name = *req.Name
|
||||
}
|
||||
if req.StepType != nil {
|
||||
step.StepType = models.StepType(*req.StepType)
|
||||
}
|
||||
if req.Position != nil {
|
||||
step.Position = *req.Position
|
||||
}
|
||||
if req.Color != nil {
|
||||
step.Color = *req.Color
|
||||
}
|
||||
if req.AutoStartAgent != nil {
|
||||
step.AutoStartAgent = *req.AutoStartAgent
|
||||
}
|
||||
if req.PlanMode != nil {
|
||||
step.PlanMode = *req.PlanMode
|
||||
}
|
||||
if req.RequireApproval != nil {
|
||||
step.RequireApproval = *req.RequireApproval
|
||||
}
|
||||
if req.PromptPrefix != nil {
|
||||
step.PromptPrefix = *req.PromptPrefix
|
||||
}
|
||||
if req.PromptSuffix != nil {
|
||||
step.PromptSuffix = *req.PromptSuffix
|
||||
}
|
||||
if req.AllowManualMove != nil {
|
||||
step.AllowManualMove = *req.AllowManualMove
|
||||
}
|
||||
if err := c.svc.UpdateStep(ctx, step); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &GetStepResponse{Step: step}, nil
|
||||
}
|
||||
|
||||
// DeleteStep deletes a workflow step.
|
||||
func (c *Controller) DeleteStep(ctx context.Context, id string) error {
|
||||
return c.svc.DeleteStep(ctx, id)
|
||||
}
|
||||
|
||||
// ReorderStepsRequest is the request for reordering workflow steps.
|
||||
type ReorderStepsRequest struct {
|
||||
BoardID string `json:"board_id"`
|
||||
StepIDs []string `json:"step_ids"`
|
||||
}
|
||||
|
||||
// ReorderSteps reorders workflow steps for a board.
|
||||
func (c *Controller) ReorderSteps(ctx context.Context, req ReorderStepsRequest) error {
|
||||
return c.svc.ReorderSteps(ctx, req.BoardID, req.StepIDs)
|
||||
}
|
||||
|
||||
// History responses
|
||||
|
||||
type ListHistoryRequest struct {
|
||||
SessionID string `json:"session_id"`
|
||||
}
|
||||
|
||||
type ListHistoryResponse struct {
|
||||
History []*models.SessionStepHistory `json:"history"`
|
||||
}
|
||||
|
||||
func (c *Controller) ListHistoryBySession(ctx context.Context, req ListHistoryRequest) (*ListHistoryResponse, error) {
|
||||
history, err := c.svc.ListHistoryBySession(ctx, req.SessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ListHistoryResponse{History: history}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/workflow/controller"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
)
|
||||
|
||||
// Handlers manages workflow HTTP and WebSocket handlers
|
||||
type Handlers struct {
|
||||
controller *controller.Controller
|
||||
logger *logger.Logger
|
||||
}
|
||||
|
||||
// NewHandlers creates new workflow handlers
|
||||
func NewHandlers(ctrl *controller.Controller, log *logger.Logger) *Handlers {
|
||||
return &Handlers{
|
||||
controller: ctrl,
|
||||
logger: log.WithFields(zap.String("component", "workflow-handlers")),
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers workflow HTTP and WebSocket handlers
|
||||
func RegisterRoutes(router *gin.Engine, dispatcher *ws.Dispatcher, ctrl *controller.Controller, log *logger.Logger) {
|
||||
handlers := NewHandlers(ctrl, log)
|
||||
handlers.registerHTTP(router)
|
||||
handlers.registerWS(dispatcher)
|
||||
}
|
||||
|
||||
func (h *Handlers) registerHTTP(router *gin.Engine) {
|
||||
api := router.Group("/api/v1")
|
||||
|
||||
// Template routes
|
||||
api.GET("/workflow/templates", h.httpListTemplates)
|
||||
api.GET("/workflow/templates/:id", h.httpGetTemplate)
|
||||
|
||||
// Step routes
|
||||
api.GET("/boards/:id/workflow/steps", h.httpListStepsByBoard)
|
||||
api.GET("/workflow/steps/:id", h.httpGetStep)
|
||||
api.POST("/boards/:id/workflow/steps", h.httpCreateStepsFromTemplate)
|
||||
api.POST("/workflow/steps", h.httpCreateStep)
|
||||
api.PUT("/workflow/steps/:id", h.httpUpdateStep)
|
||||
api.DELETE("/workflow/steps/:id", h.httpDeleteStep)
|
||||
api.PUT("/boards/:id/workflow/steps/reorder", h.httpReorderSteps)
|
||||
|
||||
// History routes
|
||||
api.GET("/sessions/:id/workflow/history", h.httpListHistoryBySession)
|
||||
}
|
||||
|
||||
func (h *Handlers) registerWS(dispatcher *ws.Dispatcher) {
|
||||
dispatcher.RegisterFunc(ws.ActionWorkflowTemplateList, h.wsListTemplates)
|
||||
dispatcher.RegisterFunc(ws.ActionWorkflowTemplateGet, h.wsGetTemplate)
|
||||
dispatcher.RegisterFunc(ws.ActionWorkflowStepList, h.wsListSteps)
|
||||
dispatcher.RegisterFunc(ws.ActionWorkflowStepGet, h.wsGetStep)
|
||||
dispatcher.RegisterFunc(ws.ActionWorkflowStepCreate, h.wsCreateStepsFromTemplate)
|
||||
dispatcher.RegisterFunc(ws.ActionWorkflowHistoryList, h.wsListHistory)
|
||||
}
|
||||
|
||||
// HTTP handlers - Templates
|
||||
|
||||
func (h *Handlers) httpListTemplates(c *gin.Context) {
|
||||
resp, err := h.controller.ListTemplates(c.Request.Context())
|
||||
if err != nil {
|
||||
h.logger.Error("failed to list templates", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list templates"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handlers) httpGetTemplate(c *gin.Context) {
|
||||
resp, err := h.controller.GetTemplate(c.Request.Context(), c.Param("id"))
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get template", zap.Error(err))
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// HTTP handlers - Steps
|
||||
|
||||
func (h *Handlers) httpListStepsByBoard(c *gin.Context) {
|
||||
resp, err := h.controller.ListStepsByBoard(c.Request.Context(), controller.ListStepsRequest{
|
||||
BoardID: c.Param("id"),
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("failed to list steps", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list steps"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (h *Handlers) httpGetStep(c *gin.Context) {
|
||||
resp, err := h.controller.GetStep(c.Request.Context(), c.Param("id"))
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get step", zap.Error(err))
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "Step not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
type httpCreateStepsRequest struct {
|
||||
TemplateID string `json:"template_id"`
|
||||
}
|
||||
|
||||
func (h *Handlers) httpCreateStepsFromTemplate(c *gin.Context) {
|
||||
var req httpCreateStepsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
|
||||
return
|
||||
}
|
||||
if err := h.controller.CreateStepsFromTemplate(c.Request.Context(), controller.CreateStepsFromTemplateRequest{
|
||||
BoardID: c.Param("id"),
|
||||
TemplateID: req.TemplateID,
|
||||
}); err != nil {
|
||||
h.logger.Error("failed to create steps from template", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create steps"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"success": true})
|
||||
}
|
||||
|
||||
func (h *Handlers) httpCreateStep(c *gin.Context) {
|
||||
var req controller.CreateStepRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if req.BoardID == "" || req.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "board_id and name are required"})
|
||||
return
|
||||
}
|
||||
resp, err := h.controller.CreateStep(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to create step", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create step"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, resp.Step)
|
||||
}
|
||||
|
||||
func (h *Handlers) httpUpdateStep(c *gin.Context) {
|
||||
var req controller.UpdateStepRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
|
||||
return
|
||||
}
|
||||
req.ID = c.Param("id")
|
||||
resp, err := h.controller.UpdateStep(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to update step", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update step"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp.Step)
|
||||
}
|
||||
|
||||
func (h *Handlers) httpDeleteStep(c *gin.Context) {
|
||||
if err := h.controller.DeleteStep(c.Request.Context(), c.Param("id")); err != nil {
|
||||
h.logger.Error("failed to delete step", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete step"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
type httpReorderStepsRequest struct {
|
||||
StepIDs []string `json:"step_ids"`
|
||||
}
|
||||
|
||||
func (h *Handlers) httpReorderSteps(c *gin.Context) {
|
||||
var req httpReorderStepsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if err := h.controller.ReorderSteps(c.Request.Context(), controller.ReorderStepsRequest{
|
||||
BoardID: c.Param("id"),
|
||||
StepIDs: req.StepIDs,
|
||||
}); err != nil {
|
||||
h.logger.Error("failed to reorder steps", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reorder steps"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// HTTP handlers - History
|
||||
|
||||
func (h *Handlers) httpListHistoryBySession(c *gin.Context) {
|
||||
resp, err := h.controller.ListHistoryBySession(c.Request.Context(), controller.ListHistoryRequest{
|
||||
SessionID: c.Param("id"),
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("failed to list history", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list history"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// WS handlers - Templates
|
||||
|
||||
func (h *Handlers) wsListTemplates(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
||||
resp, err := h.controller.ListTemplates(ctx)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to list templates", zap.Error(err))
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list templates", nil)
|
||||
}
|
||||
return ws.NewResponse(msg.ID, msg.Action, resp)
|
||||
}
|
||||
|
||||
type wsGetTemplateRequest struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func (h *Handlers) wsGetTemplate(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
||||
var req wsGetTemplateRequest
|
||||
if err := msg.ParsePayload(&req); err != nil {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
|
||||
}
|
||||
if req.ID == "" {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil)
|
||||
}
|
||||
resp, err := h.controller.GetTemplate(ctx, req.ID)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get template", zap.Error(err))
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Template not found", nil)
|
||||
}
|
||||
return ws.NewResponse(msg.ID, msg.Action, resp)
|
||||
}
|
||||
|
||||
// WS handlers - Steps
|
||||
|
||||
type wsListStepsRequest struct {
|
||||
BoardID string `json:"board_id"`
|
||||
}
|
||||
|
||||
func (h *Handlers) wsListSteps(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
||||
var req wsListStepsRequest
|
||||
if err := msg.ParsePayload(&req); err != nil {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
|
||||
}
|
||||
if req.BoardID == "" {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "board_id is required", nil)
|
||||
}
|
||||
resp, err := h.controller.ListStepsByBoard(ctx, controller.ListStepsRequest{BoardID: req.BoardID})
|
||||
if err != nil {
|
||||
h.logger.Error("failed to list steps", zap.Error(err))
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list steps", nil)
|
||||
}
|
||||
return ws.NewResponse(msg.ID, msg.Action, resp)
|
||||
}
|
||||
|
||||
type wsGetStepRequest struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func (h *Handlers) wsGetStep(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
||||
var req wsGetStepRequest
|
||||
if err := msg.ParsePayload(&req); err != nil {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
|
||||
}
|
||||
if req.ID == "" {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil)
|
||||
}
|
||||
resp, err := h.controller.GetStep(ctx, req.ID)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get step", zap.Error(err))
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Step not found", nil)
|
||||
}
|
||||
return ws.NewResponse(msg.ID, msg.Action, resp)
|
||||
}
|
||||
|
||||
type wsCreateStepsRequest struct {
|
||||
BoardID string `json:"board_id"`
|
||||
TemplateID string `json:"template_id"`
|
||||
}
|
||||
|
||||
func (h *Handlers) wsCreateStepsFromTemplate(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
||||
var req wsCreateStepsRequest
|
||||
if err := msg.ParsePayload(&req); err != nil {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
|
||||
}
|
||||
if req.BoardID == "" || req.TemplateID == "" {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "board_id and template_id are required", nil)
|
||||
}
|
||||
if err := h.controller.CreateStepsFromTemplate(ctx, controller.CreateStepsFromTemplateRequest{
|
||||
BoardID: req.BoardID,
|
||||
TemplateID: req.TemplateID,
|
||||
}); err != nil {
|
||||
h.logger.Error("failed to create steps", zap.Error(err))
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to create steps", nil)
|
||||
}
|
||||
return ws.NewResponse(msg.ID, msg.Action, map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
// WS handlers - History
|
||||
|
||||
type wsListHistoryRequest struct {
|
||||
SessionID string `json:"session_id"`
|
||||
}
|
||||
|
||||
func (h *Handlers) wsListHistory(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
||||
var req wsListHistoryRequest
|
||||
if err := msg.ParsePayload(&req); err != nil {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
|
||||
}
|
||||
if req.SessionID == "" {
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil)
|
||||
}
|
||||
resp, err := h.controller.ListHistoryBySession(ctx, controller.ListHistoryRequest{SessionID: req.SessionID})
|
||||
if err != nil {
|
||||
h.logger.Error("failed to list history", zap.Error(err))
|
||||
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list history", nil)
|
||||
}
|
||||
return ws.NewResponse(msg.ID, msg.Action, resp)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
v1 "github.com/kandev/kandev/pkg/api/v1"
|
||||
)
|
||||
|
||||
// StepType represents the semantic type of a workflow step
|
||||
type StepType string
|
||||
|
||||
const (
|
||||
StepTypeBacklog StepType = "backlog"
|
||||
StepTypePlanning StepType = "planning"
|
||||
StepTypeImplementation StepType = "implementation"
|
||||
StepTypeReview StepType = "review"
|
||||
StepTypeVerification StepType = "verification"
|
||||
StepTypeDone StepType = "done"
|
||||
StepTypeBlocked StepType = "blocked"
|
||||
)
|
||||
|
||||
// ReviewStatus represents the review state of a session
|
||||
type ReviewStatus string
|
||||
|
||||
const (
|
||||
ReviewStatusPending ReviewStatus = "pending"
|
||||
ReviewStatusChangesRequested ReviewStatus = "changes_requested"
|
||||
ReviewStatusApproved ReviewStatus = "approved"
|
||||
)
|
||||
|
||||
// WorkflowTemplate represents a pre-defined workflow type that boards can adopt
|
||||
type WorkflowTemplate struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
IsSystem bool `json:"is_system"`
|
||||
Steps []StepDefinition `json:"steps"` // JSON stored
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// StepDefinition represents a step in a workflow template (stored as JSON in WorkflowTemplate)
|
||||
type StepDefinition struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
StepType StepType `json:"step_type"`
|
||||
Position int `json:"position"`
|
||||
Color string `json:"color"`
|
||||
TaskState v1.TaskState `json:"task_state"`
|
||||
AutoStartAgent bool `json:"auto_start_agent"`
|
||||
PlanMode bool `json:"plan_mode"`
|
||||
RequireApproval bool `json:"require_approval"`
|
||||
PromptPrefix string `json:"prompt_prefix,omitempty"`
|
||||
PromptSuffix string `json:"prompt_suffix,omitempty"`
|
||||
OnCompleteStepID string `json:"on_complete_step_id,omitempty"`
|
||||
OnApprovalStepID string `json:"on_approval_step_id,omitempty"`
|
||||
AllowManualMove bool `json:"allow_manual_move"`
|
||||
}
|
||||
|
||||
// WorkflowStep represents a step in a board's workflow (replaces Column)
|
||||
type WorkflowStep struct {
|
||||
ID string `json:"id"`
|
||||
BoardID string `json:"board_id"`
|
||||
Name string `json:"name"`
|
||||
StepType StepType `json:"step_type"`
|
||||
Position int `json:"position"`
|
||||
Color string `json:"color"`
|
||||
TaskState v1.TaskState `json:"task_state"`
|
||||
AutoStartAgent bool `json:"auto_start_agent"`
|
||||
PlanMode bool `json:"plan_mode"`
|
||||
RequireApproval bool `json:"require_approval"`
|
||||
PromptPrefix string `json:"prompt_prefix,omitempty"`
|
||||
PromptSuffix string `json:"prompt_suffix,omitempty"`
|
||||
OnCompleteStepID *string `json:"on_complete_step_id,omitempty"`
|
||||
OnApprovalStepID *string `json:"on_approval_step_id,omitempty"`
|
||||
AllowManualMove bool `json:"allow_manual_move"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// StepTransitionTrigger represents how a session moved between steps
|
||||
type StepTransitionTrigger string
|
||||
|
||||
const (
|
||||
StepTransitionTriggerManual StepTransitionTrigger = "manual"
|
||||
StepTransitionTriggerAutoComplete StepTransitionTrigger = "auto_complete"
|
||||
StepTransitionTriggerApproval StepTransitionTrigger = "approval"
|
||||
)
|
||||
|
||||
// SessionStepHistory represents an audit trail entry for session step transitions
|
||||
type SessionStepHistory struct {
|
||||
ID int64 `json:"id"`
|
||||
SessionID string `json:"session_id"`
|
||||
FromStepID *string `json:"from_step_id,omitempty"`
|
||||
ToStepID string `json:"to_step_id"`
|
||||
Trigger StepTransitionTrigger `json:"trigger"`
|
||||
ActorID *string `json:"actor_id,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// Package workflow provides workflow management functionality.
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/workflow/repository"
|
||||
"github.com/kandev/kandev/internal/workflow/service"
|
||||
)
|
||||
|
||||
// Provide creates the workflow repository and service using the shared database connection.
|
||||
func Provide(db *sql.DB, log *logger.Logger) (*repository.Repository, *service.Service, func() error, error) {
|
||||
repo, err := repository.NewWithDB(db)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
svc := service.NewService(repo, log)
|
||||
return repo, svc, func() error { return nil }, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,875 @@
|
||||
// Package repository provides SQLite-based repository implementations for workflow entities.
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/sqlite"
|
||||
"github.com/kandev/kandev/internal/workflow/models"
|
||||
)
|
||||
|
||||
// Repository provides SQLite-based workflow storage operations.
|
||||
type Repository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewWithDB creates a new SQLite repository with an existing database connection.
|
||||
func NewWithDB(db *sql.DB) (*Repository, error) {
|
||||
repo := &Repository{db: db}
|
||||
if err := repo.initSchema(); err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize workflow schema: %w", err)
|
||||
}
|
||||
return repo, nil
|
||||
}
|
||||
|
||||
// DB returns the underlying sql.DB instance for shared access.
|
||||
func (r *Repository) DB() *sql.DB {
|
||||
return r.db
|
||||
}
|
||||
|
||||
// ensureColumn adds a column to a table if it doesn't exist.
|
||||
func (r *Repository) ensureColumn(table, column, definition string) error {
|
||||
return sqlite.EnsureColumn(r.db, table, column, definition)
|
||||
}
|
||||
|
||||
// initSchema creates the database tables if they don't exist.
|
||||
func (r *Repository) initSchema() error {
|
||||
// Create workflow_templates table
|
||||
templatesSchema := `
|
||||
CREATE TABLE IF NOT EXISTS workflow_templates (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
is_system INTEGER DEFAULT 0,
|
||||
steps TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL
|
||||
);
|
||||
`
|
||||
if _, err := r.db.Exec(templatesSchema); err != nil {
|
||||
return fmt.Errorf("failed to create workflow_templates table: %w", err)
|
||||
}
|
||||
|
||||
// Create workflow_steps table
|
||||
stepsSchema := `
|
||||
CREATE TABLE IF NOT EXISTS workflow_steps (
|
||||
id TEXT PRIMARY KEY,
|
||||
board_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
step_type TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
color TEXT,
|
||||
auto_start_agent INTEGER DEFAULT 0,
|
||||
plan_mode INTEGER DEFAULT 0,
|
||||
require_approval INTEGER DEFAULT 0,
|
||||
prompt_prefix TEXT,
|
||||
prompt_suffix TEXT,
|
||||
on_complete_step_id TEXT,
|
||||
on_approval_step_id TEXT,
|
||||
allow_manual_move INTEGER DEFAULT 1,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
FOREIGN KEY (board_id) REFERENCES boards(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_steps_board ON workflow_steps(board_id);
|
||||
`
|
||||
if _, err := r.db.Exec(stepsSchema); err != nil {
|
||||
return fmt.Errorf("failed to create workflow_steps table: %w", err)
|
||||
}
|
||||
|
||||
// Create session_step_history table
|
||||
historySchema := `
|
||||
CREATE TABLE IF NOT EXISTS session_step_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
from_step_id TEXT,
|
||||
to_step_id TEXT NOT NULL,
|
||||
trigger TEXT NOT NULL,
|
||||
actor_id TEXT,
|
||||
metadata TEXT,
|
||||
created_at DATETIME NOT NULL,
|
||||
FOREIGN KEY (session_id) REFERENCES task_sessions(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_session_step_history_session ON session_step_history(session_id);
|
||||
`
|
||||
if _, err := r.db.Exec(historySchema); err != nil {
|
||||
return fmt.Errorf("failed to create session_step_history table: %w", err)
|
||||
}
|
||||
|
||||
// Run migrations for existing tables
|
||||
if err := r.runMigrations(); err != nil {
|
||||
return fmt.Errorf("failed to run migrations: %w", err)
|
||||
}
|
||||
|
||||
// Seed system templates
|
||||
if err := r.seedSystemTemplates(); err != nil {
|
||||
return fmt.Errorf("failed to seed system templates: %w", err)
|
||||
}
|
||||
|
||||
// Seed default workflow steps for boards that don't have any
|
||||
if err := r.seedDefaultWorkflowSteps(); err != nil {
|
||||
return fmt.Errorf("failed to seed default workflow steps: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// runMigrations adds columns to existing tables.
|
||||
func (r *Repository) runMigrations() error {
|
||||
// Add workflow_template_id to boards table
|
||||
if err := r.ensureColumn("boards", "workflow_template_id", "TEXT"); err != nil {
|
||||
return fmt.Errorf("failed to add workflow_template_id to boards: %w", err)
|
||||
}
|
||||
|
||||
// Add workflow-related columns to task_sessions table
|
||||
if err := r.ensureColumn("task_sessions", "is_primary", "INTEGER DEFAULT 0"); err != nil {
|
||||
return fmt.Errorf("failed to add is_primary to task_sessions: %w", err)
|
||||
}
|
||||
if err := r.ensureColumn("task_sessions", "workflow_step_id", "TEXT"); err != nil {
|
||||
return fmt.Errorf("failed to add workflow_step_id to task_sessions: %w", err)
|
||||
}
|
||||
if err := r.ensureColumn("task_sessions", "review_status", "TEXT"); err != nil {
|
||||
return fmt.Errorf("failed to add review_status to task_sessions: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// seedDefaultWorkflowSteps creates default workflow steps for boards that don't have any.
|
||||
func (r *Repository) seedDefaultWorkflowSteps() error {
|
||||
// Find boards without workflow steps
|
||||
rows, err := r.db.Query(`
|
||||
SELECT b.id FROM boards b
|
||||
LEFT JOIN workflow_steps ws ON ws.board_id = b.id
|
||||
WHERE ws.id IS NULL
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var boardIDs []string
|
||||
for rows.Next() {
|
||||
var boardID string
|
||||
if err := rows.Scan(&boardID); err != nil {
|
||||
return err
|
||||
}
|
||||
boardIDs = append(boardIDs, boardID)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create default workflow steps for each board
|
||||
now := time.Now()
|
||||
type stepSeed struct {
|
||||
name string
|
||||
position int
|
||||
stepType string
|
||||
color string
|
||||
}
|
||||
defaultSteps := []stepSeed{
|
||||
{name: "Todo", position: 0, stepType: "backlog", color: "bg-neutral-400"},
|
||||
{name: "In Progress", position: 1, stepType: "implementation", color: "bg-blue-500"},
|
||||
{name: "Review", position: 2, stepType: "review", color: "bg-yellow-500"},
|
||||
{name: "Done", position: 3, stepType: "done", color: "bg-green-500"},
|
||||
}
|
||||
|
||||
for _, boardID := range boardIDs {
|
||||
for _, step := range defaultSteps {
|
||||
if _, err := r.db.Exec(`
|
||||
INSERT INTO workflow_steps (id, board_id, name, step_type, position, color, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, uuid.New().String(), boardID, step.name, step.stepType, step.position, step.color, now, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// seedSystemTemplates inserts the default system workflow templates.
|
||||
func (r *Repository) seedSystemTemplates() error {
|
||||
templates := r.getSystemTemplates()
|
||||
|
||||
for _, tmpl := range templates {
|
||||
// Check if template already exists
|
||||
var exists bool
|
||||
err := r.db.QueryRow("SELECT 1 FROM workflow_templates WHERE id = ?", tmpl.ID).Scan(&exists)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
|
||||
stepsJSON, err := json.Marshal(tmpl.Steps)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal steps for template %s: %w", tmpl.ID, err)
|
||||
}
|
||||
|
||||
_, err = r.db.Exec(`
|
||||
INSERT INTO workflow_templates (id, name, description, is_system, steps, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, tmpl.ID, tmpl.Name, tmpl.Description, sqlite.BoolToInt(tmpl.IsSystem), string(stepsJSON), tmpl.CreatedAt, tmpl.UpdatedAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to insert template %s: %w", tmpl.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getSystemTemplates returns the predefined system workflow templates.
|
||||
func (r *Repository) getSystemTemplates() []*models.WorkflowTemplate {
|
||||
now := time.Now().UTC()
|
||||
return []*models.WorkflowTemplate{
|
||||
r.getDevTemplate(now),
|
||||
r.getArchitectureTemplate(now),
|
||||
r.getSimpleTemplate(now),
|
||||
}
|
||||
}
|
||||
|
||||
// getDevTemplate returns the full development workflow template.
|
||||
func (r *Repository) getDevTemplate(now time.Time) *models.WorkflowTemplate {
|
||||
return &models.WorkflowTemplate{
|
||||
ID: "dev",
|
||||
Name: "Development",
|
||||
Description: "Standard software development workflow with planning phase",
|
||||
IsSystem: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Steps: []models.StepDefinition{
|
||||
{
|
||||
ID: "backlog",
|
||||
Name: "Backlog",
|
||||
StepType: models.StepTypeBacklog,
|
||||
Position: 0,
|
||||
Color: "bg-neutral-400",
|
||||
TaskState: "TODO",
|
||||
AutoStartAgent: false,
|
||||
AllowManualMove: true,
|
||||
},
|
||||
{
|
||||
ID: "planning",
|
||||
Name: "Planning",
|
||||
StepType: models.StepTypePlanning,
|
||||
Position: 1,
|
||||
Color: "bg-purple-500",
|
||||
TaskState: "IN_PROGRESS",
|
||||
AutoStartAgent: true,
|
||||
PlanMode: true,
|
||||
PromptPrefix: "[PLANNING PHASE]\nAnalyze this task and create a detailed implementation plan.\nDo NOT make any code changes yet - only analyze and plan.\n\nCreate a plan that includes:\n1. Understanding of the requirements\n2. Files that need to be modified or created\n3. Step-by-step implementation approach\n4. Potential risks or considerations\n\nIMPORTANT: Save your plan to ONLY this file: `.kandev/plans/{task_id}.md`\nDo not create any other files during this phase - only the plan file above.",
|
||||
OnCompleteStepID: "review-plan",
|
||||
AllowManualMove: true,
|
||||
},
|
||||
{
|
||||
ID: "review-plan",
|
||||
Name: "Review Plan",
|
||||
StepType: models.StepTypeReview,
|
||||
Position: 2,
|
||||
Color: "bg-yellow-500",
|
||||
TaskState: "REVIEW",
|
||||
AutoStartAgent: false,
|
||||
RequireApproval: true,
|
||||
OnApprovalStepID: "implementation",
|
||||
AllowManualMove: true,
|
||||
},
|
||||
{
|
||||
ID: "implementation",
|
||||
Name: "Implementation",
|
||||
StepType: models.StepTypeImplementation,
|
||||
Position: 3,
|
||||
Color: "bg-blue-500",
|
||||
TaskState: "IN_PROGRESS",
|
||||
AutoStartAgent: true,
|
||||
PromptPrefix: "[IMPLEMENTATION PHASE]\nRead the plan from `.kandev/plans/{task_id}.md` and implement the task according to the plan.\nFollow the plan step by step.",
|
||||
OnCompleteStepID: "review-code",
|
||||
AllowManualMove: true,
|
||||
},
|
||||
{
|
||||
ID: "review-code",
|
||||
Name: "Code Review",
|
||||
StepType: models.StepTypeReview,
|
||||
Position: 4,
|
||||
Color: "bg-orange-500",
|
||||
TaskState: "REVIEW",
|
||||
AutoStartAgent: false,
|
||||
RequireApproval: true,
|
||||
OnApprovalStepID: "done",
|
||||
AllowManualMove: true,
|
||||
},
|
||||
{
|
||||
ID: "done",
|
||||
Name: "Done",
|
||||
StepType: models.StepTypeDone,
|
||||
Position: 5,
|
||||
Color: "bg-green-500",
|
||||
TaskState: "COMPLETED",
|
||||
AutoStartAgent: false,
|
||||
AllowManualMove: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// getArchitectureTemplate returns the architecture workflow template.
|
||||
func (r *Repository) getArchitectureTemplate(now time.Time) *models.WorkflowTemplate {
|
||||
return &models.WorkflowTemplate{
|
||||
ID: "architecture",
|
||||
Name: "Architecture",
|
||||
Description: "Architecture focused: Backlog → Planning → Review → Done",
|
||||
IsSystem: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Steps: []models.StepDefinition{
|
||||
{
|
||||
ID: "backlog",
|
||||
Name: "Ideas",
|
||||
StepType: models.StepTypeBacklog,
|
||||
Position: 0,
|
||||
Color: "bg-neutral-400",
|
||||
TaskState: "TODO",
|
||||
AutoStartAgent: false,
|
||||
AllowManualMove: true,
|
||||
},
|
||||
{
|
||||
ID: "planning",
|
||||
Name: "Planning",
|
||||
StepType: models.StepTypePlanning,
|
||||
Position: 1,
|
||||
Color: "bg-purple-500",
|
||||
TaskState: "IN_PROGRESS",
|
||||
AutoStartAgent: true,
|
||||
PlanMode: true,
|
||||
PromptPrefix: "[ARCHITECTURE PHASE]\nAnalyze and design the architecture for this task.\n\nIMPORTANT: Document your design in ONLY this file: `.kandev/designs/{task_id}.md`\nDo not create any other files during this phase - only the design file above.",
|
||||
OnCompleteStepID: "review",
|
||||
AllowManualMove: true,
|
||||
},
|
||||
{
|
||||
ID: "review",
|
||||
Name: "Review",
|
||||
StepType: models.StepTypeReview,
|
||||
Position: 2,
|
||||
Color: "bg-yellow-500",
|
||||
TaskState: "REVIEW",
|
||||
AutoStartAgent: false,
|
||||
RequireApproval: true,
|
||||
OnApprovalStepID: "done",
|
||||
AllowManualMove: true,
|
||||
},
|
||||
{
|
||||
ID: "done",
|
||||
Name: "Approved",
|
||||
StepType: models.StepTypeDone,
|
||||
Position: 3,
|
||||
Color: "bg-green-500",
|
||||
TaskState: "COMPLETED",
|
||||
AutoStartAgent: false,
|
||||
AllowManualMove: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// getSimpleTemplate returns the simple workflow template.
|
||||
func (r *Repository) getSimpleTemplate(now time.Time) *models.WorkflowTemplate {
|
||||
return &models.WorkflowTemplate{
|
||||
ID: "simple",
|
||||
Name: "Simple",
|
||||
Description: "Minimal workflow: Backlog → In Progress → Done",
|
||||
IsSystem: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Steps: []models.StepDefinition{
|
||||
{
|
||||
ID: "backlog",
|
||||
Name: "Backlog",
|
||||
StepType: models.StepTypeBacklog,
|
||||
Position: 0,
|
||||
Color: "bg-neutral-400",
|
||||
TaskState: "TODO",
|
||||
AutoStartAgent: false,
|
||||
AllowManualMove: true,
|
||||
},
|
||||
{
|
||||
ID: "in-progress",
|
||||
Name: "In Progress",
|
||||
StepType: models.StepTypeImplementation,
|
||||
Position: 1,
|
||||
Color: "bg-blue-500",
|
||||
TaskState: "IN_PROGRESS",
|
||||
AutoStartAgent: true,
|
||||
OnCompleteStepID: "done",
|
||||
AllowManualMove: true,
|
||||
},
|
||||
{
|
||||
ID: "done",
|
||||
Name: "Done",
|
||||
StepType: models.StepTypeDone,
|
||||
Position: 2,
|
||||
Color: "bg-green-500",
|
||||
TaskState: "COMPLETED",
|
||||
AutoStartAgent: false,
|
||||
AllowManualMove: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ============================================================================
|
||||
// WorkflowTemplate CRUD Operations
|
||||
// ============================================================================
|
||||
|
||||
// CreateTemplate creates a new workflow template.
|
||||
func (r *Repository) CreateTemplate(ctx context.Context, template *models.WorkflowTemplate) error {
|
||||
if template.ID == "" {
|
||||
template.ID = uuid.New().String()
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
template.CreatedAt = now
|
||||
template.UpdatedAt = now
|
||||
|
||||
stepsJSON, err := json.Marshal(template.Steps)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal steps: %w", err)
|
||||
}
|
||||
|
||||
_, err = r.db.ExecContext(ctx, `
|
||||
INSERT INTO workflow_templates (id, name, description, is_system, steps, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, template.ID, template.Name, template.Description, sqlite.BoolToInt(template.IsSystem), string(stepsJSON), template.CreatedAt, template.UpdatedAt)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// GetTemplate retrieves a workflow template by ID.
|
||||
func (r *Repository) GetTemplate(ctx context.Context, id string) (*models.WorkflowTemplate, error) {
|
||||
template := &models.WorkflowTemplate{}
|
||||
var stepsJSON string
|
||||
var isSystem int
|
||||
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, name, description, is_system, steps, created_at, updated_at
|
||||
FROM workflow_templates WHERE id = ?
|
||||
`, id).Scan(&template.ID, &template.Name, &template.Description, &isSystem, &stepsJSON, &template.CreatedAt, &template.UpdatedAt)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("workflow template not found: %s", id)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
template.IsSystem = isSystem == 1
|
||||
if err := json.Unmarshal([]byte(stepsJSON), &template.Steps); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal steps: %w", err)
|
||||
}
|
||||
|
||||
return template, nil
|
||||
}
|
||||
|
||||
// UpdateTemplate updates an existing workflow template.
|
||||
func (r *Repository) UpdateTemplate(ctx context.Context, template *models.WorkflowTemplate) error {
|
||||
template.UpdatedAt = time.Now().UTC()
|
||||
|
||||
stepsJSON, err := json.Marshal(template.Steps)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal steps: %w", err)
|
||||
}
|
||||
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE workflow_templates SET name = ?, description = ?, steps = ?, updated_at = ?
|
||||
WHERE id = ? AND is_system = 0
|
||||
`, template.Name, template.Description, string(stepsJSON), template.UpdatedAt, template.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("workflow template not found or is a system template: %s", template.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteTemplate deletes a workflow template by ID.
|
||||
func (r *Repository) DeleteTemplate(ctx context.Context, id string) error {
|
||||
result, err := r.db.ExecContext(ctx, `DELETE FROM workflow_templates WHERE id = ? AND is_system = 0`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("workflow template not found or is a system template: %s", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListTemplates returns all workflow templates.
|
||||
func (r *Repository) ListTemplates(ctx context.Context) ([]*models.WorkflowTemplate, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, name, description, is_system, steps, created_at, updated_at
|
||||
FROM workflow_templates ORDER BY is_system DESC, name ASC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var result []*models.WorkflowTemplate
|
||||
for rows.Next() {
|
||||
template := &models.WorkflowTemplate{}
|
||||
var stepsJSON string
|
||||
var isSystem int
|
||||
|
||||
err := rows.Scan(&template.ID, &template.Name, &template.Description, &isSystem, &stepsJSON, &template.CreatedAt, &template.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
template.IsSystem = isSystem == 1
|
||||
if err := json.Unmarshal([]byte(stepsJSON), &template.Steps); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal steps for template %s: %w", template.ID, err)
|
||||
}
|
||||
|
||||
result = append(result, template)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// GetSystemTemplates returns only system workflow templates.
|
||||
func (r *Repository) GetSystemTemplates(ctx context.Context) ([]*models.WorkflowTemplate, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, name, description, is_system, steps, created_at, updated_at
|
||||
FROM workflow_templates WHERE is_system = 1 ORDER BY name ASC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var result []*models.WorkflowTemplate
|
||||
for rows.Next() {
|
||||
template := &models.WorkflowTemplate{}
|
||||
var stepsJSON string
|
||||
var isSystem int
|
||||
|
||||
err := rows.Scan(&template.ID, &template.Name, &template.Description, &isSystem, &stepsJSON, &template.CreatedAt, &template.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
template.IsSystem = isSystem == 1
|
||||
if err := json.Unmarshal([]byte(stepsJSON), &template.Steps); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal steps for template %s: %w", template.ID, err)
|
||||
}
|
||||
|
||||
result = append(result, template)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WorkflowStep CRUD Operations
|
||||
// ============================================================================
|
||||
|
||||
// CreateStep creates a new workflow step.
|
||||
func (r *Repository) CreateStep(ctx context.Context, step *models.WorkflowStep) error {
|
||||
if step.ID == "" {
|
||||
step.ID = uuid.New().String()
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
step.CreatedAt = now
|
||||
step.UpdatedAt = now
|
||||
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO workflow_steps (
|
||||
id, board_id, name, step_type, position, color,
|
||||
auto_start_agent, plan_mode, require_approval,
|
||||
prompt_prefix, prompt_suffix, on_complete_step_id, on_approval_step_id,
|
||||
allow_manual_move, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, step.ID, step.BoardID, step.Name, step.StepType, step.Position, step.Color,
|
||||
sqlite.BoolToInt(step.AutoStartAgent), sqlite.BoolToInt(step.PlanMode), sqlite.BoolToInt(step.RequireApproval),
|
||||
step.PromptPrefix, step.PromptSuffix, step.OnCompleteStepID, step.OnApprovalStepID,
|
||||
sqlite.BoolToInt(step.AllowManualMove), step.CreatedAt, step.UpdatedAt)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// GetStep retrieves a workflow step by ID.
|
||||
func (r *Repository) GetStep(ctx context.Context, id string) (*models.WorkflowStep, error) {
|
||||
step := &models.WorkflowStep{}
|
||||
var autoStartAgent, planMode, requireApproval, allowManualMove int
|
||||
var onCompleteStepID, onApprovalStepID, promptPrefix, promptSuffix, color sql.NullString
|
||||
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, board_id, name, step_type, position, color,
|
||||
auto_start_agent, plan_mode, require_approval,
|
||||
prompt_prefix, prompt_suffix, on_complete_step_id, on_approval_step_id,
|
||||
allow_manual_move, created_at, updated_at
|
||||
FROM workflow_steps WHERE id = ?
|
||||
`, id).Scan(&step.ID, &step.BoardID, &step.Name, &step.StepType, &step.Position, &color,
|
||||
&autoStartAgent, &planMode, &requireApproval,
|
||||
&promptPrefix, &promptSuffix, &onCompleteStepID, &onApprovalStepID,
|
||||
&allowManualMove, &step.CreatedAt, &step.UpdatedAt)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("workflow step not found: %s", id)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
step.AutoStartAgent = autoStartAgent == 1
|
||||
step.PlanMode = planMode == 1
|
||||
step.RequireApproval = requireApproval == 1
|
||||
step.AllowManualMove = allowManualMove == 1
|
||||
if color.Valid {
|
||||
step.Color = color.String
|
||||
}
|
||||
if promptPrefix.Valid {
|
||||
step.PromptPrefix = promptPrefix.String
|
||||
}
|
||||
if promptSuffix.Valid {
|
||||
step.PromptSuffix = promptSuffix.String
|
||||
}
|
||||
if onCompleteStepID.Valid {
|
||||
step.OnCompleteStepID = &onCompleteStepID.String
|
||||
}
|
||||
if onApprovalStepID.Valid {
|
||||
step.OnApprovalStepID = &onApprovalStepID.String
|
||||
}
|
||||
|
||||
return step, nil
|
||||
}
|
||||
|
||||
// UpdateStep updates an existing workflow step.
|
||||
func (r *Repository) UpdateStep(ctx context.Context, step *models.WorkflowStep) error {
|
||||
step.UpdatedAt = time.Now().UTC()
|
||||
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE workflow_steps SET
|
||||
name = ?, step_type = ?, position = ?, color = ?,
|
||||
auto_start_agent = ?, plan_mode = ?, require_approval = ?,
|
||||
prompt_prefix = ?, prompt_suffix = ?, on_complete_step_id = ?, on_approval_step_id = ?,
|
||||
allow_manual_move = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`, step.Name, step.StepType, step.Position, step.Color,
|
||||
sqlite.BoolToInt(step.AutoStartAgent), sqlite.BoolToInt(step.PlanMode), sqlite.BoolToInt(step.RequireApproval),
|
||||
step.PromptPrefix, step.PromptSuffix, step.OnCompleteStepID, step.OnApprovalStepID,
|
||||
sqlite.BoolToInt(step.AllowManualMove), step.UpdatedAt, step.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("workflow step not found: %s", step.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteStep deletes a workflow step by ID.
|
||||
func (r *Repository) DeleteStep(ctx context.Context, id string) error {
|
||||
result, err := r.db.ExecContext(ctx, `DELETE FROM workflow_steps WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("workflow step not found: %s", id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListStepsByBoard returns all workflow steps for a board.
|
||||
func (r *Repository) ListStepsByBoard(ctx context.Context, boardID string) ([]*models.WorkflowStep, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, board_id, name, step_type, position, color,
|
||||
auto_start_agent, plan_mode, require_approval,
|
||||
prompt_prefix, prompt_suffix, on_complete_step_id, on_approval_step_id,
|
||||
allow_manual_move, created_at, updated_at
|
||||
FROM workflow_steps WHERE board_id = ? ORDER BY position
|
||||
`, boardID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
return r.scanSteps(rows)
|
||||
}
|
||||
|
||||
// GetStepByBoardAndType retrieves a workflow step by board ID and step type.
|
||||
func (r *Repository) GetStepByBoardAndType(ctx context.Context, boardID string, stepType models.StepType) (*models.WorkflowStep, error) {
|
||||
step := &models.WorkflowStep{}
|
||||
var autoStartAgent, planMode, requireApproval, allowManualMove int
|
||||
var onCompleteStepID, onApprovalStepID, promptPrefix, promptSuffix, color sql.NullString
|
||||
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT id, board_id, name, step_type, position, color,
|
||||
auto_start_agent, plan_mode, require_approval,
|
||||
prompt_prefix, prompt_suffix, on_complete_step_id, on_approval_step_id,
|
||||
allow_manual_move, created_at, updated_at
|
||||
FROM workflow_steps WHERE board_id = ? AND step_type = ?
|
||||
`, boardID, stepType).Scan(&step.ID, &step.BoardID, &step.Name, &step.StepType, &step.Position, &color,
|
||||
&autoStartAgent, &planMode, &requireApproval,
|
||||
&promptPrefix, &promptSuffix, &onCompleteStepID, &onApprovalStepID,
|
||||
&allowManualMove, &step.CreatedAt, &step.UpdatedAt)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("workflow step not found for board %s with type %s", boardID, stepType)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
step.AutoStartAgent = autoStartAgent == 1
|
||||
step.PlanMode = planMode == 1
|
||||
step.RequireApproval = requireApproval == 1
|
||||
step.AllowManualMove = allowManualMove == 1
|
||||
if color.Valid {
|
||||
step.Color = color.String
|
||||
}
|
||||
if promptPrefix.Valid {
|
||||
step.PromptPrefix = promptPrefix.String
|
||||
}
|
||||
if promptSuffix.Valid {
|
||||
step.PromptSuffix = promptSuffix.String
|
||||
}
|
||||
if onCompleteStepID.Valid {
|
||||
step.OnCompleteStepID = &onCompleteStepID.String
|
||||
}
|
||||
if onApprovalStepID.Valid {
|
||||
step.OnApprovalStepID = &onApprovalStepID.String
|
||||
}
|
||||
|
||||
return step, nil
|
||||
}
|
||||
|
||||
// scanSteps is a helper to scan multiple workflow step rows.
|
||||
func (r *Repository) scanSteps(rows *sql.Rows) ([]*models.WorkflowStep, error) {
|
||||
var result []*models.WorkflowStep
|
||||
for rows.Next() {
|
||||
step := &models.WorkflowStep{}
|
||||
var autoStartAgent, planMode, requireApproval, allowManualMove int
|
||||
var onCompleteStepID, onApprovalStepID, promptPrefix, promptSuffix, color sql.NullString
|
||||
|
||||
err := rows.Scan(&step.ID, &step.BoardID, &step.Name, &step.StepType, &step.Position, &color,
|
||||
&autoStartAgent, &planMode, &requireApproval,
|
||||
&promptPrefix, &promptSuffix, &onCompleteStepID, &onApprovalStepID,
|
||||
&allowManualMove, &step.CreatedAt, &step.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
step.AutoStartAgent = autoStartAgent == 1
|
||||
step.PlanMode = planMode == 1
|
||||
step.RequireApproval = requireApproval == 1
|
||||
step.AllowManualMove = allowManualMove == 1
|
||||
if color.Valid {
|
||||
step.Color = color.String
|
||||
}
|
||||
if promptPrefix.Valid {
|
||||
step.PromptPrefix = promptPrefix.String
|
||||
}
|
||||
if promptSuffix.Valid {
|
||||
step.PromptSuffix = promptSuffix.String
|
||||
}
|
||||
if onCompleteStepID.Valid {
|
||||
step.OnCompleteStepID = &onCompleteStepID.String
|
||||
}
|
||||
if onApprovalStepID.Valid {
|
||||
step.OnApprovalStepID = &onApprovalStepID.String
|
||||
}
|
||||
|
||||
result = append(result, step)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SessionStepHistory Operations
|
||||
// ============================================================================
|
||||
|
||||
// CreateHistory creates a new session step history entry.
|
||||
func (r *Repository) CreateHistory(ctx context.Context, history *models.SessionStepHistory) error {
|
||||
now := time.Now().UTC()
|
||||
history.CreatedAt = now
|
||||
|
||||
var metadataJSON *string
|
||||
if history.Metadata != nil {
|
||||
data, err := json.Marshal(history.Metadata)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal metadata: %w", err)
|
||||
}
|
||||
s := string(data)
|
||||
metadataJSON = &s
|
||||
}
|
||||
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO session_step_history (session_id, from_step_id, to_step_id, trigger, actor_id, metadata, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`, history.SessionID, history.FromStepID, history.ToStepID, history.Trigger, history.ActorID, metadataJSON, history.CreatedAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the auto-incremented ID
|
||||
id, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
history.ID = id
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListHistoryBySession returns all step history entries for a session.
|
||||
func (r *Repository) ListHistoryBySession(ctx context.Context, sessionID string) ([]*models.SessionStepHistory, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, session_id, from_step_id, to_step_id, trigger, actor_id, metadata, created_at
|
||||
FROM session_step_history WHERE session_id = ? ORDER BY created_at
|
||||
`, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var result []*models.SessionStepHistory
|
||||
for rows.Next() {
|
||||
history := &models.SessionStepHistory{}
|
||||
var fromStepID, actorID, metadataJSON sql.NullString
|
||||
|
||||
err := rows.Scan(&history.ID, &history.SessionID, &fromStepID, &history.ToStepID, &history.Trigger, &actorID, &metadataJSON, &history.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if fromStepID.Valid {
|
||||
history.FromStepID = &fromStepID.String
|
||||
}
|
||||
if actorID.Valid {
|
||||
history.ActorID = &actorID.String
|
||||
}
|
||||
if metadataJSON.Valid && metadataJSON.String != "" {
|
||||
if err := json.Unmarshal([]byte(metadataJSON.String), &history.Metadata); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal metadata: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
result = append(result, history)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
// Package service provides workflow business logic operations.
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/workflow/models"
|
||||
"github.com/kandev/kandev/internal/workflow/repository"
|
||||
)
|
||||
|
||||
// Service provides workflow business logic
|
||||
type Service struct {
|
||||
repo *repository.Repository
|
||||
logger *logger.Logger
|
||||
}
|
||||
|
||||
// NewService creates a new workflow service
|
||||
func NewService(repo *repository.Repository, log *logger.Logger) *Service {
|
||||
return &Service{
|
||||
repo: repo,
|
||||
logger: log.WithFields(zap.String("component", "workflow-service")),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Template Operations
|
||||
// ============================================================================
|
||||
|
||||
// ListTemplates returns all workflow templates.
|
||||
func (s *Service) ListTemplates(ctx context.Context) ([]*models.WorkflowTemplate, error) {
|
||||
templates, err := s.repo.ListTemplates(ctx)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to list templates", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
return templates, nil
|
||||
}
|
||||
|
||||
// GetTemplate retrieves a workflow template by ID.
|
||||
func (s *Service) GetTemplate(ctx context.Context, id string) (*models.WorkflowTemplate, error) {
|
||||
template, err := s.repo.GetTemplate(ctx, id)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to get template", zap.String("template_id", id), zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
return template, nil
|
||||
}
|
||||
|
||||
// GetSystemTemplates returns only system workflow templates.
|
||||
func (s *Service) GetSystemTemplates(ctx context.Context) ([]*models.WorkflowTemplate, error) {
|
||||
templates, err := s.repo.GetSystemTemplates(ctx)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to get system templates", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
return templates, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Step Operations
|
||||
// ============================================================================
|
||||
|
||||
// ListStepsByBoard returns all workflow steps for a board.
|
||||
func (s *Service) ListStepsByBoard(ctx context.Context, boardID string) ([]*models.WorkflowStep, error) {
|
||||
steps, err := s.repo.ListStepsByBoard(ctx, boardID)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to list steps by board", zap.String("board_id", boardID), zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
return steps, nil
|
||||
}
|
||||
|
||||
// GetStep retrieves a workflow step by ID.
|
||||
func (s *Service) GetStep(ctx context.Context, stepID string) (*models.WorkflowStep, error) {
|
||||
step, err := s.repo.GetStep(ctx, stepID)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to get step", zap.String("step_id", stepID), zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
return step, nil
|
||||
}
|
||||
|
||||
// GetNextStep gets the step to transition to based on OnCompleteStepID.
|
||||
func (s *Service) GetNextStep(ctx context.Context, boardID, currentStepID string) (*models.WorkflowStep, error) {
|
||||
currentStep, err := s.repo.GetStep(ctx, currentStepID)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to get current step", zap.String("step_id", currentStepID), zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if currentStep.OnCompleteStepID == nil {
|
||||
return nil, nil // No next step configured
|
||||
}
|
||||
|
||||
nextStep, err := s.repo.GetStep(ctx, *currentStep.OnCompleteStepID)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to get next step",
|
||||
zap.String("current_step_id", currentStepID),
|
||||
zap.String("next_step_id", *currentStep.OnCompleteStepID),
|
||||
zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nextStep, nil
|
||||
}
|
||||
|
||||
// GetSourceStep finds the step that has on_complete_step_id pointing to the given step.
|
||||
// This is used to find the "previous" step when moving back from a review step.
|
||||
// Returns nil if no source step is found.
|
||||
func (s *Service) GetSourceStep(ctx context.Context, boardID, targetStepID string) (*models.WorkflowStep, error) {
|
||||
steps, err := s.repo.ListStepsByBoard(ctx, boardID)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to list steps for source lookup",
|
||||
zap.String("board_id", boardID),
|
||||
zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, step := range steps {
|
||||
if step.OnCompleteStepID != nil && *step.OnCompleteStepID == targetStepID {
|
||||
return step, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil // No source step found
|
||||
}
|
||||
|
||||
// CreateStepsFromTemplate creates workflow steps for a board from a template.
|
||||
func (s *Service) CreateStepsFromTemplate(ctx context.Context, boardID, templateID string) error {
|
||||
template, err := s.repo.GetTemplate(ctx, templateID)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to get template for step creation",
|
||||
zap.String("template_id", templateID),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("failed to get template: %w", err)
|
||||
}
|
||||
|
||||
// Map template step IDs to generated UUIDs for linking
|
||||
idMap := make(map[string]string)
|
||||
for _, stepDef := range template.Steps {
|
||||
idMap[stepDef.ID] = uuid.New().String()
|
||||
}
|
||||
|
||||
// Create each step from the template
|
||||
for _, stepDef := range template.Steps {
|
||||
step := &models.WorkflowStep{
|
||||
ID: idMap[stepDef.ID],
|
||||
BoardID: boardID,
|
||||
Name: stepDef.Name,
|
||||
StepType: stepDef.StepType,
|
||||
Position: stepDef.Position,
|
||||
Color: stepDef.Color,
|
||||
AutoStartAgent: stepDef.AutoStartAgent,
|
||||
PlanMode: stepDef.PlanMode,
|
||||
RequireApproval: stepDef.RequireApproval,
|
||||
PromptPrefix: stepDef.PromptPrefix,
|
||||
PromptSuffix: stepDef.PromptSuffix,
|
||||
AllowManualMove: stepDef.AllowManualMove,
|
||||
}
|
||||
|
||||
// Map OnCompleteStepID if set
|
||||
if stepDef.OnCompleteStepID != "" {
|
||||
if mappedID, ok := idMap[stepDef.OnCompleteStepID]; ok {
|
||||
step.OnCompleteStepID = &mappedID
|
||||
}
|
||||
}
|
||||
|
||||
// Map OnApprovalStepID if set
|
||||
if stepDef.OnApprovalStepID != "" {
|
||||
if mappedID, ok := idMap[stepDef.OnApprovalStepID]; ok {
|
||||
step.OnApprovalStepID = &mappedID
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.repo.CreateStep(ctx, step); err != nil {
|
||||
s.logger.Error("failed to create step from template",
|
||||
zap.String("board_id", boardID),
|
||||
zap.String("step_name", step.Name),
|
||||
zap.Error(err))
|
||||
return fmt.Errorf("failed to create step %s: %w", step.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
s.logger.Info("created workflow steps from template",
|
||||
zap.String("board_id", boardID),
|
||||
zap.String("template_id", templateID),
|
||||
zap.Int("step_count", len(template.Steps)))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateStep creates a new workflow step.
|
||||
func (s *Service) CreateStep(ctx context.Context, step *models.WorkflowStep) error {
|
||||
step.ID = uuid.New().String()
|
||||
if err := s.repo.CreateStep(ctx, step); err != nil {
|
||||
s.logger.Error("failed to create step", zap.String("board_id", step.BoardID), zap.Error(err))
|
||||
return err
|
||||
}
|
||||
s.logger.Info("created workflow step", zap.String("step_id", step.ID), zap.String("board_id", step.BoardID))
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateStep updates an existing workflow step.
|
||||
func (s *Service) UpdateStep(ctx context.Context, step *models.WorkflowStep) error {
|
||||
if err := s.repo.UpdateStep(ctx, step); err != nil {
|
||||
s.logger.Error("failed to update step", zap.String("step_id", step.ID), zap.Error(err))
|
||||
return err
|
||||
}
|
||||
s.logger.Info("updated workflow step", zap.String("step_id", step.ID))
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteStep deletes a workflow step.
|
||||
func (s *Service) DeleteStep(ctx context.Context, stepID string) error {
|
||||
if err := s.repo.DeleteStep(ctx, stepID); err != nil {
|
||||
s.logger.Error("failed to delete step", zap.String("step_id", stepID), zap.Error(err))
|
||||
return err
|
||||
}
|
||||
s.logger.Info("deleted workflow step", zap.String("step_id", stepID))
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReorderSteps reorders workflow steps for a board.
|
||||
func (s *Service) ReorderSteps(ctx context.Context, boardID string, stepIDs []string) error {
|
||||
for i, stepID := range stepIDs {
|
||||
step, err := s.repo.GetStep(ctx, stepID)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to get step for reorder", zap.String("step_id", stepID), zap.Error(err))
|
||||
return err
|
||||
}
|
||||
step.Position = i
|
||||
if err := s.repo.UpdateStep(ctx, step); err != nil {
|
||||
s.logger.Error("failed to update step position", zap.String("step_id", stepID), zap.Error(err))
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.logger.Info("reordered workflow steps", zap.String("board_id", boardID), zap.Int("count", len(stepIDs)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// History Operations
|
||||
// ============================================================================
|
||||
|
||||
// CreateStepTransition creates a new step transition history entry.
|
||||
func (s *Service) CreateStepTransition(ctx context.Context, sessionID string, fromStepID, toStepID string, trigger models.StepTransitionTrigger, actorID *string) error {
|
||||
history := &models.SessionStepHistory{
|
||||
SessionID: sessionID,
|
||||
ToStepID: toStepID,
|
||||
Trigger: trigger,
|
||||
ActorID: actorID,
|
||||
}
|
||||
|
||||
if fromStepID != "" {
|
||||
history.FromStepID = &fromStepID
|
||||
}
|
||||
|
||||
if err := s.repo.CreateHistory(ctx, history); err != nil {
|
||||
s.logger.Error("failed to create step transition",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.String("to_step_id", toStepID),
|
||||
zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
s.logger.Info("step transition recorded",
|
||||
zap.String("session_id", sessionID),
|
||||
zap.Stringp("from_step_id", history.FromStepID),
|
||||
zap.String("to_step_id", toStepID),
|
||||
zap.String("trigger", string(trigger)))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListHistoryBySession returns all step history entries for a session.
|
||||
func (s *Service) ListHistoryBySession(ctx context.Context, sessionID string) ([]*models.SessionStepHistory, error) {
|
||||
history, err := s.repo.ListHistoryBySession(ctx, sessionID)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to list history by session", zap.String("session_id", sessionID), zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
return history, nil
|
||||
}
|
||||
|
||||
@@ -19,13 +19,6 @@ const (
|
||||
ActionWorkspaceUpdate = "workspace.update"
|
||||
ActionWorkspaceDelete = "workspace.delete"
|
||||
|
||||
// Column actions
|
||||
ActionColumnList = "column.list"
|
||||
ActionColumnCreate = "column.create"
|
||||
ActionColumnGet = "column.get"
|
||||
ActionColumnUpdate = "column.update"
|
||||
ActionColumnDelete = "column.delete"
|
||||
|
||||
// Repository actions
|
||||
ActionRepositoryList = "repository.list"
|
||||
ActionRepositoryCreate = "repository.create"
|
||||
@@ -94,6 +87,14 @@ const (
|
||||
ActionOrchestratorPrompt = "orchestrator.prompt"
|
||||
ActionOrchestratorComplete = "orchestrator.complete"
|
||||
|
||||
// Workflow actions
|
||||
ActionWorkflowTemplateList = "workflow.template.list"
|
||||
ActionWorkflowTemplateGet = "workflow.template.get"
|
||||
ActionWorkflowStepList = "workflow.step.list"
|
||||
ActionWorkflowStepGet = "workflow.step.get"
|
||||
ActionWorkflowStepCreate = "workflow.step.create"
|
||||
ActionWorkflowHistoryList = "workflow.history.list"
|
||||
|
||||
// Subscription actions
|
||||
ActionTaskSubscribe = "task.subscribe"
|
||||
ActionTaskUnsubscribe = "task.unsubscribe"
|
||||
@@ -126,9 +127,6 @@ const (
|
||||
ActionBoardCreated = "board.created"
|
||||
ActionBoardUpdated = "board.updated"
|
||||
ActionBoardDeleted = "board.deleted"
|
||||
ActionColumnCreated = "column.created"
|
||||
ActionColumnUpdated = "column.updated"
|
||||
ActionColumnDeleted = "column.deleted"
|
||||
ActionSessionMessageAdded = "session.message.added"
|
||||
ActionSessionMessageUpdated = "session.message.updated"
|
||||
ActionSessionStateChanged = "session.state_changed"
|
||||
|
||||
@@ -32,7 +32,7 @@ export type KanbanUpdatePayload = {
|
||||
columns: Array<{ id: string; title: string; color?: string; position?: number }>;
|
||||
tasks: Array<{
|
||||
id: string;
|
||||
columnId: string;
|
||||
workflowStepId: string;
|
||||
title: string;
|
||||
position?: number;
|
||||
description?: string;
|
||||
@@ -43,7 +43,7 @@ export type KanbanUpdatePayload = {
|
||||
export type TaskEventPayload = {
|
||||
task_id: string;
|
||||
board_id: string;
|
||||
column_id: string;
|
||||
workflow_step_id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
state?: string;
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import { getBackendConfig } from '@/lib/config';
|
||||
import type {
|
||||
ApproveSessionResponse,
|
||||
Board,
|
||||
Column,
|
||||
ListBoardsResponse,
|
||||
ListColumnsResponse,
|
||||
ListWorkflowStepsResponse,
|
||||
RepositoryDiscoveryResponse,
|
||||
ListRepositoriesResponse,
|
||||
RepositoryBranchesResponse,
|
||||
@@ -14,7 +14,11 @@ import type {
|
||||
RepositoryPathValidationResponse,
|
||||
Repository,
|
||||
RepositoryScript,
|
||||
StepBehaviors,
|
||||
Workspace,
|
||||
WorkflowStep,
|
||||
WorkflowStepType,
|
||||
ListWorkflowTemplatesResponse,
|
||||
} from '@/lib/types/http';
|
||||
|
||||
const { apiBaseUrl } = getBackendConfig();
|
||||
@@ -88,7 +92,12 @@ export async function listBoardsAction(workspaceId: string): Promise<ListBoardsR
|
||||
return fetchJson<ListBoardsResponse>(url.toString());
|
||||
}
|
||||
|
||||
export async function createBoardAction(payload: { workspace_id: string; name: string; description?: string }) {
|
||||
export async function createBoardAction(payload: {
|
||||
workspace_id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
workflow_template_id?: string;
|
||||
}) {
|
||||
return fetchJson<Board>(`${apiBaseUrl}/api/v1/boards`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
@@ -106,39 +115,6 @@ export async function deleteBoardAction(id: string) {
|
||||
await fetchJson<void>(`${apiBaseUrl}/api/v1/boards/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function listColumnsAction(boardId: string): Promise<ListColumnsResponse> {
|
||||
return fetchJson<ListColumnsResponse>(`${apiBaseUrl}/api/v1/boards/${boardId}/columns`);
|
||||
}
|
||||
|
||||
export async function createColumnAction(payload: {
|
||||
board_id: string;
|
||||
name: string;
|
||||
position: number;
|
||||
state: string;
|
||||
color: string;
|
||||
}) {
|
||||
return fetchJson<Column>(`${apiBaseUrl}/api/v1/boards/${payload.board_id}/columns`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: payload.name,
|
||||
position: payload.position,
|
||||
state: payload.state,
|
||||
color: payload.color,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateColumnAction(id: string, payload: Partial<Pick<Column, 'name' | 'position' | 'state' | 'color'>>) {
|
||||
return fetchJson<Column>(`${apiBaseUrl}/api/v1/columns/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteColumnAction(id: string) {
|
||||
await fetchJson<void>(`${apiBaseUrl}/api/v1/columns/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function listRepositoriesAction(workspaceId: string): Promise<ListRepositoriesResponse> {
|
||||
return fetchJson<ListRepositoriesResponse>(`${apiBaseUrl}/api/v1/workspaces/${workspaceId}/repositories`);
|
||||
}
|
||||
@@ -250,3 +226,154 @@ export async function updateRepositoryScriptAction(id: string, payload: Partial<
|
||||
export async function deleteRepositoryScriptAction(id: string) {
|
||||
await fetchJson<void>(`${apiBaseUrl}/api/v1/scripts/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// Workflow Templates
|
||||
export async function listWorkflowTemplatesAction(): Promise<ListWorkflowTemplatesResponse> {
|
||||
return fetchJson<ListWorkflowTemplatesResponse>(`${apiBaseUrl}/api/v1/workflow/templates`);
|
||||
}
|
||||
|
||||
// Helper to transform backend workflow step (flat fields) to frontend format (nested behaviors)
|
||||
type BackendWorkflowStep = {
|
||||
id: string;
|
||||
board_id: string;
|
||||
name: string;
|
||||
step_type: WorkflowStepType;
|
||||
position: number;
|
||||
color: string;
|
||||
task_state?: string;
|
||||
auto_start_agent?: boolean;
|
||||
plan_mode?: boolean;
|
||||
require_approval?: boolean;
|
||||
prompt_prefix?: string;
|
||||
prompt_suffix?: string;
|
||||
on_complete_step_id?: string;
|
||||
on_approval_step_id?: string;
|
||||
allow_manual_move?: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
function transformWorkflowStep(step: BackendWorkflowStep): WorkflowStep {
|
||||
return {
|
||||
id: step.id,
|
||||
board_id: step.board_id,
|
||||
name: step.name,
|
||||
step_type: step.step_type,
|
||||
position: step.position,
|
||||
color: step.color,
|
||||
behaviors: {
|
||||
autoStartAgent: step.auto_start_agent ?? false,
|
||||
planMode: step.plan_mode ?? false,
|
||||
requireApproval: step.require_approval ?? false,
|
||||
promptPrefix: step.prompt_prefix ?? '',
|
||||
promptSuffix: step.prompt_suffix ?? '',
|
||||
},
|
||||
created_at: step.created_at,
|
||||
updated_at: step.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
// Workflow Steps
|
||||
export async function listWorkflowStepsAction(boardId: string): Promise<ListWorkflowStepsResponse> {
|
||||
const response = await fetchJson<{ steps: BackendWorkflowStep[] | null }>(
|
||||
`${apiBaseUrl}/api/v1/boards/${boardId}/workflow/steps`
|
||||
);
|
||||
return {
|
||||
steps: (response.steps ?? []).map(transformWorkflowStep),
|
||||
total: response.steps?.length ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createWorkflowStepAction(payload: {
|
||||
board_id: string;
|
||||
name: string;
|
||||
step_type: WorkflowStepType;
|
||||
position: number;
|
||||
color: string;
|
||||
behaviors?: StepBehaviors;
|
||||
}): Promise<WorkflowStep> {
|
||||
// Flatten behaviors into top-level fields for the backend
|
||||
const body = {
|
||||
board_id: payload.board_id,
|
||||
name: payload.name,
|
||||
step_type: payload.step_type,
|
||||
position: payload.position,
|
||||
color: payload.color,
|
||||
auto_start_agent: payload.behaviors?.autoStartAgent ?? false,
|
||||
plan_mode: payload.behaviors?.planMode ?? false,
|
||||
require_approval: payload.behaviors?.requireApproval ?? false,
|
||||
prompt_prefix: payload.behaviors?.promptPrefix ?? '',
|
||||
prompt_suffix: payload.behaviors?.promptSuffix ?? '',
|
||||
allow_manual_move: true,
|
||||
};
|
||||
const response = await fetchJson<BackendWorkflowStep>(`${apiBaseUrl}/api/v1/workflow/steps`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return transformWorkflowStep(response);
|
||||
}
|
||||
|
||||
export async function updateWorkflowStepAction(
|
||||
stepId: string,
|
||||
payload: Partial<Pick<WorkflowStep, 'name' | 'step_type' | 'position' | 'color' | 'behaviors'>>
|
||||
): Promise<WorkflowStep> {
|
||||
// Flatten behaviors into top-level fields for the backend
|
||||
const body: Record<string, unknown> = {};
|
||||
if (payload.name !== undefined) body.name = payload.name;
|
||||
if (payload.step_type !== undefined) body.step_type = payload.step_type;
|
||||
if (payload.position !== undefined) body.position = payload.position;
|
||||
if (payload.color !== undefined) body.color = payload.color;
|
||||
if (payload.behaviors !== undefined) {
|
||||
if (payload.behaviors.autoStartAgent !== undefined) body.auto_start_agent = payload.behaviors.autoStartAgent;
|
||||
if (payload.behaviors.planMode !== undefined) body.plan_mode = payload.behaviors.planMode;
|
||||
if (payload.behaviors.requireApproval !== undefined) body.require_approval = payload.behaviors.requireApproval;
|
||||
if (payload.behaviors.promptPrefix !== undefined) body.prompt_prefix = payload.behaviors.promptPrefix;
|
||||
if (payload.behaviors.promptSuffix !== undefined) body.prompt_suffix = payload.behaviors.promptSuffix;
|
||||
}
|
||||
const response = await fetchJson<BackendWorkflowStep>(`${apiBaseUrl}/api/v1/workflow/steps/${stepId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return transformWorkflowStep(response);
|
||||
}
|
||||
|
||||
export async function deleteWorkflowStepAction(stepId: string) {
|
||||
await fetchJson<void>(`${apiBaseUrl}/api/v1/workflow/steps/${stepId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function reorderWorkflowStepsAction(boardId: string, stepIds: string[]): Promise<ListWorkflowStepsResponse> {
|
||||
const response = await fetchJson<{ steps: BackendWorkflowStep[] | null }>(
|
||||
`${apiBaseUrl}/api/v1/boards/${boardId}/workflow/steps/reorder`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ step_ids: stepIds }),
|
||||
}
|
||||
);
|
||||
return {
|
||||
steps: (response.steps ?? []).map(transformWorkflowStep),
|
||||
total: response.steps?.length ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Session Workflow Actions
|
||||
export async function setPrimarySessionAction(taskId: string, sessionId: string) {
|
||||
return fetchJson(`${apiBaseUrl}/api/v1/tasks/${taskId}/primary-session`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ session_id: sessionId }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function approveSessionAction(sessionId: string): Promise<ApproveSessionResponse> {
|
||||
return fetchJson<ApproveSessionResponse>(`${apiBaseUrl}/api/v1/sessions/${sessionId}/approve`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
export async function moveSessionToStepAction(sessionId: string, stepId: string) {
|
||||
return fetchJson(`${apiBaseUrl}/api/v1/sessions/${sessionId}/workflow-step`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ step_id: stepId }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ export function ProfileMcpConfigCard({
|
||||
const currentError = isDraft ? draftState?.error ?? null : mcpError;
|
||||
const currentDirty = isDraft ? draftState?.dirty ?? false : mcpDirty;
|
||||
const { mcpServerUrl } = getBackendConfig();
|
||||
const kandevTools = 'Tools: list_workspaces, list_boards, list_columns, list_tasks, create_task, update_task';
|
||||
const kandevTools = 'Tools: list_workspaces, list_boards, list_workflow_steps, list_tasks, create_task, update_task';
|
||||
|
||||
const popularServers: Record<string, Record<string, unknown>> = {
|
||||
kandev: {
|
||||
|
||||
@@ -1,32 +1,36 @@
|
||||
import {
|
||||
getWorkspaceAction,
|
||||
listBoardsAction,
|
||||
listColumnsAction,
|
||||
listWorkflowTemplatesAction,
|
||||
} from '@/app/actions/workspaces';
|
||||
import { WorkspaceBoardsClient } from '@/app/settings/workspace/workspace-boards-client';
|
||||
import type { Board, WorkflowTemplate } from '@/lib/types/http';
|
||||
|
||||
export default async function WorkspaceBoardsPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
let workspace = null;
|
||||
let boardsWithColumns: Array<
|
||||
Awaited<ReturnType<typeof listBoardsAction>>['boards'][number] & {
|
||||
columns: Awaited<ReturnType<typeof listColumnsAction>>['columns'];
|
||||
}
|
||||
> = [];
|
||||
let boards: Board[] = [];
|
||||
let workflowTemplates: WorkflowTemplate[] = [];
|
||||
|
||||
try {
|
||||
workspace = await getWorkspaceAction(id);
|
||||
const boards = await listBoardsAction(id);
|
||||
boardsWithColumns = await Promise.all(
|
||||
boards.boards.map(async (board) => {
|
||||
const columns = await listColumnsAction(board.id);
|
||||
return { ...board, columns: columns.columns };
|
||||
})
|
||||
);
|
||||
const [boardsResponse, templatesResponse] = await Promise.all([
|
||||
listBoardsAction(id),
|
||||
listWorkflowTemplatesAction(),
|
||||
]);
|
||||
boards = boardsResponse.boards;
|
||||
workflowTemplates = templatesResponse.templates ?? [];
|
||||
} catch {
|
||||
workspace = null;
|
||||
boardsWithColumns = [];
|
||||
boards = [];
|
||||
workflowTemplates = [];
|
||||
}
|
||||
|
||||
return <WorkspaceBoardsClient workspace={workspace} boards={boardsWithColumns} />;
|
||||
return (
|
||||
<WorkspaceBoardsClient
|
||||
workspace={workspace}
|
||||
boards={boards}
|
||||
workflowTemplates={workflowTemplates}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,127 +3,87 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { IconLayoutColumns, IconPlus } from '@tabler/icons-react';
|
||||
import { IconLayoutColumns, IconPlus, IconRobot, IconCheck, IconArrowRight } from '@tabler/icons-react';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import { Card, CardContent } from '@kandev/ui/card';
|
||||
import { Separator } from '@kandev/ui/separator';
|
||||
import { Label } from '@kandev/ui/label';
|
||||
import { Input } from '@kandev/ui/input';
|
||||
import { RadioGroup, RadioGroupItem } from '@kandev/ui/radio-group';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@kandev/ui/dialog';
|
||||
import { SettingsSection } from '@/components/settings/settings-section';
|
||||
import { BoardCard } from '@/components/settings/board-card';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
import { cn } from '@kandev/ui/lib/utils';
|
||||
import {
|
||||
createBoardAction,
|
||||
createColumnAction,
|
||||
deleteBoardAction,
|
||||
deleteColumnAction,
|
||||
updateBoardAction,
|
||||
updateColumnAction,
|
||||
} from '@/app/actions/workspaces';
|
||||
import type { Board, Column, Workspace } from '@/lib/types/http';
|
||||
|
||||
type BoardWithColumns = Board & { columns: Column[] };
|
||||
import type { Board, Workspace, WorkflowTemplate } from '@/lib/types/http';
|
||||
|
||||
type WorkspaceBoardsClientProps = {
|
||||
workspace: Workspace | null;
|
||||
boards: BoardWithColumns[];
|
||||
boards: Board[];
|
||||
workflowTemplates: WorkflowTemplate[];
|
||||
};
|
||||
|
||||
export function WorkspaceBoardsClient({ workspace, boards }: WorkspaceBoardsClientProps) {
|
||||
export function WorkspaceBoardsClient({
|
||||
workspace,
|
||||
boards,
|
||||
workflowTemplates,
|
||||
}: WorkspaceBoardsClientProps) {
|
||||
const router = useRouter();
|
||||
const [boardItems, setBoardItems] = useState<BoardWithColumns[]>(boards);
|
||||
const [savedBoardItems, setSavedBoardItems] = useState<BoardWithColumns[]>(boards);
|
||||
const [boardItems, setBoardItems] = useState<Board[]>(boards);
|
||||
const [savedBoardItems, setSavedBoardItems] = useState<Board[]>(boards);
|
||||
|
||||
const cloneBoard = (board: BoardWithColumns): BoardWithColumns => ({
|
||||
...board,
|
||||
columns: board.columns.map((column) => ({ ...column })),
|
||||
});
|
||||
// Dialog state for creating a new board
|
||||
const [isAddBoardDialogOpen, setIsAddBoardDialogOpen] = useState(false);
|
||||
const [newBoardName, setNewBoardName] = useState('');
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
|
||||
|
||||
// Get selected template steps for preview
|
||||
const selectedTemplate = workflowTemplates.find((t) => t.id === selectedTemplateId);
|
||||
const previewSteps = selectedTemplate?.default_steps ?? [];
|
||||
|
||||
const savedBoardsById = useMemo(() => {
|
||||
return new Map(savedBoardItems.map((board) => [board.id, board]));
|
||||
}, [savedBoardItems]);
|
||||
|
||||
const isBoardNameDirty = (board: BoardWithColumns) => {
|
||||
const isBoardDirty = (board: Board) => {
|
||||
const saved = savedBoardsById.get(board.id);
|
||||
if (!saved) return true;
|
||||
if (board.name !== saved.name || board.description !== saved.description) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const areColumnsDirty = (board: BoardWithColumns) => {
|
||||
const saved = savedBoardsById.get(board.id);
|
||||
if (!saved) {
|
||||
return board.columns.length > 0;
|
||||
}
|
||||
if (board.columns.length !== saved.columns.length) return true;
|
||||
const savedColumns = new Map(saved.columns.map((column) => [column.id, column]));
|
||||
for (const column of board.columns) {
|
||||
const savedColumn = savedColumns.get(column.id);
|
||||
if (!savedColumn) return true;
|
||||
if (
|
||||
column.name !== savedColumn.name ||
|
||||
column.color !== savedColumn.color ||
|
||||
column.position !== savedColumn.position ||
|
||||
column.state !== savedColumn.state
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
const handleOpenAddBoardDialog = () => {
|
||||
setNewBoardName('');
|
||||
setSelectedTemplateId(workflowTemplates.length > 0 ? workflowTemplates[0].id : null);
|
||||
setIsAddBoardDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleAddBoard = () => {
|
||||
const handleCreateBoard = () => {
|
||||
if (!workspace) return;
|
||||
const draftBoardId = `temp-${generateUUID()}`;
|
||||
const draftBoard: BoardWithColumns = {
|
||||
id: draftBoardId,
|
||||
|
||||
const draftBoard: Board = {
|
||||
id: `temp-${generateUUID()}`,
|
||||
workspace_id: workspace.id,
|
||||
name: 'New Board',
|
||||
name: newBoardName.trim() || 'New Board',
|
||||
description: '',
|
||||
workflow_template_id: selectedTemplateId,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
columns: [
|
||||
{
|
||||
id: `temp-col-${generateUUID()}`,
|
||||
board_id: draftBoardId,
|
||||
name: 'Todo',
|
||||
position: 0,
|
||||
state: 'TODO',
|
||||
color: 'bg-cyan-500',
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
{
|
||||
id: `temp-col-${generateUUID()}`,
|
||||
board_id: draftBoardId,
|
||||
name: 'In Progress',
|
||||
position: 1,
|
||||
state: 'IN_PROGRESS',
|
||||
color: 'bg-yellow-500',
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
{
|
||||
id: `temp-col-${generateUUID()}`,
|
||||
board_id: draftBoardId,
|
||||
name: 'To Review',
|
||||
position: 2,
|
||||
state: 'REVIEW',
|
||||
color: 'bg-green-500',
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
{
|
||||
id: `temp-col-${generateUUID()}`,
|
||||
board_id: draftBoardId,
|
||||
name: 'Done',
|
||||
position: 3,
|
||||
state: 'COMPLETED',
|
||||
color: 'bg-indigo-500',
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
setBoardItems((prev) => [draftBoard, ...prev]);
|
||||
setIsAddBoardDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleUpdateBoard = (boardId: string, updates: { name?: string; description?: string }) => {
|
||||
@@ -142,69 +102,6 @@ export function WorkspaceBoardsClient({ workspace, boards }: WorkspaceBoardsClie
|
||||
setSavedBoardItems((prev) => prev.filter((board) => board.id !== boardId));
|
||||
};
|
||||
|
||||
const handleCreateColumn = (
|
||||
boardId: string,
|
||||
column: Omit<Column, 'id' | 'board_id' | 'created_at' | 'updated_at'>
|
||||
) => {
|
||||
const draftColumn: Column = {
|
||||
id: `temp-col-${generateUUID()}`,
|
||||
board_id: boardId,
|
||||
name: column.name,
|
||||
position: column.position,
|
||||
state: column.state,
|
||||
color: column.color,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
};
|
||||
setBoardItems((prev) =>
|
||||
prev.map((board) =>
|
||||
board.id === boardId ? { ...board, columns: [...board.columns, draftColumn] } : board
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handleUpdateColumn = (boardId: string, columnId: string, updates: Partial<Column>) => {
|
||||
setBoardItems((prev) =>
|
||||
prev.map((board) =>
|
||||
board.id === boardId
|
||||
? {
|
||||
...board,
|
||||
columns: board.columns.map((column) =>
|
||||
column.id === columnId ? { ...column, ...updates } : column
|
||||
),
|
||||
}
|
||||
: board
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeleteColumn = async (boardId: string, columnId: string) => {
|
||||
if (boardId.startsWith('temp-')) {
|
||||
setBoardItems((prev) =>
|
||||
prev.map((board) =>
|
||||
board.id === boardId
|
||||
? { ...board, columns: board.columns.filter((column) => column.id !== columnId) }
|
||||
: board
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
await deleteColumnAction(columnId);
|
||||
setBoardItems((prev) =>
|
||||
prev.map((board) =>
|
||||
board.id === boardId
|
||||
? { ...board, columns: board.columns.filter((column) => column.id !== columnId) }
|
||||
: board
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handleReorderColumns = (boardId: string, columns: Column[]) => {
|
||||
setBoardItems((prev) =>
|
||||
prev.map((board) => (board.id === boardId ? { ...board, columns } : board))
|
||||
);
|
||||
};
|
||||
|
||||
const handleSaveBoard = async (boardId: string) => {
|
||||
const board = boardItems.find((item) => item.id === boardId);
|
||||
if (!board) return;
|
||||
@@ -213,26 +110,16 @@ export function WorkspaceBoardsClient({ workspace, boards }: WorkspaceBoardsClie
|
||||
const createdBoard = await createBoardAction({
|
||||
workspace_id: workspace?.id ?? board.workspace_id,
|
||||
name,
|
||||
workflow_template_id: board.workflow_template_id ?? undefined,
|
||||
});
|
||||
const createdColumns = await Promise.all(
|
||||
board.columns.map((column, index) =>
|
||||
createColumnAction({
|
||||
board_id: createdBoard.id,
|
||||
name: column.name.trim() || 'New Column',
|
||||
position: column.position ?? index,
|
||||
state: column.state,
|
||||
color: column.color,
|
||||
})
|
||||
)
|
||||
);
|
||||
const nextBoard = {
|
||||
...createdBoard,
|
||||
columns: createdColumns,
|
||||
};
|
||||
setBoardItems((prev) => prev.map((item) => (item.id === boardId ? nextBoard : item)));
|
||||
setSavedBoardItems((prev) => [cloneBoard(nextBoard), ...prev]);
|
||||
// Backend creates workflow steps automatically from the template
|
||||
setBoardItems((prev) => prev.map((item) => (item.id === boardId ? createdBoard : item)));
|
||||
setSavedBoardItems((prev) => [{ ...createdBoard }, ...prev]);
|
||||
// Refresh the page to load the workflow steps created by the backend
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
// For existing boards, only update board name/description
|
||||
const updates: { name?: string; description?: string } = {};
|
||||
if (board.name.trim()) {
|
||||
updates.name = board.name.trim();
|
||||
@@ -240,34 +127,15 @@ export function WorkspaceBoardsClient({ workspace, boards }: WorkspaceBoardsClie
|
||||
if (Object.keys(updates).length) {
|
||||
await updateBoardAction(boardId, updates);
|
||||
}
|
||||
const nextColumns = await Promise.all(
|
||||
board.columns.map((column, index) => {
|
||||
if (column.id.startsWith('temp-col-')) {
|
||||
return createColumnAction({
|
||||
board_id: boardId,
|
||||
name: column.name.trim() || 'New Column',
|
||||
position: column.position ?? index,
|
||||
state: column.state,
|
||||
color: column.color,
|
||||
});
|
||||
}
|
||||
return updateColumnAction(column.id, {
|
||||
name: column.name,
|
||||
color: column.color,
|
||||
position: column.position ?? index,
|
||||
state: column.state,
|
||||
});
|
||||
})
|
||||
);
|
||||
setBoardItems((prev) =>
|
||||
prev.map((item) => (item.id === boardId ? { ...item, columns: nextColumns } : item))
|
||||
prev.map((item) => (item.id === boardId ? { ...item, ...updates } : item))
|
||||
);
|
||||
setSavedBoardItems((prev) =>
|
||||
prev.some((item) => item.id === boardId)
|
||||
? prev.map((item) =>
|
||||
item.id === boardId ? cloneBoard({ ...board, columns: nextColumns }) : item
|
||||
item.id === boardId ? { ...board, ...updates } : item
|
||||
)
|
||||
: [...prev, cloneBoard({ ...board, columns: nextColumns })]
|
||||
: [...prev, { ...board, ...updates }]
|
||||
);
|
||||
};
|
||||
|
||||
@@ -292,7 +160,7 @@ export function WorkspaceBoardsClient({ workspace, boards }: WorkspaceBoardsClie
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">{workspace.name}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Manage boards and columns for this workspace.
|
||||
Manage boards for this workspace.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
@@ -305,9 +173,9 @@ export function WorkspaceBoardsClient({ workspace, boards }: WorkspaceBoardsClie
|
||||
<SettingsSection
|
||||
icon={<IconLayoutColumns className="h-5 w-5" />}
|
||||
title="Boards"
|
||||
description="Boards and columns in this workspace"
|
||||
description="Boards in this workspace"
|
||||
action={
|
||||
<Button size="sm" onClick={handleAddBoard}>
|
||||
<Button size="sm" onClick={handleOpenAddBoardDialog}>
|
||||
<IconPlus className="h-4 w-4 mr-2" />
|
||||
Add Board
|
||||
</Button>
|
||||
@@ -318,22 +186,108 @@ export function WorkspaceBoardsClient({ workspace, boards }: WorkspaceBoardsClie
|
||||
<BoardCard
|
||||
key={board.id}
|
||||
board={board}
|
||||
columns={board.columns}
|
||||
isBoardDirty={isBoardNameDirty(board)}
|
||||
areColumnsDirty={areColumnsDirty(board)}
|
||||
isBoardDirty={isBoardDirty(board)}
|
||||
onUpdateBoard={(updates) => handleUpdateBoard(board.id, updates)}
|
||||
onDeleteBoard={() => handleDeleteBoard(board.id)}
|
||||
onCreateColumn={(column) => handleCreateColumn(board.id, column)}
|
||||
onUpdateColumn={(columnId, updates) =>
|
||||
handleUpdateColumn(board.id, columnId, updates)
|
||||
}
|
||||
onDeleteColumn={(columnId) => handleDeleteColumn(board.id, columnId)}
|
||||
onReorderColumns={(columns) => handleReorderColumns(board.id, columns)}
|
||||
onSaveBoard={() => handleSaveBoard(board.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
{/* Create Board Dialog */}
|
||||
<Dialog open={isAddBoardDialogOpen} onOpenChange={setIsAddBoardDialogOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Board</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="boardName">Board Name</Label>
|
||||
<Input
|
||||
id="boardName"
|
||||
placeholder="My Project Board"
|
||||
value={newBoardName}
|
||||
onChange={(e) => setNewBoardName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{workflowTemplates.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>Workflow Template</Label>
|
||||
<RadioGroup
|
||||
value={selectedTemplateId ?? 'custom'}
|
||||
onValueChange={(value) =>
|
||||
setSelectedTemplateId(value === 'custom' ? null : value)
|
||||
}
|
||||
>
|
||||
{workflowTemplates.map((template) => (
|
||||
<div key={template.id} className="flex items-start space-x-3">
|
||||
<RadioGroupItem value={template.id} id={template.id} className="mt-1" />
|
||||
<label htmlFor={template.id} className="flex flex-col cursor-pointer">
|
||||
<span className="font-medium">{template.name}</span>
|
||||
{template.description && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{template.description}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-start space-x-3">
|
||||
<RadioGroupItem value="custom" id="custom" className="mt-1" />
|
||||
<label htmlFor="custom" className="flex flex-col cursor-pointer">
|
||||
<span className="font-medium">Custom</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Start with basic columns (Todo, In Progress, Review, Done)
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Workflow Preview */}
|
||||
{previewSteps.length > 0 && (
|
||||
<div className="border rounded-lg p-4 bg-muted/50">
|
||||
<Label className="text-sm">Workflow Preview</Label>
|
||||
<div className="flex items-center gap-2 mt-2 overflow-x-auto pb-2">
|
||||
{previewSteps.map((step, i) => (
|
||||
<div key={`step-${i}`} className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'px-3 py-1.5 rounded text-sm whitespace-nowrap flex items-center gap-1',
|
||||
step.color || 'bg-slate-500',
|
||||
'text-white'
|
||||
)}
|
||||
>
|
||||
{step.name}
|
||||
{step.behaviors?.autoStartAgent && (
|
||||
<IconRobot className="h-3 w-3" />
|
||||
)}
|
||||
{step.behaviors?.requireApproval && (
|
||||
<IconCheck className="h-3 w-3" />
|
||||
)}
|
||||
</div>
|
||||
{i < previewSteps.length - 1 && (
|
||||
<IconArrowRight className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsAddBoardDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreateBoard}>Create Board</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,11 +9,11 @@ import {
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { KanbanColumn, Column } from './kanban-column';
|
||||
import { KanbanColumn, WorkflowStep } from './kanban-column';
|
||||
import { KanbanCardPreview, Task } from './kanban-card';
|
||||
|
||||
export type KanbanBoardGridProps = {
|
||||
columns: Column[];
|
||||
columns: WorkflowStep[];
|
||||
tasks: Task[];
|
||||
onPreviewTask: (task: Task) => void;
|
||||
onOpenTask: (task: Task) => void;
|
||||
@@ -49,7 +49,7 @@ export function KanbanBoardGrid({
|
||||
|
||||
const getTasksForColumn = (columnId: string) => {
|
||||
return tasks
|
||||
.filter((task) => task.columnId === columnId)
|
||||
.filter((task) => task.workflowStepId === columnId)
|
||||
.map((task) => ({ ...task, position: task.position ?? 0 }))
|
||||
.sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
|
||||
};
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Task } from './kanban-card';
|
||||
import { TaskCreateDialog } from './task-create-dialog';
|
||||
import { useAppStore, useAppStoreApi } from '@/components/state-provider';
|
||||
import type { Task as BackendTask } from '@/lib/types/http';
|
||||
import type { BoardState } from '@/lib/state/slices';
|
||||
import { useDragAndDrop } from '@/hooks/use-drag-and-drop';
|
||||
import { useDragAndDrop, type WorkflowAutomation, type MoveTaskError } from '@/hooks/use-drag-and-drop';
|
||||
import { KanbanBoardGrid } from './kanban-board-grid';
|
||||
import { KanbanHeader } from './kanban/kanban-header';
|
||||
import { useKanbanData, useKanbanActions, useKanbanNavigation } from '@/hooks/domains/kanban';
|
||||
import { getWebSocketClient } from '@/lib/ws/connection';
|
||||
import { linkToSession } from '@/lib/links';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogCancel,
|
||||
AlertDialogAction,
|
||||
} from '@kandev/ui/alert-dialog';
|
||||
import { IconAlertTriangle } from '@tabler/icons-react';
|
||||
|
||||
interface KanbanBoardProps {
|
||||
onPreviewTask?: (task: Task) => void;
|
||||
@@ -19,6 +33,7 @@ interface KanbanBoardProps {
|
||||
export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {}) {
|
||||
// Store access
|
||||
const store = useAppStoreApi();
|
||||
const router = useRouter();
|
||||
|
||||
// Get initial state for actions hook
|
||||
const kanban = useAppStore((state) => state.kanban);
|
||||
@@ -66,8 +81,69 @@ export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {}
|
||||
setTaskSessionAvailability,
|
||||
});
|
||||
|
||||
// Drag and drop
|
||||
const { activeTask, handleDragStart, handleDragEnd, handleDragCancel } = useDragAndDrop(visibleTasksWithSessions);
|
||||
// Workflow automation state for session creation dialog
|
||||
const [workflowAutomation, setWorkflowAutomation] = useState<WorkflowAutomation | null>(null);
|
||||
const [isWorkflowDialogOpen, setIsWorkflowDialogOpen] = useState(false);
|
||||
|
||||
// Move error state for approval warning modal
|
||||
const [moveError, setMoveError] = useState<MoveTaskError | null>(null);
|
||||
|
||||
// Handle workflow automation when task is moved to auto-start step
|
||||
const handleWorkflowAutomation = useCallback((automation: WorkflowAutomation) => {
|
||||
setWorkflowAutomation(automation);
|
||||
setIsWorkflowDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
// Handle move error - show approval warning modal
|
||||
const handleMoveError = useCallback((error: MoveTaskError) => {
|
||||
setMoveError(error);
|
||||
}, []);
|
||||
|
||||
// Handle navigation to task session from approval warning modal
|
||||
const handleGoToTask = useCallback(() => {
|
||||
if (moveError?.sessionId) {
|
||||
router.push(linkToSession(moveError.sessionId));
|
||||
}
|
||||
setMoveError(null);
|
||||
}, [moveError, router]);
|
||||
|
||||
// Handle session creation from workflow automation dialog
|
||||
const handleWorkflowSessionCreate = useCallback(
|
||||
async (data: { prompt: string; agentProfileId: string; executorId: string; environmentId: string }) => {
|
||||
if (!workflowAutomation) return;
|
||||
|
||||
const client = getWebSocketClient();
|
||||
if (!client) return;
|
||||
|
||||
try {
|
||||
// Start the task with workflow step configuration
|
||||
await client.request(
|
||||
'orchestrator.start',
|
||||
{
|
||||
task_id: workflowAutomation.taskId,
|
||||
agent_profile_id: data.agentProfileId,
|
||||
executor_id: data.executorId,
|
||||
prompt: data.prompt.trim(),
|
||||
workflow_step_id: workflowAutomation.workflowStep.id,
|
||||
},
|
||||
15000
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Failed to start session for workflow step:', err);
|
||||
} finally {
|
||||
setWorkflowAutomation(null);
|
||||
setIsWorkflowDialogOpen(false);
|
||||
}
|
||||
},
|
||||
[workflowAutomation]
|
||||
);
|
||||
|
||||
// Drag and drop with workflow automation callback
|
||||
const { activeTask, handleDragStart, handleDragEnd, handleDragCancel } = useDragAndDrop({
|
||||
visibleTasks: visibleTasksWithSessions,
|
||||
onWorkflowAutomation: handleWorkflowAutomation,
|
||||
onMoveError: handleMoveError,
|
||||
});
|
||||
|
||||
// Board selection effect
|
||||
useEffect(() => {
|
||||
@@ -94,7 +170,7 @@ export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {}
|
||||
}
|
||||
if (!desiredBoardId) {
|
||||
store.getState().hydrate({
|
||||
kanban: { boardId: null, columns: [], tasks: [] },
|
||||
kanban: { boardId: null, steps: [], tasks: [] },
|
||||
});
|
||||
}
|
||||
}, [
|
||||
@@ -126,14 +202,14 @@ export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {}
|
||||
workspaceId={workspaceState.activeId}
|
||||
boardId={kanban.boardId}
|
||||
defaultColumnId={activeColumns[0]?.id ?? null}
|
||||
columns={activeColumns.map((column) => ({ id: column.id, title: column.title }))}
|
||||
columns={activeColumns.map((column) => ({ id: column.id, title: column.title, autoStartAgent: column.autoStartAgent }))}
|
||||
editingTask={
|
||||
editingTask
|
||||
? {
|
||||
id: editingTask.id,
|
||||
title: editingTask.title,
|
||||
description: editingTask.description,
|
||||
columnId: editingTask.columnId,
|
||||
workflowStepId: editingTask.workflowStepId,
|
||||
state: editingTask.state as BackendTask['state'],
|
||||
repositoryId: editingTask.repositoryId,
|
||||
}
|
||||
@@ -152,6 +228,49 @@ export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {}
|
||||
}
|
||||
submitLabel={editingTask ? 'Update' : 'Create'}
|
||||
/>
|
||||
{/* Workflow automation session creation dialog */}
|
||||
<TaskCreateDialog
|
||||
key={isWorkflowDialogOpen ? 'workflow-open' : 'workflow-closed'}
|
||||
open={isWorkflowDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsWorkflowDialogOpen(open);
|
||||
if (!open) setWorkflowAutomation(null);
|
||||
}}
|
||||
mode="session"
|
||||
workspaceId={workspaceState.activeId}
|
||||
boardId={kanban.boardId}
|
||||
defaultColumnId={workflowAutomation?.workflowStep.id ?? null}
|
||||
columns={activeColumns.map((column) => ({ id: column.id, title: column.title, autoStartAgent: column.autoStartAgent }))}
|
||||
editingTask={null}
|
||||
onCreateSession={handleWorkflowSessionCreate}
|
||||
initialValues={{
|
||||
title: '',
|
||||
description: workflowAutomation?.taskDescription ?? '',
|
||||
}}
|
||||
submitLabel="Start Agent"
|
||||
/>
|
||||
{/* Approval warning modal */}
|
||||
<AlertDialog open={!!moveError} onOpenChange={(open) => !open && setMoveError(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2">
|
||||
<IconAlertTriangle className="h-5 w-5 text-amber-500" />
|
||||
Approval Required
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{moveError?.message}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Dismiss</AlertDialogCancel>
|
||||
{moveError?.sessionId && (
|
||||
<AlertDialogAction onClick={handleGoToTask}>
|
||||
Go to Task
|
||||
</AlertDialogAction>
|
||||
)}
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<KanbanBoardGrid
|
||||
columns={activeColumns}
|
||||
tasks={visibleTasksWithSessions}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useDraggable } from '@dnd-kit/core';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { IconDots, IconArrowsMaximize } from '@tabler/icons-react';
|
||||
import { Card, CardContent } from '@kandev/ui/card';
|
||||
import { Badge } from '@kandev/ui/badge';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -19,12 +20,16 @@ import { useAppStore } from '@/components/state-provider';
|
||||
export interface Task {
|
||||
id: string;
|
||||
title: string;
|
||||
columnId: string;
|
||||
workflowStepId: string;
|
||||
state?: TaskState;
|
||||
description?: string;
|
||||
position?: number;
|
||||
repositoryId?: string;
|
||||
hasSession?: boolean;
|
||||
// Workflow fields
|
||||
sessionCount?: number;
|
||||
primarySessionId?: string | null;
|
||||
reviewStatus?: 'pending' | 'approved' | 'changes_requested' | 'rejected' | null;
|
||||
}
|
||||
|
||||
interface KanbanCardProps {
|
||||
@@ -62,6 +67,20 @@ function KanbanCardBody({
|
||||
{task.description}
|
||||
</p>
|
||||
)}
|
||||
{(task.sessionCount && task.sessionCount > 1 || task.reviewStatus === 'changes_requested') && (
|
||||
<div className="flex items-center justify-between gap-2 mt-1">
|
||||
{task.sessionCount && task.sessionCount > 1 && (
|
||||
<Badge variant="secondary" className="text-xs h-5">
|
||||
{task.sessionCount} sessions
|
||||
</Badge>
|
||||
)}
|
||||
{task.reviewStatus === 'changes_requested' && (
|
||||
<Badge variant="outline" className="border-amber-500 text-amber-600 bg-amber-50 dark:bg-amber-950/50 text-xs h-5">
|
||||
Changes Requested
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,14 +8,15 @@ import { cn, getRepositoryDisplayName } from '@/lib/utils';
|
||||
import { useAppStore } from '@/components/state-provider';
|
||||
import type { Repository } from '@/lib/types/http';
|
||||
|
||||
export interface Column {
|
||||
export interface WorkflowStep {
|
||||
id: string;
|
||||
title: string;
|
||||
color: string;
|
||||
autoStartAgent?: boolean;
|
||||
}
|
||||
|
||||
interface KanbanColumnProps {
|
||||
column: Column;
|
||||
column: WorkflowStep;
|
||||
tasks: Task[];
|
||||
onPreviewTask: (task: Task) => void;
|
||||
onOpenTask: (task: Task) => void;
|
||||
|
||||
@@ -55,7 +55,7 @@ export function KanbanWithPreview({ initialTaskId }: KanbanWithPreviewProps) {
|
||||
return {
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
columnId: task.columnId,
|
||||
workflowStepId: task.workflowStepId,
|
||||
state: task.state,
|
||||
description: task.description,
|
||||
position: task.position,
|
||||
|
||||
@@ -1,22 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState, useSyncExternalStore } from 'react';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
useSortable,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import {
|
||||
IconGripVertical,
|
||||
IconTrash,
|
||||
IconPlus,
|
||||
} from '@tabler/icons-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { IconTrash } from '@tabler/icons-react';
|
||||
import { Card, CardContent } from '@kandev/ui/card';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import { Input } from '@kandev/ui/input';
|
||||
@@ -29,173 +14,147 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@kandev/ui/dialog';
|
||||
import type { Board, Column } from '@/lib/types/http';
|
||||
import type { Board, WorkflowStep } from '@/lib/types/http';
|
||||
import { useRequest } from '@/lib/http/use-request';
|
||||
import { useToast } from '@/components/toast-provider';
|
||||
import { UnsavedChangesBadge, UnsavedSaveButton } from '@/components/settings/unsaved-indicator';
|
||||
import { WorkflowStepEditor } from '@/components/settings/workflow-step-editor';
|
||||
import {
|
||||
listWorkflowStepsAction,
|
||||
createWorkflowStepAction,
|
||||
updateWorkflowStepAction,
|
||||
deleteWorkflowStepAction,
|
||||
reorderWorkflowStepsAction,
|
||||
} from '@/app/actions/workspaces';
|
||||
|
||||
type BoardCardProps = {
|
||||
board: Board;
|
||||
columns: Column[];
|
||||
isBoardDirty: boolean;
|
||||
areColumnsDirty: boolean;
|
||||
onUpdateBoard: (updates: { name?: string; description?: string }) => void;
|
||||
onDeleteBoard: () => Promise<unknown>;
|
||||
onCreateColumn: (column: Omit<Column, 'id' | 'board_id' | 'created_at' | 'updated_at'>) => void;
|
||||
onUpdateColumn: (columnId: string, updates: Partial<Column>) => void;
|
||||
onDeleteColumn: (columnId: string) => Promise<unknown>;
|
||||
onReorderColumns: (columns: Column[]) => void;
|
||||
onSaveBoard: () => Promise<unknown>;
|
||||
};
|
||||
|
||||
const COLOR_OPTIONS = [
|
||||
{ value: 'bg-neutral-400', label: 'Gray' },
|
||||
{ value: 'bg-red-500', label: 'Red' },
|
||||
{ value: 'bg-orange-500', label: 'Orange' },
|
||||
{ value: 'bg-yellow-500', label: 'Yellow' },
|
||||
{ value: 'bg-green-500', label: 'Green' },
|
||||
{ value: 'bg-blue-500', label: 'Blue' },
|
||||
{ value: 'bg-indigo-500', label: 'Indigo' },
|
||||
{ value: 'bg-violet-500', label: 'Violet' },
|
||||
{ value: 'bg-cyan-500', label: 'Cyan' },
|
||||
{ value: 'bg-sky-500', label: 'Sky' },
|
||||
];
|
||||
|
||||
function SortableColumnRow({
|
||||
column,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
}: {
|
||||
column: Column;
|
||||
onUpdate: (updates: Partial<Column>) => void;
|
||||
onDelete: () => Promise<unknown>;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
|
||||
id: column.id,
|
||||
});
|
||||
export function BoardCard({
|
||||
board,
|
||||
isBoardDirty,
|
||||
onUpdateBoard,
|
||||
onDeleteBoard,
|
||||
onSaveBoard,
|
||||
}: BoardCardProps) {
|
||||
const { toast } = useToast();
|
||||
const deleteRequest = useRequest(onDelete);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
const saveBoardRequest = useRequest(onSaveBoard);
|
||||
const deleteBoardRequest = useRequest(onDeleteBoard);
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
onUpdate({ name: value });
|
||||
};
|
||||
// Workflow state
|
||||
const [workflowSteps, setWorkflowSteps] = useState<WorkflowStep[]>([]);
|
||||
const [workflowLoading, setWorkflowLoading] = useState(false);
|
||||
|
||||
const handleColorChange = (value: string) => {
|
||||
onUpdate({ color: value });
|
||||
};
|
||||
const isNewBoard = board.id.startsWith('temp-');
|
||||
|
||||
const handleDelete = async () => {
|
||||
// Load workflow steps on mount (only for saved boards)
|
||||
useEffect(() => {
|
||||
if (isNewBoard) return;
|
||||
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
setWorkflowLoading(true);
|
||||
try {
|
||||
const res = await listWorkflowStepsAction(board.id);
|
||||
if (!cancelled) {
|
||||
setWorkflowSteps(res.steps ?? []);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
toast({
|
||||
title: 'Failed to load workflow steps',
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setWorkflowLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [board.id, isNewBoard]);
|
||||
|
||||
const refreshWorkflowSteps = async () => {
|
||||
try {
|
||||
await deleteRequest.run();
|
||||
const res = await listWorkflowStepsAction(board.id);
|
||||
setWorkflowSteps(res.steps ?? []);
|
||||
} catch {
|
||||
// Ignore errors on refresh
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateWorkflowStep = async (stepId: string, updates: Partial<WorkflowStep>) => {
|
||||
try {
|
||||
await updateWorkflowStepAction(stepId, updates);
|
||||
await refreshWorkflowSteps();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to delete column',
|
||||
title: 'Failed to update workflow step',
|
||||
description: error instanceof Error ? error.message : 'Request failed',
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className="flex gap-2 items-start">
|
||||
<button
|
||||
type="button"
|
||||
className="mt-2 p-1 rounded-md text-muted-foreground hover:text-foreground cursor-grab"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<IconGripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="flex-1 grid grid-cols-[1fr_1fr_auto] gap-2 items-start">
|
||||
<div className="space-y-1">
|
||||
<Input
|
||||
placeholder="Column title"
|
||||
value={column.name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<select
|
||||
value={column.color}
|
||||
onChange={(e) => handleColorChange(e.target.value)}
|
||||
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm"
|
||||
>
|
||||
{COLOR_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
disabled={deleteRequest.isLoading}
|
||||
className="mt-1"
|
||||
>
|
||||
<IconTrash className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BoardCard({
|
||||
board,
|
||||
columns,
|
||||
isBoardDirty,
|
||||
areColumnsDirty,
|
||||
onUpdateBoard,
|
||||
onDeleteBoard,
|
||||
onCreateColumn,
|
||||
onUpdateColumn,
|
||||
onDeleteColumn,
|
||||
onReorderColumns,
|
||||
onSaveBoard,
|
||||
}: BoardCardProps) {
|
||||
const columnItems = useMemo(() => columns.map((column) => column.id), [columns]);
|
||||
const { toast } = useToast();
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const isMounted = useSyncExternalStore(
|
||||
() => () => {},
|
||||
() => true,
|
||||
() => false
|
||||
);
|
||||
|
||||
const saveBoardRequest = useRequest(onSaveBoard);
|
||||
const deleteBoardRequest = useRequest(onDeleteBoard);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const oldIndex = columns.findIndex((column) => column.id === active.id);
|
||||
const newIndex = columns.findIndex((column) => column.id === over.id);
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
const nextColumns = arrayMove(columns, oldIndex, newIndex).map((column, index) => ({
|
||||
...column,
|
||||
position: index,
|
||||
}));
|
||||
onReorderColumns(nextColumns);
|
||||
const handleAddWorkflowStep = async () => {
|
||||
try {
|
||||
await createWorkflowStepAction({
|
||||
board_id: board.id,
|
||||
name: 'New Step',
|
||||
step_type: 'implementation',
|
||||
position: workflowSteps.length,
|
||||
color: 'bg-slate-500',
|
||||
});
|
||||
await refreshWorkflowSteps();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to add workflow step',
|
||||
description: error instanceof Error ? error.message : 'Request failed',
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddColumn = async () => {
|
||||
const lastColor = columns[columns.length - 1]?.color ?? COLOR_OPTIONS[0].value;
|
||||
const lastIndex = COLOR_OPTIONS.findIndex((option) => option.value === lastColor);
|
||||
const nextColor =
|
||||
lastIndex >= 0 ? COLOR_OPTIONS[(lastIndex + 1) % COLOR_OPTIONS.length].value : COLOR_OPTIONS[0].value;
|
||||
onCreateColumn({
|
||||
name: '',
|
||||
position: columns.length,
|
||||
state: 'TODO',
|
||||
color: nextColor,
|
||||
});
|
||||
const handleRemoveWorkflowStep = async (stepId: string) => {
|
||||
try {
|
||||
await deleteWorkflowStepAction(stepId);
|
||||
await refreshWorkflowSteps();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to delete workflow step',
|
||||
description: error instanceof Error ? error.message : 'Request failed',
|
||||
variant: 'error',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleReorderWorkflowSteps = async (reorderedSteps: WorkflowStep[]) => {
|
||||
// Optimistically update the UI
|
||||
setWorkflowSteps(reorderedSteps);
|
||||
try {
|
||||
const stepIds = reorderedSteps.map((step) => step.id);
|
||||
await reorderWorkflowStepsAction(board.id, stepIds);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Failed to reorder workflow steps',
|
||||
description: error instanceof Error ? error.message : 'Request failed',
|
||||
variant: 'error',
|
||||
});
|
||||
// Refresh to get the actual order
|
||||
await refreshWorkflowSteps();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteBoard = async () => {
|
||||
@@ -239,7 +198,7 @@ export function BoardCard({
|
||||
onChange={(e) => onUpdateBoard({ name: e.target.value })}
|
||||
/>
|
||||
<UnsavedSaveButton
|
||||
isDirty={isBoardDirty || areColumnsDirty}
|
||||
isDirty={isBoardDirty}
|
||||
isLoading={saveBoardRequest.isLoading}
|
||||
status={saveBoardRequest.status}
|
||||
onClick={handleSaveBoard}
|
||||
@@ -248,52 +207,24 @@ export function BoardCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<span>Columns</span>
|
||||
{areColumnsDirty && <UnsavedChangesBadge />}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddColumn}
|
||||
>
|
||||
<IconPlus className="h-4 w-4 mr-1" />
|
||||
Add Column
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Workflow Steps Section - only show for saved boards */}
|
||||
{!isNewBoard && (
|
||||
<div className="space-y-2">
|
||||
{isMounted ? (
|
||||
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={columnItems}>
|
||||
{columns.map((column) => (
|
||||
<SortableColumnRow
|
||||
key={column.id}
|
||||
column={column}
|
||||
onUpdate={(updates) => onUpdateColumn(column.id, updates)}
|
||||
onDelete={() => onDeleteColumn(column.id)}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<Label>Workflow Steps</Label>
|
||||
{workflowLoading ? (
|
||||
<div className="text-sm text-muted-foreground">Loading workflow steps...</div>
|
||||
) : (
|
||||
columns.map((column) => (
|
||||
<SortableColumnRow
|
||||
key={column.id}
|
||||
column={column}
|
||||
onUpdate={(updates) => onUpdateColumn(column.id, updates)}
|
||||
onDelete={() => onDeleteColumn(column.id)}
|
||||
/>
|
||||
))
|
||||
<WorkflowStepEditor
|
||||
steps={workflowSteps}
|
||||
onUpdateStep={handleUpdateWorkflowStep}
|
||||
onAddStep={handleAddWorkflowStep}
|
||||
onRemoveStep={handleRemoveWorkflowStep}
|
||||
onReorderSteps={handleReorderWorkflowSteps}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
@@ -312,7 +243,7 @@ export function BoardCard({
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete board</DialogTitle>
|
||||
<DialogDescription>
|
||||
This will remove the board and its columns. Tasks will not be deleted. This action cannot be undone.
|
||||
This will remove the board and its workflow steps. Tasks will not be deleted. This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
useSortable,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import {
|
||||
IconGripVertical,
|
||||
IconPlus,
|
||||
IconTrash,
|
||||
IconChevronDown,
|
||||
IconChevronUp,
|
||||
IconRobot,
|
||||
IconCheck,
|
||||
IconClipboard,
|
||||
} from '@tabler/icons-react';
|
||||
import { Card, CardContent } from '@kandev/ui/card';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import { Input } from '@kandev/ui/input';
|
||||
import { Textarea } from '@kandev/ui/textarea';
|
||||
import { Checkbox } from '@kandev/ui/checkbox';
|
||||
import { Label } from '@kandev/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@kandev/ui/select';
|
||||
import type { WorkflowStep, WorkflowStepType, StepBehaviors } from '@/lib/types/http';
|
||||
|
||||
// Debounce hook for text inputs
|
||||
function useDebouncedCallback(
|
||||
callback: (value: string) => void,
|
||||
delay: number
|
||||
): (value: string) => void {
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
return useCallback(
|
||||
function debouncedFn(value: string) {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
callback(value);
|
||||
}, delay);
|
||||
},
|
||||
[callback, delay]
|
||||
);
|
||||
}
|
||||
|
||||
const STEP_COLORS = [
|
||||
{ value: 'bg-slate-500', label: 'Gray' },
|
||||
{ value: 'bg-red-500', label: 'Red' },
|
||||
{ value: 'bg-orange-500', label: 'Orange' },
|
||||
{ value: 'bg-yellow-500', label: 'Yellow' },
|
||||
{ value: 'bg-green-500', label: 'Green' },
|
||||
{ value: 'bg-cyan-500', label: 'Cyan' },
|
||||
{ value: 'bg-blue-500', label: 'Blue' },
|
||||
{ value: 'bg-indigo-500', label: 'Indigo' },
|
||||
{ value: 'bg-purple-500', label: 'Purple' },
|
||||
];
|
||||
|
||||
const STEP_TYPES: { value: WorkflowStepType; label: string }[] = [
|
||||
{ value: 'backlog', label: 'Backlog' },
|
||||
{ value: 'planning', label: 'Planning' },
|
||||
{ value: 'implementation', label: 'Implementation' },
|
||||
{ value: 'review', label: 'Review' },
|
||||
{ value: 'verification', label: 'Verification' },
|
||||
{ value: 'done', label: 'Done' },
|
||||
{ value: 'blocked', label: 'Blocked' },
|
||||
];
|
||||
|
||||
type WorkflowStepEditorProps = {
|
||||
steps: WorkflowStep[];
|
||||
onUpdateStep: (stepId: string, updates: Partial<WorkflowStep>) => void;
|
||||
onAddStep: () => void;
|
||||
onRemoveStep: (stepId: string) => void;
|
||||
onReorderSteps: (steps: WorkflowStep[]) => void;
|
||||
};
|
||||
|
||||
type SortableStepCardProps = {
|
||||
step: WorkflowStep;
|
||||
onUpdate: (updates: Partial<WorkflowStep>) => void;
|
||||
onRemove: () => void;
|
||||
};
|
||||
|
||||
// Inner component that uses step values directly - reset via key prop from parent
|
||||
function SortableStepCardInner({ step, onUpdate, onRemove }: SortableStepCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
// Local state for text inputs to avoid API calls on every keystroke
|
||||
const [localName, setLocalName] = useState(step.name);
|
||||
const [localPromptPrefix, setLocalPromptPrefix] = useState(step.behaviors?.promptPrefix ?? '');
|
||||
const [localPromptSuffix, setLocalPromptSuffix] = useState(step.behaviors?.promptSuffix ?? '');
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
|
||||
id: step.id,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
const behaviors = step.behaviors ?? {};
|
||||
|
||||
// Debounced update functions
|
||||
const debouncedUpdateName = useDebouncedCallback((name: string) => {
|
||||
onUpdate({ name });
|
||||
}, 500);
|
||||
|
||||
const debouncedUpdatePromptPrefix = useDebouncedCallback((promptPrefix: string) => {
|
||||
onUpdate({ behaviors: { ...behaviors, promptPrefix } });
|
||||
}, 500);
|
||||
|
||||
const debouncedUpdatePromptSuffix = useDebouncedCallback((promptSuffix: string) => {
|
||||
onUpdate({ behaviors: { ...behaviors, promptSuffix } });
|
||||
}, 500);
|
||||
|
||||
const updateBehaviors = (updates: Partial<StepBehaviors>) => {
|
||||
onUpdate({ behaviors: { ...behaviors, ...updates } });
|
||||
};
|
||||
|
||||
return (
|
||||
<Card ref={setNodeRef} style={style} className="mb-2">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded-md text-muted-foreground hover:text-foreground cursor-grab"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<IconGripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div className={`w-4 h-4 rounded ${step.color}`} />
|
||||
|
||||
<Input
|
||||
value={localName}
|
||||
onChange={(e) => {
|
||||
setLocalName(e.target.value);
|
||||
debouncedUpdateName(e.target.value);
|
||||
}}
|
||||
className="flex-1"
|
||||
placeholder="Step name"
|
||||
/>
|
||||
|
||||
<Select
|
||||
value={step.step_type}
|
||||
onValueChange={(value: WorkflowStepType) => onUpdate({ step_type: value })}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STEP_TYPES.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="flex items-center gap-1 text-muted-foreground">
|
||||
{behaviors.autoStartAgent && <IconRobot className="h-4 w-4" title="Auto-start agent" />}
|
||||
{behaviors.requireApproval && <IconCheck className="h-4 w-4" title="Require approval" />}
|
||||
{behaviors.planMode && <IconClipboard className="h-4 w-4" title="Plan mode" />}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded ? <IconChevronUp className="h-4 w-4" /> : <IconChevronDown className="h-4 w-4" />}
|
||||
</Button>
|
||||
|
||||
<Button type="button" variant="ghost" size="icon-sm" onClick={onRemove}>
|
||||
<IconTrash className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="mt-4 pt-4 border-t space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`${step.id}-auto-start`}
|
||||
checked={behaviors.autoStartAgent ?? false}
|
||||
onCheckedChange={(checked) => updateBehaviors({ autoStartAgent: checked === true })}
|
||||
/>
|
||||
<Label htmlFor={`${step.id}-auto-start`}>Auto-start agent</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`${step.id}-plan-mode`}
|
||||
checked={behaviors.planMode ?? false}
|
||||
onCheckedChange={(checked) => updateBehaviors({ planMode: checked === true })}
|
||||
/>
|
||||
<Label htmlFor={`${step.id}-plan-mode`}>Plan mode</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`${step.id}-require-approval`}
|
||||
checked={behaviors.requireApproval ?? false}
|
||||
onCheckedChange={(checked) => updateBehaviors({ requireApproval: checked === true })}
|
||||
/>
|
||||
<Label htmlFor={`${step.id}-require-approval`}>Require approval</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${step.id}-prompt-prefix`}>Prompt Prefix</Label>
|
||||
<Textarea
|
||||
id={`${step.id}-prompt-prefix`}
|
||||
value={localPromptPrefix}
|
||||
onChange={(e) => {
|
||||
setLocalPromptPrefix(e.target.value);
|
||||
debouncedUpdatePromptPrefix(e.target.value);
|
||||
}}
|
||||
placeholder="Text prepended to the task description when agent starts..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${step.id}-prompt-suffix`}>Prompt Suffix</Label>
|
||||
<Textarea
|
||||
id={`${step.id}-prompt-suffix`}
|
||||
value={localPromptSuffix}
|
||||
onChange={(e) => {
|
||||
setLocalPromptSuffix(e.target.value);
|
||||
debouncedUpdatePromptSuffix(e.target.value);
|
||||
}}
|
||||
placeholder="Text appended to the task description when agent starts..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${step.id}-color`}>Color</Label>
|
||||
<Select value={step.color} onValueChange={(value) => onUpdate({ color: value })}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-3 h-3 rounded ${step.color}`} />
|
||||
<SelectValue placeholder="Select color" />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STEP_COLORS.map((color) => (
|
||||
<SelectItem key={color.value} value={color.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-3 h-3 rounded ${color.value}`} />
|
||||
{color.label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Wrapper component that uses key to reset local state when step data changes externally
|
||||
function SortableStepCard({ step, onUpdate, onRemove }: SortableStepCardProps) {
|
||||
// Generate a stable key based on step values that should trigger a reset
|
||||
// This resets local state when the step is reordered or updated externally
|
||||
const resetKey = `${step.id}-${step.name}-${step.behaviors?.promptPrefix ?? ''}-${step.behaviors?.promptSuffix ?? ''}`;
|
||||
return <SortableStepCardInner key={resetKey} step={step} onUpdate={onUpdate} onRemove={onRemove} />;
|
||||
}
|
||||
|
||||
export function WorkflowStepEditor({
|
||||
steps,
|
||||
onUpdateStep,
|
||||
onAddStep,
|
||||
onRemoveStep,
|
||||
onReorderSteps,
|
||||
}: WorkflowStepEditorProps) {
|
||||
const stepItems = useMemo(() => steps.map((step) => step.id), [steps]);
|
||||
const isMounted = useSyncExternalStore(
|
||||
() => () => {},
|
||||
() => true,
|
||||
() => false
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const oldIndex = steps.findIndex((step) => step.id === active.id);
|
||||
const newIndex = steps.findIndex((step) => step.id === over.id);
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
const nextSteps = arrayMove(steps, oldIndex, newIndex).map((step, index) => ({
|
||||
...step,
|
||||
position: index,
|
||||
}));
|
||||
onReorderSteps(nextSteps);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium">Workflow Steps</h3>
|
||||
<Button variant="outline" size="sm" onClick={onAddStep}>
|
||||
<IconPlus className="mr-2 h-4 w-4" />
|
||||
Add Step
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{isMounted ? (
|
||||
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={stepItems}>
|
||||
{steps.map((step) => (
|
||||
<SortableStepCard
|
||||
key={step.id}
|
||||
step={step}
|
||||
onUpdate={(updates) => onUpdateStep(step.id, updates)}
|
||||
onRemove={() => onRemoveStep(step.id)}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
) : (
|
||||
steps.map((step) => (
|
||||
<SortableStepCard
|
||||
key={step.id}
|
||||
step={step}
|
||||
onUpdate={(updates) => onUpdateStep(step.id, updates)}
|
||||
onRemove={() => onRemoveStep(step.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,8 +53,8 @@ interface TaskCreateDialogProps {
|
||||
workspaceId: string | null;
|
||||
boardId: string | null;
|
||||
defaultColumnId: string | null;
|
||||
columns: Array<{ id: string; title: string }>;
|
||||
editingTask?: { id: string; title: string; description?: string; columnId: string; state?: Task['state']; repositoryId?: string } | null;
|
||||
columns: Array<{ id: string; title: string; autoStartAgent?: boolean }>;
|
||||
editingTask?: { id: string; title: string; description?: string; workflowStepId: string; state?: Task['state']; repositoryId?: string } | null;
|
||||
onSuccess?: (task: Task, mode: 'create' | 'edit', meta?: { taskSessionId?: string | null }) => void;
|
||||
onCreateSession?: (data: {
|
||||
prompt: string;
|
||||
@@ -538,22 +538,21 @@ export function TaskCreateDialog({
|
||||
}
|
||||
if (!workspaceId || !boardId) return;
|
||||
if (!repositoryId && !selectedLocalRepo) return;
|
||||
const columnId = editingTask?.columnId ?? defaultColumnId;
|
||||
const columnId = editingTask?.workflowStepId ?? defaultColumnId;
|
||||
if (!columnId) return;
|
||||
let targetColumnId = columnId;
|
||||
let targetState: Task['state'] = 'CREATED';
|
||||
if (startAgent && !isEditMode) {
|
||||
const progressColumn = columns.find((column) =>
|
||||
column.title.toLowerCase().includes('progress')
|
||||
);
|
||||
targetColumnId = progressColumn?.id ?? columnId;
|
||||
// Find the first step that has auto_start_agent enabled
|
||||
const autoStartStep = columns.find((column) => column.autoStartAgent);
|
||||
targetColumnId = autoStartStep?.id ?? columnId;
|
||||
targetState = 'IN_PROGRESS';
|
||||
}
|
||||
try {
|
||||
const taskResponse = await createTask({
|
||||
workspace_id: workspaceId,
|
||||
board_id: boardId,
|
||||
column_id: targetColumnId,
|
||||
workflow_step_id: targetColumnId,
|
||||
title: trimmedTitle,
|
||||
description: description.trim(),
|
||||
repositories: repositoryId
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { IconBrain, IconListCheck, IconLoader2, IconPaperclip } from '@tabler/icons-react';
|
||||
import { IconBrain, IconCheck, IconListCheck, IconLoader2, IconPaperclip } from '@tabler/icons-react';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -27,6 +27,8 @@ type ChatToolbarProps = {
|
||||
isStarting: boolean;
|
||||
isSending: boolean;
|
||||
onCancel: () => void;
|
||||
showApproveButton?: boolean;
|
||||
onApprove?: () => void;
|
||||
};
|
||||
|
||||
export function ChatToolbar({
|
||||
@@ -40,6 +42,8 @@ export function ChatToolbar({
|
||||
isStarting,
|
||||
isSending,
|
||||
onCancel,
|
||||
showApproveButton,
|
||||
onApprove,
|
||||
}: ChatToolbarProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
@@ -102,6 +106,22 @@ export function ChatToolbar({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Add attachments</TooltipContent>
|
||||
</Tooltip>
|
||||
{showApproveButton && onApprove && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-7 gap-1 border-green-300 text-green-700 hover:bg-green-50 hover:text-green-800"
|
||||
onClick={onApprove}
|
||||
>
|
||||
<IconCheck className="h-3.5 w-3.5" />
|
||||
Approve
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Approve and move to next step</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<KeyboardShortcutTooltip shortcut={SHORTCUTS.SUBMIT} enabled={!isAgentBusy && !isStarting}>
|
||||
<Button
|
||||
type={isAgentBusy ? 'button' : 'submit'}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkBreaks from 'remark-breaks';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { IconRobot } from '@tabler/icons-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Message } from '@/lib/types/http';
|
||||
import { RichBlocks } from '@/components/task/chat/messages/rich-blocks';
|
||||
@@ -62,7 +61,7 @@ export function ChatMessage({ comment, label, className, showRichBlocks }: ChatM
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||
components={{
|
||||
code: ({ className, children, ...props }) => {
|
||||
code: ({ className, children }) => {
|
||||
// Check if it's inline code (no className means inline, className with language-* means code block)
|
||||
const isInline = !className || !className.startsWith('language-');
|
||||
|
||||
|
||||
@@ -87,7 +87,6 @@ export function CodeBlock({ children, className }: CodeBlockProps) {
|
||||
const effectiveTheme = theme === 'system' ? systemTheme : theme;
|
||||
|
||||
const code = String(children).replace(/\n$/, '');
|
||||
const language = className?.replace('language-', '') || '';
|
||||
const languageExtension = getLanguageExtension(className);
|
||||
|
||||
// Custom padding theme
|
||||
|
||||
@@ -77,12 +77,6 @@ export function ScriptExecutionMessage({ comment }: { comment: Message }) {
|
||||
? IconCheck
|
||||
: IconX;
|
||||
|
||||
const statusText = isRunning
|
||||
? 'Running...'
|
||||
: isSuccess
|
||||
? 'Completed'
|
||||
: 'Failed';
|
||||
|
||||
const statusColor = isRunning
|
||||
? 'text-blue-500'
|
||||
: isSuccess
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { IconAlertTriangle, IconInfoCircle, IconPointFilled, IconHandStop, IconChevronDown, IconChevronRight } from '@tabler/icons-react';
|
||||
import { IconAlertTriangle, IconInfoCircle, IconHandStop, IconChevronDown, IconChevronRight } from '@tabler/icons-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Message } from '@/lib/types/http';
|
||||
import type { StatusMetadata } from '@/components/task/chat/types';
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { IconBrain, IconChevronDown, IconChevronRight } from '@tabler/icons-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { Message } from '@/lib/types/http';
|
||||
import type { RichMetadata } from '@/components/task/chat/types';
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import type { WorkflowStep } from '@/lib/types/http';
|
||||
import { cn } from '@kandev/ui/lib/utils';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@kandev/ui/tooltip';
|
||||
|
||||
type SessionWorkflowProgressProps = {
|
||||
steps: WorkflowStep[];
|
||||
currentStepId: string | null;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function SessionWorkflowProgress({
|
||||
steps,
|
||||
currentStepId,
|
||||
className,
|
||||
}: SessionWorkflowProgressProps) {
|
||||
const currentStepIndex = currentStepId
|
||||
? steps.findIndex((step) => step.id === currentStepId)
|
||||
: -1;
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-1', className)}>
|
||||
{steps.map((step, i) => (
|
||||
<Tooltip key={step.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full',
|
||||
i < currentStepIndex && 'bg-green-500',
|
||||
i === currentStepIndex && 'bg-blue-500 ring-2 ring-blue-200',
|
||||
i > currentStepIndex && 'bg-gray-300'
|
||||
)}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{step.name}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { IconStack2, IconPlus } from '@tabler/icons-react';
|
||||
import { IconStack2, IconPlus, IconStar } from '@tabler/icons-react';
|
||||
import { Button } from '@kandev/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -88,6 +88,8 @@ type SessionsDropdownProps = {
|
||||
activeSessionId?: string | null;
|
||||
taskTitle?: string;
|
||||
taskDescription?: string;
|
||||
primarySessionId?: string | null;
|
||||
onSetPrimary?: (sessionId: string) => void;
|
||||
};
|
||||
|
||||
export function SessionsDropdown({
|
||||
@@ -95,6 +97,8 @@ export function SessionsDropdown({
|
||||
activeSessionId = null,
|
||||
taskTitle = '',
|
||||
taskDescription = '',
|
||||
primarySessionId = null,
|
||||
onSetPrimary,
|
||||
}: SessionsDropdownProps) {
|
||||
const [currentTime, setCurrentTime] = useState(() => Date.now());
|
||||
const [showNewSessionDialog, setShowNewSessionDialog] = useState(false);
|
||||
@@ -242,6 +246,8 @@ export function SessionsDropdown({
|
||||
const showDuration = duration !== '0s';
|
||||
const number = sortedSessions.length - index;
|
||||
|
||||
const isPrimary = session.id === primarySessionId;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
@@ -253,8 +259,11 @@ export function SessionsDropdown({
|
||||
#{number}
|
||||
</span>
|
||||
|
||||
<span className="text-xs text-foreground flex-1 text-left">
|
||||
<span className="text-xs text-foreground flex-1 text-left flex items-center gap-1.5">
|
||||
{resolveAgentLabel(session)}
|
||||
{isPrimary && (
|
||||
<IconStar className="h-3.5 w-3.5 text-amber-500 fill-amber-500" />
|
||||
)}
|
||||
</span>
|
||||
|
||||
{showDuration ?
|
||||
@@ -263,6 +272,24 @@ export function SessionsDropdown({
|
||||
</span>
|
||||
: ''}
|
||||
|
||||
{!isPrimary && onSetPrimary && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSetPrimary(session.id);
|
||||
}}
|
||||
className="w-5 shrink-0 flex items-center justify-center text-muted-foreground hover:text-amber-500 transition-colors"
|
||||
>
|
||||
<IconStar className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">Set as Primary</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<div className="w-5 shrink-0 flex items-center justify-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { FormEvent, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { getWebSocketClient } from '@/lib/ws/connection';
|
||||
import { useAppStore } from '@/components/state-provider';
|
||||
import { useSessionMessages } from '@/hooks/domains/session/use-session-messages';
|
||||
import { useSettingsData } from '@/hooks/domains/settings/use-settings-data';
|
||||
import { useSessionState } from '@/hooks/domains/session/use-session-state';
|
||||
@@ -14,6 +15,7 @@ import { RunningIndicator } from '@/components/task/chat/messages/running-indica
|
||||
import { TodoSummary } from '@/components/task/chat/todo-summary';
|
||||
import { VirtualizedMessageList } from '@/components/task/chat/virtualized-message-list';
|
||||
import { ChatToolbar } from '@/components/task/chat/chat-toolbar';
|
||||
import { approveSessionAction } from '@/app/actions/workspaces';
|
||||
|
||||
type TaskChatPanelProps = {
|
||||
onSend?: (message: string) => void;
|
||||
@@ -89,6 +91,48 @@ export function TaskChatPanel({
|
||||
}
|
||||
}, [resolvedSessionId]);
|
||||
|
||||
// Get store action for updating session
|
||||
const setTaskSession = useAppStore((state) => state.setTaskSession);
|
||||
|
||||
// Review panel handlers
|
||||
const handleApprove = useCallback(async () => {
|
||||
if (!resolvedSessionId || !taskId) return;
|
||||
try {
|
||||
const response = await approveSessionAction(resolvedSessionId);
|
||||
|
||||
// Update the session in the store so the review panel closes
|
||||
if (response?.session) {
|
||||
setTaskSession(response.session);
|
||||
}
|
||||
|
||||
// Check if the new step has auto_start_agent enabled
|
||||
// Use fire-and-forget (send) instead of request since resuming session + sending prompt
|
||||
// can take a long time. Session state updates will come via WebSocket events anyway.
|
||||
if (response?.workflow_step?.auto_start_agent) {
|
||||
const client = getWebSocketClient();
|
||||
if (client) {
|
||||
client.send({
|
||||
type: 'request',
|
||||
action: 'orchestrator.start',
|
||||
payload: {
|
||||
task_id: taskId,
|
||||
session_id: resolvedSessionId,
|
||||
workflow_step_id: response.workflow_step.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to approve session:', error);
|
||||
}
|
||||
}, [resolvedSessionId, taskId, setTaskSession]);
|
||||
|
||||
|
||||
|
||||
// Check if we should show the approve button
|
||||
// Show when session has a review_status that is not approved (meaning it's in a review step)
|
||||
const showApproveButton = session?.review_status != null && session.review_status !== 'approved';
|
||||
|
||||
const handleSubmit = async (event?: FormEvent) => {
|
||||
event?.preventDefault();
|
||||
const trimmed = messageInput.trim();
|
||||
@@ -155,6 +199,8 @@ export function TaskChatPanel({
|
||||
isStarting={isStarting}
|
||||
isSending={isSending}
|
||||
onCancel={handleCancelTurn}
|
||||
showApproveButton={showApproveButton}
|
||||
onApprove={handleApprove}
|
||||
/>
|
||||
</form>
|
||||
</>
|
||||
|
||||
@@ -55,7 +55,7 @@ export function TaskPageContent({
|
||||
...baseTask,
|
||||
title: kanbanTask.title ?? baseTask.title,
|
||||
description: kanbanTask.description ?? baseTask.description,
|
||||
column_id: (kanbanTask.columnId as string | undefined) ?? baseTask.column_id,
|
||||
workflow_step_id: (kanbanTask.workflowStepId as string | undefined) ?? baseTask.workflow_step_id,
|
||||
position: kanbanTask.position ?? baseTask.position,
|
||||
state: (kanbanTask.state as Task['state'] | undefined) ?? baseTask.state,
|
||||
repositories: baseTask.repositories,
|
||||
|
||||
@@ -23,7 +23,7 @@ export function TaskSessionSidebar({ workspaceId, boardId }: TaskSessionSidebarP
|
||||
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
|
||||
const sessionsById = useAppStore((state) => state.taskSessions.items);
|
||||
const { tasks } = useTasks(boardId);
|
||||
const columns = useAppStore((state) => state.kanban.columns);
|
||||
const columns = useAppStore((state) => state.kanban.steps);
|
||||
const workspaces = useAppStore((state) => state.workspaces.items);
|
||||
const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId);
|
||||
const setActiveTask = useAppStore((state) => state.setActiveTask);
|
||||
@@ -52,7 +52,7 @@ export function TaskSessionSidebar({ workspaceId, boardId }: TaskSessionSidebarP
|
||||
title: task.title,
|
||||
state: task.state as TaskState | undefined,
|
||||
description: task.description,
|
||||
columnId: task.columnId,
|
||||
workflowStepId: task.workflowStepId,
|
||||
repositoryPath: task.repositoryId ? repositoryPathsById.get(task.repositoryId) : undefined,
|
||||
}));
|
||||
}, [repositoriesByWorkspace, tasks, workspaceId]);
|
||||
@@ -121,7 +121,7 @@ export function TaskSessionSidebar({ workspaceId, boardId }: TaskSessionSidebarP
|
||||
<div className="flex-1 min-h-0 overflow-y-auto space-y-4 pt-3">
|
||||
<TaskSwitcher
|
||||
tasks={tasksWithRepositories}
|
||||
columns={columns.map((column: KanbanState['columns'][number]) => ({ id: column.id, title: column.title }))}
|
||||
columns={columns.map((step: KanbanState['steps'][number]) => ({ id: step.id, title: step.title, color: step.color }))}
|
||||
activeTaskId={activeTaskId}
|
||||
selectedTaskId={selectedTaskId}
|
||||
onSelectTask={(taskId) => {
|
||||
@@ -137,13 +137,13 @@ export function TaskSessionSidebar({ workspaceId, boardId }: TaskSessionSidebarP
|
||||
workspaceId={workspaceId}
|
||||
boardId={boardId}
|
||||
defaultColumnId={columns[0]?.id ?? null}
|
||||
columns={columns.map((column: KanbanState['columns'][number]) => ({ id: column.id, title: column.title }))}
|
||||
columns={columns.map((step: KanbanState['steps'][number]) => ({ id: step.id, title: step.title, color: step.color }))}
|
||||
onSuccess={(task, _mode, meta) => {
|
||||
store.setState((state) => {
|
||||
if (state.kanban.boardId !== task.board_id) return state;
|
||||
const nextTask = {
|
||||
id: task.id,
|
||||
columnId: task.column_id,
|
||||
workflowStepId: task.workflow_step_id,
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
position: task.position ?? 0,
|
||||
|
||||
@@ -19,13 +19,13 @@ type TaskSwitcherItem = {
|
||||
title: string;
|
||||
state?: TaskState;
|
||||
description?: string;
|
||||
columnId?: string;
|
||||
workflowStepId?: string;
|
||||
repositoryPath?: string;
|
||||
};
|
||||
|
||||
type TaskSwitcherProps = {
|
||||
tasks: TaskSwitcherItem[];
|
||||
columns: Array<{ id: string; title: string }>;
|
||||
columns: Array<{ id: string; title: string; color?: string }>;
|
||||
activeTaskId: string | null;
|
||||
selectedTaskId: string | null;
|
||||
onSelectTask: (taskId: string) => void;
|
||||
@@ -47,7 +47,7 @@ export function TaskSwitcher({
|
||||
const tasksByColumn = useMemo(() => {
|
||||
const grouped: Record<string, TaskSwitcherItem[]> = {};
|
||||
for (const task of sortedTasks) {
|
||||
const key = task.columnId ?? 'unknown';
|
||||
const key = task.workflowStepId ?? 'unknown';
|
||||
if (!grouped[key]) grouped[key] = [];
|
||||
grouped[key].push(task);
|
||||
}
|
||||
@@ -56,9 +56,9 @@ export function TaskSwitcher({
|
||||
|
||||
const columnsWithFallback = useMemo(() => {
|
||||
const seen = new Set(columns.map((column) => column.id));
|
||||
const extra: Array<{ id: string; title: string }> = [];
|
||||
const extra: Array<{ id: string; title: string; color?: string }> = [];
|
||||
if (tasksByColumn.unknown?.length) {
|
||||
extra.push({ id: 'unknown', title: 'Other' });
|
||||
extra.push({ id: 'unknown', title: 'Other', color: 'bg-neutral-400' });
|
||||
}
|
||||
return [...columns, ...extra].filter((column) => {
|
||||
if (seen.has(column.id)) return true;
|
||||
@@ -95,7 +95,10 @@ export function TaskSwitcher({
|
||||
className="group flex w-full items-center justify-between cursor-pointer"
|
||||
aria-label="Toggle column"
|
||||
>
|
||||
<span className="truncate text-sm font-medium text-muted-foreground">{column.title}</span>
|
||||
<span className="flex items-center gap-2 truncate text-sm font-medium text-muted-foreground">
|
||||
<span className={`w-2 h-2 rounded-full shrink-0 ${column.color ?? 'bg-neutral-400'}`} />
|
||||
{column.title}
|
||||
</span>
|
||||
<IconChevronRight className={`h-4 w-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity ${isColumnOpen ? 'rotate-90' : ''}`} />
|
||||
</button>
|
||||
{isColumnOpen && (
|
||||
|
||||
@@ -40,7 +40,7 @@ export function useKanbanActions({ kanban, workspaceState, boardsState }: UseKan
|
||||
...kanban.tasks,
|
||||
{
|
||||
id: task.id,
|
||||
columnId: task.column_id,
|
||||
workflowStepId: task.workflow_step_id,
|
||||
title: task.title,
|
||||
description: task.description ?? undefined,
|
||||
position: task.position ?? 0,
|
||||
@@ -61,7 +61,7 @@ export function useKanbanActions({ kanban, workspaceState, boardsState }: UseKan
|
||||
...item,
|
||||
title: task.title,
|
||||
description: task.description ?? undefined,
|
||||
columnId: task.column_id ?? item.columnId,
|
||||
workflowStepId: task.workflow_step_id ?? item.workflowStepId,
|
||||
position: task.position ?? item.position,
|
||||
state: task.state ?? item.state,
|
||||
repositoryId: task.repositories?.[0]?.repository_id ?? item.repositoryId,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useBoards } from '@/hooks/use-boards';
|
||||
import { useBoardSnapshot } from '@/hooks/use-board-snapshot';
|
||||
import { useUserDisplaySettings } from '@/hooks/use-user-display-settings';
|
||||
import { filterTasksByRepositories } from '@/lib/kanban/filters';
|
||||
import type { Column } from '@/components/kanban-column';
|
||||
import type { WorkflowStep } from '@/components/kanban-column';
|
||||
|
||||
type KanbanDataOptions = {
|
||||
onWorkspaceChange: (workspaceId: string | null) => void;
|
||||
@@ -46,26 +46,28 @@ export function useKanbanData({ onWorkspaceChange, onBoardChange }: KanbanDataOp
|
||||
);
|
||||
|
||||
// Derived data
|
||||
const columns = useMemo<Column[]>(
|
||||
const columns = useMemo<WorkflowStep[]>(
|
||||
() =>
|
||||
[...kanban.columns]
|
||||
[...kanban.steps]
|
||||
.sort((a, b) => (a.position ?? 0) - (b.position ?? 0))
|
||||
.map((column) => ({
|
||||
id: column.id,
|
||||
title: column.title,
|
||||
color: column.color || 'bg-neutral-400',
|
||||
.map((step) => ({
|
||||
id: step.id,
|
||||
title: step.title,
|
||||
color: step.color || 'bg-neutral-400',
|
||||
autoStartAgent: step.autoStartAgent,
|
||||
})),
|
||||
[kanban.columns]
|
||||
[kanban.steps]
|
||||
);
|
||||
|
||||
const tasks = kanban.tasks.map((task: typeof kanban.tasks[number]) => ({
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
columnId: task.columnId,
|
||||
workflowStepId: task.workflowStepId,
|
||||
state: task.state,
|
||||
description: task.description,
|
||||
position: task.position,
|
||||
repositoryId: task.repositoryId,
|
||||
primarySessionId: task.primarySessionId,
|
||||
}));
|
||||
|
||||
const activeColumns = kanban.boardId ? columns : [];
|
||||
|
||||
@@ -6,15 +6,40 @@ import { useAppStore, useAppStoreApi } from '@/components/state-provider';
|
||||
import { useTaskActions } from '@/hooks/use-task-actions';
|
||||
import type { Task } from '@/components/kanban-card';
|
||||
import type { KanbanState } from '@/lib/state/slices';
|
||||
import type { WorkflowStepDTO } from '@/lib/types/http';
|
||||
import { getWebSocketClient } from '@/lib/ws/connection';
|
||||
|
||||
export type WorkflowAutomation = {
|
||||
taskId: string;
|
||||
sessionId: string | null;
|
||||
workflowStep: WorkflowStepDTO;
|
||||
taskDescription: string;
|
||||
};
|
||||
|
||||
export type MoveTaskError = {
|
||||
message: string;
|
||||
taskId: string;
|
||||
sessionId: string | null;
|
||||
};
|
||||
|
||||
export type DragAndDropOptions = {
|
||||
visibleTasks: Task[];
|
||||
onWorkflowAutomation?: (automation: WorkflowAutomation) => void;
|
||||
onMoveError?: (error: MoveTaskError) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom hook that extracts drag-and-drop logic from the KanbanBoard component.
|
||||
* Manages drag state and provides handlers for drag operations.
|
||||
*
|
||||
* @param visibleTasks - The list of visible tasks (filtered by repository, etc.)
|
||||
* @param options - Configuration options for drag and drop
|
||||
* @returns Object with drag state and drag operation handlers
|
||||
*/
|
||||
export function useDragAndDrop(visibleTasks: Task[]) {
|
||||
export function useDragAndDrop({
|
||||
visibleTasks,
|
||||
onWorkflowAutomation,
|
||||
onMoveError,
|
||||
}: DragAndDropOptions) {
|
||||
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
|
||||
const [isMovingTask, setIsMovingTask] = useState(false);
|
||||
const { moveTaskById } = useTaskActions();
|
||||
@@ -39,18 +64,25 @@ export function useDragAndDrop(visibleTasks: Task[]) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the task being moved from visibleTasks (which has session info)
|
||||
const movedTask = visibleTasks.find((t) => t.id === taskId);
|
||||
if (!movedTask) return;
|
||||
|
||||
const targetTasks = kanban.tasks
|
||||
.filter((task: KanbanState['tasks'][number]) => task.columnId === newStatus && task.id !== taskId)
|
||||
.filter((task: KanbanState['tasks'][number]) => task.workflowStepId === newStatus && task.id !== taskId)
|
||||
.sort((a: KanbanState['tasks'][number], b: KanbanState['tasks'][number]) => a.position - b.position);
|
||||
const nextPosition = targetTasks.length;
|
||||
|
||||
// Save original state for rollback
|
||||
const originalTasks = kanban.tasks;
|
||||
|
||||
// Optimistic update
|
||||
store.getState().hydrate({
|
||||
kanban: {
|
||||
...kanban,
|
||||
tasks: kanban.tasks.map((task: KanbanState['tasks'][number]) =>
|
||||
task.id === taskId
|
||||
? { ...task, columnId: newStatus, position: nextPosition }
|
||||
? { ...task, workflowStepId: newStatus, position: nextPosition }
|
||||
: task
|
||||
),
|
||||
},
|
||||
@@ -58,18 +90,64 @@ export function useDragAndDrop(visibleTasks: Task[]) {
|
||||
|
||||
try {
|
||||
setIsMovingTask(true);
|
||||
await moveTaskById(taskId, {
|
||||
const response = await moveTaskById(taskId, {
|
||||
board_id: kanban.boardId,
|
||||
column_id: newStatus,
|
||||
workflow_step_id: newStatus,
|
||||
position: nextPosition,
|
||||
});
|
||||
} catch {
|
||||
// Ignore move errors for now; WS updates or next snapshot will correct.
|
||||
|
||||
// Check if target step has auto_start_agent enabled
|
||||
if (response?.workflow_step?.auto_start_agent) {
|
||||
const sessionId = movedTask.primarySessionId ?? null;
|
||||
|
||||
if (sessionId) {
|
||||
// Task has a session - auto-start with workflow step config
|
||||
const client = getWebSocketClient();
|
||||
if (client) {
|
||||
try {
|
||||
await client.request(
|
||||
'orchestrator.start',
|
||||
{
|
||||
task_id: taskId,
|
||||
session_id: sessionId,
|
||||
workflow_step_id: response.workflow_step.id,
|
||||
},
|
||||
15000
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Failed to auto-start session for workflow step:', err);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No session - notify parent to show session creation dialog
|
||||
onWorkflowAutomation?.({
|
||||
taskId,
|
||||
sessionId: null,
|
||||
workflowStep: response.workflow_step,
|
||||
taskDescription: movedTask.description ?? '',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Revert optimistic update on error
|
||||
store.getState().hydrate({
|
||||
kanban: {
|
||||
...store.getState().kanban,
|
||||
tasks: originalTasks,
|
||||
},
|
||||
});
|
||||
// Show error to user with task context
|
||||
const message = error instanceof Error ? error.message : 'Failed to move task';
|
||||
onMoveError?.({
|
||||
message,
|
||||
taskId,
|
||||
sessionId: movedTask.primarySessionId ?? null,
|
||||
});
|
||||
} finally {
|
||||
setIsMovingTask(false);
|
||||
}
|
||||
},
|
||||
[kanban, isMovingTask, moveTaskById, store]
|
||||
[kanban, isMovingTask, moveTaskById, store, onWorkflowAutomation, onMoveError, visibleTasks]
|
||||
);
|
||||
|
||||
const handleDragCancel = useCallback(() => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { deleteTask, moveTask } from '@/lib/api';
|
||||
|
||||
type MovePayload = { board_id: string; column_id: string; position: number };
|
||||
type MovePayload = { board_id: string; workflow_step_id: string; position: number };
|
||||
|
||||
export function useTaskActions() {
|
||||
const moveTaskById = useCallback(async (taskId: string, payload: MovePayload) => {
|
||||
|
||||
@@ -25,7 +25,17 @@ export async function fetchJson<T>(pathOrUrl: string, options?: ApiRequestOption
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed: ${response.status} ${response.statusText} (${url})`);
|
||||
// Try to extract error message from response body
|
||||
let errorMessage = `Request failed: ${response.status} ${response.statusText}`;
|
||||
try {
|
||||
const errorBody = await response.json();
|
||||
if (errorBody?.error) {
|
||||
errorMessage = errorBody.error;
|
||||
}
|
||||
} catch {
|
||||
// Ignore JSON parse errors, use default message
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
ListBoardsResponse,
|
||||
CreateTaskResponse,
|
||||
Task,
|
||||
MoveTaskResponse,
|
||||
} from '@/lib/types/http';
|
||||
|
||||
// Board operations
|
||||
@@ -24,7 +25,7 @@ export async function createTask(
|
||||
payload: {
|
||||
workspace_id: string;
|
||||
board_id: string;
|
||||
column_id: string;
|
||||
workflow_step_id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
position?: number;
|
||||
@@ -77,10 +78,10 @@ export async function deleteTask(taskId: string, options?: ApiRequestOptions) {
|
||||
|
||||
export async function moveTask(
|
||||
taskId: string,
|
||||
payload: { board_id: string; column_id: string; position: number },
|
||||
payload: { board_id: string; workflow_step_id: string; position: number },
|
||||
options?: ApiRequestOptions
|
||||
) {
|
||||
return fetchJson<Task>(`/api/v1/tasks/${taskId}/move`, {
|
||||
return fetchJson<MoveTaskResponse>(`/api/v1/tasks/${taskId}/move`, {
|
||||
...options,
|
||||
init: { method: 'POST', body: JSON.stringify(payload), ...(options?.init ?? {}) },
|
||||
});
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { fetchJson, type ApiRequestOptions } from '../client';
|
||||
import type {
|
||||
ListWorkflowTemplatesResponse,
|
||||
ListWorkflowStepsResponse,
|
||||
ListSessionStepHistoryResponse,
|
||||
WorkflowTemplate,
|
||||
WorkflowStep,
|
||||
} from '@/lib/types/http';
|
||||
|
||||
// Workflow Template operations
|
||||
export async function listWorkflowTemplates(options?: ApiRequestOptions) {
|
||||
return fetchJson<ListWorkflowTemplatesResponse>('/api/v1/workflow/templates', options);
|
||||
}
|
||||
|
||||
export async function getWorkflowTemplate(templateId: string, options?: ApiRequestOptions) {
|
||||
return fetchJson<WorkflowTemplate>(`/api/v1/workflow/templates/${templateId}`, options);
|
||||
}
|
||||
|
||||
// Workflow Step operations
|
||||
export async function listWorkflowSteps(boardId: string, options?: ApiRequestOptions) {
|
||||
return fetchJson<ListWorkflowStepsResponse>(`/api/v1/workflow/steps?board_id=${boardId}`, options);
|
||||
}
|
||||
|
||||
export async function getWorkflowStep(stepId: string, options?: ApiRequestOptions) {
|
||||
return fetchJson<WorkflowStep>(`/api/v1/workflow/steps/${stepId}`, options);
|
||||
}
|
||||
|
||||
export async function createWorkflowStep(
|
||||
payload: {
|
||||
board_id: string;
|
||||
name: string;
|
||||
step_type: string;
|
||||
position: number;
|
||||
color?: string;
|
||||
behaviors?: Record<string, unknown>;
|
||||
},
|
||||
options?: ApiRequestOptions
|
||||
) {
|
||||
return fetchJson<WorkflowStep>('/api/v1/workflow/steps', {
|
||||
...options,
|
||||
init: { method: 'POST', body: JSON.stringify(payload), ...(options?.init ?? {}) },
|
||||
});
|
||||
}
|
||||
|
||||
// Session Step History operations
|
||||
export async function listSessionStepHistory(sessionId: string, options?: ApiRequestOptions) {
|
||||
return fetchJson<ListSessionStepHistoryResponse>(
|
||||
`/api/v1/workflow/history?session_id=${sessionId}`,
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,16 +6,17 @@ type KanbanTask = KanbanState['tasks'][number];
|
||||
export function snapshotToState(snapshot: BoardSnapshot): Partial<AppState> {
|
||||
const tasks = snapshot.tasks
|
||||
.map((task) => {
|
||||
const columnId = task.column_id;
|
||||
if (!columnId) return null;
|
||||
const workflowStepId = task.workflow_step_id;
|
||||
if (!workflowStepId) return null;
|
||||
return {
|
||||
id: task.id,
|
||||
columnId,
|
||||
workflowStepId,
|
||||
title: task.title,
|
||||
description: task.description ?? undefined,
|
||||
position: task.position ?? 0,
|
||||
state: task.state,
|
||||
repositoryId: task.repositories?.[0]?.repository_id ?? undefined,
|
||||
primarySessionId: task.primary_session_id ?? undefined,
|
||||
} as KanbanTask;
|
||||
})
|
||||
.filter((task): task is KanbanTask => task !== null);
|
||||
@@ -23,11 +24,12 @@ export function snapshotToState(snapshot: BoardSnapshot): Partial<AppState> {
|
||||
return {
|
||||
kanban: {
|
||||
boardId: snapshot.board.id,
|
||||
columns: snapshot.columns.map((column) => ({
|
||||
id: column.id,
|
||||
title: column.name,
|
||||
color: column.color ?? 'bg-neutral-400',
|
||||
position: column.position,
|
||||
steps: snapshot.steps.map((step) => ({
|
||||
id: step.id,
|
||||
title: step.name,
|
||||
color: step.color ?? 'bg-neutral-400',
|
||||
position: step.position,
|
||||
autoStartAgent: step.auto_start_agent ?? false,
|
||||
})),
|
||||
tasks,
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { StateCreator } from 'zustand';
|
||||
import type { KanbanSlice, KanbanSliceState } from './types';
|
||||
|
||||
export const defaultKanbanState: KanbanSliceState = {
|
||||
kanban: { boardId: null, columns: [], tasks: [] },
|
||||
kanban: { boardId: null, steps: [], tasks: [] },
|
||||
boards: { items: [], activeId: null },
|
||||
tasks: { activeTaskId: null, activeSessionId: null },
|
||||
};
|
||||
|
||||
@@ -2,15 +2,16 @@ import type { TaskState as TaskStatus } from '@/lib/types/http';
|
||||
|
||||
export type KanbanState = {
|
||||
boardId: string | null;
|
||||
columns: Array<{ id: string; title: string; color: string; position: number }>;
|
||||
steps: Array<{ id: string; title: string; color: string; position: number; autoStartAgent?: boolean }>;
|
||||
tasks: Array<{
|
||||
id: string;
|
||||
columnId: string;
|
||||
workflowStepId: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
position: number;
|
||||
state?: TaskStatus;
|
||||
repositoryId?: string;
|
||||
primarySessionId?: string | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
|
||||
@@ -65,7 +65,10 @@ export const createSessionSlice: StateCreator<
|
||||
prependMessages: (sessionId, messages, meta) =>
|
||||
set((draft) => {
|
||||
const existing = draft.messages.bySession[sessionId] || [];
|
||||
draft.messages.bySession[sessionId] = [...messages, ...existing];
|
||||
// Deduplicate - only add messages that don't already exist
|
||||
const existingIds = new Set(existing.map((m) => m.id));
|
||||
const newMessages = messages.filter((m) => !existingIds.has(m.id));
|
||||
draft.messages.bySession[sessionId] = [...newMessages, ...existing];
|
||||
if (!draft.messages.metaBySession[sessionId]) {
|
||||
draft.messages.metaBySession[sessionId] = {
|
||||
isLoading: false,
|
||||
@@ -142,14 +145,24 @@ export const createSessionSlice: StateCreator<
|
||||
set((draft) => {
|
||||
// Merge with existing session data to preserve fields like agent_profile_id and agent_profile_snapshot
|
||||
const existingSession = draft.taskSessions.items[session.id];
|
||||
if (existingSession) {
|
||||
draft.taskSessions.items[session.id] = {
|
||||
...existingSession,
|
||||
...session,
|
||||
agent_profile_snapshot: session.agent_profile_snapshot ?? existingSession.agent_profile_snapshot,
|
||||
};
|
||||
} else {
|
||||
draft.taskSessions.items[session.id] = session;
|
||||
const mergedSession = existingSession
|
||||
? {
|
||||
...existingSession,
|
||||
...session,
|
||||
agent_profile_snapshot: session.agent_profile_snapshot ?? existingSession.agent_profile_snapshot,
|
||||
}
|
||||
: session;
|
||||
|
||||
draft.taskSessions.items[session.id] = mergedSession;
|
||||
|
||||
// Also update taskSessionsByTask to keep both stores in sync
|
||||
const taskId = session.task_id;
|
||||
const sessionsByTask = draft.taskSessionsByTask.itemsByTaskId[taskId];
|
||||
if (sessionsByTask) {
|
||||
const sessionIndex = sessionsByTask.findIndex((s) => s.id === session.id);
|
||||
if (sessionIndex >= 0) {
|
||||
sessionsByTask[sessionIndex] = mergedSession;
|
||||
}
|
||||
}
|
||||
}),
|
||||
setTaskSessionsForTask: (taskId, sessions) =>
|
||||
|
||||
@@ -56,10 +56,10 @@ import type { GitEventPayload } from '@/lib/types/git-events';
|
||||
|
||||
export type KanbanUpdatePayload = {
|
||||
boardId: string;
|
||||
columns: Array<{ id: string; title: string; color?: string; position?: number }>;
|
||||
steps: Array<{ id: string; title: string; color?: string; position?: number; autoStartAgent?: boolean }>;
|
||||
tasks: Array<{
|
||||
id: string;
|
||||
columnId: string;
|
||||
workflowStepId: string;
|
||||
title: string;
|
||||
position?: number;
|
||||
description?: string;
|
||||
@@ -70,7 +70,7 @@ export type KanbanUpdatePayload = {
|
||||
export type TaskEventPayload = {
|
||||
task_id: string;
|
||||
board_id: string;
|
||||
column_id: string;
|
||||
workflow_step_id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
state?: TaskState;
|
||||
@@ -138,6 +138,7 @@ export type ColumnPayload = {
|
||||
position: number;
|
||||
state: string;
|
||||
color: string;
|
||||
auto_start_agent?: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
};
|
||||
@@ -159,10 +160,13 @@ export type TaskSessionStateChangedPayload = {
|
||||
task_id: string;
|
||||
session_id: string;
|
||||
old_state?: string;
|
||||
new_state: string;
|
||||
new_state?: string;
|
||||
agent_profile_id?: string;
|
||||
agent_profile_snapshot?: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown>;
|
||||
// Workflow-related fields (sent during workflow transitions)
|
||||
review_status?: string;
|
||||
workflow_step_id?: string;
|
||||
};
|
||||
|
||||
export type TaskSessionWaitingForInputPayload = {
|
||||
|
||||
+124
-18
@@ -10,6 +10,94 @@ export type TaskState =
|
||||
| 'FAILED'
|
||||
| 'CANCELLED';
|
||||
|
||||
// Workflow Step Types
|
||||
export type WorkflowStepType =
|
||||
| 'backlog'
|
||||
| 'planning'
|
||||
| 'implementation'
|
||||
| 'review'
|
||||
| 'verification'
|
||||
| 'done'
|
||||
| 'blocked';
|
||||
|
||||
// Workflow Review Status
|
||||
export type WorkflowReviewStatus =
|
||||
| 'pending'
|
||||
| 'approved'
|
||||
| 'changes_requested'
|
||||
| 'rejected';
|
||||
|
||||
// Step Behaviors - controls auto-start and other step-specific behaviors
|
||||
export type StepBehaviors = {
|
||||
autoStartAgent?: boolean;
|
||||
planMode?: boolean;
|
||||
requireApproval?: boolean;
|
||||
promptPrefix?: string;
|
||||
promptSuffix?: string;
|
||||
};
|
||||
|
||||
// Workflow Template - pre-defined workflow configurations
|
||||
export type WorkflowTemplate = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
is_system: boolean;
|
||||
default_steps?: StepDefinition[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
// Step Definition - template step configuration
|
||||
export type StepDefinition = {
|
||||
name: string;
|
||||
step_type: WorkflowStepType;
|
||||
position: number;
|
||||
color?: string;
|
||||
behaviors?: StepBehaviors;
|
||||
};
|
||||
|
||||
// Workflow Step - instance of a step on a board
|
||||
export type WorkflowStep = {
|
||||
id: string;
|
||||
board_id: string;
|
||||
name: string;
|
||||
step_type: WorkflowStepType;
|
||||
position: number;
|
||||
color: string;
|
||||
behaviors?: StepBehaviors;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
// Session Step History - audit trail
|
||||
export type SessionStepHistory = {
|
||||
id: string;
|
||||
session_id: string;
|
||||
from_step_id?: string;
|
||||
to_step_id: string;
|
||||
trigger: string;
|
||||
actor_id?: string;
|
||||
notes?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
// Response types for workflow APIs
|
||||
export type ListWorkflowTemplatesResponse = {
|
||||
templates: WorkflowTemplate[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type ListWorkflowStepsResponse = {
|
||||
steps: WorkflowStep[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type ListSessionStepHistoryResponse = {
|
||||
history: SessionStepHistory[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type TaskSessionState =
|
||||
| 'CREATED'
|
||||
| 'STARTING'
|
||||
@@ -24,6 +112,7 @@ export type Board = {
|
||||
workspace_id: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
workflow_template_id?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
@@ -40,17 +129,6 @@ export type Workspace = {
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type Column = {
|
||||
id: string;
|
||||
board_id: string;
|
||||
name: string;
|
||||
position: number;
|
||||
state: TaskState;
|
||||
color: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type Repository = {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
@@ -116,13 +194,14 @@ export type Task = {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
board_id: string;
|
||||
column_id: string;
|
||||
workflow_step_id: string;
|
||||
position: number;
|
||||
title: string;
|
||||
description: string;
|
||||
state: TaskState;
|
||||
priority: number;
|
||||
repositories?: TaskRepository[];
|
||||
primary_session_id?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
@@ -133,6 +212,28 @@ export type CreateTaskResponse = Task & {
|
||||
agent_execution_id?: string;
|
||||
};
|
||||
|
||||
// Backend workflow step DTO (flat fields, as returned from API)
|
||||
export type WorkflowStepDTO = {
|
||||
id: string;
|
||||
board_id: string;
|
||||
name: string;
|
||||
step_type: string;
|
||||
position: number;
|
||||
color: string;
|
||||
auto_start_agent: boolean;
|
||||
plan_mode: boolean;
|
||||
require_approval: boolean;
|
||||
prompt_prefix?: string;
|
||||
prompt_suffix?: string;
|
||||
allow_manual_move: boolean;
|
||||
};
|
||||
|
||||
// Response from moving a task - includes workflow step info for automation
|
||||
export type MoveTaskResponse = {
|
||||
task: Task;
|
||||
workflow_step: WorkflowStepDTO;
|
||||
};
|
||||
|
||||
export type TaskSession = {
|
||||
id: string;
|
||||
task_id: string;
|
||||
@@ -156,6 +257,10 @@ export type TaskSession = {
|
||||
started_at: string;
|
||||
completed_at?: string | null;
|
||||
updated_at: string;
|
||||
// Workflow fields
|
||||
is_primary?: boolean;
|
||||
workflow_step_id?: string;
|
||||
review_status?: WorkflowReviewStatus;
|
||||
};
|
||||
|
||||
export type TaskSessionsResponse = {
|
||||
@@ -167,6 +272,12 @@ export type TaskSessionResponse = {
|
||||
session: TaskSession;
|
||||
};
|
||||
|
||||
export type ApproveSessionResponse = {
|
||||
success: boolean;
|
||||
session: TaskSession;
|
||||
workflow_step?: WorkflowStepDTO;
|
||||
};
|
||||
|
||||
export type NotificationProviderType = 'local' | 'apprise' | 'system';
|
||||
|
||||
export type NotificationProvider = {
|
||||
@@ -247,7 +358,7 @@ export type UserResponse = {
|
||||
|
||||
export type BoardSnapshot = {
|
||||
board: Board;
|
||||
columns: Column[];
|
||||
steps: WorkflowStepDTO[];
|
||||
tasks: Task[];
|
||||
};
|
||||
|
||||
@@ -256,11 +367,6 @@ export type ListBoardsResponse = {
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type ListColumnsResponse = {
|
||||
columns: Column[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type ListRepositoriesResponse = {
|
||||
repositories: Repository[];
|
||||
total: number;
|
||||
|
||||
@@ -7,12 +7,14 @@ export function registerTaskSessionHandlers(store: StoreApi<AppState>): WsHandle
|
||||
return {
|
||||
'session.state_changed': (message) => {
|
||||
const payload = message.payload;
|
||||
if (!payload?.task_id || !payload?.new_state) {
|
||||
if (!payload?.task_id) {
|
||||
return;
|
||||
}
|
||||
const taskId = payload.task_id;
|
||||
const newState = payload.new_state as TaskSessionState;
|
||||
const newState = payload.new_state as TaskSessionState | undefined;
|
||||
const sessionId = payload.session_id;
|
||||
const reviewStatus = payload.review_status as string | undefined;
|
||||
const workflowStepId = payload.workflow_step_id as string | undefined;
|
||||
|
||||
// Also update or create the session object if we have the session ID
|
||||
if (sessionId) {
|
||||
@@ -20,16 +22,21 @@ export function registerTaskSessionHandlers(store: StoreApi<AppState>): WsHandle
|
||||
const agentProfileId = payload.agent_profile_id;
|
||||
const agentProfileSnapshot = payload.agent_profile_snapshot as Record<string, unknown> | undefined;
|
||||
|
||||
// Build the update object with all possible fields
|
||||
const sessionUpdate: Record<string, unknown> = {};
|
||||
if (newState) sessionUpdate.state = newState;
|
||||
if (reviewStatus !== undefined) sessionUpdate.review_status = reviewStatus;
|
||||
if (workflowStepId !== undefined) sessionUpdate.workflow_step_id = workflowStepId;
|
||||
if (agentProfileSnapshot) sessionUpdate.agent_profile_snapshot = agentProfileSnapshot;
|
||||
|
||||
if (existingSession) {
|
||||
// Update existing session - preserve all existing fields, but update snapshot if provided
|
||||
// Update existing session - preserve all existing fields
|
||||
store.getState().setTaskSession({
|
||||
...existingSession,
|
||||
state: newState,
|
||||
...(agentProfileSnapshot ? { agent_profile_snapshot: agentProfileSnapshot } : {}),
|
||||
...sessionUpdate,
|
||||
});
|
||||
} else {
|
||||
// Create partial session object
|
||||
// Include agent_profile_id and agent_profile_snapshot from the payload if provided
|
||||
} else if (newState) {
|
||||
// Create partial session object only if we have a state
|
||||
store.getState().setTaskSession({
|
||||
id: sessionId,
|
||||
task_id: taskId,
|
||||
@@ -37,14 +44,14 @@ export function registerTaskSessionHandlers(store: StoreApi<AppState>): WsHandle
|
||||
started_at: '',
|
||||
updated_at: '',
|
||||
...(agentProfileId ? { agent_profile_id: agentProfileId } : {}),
|
||||
...(agentProfileSnapshot ? { agent_profile_snapshot: agentProfileSnapshot } : {}),
|
||||
...sessionUpdate,
|
||||
});
|
||||
}
|
||||
|
||||
const sessionsByTask = store.getState().taskSessionsByTask.itemsByTaskId[taskId];
|
||||
if (sessionsByTask) {
|
||||
const hasSession = sessionsByTask.some((session) => session.id === sessionId);
|
||||
if (!hasSession) {
|
||||
if (!hasSession && newState) {
|
||||
store.getState().setTaskSessionsForTask(taskId, [...sessionsByTask, {
|
||||
id: sessionId,
|
||||
task_id: taskId,
|
||||
@@ -52,12 +59,12 @@ export function registerTaskSessionHandlers(store: StoreApi<AppState>): WsHandle
|
||||
started_at: '',
|
||||
updated_at: '',
|
||||
...(agentProfileId ? { agent_profile_id: agentProfileId } : {}),
|
||||
...(agentProfileSnapshot ? { agent_profile_snapshot: agentProfileSnapshot } : {}),
|
||||
...sessionUpdate,
|
||||
}]);
|
||||
} else {
|
||||
} else if (hasSession) {
|
||||
const nextSessions = sessionsByTask.map((session) =>
|
||||
session.id === sessionId
|
||||
? { ...session, state: newState, ...(agentProfileSnapshot ? { agent_profile_snapshot: agentProfileSnapshot } : {}) }
|
||||
? { ...session, ...sessionUpdate }
|
||||
: session
|
||||
);
|
||||
store.getState().setTaskSessionsForTask(taskId, nextSessions);
|
||||
|
||||
@@ -53,7 +53,7 @@ export function registerBoardsHandlers(store: StoreApi<AppState>): WsHandlers {
|
||||
},
|
||||
kanban:
|
||||
state.kanban.boardId === message.payload.id
|
||||
? { boardId: nextActiveId, columns: [], tasks: [] }
|
||||
? { boardId: nextActiveId, steps: [], tasks: [] }
|
||||
: state.kanban,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import type { StoreApi } from 'zustand';
|
||||
import type { AppState } from '@/lib/state/store';
|
||||
import type { WsHandlers } from '@/lib/ws/handlers/types';
|
||||
|
||||
export function registerColumnsHandlers(store: StoreApi<AppState>): WsHandlers {
|
||||
return {
|
||||
'column.created': (message) => {
|
||||
store.setState((state) => {
|
||||
if (state.kanban.boardId !== message.payload.board_id) {
|
||||
return state;
|
||||
}
|
||||
const nextColumns = [
|
||||
...state.kanban.columns,
|
||||
{
|
||||
id: message.payload.id,
|
||||
title: message.payload.name,
|
||||
color: message.payload.color,
|
||||
position: message.payload.position,
|
||||
},
|
||||
].sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
|
||||
return {
|
||||
...state,
|
||||
kanban: {
|
||||
...state.kanban,
|
||||
columns: nextColumns,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
'column.updated': (message) => {
|
||||
store.setState((state) => {
|
||||
if (state.kanban.boardId !== message.payload.board_id) {
|
||||
return state;
|
||||
}
|
||||
const exists = state.kanban.columns.some((column) => column.id === message.payload.id);
|
||||
const updatedColumns = exists
|
||||
? state.kanban.columns.map((column) =>
|
||||
column.id === message.payload.id
|
||||
? {
|
||||
...column,
|
||||
title: message.payload.name,
|
||||
color: message.payload.color,
|
||||
position: message.payload.position,
|
||||
}
|
||||
: column
|
||||
)
|
||||
: [
|
||||
...state.kanban.columns,
|
||||
{
|
||||
id: message.payload.id,
|
||||
title: message.payload.name,
|
||||
color: message.payload.color,
|
||||
position: message.payload.position,
|
||||
},
|
||||
];
|
||||
const nextColumns = updatedColumns.sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
|
||||
return {
|
||||
...state,
|
||||
kanban: {
|
||||
...state.kanban,
|
||||
columns: nextColumns,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
'column.deleted': (message) => {
|
||||
store.setState((state) => {
|
||||
if (state.kanban.boardId !== message.payload.board_id) {
|
||||
return state;
|
||||
}
|
||||
const nextColumns = state.kanban.columns.filter((column) => column.id !== message.payload.id);
|
||||
return {
|
||||
...state,
|
||||
kanban: {
|
||||
...state.kanban,
|
||||
columns: nextColumns,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -9,15 +9,16 @@ export function registerKanbanHandlers(store: StoreApi<AppState>): WsHandlers {
|
||||
...state,
|
||||
kanban: {
|
||||
boardId: message.payload.boardId,
|
||||
columns: message.payload.columns.map((column, index) => ({
|
||||
id: column.id,
|
||||
title: column.title,
|
||||
color: column.color ?? 'bg-neutral-400',
|
||||
position: column.position ?? index,
|
||||
steps: message.payload.steps.map((step, index) => ({
|
||||
id: step.id,
|
||||
title: step.title,
|
||||
color: step.color ?? 'bg-neutral-400',
|
||||
position: step.position ?? index,
|
||||
autoStartAgent: step.autoStartAgent ?? false,
|
||||
})),
|
||||
tasks: message.payload.tasks.map((task) => ({
|
||||
id: task.id,
|
||||
columnId: task.columnId,
|
||||
workflowStepId: task.workflowStepId,
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
position: task.position ?? 0,
|
||||
|
||||
@@ -13,7 +13,7 @@ export function registerTasksHandlers(store: StoreApi<AppState>): WsHandlers {
|
||||
const existingTask = state.kanban.tasks.find((task) => task.id === message.payload.task_id);
|
||||
const nextTask = {
|
||||
id: message.payload.task_id,
|
||||
columnId: message.payload.column_id,
|
||||
workflowStepId: message.payload.workflow_step_id,
|
||||
title: message.payload.title,
|
||||
description: message.payload.description,
|
||||
position: message.payload.position ?? 0,
|
||||
@@ -39,7 +39,7 @@ export function registerTasksHandlers(store: StoreApi<AppState>): WsHandlers {
|
||||
const existingTask = state.kanban.tasks.find((task) => task.id === message.payload.task_id);
|
||||
const nextTask = {
|
||||
id: message.payload.task_id,
|
||||
columnId: message.payload.column_id,
|
||||
workflowStepId: message.payload.workflow_step_id,
|
||||
title: message.payload.title,
|
||||
description: message.payload.description,
|
||||
position: message.payload.position ?? 0,
|
||||
@@ -74,7 +74,7 @@ export function registerTasksHandlers(store: StoreApi<AppState>): WsHandlers {
|
||||
const existingTask = state.kanban.tasks.find((t) => t.id === message.payload.task_id);
|
||||
const nextTask = {
|
||||
id: message.payload.task_id,
|
||||
columnId: message.payload.column_id,
|
||||
workflowStepId: message.payload.workflow_step_id,
|
||||
title: message.payload.title,
|
||||
description: message.payload.description,
|
||||
position: message.payload.position ?? 0,
|
||||
|
||||
@@ -68,7 +68,7 @@ export function registerWorkspacesHandlers(store: StoreApi<AppState>): WsHandler
|
||||
activeId,
|
||||
},
|
||||
boards: clearBoards ? { items: [], activeId: null } : state.boards,
|
||||
kanban: clearBoards ? { boardId: null, columns: [], tasks: [] } : state.kanban,
|
||||
kanban: clearBoards ? { boardId: null, steps: [], tasks: [] } : state.kanban,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { AppState } from '@/lib/state/store';
|
||||
import { registerAgentsHandlers } from '@/lib/ws/handlers/agents';
|
||||
import { registerTaskSessionHandlers } from '@/lib/ws/handlers/agent-session';
|
||||
import { registerBoardsHandlers } from '@/lib/ws/handlers/boards';
|
||||
import { registerColumnsHandlers } from '@/lib/ws/handlers/columns';
|
||||
|
||||
import { registerMessagesHandlers } from '@/lib/ws/handlers/messages';
|
||||
import { registerNotificationsHandlers } from '@/lib/ws/handlers/notifications';
|
||||
import { registerDiffsHandlers } from '@/lib/ws/handlers/diffs';
|
||||
@@ -23,7 +23,7 @@ export function registerWsHandlers(store: StoreApi<AppState>) {
|
||||
...registerKanbanHandlers(store),
|
||||
...registerTasksHandlers(store),
|
||||
...registerBoardsHandlers(store),
|
||||
...registerColumnsHandlers(store),
|
||||
|
||||
...registerWorkspacesHandlers(store),
|
||||
...registerExecutorsHandlers(store),
|
||||
...registerEnvironmentsHandlers(store),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user