From 0b573bbdda5cb5f368ca9129dbbfa214c11fc124 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Tue, 27 Jan 2026 01:21:47 +0000 Subject: [PATCH] feat(workflow): implement workflow system with steps terminology (#102) --- apps/backend/cmd/kandev/main.go | 11 +- apps/backend/cmd/kandev/orchestrator.go | 72 + apps/backend/cmd/kandev/services.go | 48 +- apps/backend/cmd/kandev/storage.go | 7 + apps/backend/cmd/kandev/types.go | 4 + .../internal/agent/settings/store/sqlite.go | 64 +- .../server/process/workspace_tracker.go | 5 +- apps/backend/internal/common/sqlite/utils.go | 56 + apps/backend/internal/editors/store/sqlite.go | 16 +- apps/backend/internal/events/types.go | 7 - .../gateway/websocket/task_notifications.go | 3 - .../internal/integration/integration_test.go | 352 ++- .../internal/integration/orchestrator_test.go | 93 +- apps/backend/internal/mcpserver/tools.go | 32 +- .../internal/notifications/store/sqlite.go | 14 +- .../orchestrator/controller/controller.go | 21 +- apps/backend/internal/orchestrator/dto/dto.go | 10 +- .../internal/orchestrator/event_handlers.go | 297 ++- .../orchestrator/executor/executor.go | 20 +- .../orchestrator/handlers/handlers.go | 9 +- apps/backend/internal/orchestrator/service.go | 36 + .../internal/orchestrator/task_operations.go | 200 +- .../backend/internal/task/controller/board.go | 190 +- apps/backend/internal/task/controller/task.go | 84 +- apps/backend/internal/task/dto/dto.go | 141 +- apps/backend/internal/task/dto/requests.go | 55 +- .../internal/task/handlers/board_handlers.go | 252 +- apps/backend/internal/task/handlers/errors.go | 15 + .../task/handlers/process_handlers_test.go | 35 +- .../internal/task/handlers/task_handlers.go | 119 +- apps/backend/internal/task/models/models.go | 73 +- .../internal/task/models/models_test.go | 75 +- .../internal/task/repository/interface.go | 19 +- .../internal/task/repository/sqlite.go | 2221 ----------------- .../internal/task/repository/sqlite/base.go | 83 +- .../internal/task/repository/sqlite/board.go | 146 +- .../task/repository/sqlite/defaults.go | 30 +- .../task/repository/sqlite/environment.go | 5 +- .../task/repository/sqlite/executor.go | 7 +- .../repository/sqlite/repository_entity.go | 5 +- .../task/repository/sqlite/session.go | 273 +- .../internal/task/repository/sqlite/task.go | 26 +- .../internal/task/repository/sqlite_test.go | 229 +- apps/backend/internal/task/service/service.go | 495 ++-- .../internal/task/service/service_test.go | 106 +- apps/backend/internal/user/store/sqlite.go | 36 +- .../workflow/controller/controller.go | 211 ++ .../internal/workflow/handlers/handlers.go | 328 +++ .../internal/workflow/models/models.go | 101 + apps/backend/internal/workflow/provider.go | 21 + .../internal/workflow/repository/sqlite.go | 875 +++++++ .../internal/workflow/service/service.go | 289 +++ apps/backend/pkg/websocket/actions.go | 18 +- apps/packages/types/src/backend.ts | 4 +- apps/web/app/actions/workspaces.ts | 199 +- .../[agentId]/profile-mcp-config-card.tsx | 2 +- .../settings/workspace/[id]/boards/page.tsx | 34 +- .../workspace/workspace-boards-client.tsx | 348 ++- apps/web/components/kanban-board-grid.tsx | 6 +- apps/web/components/kanban-board.tsx | 133 +- apps/web/components/kanban-card.tsx | 21 +- apps/web/components/kanban-column.tsx | 5 +- apps/web/components/kanban-with-preview.tsx | 2 +- apps/web/components/settings/board-card.tsx | 331 +-- .../settings/workflow-step-editor.tsx | 351 +++ apps/web/components/task-create-dialog.tsx | 15 +- .../web/components/task/chat/chat-toolbar.tsx | 22 +- .../task/chat/messages/chat-message.tsx | 3 +- .../task/chat/messages/code-block.tsx | 1 - .../messages/script-execution-message.tsx | 6 - .../task/chat/messages/status-message.tsx | 2 +- .../task/chat/messages/thinking-message.tsx | 1 - .../task/session-workflow-progress.tsx | 46 + .../web/components/task/sessions-dropdown.tsx | 31 +- apps/web/components/task/task-chat-panel.tsx | 46 + .../web/components/task/task-page-content.tsx | 2 +- .../components/task/task-session-sidebar.tsx | 10 +- apps/web/components/task/task-switcher.tsx | 15 +- .../domains/kanban/use-kanban-actions.ts | 4 +- .../hooks/domains/kanban/use-kanban-data.ts | 20 +- apps/web/hooks/use-drag-and-drop.ts | 96 +- apps/web/hooks/use-task-actions.ts | 2 +- apps/web/lib/api/client.ts | 12 +- apps/web/lib/api/domains/kanban-api.ts | 7 +- apps/web/lib/api/domains/workflow-api.ts | 52 + apps/web/lib/ssr/mapper.ts | 18 +- .../lib/state/slices/kanban/kanban-slice.ts | 2 +- apps/web/lib/state/slices/kanban/types.ts | 5 +- .../lib/state/slices/session/session-slice.ts | 31 +- apps/web/lib/types/backend.ts | 12 +- apps/web/lib/types/http.ts | 142 +- apps/web/lib/ws/handlers/agent-session.ts | 33 +- apps/web/lib/ws/handlers/boards.ts | 2 +- apps/web/lib/ws/handlers/columns.ts | 82 - apps/web/lib/ws/handlers/kanban.ts | 13 +- apps/web/lib/ws/handlers/tasks.ts | 6 +- apps/web/lib/ws/handlers/workspaces.ts | 2 +- apps/web/lib/ws/router.ts | 4 +- docs/design/workflow-system.md | 1772 +++++++++++++ 99 files changed, 7269 insertions(+), 4624 deletions(-) create mode 100644 apps/backend/internal/common/sqlite/utils.go delete mode 100644 apps/backend/internal/task/repository/sqlite.go create mode 100644 apps/backend/internal/workflow/controller/controller.go create mode 100644 apps/backend/internal/workflow/handlers/handlers.go create mode 100644 apps/backend/internal/workflow/models/models.go create mode 100644 apps/backend/internal/workflow/provider.go create mode 100644 apps/backend/internal/workflow/repository/sqlite.go create mode 100644 apps/backend/internal/workflow/service/service.go create mode 100644 apps/web/components/settings/workflow-step-editor.tsx create mode 100644 apps/web/components/task/session-workflow-progress.tsx create mode 100644 apps/web/lib/api/domains/workflow-api.ts delete mode 100644 apps/web/lib/ws/handlers/columns.ts create mode 100644 docs/design/workflow-system.md diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index 710f91c956..89f0a2d9d5 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -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)") diff --git a/apps/backend/cmd/kandev/orchestrator.go b/apps/backend/cmd/kandev/orchestrator.go index e2a88a9648..edc90b7980 100644 --- a/apps/backend/cmd/kandev/orchestrator.go +++ b/apps/backend/cmd/kandev/orchestrator.go @@ -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 +} diff --git a/apps/backend/cmd/kandev/services.go b/apps/backend/cmd/kandev/services.go index fd8f84810e..819c7084a2 100644 --- a/apps/backend/cmd/kandev/services.go +++ b/apps/backend/cmd/kandev/services.go @@ -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 +} diff --git a/apps/backend/cmd/kandev/storage.go b/apps/backend/cmd/kandev/storage.go index 8547854911..065f0c7c85 100644 --- a/apps/backend/cmd/kandev/storage.go +++ b/apps/backend/cmd/kandev/storage.go @@ -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 } diff --git a/apps/backend/cmd/kandev/types.go b/apps/backend/cmd/kandev/types.go index 80ab207da0..236664b14d 100644 --- a/apps/backend/cmd/kandev/types.go +++ b/apps/backend/cmd/kandev/types.go @@ -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 } diff --git a/apps/backend/internal/agent/settings/store/sqlite.go b/apps/backend/internal/agent/settings/store/sqlite.go index 12c42520a8..51bf432179 100644 --- a/apps/backend/internal/agent/settings/store/sqlite.go +++ b/apps/backend/internal/agent/settings/store/sqlite.go @@ -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 -} diff --git a/apps/backend/internal/agentctl/server/process/workspace_tracker.go b/apps/backend/internal/agentctl/server/process/workspace_tracker.go index 3bdb12d8e8..5ccc9054a6 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_tracker.go +++ b/apps/backend/internal/agentctl/server/process/workspace_tracker.go @@ -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 { diff --git a/apps/backend/internal/common/sqlite/utils.go b/apps/backend/internal/common/sqlite/utils.go new file mode 100644 index 0000000000..7e60328b6e --- /dev/null +++ b/apps/backend/internal/common/sqlite/utils.go @@ -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() +} + diff --git a/apps/backend/internal/editors/store/sqlite.go b/apps/backend/internal/editors/store/sqlite.go index 13b6d7c888..1d783ada26 100644 --- a/apps/backend/internal/editors/store/sqlite.go +++ b/apps/backend/internal/editors/store/sqlite.go @@ -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 diff --git a/apps/backend/internal/events/types.go b/apps/backend/internal/events/types.go index ac37e53539..7ecdf167ec 100644 --- a/apps/backend/internal/events/types.go +++ b/apps/backend/internal/events/types.go @@ -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" diff --git a/apps/backend/internal/gateway/websocket/task_notifications.go b/apps/backend/internal/gateway/websocket/task_notifications.go index 9e6f526780..d84c6bcaec 100644 --- a/apps/backend/internal/gateway/websocket/task_notifications.go +++ b/apps/backend/internal/gateway/websocket/task_notifications.go @@ -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) diff --git a/apps/backend/internal/integration/integration_test.go b/apps/backend/internal/integration/integration_test.go index f048f3ab88..094aea082c 100644 --- a/apps/backend/internal/integration/integration_test.go +++ b/apps/backend/internal/integration/integration_test.go @@ -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 diff --git a/apps/backend/internal/integration/orchestrator_test.go b/apps/backend/internal/integration/orchestrator_test.go index 37e81764fb..1611157187 100644 --- a/apps/backend/internal/integration/orchestrator_test.go +++ b/apps/backend/internal/integration/orchestrator_test.go @@ -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) diff --git a/apps/backend/internal/mcpserver/tools.go b/apps/backend/internal/mcpserver/tools.go index 5c15936c48..e65ece6f05 100644 --- a/apps/backend/internal/mcpserver/tools.go +++ b/apps/backend/internal/mcpserver/tools.go @@ -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 diff --git a/apps/backend/internal/notifications/store/sqlite.go b/apps/backend/internal/notifications/store/sqlite.go index ed371d84cd..db564e7bc9 100644 --- a/apps/backend/internal/notifications/store/sqlite.go +++ b/apps/backend/internal/notifications/store/sqlite.go @@ -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 -} diff --git a/apps/backend/internal/orchestrator/controller/controller.go b/apps/backend/internal/orchestrator/controller/controller.go index 6809e4f38f..a1bd06e1b5 100644 --- a/apps/backend/internal/orchestrator/controller/controller.go +++ b/apps/backend/internal/orchestrator/controller/controller.go @@ -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 } diff --git a/apps/backend/internal/orchestrator/dto/dto.go b/apps/backend/internal/orchestrator/dto/dto.go index c57bc96943..99680d6cae 100644 --- a/apps/backend/internal/orchestrator/dto/dto.go +++ b/apps/backend/internal/orchestrator/dto/dto.go @@ -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 diff --git a/apps/backend/internal/orchestrator/event_handlers.go b/apps/backend/internal/orchestrator/event_handlers.go index ecbf332d8d..4a8845895c 100644 --- a/apps/backend/internal/orchestrator/event_handlers.go +++ b/apps/backend/internal/orchestrator/event_handlers.go @@ -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", diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index fdab291bfb..7d58b82cb5 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -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 diff --git a/apps/backend/internal/orchestrator/handlers/handlers.go b/apps/backend/internal/orchestrator/handlers/handlers.go index a621f3c24c..53ab5ef5e5 100644 --- a/apps/backend/internal/orchestrator/handlers/handlers.go +++ b/apps/backend/internal/orchestrator/handlers/handlers.go @@ -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)) diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index a3ebf163b1..b463c25302 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -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 { diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index 4e211d68de..b096314fb3 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -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 = "" + +// SystemTagEnd marks the end of system-injected content that should be hidden from the UI. +const SystemTagEnd = "" + +// 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 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 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 diff --git a/apps/backend/internal/task/controller/board.go b/apps/backend/internal/task/controller/board.go index 477f4b3e01..dbc2977cea 100644 --- a/apps/backend/internal/task/controller/board.go +++ b/apps/backend/internal/task/controller/board.go @@ -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 } diff --git a/apps/backend/internal/task/controller/task.go b/apps/backend/internal/task/controller/task.go index 0ebabe825d..e40d81be4b 100644 --- a/apps/backend/internal/task/controller/task.go +++ b/apps/backend/internal/task/controller/task.go @@ -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 +} diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index de0f4a3526..62cf567430 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -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 +} diff --git a/apps/backend/internal/task/dto/requests.go b/apps/backend/internal/task/dto/requests.go index 037a750042..7649113836 100644 --- a/apps/backend/internal/task/dto/requests.go +++ b/apps/backend/internal/task/dto/requests.go @@ -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 { diff --git a/apps/backend/internal/task/handlers/board_handlers.go b/apps/backend/internal/task/handlers/board_handlers.go index e7835d12fb..fa28de0d9b 100644 --- a/apps/backend/internal/task/handlers/board_handlers.go +++ b/apps/backend/internal/task/handlers/board_handlers.go @@ -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}) -} diff --git a/apps/backend/internal/task/handlers/errors.go b/apps/backend/internal/task/handlers/errors.go index b296d6c425..701368427c 100644 --- a/apps/backend/internal/task/handlers/errors.go +++ b/apps/backend/internal/task/handlers/errors.go @@ -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") +} diff --git a/apps/backend/internal/task/handlers/process_handlers_test.go b/apps/backend/internal/task/handlers/process_handlers_test.go index d105e5beac..f6ac270f8f 100644 --- a/apps/backend/internal/task/handlers/process_handlers_test.go +++ b/apps/backend/internal/task/handlers/process_handlers_test.go @@ -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{ diff --git a/apps/backend/internal/task/handlers/task_handlers.go b/apps/backend/internal/task/handlers/task_handlers.go index 7377250d2b..ecaf204263 100644 --- a/apps/backend/internal/task/handlers/task_handlers.go +++ b/apps/backend/internal/task/handlers/task_handlers.go @@ -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)) diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index 3a32f7ae9a..99de260825 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -1,11 +1,22 @@ package models import ( + "regexp" "time" v1 "github.com/kandev/kandev/pkg/api/v1" ) +// systemTagRegex matches ... content including the tags. +// This is used to strip system-injected content from messages before displaying to users. +var systemTagRegex = regexp.MustCompile(`[\s\S]*?\s*`) + +// StripSystemContent removes all ... 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 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 diff --git a/apps/backend/internal/task/models/models_test.go b/apps/backend/internal/task/models/models_test.go index f681ed9b7c..1481669140 100644 --- a/apps/backend/internal/task/models/models_test.go +++ b/apps/backend/internal/task/models/models_test.go @@ -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, diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index 9f4c9f68c1..5a6a533844 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -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) diff --git a/apps/backend/internal/task/repository/sqlite.go b/apps/backend/internal/task/repository/sqlite.go deleted file mode 100644 index d392f3c28b..0000000000 --- a/apps/backend/internal/task/repository/sqlite.go +++ /dev/null @@ -1,2221 +0,0 @@ -package repository - -import ( - "context" - "database/sql" - "encoding/json" - "fmt" - "strings" - "time" - - "github.com/google/uuid" - - "github.com/kandev/kandev/internal/task/models" - v1 "github.com/kandev/kandev/pkg/api/v1" -) - -// sqliteRepository provides SQLite-based task storage operations. -type sqliteRepository struct { - db *sql.DB - ownsDB bool -} - -// NOTE: Interface check removed - sqliteRepository is being phased out in favor of sqlite.Repository -// var _ Repository = (*sqliteRepository)(nil) - -func newSQLiteRepositoryWithDB(dbConn *sql.DB) (*sqliteRepository, error) { - return newSQLiteRepository(dbConn, false) -} - -func newSQLiteRepository(dbConn *sql.DB, ownsDB bool) (*sqliteRepository, error) { - repo := &sqliteRepository{db: dbConn, ownsDB: ownsDB} - if err := repo.initSchema(); err != nil { - if ownsDB { - if closeErr := dbConn.Close(); closeErr != nil { - return nil, fmt.Errorf("failed to close database after schema error: %w", closeErr) - } - } - return nil, fmt.Errorf("failed to initialize schema: %w", err) - } - return repo, nil -} - -// initSchema creates the database tables if they don't exist -func (r *sqliteRepository) initSchema() error { - schema := ` - CREATE TABLE IF NOT EXISTS workspaces ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - description TEXT DEFAULT '', - owner_id TEXT DEFAULT '', - default_executor_id TEXT DEFAULT '', - default_environment_id TEXT DEFAULT '', - default_agent_profile_id TEXT DEFAULT '', - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL - ); - - CREATE TABLE IF NOT EXISTS executors ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - type TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'active', - is_system INTEGER NOT NULL DEFAULT 0, - resumable INTEGER NOT NULL DEFAULT 1, - config TEXT DEFAULT '{}', - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, - deleted_at DATETIME - ); - - CREATE TABLE IF NOT EXISTS executors_running ( - id TEXT PRIMARY KEY, - session_id TEXT NOT NULL UNIQUE, - task_id TEXT NOT NULL, - executor_id TEXT NOT NULL, - runtime TEXT DEFAULT '', - status TEXT NOT NULL DEFAULT 'starting', - resumable INTEGER NOT NULL DEFAULT 0, - resume_token TEXT DEFAULT '', - agent_execution_id TEXT DEFAULT '', - container_id TEXT DEFAULT '', - agentctl_url TEXT DEFAULT '', - agentctl_port INTEGER DEFAULT 0, - pid INTEGER DEFAULT 0, - worktree_id TEXT DEFAULT '', - worktree_path TEXT DEFAULT '', - worktree_branch TEXT DEFAULT '', - last_seen_at DATETIME, - error_message TEXT DEFAULT '', - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL - ); - - CREATE TABLE IF NOT EXISTS environments ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - kind TEXT NOT NULL, - is_system INTEGER NOT NULL DEFAULT 0, - worktree_root TEXT DEFAULT '', - image_tag TEXT DEFAULT '', - dockerfile TEXT DEFAULT '', - build_config TEXT DEFAULT '{}', - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, - deleted_at DATETIME - ); - - CREATE TABLE IF NOT EXISTS boards ( - id TEXT PRIMARY KEY, - workspace_id TEXT NOT NULL DEFAULT '', - name TEXT NOT NULL, - description TEXT DEFAULT '', - created_at DATETIME NOT NULL, - 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, - title TEXT NOT NULL, - description TEXT DEFAULT '', - state TEXT DEFAULT 'TODO', - priority INTEGER DEFAULT 0, - position INTEGER DEFAULT 0, - 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 - ); - - CREATE TABLE IF NOT EXISTS task_repositories ( - id TEXT PRIMARY KEY, - task_id TEXT NOT NULL, - repository_id TEXT NOT NULL, - base_branch TEXT DEFAULT '', - position INTEGER DEFAULT 0, - metadata TEXT DEFAULT '{}', - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, - FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, - FOREIGN KEY (repository_id) REFERENCES repositories(id) ON DELETE CASCADE, - UNIQUE(task_id, repository_id) - ); - - CREATE TABLE IF NOT EXISTS repositories ( - id TEXT PRIMARY KEY, - workspace_id TEXT NOT NULL, - name TEXT NOT NULL, - source_type TEXT NOT NULL DEFAULT 'local', - local_path TEXT DEFAULT '', - provider TEXT DEFAULT '', - provider_repo_id TEXT DEFAULT '', - provider_owner TEXT DEFAULT '', - provider_name TEXT DEFAULT '', - default_branch TEXT DEFAULT '', - worktree_branch_prefix TEXT DEFAULT 'feature/', - pull_before_worktree INTEGER NOT NULL DEFAULT 1, - setup_script TEXT DEFAULT '', - cleanup_script TEXT DEFAULT '', - dev_script TEXT DEFAULT '', - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, - deleted_at DATETIME, - FOREIGN KEY (workspace_id) REFERENCES workspaces(id) ON DELETE CASCADE - ); - - CREATE TABLE IF NOT EXISTS repository_scripts ( - id TEXT PRIMARY KEY, - repository_id TEXT NOT NULL, - name TEXT NOT NULL, - command TEXT NOT NULL, - position INTEGER DEFAULT 0, - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, - FOREIGN KEY (repository_id) REFERENCES repositories(id) ON DELETE CASCADE - ); - - 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_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); - - CREATE TABLE IF NOT EXISTS task_session_messages ( - id TEXT PRIMARY KEY, - task_session_id TEXT NOT NULL, - task_id TEXT DEFAULT '', - turn_id TEXT NOT NULL, - author_type TEXT NOT NULL DEFAULT 'user', - author_id TEXT DEFAULT '', - content TEXT NOT NULL, - requests_input INTEGER DEFAULT 0, - type TEXT NOT NULL DEFAULT 'message', - metadata TEXT DEFAULT '{}', - created_at DATETIME NOT NULL, - FOREIGN KEY (task_session_id) REFERENCES task_sessions(id) ON DELETE CASCADE, - FOREIGN KEY (turn_id) REFERENCES task_session_turns(id) ON DELETE CASCADE - ); - - CREATE INDEX IF NOT EXISTS idx_messages_session_id ON task_session_messages(task_session_id); - CREATE INDEX IF NOT EXISTS idx_messages_created_at ON task_session_messages(created_at); - CREATE INDEX IF NOT EXISTS idx_messages_session_created ON task_session_messages(task_session_id, created_at); - CREATE INDEX IF NOT EXISTS idx_messages_turn_id ON task_session_messages(turn_id); - - CREATE TABLE IF NOT EXISTS task_session_turns ( - id TEXT PRIMARY KEY, - task_session_id TEXT NOT NULL, - task_id TEXT NOT NULL, - started_at DATETIME NOT NULL, - completed_at DATETIME, - metadata TEXT DEFAULT '{}', - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, - FOREIGN KEY (task_session_id) REFERENCES task_sessions(id) ON DELETE CASCADE - ); - - CREATE INDEX IF NOT EXISTS idx_turns_session_id ON task_session_turns(task_session_id); - CREATE INDEX IF NOT EXISTS idx_turns_session_started ON task_session_turns(task_session_id, started_at); - CREATE INDEX IF NOT EXISTS idx_turns_task_id ON task_session_turns(task_id); - - CREATE TABLE IF NOT EXISTS task_sessions ( - id TEXT PRIMARY KEY, - task_id TEXT NOT NULL, - agent_execution_id TEXT NOT NULL DEFAULT '', - container_id TEXT NOT NULL DEFAULT '', - agent_profile_id TEXT NOT NULL, - executor_id TEXT DEFAULT '', - environment_id TEXT DEFAULT '', - repository_id TEXT DEFAULT '', - base_branch TEXT DEFAULT '', - agent_profile_snapshot TEXT DEFAULT '{}', - executor_snapshot TEXT DEFAULT '{}', - environment_snapshot TEXT DEFAULT '{}', - repository_snapshot TEXT DEFAULT '{}', - state TEXT NOT NULL DEFAULT 'CREATED', - error_message TEXT DEFAULT '', - metadata TEXT DEFAULT '{}', - started_at DATETIME NOT NULL, - completed_at DATETIME, - updated_at DATETIME NOT NULL, - FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE - ); - - CREATE INDEX IF NOT EXISTS idx_task_sessions_task_id ON task_sessions(task_id); - CREATE INDEX IF NOT EXISTS idx_task_sessions_state ON task_sessions(state); - CREATE INDEX IF NOT EXISTS idx_task_sessions_task_state ON task_sessions(task_id, state); - - CREATE TABLE IF NOT EXISTS task_session_worktrees ( - id TEXT PRIMARY KEY, - session_id TEXT NOT NULL, - worktree_id TEXT NOT NULL, - repository_id TEXT NOT NULL, - position INTEGER DEFAULT 0, - worktree_path TEXT DEFAULT '', - worktree_branch TEXT DEFAULT '', - status TEXT NOT NULL DEFAULT 'active', - created_at DATETIME NOT NULL, - updated_at DATETIME NOT NULL, - merged_at DATETIME, - deleted_at DATETIME, - FOREIGN KEY (session_id) REFERENCES task_sessions(id) ON DELETE CASCADE, - UNIQUE(session_id, worktree_id) - ); - - CREATE INDEX IF NOT EXISTS idx_task_session_worktrees_session_id ON task_session_worktrees(session_id); - CREATE INDEX IF NOT EXISTS idx_task_session_worktrees_worktree_id ON task_session_worktrees(worktree_id); - CREATE INDEX IF NOT EXISTS idx_task_session_worktrees_repository_id ON task_session_worktrees(repository_id); - CREATE INDEX IF NOT EXISTS idx_task_session_worktrees_status ON task_session_worktrees(status); - ` - - if _, err := r.db.Exec(schema); err != nil { - return err - } - - if err := r.ensureColumn("boards", "workspace_id", "TEXT NOT NULL DEFAULT ''"); err != nil { - return err - } - if err := r.ensureColumn("tasks", "workspace_id", "TEXT NOT NULL DEFAULT ''"); err != nil { - return err - } - if err := r.ensureColumn("workspaces", "default_executor_id", "TEXT DEFAULT ''"); err != nil { - return err - } - if err := r.ensureColumn("workspaces", "default_environment_id", "TEXT DEFAULT ''"); err != nil { - return err - } - 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 - } - if err := r.ensureColumn("repositories", "deleted_at", "DATETIME"); err != nil { - return err - } - if err := r.ensureColumn("repositories", "worktree_branch_prefix", "TEXT DEFAULT 'feature/'"); err != nil { - return err - } - if err := r.ensureColumn("repositories", "pull_before_worktree", "INTEGER NOT NULL DEFAULT 1"); err != nil { - return err - } - if err := r.ensureColumn("repositories", "dev_script", "TEXT DEFAULT ''"); err != nil { - return err - } - if err := r.ensureColumn("executors", "deleted_at", "DATETIME"); err != nil { - return err - } - if err := r.ensureColumn("executors", "resumable", "INTEGER NOT NULL DEFAULT 1"); err != nil { - return err - } - if err := r.ensureColumn("environments", "deleted_at", "DATETIME"); err != nil { - return err - } - - // Ensure new message columns exist for existing databases - if err := r.ensureColumn("task_session_messages", "type", "TEXT NOT NULL DEFAULT 'message'"); err != nil { - return err - } - if err := r.ensureColumn("task_session_messages", "metadata", "TEXT DEFAULT '{}'"); err != nil { - return err - } - if err := r.ensureColumn("task_session_messages", "task_session_id", "TEXT NOT NULL DEFAULT ''"); err != nil { - return err - } - - // Ensure container_id column exists for existing databases - if err := r.ensureColumn("task_sessions", "container_id", "TEXT NOT NULL DEFAULT ''"); err != nil { - return err - } - if err := r.ensureColumn("task_sessions", "executor_id", "TEXT DEFAULT ''"); err != nil { - return err - } - if err := r.ensureColumn("task_sessions", "environment_id", "TEXT DEFAULT ''"); err != nil { - return err - } - if err := r.ensureColumn("task_sessions", "state", "TEXT NOT NULL DEFAULT 'CREATED'"); err != nil { - return err - } - - if err := r.ensureDefaultWorkspace(); err != nil { - return err - } - - if err := r.ensureDefaultExecutorsAndEnvironments(); err != nil { - return err - } - - if err := r.ensureWorkspaceIndexes(); err != nil { - return err - } - - return nil -} - -func (r *sqliteRepository) 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 -} - -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() -} - -func (r *sqliteRepository) ensureDefaultWorkspace() error { - ctx := context.Background() - - var count int - if err := r.db.QueryRowContext(ctx, "SELECT COUNT(1) FROM workspaces").Scan(&count); err != nil { - return err - } - - if count == 0 { - var boardCount int - if err := r.db.QueryRowContext(ctx, "SELECT COUNT(1) FROM boards").Scan(&boardCount); err != nil { - return err - } - var taskCount int - if err := r.db.QueryRowContext(ctx, "SELECT COUNT(1) FROM tasks").Scan(&taskCount); err != nil { - return err - } - defaultID := uuid.New().String() - now := time.Now().UTC() - workspaceName := "Default Workspace" - workspaceDescription := "Default workspace" - if boardCount > 0 || taskCount > 0 { - workspaceName = "Migrated Workspace" - workspaceDescription = "" - } - if _, err := r.db.ExecContext(ctx, ` - INSERT INTO workspaces ( - id, - name, - description, - owner_id, - default_executor_id, - default_environment_id, - default_agent_profile_id, - created_at, - updated_at - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `, defaultID, workspaceName, workspaceDescription, "", nil, nil, nil, now, now); err != nil { - return err - } - - if boardCount == 0 && taskCount == 0 { - boardID := uuid.New().String() - if _, err := r.db.ExecContext(ctx, ` - INSERT INTO boards (id, workspace_id, name, description, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?) - `, 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 - } - } - } - } - - var defaultWorkspaceID string - if err := r.db.QueryRowContext(ctx, "SELECT id FROM workspaces ORDER BY created_at LIMIT 1").Scan(&defaultWorkspaceID); err != nil { - return err - } - - if _, err := r.db.ExecContext(ctx, ` - UPDATE boards SET workspace_id = ? WHERE workspace_id = '' OR workspace_id IS NULL - `, defaultWorkspaceID); err != nil { - return err - } - - if _, err := r.db.ExecContext(ctx, ` - UPDATE tasks - SET workspace_id = ( - SELECT workspace_id FROM boards WHERE boards.id = tasks.board_id - ) - WHERE workspace_id = '' OR workspace_id IS NULL - `); err != nil { - return err - } - - return nil -} - -func (r *sqliteRepository) ensureDefaultExecutorsAndEnvironments() error { - ctx := context.Background() - - var executorCount int - if err := r.db.QueryRowContext(ctx, "SELECT COUNT(1) FROM executors").Scan(&executorCount); err != nil { - return err - } - - if executorCount == 0 { - now := time.Now().UTC() - executors := []struct { - id string - name string - execType models.ExecutorType - status models.ExecutorStatus - isSystem bool - resumable bool - config map[string]string - }{ - { - id: models.ExecutorIDLocalPC, - name: "Local PC", - execType: models.ExecutorTypeLocalPC, - status: models.ExecutorStatusActive, - isSystem: true, - resumable: true, - config: map[string]string{}, - }, - { - id: models.ExecutorIDLocalDocker, - name: "Local Docker", - execType: models.ExecutorTypeLocalDocker, - status: models.ExecutorStatusActive, - isSystem: false, - resumable: true, - config: map[string]string{ - "docker_host": "unix:///var/run/docker.sock", - }, - }, - { - id: models.ExecutorIDRemoteDocker, - name: "Remote Docker", - execType: models.ExecutorTypeRemoteDocker, - status: models.ExecutorStatusDisabled, - isSystem: false, - resumable: true, - config: map[string]string{}, - }, - } - - for _, executor := range executors { - configJSON, err := json.Marshal(executor.config) - if err != nil { - return fmt.Errorf("failed to serialize executor config: %w", err) - } - 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 { - return err - } - } - } else { - if _, err := r.db.ExecContext(ctx, ` - UPDATE executors SET is_system = 1 WHERE id = ? - `, models.ExecutorIDLocalPC); err != nil { - return err - } - } - - var envCount int - if err := r.db.QueryRowContext(ctx, "SELECT COUNT(1) FROM environments").Scan(&envCount); err != nil { - return err - } - if envCount == 0 { - now := time.Now().UTC() - 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 { - return err - } - } else { - var localCount int - if err := r.db.QueryRowContext(ctx, "SELECT COUNT(1) FROM environments WHERE id = ?", models.EnvironmentIDLocal).Scan(&localCount); err != nil { - return err - } - if localCount == 0 { - now := time.Now().UTC() - 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 { - return err - } - } - if _, err := r.db.ExecContext(ctx, ` - UPDATE environments - SET is_system = 1, - image_tag = '', - dockerfile = '', - build_config = '{}' - WHERE id = ? - `, models.EnvironmentIDLocal); err != nil { - return err - } - if _, err := r.db.ExecContext(ctx, ` - UPDATE environments - SET worktree_root = ? - WHERE id = ? AND (worktree_root IS NULL OR worktree_root = '') - `, "~/kandev", models.EnvironmentIDLocal); err != nil { - return err - } - } - - return nil -} - -func boolToInt(value bool) int { - if value { - return 1 - } - return 0 -} - -func (r *sqliteRepository) ensureWorkspaceIndexes() error { - if _, err := r.db.Exec(`CREATE INDEX IF NOT EXISTS idx_tasks_workspace_id ON tasks(workspace_id)`); err != nil { - return err - } - if _, err := r.db.Exec(`CREATE INDEX IF NOT EXISTS idx_boards_workspace_id ON boards(workspace_id)`); err != nil { - return err - } - return nil -} - -// Close closes the database connection -func (r *sqliteRepository) Close() error { - if !r.ownsDB { - return nil - } - return r.db.Close() -} - -// DB returns the underlying sql.DB instance for shared access -func (r *sqliteRepository) DB() *sql.DB { - return r.db -} - -// Workspace operations - -// CreateWorkspace creates a new workspace -func (r *sqliteRepository) CreateWorkspace(ctx context.Context, workspace *models.Workspace) error { - if workspace.ID == "" { - workspace.ID = uuid.New().String() - } - now := time.Now().UTC() - workspace.CreatedAt = now - workspace.UpdatedAt = now - - _, err := r.db.ExecContext(ctx, ` - INSERT INTO workspaces ( - id, - name, - description, - owner_id, - default_executor_id, - default_environment_id, - default_agent_profile_id, - created_at, - updated_at - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `, workspace.ID, workspace.Name, workspace.Description, workspace.OwnerID, workspace.DefaultExecutorID, workspace.DefaultEnvironmentID, workspace.DefaultAgentProfileID, workspace.CreatedAt, workspace.UpdatedAt) - - return err -} - -// GetWorkspace retrieves a workspace by ID -func (r *sqliteRepository) GetWorkspace(ctx context.Context, id string) (*models.Workspace, error) { - workspace := &models.Workspace{} - var defaultExecutorID sql.NullString - var defaultEnvironmentID sql.NullString - var defaultAgentProfileID sql.NullString - - err := r.db.QueryRowContext(ctx, ` - SELECT id, name, description, owner_id, default_executor_id, default_environment_id, default_agent_profile_id, created_at, updated_at - FROM workspaces WHERE id = ? - `, id).Scan( - &workspace.ID, - &workspace.Name, - &workspace.Description, - &workspace.OwnerID, - &defaultExecutorID, - &defaultEnvironmentID, - &defaultAgentProfileID, - &workspace.CreatedAt, - &workspace.UpdatedAt, - ) - if defaultExecutorID.Valid && defaultExecutorID.String != "" { - workspace.DefaultExecutorID = &defaultExecutorID.String - } - if defaultEnvironmentID.Valid && defaultEnvironmentID.String != "" { - workspace.DefaultEnvironmentID = &defaultEnvironmentID.String - } - if defaultAgentProfileID.Valid && defaultAgentProfileID.String != "" { - workspace.DefaultAgentProfileID = &defaultAgentProfileID.String - } - - if err == sql.ErrNoRows { - return nil, fmt.Errorf("workspace not found: %s", id) - } - return workspace, err -} - -// UpdateWorkspace updates an existing workspace -func (r *sqliteRepository) UpdateWorkspace(ctx context.Context, workspace *models.Workspace) error { - workspace.UpdatedAt = time.Now().UTC() - - result, err := r.db.ExecContext(ctx, ` - UPDATE workspaces - SET name = ?, - description = ?, - default_executor_id = ?, - default_environment_id = ?, - default_agent_profile_id = ?, - updated_at = ? - WHERE id = ? - `, workspace.Name, workspace.Description, workspace.DefaultExecutorID, workspace.DefaultEnvironmentID, workspace.DefaultAgentProfileID, workspace.UpdatedAt, workspace.ID) - if err != nil { - return err - } - - rows, _ := result.RowsAffected() - if rows == 0 { - return fmt.Errorf("workspace not found: %s", workspace.ID) - } - return nil -} - -// DeleteWorkspace deletes a workspace by ID -func (r *sqliteRepository) DeleteWorkspace(ctx context.Context, id string) error { - result, err := r.db.ExecContext(ctx, `DELETE FROM workspaces WHERE id = ?`, id) - if err != nil { - return err - } - - rows, _ := result.RowsAffected() - if rows == 0 { - return fmt.Errorf("workspace not found: %s", id) - } - return nil -} - -// ListWorkspaces returns all workspaces -func (r *sqliteRepository) ListWorkspaces(ctx context.Context) ([]*models.Workspace, error) { - rows, err := r.db.QueryContext(ctx, ` - SELECT id, name, description, owner_id, default_executor_id, default_environment_id, default_agent_profile_id, created_at, updated_at - FROM workspaces ORDER BY created_at DESC - `) - if err != nil { - return nil, err - } - defer func() { _ = rows.Close() }() - - var result []*models.Workspace - for rows.Next() { - workspace := &models.Workspace{} - var defaultExecutorID sql.NullString - var defaultEnvironmentID sql.NullString - var defaultAgentProfileID sql.NullString - if err := rows.Scan( - &workspace.ID, - &workspace.Name, - &workspace.Description, - &workspace.OwnerID, - &defaultExecutorID, - &defaultEnvironmentID, - &defaultAgentProfileID, - &workspace.CreatedAt, - &workspace.UpdatedAt, - ); err != nil { - return nil, err - } - if defaultExecutorID.Valid && defaultExecutorID.String != "" { - workspace.DefaultExecutorID = &defaultExecutorID.String - } - if defaultEnvironmentID.Valid && defaultEnvironmentID.String != "" { - workspace.DefaultEnvironmentID = &defaultEnvironmentID.String - } - if defaultAgentProfileID.Valid && defaultAgentProfileID.String != "" { - workspace.DefaultAgentProfileID = &defaultAgentProfileID.String - } - result = append(result, workspace) - } - return result, rows.Err() -} - -// Task operations - -// CreateTask creates a new task -func (r *sqliteRepository) CreateTask(ctx context.Context, task *models.Task) error { - if task.ID == "" { - task.ID = uuid.New().String() - } - now := time.Now().UTC() - task.CreatedAt = now - task.UpdatedAt = now - - metadata, err := json.Marshal(task.Metadata) - if err != nil { - metadata = []byte("{}") - } - - tx, err := r.db.BeginTx(ctx, nil) - if err != nil { - return err - } - - _, err = tx.ExecContext(ctx, ` - INSERT INTO tasks (id, workspace_id, board_id, column_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) - if err != nil { - if rollbackErr := tx.Rollback(); rollbackErr != nil { - return fmt.Errorf("failed to rollback task insert: %w", rollbackErr) - } - return err - } - - return tx.Commit() -} - -// GetTask retrieves a task by ID -func (r *sqliteRepository) GetTask(ctx context.Context, id string) (*models.Task, error) { - 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 - 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) - - if err == sql.ErrNoRows { - return nil, fmt.Errorf("task not found: %s", id) - } - if err != nil { - return nil, err - } - - _ = json.Unmarshal([]byte(metadata), &task.Metadata) - return task, nil -} - -// UpdateTask updates an existing task -func (r *sqliteRepository) UpdateTask(ctx context.Context, task *models.Task) error { - task.UpdatedAt = time.Now().UTC() - - metadata, err := json.Marshal(task.Metadata) - if err != nil { - metadata = []byte("{}") - } - - result, err := r.db.ExecContext(ctx, ` - UPDATE tasks SET workspace_id = ?, board_id = ?, column_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) - if err != nil { - return err - } - - rows, _ := result.RowsAffected() - if rows == 0 { - return fmt.Errorf("task not found: %s", task.ID) - } - return nil -} - -// DeleteTask deletes a task by ID -func (r *sqliteRepository) DeleteTask(ctx context.Context, id string) error { - result, err := r.db.ExecContext(ctx, `DELETE FROM tasks WHERE id = ?`, id) - if err != nil { - return err - } - - rows, _ := result.RowsAffected() - if rows == 0 { - return fmt.Errorf("task not found: %s", id) - } - return nil -} - -// ListTasks returns all tasks for a board -func (r *sqliteRepository) 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 - FROM tasks - WHERE board_id = ? - ORDER BY position - `, boardID) - if err != nil { - return nil, err - } - defer func() { _ = rows.Close() }() - - return r.scanTasks(rows) -} - -// ListTasksByColumn returns all tasks in a column -func (r *sqliteRepository) ListTasksByColumn(ctx context.Context, columnID 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 - FROM tasks - WHERE column_id = ? ORDER BY position - `, columnID) - if err != nil { - return nil, err - } - defer func() { _ = rows.Close() }() - - return r.scanTasks(rows) -} - -func (r *sqliteRepository) scanTasks(rows *sql.Rows) ([]*models.Task, error) { - var result []*models.Task - for rows.Next() { - task := &models.Task{} - var metadata string - err := rows.Scan( - &task.ID, - &task.WorkspaceID, - &task.BoardID, - &task.ColumnID, - &task.Title, - &task.Description, - &task.State, - &task.Priority, - &task.Position, - &metadata, - &task.CreatedAt, - &task.UpdatedAt, - ) - if err != nil { - return nil, err - } - _ = json.Unmarshal([]byte(metadata), &task.Metadata) - result = append(result, task) - } - return result, rows.Err() -} - -// UpdateTaskState updates the state of a task -func (r *sqliteRepository) UpdateTaskState(ctx context.Context, id string, state v1.TaskState) error { - result, err := r.db.ExecContext(ctx, `UPDATE tasks SET state = ?, updated_at = ? WHERE id = ?`, state, time.Now().UTC(), id) - if err != nil { - return err - } - - rows, _ := result.RowsAffected() - if rows == 0 { - return fmt.Errorf("task not found: %s", id) - } - return nil -} - -// TaskRepository operations - -func (r *sqliteRepository) CreateTaskRepository(ctx context.Context, taskRepo *models.TaskRepository) error { - if taskRepo.ID == "" { - taskRepo.ID = uuid.New().String() - } - now := time.Now().UTC() - taskRepo.CreatedAt = now - taskRepo.UpdatedAt = now - - metadataJSON, err := json.Marshal(taskRepo.Metadata) - if err != nil { - metadataJSON = []byte("{}") - } - - _, err = r.db.ExecContext(ctx, ` - INSERT INTO task_repositories ( - id, task_id, repository_id, base_branch, position, metadata, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, taskRepo.ID, taskRepo.TaskID, taskRepo.RepositoryID, taskRepo.BaseBranch, taskRepo.Position, string(metadataJSON), taskRepo.CreatedAt, taskRepo.UpdatedAt) - return err -} - -func (r *sqliteRepository) GetTaskRepository(ctx context.Context, id string) (*models.TaskRepository, error) { - taskRepo := &models.TaskRepository{} - var metadataJSON string - - err := r.db.QueryRowContext(ctx, ` - SELECT id, task_id, repository_id, base_branch, position, metadata, created_at, updated_at - FROM task_repositories WHERE id = ? - `, id).Scan( - &taskRepo.ID, - &taskRepo.TaskID, - &taskRepo.RepositoryID, - &taskRepo.BaseBranch, - &taskRepo.Position, - &metadataJSON, - &taskRepo.CreatedAt, - &taskRepo.UpdatedAt, - ) - if err == sql.ErrNoRows { - return nil, fmt.Errorf("task repository not found: %s", id) - } - if err != nil { - return nil, err - } - if metadataJSON != "" && metadataJSON != "{}" { - if err := json.Unmarshal([]byte(metadataJSON), &taskRepo.Metadata); err != nil { - return nil, fmt.Errorf("failed to deserialize task repository metadata: %w", err) - } - } - return taskRepo, nil -} - -func (r *sqliteRepository) ListTaskRepositories(ctx context.Context, taskID string) ([]*models.TaskRepository, error) { - rows, err := r.db.QueryContext(ctx, ` - SELECT id, task_id, repository_id, base_branch, position, metadata, created_at, updated_at - FROM task_repositories - WHERE task_id = ? - ORDER BY position ASC, created_at ASC - `, taskID) - if err != nil { - return nil, err - } - defer func() { _ = rows.Close() }() - - var result []*models.TaskRepository - for rows.Next() { - taskRepo := &models.TaskRepository{} - var metadataJSON string - if err := rows.Scan( - &taskRepo.ID, - &taskRepo.TaskID, - &taskRepo.RepositoryID, - &taskRepo.BaseBranch, - &taskRepo.Position, - &metadataJSON, - &taskRepo.CreatedAt, - &taskRepo.UpdatedAt, - ); err != nil { - return nil, err - } - if metadataJSON != "" && metadataJSON != "{}" { - if err := json.Unmarshal([]byte(metadataJSON), &taskRepo.Metadata); err != nil { - return nil, fmt.Errorf("failed to deserialize task repository metadata: %w", err) - } - } - result = append(result, taskRepo) - } - return result, rows.Err() -} - -func (r *sqliteRepository) UpdateTaskRepository(ctx context.Context, taskRepo *models.TaskRepository) error { - taskRepo.UpdatedAt = time.Now().UTC() - - metadataJSON, err := json.Marshal(taskRepo.Metadata) - if err != nil { - metadataJSON = []byte("{}") - } - - result, err := r.db.ExecContext(ctx, ` - UPDATE task_repositories SET - task_id = ?, repository_id = ?, base_branch = ?, position = ?, metadata = ?, updated_at = ? - WHERE id = ? - `, taskRepo.TaskID, taskRepo.RepositoryID, taskRepo.BaseBranch, taskRepo.Position, string(metadataJSON), taskRepo.UpdatedAt, taskRepo.ID) - if err != nil { - return err - } - rows, _ := result.RowsAffected() - if rows == 0 { - return fmt.Errorf("task repository not found: %s", taskRepo.ID) - } - return nil -} - -func (r *sqliteRepository) DeleteTaskRepository(ctx context.Context, id string) error { - result, err := r.db.ExecContext(ctx, `DELETE FROM task_repositories WHERE id = ?`, id) - if err != nil { - return err - } - rows, _ := result.RowsAffected() - if rows == 0 { - return fmt.Errorf("task repository not found: %s", id) - } - return nil -} - -func (r *sqliteRepository) DeleteTaskRepositoriesByTask(ctx context.Context, taskID string) error { - _, err := r.db.ExecContext(ctx, `DELETE FROM task_repositories WHERE task_id = ?`, taskID) - return err -} - -func (r *sqliteRepository) GetPrimaryTaskRepository(ctx context.Context, taskID string) (*models.TaskRepository, error) { - repos, err := r.ListTaskRepositories(ctx, taskID) - if err != nil { - return nil, err - } - if len(repos) == 0 { - return nil, nil - } - return repos[0], nil -} - -// AddTaskToBoard adds a task to a board with placement -func (r *sqliteRepository) AddTaskToBoard(ctx context.Context, taskID, boardID, columnID 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) - return err -} - -// RemoveTaskFromBoard removes a task from a board -func (r *sqliteRepository) 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 = ? - `, time.Now().UTC(), taskID, boardID) - return err -} - -// Board operations - -// CreateBoard creates a new board -func (r *sqliteRepository) CreateBoard(ctx context.Context, board *models.Board) error { - if board.ID == "" { - board.ID = uuid.New().String() - } - now := time.Now().UTC() - board.CreatedAt = now - 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) - - return err -} - -// GetBoard retrieves a board by ID -func (r *sqliteRepository) GetBoard(ctx context.Context, id string) (*models.Board, error) { - board := &models.Board{} - - err := r.db.QueryRowContext(ctx, ` - SELECT id, workspace_id, name, description, created_at, updated_at - FROM boards WHERE id = ? - `, id).Scan(&board.ID, &board.WorkspaceID, &board.Name, &board.Description, &board.CreatedAt, &board.UpdatedAt) - - if err == sql.ErrNoRows { - return nil, fmt.Errorf("board not found: %s", id) - } - return board, err -} - -// UpdateBoard updates an existing board -func (r *sqliteRepository) 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) - if err != nil { - return err - } - - rows, _ := result.RowsAffected() - if rows == 0 { - return fmt.Errorf("board not found: %s", board.ID) - } - return nil -} - -// DeleteBoard deletes a board by ID -func (r *sqliteRepository) DeleteBoard(ctx context.Context, id string) error { - result, err := r.db.ExecContext(ctx, `DELETE FROM boards WHERE id = ?`, id) - if err != nil { - return err - } - - rows, _ := result.RowsAffected() - if rows == 0 { - return fmt.Errorf("board not found: %s", id) - } - return nil -} - -// ListBoards returns all boards -func (r *sqliteRepository) ListBoards(ctx context.Context, workspaceID string) ([]*models.Board, error) { - query := ` - SELECT id, workspace_id, name, description, created_at, updated_at FROM boards - ` - var args []interface{} - if workspaceID != "" { - query += " WHERE workspace_id = ?" - args = append(args, workspaceID) - } - query += " ORDER BY created_at DESC" - - rows, err := r.db.QueryContext(ctx, query, args...) - if err != nil { - return nil, err - } - defer func() { _ = rows.Close() }() - - 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) - if err != nil { - return nil, err - } - result = append(result, board) - } - return result, rows.Err() -} - -// Column operations - -// CreateColumn creates a new column -func (r *sqliteRepository) 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 *sqliteRepository) 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 *sqliteRepository) 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 *sqliteRepository) 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 *sqliteRepository) 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 *sqliteRepository) 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() -} - -// Message operations - -// CreateMessage creates a new message -func (r *sqliteRepository) CreateMessage(ctx context.Context, message *models.Message) error { - if message.ID == "" { - message.ID = uuid.New().String() - } - if message.CreatedAt.IsZero() { - message.CreatedAt = time.Now().UTC() - } - if message.AuthorType == "" { - message.AuthorType = models.MessageAuthorUser - } - - requestsInput := 0 - if message.RequestsInput { - requestsInput = 1 - } - - messageType := string(message.Type) - if messageType == "" { - messageType = string(models.MessageTypeMessage) - } - - metadataJSON := "{}" - if message.Metadata != nil { - metadataBytes, err := json.Marshal(message.Metadata) - if err != nil { - return fmt.Errorf("failed to serialize message metadata: %w", err) - } - metadataJSON = string(metadataBytes) - } - - _, err := r.db.ExecContext(ctx, ` - INSERT INTO task_session_messages (id, task_session_id, task_id, turn_id, author_type, author_id, content, requests_input, type, metadata, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, message.ID, message.TaskSessionID, message.TaskID, message.TurnID, message.AuthorType, message.AuthorID, message.Content, requestsInput, messageType, metadataJSON, message.CreatedAt) - - return err -} - -// GetMessage retrieves a message by ID -func (r *sqliteRepository) GetMessage(ctx context.Context, id string) (*models.Message, error) { - message := &models.Message{} - var requestsInput int - var messageType string - var metadataJSON string - err := r.db.QueryRowContext(ctx, ` - SELECT id, task_session_id, task_id, turn_id, author_type, author_id, content, requests_input, type, metadata, created_at - FROM task_session_messages WHERE id = ? - `, id).Scan(&message.ID, &message.TaskSessionID, &message.TaskID, &message.TurnID, &message.AuthorType, &message.AuthorID, &message.Content, &requestsInput, &messageType, &metadataJSON, &message.CreatedAt) - if err != nil { - return nil, err - } - message.RequestsInput = requestsInput == 1 - message.Type = models.MessageType(messageType) - - if metadataJSON != "" && metadataJSON != "{}" { - if err := json.Unmarshal([]byte(metadataJSON), &message.Metadata); err != nil { - return nil, fmt.Errorf("failed to deserialize message metadata: %w", err) - } - } - - return message, nil -} - -// ListMessages returns all messages for a session ordered by creation time. -func (r *sqliteRepository) ListMessages(ctx context.Context, sessionID string) ([]*models.Message, error) { - rows, err := r.db.QueryContext(ctx, ` - SELECT id, task_session_id, task_id, turn_id, author_type, author_id, content, requests_input, type, metadata, created_at - FROM task_session_messages WHERE task_session_id = ? ORDER BY created_at ASC - `, sessionID) - if err != nil { - return nil, err - } - defer func() { _ = rows.Close() }() - - var result []*models.Message - for rows.Next() { - message := &models.Message{} - var requestsInput int - var messageType string - var metadataJSON string - err := rows.Scan(&message.ID, &message.TaskSessionID, &message.TaskID, &message.TurnID, &message.AuthorType, &message.AuthorID, &message.Content, &requestsInput, &messageType, &metadataJSON, &message.CreatedAt) - if err != nil { - return nil, err - } - message.RequestsInput = requestsInput == 1 - message.Type = models.MessageType(messageType) - - if metadataJSON != "" && metadataJSON != "{}" { - if err := json.Unmarshal([]byte(metadataJSON), &message.Metadata); err != nil { - return nil, fmt.Errorf("failed to deserialize message metadata: %w", err) - } - } - - result = append(result, message) - } - if err := rows.Err(); err != nil { - return nil, err - } - return result, nil -} - -// ListMessagesPaginated returns messages for a session ordered by creation time with pagination. -func (r *sqliteRepository) ListMessagesPaginated(ctx context.Context, sessionID string, opts models.ListMessagesOptions) ([]*models.Message, bool, error) { - limit := opts.Limit - if limit < 0 { - limit = 0 - } - - sortDir := "ASC" - if strings.EqualFold(opts.Sort, "desc") { - sortDir = "DESC" - } - - var cursor *models.Message - if opts.Before != "" { - var err error - cursor, err = r.GetMessage(ctx, opts.Before) - if err != nil { - return nil, false, err - } - if cursor.TaskSessionID != sessionID { - return nil, false, fmt.Errorf("message cursor not found: %s", opts.Before) - } - } - if opts.After != "" { - var err error - cursor, err = r.GetMessage(ctx, opts.After) - if err != nil { - return nil, false, err - } - if cursor.TaskSessionID != sessionID { - return nil, false, fmt.Errorf("message cursor not found: %s", opts.After) - } - } - - query := ` - SELECT id, task_session_id, task_id, turn_id, author_type, author_id, content, requests_input, type, metadata, created_at - FROM task_session_messages WHERE task_session_id = ?` - args := []interface{}{sessionID} - if cursor != nil { - if opts.Before != "" { - query += " AND (created_at < ? OR (created_at = ? AND id < ?))" - } else if opts.After != "" { - query += " AND (created_at > ? OR (created_at = ? AND id > ?))" - } - args = append(args, cursor.CreatedAt, cursor.CreatedAt, cursor.ID) - } - query += fmt.Sprintf(" ORDER BY created_at %s, id %s", sortDir, sortDir) - if limit > 0 { - query += " LIMIT ?" - args = append(args, limit+1) - } - - rows, err := r.db.QueryContext(ctx, query, args...) - if err != nil { - return nil, false, err - } - defer func() { _ = rows.Close() }() - - var result []*models.Message - for rows.Next() { - message := &models.Message{} - var requestsInput int - var messageType string - var metadataJSON string - err := rows.Scan(&message.ID, &message.TaskSessionID, &message.TaskID, &message.TurnID, &message.AuthorType, &message.AuthorID, &message.Content, &requestsInput, &messageType, &metadataJSON, &message.CreatedAt) - if err != nil { - return nil, false, err - } - message.RequestsInput = requestsInput == 1 - message.Type = models.MessageType(messageType) - - if metadataJSON != "" && metadataJSON != "{}" { - if err := json.Unmarshal([]byte(metadataJSON), &message.Metadata); err != nil { - return nil, false, fmt.Errorf("failed to deserialize message metadata: %w", err) - } - } - - result = append(result, message) - } - if err := rows.Err(); err != nil { - return nil, false, err - } - hasMore := false - if limit > 0 && len(result) > limit { - hasMore = true - result = result[:limit] - } - return result, hasMore, nil -} - -// DeleteMessage deletes a message by ID -func (r *sqliteRepository) DeleteMessage(ctx context.Context, id string) error { - result, err := r.db.ExecContext(ctx, `DELETE FROM task_session_messages WHERE id = ?`, id) - if err != nil { - return err - } - - rows, _ := result.RowsAffected() - if rows == 0 { - return fmt.Errorf("message not found: %s", id) - } - return nil -} - -// GetMessageByToolCallID retrieves a tool_call message by session ID and tool_call_id in metadata -func (r *sqliteRepository) GetMessageByToolCallID(ctx context.Context, sessionID, toolCallID string) (*models.Message, error) { - message := &models.Message{} - var requestsInput int - var messageType string - var metadataJSON string - err := r.db.QueryRowContext(ctx, ` - SELECT id, task_session_id, task_id, turn_id, author_type, author_id, content, requests_input, type, metadata, created_at - FROM task_session_messages WHERE task_session_id = ? AND type = 'tool_call' AND json_extract(metadata, '$.tool_call_id') = ? - `, sessionID, toolCallID).Scan(&message.ID, &message.TaskSessionID, &message.TaskID, &message.TurnID, &message.AuthorType, &message.AuthorID, - &message.Content, &requestsInput, &messageType, &metadataJSON, &message.CreatedAt) - if err != nil { - return nil, err - } - message.RequestsInput = requestsInput == 1 - message.Type = models.MessageType(messageType) - if metadataJSON != "" { - _ = json.Unmarshal([]byte(metadataJSON), &message.Metadata) - } - return message, nil -} - -// GetMessageByPendingID retrieves a message by session ID and pending_id in metadata -func (r *sqliteRepository) GetMessageByPendingID(ctx context.Context, sessionID, pendingID string) (*models.Message, error) { - message := &models.Message{} - var requestsInput int - var messageType string - var metadataJSON string - err := r.db.QueryRowContext(ctx, ` - SELECT id, task_session_id, task_id, turn_id, author_type, author_id, content, requests_input, type, metadata, created_at - FROM task_session_messages WHERE task_session_id = ? AND json_extract(metadata, '$.pending_id') = ? - `, sessionID, pendingID).Scan(&message.ID, &message.TaskSessionID, &message.TaskID, &message.TurnID, &message.AuthorType, &message.AuthorID, - &message.Content, &requestsInput, &messageType, &metadataJSON, &message.CreatedAt) - if err != nil { - return nil, err - } - message.RequestsInput = requestsInput == 1 - message.Type = models.MessageType(messageType) - if metadataJSON != "" { - _ = json.Unmarshal([]byte(metadataJSON), &message.Metadata) - } - return message, nil -} - -// UpdateMessage updates an existing message -func (r *sqliteRepository) UpdateMessage(ctx context.Context, message *models.Message) error { - metadataJSON, err := json.Marshal(message.Metadata) - if err != nil { - return fmt.Errorf("failed to marshal metadata: %w", err) - } - - requestsInput := 0 - if message.RequestsInput { - requestsInput = 1 - } - - result, err := r.db.ExecContext(ctx, ` - UPDATE task_session_messages SET content = ?, requests_input = ?, type = ?, metadata = ? - WHERE id = ? - `, message.Content, requestsInput, string(message.Type), string(metadataJSON), message.ID) - if err != nil { - return err - } - - rows, _ := result.RowsAffected() - if rows == 0 { - return fmt.Errorf("message not found: %s", message.ID) - } - return nil -} - -// Repository operations - -// CreateRepository creates a new repository -func (r *sqliteRepository) CreateRepository(ctx context.Context, repository *models.Repository) error { - if repository.ID == "" { - repository.ID = uuid.New().String() - } - now := time.Now().UTC() - repository.CreatedAt = now - repository.UpdatedAt = now - - _, err := r.db.ExecContext(ctx, ` - INSERT INTO repositories ( - id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_owner, - provider_name, default_branch, worktree_branch_prefix, pull_before_worktree, setup_script, cleanup_script, dev_script, created_at, updated_at, deleted_at - ) 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) - - return err -} - -// GetRepository retrieves a repository by ID -func (r *sqliteRepository) GetRepository(ctx context.Context, id string) (*models.Repository, error) { - repository := &models.Repository{} - - err := r.db.QueryRowContext(ctx, ` - SELECT id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_owner, - provider_name, default_branch, worktree_branch_prefix, pull_before_worktree, setup_script, cleanup_script, dev_script, created_at, updated_at, deleted_at - FROM repositories WHERE id = ? AND deleted_at IS NULL - `, id).Scan( - &repository.ID, &repository.WorkspaceID, &repository.Name, &repository.SourceType, &repository.LocalPath, - &repository.Provider, &repository.ProviderRepoID, &repository.ProviderOwner, &repository.ProviderName, - &repository.DefaultBranch, &repository.WorktreeBranchPrefix, &repository.PullBeforeWorktree, &repository.SetupScript, &repository.CleanupScript, &repository.DevScript, &repository.CreatedAt, &repository.UpdatedAt, &repository.DeletedAt, - ) - - if err == sql.ErrNoRows { - return nil, fmt.Errorf("repository not found: %s", id) - } - return repository, err -} - -// UpdateRepository updates an existing repository -func (r *sqliteRepository) UpdateRepository(ctx context.Context, repository *models.Repository) error { - repository.UpdatedAt = time.Now().UTC() - - result, err := r.db.ExecContext(ctx, ` - UPDATE repositories SET - name = ?, source_type = ?, local_path = ?, provider = ?, provider_repo_id = ?, provider_owner = ?, - 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.SetupScript, repository.CleanupScript, repository.DevScript, repository.UpdatedAt, repository.ID) - if err != nil { - return err - } - - rows, _ := result.RowsAffected() - if rows == 0 { - return fmt.Errorf("repository not found: %s", repository.ID) - } - return nil -} - -// DeleteRepository deletes a repository by ID -func (r *sqliteRepository) DeleteRepository(ctx context.Context, id string) error { - now := time.Now().UTC() - result, err := r.db.ExecContext(ctx, ` - UPDATE repositories SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL - `, now, now, id) - if err != nil { - return err - } - rows, _ := result.RowsAffected() - if rows == 0 { - return fmt.Errorf("repository not found: %s", id) - } - return nil -} - -// ListRepositories returns all repositories for a workspace -func (r *sqliteRepository) ListRepositories(ctx context.Context, workspaceID string) ([]*models.Repository, error) { - rows, err := r.db.QueryContext(ctx, ` - SELECT id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_owner, - provider_name, default_branch, worktree_branch_prefix, pull_before_worktree, setup_script, cleanup_script, dev_script, created_at, updated_at, deleted_at - FROM repositories WHERE workspace_id = ? AND deleted_at IS NULL ORDER BY created_at DESC - `, workspaceID) - if err != nil { - return nil, err - } - defer func() { _ = rows.Close() }() - - var result []*models.Repository - for rows.Next() { - repository := &models.Repository{} - err := rows.Scan( - &repository.ID, &repository.WorkspaceID, &repository.Name, &repository.SourceType, &repository.LocalPath, - &repository.Provider, &repository.ProviderRepoID, &repository.ProviderOwner, &repository.ProviderName, - &repository.DefaultBranch, &repository.WorktreeBranchPrefix, &repository.PullBeforeWorktree, &repository.SetupScript, &repository.CleanupScript, &repository.DevScript, &repository.CreatedAt, &repository.UpdatedAt, &repository.DeletedAt, - ) - if err != nil { - return nil, err - } - result = append(result, repository) - } - return result, rows.Err() -} - -// Repository script operations - -// CreateRepositoryScript creates a new repository script -func (r *sqliteRepository) CreateRepositoryScript(ctx context.Context, script *models.RepositoryScript) error { - if script.ID == "" { - script.ID = uuid.New().String() - } - now := time.Now().UTC() - script.CreatedAt = now - script.UpdatedAt = now - - _, err := r.db.ExecContext(ctx, ` - INSERT INTO repository_scripts (id, repository_id, name, command, position, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - `, script.ID, script.RepositoryID, script.Name, script.Command, script.Position, script.CreatedAt, script.UpdatedAt) - - return err -} - -// GetRepositoryScript retrieves a repository script by ID -func (r *sqliteRepository) GetRepositoryScript(ctx context.Context, id string) (*models.RepositoryScript, error) { - script := &models.RepositoryScript{} - err := r.db.QueryRowContext(ctx, ` - SELECT id, repository_id, name, command, position, created_at, updated_at - FROM repository_scripts WHERE id = ? - `, id).Scan(&script.ID, &script.RepositoryID, &script.Name, &script.Command, &script.Position, &script.CreatedAt, &script.UpdatedAt) - if err == sql.ErrNoRows { - return nil, fmt.Errorf("repository script not found: %s", id) - } - return script, err -} - -// UpdateRepositoryScript updates an existing repository script -func (r *sqliteRepository) UpdateRepositoryScript(ctx context.Context, script *models.RepositoryScript) error { - script.UpdatedAt = time.Now().UTC() - result, err := r.db.ExecContext(ctx, ` - UPDATE repository_scripts SET name = ?, command = ?, position = ?, updated_at = ? WHERE id = ? - `, script.Name, script.Command, script.Position, script.UpdatedAt, script.ID) - if err != nil { - return err - } - rows, _ := result.RowsAffected() - if rows == 0 { - return fmt.Errorf("repository script not found: %s", script.ID) - } - return nil -} - -// DeleteRepositoryScript deletes a repository script by ID -func (r *sqliteRepository) DeleteRepositoryScript(ctx context.Context, id string) error { - result, err := r.db.ExecContext(ctx, `DELETE FROM repository_scripts WHERE id = ?`, id) - if err != nil { - return err - } - rows, _ := result.RowsAffected() - if rows == 0 { - return fmt.Errorf("repository script not found: %s", id) - } - return nil -} - -// ListRepositoryScripts returns all scripts for a repository -func (r *sqliteRepository) ListRepositoryScripts(ctx context.Context, repositoryID string) ([]*models.RepositoryScript, error) { - rows, err := r.db.QueryContext(ctx, ` - SELECT id, repository_id, name, command, position, created_at, updated_at - FROM repository_scripts WHERE repository_id = ? ORDER BY position - `, repositoryID) - if err != nil { - return nil, err - } - defer func() { _ = rows.Close() }() - - var result []*models.RepositoryScript - for rows.Next() { - script := &models.RepositoryScript{} - err := rows.Scan(&script.ID, &script.RepositoryID, &script.Name, &script.Command, &script.Position, &script.CreatedAt, &script.UpdatedAt) - if err != nil { - return nil, err - } - result = append(result, script) - } - return result, rows.Err() -} - -// Executor operations - -func (r *sqliteRepository) CreateExecutor(ctx context.Context, executor *models.Executor) error { - if executor.ID == "" { - executor.ID = uuid.New().String() - } - now := time.Now().UTC() - executor.CreatedAt = now - executor.UpdatedAt = now - - configJSON, err := json.Marshal(executor.Config) - if err != nil { - return fmt.Errorf("failed to serialize executor config: %w", err) - } - - _, 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) - return err -} - -func (r *sqliteRepository) GetExecutor(ctx context.Context, id string) (*models.Executor, error) { - executor := &models.Executor{} - var configJSON string - var isSystem int - var resumable int - - err := r.db.QueryRowContext(ctx, ` - SELECT id, name, type, status, is_system, resumable, config, created_at, updated_at, deleted_at - FROM executors WHERE id = ? AND deleted_at IS NULL - `, id).Scan( - &executor.ID, &executor.Name, &executor.Type, &executor.Status, - &isSystem, &resumable, &configJSON, &executor.CreatedAt, &executor.UpdatedAt, &executor.DeletedAt, - ) - if err == sql.ErrNoRows { - return nil, fmt.Errorf("executor not found: %s", id) - } - if err != nil { - return nil, err - } - - executor.IsSystem = isSystem == 1 - executor.Resumable = resumable == 1 - if configJSON != "" && configJSON != "{}" { - if err := json.Unmarshal([]byte(configJSON), &executor.Config); err != nil { - return nil, fmt.Errorf("failed to deserialize executor config: %w", err) - } - } - return executor, nil -} - -func (r *sqliteRepository) UpdateExecutor(ctx context.Context, executor *models.Executor) error { - executor.UpdatedAt = time.Now().UTC() - - configJSON, err := json.Marshal(executor.Config) - if err != nil { - return fmt.Errorf("failed to serialize executor config: %w", err) - } - - 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) - if err != nil { - return err - } - rows, _ := result.RowsAffected() - if rows == 0 { - return fmt.Errorf("executor not found: %s", executor.ID) - } - return nil -} - -func (r *sqliteRepository) DeleteExecutor(ctx context.Context, id string) error { - now := time.Now().UTC() - result, err := r.db.ExecContext(ctx, ` - UPDATE executors SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL - `, now, now, id) - if err != nil { - return err - } - rows, _ := result.RowsAffected() - if rows == 0 { - return fmt.Errorf("executor not found: %s", id) - } - return nil -} - -func (r *sqliteRepository) ListExecutors(ctx context.Context) ([]*models.Executor, error) { - rows, err := r.db.QueryContext(ctx, ` - SELECT id, name, type, status, is_system, resumable, config, created_at, updated_at, deleted_at - FROM executors WHERE deleted_at IS NULL ORDER BY created_at ASC - `) - if err != nil { - return nil, err - } - defer func() { _ = rows.Close() }() - - var result []*models.Executor - for rows.Next() { - executor := &models.Executor{} - var configJSON string - var isSystem int - var resumable int - if err := rows.Scan( - &executor.ID, &executor.Name, &executor.Type, &executor.Status, - &isSystem, &resumable, &configJSON, &executor.CreatedAt, &executor.UpdatedAt, &executor.DeletedAt, - ); err != nil { - return nil, err - } - executor.IsSystem = isSystem == 1 - executor.Resumable = resumable == 1 - if configJSON != "" && configJSON != "{}" { - if err := json.Unmarshal([]byte(configJSON), &executor.Config); err != nil { - return nil, fmt.Errorf("failed to deserialize executor config: %w", err) - } - } - result = append(result, executor) - } - return result, rows.Err() -} - -func (r *sqliteRepository) UpsertExecutorRunning(ctx context.Context, running *models.ExecutorRunning) error { - if running == nil { - return fmt.Errorf("executor running is nil") - } - if running.SessionID == "" { - return fmt.Errorf("session_id is required") - } - if running.ID == "" { - running.ID = running.SessionID - } - now := time.Now().UTC() - if running.CreatedAt.IsZero() { - running.CreatedAt = now - } - running.UpdatedAt = now - - _, err := r.db.ExecContext(ctx, ` - INSERT INTO executors_running ( - id, session_id, task_id, executor_id, runtime, status, resumable, resume_token, - agent_execution_id, container_id, agentctl_url, agentctl_port, pid, - worktree_id, worktree_path, worktree_branch, last_seen_at, error_message, - created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(session_id) DO UPDATE SET - id = excluded.id, - task_id = excluded.task_id, - executor_id = excluded.executor_id, - runtime = excluded.runtime, - status = excluded.status, - resumable = excluded.resumable, - resume_token = excluded.resume_token, - agent_execution_id = excluded.agent_execution_id, - container_id = excluded.container_id, - agentctl_url = excluded.agentctl_url, - agentctl_port = excluded.agentctl_port, - pid = excluded.pid, - worktree_id = excluded.worktree_id, - worktree_path = excluded.worktree_path, - worktree_branch = excluded.worktree_branch, - last_seen_at = excluded.last_seen_at, - error_message = excluded.error_message, - updated_at = excluded.updated_at - `, - running.ID, - running.SessionID, - running.TaskID, - running.ExecutorID, - running.Runtime, - running.Status, - boolToInt(running.Resumable), - running.ResumeToken, - running.AgentExecutionID, - running.ContainerID, - running.AgentctlURL, - running.AgentctlPort, - running.PID, - running.WorktreeID, - running.WorktreePath, - running.WorktreeBranch, - running.LastSeenAt, - running.ErrorMessage, - running.CreatedAt, - running.UpdatedAt, - ) - return err -} - -func (r *sqliteRepository) ListExecutorsRunning(ctx context.Context) ([]*models.ExecutorRunning, error) { - rows, err := r.db.QueryContext(ctx, ` - SELECT id, session_id, task_id, executor_id, runtime, status, resumable, resume_token, - agent_execution_id, container_id, agentctl_url, agentctl_port, - worktree_id, worktree_path, worktree_branch, created_at, updated_at - FROM executors_running - ORDER BY updated_at DESC - `) - if err != nil { - return nil, err - } - defer func() { _ = rows.Close() }() - - var results []*models.ExecutorRunning - for rows.Next() { - running := &models.ExecutorRunning{} - if scanErr := rows.Scan( - &running.ID, - &running.SessionID, - &running.TaskID, - &running.ExecutorID, - &running.Runtime, - &running.Status, - &running.Resumable, - &running.ResumeToken, - &running.AgentExecutionID, - &running.ContainerID, - &running.AgentctlURL, - &running.AgentctlPort, - &running.WorktreeID, - &running.WorktreePath, - &running.WorktreeBranch, - &running.CreatedAt, - &running.UpdatedAt, - ); scanErr != nil { - return nil, scanErr - } - results = append(results, running) - } - if err := rows.Err(); err != nil { - return nil, err - } - return results, nil -} - -func (r *sqliteRepository) GetExecutorRunningBySessionID(ctx context.Context, sessionID string) (*models.ExecutorRunning, error) { - if sessionID == "" { - return nil, fmt.Errorf("session_id is required") - } - running := &models.ExecutorRunning{} - var resumable int - var lastSeen sql.NullTime - - err := r.db.QueryRowContext(ctx, ` - SELECT id, session_id, task_id, executor_id, runtime, status, resumable, resume_token, - agent_execution_id, container_id, agentctl_url, agentctl_port, pid, - worktree_id, worktree_path, worktree_branch, last_seen_at, error_message, - created_at, updated_at - FROM executors_running - WHERE session_id = ? - `, sessionID).Scan( - &running.ID, - &running.SessionID, - &running.TaskID, - &running.ExecutorID, - &running.Runtime, - &running.Status, - &resumable, - &running.ResumeToken, - &running.AgentExecutionID, - &running.ContainerID, - &running.AgentctlURL, - &running.AgentctlPort, - &running.PID, - &running.WorktreeID, - &running.WorktreePath, - &running.WorktreeBranch, - &lastSeen, - &running.ErrorMessage, - &running.CreatedAt, - &running.UpdatedAt, - ) - if err == sql.ErrNoRows { - return nil, fmt.Errorf("executor running not found for session: %s", sessionID) - } - if err != nil { - return nil, err - } - running.Resumable = resumable == 1 - if lastSeen.Valid { - running.LastSeenAt = &lastSeen.Time - } - return running, nil -} - -func (r *sqliteRepository) DeleteExecutorRunningBySessionID(ctx context.Context, sessionID string) error { - if sessionID == "" { - return fmt.Errorf("session_id is required") - } - result, err := r.db.ExecContext(ctx, `DELETE FROM executors_running WHERE session_id = ?`, sessionID) - if err != nil { - return err - } - rows, _ := result.RowsAffected() - if rows == 0 { - return fmt.Errorf("executor running not found for session: %s", sessionID) - } - return nil -} - -// Environment operations - -func (r *sqliteRepository) CreateEnvironment(ctx context.Context, environment *models.Environment) error { - if environment.ID == "" { - environment.ID = uuid.New().String() - } - now := time.Now().UTC() - environment.CreatedAt = now - environment.UpdatedAt = now - - buildConfigJSON, err := json.Marshal(environment.BuildConfig) - if err != nil { - return fmt.Errorf("failed to serialize environment build config: %w", err) - } - - _, 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) - return err -} - -func (r *sqliteRepository) GetEnvironment(ctx context.Context, id string) (*models.Environment, error) { - environment := &models.Environment{} - var buildConfigJSON string - var isSystem int - - err := r.db.QueryRowContext(ctx, ` - SELECT id, name, kind, is_system, worktree_root, image_tag, dockerfile, build_config, created_at, updated_at, deleted_at - FROM environments WHERE id = ? AND deleted_at IS NULL - `, id).Scan( - &environment.ID, &environment.Name, &environment.Kind, &isSystem, &environment.WorktreeRoot, - &environment.ImageTag, &environment.Dockerfile, &buildConfigJSON, - &environment.CreatedAt, &environment.UpdatedAt, &environment.DeletedAt, - ) - if err == sql.ErrNoRows { - return nil, fmt.Errorf("environment not found: %s", id) - } - if err != nil { - return nil, err - } - environment.IsSystem = isSystem == 1 - if buildConfigJSON != "" && buildConfigJSON != "{}" { - if err := json.Unmarshal([]byte(buildConfigJSON), &environment.BuildConfig); err != nil { - return nil, fmt.Errorf("failed to deserialize environment build config: %w", err) - } - } - return environment, nil -} - -func (r *sqliteRepository) UpdateEnvironment(ctx context.Context, environment *models.Environment) error { - environment.UpdatedAt = time.Now().UTC() - - buildConfigJSON, err := json.Marshal(environment.BuildConfig) - if err != nil { - return fmt.Errorf("failed to serialize environment build config: %w", err) - } - - 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) - if err != nil { - return err - } - rows, _ := result.RowsAffected() - if rows == 0 { - return fmt.Errorf("environment not found: %s", environment.ID) - } - return nil -} - -func (r *sqliteRepository) DeleteEnvironment(ctx context.Context, id string) error { - now := time.Now().UTC() - result, err := r.db.ExecContext(ctx, ` - UPDATE environments SET deleted_at = ?, updated_at = ? WHERE id = ? AND deleted_at IS NULL - `, now, now, id) - if err != nil { - return err - } - rows, _ := result.RowsAffected() - if rows == 0 { - return fmt.Errorf("environment not found: %s", id) - } - return nil -} - -func (r *sqliteRepository) ListEnvironments(ctx context.Context) ([]*models.Environment, error) { - rows, err := r.db.QueryContext(ctx, ` - SELECT id, name, kind, is_system, worktree_root, image_tag, dockerfile, build_config, created_at, updated_at, deleted_at - FROM environments WHERE deleted_at IS NULL ORDER BY created_at ASC - `) - if err != nil { - return nil, err - } - defer func() { _ = rows.Close() }() - - var result []*models.Environment - for rows.Next() { - environment := &models.Environment{} - var buildConfigJSON string - var isSystem int - if err := rows.Scan( - &environment.ID, &environment.Name, &environment.Kind, &isSystem, &environment.WorktreeRoot, - &environment.ImageTag, &environment.Dockerfile, &buildConfigJSON, - &environment.CreatedAt, &environment.UpdatedAt, &environment.DeletedAt, - ); err != nil { - return nil, err - } - environment.IsSystem = isSystem == 1 - if buildConfigJSON != "" && buildConfigJSON != "{}" { - if err := json.Unmarshal([]byte(buildConfigJSON), &environment.BuildConfig); err != nil { - return nil, fmt.Errorf("failed to deserialize environment build config: %w", err) - } - } - result = append(result, environment) - } - return result, rows.Err() -} diff --git a/apps/backend/internal/task/repository/sqlite/base.go b/apps/backend/internal/task/repository/sqlite/base.go index ad5b500d88..3cc5598f42 100644 --- a/apps/backend/internal/task/repository/sqlite/base.go +++ b/apps/backend/internal/task/repository/sqlite/base.go @@ -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 diff --git a/apps/backend/internal/task/repository/sqlite/board.go b/apps/backend/internal/task/repository/sqlite/board.go index 8b80a3a618..cea56ed083 100644 --- a/apps/backend/internal/task/repository/sqlite/board.go +++ b/apps/backend/internal/task/repository/sqlite/board.go @@ -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() -} - diff --git a/apps/backend/internal/task/repository/sqlite/defaults.go b/apps/backend/internal/task/repository/sqlite/defaults.go index d7fc16ab72..e4f97766e6 100644 --- a/apps/backend/internal/task/repository/sqlite/defaults.go +++ b/apps/backend/internal/task/repository/sqlite/defaults.go @@ -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 } } diff --git a/apps/backend/internal/task/repository/sqlite/environment.go b/apps/backend/internal/task/repository/sqlite/environment.go index 6a7f223db7..d51f637dfa 100644 --- a/apps/backend/internal/task/repository/sqlite/environment.go +++ b/apps/backend/internal/task/repository/sqlite/environment.go @@ -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 } diff --git a/apps/backend/internal/task/repository/sqlite/executor.go b/apps/backend/internal/task/repository/sqlite/executor.go index 093eaf0e03..9076c40874 100644 --- a/apps/backend/internal/task/repository/sqlite/executor.go +++ b/apps/backend/internal/task/repository/sqlite/executor.go @@ -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, diff --git a/apps/backend/internal/task/repository/sqlite/repository_entity.go b/apps/backend/internal/task/repository/sqlite/repository_entity.go index 9ee3937bf6..420f6345f7 100644 --- a/apps/backend/internal/task/repository/sqlite/repository_entity.go +++ b/apps/backend/internal/task/repository/sqlite/repository_entity.go @@ -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 diff --git a/apps/backend/internal/task/repository/sqlite/session.go b/apps/backend/internal/task/repository/sqlite/session.go index e2591bde5d..4000f3aea1 100644 --- a/apps/backend/internal/task/repository/sqlite/session.go +++ b/apps/backend/internal/task/repository/sqlite/session.go @@ -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 +} diff --git a/apps/backend/internal/task/repository/sqlite/task.go b/apps/backend/internal/task/repository/sqlite/task.go index 1ff0f0f1a6..8687fd956c 100644 --- a/apps/backend/internal/task/repository/sqlite/task.go +++ b/apps/backend/internal/task/repository/sqlite/task.go @@ -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, diff --git a/apps/backend/internal/task/repository/sqlite_test.go b/apps/backend/internal/task/repository/sqlite_test.go index 6eb3654670..8ef1615895 100644 --- a/apps/backend/internal/task/repository/sqlite_test.go +++ b/apps/backend/internal/task/repository/sqlite_test.go @@ -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 diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index c338759eb9..3ac7c278e6 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -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 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), diff --git a/apps/backend/internal/task/service/service_test.go b/apps/backend/internal/task/service/service_test.go index bc7f9fee84..4449695935 100644 --- a/apps/backend/internal/task/service/service_test.go +++ b/apps/backend/internal/task/service/service_test.go @@ -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 { diff --git a/apps/backend/internal/user/store/sqlite.go b/apps/backend/internal/user/store/sqlite.go index 33b911e0cc..b5295c4640 100644 --- a/apps/backend/internal/user/store/sqlite.go +++ b/apps/backend/internal/user/store/sqlite.go @@ -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 { diff --git a/apps/backend/internal/workflow/controller/controller.go b/apps/backend/internal/workflow/controller/controller.go new file mode 100644 index 0000000000..60039911af --- /dev/null +++ b/apps/backend/internal/workflow/controller/controller.go @@ -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 +} + diff --git a/apps/backend/internal/workflow/handlers/handlers.go b/apps/backend/internal/workflow/handlers/handlers.go new file mode 100644 index 0000000000..3494476a2c --- /dev/null +++ b/apps/backend/internal/workflow/handlers/handlers.go @@ -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) +} + diff --git a/apps/backend/internal/workflow/models/models.go b/apps/backend/internal/workflow/models/models.go new file mode 100644 index 0000000000..e4047e61f6 --- /dev/null +++ b/apps/backend/internal/workflow/models/models.go @@ -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"` +} + diff --git a/apps/backend/internal/workflow/provider.go b/apps/backend/internal/workflow/provider.go new file mode 100644 index 0000000000..5efb694921 --- /dev/null +++ b/apps/backend/internal/workflow/provider.go @@ -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 +} + diff --git a/apps/backend/internal/workflow/repository/sqlite.go b/apps/backend/internal/workflow/repository/sqlite.go new file mode 100644 index 0000000000..b7d0050285 --- /dev/null +++ b/apps/backend/internal/workflow/repository/sqlite.go @@ -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() +} diff --git a/apps/backend/internal/workflow/service/service.go b/apps/backend/internal/workflow/service/service.go new file mode 100644 index 0000000000..fa8c2b5fae --- /dev/null +++ b/apps/backend/internal/workflow/service/service.go @@ -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 +} + diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index cc755d2386..8b71252d55 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -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" diff --git a/apps/packages/types/src/backend.ts b/apps/packages/types/src/backend.ts index fbc2156a24..9b151a6a56 100644 --- a/apps/packages/types/src/backend.ts +++ b/apps/packages/types/src/backend.ts @@ -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; diff --git a/apps/web/app/actions/workspaces.ts b/apps/web/app/actions/workspaces.ts index 15d44678d8..c40ac5c717 100644 --- a/apps/web/app/actions/workspaces.ts +++ b/apps/web/app/actions/workspaces.ts @@ -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(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(`${apiBaseUrl}/api/v1/boards`, { method: 'POST', body: JSON.stringify(payload), @@ -106,39 +115,6 @@ export async function deleteBoardAction(id: string) { await fetchJson(`${apiBaseUrl}/api/v1/boards/${id}`, { method: 'DELETE' }); } -export async function listColumnsAction(boardId: string): Promise { - return fetchJson(`${apiBaseUrl}/api/v1/boards/${boardId}/columns`); -} - -export async function createColumnAction(payload: { - board_id: string; - name: string; - position: number; - state: string; - color: string; -}) { - return fetchJson(`${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>) { - return fetchJson(`${apiBaseUrl}/api/v1/columns/${id}`, { - method: 'PUT', - body: JSON.stringify(payload), - }); -} - -export async function deleteColumnAction(id: string) { - await fetchJson(`${apiBaseUrl}/api/v1/columns/${id}`, { method: 'DELETE' }); -} - export async function listRepositoriesAction(workspaceId: string): Promise { return fetchJson(`${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(`${apiBaseUrl}/api/v1/scripts/${id}`, { method: 'DELETE' }); } + +// Workflow Templates +export async function listWorkflowTemplatesAction(): Promise { + return fetchJson(`${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 { + 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 { + // 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(`${apiBaseUrl}/api/v1/workflow/steps`, { + method: 'POST', + body: JSON.stringify(body), + }); + return transformWorkflowStep(response); +} + +export async function updateWorkflowStepAction( + stepId: string, + payload: Partial> +): Promise { + // Flatten behaviors into top-level fields for the backend + const body: Record = {}; + 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(`${apiBaseUrl}/api/v1/workflow/steps/${stepId}`, { + method: 'PUT', + body: JSON.stringify(body), + }); + return transformWorkflowStep(response); +} + +export async function deleteWorkflowStepAction(stepId: string) { + await fetchJson(`${apiBaseUrl}/api/v1/workflow/steps/${stepId}`, { method: 'DELETE' }); +} + +export async function reorderWorkflowStepsAction(boardId: string, stepIds: string[]): Promise { + 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 { + return fetchJson(`${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 }), + }); +} diff --git a/apps/web/app/settings/agents/[agentId]/profile-mcp-config-card.tsx b/apps/web/app/settings/agents/[agentId]/profile-mcp-config-card.tsx index 0d4ae008ca..297a8d7475 100644 --- a/apps/web/app/settings/agents/[agentId]/profile-mcp-config-card.tsx +++ b/apps/web/app/settings/agents/[agentId]/profile-mcp-config-card.tsx @@ -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> = { kandev: { diff --git a/apps/web/app/settings/workspace/[id]/boards/page.tsx b/apps/web/app/settings/workspace/[id]/boards/page.tsx index 9ece56d2c1..a977f6840b 100644 --- a/apps/web/app/settings/workspace/[id]/boards/page.tsx +++ b/apps/web/app/settings/workspace/[id]/boards/page.tsx @@ -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>['boards'][number] & { - columns: Awaited>['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 ; + return ( + + ); } diff --git a/apps/web/app/settings/workspace/workspace-boards-client.tsx b/apps/web/app/settings/workspace/workspace-boards-client.tsx index 9257a42f77..ad8530bae7 100644 --- a/apps/web/app/settings/workspace/workspace-boards-client.tsx +++ b/apps/web/app/settings/workspace/workspace-boards-client.tsx @@ -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(boards); - const [savedBoardItems, setSavedBoardItems] = useState(boards); + const [boardItems, setBoardItems] = useState(boards); + const [savedBoardItems, setSavedBoardItems] = useState(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(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 - ) => { - 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) => { - 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

{workspace.name}

- Manage boards and columns for this workspace. + Manage boards for this workspace.

@@ -318,22 +186,108 @@ export function WorkspaceBoardsClient({ workspace, boards }: WorkspaceBoardsClie 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)} /> ))} + + {/* Create Board Dialog */} + + + + Create Board + + +
+
+ + setNewBoardName(e.target.value)} + /> +
+ + {workflowTemplates.length > 0 && ( +
+ + + setSelectedTemplateId(value === 'custom' ? null : value) + } + > + {workflowTemplates.map((template) => ( +
+ + +
+ ))} +
+ + +
+
+
+ )} + + {/* Workflow Preview */} + {previewSteps.length > 0 && ( +
+ +
+ {previewSteps.map((step, i) => ( +
+
+ {step.name} + {step.behaviors?.autoStartAgent && ( + + )} + {step.behaviors?.requireApproval && ( + + )} +
+ {i < previewSteps.length - 1 && ( + + )} +
+ ))} +
+
+ )} +
+ + + + + +
+
); } diff --git a/apps/web/components/kanban-board-grid.tsx b/apps/web/components/kanban-board-grid.tsx index d321588aaf..0625beb908 100644 --- a/apps/web/components/kanban-board-grid.tsx +++ b/apps/web/components/kanban-board-grid.tsx @@ -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)); }; diff --git a/apps/web/components/kanban-board.tsx b/apps/web/components/kanban-board.tsx index 5ac3579dfa..611559ca80 100644 --- a/apps/web/components/kanban-board.tsx +++ b/apps/web/components/kanban-board.tsx @@ -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(null); + const [isWorkflowDialogOpen, setIsWorkflowDialogOpen] = useState(false); + + // Move error state for approval warning modal + const [moveError, setMoveError] = useState(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 */} + { + 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 */} + !open && setMoveError(null)}> + + + + + Approval Required + + + {moveError?.message} + + + + Dismiss + {moveError?.sessionId && ( + + Go to Task + + )} + + + )} + {(task.sessionCount && task.sessionCount > 1 || task.reviewStatus === 'changes_requested') && ( +
+ {task.sessionCount && task.sessionCount > 1 && ( + + {task.sessionCount} sessions + + )} + {task.reviewStatus === 'changes_requested' && ( + + Changes Requested + + )} +
+ )} ); } diff --git a/apps/web/components/kanban-column.tsx b/apps/web/components/kanban-column.tsx index d1f142e011..5f217ab1c5 100644 --- a/apps/web/components/kanban-column.tsx +++ b/apps/web/components/kanban-column.tsx @@ -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; diff --git a/apps/web/components/kanban-with-preview.tsx b/apps/web/components/kanban-with-preview.tsx index 6651de1142..c9212356e0 100644 --- a/apps/web/components/kanban-with-preview.tsx +++ b/apps/web/components/kanban-with-preview.tsx @@ -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, diff --git a/apps/web/components/settings/board-card.tsx b/apps/web/components/settings/board-card.tsx index 02597da8a7..cb36c80ddc 100644 --- a/apps/web/components/settings/board-card.tsx +++ b/apps/web/components/settings/board-card.tsx @@ -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; - onCreateColumn: (column: Omit) => void; - onUpdateColumn: (columnId: string, updates: Partial) => void; - onDeleteColumn: (columnId: string) => Promise; - onReorderColumns: (columns: Column[]) => void; onSaveBoard: () => Promise; }; -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) => void; - onDelete: () => Promise; -}) { - 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([]); + 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) => { + 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 ( -
- -
-
- handleNameChange(e.target.value)} - /> -
-
- -
- -
-
- ); -} - -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 })} /> -
-
-
- -
-
- -
-
+ {/* Workflow Steps Section - only show for saved boards */} + {!isNewBoard && (
- {isMounted ? ( - - - {columns.map((column) => ( - onUpdateColumn(column.id, updates)} - onDelete={() => onDeleteColumn(column.id)} - /> - ))} - - + + {workflowLoading ? ( +
Loading workflow steps...
) : ( - columns.map((column) => ( - onUpdateColumn(column.id, updates)} - onDelete={() => onDeleteColumn(column.id)} - /> - )) + )}
-
+ )} +
+ +
+ + { + setLocalName(e.target.value); + debouncedUpdateName(e.target.value); + }} + className="flex-1" + placeholder="Step name" + /> + + + +
+ {behaviors.autoStartAgent && } + {behaviors.requireApproval && } + {behaviors.planMode && } +
+ + + + +
+ + {expanded && ( +
+
+
+ updateBehaviors({ autoStartAgent: checked === true })} + /> + +
+
+ updateBehaviors({ planMode: checked === true })} + /> + +
+
+ updateBehaviors({ requireApproval: checked === true })} + /> + +
+
+ +
+ +