feat(workflow): implement workflow system with steps terminology (#102)

This commit is contained in:
João Salavisa
2026-01-27 01:21:47 +00:00
committed by GitHub
parent 7c82ccdf24
commit 0b573bbdda
99 changed files with 7269 additions and 4624 deletions
+10 -1
View File
@@ -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)")
+72
View File
@@ -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
}
+44 -4
View File
@@ -1,6 +1,8 @@
package main
import (
"context"
"github.com/kandev/kandev/internal/agent/discovery"
"github.com/kandev/kandev/internal/agent/registry"
agentsettingscontroller "github.com/kandev/kandev/internal/agent/settings/controller"
@@ -11,6 +13,7 @@ import (
promptservice "github.com/kandev/kandev/internal/prompts/service"
taskservice "github.com/kandev/kandev/internal/task/service"
userservice "github.com/kandev/kandev/internal/user/service"
workflowservice "github.com/kandev/kandev/internal/workflow/service"
)
func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories, eventBus bus.EventBus) (*Services, *agentsettingscontroller.Controller, error) {
@@ -25,6 +28,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories
userSvc := userservice.NewService(repos.User, eventBus, log)
editorSvc := editorservice.NewService(repos.Editor, repos.Task, userSvc)
promptSvc := promptservice.NewService(repos.Prompts)
workflowSvc := workflowservice.NewService(repos.Workflow, log)
taskSvc := taskservice.NewService(
repos.Task,
eventBus,
@@ -35,12 +39,48 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories
},
)
// Wire workflow step creator to task service for board creation
taskSvc.SetWorkflowStepCreator(workflowSvc)
// Wire workflow step getter to task service for MoveTask
taskSvc.SetWorkflowStepGetter(&workflowStepGetterAdapter{svc: workflowSvc})
return &Services{
Task: taskSvc,
User: userSvc,
Editor: editorSvc,
Prompts: promptSvc,
Task: taskSvc,
User: userSvc,
Editor: editorSvc,
Prompts: promptSvc,
Workflow: workflowSvc,
// Notification service is initialized after gateway is available.
Notification: nil,
}, agentSettingsController, nil
}
// workflowStepGetterAdapter adapts workflow service to task service's WorkflowStepGetter interface.
type workflowStepGetterAdapter struct {
svc *workflowservice.Service
}
// GetStep implements taskservice.WorkflowStepGetter.
func (a *workflowStepGetterAdapter) GetStep(ctx context.Context, stepID string) (*taskservice.WorkflowStep, error) {
step, err := a.svc.GetStep(ctx, stepID)
if err != nil {
return nil, err
}
return &taskservice.WorkflowStep{
ID: step.ID,
BoardID: step.BoardID,
Name: step.Name,
StepType: string(step.StepType),
Position: step.Position,
Color: step.Color,
AutoStartAgent: step.AutoStartAgent,
PlanMode: step.PlanMode,
RequireApproval: step.RequireApproval,
PromptPrefix: step.PromptPrefix,
PromptSuffix: step.PromptSuffix,
AllowManualMove: step.AllowManualMove,
OnCompleteStepID: step.OnCompleteStepID,
OnApprovalStepID: step.OnApprovalStepID,
}, nil
}
+7
View File
@@ -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
}
+4
View File
@@ -12,6 +12,8 @@ import (
taskservice "github.com/kandev/kandev/internal/task/service"
userservice "github.com/kandev/kandev/internal/user/service"
userstore "github.com/kandev/kandev/internal/user/store"
workflowrepository "github.com/kandev/kandev/internal/workflow/repository"
workflowservice "github.com/kandev/kandev/internal/workflow/service"
)
type Repositories struct {
@@ -21,6 +23,7 @@ type Repositories struct {
Notification notificationstore.Repository
Editor editorstore.Repository
Prompts promptstore.Repository
Workflow *workflowrepository.Repository
}
type Services struct {
@@ -29,4 +32,5 @@ type Services struct {
Editor *editorservice.Service
Notification *notificationservice.Service
Prompts *promptservice.Service
Workflow *workflowservice.Service
}
@@ -10,6 +10,7 @@ import (
"github.com/google/uuid"
"github.com/kandev/kandev/internal/agent/settings/models"
"github.com/kandev/kandev/internal/common/sqlite"
)
type sqliteRepository struct {
@@ -82,13 +83,13 @@ func (r *sqliteRepository) initSchema() error {
if err != nil {
return err
}
if err := ensureColumn(r.db, "agent_profiles", "deleted_at", "DATETIME"); err != nil {
if err := sqlite.EnsureColumn(r.db, "agent_profiles", "deleted_at", "DATETIME"); err != nil {
return err
}
if err := ensureColumn(r.db, "agent_profiles", "agent_display_name", "TEXT NOT NULL DEFAULT ''"); err != nil {
if err := sqlite.EnsureColumn(r.db, "agent_profiles", "agent_display_name", "TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
if err := ensureColumn(r.db, "agent_profiles", "cli_passthrough", "INTEGER NOT NULL DEFAULT 0"); err != nil {
if err := sqlite.EnsureColumn(r.db, "agent_profiles", "cli_passthrough", "INTEGER NOT NULL DEFAULT 0"); err != nil {
return err
}
return nil
@@ -111,7 +112,7 @@ func (r *sqliteRepository) CreateAgent(ctx context.Context, agent *models.Agent)
_, err := r.db.ExecContext(ctx, `
INSERT INTO agents (id, name, workspace_id, supports_mcp, mcp_config_path, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, agent.ID, agent.Name, agent.WorkspaceID, boolToInt(agent.SupportsMCP), agent.MCPConfigPath, agent.CreatedAt, agent.UpdatedAt)
`, agent.ID, agent.Name, agent.WorkspaceID, sqlite.BoolToInt(agent.SupportsMCP), agent.MCPConfigPath, agent.CreatedAt, agent.UpdatedAt)
return err
}
@@ -136,7 +137,7 @@ func (r *sqliteRepository) UpdateAgent(ctx context.Context, agent *models.Agent)
result, err := r.db.ExecContext(ctx, `
UPDATE agents SET workspace_id = ?, supports_mcp = ?, mcp_config_path = ?, updated_at = ?
WHERE id = ?
`, agent.WorkspaceID, boolToInt(agent.SupportsMCP), agent.MCPConfigPath, agent.UpdatedAt, agent.ID)
`, agent.WorkspaceID, sqlite.BoolToInt(agent.SupportsMCP), agent.MCPConfigPath, agent.UpdatedAt, agent.ID)
if err != nil {
return err
}
@@ -239,7 +240,7 @@ func (r *sqliteRepository) UpsertAgentProfileMcpConfig(ctx context.Context, conf
servers_json = excluded.servers_json,
meta_json = excluded.meta_json,
updated_at = excluded.updated_at
`, config.ProfileID, boolToInt(config.Enabled), string(serversJSON), string(metaJSON), config.CreatedAt, config.UpdatedAt)
`, config.ProfileID, sqlite.BoolToInt(config.Enabled), string(serversJSON), string(metaJSON), config.CreatedAt, config.UpdatedAt)
return err
}
@@ -253,8 +254,8 @@ func (r *sqliteRepository) CreateAgentProfile(ctx context.Context, profile *mode
_, err := r.db.ExecContext(ctx, `
INSERT INTO agent_profiles (id, agent_id, name, agent_display_name, model, auto_approve, dangerously_skip_permissions, allow_indexing, cli_passthrough, plan, created_at, updated_at, deleted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, profile.ID, profile.AgentID, profile.Name, profile.AgentDisplayName, profile.Model, boolToInt(profile.AutoApprove),
boolToInt(profile.DangerouslySkipPermissions), boolToInt(profile.AllowIndexing), boolToInt(profile.CLIPassthrough), profile.Plan, profile.CreatedAt, profile.UpdatedAt, profile.DeletedAt)
`, profile.ID, profile.AgentID, profile.Name, profile.AgentDisplayName, profile.Model, sqlite.BoolToInt(profile.AutoApprove),
sqlite.BoolToInt(profile.DangerouslySkipPermissions), sqlite.BoolToInt(profile.AllowIndexing), sqlite.BoolToInt(profile.CLIPassthrough), profile.Plan, profile.CreatedAt, profile.UpdatedAt, profile.DeletedAt)
return err
}
@@ -264,8 +265,8 @@ func (r *sqliteRepository) UpdateAgentProfile(ctx context.Context, profile *mode
UPDATE agent_profiles
SET name = ?, agent_display_name = ?, model = ?, auto_approve = ?, dangerously_skip_permissions = ?, allow_indexing = ?, cli_passthrough = ?, plan = ?, updated_at = ?
WHERE id = ? AND deleted_at IS NULL
`, profile.Name, profile.AgentDisplayName, profile.Model, boolToInt(profile.AutoApprove),
boolToInt(profile.DangerouslySkipPermissions), boolToInt(profile.AllowIndexing), boolToInt(profile.CLIPassthrough), profile.Plan, profile.UpdatedAt, profile.ID)
`, profile.Name, profile.AgentDisplayName, profile.Model, sqlite.BoolToInt(profile.AutoApprove),
sqlite.BoolToInt(profile.DangerouslySkipPermissions), sqlite.BoolToInt(profile.AllowIndexing), sqlite.BoolToInt(profile.CLIPassthrough), profile.Plan, profile.UpdatedAt, profile.ID)
if err != nil {
return err
}
@@ -377,46 +378,3 @@ func scanAgentProfile(scanner interface {
profile.CLIPassthrough = cliPassthrough == 1
return profile, nil
}
func ensureColumn(db *sql.DB, table, column, definition string) error {
exists, err := columnExists(db, table, column)
if err != nil {
return err
}
if exists {
return nil
}
query := fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, column, definition)
_, err = db.Exec(query)
return err
}
func columnExists(db *sql.DB, table, column string) (bool, error) {
rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
if err != nil {
return false, err
}
defer func() {
_ = rows.Close()
}()
for rows.Next() {
var cid int
var name, ctype string
var notnull, pk int
var dflt sql.NullString
if err := rows.Scan(&cid, &name, &ctype, &notnull, &dflt, &pk); err != nil {
return false, err
}
if name == column {
return true, nil
}
}
return false, rows.Err()
}
func boolToInt(value bool) int {
if value {
return 1
}
return 0
}
@@ -548,8 +548,9 @@ func (wt *WorkspaceTracker) getGitStatus(ctx context.Context) (types.GitStatusUp
}
}
// Get file status using git status --porcelain
statusCmd := exec.CommandContext(ctx, "git", "status", "--porcelain")
// Get file status using git status --porcelain --untracked-files=all
// --untracked-files=all shows all files in untracked directories, not just the directory name
statusCmd := exec.CommandContext(ctx, "git", "status", "--porcelain", "--untracked-files=all")
statusCmd.Dir = wt.workDir
statusOut, err := statusCmd.Output()
if err != nil {
@@ -0,0 +1,56 @@
// Package sqlite provides common SQLite utility functions.
package sqlite
import (
"database/sql"
"fmt"
)
// BoolToInt converts a boolean to an integer (for SQLite).
func BoolToInt(value bool) int {
if value {
return 1
}
return 0
}
// EnsureColumn adds a column to a table if it doesn't exist.
func EnsureColumn(db *sql.DB, table, column, definition string) error {
exists, err := ColumnExists(db, table, column)
if err != nil {
return err
}
if exists {
return nil
}
query := fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, column, definition)
_, err = db.Exec(query)
return err
}
// ColumnExists checks if a column exists in a table.
func ColumnExists(db *sql.DB, table, column string) (bool, error) {
rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
if err != nil {
return false, err
}
defer func() {
_ = rows.Close()
}()
for rows.Next() {
var cid int
var name, colType string
var notNull int
var defaultValue sql.NullString
var pk int
if err := rows.Scan(&cid, &name, &colType, &notNull, &defaultValue, &pk); err != nil {
return false, err
}
if name == column {
return true, nil
}
}
return false, rows.Err()
}
+5 -11
View File
@@ -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
-7
View File
@@ -23,13 +23,6 @@ const (
BoardDeleted = "board.deleted"
)
// Event types for columns
const (
ColumnCreated = "column.created"
ColumnUpdated = "column.updated"
ColumnDeleted = "column.deleted"
)
// Event types for comments
const (
MessageAdded = "message.added"
@@ -31,9 +31,6 @@ func RegisterTaskNotifications(ctx context.Context, eventBus bus.EventBus, hub *
b.subscribe(eventBus, events.BoardCreated, ws.ActionBoardCreated)
b.subscribe(eventBus, events.BoardUpdated, ws.ActionBoardUpdated)
b.subscribe(eventBus, events.BoardDeleted, ws.ActionBoardDeleted)
b.subscribe(eventBus, events.ColumnCreated, ws.ActionColumnCreated)
b.subscribe(eventBus, events.ColumnUpdated, ws.ActionColumnUpdated)
b.subscribe(eventBus, events.ColumnDeleted, ws.ActionColumnDeleted)
b.subscribe(eventBus, events.TaskCreated, ws.ActionTaskCreated)
b.subscribe(eventBus, events.TaskUpdated, ws.ActionTaskUpdated)
b.subscribe(eventBus, events.TaskDeleted, ws.ActionTaskDeleted)
@@ -19,7 +19,6 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/kandev/kandev/internal/worktree"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/db"
"github.com/kandev/kandev/internal/events/bus"
@@ -28,6 +27,10 @@ import (
taskhandlers "github.com/kandev/kandev/internal/task/handlers"
"github.com/kandev/kandev/internal/task/repository"
taskservice "github.com/kandev/kandev/internal/task/service"
"github.com/kandev/kandev/internal/workflow"
workflowcontroller "github.com/kandev/kandev/internal/workflow/controller"
workflowhandlers "github.com/kandev/kandev/internal/workflow/handlers"
"github.com/kandev/kandev/internal/worktree"
ws "github.com/kandev/kandev/pkg/websocket"
)
@@ -79,8 +82,13 @@ func NewTestServer(t *testing.T) *TestServer {
t.Fatalf("failed to init worktree store: %v", err)
}
// Initialize task service
// Initialize workflow service
_, workflowSvc, _, err := workflow.Provide(dbConn, log)
require.NoError(t, err)
// Initialize task service and wire workflow step creator
taskSvc := taskservice.NewService(taskRepo, eventBus, log, taskservice.RepositoryDiscoveryConfig{})
taskSvc.SetWorkflowStepCreator(workflowSvc)
// Create WebSocket gateway
gateway := gateways.NewGateway(log)
@@ -96,16 +104,19 @@ func NewTestServer(t *testing.T) *TestServer {
// Register handlers (HTTP + WS)
workspaceController := taskcontroller.NewWorkspaceController(taskSvc)
boardController := taskcontroller.NewBoardController(taskSvc)
boardController.SetWorkflowStepLister(workflowSvc)
taskController := taskcontroller.NewTaskController(taskSvc)
executorController := taskcontroller.NewExecutorController(taskSvc)
environmentController := taskcontroller.NewEnvironmentController(taskSvc)
repositoryController := taskcontroller.NewRepositoryController(taskSvc)
workflowCtrl := workflowcontroller.NewController(workflowSvc)
taskhandlers.RegisterWorkspaceRoutes(router, gateway.Dispatcher, workspaceController, log)
taskhandlers.RegisterBoardRoutes(router, gateway.Dispatcher, boardController, log)
taskhandlers.RegisterTaskRoutes(router, gateway.Dispatcher, taskController, nil, log)
taskhandlers.RegisterRepositoryRoutes(router, gateway.Dispatcher, repositoryController, log)
taskhandlers.RegisterExecutorRoutes(router, gateway.Dispatcher, executorController, log)
taskhandlers.RegisterEnvironmentRoutes(router, gateway.Dispatcher, environmentController, log)
workflowhandlers.RegisterRoutes(router, gateway.Dispatcher, workflowCtrl, log)
// Create test server
server := httptest.NewServer(router)
@@ -342,9 +353,10 @@ func TestBoardCRUD(t *testing.T) {
// Create board
t.Run("CreateBoard", func(t *testing.T) {
resp, err := client.SendRequest("board-create-1", ws.ActionBoardCreate, map[string]interface{}{
"workspace_id": workspaceID,
"name": "Test Board",
"description": "A test board for integration testing",
"workspace_id": workspaceID,
"name": "Test Board",
"description": "A test board for integration testing",
"workflow_template_id": "simple",
})
require.NoError(t, err)
assert.Equal(t, ws.MessageTypeResponse, resp.Type)
@@ -375,10 +387,10 @@ func TestBoardCRUD(t *testing.T) {
}
// ============================================
// COLUMN TESTS
// WORKFLOW STEP TESTS
// ============================================
func TestColumnCRUD(t *testing.T) {
func TestWorkflowStepList(t *testing.T) {
ts := NewTestServer(t)
defer ts.Close()
@@ -387,10 +399,11 @@ func TestColumnCRUD(t *testing.T) {
workspaceID := createWorkspace(t, client)
// First create a board
// Create a board (workflow steps are created automatically with default template)
boardResp, err := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{
"workspace_id": workspaceID,
"name": "Column Test Board",
"workspace_id": workspaceID,
"name": "Workflow Step Test Board",
"workflow_template_id": "simple",
})
require.NoError(t, err)
@@ -399,43 +412,9 @@ func TestColumnCRUD(t *testing.T) {
require.NoError(t, err)
boardID := boardPayload["id"].(string)
// Create column
t.Run("CreateColumn", func(t *testing.T) {
resp, err := client.SendRequest("col-create-1", ws.ActionColumnCreate, map[string]interface{}{
"board_id": boardID,
"name": "To Do",
"position": 0,
})
require.NoError(t, err)
assert.Equal(t, ws.MessageTypeResponse, resp.Type)
var payload map[string]interface{}
err = resp.ParsePayload(&payload)
require.NoError(t, err)
assert.NotEmpty(t, payload["id"])
assert.Equal(t, "To Do", payload["name"])
})
// Create second column
t.Run("CreateSecondColumn", func(t *testing.T) {
resp, err := client.SendRequest("col-create-2", ws.ActionColumnCreate, map[string]interface{}{
"board_id": boardID,
"name": "In Progress",
"position": 1,
})
require.NoError(t, err)
var payload map[string]interface{}
err = resp.ParsePayload(&payload)
require.NoError(t, err)
assert.Equal(t, "In Progress", payload["name"])
})
// List columns
t.Run("ListColumns", func(t *testing.T) {
resp, err := client.SendRequest("col-list-1", ws.ActionColumnList, map[string]interface{}{
// List workflow steps
t.Run("ListWorkflowSteps", func(t *testing.T) {
resp, err := client.SendRequest("step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{
"board_id": boardID,
})
require.NoError(t, err)
@@ -444,9 +423,15 @@ func TestColumnCRUD(t *testing.T) {
err = resp.ParsePayload(&payload)
require.NoError(t, err)
columns, ok := payload["columns"].([]interface{})
steps, ok := payload["steps"].([]interface{})
require.True(t, ok)
assert.Len(t, columns, 2)
// Default workflow template creates 4 steps: Todo, In Progress, Review, Done
assert.GreaterOrEqual(t, len(steps), 1)
// Verify first step has expected fields
firstStep := steps[0].(map[string]interface{})
assert.NotEmpty(t, firstStep["id"])
assert.NotEmpty(t, firstStep["name"])
})
}
@@ -464,10 +449,11 @@ func TestTaskCRUD(t *testing.T) {
workspaceID := createWorkspace(t, client)
repositoryID := createRepository(t, client, workspaceID)
// Create board and column first
// Create board (workflow steps are created automatically)
boardResp, err := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{
"workspace_id": workspaceID,
"name": "Task Test Board",
"workspace_id": workspaceID,
"name": "Task Test Board",
"workflow_template_id": "simple",
})
require.NoError(t, err)
@@ -476,31 +462,32 @@ func TestTaskCRUD(t *testing.T) {
require.NoError(t, err)
boardID := boardPayload["id"].(string)
colResp, err := client.SendRequest("col-1", ws.ActionColumnCreate, map[string]interface{}{
// Get first workflow step
stepResp, err := client.SendRequest("step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{
"board_id": boardID,
"name": "To Do",
"position": 0,
})
require.NoError(t, err)
var colPayload map[string]interface{}
err = colResp.ParsePayload(&colPayload)
var stepListPayload map[string]interface{}
err = stepResp.ParsePayload(&stepListPayload)
require.NoError(t, err)
columnID := colPayload["id"].(string)
steps := stepListPayload["steps"].([]interface{})
require.NotEmpty(t, steps)
workflowStepID := steps[0].(map[string]interface{})["id"].(string)
var taskID string
// Create task
t.Run("CreateTask", func(t *testing.T) {
resp, err := client.SendRequest("task-create-1", ws.ActionTaskCreate, map[string]interface{}{
"workspace_id": workspaceID,
"board_id": boardID,
"column_id": columnID,
"title": "Test Task",
"description": "A test task for integration testing",
"priority": 3, // HIGH priority (1=LOW, 2=MEDIUM, 3=HIGH)
"repository_id": repositoryID,
"base_branch": "main",
"workspace_id": workspaceID,
"board_id": boardID,
"workflow_step_id": workflowStepID,
"title": "Test Task",
"description": "A test task for integration testing",
"priority": 3, // HIGH priority (1=LOW, 2=MEDIUM, 3=HIGH)
"repository_id": repositoryID,
"base_branch": "main",
})
require.NoError(t, err)
@@ -614,10 +601,11 @@ func TestTaskStateTransitions(t *testing.T) {
workspaceID := createWorkspace(t, client)
repositoryID := createRepository(t, client, workspaceID)
// Create board, column, and task
// Create board and task (workflow steps are created automatically)
boardResp, _ := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{
"workspace_id": workspaceID,
"name": "State Test Board",
"workspace_id": workspaceID,
"name": "State Test Board",
"workflow_template_id": "simple",
})
var boardPayload map[string]interface{}
if err := boardResp.ParsePayload(&boardPayload); err != nil {
@@ -625,25 +613,25 @@ func TestTaskStateTransitions(t *testing.T) {
}
boardID := boardPayload["id"].(string)
colResp, _ := client.SendRequest("col-1", ws.ActionColumnCreate, map[string]interface{}{
// Get first workflow step
stepResp, _ := client.SendRequest("step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{
"board_id": boardID,
"name": "To Do",
"position": 0,
})
var colPayload map[string]interface{}
if err := colResp.ParsePayload(&colPayload); err != nil {
t.Fatalf("failed to parse column payload: %v", err)
var stepListPayload map[string]interface{}
if err := stepResp.ParsePayload(&stepListPayload); err != nil {
t.Fatalf("failed to parse step list payload: %v", err)
}
columnID := colPayload["id"].(string)
steps := stepListPayload["steps"].([]interface{})
workflowStepID := steps[0].(map[string]interface{})["id"].(string)
taskResp, _ := client.SendRequest("task-1", ws.ActionTaskCreate, map[string]interface{}{
"workspace_id": workspaceID,
"board_id": boardID,
"column_id": columnID,
"title": "State Test Task",
"description": "Test state transitions",
"repository_id": repositoryID,
"base_branch": "main",
"workspace_id": workspaceID,
"board_id": boardID,
"workflow_step_id": workflowStepID,
"title": "State Test Task",
"description": "Test state transitions",
"repository_id": repositoryID,
"base_branch": "main",
})
var taskPayload map[string]interface{}
if err := taskResp.ParsePayload(&taskPayload); err != nil {
@@ -698,10 +686,11 @@ func TestTaskMove(t *testing.T) {
workspaceID := createWorkspace(t, client)
repositoryID := createRepository(t, client, workspaceID)
// Create board with two columns
// Create board (workflow steps are created automatically)
boardResp, _ := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{
"workspace_id": workspaceID,
"name": "Move Test Board",
"workspace_id": workspaceID,
"name": "Move Test Board",
"workflow_template_id": "simple",
})
var boardPayload map[string]interface{}
if err := boardResp.ParsePayload(&boardPayload); err != nil {
@@ -709,36 +698,27 @@ func TestTaskMove(t *testing.T) {
}
boardID := boardPayload["id"].(string)
col1Resp, _ := client.SendRequest("col-1", ws.ActionColumnCreate, map[string]interface{}{
// Get workflow steps (at least 2 should exist from default template)
stepResp, _ := client.SendRequest("step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{
"board_id": boardID,
"name": "To Do",
"position": 0,
})
var col1Payload map[string]interface{}
if err := col1Resp.ParsePayload(&col1Payload); err != nil {
t.Fatalf("failed to parse column payload: %v", err)
var stepListPayload map[string]interface{}
if err := stepResp.ParsePayload(&stepListPayload); err != nil {
t.Fatalf("failed to parse step list payload: %v", err)
}
col1ID := col1Payload["id"].(string)
steps := stepListPayload["steps"].([]interface{})
require.GreaterOrEqual(t, len(steps), 2, "Expected at least 2 workflow steps")
step1ID := steps[0].(map[string]interface{})["id"].(string)
step2ID := steps[1].(map[string]interface{})["id"].(string)
col2Resp, _ := client.SendRequest("col-2", ws.ActionColumnCreate, map[string]interface{}{
"board_id": boardID,
"name": "Done",
"position": 1,
})
var col2Payload map[string]interface{}
if err := col2Resp.ParsePayload(&col2Payload); err != nil {
t.Fatalf("failed to parse column payload: %v", err)
}
col2ID := col2Payload["id"].(string)
// Create task in first column
// Create task in first step
taskResp, _ := client.SendRequest("task-1", ws.ActionTaskCreate, map[string]interface{}{
"workspace_id": workspaceID,
"board_id": boardID,
"column_id": col1ID,
"title": "Movable Task",
"repository_id": repositoryID,
"base_branch": "main",
"workspace_id": workspaceID,
"board_id": boardID,
"workflow_step_id": step1ID,
"title": "Movable Task",
"repository_id": repositoryID,
"base_branch": "main",
})
var taskPayload map[string]interface{}
if err := taskResp.ParsePayload(&taskPayload); err != nil {
@@ -746,13 +726,13 @@ func TestTaskMove(t *testing.T) {
}
taskID := taskPayload["id"].(string)
// Move task to second column
t.Run("MoveToColumn2", func(t *testing.T) {
// Move task to second step
t.Run("MoveToStep2", func(t *testing.T) {
resp, err := client.SendRequest("move-1", ws.ActionTaskMove, map[string]interface{}{
"id": taskID,
"board_id": boardID,
"column_id": col2ID,
"position": 0,
"id": taskID,
"board_id": boardID,
"workflow_step_id": step2ID,
"position": 0,
})
require.NoError(t, err)
@@ -760,10 +740,12 @@ func TestTaskMove(t *testing.T) {
err = resp.ParsePayload(&payload)
require.NoError(t, err)
assert.Equal(t, col2ID, payload["column_id"])
// Response is {"task": {...}, "workflow_step": {...}}
taskData := payload["task"].(map[string]interface{})
assert.Equal(t, step2ID, taskData["workflow_step_id"])
})
// Verify task is now in column 2
// Verify task is now in step 2
t.Run("VerifyMove", func(t *testing.T) {
resp, err := client.SendRequest("get-1", ws.ActionTaskGet, map[string]interface{}{
"id": taskID,
@@ -774,7 +756,7 @@ func TestTaskMove(t *testing.T) {
err = resp.ParsePayload(&payload)
require.NoError(t, err)
assert.Equal(t, col2ID, payload["column_id"])
assert.Equal(t, step2ID, payload["workflow_step_id"])
})
}
@@ -797,8 +779,9 @@ func TestMultipleClients(t *testing.T) {
// Client 1 creates a board
boardResp, err := client1.SendRequest("c1-board-1", ws.ActionBoardCreate, map[string]interface{}{
"workspace_id": workspaceID,
"name": "Shared Board",
"workspace_id": workspaceID,
"name": "Shared Board",
"workflow_template_id": "simple",
})
require.NoError(t, err)
@@ -821,41 +804,41 @@ func TestMultipleClients(t *testing.T) {
require.True(t, ok)
assert.Len(t, boards, 1)
// Client 2 creates a column
colResp, err := client2.SendRequest("c2-col-1", ws.ActionColumnCreate, map[string]interface{}{
"board_id": boardID,
"name": "Column by Client 2",
"position": 0,
})
require.NoError(t, err)
var colPayload map[string]interface{}
err = colResp.ParsePayload(&colPayload)
require.NoError(t, err)
columnID := colPayload["id"].(string)
// Client 1 can see the column
colListResp, err := client1.SendRequest("c1-col-list-1", ws.ActionColumnList, map[string]interface{}{
// Client 2 lists workflow steps (created automatically with board)
stepResp, err := client2.SendRequest("c2-step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{
"board_id": boardID,
})
require.NoError(t, err)
var colListPayload map[string]interface{}
err = colListResp.ParsePayload(&colListPayload)
var stepPayload map[string]interface{}
err = stepResp.ParsePayload(&stepPayload)
require.NoError(t, err)
steps := stepPayload["steps"].([]interface{})
require.NotEmpty(t, steps)
workflowStepID := steps[0].(map[string]interface{})["id"].(string)
// Client 1 can also see the workflow steps
stepListResp, err := client1.SendRequest("c1-step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{
"board_id": boardID,
})
require.NoError(t, err)
columns, ok := colListPayload["columns"].([]interface{})
var stepListPayload map[string]interface{}
err = stepListResp.ParsePayload(&stepListPayload)
require.NoError(t, err)
stepsList, ok := stepListPayload["steps"].([]interface{})
require.True(t, ok)
assert.Len(t, columns, 1)
assert.GreaterOrEqual(t, len(stepsList), 1)
// Client 1 creates a task in the column
// Client 1 creates a task in the workflow step
taskResp, err := client1.SendRequest("c1-task-1", ws.ActionTaskCreate, map[string]interface{}{
"workspace_id": workspaceID,
"board_id": boardID,
"column_id": columnID,
"title": "Task by Client 1",
"repository_id": repositoryID,
"base_branch": "main",
"workspace_id": workspaceID,
"board_id": boardID,
"workflow_step_id": workflowStepID,
"title": "Task by Client 1",
"repository_id": repositoryID,
"base_branch": "main",
})
require.NoError(t, err)
@@ -929,11 +912,8 @@ func TestErrorHandling(t *testing.T) {
assert.Equal(t, ws.MessageTypeError, resp.Type)
})
t.Run("CreateColumnWithoutBoard", func(t *testing.T) {
resp, err := client.SendRequest("err-5", ws.ActionColumnCreate, map[string]interface{}{
"name": "Orphan Column",
"position": 0,
})
t.Run("ListWorkflowStepsWithoutBoard", func(t *testing.T) {
resp, err := client.SendRequest("err-5", ws.ActionWorkflowStepList, map[string]interface{}{})
require.NoError(t, err)
assert.Equal(t, ws.MessageTypeError, resp.Type)
@@ -954,10 +934,11 @@ func TestTaskSubscription(t *testing.T) {
workspaceID := createWorkspace(t, client)
repositoryID := createRepository(t, client, workspaceID)
// Create a task first
// Create a task first (workflow steps are created automatically with board)
boardResp, _ := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{
"workspace_id": workspaceID,
"name": "Sub Test Board",
"workspace_id": workspaceID,
"name": "Sub Test Board",
"workflow_template_id": "simple",
})
var boardPayload map[string]interface{}
if err := boardResp.ParsePayload(&boardPayload); err != nil {
@@ -965,24 +946,24 @@ func TestTaskSubscription(t *testing.T) {
}
boardID := boardPayload["id"].(string)
colResp, _ := client.SendRequest("col-1", ws.ActionColumnCreate, map[string]interface{}{
// Get first workflow step
stepResp, _ := client.SendRequest("step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{
"board_id": boardID,
"name": "To Do",
"position": 0,
})
var colPayload map[string]interface{}
if err := colResp.ParsePayload(&colPayload); err != nil {
t.Fatalf("failed to parse column payload: %v", err)
var stepListPayload map[string]interface{}
if err := stepResp.ParsePayload(&stepListPayload); err != nil {
t.Fatalf("failed to parse step list payload: %v", err)
}
columnID := colPayload["id"].(string)
steps := stepListPayload["steps"].([]interface{})
workflowStepID := steps[0].(map[string]interface{})["id"].(string)
taskResp, _ := client.SendRequest("task-1", ws.ActionTaskCreate, map[string]interface{}{
"workspace_id": workspaceID,
"board_id": boardID,
"column_id": columnID,
"title": "Subscribable Task",
"repository_id": repositoryID,
"base_branch": "main",
"workspace_id": workspaceID,
"board_id": boardID,
"workflow_step_id": workflowStepID,
"title": "Subscribable Task",
"repository_id": repositoryID,
"base_branch": "main",
})
var taskPayload map[string]interface{}
if err := taskResp.ParsePayload(&taskPayload); err != nil {
@@ -1036,10 +1017,11 @@ func TestConcurrentRequests(t *testing.T) {
workspaceID := createWorkspace(t, client)
repositoryID := createRepository(t, client, workspaceID)
// Create board and column
// Create board (workflow steps are created automatically)
boardResp, _ := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{
"workspace_id": workspaceID,
"name": "Concurrent Test Board",
"workspace_id": workspaceID,
"name": "Concurrent Test Board",
"workflow_template_id": "simple",
})
var boardPayload map[string]interface{}
if err := boardResp.ParsePayload(&boardPayload); err != nil {
@@ -1047,16 +1029,16 @@ func TestConcurrentRequests(t *testing.T) {
}
boardID := boardPayload["id"].(string)
colResp, _ := client.SendRequest("col-1", ws.ActionColumnCreate, map[string]interface{}{
// Get first workflow step
stepResp, _ := client.SendRequest("step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{
"board_id": boardID,
"name": "To Do",
"position": 0,
})
var colPayload map[string]interface{}
if err := colResp.ParsePayload(&colPayload); err != nil {
t.Fatalf("failed to parse column payload: %v", err)
var stepListPayload map[string]interface{}
if err := stepResp.ParsePayload(&stepListPayload); err != nil {
t.Fatalf("failed to parse step list payload: %v", err)
}
columnID := colPayload["id"].(string)
steps := stepListPayload["steps"].([]interface{})
workflowStepID := steps[0].(map[string]interface{})["id"].(string)
// Create multiple tasks concurrently
numTasks := 10
@@ -1076,12 +1058,12 @@ func TestConcurrentRequests(t *testing.T) {
"task-"+string(rune('0'+idx)),
ws.ActionTaskCreate,
map[string]interface{}{
"workspace_id": workspaceID,
"board_id": boardID,
"column_id": columnID,
"title": "Concurrent Task " + string(rune('0'+idx)),
"repository_id": repositoryID,
"base_branch": "main",
"workspace_id": workspaceID,
"board_id": boardID,
"workflow_step_id": workflowStepID,
"title": "Concurrent Task " + string(rune('0'+idx)),
"repository_id": repositoryID,
"base_branch": "main",
},
)
results <- err
@@ -22,7 +22,6 @@ import (
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/worktree"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/db"
"github.com/kandev/kandev/internal/events"
@@ -36,6 +35,11 @@ import (
taskhandlers "github.com/kandev/kandev/internal/task/handlers"
"github.com/kandev/kandev/internal/task/repository"
taskservice "github.com/kandev/kandev/internal/task/service"
"github.com/kandev/kandev/internal/workflow"
workflowcontroller "github.com/kandev/kandev/internal/workflow/controller"
workflowhandlers "github.com/kandev/kandev/internal/workflow/handlers"
workflowservice "github.com/kandev/kandev/internal/workflow/service"
"github.com/kandev/kandev/internal/worktree"
"github.com/kandev/kandev/pkg/acp/protocol"
v1 "github.com/kandev/kandev/pkg/api/v1"
ws "github.com/kandev/kandev/pkg/websocket"
@@ -425,6 +429,7 @@ type OrchestratorTestServer struct {
Gateway *gateways.Gateway
TaskRepo repository.Repository
TaskSvc *taskservice.Service
WorkflowSvc *workflowservice.Service
EventBus bus.EventBus
OrchestratorSvc *orchestrator.Service
AgentManager *SimulatedAgentManagerClient
@@ -489,8 +494,13 @@ func NewOrchestratorTestServer(t *testing.T) *OrchestratorTestServer {
t.Fatalf("failed to init worktree store: %v", err)
}
// Initialize task service
// Initialize workflow service
_, workflowSvc, _, err := workflow.Provide(dbConn, log)
require.NoError(t, err)
// Initialize task service and wire workflow step creator
taskSvc := taskservice.NewService(taskRepo, eventBus, log, taskservice.RepositoryDiscoveryConfig{})
taskSvc.SetWorkflowStepCreator(workflowSvc)
// Create simulated agent manager
agentManager := NewSimulatedAgentManager(eventBus, log)
@@ -528,16 +538,19 @@ func NewOrchestratorTestServer(t *testing.T) *OrchestratorTestServer {
// Register handlers (HTTP + WS)
workspaceController := taskcontroller.NewWorkspaceController(taskSvc)
boardController := taskcontroller.NewBoardController(taskSvc)
boardController.SetWorkflowStepLister(workflowSvc)
taskController := taskcontroller.NewTaskController(taskSvc)
executorController := taskcontroller.NewExecutorController(taskSvc)
environmentController := taskcontroller.NewEnvironmentController(taskSvc)
repositoryController := taskcontroller.NewRepositoryController(taskSvc)
workflowCtrl := workflowcontroller.NewController(workflowSvc)
taskhandlers.RegisterWorkspaceRoutes(router, gateway.Dispatcher, workspaceController, log)
taskhandlers.RegisterBoardRoutes(router, gateway.Dispatcher, boardController, log)
taskhandlers.RegisterTaskRoutes(router, gateway.Dispatcher, taskController, nil, log)
taskhandlers.RegisterRepositoryRoutes(router, gateway.Dispatcher, repositoryController, log)
taskhandlers.RegisterExecutorRoutes(router, gateway.Dispatcher, executorController, log)
taskhandlers.RegisterEnvironmentRoutes(router, gateway.Dispatcher, environmentController, log)
workflowhandlers.RegisterRoutes(router, gateway.Dispatcher, workflowCtrl, log)
// Create test server
server := httptest.NewServer(router)
@@ -547,6 +560,7 @@ func NewOrchestratorTestServer(t *testing.T) *OrchestratorTestServer {
Gateway: gateway,
TaskRepo: taskRepo,
TaskSvc: taskSvc,
WorkflowSvc: workflowSvc,
EventBus: eventBus,
OrchestratorSvc: orchestratorSvc,
AgentManager: agentManager,
@@ -579,22 +593,21 @@ func (ts *OrchestratorTestServer) CreateTestTask(t *testing.T, agentProfileID st
})
require.NoError(t, err)
// Create board first
// Create board first (workflow steps are created automatically via default template)
defaultTemplateID := "simple"
board, err := ts.TaskSvc.CreateBoard(context.Background(), &taskservice.CreateBoardRequest{
WorkspaceID: workspace.ID,
Name: "Test Board",
Description: "Test board for orchestrator",
WorkspaceID: workspace.ID,
Name: "Test Board",
Description: "Test board for orchestrator",
WorkflowTemplateID: &defaultTemplateID,
})
require.NoError(t, err)
// Create column
col, err := ts.TaskSvc.CreateColumn(context.Background(), &taskservice.CreateColumnRequest{
BoardID: board.ID,
Name: "TODO",
Position: 0,
State: v1.TaskStateTODO,
})
// Get first workflow step from board
steps, err := ts.WorkflowSvc.ListStepsByBoard(context.Background(), board.ID)
require.NoError(t, err)
require.NotEmpty(t, steps, "board should have workflow steps")
workflowStepID := steps[0].ID
// Create task with agent profile ID
repository, err := ts.TaskSvc.CreateRepository(context.Background(), &taskservice.CreateRepositoryRequest{
@@ -605,12 +618,12 @@ func (ts *OrchestratorTestServer) CreateTestTask(t *testing.T, agentProfileID st
require.NoError(t, err)
task, err := ts.TaskSvc.CreateTask(context.Background(), &taskservice.CreateTaskRequest{
WorkspaceID: workspace.ID,
BoardID: board.ID,
ColumnID: col.ID,
Title: "Test Task",
Description: "This is a test task for the orchestrator",
Priority: priority,
WorkspaceID: workspace.ID,
BoardID: board.ID,
WorkflowStepID: workflowStepID,
Title: "Test Task",
Description: "This is a test task for the orchestrator",
Priority: priority,
Repositories: []taskservice.TaskRepositoryInput{
{
RepositoryID: repository.ID,
@@ -1406,11 +1419,12 @@ func TestOrchestratorEndToEndWorkflow(t *testing.T) {
workspaceID := createOrchestratorWorkspace(t, client)
// 1. Create board
// 1. Create board with workflow template
boardResp, err := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{
"workspace_id": workspaceID,
"name": "E2E Test Board",
"description": "End-to-end test board",
"workspace_id": workspaceID,
"name": "E2E Test Board",
"description": "End-to-end test board",
"workflow_template_id": "simple",
})
require.NoError(t, err)
@@ -1418,20 +1432,19 @@ func TestOrchestratorEndToEndWorkflow(t *testing.T) {
require.NoError(t, boardResp.ParsePayload(&boardPayload))
boardID := boardPayload["id"].(string)
// 2. Create column
colResp, err := client.SendRequest("col-1", ws.ActionColumnCreate, map[string]interface{}{
// 2. Get first workflow step from board
stepResp, err := client.SendRequest("step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{
"board_id": boardID,
"name": "TODO",
"position": 0,
"state": "TODO",
})
require.NoError(t, err)
var colPayload map[string]interface{}
require.NoError(t, colResp.ParsePayload(&colPayload))
colID := colPayload["id"].(string)
var stepListPayload map[string]interface{}
require.NoError(t, stepResp.ParsePayload(&stepListPayload))
steps := stepListPayload["steps"].([]interface{})
require.NotEmpty(t, steps, "board should have workflow steps")
workflowStepID := steps[0].(map[string]interface{})["id"].(string)
// 3. Create task with agent_profile_id
// 3. Create task with workflow step
repoResp, err := client.SendRequest("repo-1", ws.ActionRepositoryCreate, map[string]interface{}{
"workspace_id": workspaceID,
"name": "Test Repo",
@@ -1445,14 +1458,14 @@ func TestOrchestratorEndToEndWorkflow(t *testing.T) {
repositoryID := repoPayload["id"].(string)
taskResp, err := client.SendRequest("task-1", ws.ActionTaskCreate, map[string]interface{}{
"workspace_id": workspaceID,
"board_id": boardID,
"column_id": colID,
"title": "Implement feature X",
"description": "Create a new feature with tests",
"priority": 2,
"repository_id": repositoryID,
"base_branch": "main",
"workspace_id": workspaceID,
"board_id": boardID,
"workflow_step_id": workflowStepID,
"title": "Implement feature X",
"description": "Create a new feature with tests",
"priority": 2,
"repository_id": repositoryID,
"base_branch": "main",
})
require.NoError(t, err)
+16 -16
View File
@@ -34,16 +34,16 @@ func registerTools(s *server.MCPServer, cfg Config, log *logger.Logger) {
listBoardsHandler(cfg, log),
)
// List Columns tool
// List Workflow Steps tool
s.AddTool(
mcp.NewTool("list_columns",
mcp.WithDescription("List all columns in a board. Use this to get column IDs for creating tasks."),
mcp.NewTool("list_workflow_steps",
mcp.WithDescription("List all workflow steps in a board. Use this to get workflow step IDs for creating tasks."),
mcp.WithString("board_id",
mcp.Required(),
mcp.Description("The board ID to list columns from"),
mcp.Description("The board ID to list workflow steps from"),
),
),
listColumnsHandler(cfg, log),
listWorkflowStepsHandler(cfg, log),
)
// List Tasks tool
@@ -70,9 +70,9 @@ func registerTools(s *server.MCPServer, cfg Config, log *logger.Logger) {
mcp.Required(),
mcp.Description("The board ID"),
),
mcp.WithString("column_id",
mcp.WithString("workflow_step_id",
mcp.Required(),
mcp.Description("The column ID to place the task in"),
mcp.Description("The workflow step ID to place the task in"),
),
mcp.WithString("title",
mcp.Required(),
@@ -154,18 +154,18 @@ func listBoardsHandler(cfg Config, log *logger.Logger) server.ToolHandlerFunc {
}
}
func listColumnsHandler(cfg Config, log *logger.Logger) server.ToolHandlerFunc {
func listWorkflowStepsHandler(cfg Config, log *logger.Logger) server.ToolHandlerFunc {
return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
boardID, err := req.RequireString("board_id")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
url := fmt.Sprintf("%s/api/v1/boards/%s/columns", cfg.KandevURL, boardID)
url := fmt.Sprintf("%s/api/v1/boards/%s/workflow/steps", cfg.KandevURL, boardID)
resp, err := http.Get(url)
if err != nil {
log.Error("failed to fetch columns", zap.Error(err))
return mcp.NewToolResultError(fmt.Sprintf("Failed to fetch columns: %v", err)), nil
log.Error("failed to fetch workflow steps", zap.Error(err))
return mcp.NewToolResultError(fmt.Sprintf("Failed to fetch workflow steps: %v", err)), nil
}
defer func() { _ = resp.Body.Close() }()
@@ -214,7 +214,7 @@ func createTaskHandler(cfg Config, log *logger.Logger) server.ToolHandlerFunc {
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
columnID, err := req.RequireString("column_id")
workflowStepID, err := req.RequireString("workflow_step_id")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
@@ -224,10 +224,10 @@ func createTaskHandler(cfg Config, log *logger.Logger) server.ToolHandlerFunc {
}
payload := map[string]interface{}{
"workspace_id": workspaceID,
"board_id": boardID,
"column_id": columnID,
"title": title,
"workspace_id": workspaceID,
"board_id": boardID,
"workflow_step_id": workflowStepID,
"title": title,
}
if desc := req.GetString("description", ""); desc != "" {
payload["description"] = desc
@@ -9,6 +9,7 @@ import (
"github.com/google/uuid"
"github.com/kandev/kandev/internal/common/sqlite"
"github.com/kandev/kandev/internal/notifications/models"
)
@@ -103,7 +104,7 @@ func (r *sqliteRepository) CreateProvider(ctx context.Context, provider *models.
_, err = r.db.ExecContext(ctx, `
INSERT INTO notification_providers (id, user_id, name, type, config, enabled, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, provider.ID, provider.UserID, provider.Name, provider.Type, string(configJSON), boolToInt(provider.Enabled), provider.CreatedAt, provider.UpdatedAt)
`, provider.ID, provider.UserID, provider.Name, provider.Type, string(configJSON), sqlite.BoolToInt(provider.Enabled), provider.CreatedAt, provider.UpdatedAt)
return err
}
@@ -120,7 +121,7 @@ func (r *sqliteRepository) UpdateProvider(ctx context.Context, provider *models.
UPDATE notification_providers
SET name = ?, type = ?, config = ?, enabled = ?, updated_at = ?
WHERE id = ?
`, provider.Name, provider.Type, string(configJSON), boolToInt(provider.Enabled), provider.UpdatedAt, provider.ID)
`, provider.Name, provider.Type, string(configJSON), sqlite.BoolToInt(provider.Enabled), provider.UpdatedAt, provider.ID)
return err
}
@@ -210,7 +211,7 @@ func (r *sqliteRepository) ReplaceSubscriptions(ctx context.Context, providerID,
if _, err := tx.ExecContext(ctx, `
INSERT INTO notification_subscriptions (id, user_id, provider_id, event_type, enabled, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, subID, userID, providerID, eventType, boolToInt(true), now, now); err != nil {
`, subID, userID, providerID, eventType, sqlite.BoolToInt(true), now, now); err != nil {
_ = tx.Rollback()
return err
}
@@ -290,10 +291,3 @@ func scanSubscription(scanner interface{ Scan(dest ...any) error }) (*models.Sub
sub.Enabled = enabledInt == 1
return sub, nil
}
func boolToInt(value bool) int {
if value {
return 1
}
return 0
}
@@ -59,9 +59,26 @@ func (c *Controller) TriggerTask(ctx context.Context, req dto.TriggerTaskRequest
}, nil
}
// StartTask starts task execution
// StartTask starts task execution.
// If SessionID is provided, resumes the existing session and sends a prompt with workflow step config.
// If SessionID is not provided, creates a new session (requires AgentProfileID).
func (c *Controller) StartTask(ctx context.Context, req dto.StartTaskRequest) (dto.StartTaskResponse, error) {
execution, err := c.service.StartTask(ctx, req.TaskID, req.AgentProfileID, req.ExecutorID, req.Priority, req.Prompt)
// If session_id is provided, resume existing session with workflow step config
if req.SessionID != "" {
err := c.service.StartSessionForWorkflowStep(ctx, req.TaskID, req.SessionID, req.WorkflowStepID)
if err != nil {
return dto.StartTaskResponse{}, err
}
return dto.StartTaskResponse{
Success: true,
TaskID: req.TaskID,
TaskSessionID: req.SessionID,
State: "RUNNING",
}, nil
}
// Otherwise, create a new session
execution, err := c.service.StartTask(ctx, req.TaskID, req.AgentProfileID, req.ExecutorID, req.Priority, req.Prompt, req.WorkflowStepID)
if err != nil {
return dto.StartTaskResponse{}, err
}
@@ -40,12 +40,16 @@ type TriggerTaskResponse struct {
}
// StartTaskRequest is the payload for orchestrator.start
// If SessionID is provided, the existing session is resumed and prompted with workflow step config.
// If SessionID is not provided, a new session is created (requires AgentProfileID).
type StartTaskRequest struct {
TaskID string `json:"task_id"`
AgentProfileID string `json:"agent_profile_id,omitempty"`
ExecutorID string `json:"executor_id,omitempty"` // Executor to use (determines runtime: local_pc, local_docker, etc.)
SessionID string `json:"session_id,omitempty"` // If provided, resume this existing session instead of creating new
AgentProfileID string `json:"agent_profile_id,omitempty"` // Required when creating new session (SessionID not provided)
ExecutorID string `json:"executor_id,omitempty"` // Executor to use (determines runtime: local_pc, local_docker, etc.)
Priority int `json:"priority,omitempty"`
Prompt string `json:"prompt,omitempty"` // Initial prompt to send to the agent
Prompt string `json:"prompt,omitempty"` // Initial prompt to send to the agent
WorkflowStepID string `json:"workflow_step_id,omitempty"` // If provided, prompt prefix/suffix and plan mode are applied from the step
}
// StartTaskResponse is the response for orchestrator.start
@@ -100,32 +100,307 @@ func (s *Service) handleAgentReady(ctx context.Context, data watcher.AgentEventD
// Complete the current turn
s.completeTurnForSession(ctx, data.SessionID)
// Move session to waiting for input and task to review
s.setSessionWaitingForInput(ctx, data.TaskID, data.SessionID)
// Check for workflow transition based on session's current step
// This handles the case where the agent finishes a step and should move to the next
transitioned := s.handleWorkflowTransition(ctx, data.TaskID, data.SessionID)
// If no workflow transition occurred, move session to waiting for input and task to review
if !transitioned {
s.setSessionWaitingForInput(ctx, data.TaskID, data.SessionID)
}
}
// handleAgentCompleted handles agent completion events
func (s *Service) handleAgentCompleted(ctx context.Context, data watcher.AgentEventData) {
s.logger.Info("handling agent completed",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID),
zap.String("agent_execution_id", data.AgentExecutionID))
// Update scheduler and remove from queue
s.scheduler.HandleTaskCompleted(data.TaskID, true)
s.scheduler.RemoveTask(data.TaskID)
// Move task to REVIEW state for user review
// The user can then send a follow-up (moves back to IN_PROGRESS) or mark as COMPLETED
if err := s.taskRepo.UpdateTaskState(ctx, data.TaskID, v1.TaskStateReview); err != nil {
s.logger.Error("failed to update task state to REVIEW",
zap.String("task_id", data.TaskID),
zap.Error(err))
} else {
s.logger.Info("task moved to REVIEW state after agent completion",
zap.String("task_id", data.TaskID))
// Check for workflow transition based on session's current step
transitioned := s.handleWorkflowTransition(ctx, data.TaskID, data.SessionID)
// If no workflow transition occurred, move task to REVIEW state for user review
if !transitioned {
if err := s.taskRepo.UpdateTaskState(ctx, data.TaskID, v1.TaskStateReview); err != nil {
s.logger.Error("failed to update task state to REVIEW",
zap.String("task_id", data.TaskID),
zap.Error(err))
} else {
s.logger.Info("task moved to REVIEW state after agent completion",
zap.String("task_id", data.TaskID))
}
}
}
// handleWorkflowTransition checks if the session's current workflow step has an on_complete_step_id
// and moves the task to that step if so. Returns true if a transition occurred.
func (s *Service) handleWorkflowTransition(ctx context.Context, taskID, sessionID string) bool {
if sessionID == "" || s.workflowStepGetter == nil {
return false
}
// Get the session to find its current workflow step
session, err := s.repo.GetTaskSession(ctx, sessionID)
if err != nil {
s.logger.Warn("failed to get session for workflow transition",
zap.String("session_id", sessionID),
zap.Error(err))
return false
}
if session.WorkflowStepID == nil || *session.WorkflowStepID == "" {
s.logger.Debug("session has no workflow step, skipping transition",
zap.String("session_id", sessionID))
return false
}
workflowStepID := *session.WorkflowStepID
// Get the current workflow step
currentStep, err := s.workflowStepGetter.GetStep(ctx, workflowStepID)
if err != nil {
s.logger.Warn("failed to get workflow step for transition",
zap.String("workflow_step_id", workflowStepID),
zap.Error(err))
return false
}
// Check if there's an on_complete transition
if currentStep.OnCompleteStepID == "" {
s.logger.Debug("workflow step has no on_complete transition",
zap.String("step_id", currentStep.ID),
zap.String("step_name", currentStep.Name))
return false
}
// Get the target step to log its name
targetStep, err := s.workflowStepGetter.GetStep(ctx, currentStep.OnCompleteStepID)
if err != nil {
s.logger.Warn("failed to get target workflow step",
zap.String("target_step_id", currentStep.OnCompleteStepID),
zap.Error(err))
return false
}
// Get the task to update its workflow step
task, err := s.repo.GetTask(ctx, taskID)
if err != nil {
s.logger.Warn("failed to get task for workflow transition",
zap.String("task_id", taskID),
zap.Error(err))
return false
}
// Update the task's workflow step
task.WorkflowStepID = currentStep.OnCompleteStepID
task.UpdatedAt = time.Now().UTC()
if err := s.repo.UpdateTask(ctx, task); err != nil {
s.logger.Error("failed to move task to next workflow step",
zap.String("task_id", taskID),
zap.String("from_step", currentStep.Name),
zap.String("to_step", targetStep.Name),
zap.Error(err))
return false
}
// Publish task updated event so frontend updates the kanban board
if s.eventBus != nil {
taskEventData := map[string]interface{}{
"task_id": task.ID,
"board_id": task.BoardID,
"workflow_step_id": task.WorkflowStepID,
"title": task.Title,
"description": task.Description,
"state": string(task.State),
"priority": task.Priority,
"position": task.Position,
}
_ = s.eventBus.Publish(ctx, events.TaskUpdated, bus.NewEvent(
events.TaskUpdated,
"orchestrator",
taskEventData,
))
}
// Also update the session's workflow step
if err := s.repo.UpdateSessionWorkflowStep(ctx, sessionID, currentStep.OnCompleteStepID); err != nil {
s.logger.Warn("failed to update session workflow step",
zap.String("session_id", sessionID),
zap.String("step_id", currentStep.OnCompleteStepID),
zap.Error(err))
// Don't return false - task was already moved
}
// If the target step requires approval, set the session's review_status to "pending"
var reviewStatus string
if targetStep.RequireApproval {
reviewStatus = "pending"
if err := s.repo.UpdateSessionReviewStatus(ctx, sessionID, reviewStatus); err != nil {
s.logger.Warn("failed to set session review status to pending",
zap.String("session_id", sessionID),
zap.Error(err))
reviewStatus = "" // Clear on error
} else {
s.logger.Info("session review status set to pending",
zap.String("session_id", sessionID),
zap.String("target_step", targetStep.Name))
}
}
// Update session state to WAITING_FOR_INPUT since the agent completed this step
// This must happen before publishing the event so the frontend gets the correct state
if err := s.repo.UpdateTaskSessionState(ctx, sessionID, models.TaskSessionStateWaitingForInput, ""); err != nil {
s.logger.Error("failed to update session state to WAITING_FOR_INPUT during workflow transition",
zap.String("session_id", sessionID),
zap.Error(err))
}
// Publish session updated event with workflow step and review status changes
// This allows the frontend to update the session state without a full refresh
if s.eventBus != nil {
eventData := map[string]interface{}{
"task_id": taskID,
"session_id": sessionID,
"workflow_step_id": targetStep.ID,
"new_state": string(models.TaskSessionStateWaitingForInput),
}
if reviewStatus != "" {
eventData["review_status"] = reviewStatus
}
_ = s.eventBus.Publish(ctx, events.TaskSessionStateChanged, bus.NewEvent(
events.TaskSessionStateChanged,
"orchestrator",
eventData,
))
}
s.logger.Info("workflow transition completed",
zap.String("task_id", taskID),
zap.String("session_id", sessionID),
zap.String("from_step", currentStep.Name),
zap.String("to_step", targetStep.Name))
return true
}
// handleReviewStepRollback checks if the session is in a review step with pending status.
// If so, it moves the session back to the previous step (the step that led to the review step)
// and clears the review status. This handles the case where the user sends a message to
// iterate on the work instead of approving.
func (s *Service) handleReviewStepRollback(ctx context.Context, taskID, sessionID string) {
if sessionID == "" || s.workflowStepGetter == nil {
return
}
// Get the session to check its current workflow step and review status
session, err := s.repo.GetTaskSession(ctx, sessionID)
if err != nil {
s.logger.Warn("failed to get session for review rollback check",
zap.String("session_id", sessionID),
zap.Error(err))
return
}
// Only proceed if session has a workflow step and pending review status
if session.WorkflowStepID == nil || *session.WorkflowStepID == "" {
return
}
if session.ReviewStatus == nil || *session.ReviewStatus != "pending" {
return
}
currentStepID := *session.WorkflowStepID
// Get the current workflow step to check if it requires approval
currentStep, err := s.workflowStepGetter.GetStep(ctx, currentStepID)
if err != nil {
s.logger.Warn("failed to get workflow step for review rollback",
zap.String("workflow_step_id", currentStepID),
zap.Error(err))
return
}
// Only proceed if the current step requires approval (is a review step)
if !currentStep.RequireApproval {
return
}
// Get the task to find the board ID
task, err := s.repo.GetTask(ctx, taskID)
if err != nil {
s.logger.Warn("failed to get task for review rollback",
zap.String("task_id", taskID),
zap.Error(err))
return
}
// Find the source step (the step that has on_complete_step_id pointing to current step)
sourceStep, err := s.workflowStepGetter.GetSourceStep(ctx, task.BoardID, currentStepID)
if err != nil {
s.logger.Warn("failed to get source step for review rollback",
zap.String("current_step_id", currentStepID),
zap.Error(err))
return
}
if sourceStep == nil {
s.logger.Debug("no source step found for review rollback, staying in current step",
zap.String("current_step_id", currentStepID))
return
}
// Move session back to the source step
if err := s.repo.UpdateSessionWorkflowStep(ctx, sessionID, sourceStep.ID); err != nil {
s.logger.Error("failed to move session back to source step",
zap.String("session_id", sessionID),
zap.String("source_step_id", sourceStep.ID),
zap.Error(err))
return
}
// Clear the review status
if err := s.repo.UpdateSessionReviewStatus(ctx, sessionID, ""); err != nil {
s.logger.Warn("failed to clear session review status",
zap.String("session_id", sessionID),
zap.Error(err))
}
// Also move the task to the source step
task.WorkflowStepID = sourceStep.ID
if err := s.repo.UpdateTask(ctx, task); err != nil {
s.logger.Error("failed to move task back to source step",
zap.String("task_id", taskID),
zap.String("source_step_id", sourceStep.ID),
zap.Error(err))
}
// Publish session state change event so frontend updates
if s.eventBus != nil {
eventData := map[string]interface{}{
"task_id": taskID,
"session_id": sessionID,
"workflow_step_id": sourceStep.ID,
"new_state": string(session.State),
"review_status": "", // Cleared
}
_ = s.eventBus.Publish(ctx, events.TaskSessionStateChanged, bus.NewEvent(
events.TaskSessionStateChanged,
"orchestrator",
eventData,
))
}
s.logger.Info("session moved back from review step",
zap.String("task_id", taskID),
zap.String("session_id", sessionID),
zap.String("from_step", currentStep.Name),
zap.String("to_step", sourceStep.Name))
}
// handleAgentFailed handles agent failure events
func (s *Service) handleAgentFailed(ctx context.Context, data watcher.AgentEventData) {
s.logger.Warn("handling agent failed",
@@ -204,14 +204,15 @@ func (e *Executor) applyPreferredShellEnv(ctx context.Context, env map[string]st
// Execute starts agent execution for a task
func (e *Executor) Execute(ctx context.Context, task *v1.Task) (*TaskExecution, error) {
return e.ExecuteWithProfile(ctx, task, "", "", task.Description)
return e.ExecuteWithProfile(ctx, task, "", "", task.Description, "")
}
// ExecuteWithProfile starts agent execution for a task using an explicit agent profile.
// The executorID parameter specifies which executor to use (determines runtime: local_pc, local_docker, etc.).
// If executorID is empty, falls back to workspace's default executor.
// The prompt parameter is the initial prompt to send to the agent.
func (e *Executor) ExecuteWithProfile(ctx context.Context, task *v1.Task, agentProfileID string, executorID string, prompt string) (*TaskExecution, error) {
// The workflowStepID parameter associates the session with a workflow step for transitions.
func (e *Executor) ExecuteWithProfile(ctx context.Context, task *v1.Task, agentProfileID string, executorID string, prompt string, workflowStepID string) (*TaskExecution, error) {
if agentProfileID == "" {
e.logger.Error("task has no agent_profile_id configured", zap.String("task_id", task.ID))
return nil, ErrNoAgentProfileID
@@ -324,6 +325,11 @@ func (e *Executor) ExecuteWithProfile(ctx context.Context, task *v1.Task, agentP
StartedAt: now,
UpdatedAt: now,
AgentProfileSnapshot: agentProfileSnapshot,
IsPrimary: true, // Set as primary when creating
}
// Set workflow step ID if provided (for workflow transitions)
if workflowStepID != "" {
session.WorkflowStepID = &workflowStepID
}
// Resolve executor configuration
execConfig := e.resolveExecutorConfig(ctx, executorID, task.WorkspaceID, metadata)
@@ -344,6 +350,16 @@ func (e *Executor) ExecuteWithProfile(ctx context.Context, task *v1.Task, agentP
return nil, err
}
// Clear primary flag on any other sessions for this task
// (the new session was created with IsPrimary=true)
if err := e.repo.SetSessionPrimary(ctx, sessionID); err != nil {
e.logger.Warn("failed to update primary session flag",
zap.String("task_id", task.ID),
zap.String("session_id", sessionID),
zap.Error(err))
// Continue anyway - the session was already created with is_primary=true
}
req.SessionID = sessionID
if len(metadata) > 0 {
req.Metadata = metadata
@@ -83,10 +83,12 @@ func (h *Handlers) wsTriggerTask(ctx context.Context, msg *ws.Message) (*ws.Mess
type wsStartTaskRequest struct {
TaskID string `json:"task_id"`
SessionID string `json:"session_id,omitempty"`
AgentProfileID string `json:"agent_profile_id,omitempty"`
ExecutorID string `json:"executor_id,omitempty"`
Priority int `json:"priority,omitempty"`
Prompt string `json:"prompt,omitempty"`
WorkflowStepID string `json:"workflow_step_id,omitempty"`
}
func (h *Handlers) wsStartTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
@@ -97,16 +99,19 @@ func (h *Handlers) wsStartTask(ctx context.Context, msg *ws.Message) (*ws.Messag
if req.TaskID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil)
}
if req.AgentProfileID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "agent_profile_id is required", nil)
// agent_profile_id is only required when creating a new session (no session_id provided)
if req.SessionID == "" && req.AgentProfileID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "agent_profile_id is required when creating a new session", nil)
}
resp, err := h.controller.StartTask(ctx, dto.StartTaskRequest{
TaskID: req.TaskID,
SessionID: req.SessionID,
AgentProfileID: req.AgentProfileID,
ExecutorID: req.ExecutorID,
Priority: req.Priority,
Prompt: req.Prompt,
WorkflowStepID: req.WorkflowStepID,
})
if err != nil {
h.logger.Error("failed to start task", zap.String("task_id", req.TaskID), zap.Error(err))
@@ -72,6 +72,28 @@ type TurnService interface {
GetActiveTurn(ctx context.Context, sessionID string) (*models.Turn, error)
}
// WorkflowStep contains workflow step data needed for prompt construction and transitions.
type WorkflowStep struct {
ID string
Name string
StepType string
AutoStartAgent bool
PlanMode bool
RequireApproval bool
PromptPrefix string
PromptSuffix string
OnCompleteStepID string // Step to transition to when agent completes
OnApprovalStepID string // Step to transition to when user approves
}
// WorkflowStepGetter retrieves workflow step information for prompt building.
type WorkflowStepGetter interface {
GetStep(ctx context.Context, stepID string) (*WorkflowStep, error)
// GetSourceStep finds the step that has on_complete_step_id pointing to the given step.
// Used to find the "previous" step when moving back from a review step.
GetSourceStep(ctx context.Context, boardID, targetStepID string) (*WorkflowStep, error)
}
// Service is the main orchestrator service
type Service struct {
config ServiceConfig
@@ -93,6 +115,9 @@ type Service struct {
// Turn service for managing session turns
turnService TurnService
// Workflow step getter for prompt building
workflowStepGetter WorkflowStepGetter
// Active turns map: sessionID -> turnID
activeTurns sync.Map
@@ -207,6 +232,17 @@ func (s *Service) SetTurnService(turnService TurnService) {
s.turnService = turnService
}
// SetWorkflowStepGetter sets the workflow step getter for prompt building.
//
// When workflow_step_id is provided to StartTask, the orchestrator uses this getter
// to retrieve the step's prompt_prefix, prompt_suffix, and plan_mode settings to
// build the effective prompt.
//
// If not set: workflow_step_id in StartTask is ignored and the prompt is used as-is.
func (s *Service) SetWorkflowStepGetter(getter WorkflowStepGetter) {
s.workflowStepGetter = getter
}
// startTurnForSession starts a new turn for the session and stores it.
func (s *Service) startTurnForSession(ctx context.Context, sessionID string) string {
if s.turnService == nil {
@@ -5,6 +5,7 @@ import (
"context"
"fmt"
"os"
"strings"
"go.uber.org/zap"
@@ -41,14 +42,18 @@ func (s *Service) EnqueueTask(ctx context.Context, task *v1.Task) error {
return s.scheduler.EnqueueTask(task)
}
// StartTask manually starts agent execution for a task
func (s *Service) StartTask(ctx context.Context, taskID string, agentProfileID string, executorID string, priority int, prompt string) (*executor.TaskExecution, error) {
// StartTask manually starts agent execution for a task.
// If workflowStepID is provided and workflowStepGetter is set, the prompt will be built
// using the step's prompt_prefix + base prompt + prompt_suffix, and plan mode will be
// applied if the step has plan_mode enabled.
func (s *Service) StartTask(ctx context.Context, taskID string, agentProfileID string, executorID string, priority int, prompt string, workflowStepID string) (*executor.TaskExecution, error) {
s.logger.Debug("manually starting task",
zap.String("task_id", taskID),
zap.String("agent_profile_id", agentProfileID),
zap.String("executor_id", executorID),
zap.Int("priority", priority),
zap.Int("prompt_length", len(prompt)))
zap.Int("prompt_length", len(prompt)),
zap.String("workflow_step_id", workflowStepID))
if err := s.taskRepo.UpdateTaskState(ctx, taskID, v1.TaskStateScheduling); err != nil {
s.logger.Warn("failed to update task state to SCHEDULING",
@@ -76,7 +81,27 @@ func (s *Service) StartTask(ctx context.Context, taskID string, agentProfileID s
effectivePrompt = task.Description
}
execution, err := s.executor.ExecuteWithProfile(ctx, task, agentProfileID, executorID, effectivePrompt)
// Apply workflow step configuration if provided
if workflowStepID != "" && s.workflowStepGetter != nil {
step, err := s.workflowStepGetter.GetStep(ctx, workflowStepID)
if err != nil {
s.logger.Warn("failed to get workflow step for prompt building",
zap.String("workflow_step_id", workflowStepID),
zap.Error(err))
// Continue without step config rather than failing
} else {
// Build prompt with prefix and suffix
effectivePrompt = s.buildWorkflowPrompt(effectivePrompt, step, task.ID)
s.logger.Debug("applied workflow step prompt config",
zap.String("workflow_step_id", workflowStepID),
zap.String("step_name", step.Name),
zap.Bool("plan_mode", step.PlanMode),
zap.Int("effective_prompt_length", len(effectivePrompt)))
}
}
execution, err := s.executor.ExecuteWithProfile(ctx, task, agentProfileID, executorID, effectivePrompt, workflowStepID)
if err != nil {
return nil, err
}
@@ -99,6 +124,56 @@ func (s *Service) StartTask(ctx context.Context, taskID string, agentProfileID s
return execution, nil
}
// SystemTagStart marks the beginning of system-injected content that should be hidden from the UI.
const SystemTagStart = "<kandev-system>"
// SystemTagEnd marks the end of system-injected content that should be hidden from the UI.
const SystemTagEnd = "</kandev-system>"
// buildWorkflowPrompt constructs the effective prompt using workflow step configuration.
// It combines: step.PromptPrefix + basePrompt + step.PromptSuffix
// If step.PlanMode is true, it also prepends the plan mode prefix.
// Placeholders like {task_id} are interpolated with actual values.
// System-injected content (prefix, suffix, plan mode) is wrapped in <kandev-system> tags
// so it can be stripped when displaying to users.
func (s *Service) buildWorkflowPrompt(basePrompt string, step *WorkflowStep, taskID string) string {
var parts []string
// Apply plan mode prefix if enabled (wrapped in system tags)
if step.PlanMode {
parts = append(parts, wrapSystemContent(PlanModePrefix))
}
// Add prompt prefix if set (with interpolation, wrapped in system tags)
if step.PromptPrefix != "" {
parts = append(parts, wrapSystemContent(interpolatePrompt(step.PromptPrefix, taskID)))
}
// Add base prompt (user's actual message - not wrapped)
parts = append(parts, basePrompt)
// Add prompt suffix if set (with interpolation, wrapped in system tags)
if step.PromptSuffix != "" {
parts = append(parts, wrapSystemContent(interpolatePrompt(step.PromptSuffix, taskID)))
}
return strings.Join(parts, "\n\n")
}
// wrapSystemContent wraps content in <kandev-system> tags to mark it as system-injected.
func wrapSystemContent(content string) string {
return SystemTagStart + content + SystemTagEnd
}
// interpolatePrompt replaces placeholders in prompt templates with actual values.
// Supported placeholders:
// - {task_id} - the task ID
func interpolatePrompt(template string, taskID string) string {
result := template
result = strings.ReplaceAll(result, "{task_id}", taskID)
return result
}
// ResumeTaskSession restarts a specific task session using its stored worktree.
func (s *Service) ResumeTaskSession(ctx context.Context, taskID, sessionID string) (*executor.TaskExecution, error) {
s.logger.Debug("resuming task session",
@@ -135,6 +210,119 @@ func (s *Service) ResumeTaskSession(ctx context.Context, taskID, sessionID strin
return execution, nil
}
// StartSessionForWorkflowStep starts an existing session with a workflow step's prompt configuration.
// If the session is not running, it will be resumed first. Then a prompt is sent using the
// step's prompt_prefix, prompt_suffix, and plan_mode settings combined with the task description.
func (s *Service) StartSessionForWorkflowStep(ctx context.Context, taskID, sessionID, workflowStepID string) error {
s.logger.Debug("starting session for workflow step",
zap.String("task_id", taskID),
zap.String("session_id", sessionID),
zap.String("workflow_step_id", workflowStepID))
if workflowStepID == "" {
return fmt.Errorf("workflow_step_id is required")
}
if s.workflowStepGetter == nil {
return fmt.Errorf("workflow step getter not configured")
}
// Get the workflow step
step, err := s.workflowStepGetter.GetStep(ctx, workflowStepID)
if err != nil {
return fmt.Errorf("failed to get workflow step: %w", err)
}
// Get the session
session, err := s.repo.GetTaskSession(ctx, sessionID)
if err != nil {
return fmt.Errorf("failed to get session: %w", err)
}
if session.TaskID != taskID {
return fmt.Errorf("session does not belong to task")
}
// Get the task to use its description as the base prompt
task, err := s.scheduler.GetTask(ctx, taskID)
if err != nil {
return fmt.Errorf("failed to get task: %w", err)
}
// Check if session is in a review step with pending approval
// If so, reject the request - user must use Approve button or send a chat message
if session.ReviewStatus != nil && *session.ReviewStatus == "pending" {
if session.WorkflowStepID != nil && *session.WorkflowStepID != "" {
currentStep, err := s.workflowStepGetter.GetStep(ctx, *session.WorkflowStepID)
if err == nil && currentStep.RequireApproval {
return fmt.Errorf("session is pending approval - use Approve button to proceed or send a message to request changes")
}
}
}
// Update session's workflow step to the new step
if session.WorkflowStepID == nil || *session.WorkflowStepID != workflowStepID {
if err := s.repo.UpdateSessionWorkflowStep(ctx, sessionID, workflowStepID); err != nil {
s.logger.Warn("failed to update session workflow step",
zap.String("session_id", sessionID),
zap.String("workflow_step_id", workflowStepID),
zap.Error(err))
}
// Clear review status when moving to a new step
if session.ReviewStatus != nil && *session.ReviewStatus != "" {
if err := s.repo.UpdateSessionReviewStatus(ctx, sessionID, ""); err != nil {
s.logger.Warn("failed to clear session review status",
zap.String("session_id", sessionID),
zap.Error(err))
}
}
}
// Build prompt from workflow step configuration
effectivePrompt := s.buildWorkflowPrompt(task.Description, step, taskID)
// Check if session needs to be resumed first
// A session in "running" or "waiting_for_input" state can receive prompts directly
// Other states (created, starting, completed, failed, cancelled) need resume
sessionState := session.State
if sessionState != models.TaskSessionStateRunning && sessionState != models.TaskSessionStateWaitingForInput {
// Try to resume the session
s.logger.Debug("session not running, attempting resume",
zap.String("session_id", sessionID),
zap.String("session_state", string(session.State)))
running, err := s.repo.GetExecutorRunningBySessionID(ctx, sessionID)
if err != nil || running == nil || running.ResumeToken == "" || !running.Resumable {
return fmt.Errorf("session is not resumable (state: %s)", session.State)
}
if err := validateSessionWorktrees(session); err != nil {
return err
}
_, err = s.executor.ResumeSession(ctx, session, true)
if err != nil {
_ = s.repo.UpdateTaskSessionState(ctx, sessionID, models.TaskSessionStateFailed, err.Error())
return fmt.Errorf("failed to resume session: %w", err)
}
s.logger.Debug("session resumed, now sending prompt")
}
// Send the prompt using PromptTask (with planMode from step)
_, err = s.PromptTask(ctx, taskID, sessionID, effectivePrompt, "", step.PlanMode)
if err != nil {
return fmt.Errorf("failed to prompt session: %w", err)
}
s.logger.Info("session started for workflow step",
zap.String("task_id", taskID),
zap.String("session_id", sessionID),
zap.String("workflow_step_id", workflowStepID),
zap.String("step_name", step.Name),
zap.Bool("plan_mode", step.PlanMode))
return nil
}
// GetTaskSessionStatus returns the status of a task session including whether it's resumable
func (s *Service) GetTaskSessionStatus(ctx context.Context, taskID, sessionID string) (dto.TaskSessionStatusResponse, error) {
s.logger.Debug("checking task session status",
@@ -261,6 +449,10 @@ func (s *Service) PromptTask(ctx context.Context, taskID, sessionID string, prom
effectivePrompt = PlanModePrefix + prompt
}
// Check if session is in a review step - if so, move back to the previous step
// This handles the case where the user sends a message to iterate on the work
s.handleReviewStepRollback(ctx, taskID, sessionID)
// Check if model switching is requested
if model != "" {
// Get the current session to check current model
+108 -82
View File
@@ -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
}
+70 -14
View File
@@ -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
}
+83 -58
View File
@@ -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
}
+16 -39
View File
@@ -42,9 +42,10 @@ type GetBoardRequest struct {
}
type CreateBoardRequest struct {
WorkspaceID string
Name string
Description string
WorkspaceID string
Name string
Description string
WorkflowTemplateID *string
}
type UpdateBoardRequest struct {
@@ -57,30 +58,6 @@ type DeleteBoardRequest struct {
ID string
}
type ListColumnsRequest struct {
BoardID string
}
type GetColumnRequest struct {
ID string
}
type CreateColumnRequest struct {
BoardID string
Name string
Position int
State v1.TaskState
Color string
}
type UpdateColumnRequest struct {
ID string
Name *string
Position *int
State *v1.TaskState
Color *string
}
type ListRepositoriesRequest struct {
WorkspaceID string
}
@@ -263,14 +240,14 @@ type TaskRepositoryInput struct {
}
type CreateTaskRequest struct {
WorkspaceID string
BoardID string
ColumnID string
Title string
Description string
Priority int
State *v1.TaskState
Repositories []TaskRepositoryInput
WorkspaceID string
BoardID string
WorkflowStepID string
Title string
Description string
Priority int
State *v1.TaskState
Repositories []TaskRepositoryInput
Position int
Metadata map[string]interface{}
}
@@ -310,10 +287,10 @@ type CreateMessageRequest struct {
}
type MoveTaskRequest struct {
ID string
BoardID string
ColumnID string
Position int
ID string
BoardID string
WorkflowStepID string
Position int
}
type UpdateTaskStateRequest struct {
@@ -9,7 +9,6 @@ import (
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/task/controller"
"github.com/kandev/kandev/internal/task/dto"
v1 "github.com/kandev/kandev/pkg/api/v1"
ws "github.com/kandev/kandev/pkg/websocket"
"go.uber.org/zap"
)
@@ -38,14 +37,11 @@ func (h *BoardHandlers) registerHTTP(router *gin.Engine) {
api.GET("/workspaces/:id/boards", h.httpListBoardsByWorkspace)
api.GET("/workspaces/:id/board-snapshot", h.httpGetWorkspaceBoardSnapshot)
api.GET("/boards/:id", h.httpGetBoard)
api.GET("/boards/:id/columns", h.httpListColumns)
api.GET("/boards/:id/snapshot", h.httpGetBoardSnapshot)
api.POST("/boards", h.httpCreateBoard)
api.PATCH("/boards/:id", h.httpUpdateBoard)
api.DELETE("/boards/:id", h.httpDeleteBoard)
api.POST("/boards/:id/columns", h.httpCreateColumn)
api.PUT("/columns/:id", h.httpUpdateColumn)
api.DELETE("/columns/:id", h.httpDeleteColumn)
// Column endpoints removed - use workflow steps instead
}
func (h *BoardHandlers) registerWS(dispatcher *ws.Dispatcher) {
@@ -54,11 +50,7 @@ func (h *BoardHandlers) registerWS(dispatcher *ws.Dispatcher) {
dispatcher.RegisterFunc(ws.ActionBoardGet, h.wsGetBoard)
dispatcher.RegisterFunc(ws.ActionBoardUpdate, h.wsUpdateBoard)
dispatcher.RegisterFunc(ws.ActionBoardDelete, h.wsDeleteBoard)
dispatcher.RegisterFunc(ws.ActionColumnList, h.wsListColumns)
dispatcher.RegisterFunc(ws.ActionColumnCreate, h.wsCreateColumn)
dispatcher.RegisterFunc(ws.ActionColumnGet, h.wsGetColumn)
dispatcher.RegisterFunc(ws.ActionColumnUpdate, h.wsUpdateColumn)
dispatcher.RegisterFunc(ws.ActionColumnDelete, h.wsDeleteColumn)
// Column WS actions removed - use workflow steps instead
}
// HTTP handlers
@@ -97,9 +89,10 @@ func (h *BoardHandlers) httpGetBoard(c *gin.Context) {
}
type httpCreateBoardRequest struct {
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
WorkflowTemplateID *string `json:"workflow_template_id,omitempty"`
}
func (h *BoardHandlers) httpCreateBoard(c *gin.Context) {
@@ -113,9 +106,10 @@ func (h *BoardHandlers) httpCreateBoard(c *gin.Context) {
return
}
resp, err := h.controller.CreateBoard(c.Request.Context(), dto.CreateBoardRequest{
WorkspaceID: body.WorkspaceID,
Name: body.Name,
Description: body.Description,
WorkspaceID: body.WorkspaceID,
Name: body.Name,
Description: body.Description,
WorkflowTemplateID: body.WorkflowTemplateID,
})
if err != nil {
h.logger.Error("failed to create board", zap.Error(err))
@@ -157,89 +151,6 @@ func (h *BoardHandlers) httpDeleteBoard(c *gin.Context) {
c.JSON(http.StatusOK, resp)
}
func (h *BoardHandlers) httpListColumns(c *gin.Context) {
resp, err := h.controller.ListColumns(c.Request.Context(), dto.ListColumnsRequest{BoardID: c.Param("id")})
if err != nil {
handleNotFound(c, h.logger, err, "columns not found")
return
}
c.JSON(http.StatusOK, resp)
}
type httpCreateColumnRequest struct {
Name string `json:"name"`
Position int `json:"position"`
State v1.TaskState `json:"state"`
Color string `json:"color"`
}
func (h *BoardHandlers) httpCreateColumn(c *gin.Context) {
var body httpCreateColumnRequest
if err := c.ShouldBindJSON(&body); err != nil {
h.logger.Error("failed to bind create column request", zap.Error(err), zap.String("board_id", c.Param("id")))
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
if body.Name == "" {
h.logger.Error("create column missing name", zap.String("board_id", c.Param("id")))
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
state := body.State
if state == "" {
state = v1.TaskStateTODO
}
resp, err := h.controller.CreateColumn(c.Request.Context(), dto.CreateColumnRequest{
BoardID: c.Param("id"),
Name: body.Name,
Position: body.Position,
State: state,
Color: body.Color,
})
if err != nil {
h.logger.Error("failed to create column", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create column"})
return
}
c.JSON(http.StatusCreated, resp)
}
type httpUpdateColumnRequest struct {
Name *string `json:"name"`
Position *int `json:"position"`
State *v1.TaskState `json:"state"`
Color *string `json:"color"`
}
func (h *BoardHandlers) httpUpdateColumn(c *gin.Context) {
var body httpUpdateColumnRequest
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
resp, err := h.controller.UpdateColumn(c.Request.Context(), dto.UpdateColumnRequest{
ID: c.Param("id"),
Name: body.Name,
Position: body.Position,
State: body.State,
Color: body.Color,
})
if err != nil {
handleNotFound(c, h.logger, err, "column not found")
return
}
c.JSON(http.StatusOK, resp)
}
func (h *BoardHandlers) httpDeleteColumn(c *gin.Context) {
if err := h.controller.DeleteColumn(c.Request.Context(), dto.GetColumnRequest{ID: c.Param("id")}); err != nil {
h.logger.Error("failed to delete column", zap.Error(err), zap.String("column_id", c.Param("id")))
handleNotFound(c, h.logger, err, "column not found")
return
}
c.Status(http.StatusNoContent)
}
func (h *BoardHandlers) httpGetBoardSnapshot(c *gin.Context) {
resp, err := h.controller.GetSnapshot(c.Request.Context(), dto.GetBoardSnapshotRequest{BoardID: c.Param("id")})
if err != nil {
@@ -287,9 +198,10 @@ func (h *BoardHandlers) wsListBoards(ctx context.Context, msg *ws.Message) (*ws.
}
type wsCreateBoardRequest struct {
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
WorkflowTemplateID *string `json:"workflow_template_id,omitempty"`
}
func (h *BoardHandlers) wsCreateBoard(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
@@ -305,9 +217,10 @@ func (h *BoardHandlers) wsCreateBoard(ctx context.Context, msg *ws.Message) (*ws
}
resp, err := h.controller.CreateBoard(ctx, dto.CreateBoardRequest{
WorkspaceID: req.WorkspaceID,
Name: req.Name,
Description: req.Description,
WorkspaceID: req.WorkspaceID,
Name: req.Name,
Description: req.Description,
WorkflowTemplateID: req.WorkflowTemplateID,
})
if err != nil {
h.logger.Error("failed to create board", zap.Error(err))
@@ -383,132 +296,3 @@ func (h *BoardHandlers) wsDeleteBoard(ctx context.Context, msg *ws.Message) (*ws
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
type wsListColumnsRequest struct {
BoardID string `json:"board_id"`
}
func (h *BoardHandlers) wsListColumns(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req wsListColumnsRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.BoardID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "board_id is required", nil)
}
resp, err := h.controller.ListColumns(ctx, dto.ListColumnsRequest{BoardID: req.BoardID})
if err != nil {
h.logger.Error("failed to list columns", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list columns", nil)
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
type wsCreateColumnRequest struct {
BoardID string `json:"board_id"`
Name string `json:"name"`
Position int `json:"position"`
State string `json:"state,omitempty"`
Color string `json:"color,omitempty"`
}
func (h *BoardHandlers) wsCreateColumn(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req wsCreateColumnRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.BoardID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "board_id is required", nil)
}
if req.Name == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "name is required", nil)
}
state := v1.TaskState(req.State)
if state == "" {
state = v1.TaskStateTODO
}
resp, err := h.controller.CreateColumn(ctx, dto.CreateColumnRequest{
BoardID: req.BoardID,
Name: req.Name,
Position: req.Position,
State: state,
Color: req.Color,
})
if err != nil {
h.logger.Error("failed to create column", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to create column", nil)
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
type wsGetColumnRequest struct {
ID string `json:"id"`
}
func (h *BoardHandlers) wsGetColumn(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req wsGetColumnRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.ID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil)
}
resp, err := h.controller.GetColumn(ctx, dto.GetColumnRequest{ID: req.ID})
if err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Column not found", nil)
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
type wsUpdateColumnRequest struct {
ID string `json:"id"`
Name *string `json:"name,omitempty"`
Position *int `json:"position,omitempty"`
State *v1.TaskState `json:"state,omitempty"`
Color *string `json:"color,omitempty"`
}
func (h *BoardHandlers) wsUpdateColumn(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req wsUpdateColumnRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.ID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil)
}
resp, err := h.controller.UpdateColumn(ctx, dto.UpdateColumnRequest{
ID: req.ID,
Name: req.Name,
Position: req.Position,
State: req.State,
Color: req.Color,
})
if err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Column not found", nil)
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
type wsDeleteColumnRequest struct {
ID string `json:"id"`
}
func (h *BoardHandlers) wsDeleteColumn(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req wsDeleteColumnRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.ID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil)
}
if err := h.controller.DeleteColumn(ctx, dto.GetColumnRequest{ID: req.ID}); err != nil {
h.logger.Error("failed to delete column (ws)", zap.Error(err), zap.String("column_id", req.ID))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Column not found", nil)
}
return ws.NewResponse(msg.ID, msg.Action, gin.H{"deleted": true})
}
@@ -14,6 +14,10 @@ func handleNotFound(c *gin.Context, log *logger.Logger, err error, fallback stri
c.JSON(http.StatusNotFound, gin.H{"error": fallback})
return
}
if isValidationError(err) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
log.Error("request failed", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "request failed"})
}
@@ -24,3 +28,14 @@ func isNotFound(err error) bool {
}
return strings.Contains(strings.ToLower(err.Error()), "not found")
}
func isValidationError(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "pending approval") ||
strings.Contains(msg, "validation") ||
strings.Contains(msg, "required") ||
strings.Contains(msg, "invalid")
}
@@ -60,7 +60,7 @@ func (m *mockRepository) DeleteTask(ctx context.Context, id string) error {
func (m *mockRepository) ListTasks(ctx context.Context, boardID string) ([]*models.Task, error) {
return nil, nil
}
func (m *mockRepository) ListTasksByColumn(ctx context.Context, columnID string) ([]*models.Task, error) {
func (m *mockRepository) ListTasksByWorkflowStep(ctx context.Context, workflowStepID string) ([]*models.Task, error) {
return nil, nil
}
func (m *mockRepository) UpdateTaskState(ctx context.Context, id string, state v1.TaskState) error {
@@ -108,24 +108,6 @@ func (m *mockRepository) DeleteBoard(ctx context.Context, id string) error {
func (m *mockRepository) ListBoards(ctx context.Context, workspaceID string) ([]*models.Board, error) {
return nil, nil
}
func (m *mockRepository) CreateColumn(ctx context.Context, column *models.Column) error {
return nil
}
func (m *mockRepository) GetColumn(ctx context.Context, id string) (*models.Column, error) {
return nil, nil
}
func (m *mockRepository) GetColumnByState(ctx context.Context, boardID string, state v1.TaskState) (*models.Column, error) {
return nil, nil
}
func (m *mockRepository) UpdateColumn(ctx context.Context, column *models.Column) error {
return nil
}
func (m *mockRepository) DeleteColumn(ctx context.Context, id string) error {
return nil
}
func (m *mockRepository) ListColumns(ctx context.Context, boardID string) ([]*models.Column, error) {
return nil, nil
}
func (m *mockRepository) CreateMessage(ctx context.Context, message *models.Message) error {
return nil
}
@@ -338,6 +320,21 @@ func (m *mockRepository) GetLatestSessionCommit(ctx context.Context, sessionID s
func (m *mockRepository) DeleteSessionCommit(ctx context.Context, id string) error {
return nil
}
func (m *mockRepository) GetPrimarySessionByTaskID(ctx context.Context, taskID string) (*models.TaskSession, error) {
return nil, nil
}
func (m *mockRepository) GetPrimarySessionIDsByTaskIDs(ctx context.Context, taskIDs []string) (map[string]string, error) {
return make(map[string]string), nil
}
func (m *mockRepository) SetSessionPrimary(ctx context.Context, sessionID string) error {
return nil
}
func (m *mockRepository) UpdateSessionWorkflowStep(ctx context.Context, sessionID string, stepID string) error {
return nil
}
func (m *mockRepository) UpdateSessionReviewStatus(ctx context.Context, sessionID string, status string) error {
return nil
}
func newTestService(t *testing.T, scripts map[string][]*models.RepositoryScript) *service.Service {
log, err := logger.NewLogger(logger.LoggingConfig{
@@ -23,7 +23,9 @@ type TaskHandlers struct {
}
type OrchestratorStarter interface {
StartTask(ctx context.Context, taskID string, agentProfileID string, executorID string, priority int, prompt string) (*executor.TaskExecution, error)
// StartTask starts agent execution for a task.
// If workflowStepID is provided, prompt prefix/suffix and plan mode from the step are applied.
StartTask(ctx context.Context, taskID string, agentProfileID string, executorID string, priority int, prompt string, workflowStepID string) (*executor.TaskExecution, error)
}
func NewTaskHandlers(ctrl *controller.TaskController, orchestrator OrchestratorStarter, log *logger.Logger) *TaskHandlers {
@@ -50,6 +52,9 @@ func (h *TaskHandlers) registerHTTP(router *gin.Engine) {
api.PATCH("/tasks/:id", h.httpUpdateTask)
api.POST("/tasks/:id/move", h.httpMoveTask)
api.DELETE("/tasks/:id", h.httpDeleteTask)
// Session workflow review endpoints
api.POST("/sessions/:id/approve", h.httpApproveSession)
}
func (h *TaskHandlers) registerWS(dispatcher *ws.Dispatcher) {
@@ -108,6 +113,19 @@ func (h *TaskHandlers) httpGetTaskSession(c *gin.Context) {
c.JSON(http.StatusOK, resp)
}
func (h *TaskHandlers) httpApproveSession(c *gin.Context) {
resp, err := h.controller.ApproveSession(
c.Request.Context(),
dto.ApproveSessionRequest{SessionID: c.Param("id")},
)
if err != nil {
h.logger.Error("failed to approve session", zap.String("session_id", c.Param("id")), zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, resp)
}
type httpTaskRepositoryInput struct {
RepositoryID string `json:"repository_id"`
BaseBranch string `json:"base_branch"`
@@ -119,7 +137,7 @@ type httpTaskRepositoryInput struct {
type httpCreateTaskRequest struct {
WorkspaceID string `json:"workspace_id"`
BoardID string `json:"board_id"`
ColumnID string `json:"column_id"`
WorkflowStepID string `json:"workflow_step_id"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
Priority int `json:"priority,omitempty"`
@@ -171,16 +189,16 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) {
reqCtx := c.Request.Context()
resp, err := h.controller.CreateTask(reqCtx, dto.CreateTaskRequest{
WorkspaceID: body.WorkspaceID,
BoardID: body.BoardID,
ColumnID: body.ColumnID,
Title: body.Title,
Description: body.Description,
Priority: body.Priority,
State: body.State,
Repositories: repos,
Position: body.Position,
Metadata: body.Metadata,
WorkspaceID: body.WorkspaceID,
BoardID: body.BoardID,
WorkflowStepID: body.WorkflowStepID,
Title: body.Title,
Description: body.Description,
Priority: body.Priority,
State: body.State,
Repositories: repos,
Position: body.Position,
Metadata: body.Metadata,
})
if err != nil {
handleNotFound(c, h.logger, err, "task not created")
@@ -192,13 +210,14 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) {
// Launch agent asynchronously so the HTTP request can return immediately.
// The frontend will receive WebSocket updates when the agent actually starts.
// This allows the "new task" dialog to close right away while setup scripts run.
executorID := body.ExecutorID // Capture for goroutine
executorID := body.ExecutorID // Capture for goroutine
workflowStepID := body.WorkflowStepID // Capture for goroutine
go func() {
// Use a longer timeout to accommodate setup scripts (which can take minutes for npm install, etc.)
startCtx, cancel := context.WithTimeout(context.Background(), constants.AgentLaunchTimeout)
defer cancel()
// Use task description as the initial prompt
execution, err := h.orchestrator.StartTask(startCtx, resp.ID, body.AgentProfileID, executorID, body.Priority, resp.Description)
// Use task description as the initial prompt with workflow step config (prompt prefix/suffix, plan mode)
execution, err := h.orchestrator.StartTask(startCtx, resp.ID, body.AgentProfileID, executorID, body.Priority, resp.Description, workflowStepID)
if err != nil {
h.logger.Error("failed to start agent for task (async)", zap.Error(err), zap.String("task_id", resp.ID))
return
@@ -207,6 +226,7 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) {
zap.String("task_id", resp.ID),
zap.String("session_id", execution.SessionID),
zap.String("executor_id", executorID),
zap.String("workflow_step_id", workflowStepID),
zap.String("execution_id", execution.AgentExecutionID))
}()
}
@@ -263,9 +283,9 @@ func (h *TaskHandlers) httpUpdateTask(c *gin.Context) {
}
type httpMoveTaskRequest struct {
BoardID string `json:"board_id"`
ColumnID string `json:"column_id"`
Position int `json:"position"`
BoardID string `json:"board_id"`
WorkflowStepID string `json:"workflow_step_id"`
Position int `json:"position"`
}
func (h *TaskHandlers) httpMoveTask(c *gin.Context) {
@@ -274,15 +294,15 @@ func (h *TaskHandlers) httpMoveTask(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
if body.BoardID == "" || body.ColumnID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "board_id and column_id are required"})
if body.BoardID == "" || body.WorkflowStepID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "board_id and workflow_step_id are required"})
return
}
resp, err := h.controller.MoveTask(c.Request.Context(), dto.MoveTaskRequest{
ID: c.Param("id"),
BoardID: body.BoardID,
ColumnID: body.ColumnID,
Position: body.Position,
ID: c.Param("id"),
BoardID: body.BoardID,
WorkflowStepID: body.WorkflowStepID,
Position: body.Position,
})
if err != nil {
handleNotFound(c, h.logger, err, "task not moved")
@@ -349,7 +369,7 @@ func (h *TaskHandlers) wsListTasks(ctx context.Context, msg *ws.Message) (*ws.Me
type wsCreateTaskRequest struct {
WorkspaceID string `json:"workspace_id"`
BoardID string `json:"board_id"`
ColumnID string `json:"column_id"`
WorkflowStepID string `json:"workflow_step_id"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
Priority int `json:"priority,omitempty"`
@@ -373,8 +393,8 @@ func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.M
if req.BoardID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "board_id is required", nil)
}
if req.ColumnID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "column_id is required", nil)
if req.WorkflowStepID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workflow_step_id is required", nil)
}
if req.Title == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "title is required", nil)
@@ -399,16 +419,16 @@ func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.M
}
resp, err := h.controller.CreateTask(ctx, dto.CreateTaskRequest{
WorkspaceID: req.WorkspaceID,
BoardID: req.BoardID,
ColumnID: req.ColumnID,
Title: req.Title,
Description: req.Description,
Priority: req.Priority,
State: req.State,
Repositories: repos,
Position: req.Position,
Metadata: req.Metadata,
WorkspaceID: req.WorkspaceID,
BoardID: req.BoardID,
WorkflowStepID: req.WorkflowStepID,
Title: req.Title,
Description: req.Description,
Priority: req.Priority,
State: req.State,
Repositories: repos,
Position: req.Position,
Metadata: req.Metadata,
})
if err != nil {
h.logger.Error("failed to create task", zap.Error(err))
@@ -417,8 +437,8 @@ func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.M
response := createTaskResponse{TaskDTO: resp}
if req.StartAgent && req.AgentProfileID != "" && h.orchestrator != nil {
// Use task description as the initial prompt
execution, err := h.orchestrator.StartTask(ctx, resp.ID, req.AgentProfileID, req.ExecutorID, req.Priority, resp.Description)
// Use task description as the initial prompt with workflow step config (prompt prefix/suffix, plan mode)
execution, err := h.orchestrator.StartTask(ctx, resp.ID, req.AgentProfileID, req.ExecutorID, req.Priority, resp.Description, req.WorkflowStepID)
if err != nil {
h.logger.Error("failed to start agent for task", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to start agent for task", nil)
@@ -426,6 +446,7 @@ func (h *TaskHandlers) wsCreateTask(ctx context.Context, msg *ws.Message) (*ws.M
h.logger.Info("wsCreateTask started agent",
zap.String("task_id", resp.ID),
zap.String("executor_id", req.ExecutorID),
zap.String("workflow_step_id", req.WorkflowStepID),
zap.String("session_id", execution.SessionID))
response.TaskSessionID = execution.SessionID
response.AgentExecutionID = execution.AgentExecutionID
@@ -526,10 +547,10 @@ func (h *TaskHandlers) wsDeleteTask(ctx context.Context, msg *ws.Message) (*ws.M
}
type wsMoveTaskRequest struct {
ID string `json:"id"`
BoardID string `json:"board_id"`
ColumnID string `json:"column_id"`
Position int `json:"position"`
ID string `json:"id"`
BoardID string `json:"board_id"`
WorkflowStepID string `json:"workflow_step_id"`
Position int `json:"position"`
}
func (h *TaskHandlers) wsMoveTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
@@ -543,15 +564,15 @@ func (h *TaskHandlers) wsMoveTask(ctx context.Context, msg *ws.Message) (*ws.Mes
if req.BoardID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "board_id is required", nil)
}
if req.ColumnID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "column_id is required", nil)
if req.WorkflowStepID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "workflow_step_id is required", nil)
}
resp, err := h.controller.MoveTask(ctx, dto.MoveTaskRequest{
ID: req.ID,
BoardID: req.BoardID,
ColumnID: req.ColumnID,
Position: req.Position,
ID: req.ID,
BoardID: req.BoardID,
WorkflowStepID: req.WorkflowStepID,
Position: req.Position,
})
if err != nil {
h.logger.Error("failed to move task", zap.Error(err))
+40 -33
View File
@@ -1,11 +1,22 @@
package models
import (
"regexp"
"time"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
// systemTagRegex matches <kandev-system>...</kandev-system> content including the tags.
// This is used to strip system-injected content from messages before displaying to users.
var systemTagRegex = regexp.MustCompile(`<kandev-system>[\s\S]*?</kandev-system>\s*`)
// StripSystemContent removes all <kandev-system>...</kandev-system> blocks from text.
// This is used to hide workflow step prompt modifications (prefix/suffix) from the frontend UI.
func StripSystemContent(text string) string {
return systemTagRegex.ReplaceAllString(text, "")
}
// ListMessagesOptions defines pagination options for listing messages
type ListMessagesOptions struct {
Limit int
@@ -16,29 +27,30 @@ type ListMessagesOptions struct {
// Task represents a task in the database
type Task struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
BoardID string `json:"board_id"`
ColumnID string `json:"column_id"`
Title string `json:"title"`
Description string `json:"description"`
State v1.TaskState `json:"state"`
Priority int `json:"priority"`
Position int `json:"position"` // Order within column
Metadata map[string]interface{} `json:"metadata,omitempty"`
Repositories []*TaskRepository `json:"repositories,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
BoardID string `json:"board_id"`
WorkflowStepID string `json:"workflow_step_id"`
Title string `json:"title"`
Description string `json:"description"`
State v1.TaskState `json:"state"`
Priority int `json:"priority"`
Position int `json:"position"` // Order within workflow step
Metadata map[string]interface{} `json:"metadata,omitempty"`
Repositories []*TaskRepository `json:"repositories,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Board represents a Kanban board
type Board struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
Description string `json:"description"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
Description string `json:"description"`
WorkflowTemplateID *string `json:"workflow_template_id,omitempty"` // Optional workflow template
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Workspace represents a workspace
@@ -54,18 +66,6 @@ type Workspace struct {
UpdatedAt time.Time `json:"updated_at"`
}
// Column represents a column in a board
type Column struct {
ID string `json:"id"`
BoardID string `json:"board_id"`
Name string `json:"name"`
Position int `json:"position"`
State v1.TaskState `json:"state"` // Maps column to task state
Color string `json:"color"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TaskRepository represents a repository associated with a task
type TaskRepository struct {
ID string `json:"id"`
@@ -125,7 +125,9 @@ type Message struct {
CreatedAt time.Time `json:"created_at"`
}
// ToAPI converts internal Message to API type
// ToAPI converts internal Message to API type.
// System-injected content (wrapped in <kandev-system> tags) is stripped from the content
// so users don't see workflow step prompt modifications in the UI.
func (m *Message) ToAPI() *v1.Message {
messageType := string(m.Type)
if messageType == "" {
@@ -138,7 +140,7 @@ func (m *Message) ToAPI() *v1.Message {
TurnID: m.TurnID,
AuthorType: string(m.AuthorType),
AuthorID: m.AuthorID,
Content: m.Content,
Content: StripSystemContent(m.Content),
Type: messageType,
Metadata: m.Metadata,
RequestsInput: m.RequestsInput,
@@ -218,6 +220,11 @@ type TaskSession struct {
StartedAt time.Time `json:"started_at"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
// Workflow-related fields
IsPrimary bool `json:"is_primary"` // Whether this is the primary session for the task
WorkflowStepID *string `json:"workflow_step_id,omitempty"` // Current workflow step ID
ReviewStatus *string `json:"review_status,omitempty"` // pending, approved
}
// ToAPI converts internal TaskSession to API type
@@ -49,19 +49,19 @@ func TestTaskStructInitialization(t *testing.T) {
},
}
task := Task{
ID: "task-123",
WorkspaceID: "workspace-001",
BoardID: "board-456",
ColumnID: "column-789",
Title: "Test Task",
Description: "A test task description",
State: v1.TaskStateTODO,
Priority: 5,
Position: 1,
Metadata: map[string]interface{}{"key": "value"},
Repositories: repos,
CreatedAt: now,
UpdatedAt: now,
ID: "task-123",
WorkspaceID: "workspace-001",
BoardID: "board-456",
WorkflowStepID: "workflow-step-789",
Title: "Test Task",
Description: "A test task description",
State: v1.TaskStateTODO,
Priority: 5,
Position: 1,
Metadata: map[string]interface{}{"key": "value"},
Repositories: repos,
CreatedAt: now,
UpdatedAt: now,
}
if task.ID != "task-123" {
@@ -73,8 +73,8 @@ func TestTaskStructInitialization(t *testing.T) {
if task.BoardID != "board-456" {
t.Errorf("expected BoardID board-456, got %s", task.BoardID)
}
if task.ColumnID != "column-789" {
t.Errorf("expected ColumnID column-789, got %s", task.ColumnID)
if task.WorkflowStepID != "workflow-step-789" {
t.Errorf("expected WorkflowStepID workflow-step-789, got %s", task.WorkflowStepID)
}
if task.Title != "Test Task" {
t.Errorf("expected Title 'Test Task', got %s", task.Title)
@@ -130,42 +130,13 @@ func TestBoardStructInitialization(t *testing.T) {
}
}
func TestColumnStructInitialization(t *testing.T) {
now := time.Now().UTC()
column := Column{
ID: "column-123",
BoardID: "board-456",
Name: "To Do",
Position: 0,
State: v1.TaskStateTODO,
CreatedAt: now,
UpdatedAt: now,
}
if column.ID != "column-123" {
t.Errorf("expected ID column-123, got %s", column.ID)
}
if column.BoardID != "board-456" {
t.Errorf("expected BoardID board-456, got %s", column.BoardID)
}
if column.Name != "To Do" {
t.Errorf("expected Name 'To Do', got %s", column.Name)
}
if column.Position != 0 {
t.Errorf("expected Position 0, got %d", column.Position)
}
if column.State != v1.TaskStateTODO {
t.Errorf("expected State TODO, got %s", column.State)
}
}
func TestTaskToAPI(t *testing.T) {
now := time.Now().UTC()
task := &Task{
ID: "task-123",
WorkspaceID: "workspace-001",
BoardID: "board-456",
ColumnID: "column-789",
ID: "task-123",
WorkspaceID: "workspace-001",
BoardID: "board-456",
WorkflowStepID: "step-789",
Title: "Test Task",
Description: "A test task description",
State: v1.TaskStateInProgress,
@@ -228,10 +199,10 @@ func TestTaskToAPI(t *testing.T) {
func TestTaskToAPIWithEmptyOptionalFields(t *testing.T) {
now := time.Now().UTC()
task := &Task{
ID: "task-123",
WorkspaceID: "workspace-001",
BoardID: "board-456",
ColumnID: "column-789",
ID: "task-123",
WorkspaceID: "workspace-001",
BoardID: "board-456",
WorkflowStepID: "step-789",
Title: "Test Task",
Description: "A test task description",
State: v1.TaskStateTODO,
@@ -22,9 +22,9 @@ type Repository interface {
UpdateTask(ctx context.Context, task *models.Task) error
DeleteTask(ctx context.Context, id string) error
ListTasks(ctx context.Context, boardID string) ([]*models.Task, error)
ListTasksByColumn(ctx context.Context, columnID string) ([]*models.Task, error)
ListTasksByWorkflowStep(ctx context.Context, workflowStepID string) ([]*models.Task, error)
UpdateTaskState(ctx context.Context, id string, state v1.TaskState) error
AddTaskToBoard(ctx context.Context, taskID, boardID, columnID string, position int) error
AddTaskToBoard(ctx context.Context, taskID, boardID, workflowStepID string, position int) error
RemoveTaskFromBoard(ctx context.Context, taskID, boardID string) error
// TaskRepository operations
@@ -43,14 +43,6 @@ type Repository interface {
DeleteBoard(ctx context.Context, id string) error
ListBoards(ctx context.Context, workspaceID string) ([]*models.Board, error)
// Column operations
CreateColumn(ctx context.Context, column *models.Column) error
GetColumn(ctx context.Context, id string) (*models.Column, error)
GetColumnByState(ctx context.Context, boardID string, state v1.TaskState) (*models.Column, error)
UpdateColumn(ctx context.Context, column *models.Column) error
DeleteColumn(ctx context.Context, id string) error
ListColumns(ctx context.Context, boardID string) ([]*models.Column, error)
// Message operations
CreateMessage(ctx context.Context, message *models.Message) error
GetMessage(ctx context.Context, id string) (*models.Message, error)
@@ -85,6 +77,13 @@ type Repository interface {
HasActiveTaskSessionsByRepository(ctx context.Context, repositoryID string) (bool, error)
DeleteTaskSession(ctx context.Context, id string) error
// Workflow-related session operations
GetPrimarySessionByTaskID(ctx context.Context, taskID string) (*models.TaskSession, error)
GetPrimarySessionIDsByTaskIDs(ctx context.Context, taskIDs []string) (map[string]string, error)
SetSessionPrimary(ctx context.Context, sessionID string) error
UpdateSessionWorkflowStep(ctx context.Context, sessionID string, stepID string) error
UpdateSessionReviewStatus(ctx context.Context, sessionID string, status string) error
// Task Session Worktree operations
CreateTaskSessionWorktree(ctx context.Context, sessionWorktree *models.TaskSessionWorktree) error
ListTaskSessionWorktrees(ctx context.Context, sessionID string) ([]*models.TaskSessionWorktree, error)
File diff suppressed because it is too large Load Diff
@@ -4,6 +4,8 @@ package sqlite
import (
"database/sql"
"fmt"
commonsqlite "github.com/kandev/kandev/internal/common/sqlite"
)
// Repository provides SQLite-based task storage operations.
@@ -45,47 +47,7 @@ func (r *Repository) DB() *sql.DB {
// ensureColumn adds a column to a table if it doesn't exist
func (r *Repository) ensureColumn(table, column, definition string) error {
exists, err := r.columnExists(table, column)
if err != nil {
return err
}
if exists {
return nil
}
_, err = r.db.Exec(fmt.Sprintf("ALTER TABLE %s ADD COLUMN %s %s", table, column, definition))
return err
}
// columnExists checks if a column exists in a table
func (r *Repository) columnExists(table, column string) (bool, error) {
rows, err := r.db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
if err != nil {
return false, err
}
defer func() { _ = rows.Close() }()
for rows.Next() {
var cid int
var name, colType string
var notNull int
var defaultValue *string
var pk int
if err := rows.Scan(&cid, &name, &colType, &notNull, &defaultValue, &pk); err != nil {
return false, err
}
if name == column {
return true, nil
}
}
return false, rows.Err()
}
// boolToInt converts a boolean to an integer (for SQLite)
func boolToInt(value bool) int {
if value {
return 1
}
return 0
return commonsqlite.EnsureColumn(r.db, table, column, definition)
}
// ensureWorkspaceIndexes creates workspace-related indexes
@@ -173,23 +135,11 @@ func (r *Repository) initSchema() error {
updated_at DATETIME NOT NULL
);
CREATE TABLE IF NOT EXISTS columns (
id TEXT PRIMARY KEY,
board_id TEXT NOT NULL,
name TEXT NOT NULL,
position INTEGER DEFAULT 0,
state TEXT DEFAULT 'TODO',
color TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (board_id) REFERENCES boards(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL DEFAULT '',
board_id TEXT NOT NULL,
column_id TEXT NOT NULL,
workflow_step_id TEXT NOT NULL DEFAULT '',
title TEXT NOT NULL,
description TEXT DEFAULT '',
state TEXT DEFAULT 'TODO',
@@ -198,8 +148,7 @@ func (r *Repository) initSchema() error {
metadata TEXT DEFAULT '{}',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (board_id) REFERENCES boards(id) ON DELETE CASCADE,
FOREIGN KEY (column_id) REFERENCES columns(id) ON DELETE CASCADE
FOREIGN KEY (board_id) REFERENCES boards(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS task_repositories (
@@ -250,10 +199,9 @@ func (r *Repository) initSchema() error {
);
CREATE INDEX IF NOT EXISTS idx_tasks_board_id ON tasks(board_id);
CREATE INDEX IF NOT EXISTS idx_tasks_column_id ON tasks(column_id);
CREATE INDEX IF NOT EXISTS idx_tasks_workflow_step_id ON tasks(workflow_step_id);
CREATE INDEX IF NOT EXISTS idx_task_repositories_task_id ON task_repositories(task_id);
CREATE INDEX IF NOT EXISTS idx_task_repositories_repository_id ON task_repositories(repository_id);
CREATE INDEX IF NOT EXISTS idx_columns_board_id ON columns(board_id);
CREATE INDEX IF NOT EXISTS idx_repositories_workspace_id ON repositories(workspace_id);
CREATE INDEX IF NOT EXISTS idx_repository_scripts_repo_id ON repository_scripts(repository_id);
`
@@ -320,6 +268,9 @@ func (r *Repository) initSchema() error {
started_at DATETIME NOT NULL,
completed_at DATETIME,
updated_at DATETIME NOT NULL,
is_primary INTEGER DEFAULT 0,
workflow_step_id TEXT DEFAULT '',
review_status TEXT DEFAULT '',
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
);
@@ -406,6 +357,9 @@ func (r *Repository) initSchema() error {
if err := r.ensureColumn("boards", "workspace_id", "TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
if err := r.ensureColumn("boards", "workflow_template_id", "TEXT DEFAULT ''"); err != nil {
return err
}
if err := r.ensureColumn("tasks", "workspace_id", "TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
@@ -418,9 +372,7 @@ func (r *Repository) initSchema() error {
if err := r.ensureColumn("workspaces", "default_agent_profile_id", "TEXT DEFAULT ''"); err != nil {
return err
}
if err := r.ensureColumn("columns", "color", "TEXT NOT NULL DEFAULT ''"); err != nil {
return err
}
if err := r.ensureColumn("environments", "is_system", "INTEGER NOT NULL DEFAULT 0"); err != nil {
return err
}
@@ -470,6 +422,15 @@ func (r *Repository) initSchema() error {
if err := r.ensureColumn("task_sessions", "state", "TEXT NOT NULL DEFAULT 'CREATED'"); err != nil {
return err
}
if err := r.ensureColumn("task_sessions", "is_primary", "INTEGER DEFAULT 0"); err != nil {
return err
}
if err := r.ensureColumn("task_sessions", "workflow_step_id", "TEXT DEFAULT ''"); err != nil {
return err
}
if err := r.ensureColumn("task_sessions", "review_status", "TEXT DEFAULT ''"); err != nil {
return err
}
if err := r.ensureDefaultWorkspace(); err != nil {
return err
@@ -9,21 +9,20 @@ import (
"github.com/google/uuid"
"github.com/kandev/kandev/internal/task/models"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
// AddTaskToBoard adds a task to a board with placement
func (r *Repository) AddTaskToBoard(ctx context.Context, taskID, boardID, columnID string, position int) error {
func (r *Repository) AddTaskToBoard(ctx context.Context, taskID, boardID, workflowStepID string, position int) error {
_, err := r.db.ExecContext(ctx, `
UPDATE tasks SET board_id = ?, column_id = ?, position = ?, updated_at = ? WHERE id = ?
`, boardID, columnID, position, time.Now().UTC(), taskID)
UPDATE tasks SET board_id = ?, workflow_step_id = ?, position = ?, updated_at = ? WHERE id = ?
`, boardID, workflowStepID, position, time.Now().UTC(), taskID)
return err
}
// RemoveTaskFromBoard removes a task from a board
func (r *Repository) RemoveTaskFromBoard(ctx context.Context, taskID, boardID string) error {
_, err := r.db.ExecContext(ctx, `
UPDATE tasks SET board_id = '', column_id = '', position = 0, updated_at = ? WHERE id = ? AND board_id = ?
UPDATE tasks SET board_id = '', workflow_step_id = '', position = 0, updated_at = ? WHERE id = ? AND board_id = ?
`, time.Now().UTC(), taskID, boardID)
return err
}
@@ -40,9 +39,9 @@ func (r *Repository) CreateBoard(ctx context.Context, board *models.Board) error
board.UpdatedAt = now
_, err := r.db.ExecContext(ctx, `
INSERT INTO boards (id, workspace_id, name, description, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
`, board.ID, board.WorkspaceID, board.Name, board.Description, board.CreatedAt, board.UpdatedAt)
INSERT INTO boards (id, workspace_id, name, description, workflow_template_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, board.ID, board.WorkspaceID, board.Name, board.Description, board.WorkflowTemplateID, board.CreatedAt, board.UpdatedAt)
return err
}
@@ -50,16 +49,24 @@ func (r *Repository) CreateBoard(ctx context.Context, board *models.Board) error
// GetBoard retrieves a board by ID
func (r *Repository) GetBoard(ctx context.Context, id string) (*models.Board, error) {
board := &models.Board{}
var workflowTemplateID sql.NullString
err := r.db.QueryRowContext(ctx, `
SELECT id, workspace_id, name, description, created_at, updated_at
SELECT id, workspace_id, name, description, workflow_template_id, created_at, updated_at
FROM boards WHERE id = ?
`, id).Scan(&board.ID, &board.WorkspaceID, &board.Name, &board.Description, &board.CreatedAt, &board.UpdatedAt)
`, id).Scan(&board.ID, &board.WorkspaceID, &board.Name, &board.Description, &workflowTemplateID, &board.CreatedAt, &board.UpdatedAt)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("board not found: %s", id)
}
return board, err
if err != nil {
return nil, err
}
if workflowTemplateID.Valid {
board.WorkflowTemplateID = &workflowTemplateID.String
}
return board, nil
}
// UpdateBoard updates an existing board
@@ -67,8 +74,8 @@ func (r *Repository) UpdateBoard(ctx context.Context, board *models.Board) error
board.UpdatedAt = time.Now().UTC()
result, err := r.db.ExecContext(ctx, `
UPDATE boards SET name = ?, description = ?, updated_at = ? WHERE id = ?
`, board.Name, board.Description, board.UpdatedAt, board.ID)
UPDATE boards SET name = ?, description = ?, workflow_template_id = ?, updated_at = ? WHERE id = ?
`, board.Name, board.Description, board.WorkflowTemplateID, board.UpdatedAt, board.ID)
if err != nil {
return err
}
@@ -97,7 +104,7 @@ func (r *Repository) DeleteBoard(ctx context.Context, id string) error {
// ListBoards returns all boards
func (r *Repository) ListBoards(ctx context.Context, workspaceID string) ([]*models.Board, error) {
query := `
SELECT id, workspace_id, name, description, created_at, updated_at FROM boards
SELECT id, workspace_id, name, description, workflow_template_id, created_at, updated_at FROM boards
`
var args []interface{}
if workspaceID != "" {
@@ -115,116 +122,15 @@ func (r *Repository) ListBoards(ctx context.Context, workspaceID string) ([]*mod
var result []*models.Board
for rows.Next() {
board := &models.Board{}
err := rows.Scan(&board.ID, &board.WorkspaceID, &board.Name, &board.Description, &board.CreatedAt, &board.UpdatedAt)
var workflowTemplateID sql.NullString
err := rows.Scan(&board.ID, &board.WorkspaceID, &board.Name, &board.Description, &workflowTemplateID, &board.CreatedAt, &board.UpdatedAt)
if err != nil {
return nil, err
}
if workflowTemplateID.Valid {
board.WorkflowTemplateID = &workflowTemplateID.String
}
result = append(result, board)
}
return result, rows.Err()
}
// Column operations
// CreateColumn creates a new column
func (r *Repository) CreateColumn(ctx context.Context, column *models.Column) error {
if column.ID == "" {
column.ID = uuid.New().String()
}
now := time.Now().UTC()
column.CreatedAt = now
column.UpdatedAt = now
_, err := r.db.ExecContext(ctx, `
INSERT INTO columns (id, board_id, name, position, state, color, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, column.ID, column.BoardID, column.Name, column.Position, column.State, column.Color, column.CreatedAt, column.UpdatedAt)
return err
}
// GetColumn retrieves a column by ID
func (r *Repository) GetColumn(ctx context.Context, id string) (*models.Column, error) {
column := &models.Column{}
err := r.db.QueryRowContext(ctx, `
SELECT id, board_id, name, position, state, color, created_at, updated_at
FROM columns WHERE id = ?
`, id).Scan(&column.ID, &column.BoardID, &column.Name, &column.Position, &column.State, &column.Color, &column.CreatedAt, &column.UpdatedAt)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("column not found: %s", id)
}
return column, err
}
// GetColumnByState retrieves a column by board ID and state
func (r *Repository) GetColumnByState(ctx context.Context, boardID string, state v1.TaskState) (*models.Column, error) {
column := &models.Column{}
err := r.db.QueryRowContext(ctx, `
SELECT id, board_id, name, position, state, color, created_at, updated_at
FROM columns WHERE board_id = ? AND state = ?
`, boardID, state).Scan(&column.ID, &column.BoardID, &column.Name, &column.Position, &column.State, &column.Color, &column.CreatedAt, &column.UpdatedAt)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("column not found for board %s with state %s", boardID, state)
}
return column, err
}
// UpdateColumn updates an existing column
func (r *Repository) UpdateColumn(ctx context.Context, column *models.Column) error {
column.UpdatedAt = time.Now().UTC()
result, err := r.db.ExecContext(ctx, `
UPDATE columns SET name = ?, position = ?, state = ?, color = ?, updated_at = ? WHERE id = ?
`, column.Name, column.Position, column.State, column.Color, column.UpdatedAt, column.ID)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("column not found: %s", column.ID)
}
return nil
}
// DeleteColumn deletes a column by ID
func (r *Repository) DeleteColumn(ctx context.Context, id string) error {
result, err := r.db.ExecContext(ctx, `DELETE FROM columns WHERE id = ?`, id)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("column not found: %s", id)
}
return nil
}
// ListColumns returns all columns for a board
func (r *Repository) ListColumns(ctx context.Context, boardID string) ([]*models.Column, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, board_id, name, position, state, color, created_at, updated_at
FROM columns WHERE board_id = ? ORDER BY position
`, boardID)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
var result []*models.Column
for rows.Next() {
column := &models.Column{}
err := rows.Scan(&column.ID, &column.BoardID, &column.Name, &column.Position, &column.State, &column.Color, &column.CreatedAt, &column.UpdatedAt)
if err != nil {
return nil, err
}
result = append(result, column)
}
return result, rows.Err()
}
@@ -8,8 +8,8 @@ import (
"github.com/google/uuid"
commonsqlite "github.com/kandev/kandev/internal/common/sqlite"
"github.com/kandev/kandev/internal/task/models"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
// ensureDefaultWorkspace creates a default workspace if none exists
@@ -63,27 +63,7 @@ func (r *Repository) ensureDefaultWorkspace() error {
`, boardID, defaultID, "Dev", "Default development board", now, now); err != nil {
return err
}
type columnSeed struct {
name string
position int
state string
color string
}
columns := []columnSeed{
{name: "Todo", position: 0, state: string(v1.TaskStateTODO), color: "bg-neutral-400"},
{name: "In Progress", position: 1, state: string(v1.TaskStateInProgress), color: "bg-blue-500"},
{name: "Review", position: 2, state: string(v1.TaskStateReview), color: "bg-yellow-500"},
{name: "Done", position: 3, state: string(v1.TaskStateCompleted), color: "bg-green-500"},
}
for _, column := range columns {
if _, err := r.db.ExecContext(ctx, `
INSERT INTO columns (id, board_id, name, position, state, color, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, uuid.New().String(), boardID, column.name, column.position, column.state, column.color, now, now); err != nil {
return err
}
}
// Note: Workflow steps will be created by the workflow repository after it initializes
}
}
@@ -161,7 +141,7 @@ func (r *Repository) ensureDefaultExecutorsAndEnvironments() error {
if _, err := r.db.ExecContext(ctx, `
INSERT INTO executors (id, name, type, status, is_system, resumable, config, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, executor.id, executor.name, executor.execType, executor.status, boolToInt(executor.isSystem), boolToInt(executor.resumable), string(configJSON), now, now); err != nil {
`, executor.id, executor.name, executor.execType, executor.status, commonsqlite.BoolToInt(executor.isSystem), commonsqlite.BoolToInt(executor.resumable), string(configJSON), now, now); err != nil {
return err
}
}
@@ -182,7 +162,7 @@ func (r *Repository) ensureDefaultExecutorsAndEnvironments() error {
if _, err := r.db.ExecContext(ctx, `
INSERT INTO environments (id, name, kind, is_system, worktree_root, image_tag, dockerfile, build_config, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, models.EnvironmentIDLocal, "Local", models.EnvironmentKindLocalPC, boolToInt(true), "~/kandev", "", "", "{}", now, now); err != nil {
`, models.EnvironmentIDLocal, "Local", models.EnvironmentKindLocalPC, commonsqlite.BoolToInt(true), "~/kandev", "", "", "{}", now, now); err != nil {
return err
}
} else {
@@ -195,7 +175,7 @@ func (r *Repository) ensureDefaultExecutorsAndEnvironments() error {
if _, err := r.db.ExecContext(ctx, `
INSERT INTO environments (id, name, kind, is_system, worktree_root, image_tag, dockerfile, build_config, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, models.EnvironmentIDLocal, "Local", models.EnvironmentKindLocalPC, boolToInt(true), "~/kandev", "", "", "{}", now, now); err != nil {
`, models.EnvironmentIDLocal, "Local", models.EnvironmentKindLocalPC, commonsqlite.BoolToInt(true), "~/kandev", "", "", "{}", now, now); err != nil {
return err
}
}
@@ -9,6 +9,7 @@ import (
"github.com/google/uuid"
commonsqlite "github.com/kandev/kandev/internal/common/sqlite"
"github.com/kandev/kandev/internal/task/models"
)
@@ -29,7 +30,7 @@ func (r *Repository) CreateEnvironment(ctx context.Context, environment *models.
_, err = r.db.ExecContext(ctx, `
INSERT INTO environments (id, name, kind, is_system, worktree_root, image_tag, dockerfile, build_config, created_at, updated_at, deleted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, environment.ID, environment.Name, environment.Kind, boolToInt(environment.IsSystem), environment.WorktreeRoot, environment.ImageTag, environment.Dockerfile, string(buildConfigJSON), environment.CreatedAt, environment.UpdatedAt, environment.DeletedAt)
`, environment.ID, environment.Name, environment.Kind, commonsqlite.BoolToInt(environment.IsSystem), environment.WorktreeRoot, environment.ImageTag, environment.Dockerfile, string(buildConfigJSON), environment.CreatedAt, environment.UpdatedAt, environment.DeletedAt)
return err
}
@@ -74,7 +75,7 @@ func (r *Repository) UpdateEnvironment(ctx context.Context, environment *models.
result, err := r.db.ExecContext(ctx, `
UPDATE environments SET name = ?, kind = ?, is_system = ?, worktree_root = ?, image_tag = ?, dockerfile = ?, build_config = ?, updated_at = ?
WHERE id = ? AND deleted_at IS NULL
`, environment.Name, environment.Kind, boolToInt(environment.IsSystem), environment.WorktreeRoot, environment.ImageTag, environment.Dockerfile, string(buildConfigJSON), environment.UpdatedAt, environment.ID)
`, environment.Name, environment.Kind, commonsqlite.BoolToInt(environment.IsSystem), environment.WorktreeRoot, environment.ImageTag, environment.Dockerfile, string(buildConfigJSON), environment.UpdatedAt, environment.ID)
if err != nil {
return err
}
@@ -9,6 +9,7 @@ import (
"github.com/google/uuid"
commonsqlite "github.com/kandev/kandev/internal/common/sqlite"
"github.com/kandev/kandev/internal/task/models"
)
@@ -30,7 +31,7 @@ func (r *Repository) CreateExecutor(ctx context.Context, executor *models.Execut
_, err = r.db.ExecContext(ctx, `
INSERT INTO executors (id, name, type, status, is_system, resumable, config, created_at, updated_at, deleted_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, executor.ID, executor.Name, executor.Type, executor.Status, boolToInt(executor.IsSystem), boolToInt(executor.Resumable), string(configJSON), executor.CreatedAt, executor.UpdatedAt, executor.DeletedAt)
`, executor.ID, executor.Name, executor.Type, executor.Status, commonsqlite.BoolToInt(executor.IsSystem), commonsqlite.BoolToInt(executor.Resumable), string(configJSON), executor.CreatedAt, executor.UpdatedAt, executor.DeletedAt)
return err
}
@@ -75,7 +76,7 @@ func (r *Repository) UpdateExecutor(ctx context.Context, executor *models.Execut
result, err := r.db.ExecContext(ctx, `
UPDATE executors SET name = ?, type = ?, status = ?, is_system = ?, resumable = ?, config = ?, updated_at = ?
WHERE id = ? AND deleted_at IS NULL
`, executor.Name, executor.Type, executor.Status, boolToInt(executor.IsSystem), boolToInt(executor.Resumable), string(configJSON), executor.UpdatedAt, executor.ID)
`, executor.Name, executor.Type, executor.Status, commonsqlite.BoolToInt(executor.IsSystem), commonsqlite.BoolToInt(executor.Resumable), string(configJSON), executor.UpdatedAt, executor.ID)
if err != nil {
return err
}
@@ -184,7 +185,7 @@ func (r *Repository) UpsertExecutorRunning(ctx context.Context, running *models.
running.ExecutorID,
running.Runtime,
running.Status,
boolToInt(running.Resumable),
commonsqlite.BoolToInt(running.Resumable),
running.ResumeToken,
running.AgentExecutionID,
running.ContainerID,
@@ -8,6 +8,7 @@ import (
"github.com/google/uuid"
commonsqlite "github.com/kandev/kandev/internal/common/sqlite"
"github.com/kandev/kandev/internal/task/models"
)
@@ -27,7 +28,7 @@ func (r *Repository) CreateRepository(ctx context.Context, repository *models.Re
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, repository.ID, repository.WorkspaceID, repository.Name, repository.SourceType, repository.LocalPath, repository.Provider,
repository.ProviderRepoID, repository.ProviderOwner, repository.ProviderName, repository.DefaultBranch, repository.WorktreeBranchPrefix,
boolToInt(repository.PullBeforeWorktree), repository.SetupScript, repository.CleanupScript, repository.DevScript, repository.CreatedAt, repository.UpdatedAt, repository.DeletedAt)
commonsqlite.BoolToInt(repository.PullBeforeWorktree), repository.SetupScript, repository.CleanupScript, repository.DevScript, repository.CreatedAt, repository.UpdatedAt, repository.DeletedAt)
return err
}
@@ -62,7 +63,7 @@ func (r *Repository) UpdateRepository(ctx context.Context, repository *models.Re
provider_name = ?, default_branch = ?, worktree_branch_prefix = ?, pull_before_worktree = ?, setup_script = ?, cleanup_script = ?, dev_script = ?, updated_at = ?
WHERE id = ? AND deleted_at IS NULL
`, repository.Name, repository.SourceType, repository.LocalPath, repository.Provider, repository.ProviderRepoID,
repository.ProviderOwner, repository.ProviderName, repository.DefaultBranch, repository.WorktreeBranchPrefix, boolToInt(repository.PullBeforeWorktree),
repository.ProviderOwner, repository.ProviderName, repository.DefaultBranch, repository.WorktreeBranchPrefix, commonsqlite.BoolToInt(repository.PullBeforeWorktree),
repository.SetupScript, repository.CleanupScript, repository.DevScript, repository.UpdatedAt, repository.ID)
if err != nil {
return err
@@ -6,10 +6,12 @@ import (
"database/sql"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/google/uuid"
commonsqlite "github.com/kandev/kandev/internal/common/sqlite"
"github.com/kandev/kandev/internal/task/models"
)
@@ -197,19 +199,20 @@ func (r *Repository) CreateTaskSession(ctx context.Context, session *models.Task
if err != nil {
return fmt.Errorf("failed to serialize repository snapshot: %w", err)
}
_, err = r.db.ExecContext(ctx, `
INSERT INTO task_sessions (
id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id,
repository_id, base_branch,
agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot,
state, error_message, metadata, started_at, completed_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
state, error_message, metadata, started_at, completed_at, updated_at,
is_primary, workflow_step_id, review_status
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, session.ID, session.TaskID, session.AgentExecutionID, session.ContainerID, session.AgentProfileID,
session.ExecutorID, session.EnvironmentID, session.RepositoryID, session.BaseBranch,
string(agentProfileSnapshotJSON), string(executorSnapshotJSON), string(environmentSnapshotJSON), string(repositorySnapshotJSON),
string(session.State), session.ErrorMessage, string(metadataJSON),
session.StartedAt, session.CompletedAt, session.UpdatedAt)
session.StartedAt, session.CompletedAt, session.UpdatedAt,
commonsqlite.BoolToInt(session.IsPrimary), session.WorkflowStepID, session.ReviewStatus)
return err
}
@@ -224,12 +227,16 @@ func (r *Repository) GetTaskSession(ctx context.Context, id string) (*models.Tas
var environmentSnapshotJSON string
var repositorySnapshotJSON string
var completedAt sql.NullTime
var isPrimary int
var workflowStepID sql.NullString
var reviewStatus sql.NullString
err := r.db.QueryRowContext(ctx, `
SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id,
repository_id, base_branch,
agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot,
state, error_message, metadata, started_at, completed_at, updated_at
state, error_message, metadata, started_at, completed_at, updated_at,
is_primary, workflow_step_id, review_status
FROM task_sessions WHERE id = ?
`, id).Scan(
&session.ID, &session.TaskID, &session.AgentExecutionID, &session.ContainerID, &session.AgentProfileID,
@@ -237,6 +244,7 @@ func (r *Repository) GetTaskSession(ctx context.Context, id string) (*models.Tas
&session.RepositoryID, &session.BaseBranch,
&agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON,
&state, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt,
&isPrimary, &workflowStepID, &reviewStatus,
)
if err == sql.ErrNoRows {
@@ -247,6 +255,13 @@ func (r *Repository) GetTaskSession(ctx context.Context, id string) (*models.Tas
}
session.State = models.TaskSessionState(state)
session.IsPrimary = isPrimary == 1
if workflowStepID.Valid {
session.WorkflowStepID = &workflowStepID.String
}
if reviewStatus.Valid {
session.ReviewStatus = &reviewStatus.String
}
if completedAt.Valid {
session.CompletedAt = &completedAt.Time
}
@@ -295,12 +310,16 @@ func (r *Repository) GetTaskSessionByTaskID(ctx context.Context, taskID string)
var environmentSnapshotJSON string
var repositorySnapshotJSON string
var completedAt sql.NullTime
var isPrimary int
var workflowStepID sql.NullString
var reviewStatus sql.NullString
err := r.db.QueryRowContext(ctx, `
SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id,
repository_id, base_branch,
agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot,
state, error_message, metadata, started_at, completed_at, updated_at
state, error_message, metadata, started_at, completed_at, updated_at,
is_primary, workflow_step_id, review_status
FROM task_sessions WHERE task_id = ? ORDER BY started_at DESC LIMIT 1
`, taskID).Scan(
&session.ID, &session.TaskID, &session.AgentExecutionID, &session.ContainerID, &session.AgentProfileID,
@@ -308,6 +327,7 @@ func (r *Repository) GetTaskSessionByTaskID(ctx context.Context, taskID string)
&session.RepositoryID, &session.BaseBranch,
&agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON,
&state, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt,
&isPrimary, &workflowStepID, &reviewStatus,
)
if err == sql.ErrNoRows {
@@ -318,6 +338,13 @@ func (r *Repository) GetTaskSessionByTaskID(ctx context.Context, taskID string)
}
session.State = models.TaskSessionState(state)
session.IsPrimary = isPrimary == 1
if workflowStepID.Valid {
session.WorkflowStepID = &workflowStepID.String
}
if reviewStatus.Valid {
session.ReviewStatus = &reviewStatus.String
}
if completedAt.Valid {
session.CompletedAt = &completedAt.Time
}
@@ -366,12 +393,16 @@ func (r *Repository) GetActiveTaskSessionByTaskID(ctx context.Context, taskID st
var environmentSnapshotJSON string
var repositorySnapshotJSON string
var completedAt sql.NullTime
var isPrimary int
var workflowStepID sql.NullString
var reviewStatus sql.NullString
err := r.db.QueryRowContext(ctx, `
SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id,
repository_id, base_branch,
agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot,
state, error_message, metadata, started_at, completed_at, updated_at
state, error_message, metadata, started_at, completed_at, updated_at,
is_primary, workflow_step_id, review_status
FROM task_sessions
WHERE task_id = ? AND state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT')
ORDER BY started_at DESC LIMIT 1
@@ -381,6 +412,7 @@ func (r *Repository) GetActiveTaskSessionByTaskID(ctx context.Context, taskID st
&session.RepositoryID, &session.BaseBranch,
&agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON,
&state, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt,
&isPrimary, &workflowStepID, &reviewStatus,
)
if err == sql.ErrNoRows {
@@ -391,6 +423,13 @@ func (r *Repository) GetActiveTaskSessionByTaskID(ctx context.Context, taskID st
}
session.State = models.TaskSessionState(state)
session.IsPrimary = isPrimary == 1
if workflowStepID.Valid {
session.WorkflowStepID = &workflowStepID.String
}
if reviewStatus.Valid {
session.ReviewStatus = &reviewStatus.String
}
if completedAt.Valid {
session.CompletedAt = &completedAt.Time
}
@@ -459,13 +498,15 @@ func (r *Repository) UpdateTaskSession(ctx context.Context, session *models.Task
agent_execution_id = ?, container_id = ?, agent_profile_id = ?, executor_id = ?, environment_id = ?,
repository_id = ?, base_branch = ?,
agent_profile_snapshot = ?, executor_snapshot = ?, environment_snapshot = ?, repository_snapshot = ?,
state = ?, error_message = ?, metadata = ?, completed_at = ?, updated_at = ?
state = ?, error_message = ?, metadata = ?, completed_at = ?, updated_at = ?,
is_primary = ?, workflow_step_id = ?, review_status = ?
WHERE id = ?
`, session.AgentExecutionID, session.ContainerID, session.AgentProfileID, session.ExecutorID, session.EnvironmentID,
session.RepositoryID, session.BaseBranch,
string(agentProfileSnapshotJSON), string(executorSnapshotJSON), string(environmentSnapshotJSON), string(repositorySnapshotJSON),
string(session.State), session.ErrorMessage, string(metadataJSON), session.CompletedAt,
session.UpdatedAt, session.ID)
string(session.State), session.ErrorMessage, string(metadataJSON), session.CompletedAt, session.UpdatedAt,
commonsqlite.BoolToInt(session.IsPrimary), session.WorkflowStepID, session.ReviewStatus,
session.ID)
if err != nil {
return err
}
@@ -506,7 +547,8 @@ func (r *Repository) ListTaskSessions(ctx context.Context, taskID string) ([]*mo
SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id,
repository_id, base_branch,
agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot,
state, error_message, metadata, started_at, completed_at, updated_at
state, error_message, metadata, started_at, completed_at, updated_at,
is_primary, workflow_step_id, review_status
FROM task_sessions WHERE task_id = ? ORDER BY started_at DESC
`, taskID)
if err != nil {
@@ -534,7 +576,8 @@ func (r *Repository) ListActiveTaskSessions(ctx context.Context) ([]*models.Task
SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id,
repository_id, base_branch,
agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot,
state, error_message, metadata, started_at, completed_at, updated_at
state, error_message, metadata, started_at, completed_at, updated_at,
is_primary, workflow_step_id, review_status
FROM task_sessions WHERE state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT') ORDER BY started_at DESC
`)
if err != nil {
@@ -562,7 +605,8 @@ func (r *Repository) ListActiveTaskSessionsByTaskID(ctx context.Context, taskID
SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id,
repository_id, base_branch,
agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot,
state, error_message, metadata, started_at, completed_at, updated_at
state, error_message, metadata, started_at, completed_at, updated_at,
is_primary, workflow_step_id, review_status
FROM task_sessions WHERE task_id = ? AND state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT') ORDER BY started_at DESC
`, taskID)
if err != nil {
@@ -638,6 +682,9 @@ func (r *Repository) scanTaskSessions(ctx context.Context, rows *sql.Rows) ([]*m
var environmentSnapshotJSON string
var repositorySnapshotJSON string
var completedAt sql.NullTime
var isPrimary int
var workflowStepID sql.NullString
var reviewStatus sql.NullString
err := rows.Scan(
&session.ID, &session.TaskID, &session.AgentExecutionID, &session.ContainerID, &session.AgentProfileID,
@@ -645,12 +692,20 @@ func (r *Repository) scanTaskSessions(ctx context.Context, rows *sql.Rows) ([]*m
&session.RepositoryID, &session.BaseBranch,
&agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON,
&state, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt,
&isPrimary, &workflowStepID, &reviewStatus,
)
if err != nil {
return nil, err
}
session.State = models.TaskSessionState(state)
session.IsPrimary = isPrimary == 1
if workflowStepID.Valid {
session.WorkflowStepID = &workflowStepID.String
}
if reviewStatus.Valid {
session.ReviewStatus = &reviewStatus.String
}
if completedAt.Valid {
session.CompletedAt = &completedAt.Time
}
@@ -787,3 +842,195 @@ func (r *Repository) DeleteTaskSessionWorktreesBySession(ctx context.Context, se
return err
}
// GetPrimarySessionByTaskID retrieves the primary session for a task
func (r *Repository) GetPrimarySessionByTaskID(ctx context.Context, taskID string) (*models.TaskSession, error) {
session := &models.TaskSession{}
var state string
var metadataJSON string
var agentProfileSnapshotJSON string
var executorSnapshotJSON string
var environmentSnapshotJSON string
var repositorySnapshotJSON string
var completedAt sql.NullTime
var isPrimary int
var workflowStepID sql.NullString
var reviewStatus sql.NullString
err := r.db.QueryRowContext(ctx, `
SELECT id, task_id, agent_execution_id, container_id, agent_profile_id, executor_id, environment_id,
repository_id, base_branch,
agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot,
state, error_message, metadata, started_at, completed_at, updated_at,
is_primary, workflow_step_id, review_status
FROM task_sessions WHERE task_id = ? AND is_primary = 1 LIMIT 1
`, taskID).Scan(
&session.ID, &session.TaskID, &session.AgentExecutionID, &session.ContainerID, &session.AgentProfileID,
&session.ExecutorID, &session.EnvironmentID,
&session.RepositoryID, &session.BaseBranch,
&agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON,
&state, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt,
&isPrimary, &workflowStepID, &reviewStatus,
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("no primary session found for task: %s", taskID)
}
if err != nil {
return nil, err
}
session.State = models.TaskSessionState(state)
session.IsPrimary = isPrimary == 1
if workflowStepID.Valid {
session.WorkflowStepID = &workflowStepID.String
}
if reviewStatus.Valid {
session.ReviewStatus = &reviewStatus.String
}
if completedAt.Valid {
session.CompletedAt = &completedAt.Time
}
if metadataJSON != "" && metadataJSON != "{}" {
if err := json.Unmarshal([]byte(metadataJSON), &session.Metadata); err != nil {
return nil, fmt.Errorf("failed to deserialize agent session metadata: %w", err)
}
}
if agentProfileSnapshotJSON != "" && agentProfileSnapshotJSON != "{}" {
if err := json.Unmarshal([]byte(agentProfileSnapshotJSON), &session.AgentProfileSnapshot); err != nil {
return nil, fmt.Errorf("failed to deserialize agent profile snapshot: %w", err)
}
}
if executorSnapshotJSON != "" && executorSnapshotJSON != "{}" {
if err := json.Unmarshal([]byte(executorSnapshotJSON), &session.ExecutorSnapshot); err != nil {
return nil, fmt.Errorf("failed to deserialize executor snapshot: %w", err)
}
}
if environmentSnapshotJSON != "" && environmentSnapshotJSON != "{}" {
if err := json.Unmarshal([]byte(environmentSnapshotJSON), &session.EnvironmentSnapshot); err != nil {
return nil, fmt.Errorf("failed to deserialize environment snapshot: %w", err)
}
}
if repositorySnapshotJSON != "" && repositorySnapshotJSON != "{}" {
if err := json.Unmarshal([]byte(repositorySnapshotJSON), &session.RepositorySnapshot); err != nil {
return nil, fmt.Errorf("failed to deserialize repository snapshot: %w", err)
}
}
worktrees, err := r.ListTaskSessionWorktrees(ctx, session.ID)
if err != nil {
return nil, fmt.Errorf("failed to load session worktrees: %w", err)
}
session.Worktrees = worktrees
return session, nil
}
// GetPrimarySessionIDsByTaskIDs returns a map of task ID to primary session ID for the given task IDs.
// Tasks without a primary session are not included in the result.
func (r *Repository) GetPrimarySessionIDsByTaskIDs(ctx context.Context, taskIDs []string) (map[string]string, error) {
if len(taskIDs) == 0 {
return make(map[string]string), nil
}
// Build placeholders for IN clause
placeholders := make([]string, len(taskIDs))
args := make([]interface{}, len(taskIDs))
for i, id := range taskIDs {
placeholders[i] = "?"
args[i] = id
}
query := fmt.Sprintf(`
SELECT id, task_id FROM task_sessions
WHERE task_id IN (%s) AND is_primary = 1
`, strings.Join(placeholders, ","))
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
result := make(map[string]string)
for rows.Next() {
var sessionID, taskID string
if err := rows.Scan(&sessionID, &taskID); err != nil {
return nil, err
}
result[taskID] = sessionID
}
return result, rows.Err()
}
// SetSessionPrimary marks a session as primary and clears primary flag on other sessions for the same task
func (r *Repository) SetSessionPrimary(ctx context.Context, sessionID string) error {
now := time.Now().UTC()
// First, get the task_id for this session
var taskID string
err := r.db.QueryRowContext(ctx, `SELECT task_id FROM task_sessions WHERE id = ?`, sessionID).Scan(&taskID)
if err == sql.ErrNoRows {
return fmt.Errorf("session not found: %s", sessionID)
}
if err != nil {
return err
}
// Clear primary flag on all sessions for this task
_, err = r.db.ExecContext(ctx, `
UPDATE task_sessions SET is_primary = 0, updated_at = ? WHERE task_id = ?
`, now, taskID)
if err != nil {
return err
}
// Set primary flag on the specified session
result, err := r.db.ExecContext(ctx, `
UPDATE task_sessions SET is_primary = 1, updated_at = ? WHERE id = ?
`, now, sessionID)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("session not found: %s", sessionID)
}
return nil
}
// UpdateSessionWorkflowStep updates the workflow step for a session
func (r *Repository) UpdateSessionWorkflowStep(ctx context.Context, sessionID string, stepID string) error {
now := time.Now().UTC()
result, err := r.db.ExecContext(ctx, `
UPDATE task_sessions SET workflow_step_id = ?, updated_at = ? WHERE id = ?
`, stepID, now, sessionID)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("session not found: %s", sessionID)
}
return nil
}
// UpdateSessionReviewStatus updates the review status of a session
func (r *Repository) UpdateSessionReviewStatus(ctx context.Context, sessionID string, status string) error {
now := time.Now().UTC()
result, err := r.db.ExecContext(ctx, `
UPDATE task_sessions SET review_status = ?, updated_at = ? WHERE id = ?
`, status, now, sessionID)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("session not found: %s", sessionID)
}
return nil
}
@@ -33,9 +33,9 @@ func (r *Repository) CreateTask(ctx context.Context, task *models.Task) error {
}
_, err = tx.ExecContext(ctx, `
INSERT INTO tasks (id, workspace_id, board_id, column_id, title, description, state, priority, position, metadata, created_at, updated_at)
INSERT INTO tasks (id, workspace_id, board_id, workflow_step_id, title, description, state, priority, position, metadata, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, task.ID, task.WorkspaceID, task.BoardID, task.ColumnID, task.Title, task.Description, task.State, task.Priority, task.Position, string(metadata), task.CreatedAt, task.UpdatedAt)
`, task.ID, task.WorkspaceID, task.BoardID, task.WorkflowStepID, task.Title, task.Description, task.State, task.Priority, task.Position, string(metadata), task.CreatedAt, task.UpdatedAt)
if err != nil {
if rollbackErr := tx.Rollback(); rollbackErr != nil {
return fmt.Errorf("failed to rollback task insert: %w", rollbackErr)
@@ -51,9 +51,9 @@ func (r *Repository) GetTask(ctx context.Context, id string) (*models.Task, erro
task := &models.Task{}
var metadata string
err := r.db.QueryRowContext(ctx, `
SELECT id, workspace_id, board_id, column_id, title, description, state, priority, position, metadata, created_at, updated_at
SELECT id, workspace_id, board_id, workflow_step_id, title, description, state, priority, position, metadata, created_at, updated_at
FROM tasks WHERE id = ?
`, id).Scan(&task.ID, &task.WorkspaceID, &task.BoardID, &task.ColumnID, &task.Title, &task.Description, &task.State, &task.Priority, &task.Position, &metadata, &task.CreatedAt, &task.UpdatedAt)
`, id).Scan(&task.ID, &task.WorkspaceID, &task.BoardID, &task.WorkflowStepID, &task.Title, &task.Description, &task.State, &task.Priority, &task.Position, &metadata, &task.CreatedAt, &task.UpdatedAt)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("task not found: %s", id)
@@ -76,9 +76,9 @@ func (r *Repository) UpdateTask(ctx context.Context, task *models.Task) error {
}
result, err := r.db.ExecContext(ctx, `
UPDATE tasks SET workspace_id = ?, board_id = ?, column_id = ?, title = ?, description = ?, state = ?, priority = ?, position = ?, metadata = ?, updated_at = ?
UPDATE tasks SET workspace_id = ?, board_id = ?, workflow_step_id = ?, title = ?, description = ?, state = ?, priority = ?, position = ?, metadata = ?, updated_at = ?
WHERE id = ?
`, task.WorkspaceID, task.BoardID, task.ColumnID, task.Title, task.Description, task.State, task.Priority, task.Position, string(metadata), task.UpdatedAt, task.ID)
`, task.WorkspaceID, task.BoardID, task.WorkflowStepID, task.Title, task.Description, task.State, task.Priority, task.Position, string(metadata), task.UpdatedAt, task.ID)
if err != nil {
return err
}
@@ -107,7 +107,7 @@ func (r *Repository) DeleteTask(ctx context.Context, id string) error {
// ListTasks returns all tasks for a board
func (r *Repository) ListTasks(ctx context.Context, boardID string) ([]*models.Task, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, workspace_id, board_id, column_id, title, description, state, priority, position, metadata, created_at, updated_at
SELECT id, workspace_id, board_id, workflow_step_id, title, description, state, priority, position, metadata, created_at, updated_at
FROM tasks
WHERE board_id = ?
ORDER BY position
@@ -120,13 +120,13 @@ func (r *Repository) ListTasks(ctx context.Context, boardID string) ([]*models.T
return r.scanTasks(rows)
}
// ListTasksByColumn returns all tasks in a column
func (r *Repository) ListTasksByColumn(ctx context.Context, columnID string) ([]*models.Task, error) {
// ListTasksByWorkflowStep returns all tasks in a workflow step
func (r *Repository) ListTasksByWorkflowStep(ctx context.Context, workflowStepID string) ([]*models.Task, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, workspace_id, board_id, column_id, title, description, state, priority, position, metadata, created_at, updated_at
SELECT id, workspace_id, board_id, workflow_step_id, title, description, state, priority, position, metadata, created_at, updated_at
FROM tasks
WHERE column_id = ? ORDER BY position
`, columnID)
WHERE workflow_step_id = ? ORDER BY position
`, workflowStepID)
if err != nil {
return nil, err
}
@@ -145,7 +145,7 @@ func (r *Repository) scanTasks(rows *sql.Rows) ([]*models.Task, error) {
&task.ID,
&task.WorkspaceID,
&task.BoardID,
&task.ColumnID,
&task.WorkflowStepID,
&task.Title,
&task.Description,
&task.State,
@@ -88,30 +88,7 @@ func TestSQLiteRepository_SeedsDefaultWorkspace(t *testing.T) {
if boards[0].Name != "Dev" {
t.Errorf("expected Dev board, got %s", boards[0].Name)
}
columns, err := repo.ListColumns(ctx, boards[0].ID)
if err != nil {
t.Fatalf("failed to list columns: %v", err)
}
if len(columns) != 4 {
t.Fatalf("expected 4 columns, got %d", len(columns))
}
expected := map[string]v1.TaskState{
"Todo": v1.TaskStateTODO,
"In Progress": v1.TaskStateInProgress,
"Review": v1.TaskStateReview,
"Done": v1.TaskStateCompleted,
}
for _, column := range columns {
expectedState, ok := expected[column.Name]
if !ok {
t.Errorf("unexpected column name %s", column.Name)
continue
}
if column.State != expectedState {
t.Errorf("expected %s state %s, got %s", column.Name, expectedState, column.State)
}
}
// Note: workflow steps are now managed by the workflow repository, not the task repository
}
// Board CRUD tests
@@ -209,99 +186,6 @@ func TestSQLiteRepository_ListBoards(t *testing.T) {
}
}
// Column CRUD tests
func TestSQLiteRepository_ColumnCRUD(t *testing.T) {
repo, cleanup := createTestSQLiteRepo(t)
defer cleanup()
ctx := context.Background()
// First create a workspace and board for foreign key
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
board := &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Test Board"}
_ = repo.CreateBoard(ctx, board)
// Create column
column := &models.Column{BoardID: "board-123", Name: "To Do", Position: 0, State: v1.TaskStateTODO}
if err := repo.CreateColumn(ctx, column); err != nil {
t.Fatalf("failed to create column: %v", err)
}
if column.ID == "" {
t.Error("expected column ID to be set")
}
// Get
retrieved, err := repo.GetColumn(ctx, column.ID)
if err != nil {
t.Fatalf("failed to get column: %v", err)
}
if retrieved.Name != "To Do" {
t.Errorf("expected name 'To Do', got %s", retrieved.Name)
}
// Update
column.Name = "Done"
column.State = v1.TaskStateCompleted
if err := repo.UpdateColumn(ctx, column); err != nil {
t.Fatalf("failed to update column: %v", err)
}
retrieved, _ = repo.GetColumn(ctx, column.ID)
if retrieved.Name != "Done" {
t.Errorf("expected name 'Done', got %s", retrieved.Name)
}
// Delete
if err := repo.DeleteColumn(ctx, column.ID); err != nil {
t.Fatalf("failed to delete column: %v", err)
}
_, err = repo.GetColumn(ctx, column.ID)
if err == nil {
t.Error("expected column to be deleted")
}
}
func TestSQLiteRepository_ColumnNotFound(t *testing.T) {
repo, cleanup := createTestSQLiteRepo(t)
defer cleanup()
ctx := context.Background()
_, err := repo.GetColumn(ctx, "nonexistent")
if err == nil {
t.Error("expected error for nonexistent column")
}
err = repo.UpdateColumn(ctx, &models.Column{ID: "nonexistent", Name: "Test"})
if err == nil {
t.Error("expected error for updating nonexistent column")
}
err = repo.DeleteColumn(ctx, "nonexistent")
if err == nil {
t.Error("expected error for deleting nonexistent column")
}
}
func TestSQLiteRepository_ListColumns(t *testing.T) {
repo, cleanup := createTestSQLiteRepo(t)
defer cleanup()
ctx := context.Background()
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
board := &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Test Board"}
_ = repo.CreateBoard(ctx, board)
_ = repo.CreateColumn(ctx, &models.Column{ID: "col-1", BoardID: "board-123", Name: "To Do"})
_ = repo.CreateColumn(ctx, &models.Column{ID: "col-2", BoardID: "board-123", Name: "In Progress"})
columns, err := repo.ListColumns(ctx, "board-123")
if err != nil {
t.Fatalf("failed to list columns: %v", err)
}
if len(columns) != 2 {
t.Errorf("expected 2 columns, got %d", len(columns))
}
}
// Task CRUD tests
func TestSQLiteRepository_TaskCRUD(t *testing.T) {
@@ -309,23 +193,21 @@ func TestSQLiteRepository_TaskCRUD(t *testing.T) {
defer cleanup()
ctx := context.Background()
// Create workspace, board, and column for foreign keys
// Create workspace and board for foreign keys
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
board := &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Test Board"}
_ = repo.CreateBoard(ctx, board)
column := &models.Column{ID: "col-123", BoardID: "board-123", Name: "To Do"}
_ = repo.CreateColumn(ctx, column)
// Create task
// Create task (workflow steps are managed by workflow repository)
task := &models.Task{
WorkspaceID: "ws-1",
BoardID: "board-123",
ColumnID: "col-123",
Title: "Test Task",
Description: "A test task",
State: v1.TaskStateTODO,
Priority: 5,
Metadata: map[string]interface{}{"key": "value"},
WorkspaceID: "ws-1",
BoardID: "board-123",
WorkflowStepID: "step-123",
Title: "Test Task",
Description: "A test task",
State: v1.TaskStateTODO,
Priority: 5,
Metadata: map[string]interface{}{"key": "value"},
}
if err := repo.CreateTask(ctx, task); err != nil {
t.Fatalf("failed to create task: %v", err)
@@ -392,13 +274,11 @@ func TestSQLiteRepository_UpdateTaskState(t *testing.T) {
defer cleanup()
ctx := context.Background()
// Create workspace, board, column, and task
// Create workspace, board, and task
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
board := &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Test Board"}
_ = repo.CreateBoard(ctx, board)
column := &models.Column{ID: "col-123", BoardID: "board-123", Name: "To Do"}
_ = repo.CreateColumn(ctx, column)
task := &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-123", Title: "Test", State: v1.TaskStateTODO}
task := &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Test", State: v1.TaskStateTODO}
_ = repo.CreateTask(ctx, task)
err := repo.UpdateTaskState(ctx, "task-123", v1.TaskStateInProgress)
@@ -428,15 +308,13 @@ func TestSQLiteRepository_ListTasks(t *testing.T) {
defer cleanup()
ctx := context.Background()
// Create workspace, board, and column
// Create workspace and board
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
board := &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Test Board"}
_ = repo.CreateBoard(ctx, board)
column := &models.Column{ID: "col-123", BoardID: "board-123", Name: "To Do"}
_ = repo.CreateColumn(ctx, column)
_ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-123", Title: "Task 1"})
_ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-123", Title: "Task 2"})
_ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Task 1"})
_ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Task 2"})
tasks, err := repo.ListTasks(ctx, "board-123")
if err != nil {
@@ -447,30 +325,27 @@ func TestSQLiteRepository_ListTasks(t *testing.T) {
}
}
func TestSQLiteRepository_ListTasksByColumn(t *testing.T) {
func TestSQLiteRepository_ListTasksByWorkflowStep(t *testing.T) {
repo, cleanup := createTestSQLiteRepo(t)
defer cleanup()
ctx := context.Background()
// Create workspace, board, and columns
// Create workspace and board
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
board := &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Test Board"}
_ = repo.CreateBoard(ctx, board)
col1 := &models.Column{ID: "col-1", BoardID: "board-123", Name: "To Do"}
col2 := &models.Column{ID: "col-2", BoardID: "board-123", Name: "Done"}
_ = repo.CreateColumn(ctx, col1)
_ = repo.CreateColumn(ctx, col2)
_ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-1", Title: "Task 1"})
_ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-1", Title: "Task 2"})
_ = repo.CreateTask(ctx, &models.Task{ID: "task-3", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-2", Title: "Task 3"})
// Tasks with different workflow steps
_ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-1", Title: "Task 1"})
_ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-1", Title: "Task 2"})
_ = repo.CreateTask(ctx, &models.Task{ID: "task-3", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-2", Title: "Task 3"})
tasks, err := repo.ListTasksByColumn(ctx, "col-1")
tasks, err := repo.ListTasksByWorkflowStep(ctx, "step-1")
if err != nil {
t.Fatalf("failed to list tasks by column: %v", err)
t.Fatalf("failed to list tasks by workflow step: %v", err)
}
if len(tasks) != 2 {
t.Errorf("expected 2 tasks for col-1, got %d", len(tasks))
t.Errorf("expected 2 tasks for step-1, got %d", len(tasks))
}
}
@@ -484,7 +359,7 @@ func TestSQLiteRepository_Persistence(t *testing.T) {
if err != nil {
t.Fatalf("failed to open SQLite database: %v", err)
}
repo1, err := newSQLiteRepositoryWithDB(dbConn1)
repo1, err := sqlite.NewWithDB(dbConn1)
if err != nil {
t.Fatalf("failed to create first repository: %v", err)
}
@@ -504,7 +379,7 @@ func TestSQLiteRepository_Persistence(t *testing.T) {
if err != nil {
t.Fatalf("failed to open SQLite database: %v", err)
}
repo2, err := newSQLiteRepositoryWithDB(dbConn2)
repo2, err := sqlite.NewWithDB(dbConn2)
if err != nil {
t.Fatalf("failed to create second repository: %v", err)
}
@@ -558,14 +433,12 @@ func TestSQLiteRepository_MessageCRUD(t *testing.T) {
defer cleanup()
ctx := context.Background()
// Create board and column first (required for foreign key constraints)
// Create board first (required for foreign key constraints)
board := &models.Board{ID: "board-123", Name: "Test Board"}
_ = repo.CreateBoard(ctx, board)
column := &models.Column{ID: "col-123", BoardID: "board-123", Name: "Test Column"}
_ = repo.CreateColumn(ctx, column)
// Create a task first
task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task"}
task := &models.Task{ID: "task-123", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Test Task"}
_ = repo.CreateTask(ctx, task)
sessionID := setupSQLiteTestSession(ctx, repo, task.ID, "session-123")
turnID := setupSQLiteTestTurn(ctx, repo, sessionID, task.ID, "turn-123")
@@ -633,16 +506,14 @@ func TestSQLiteRepository_ListMessages(t *testing.T) {
defer cleanup()
ctx := context.Background()
// Create board and column first (required for foreign key constraints)
// Create board first (required for foreign key constraints)
board := &models.Board{ID: "board-123", Name: "Test Board"}
_ = repo.CreateBoard(ctx, board)
column := &models.Column{ID: "col-123", BoardID: "board-123", Name: "Test Column"}
_ = repo.CreateColumn(ctx, column)
// Create tasks
task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task"}
task := &models.Task{ID: "task-123", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Test Task"}
_ = repo.CreateTask(ctx, task)
task2 := &models.Task{ID: "task-456", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task 2"}
task2 := &models.Task{ID: "task-456", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Test Task 2"}
_ = repo.CreateTask(ctx, task2)
sessionID := setupSQLiteTestSession(ctx, repo, task.ID, "session-123")
session2ID := setupSQLiteTestSession(ctx, repo, task2.ID, "session-456")
@@ -670,9 +541,7 @@ func TestSQLiteRepository_ListMessagesPagination(t *testing.T) {
board := &models.Board{ID: "board-123", Name: "Test Board"}
_ = repo.CreateBoard(ctx, board)
column := &models.Column{ID: "col-123", BoardID: "board-123", Name: "Test Column"}
_ = repo.CreateColumn(ctx, column)
task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task"}
task := &models.Task{ID: "task-123", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Test Task"}
_ = repo.CreateTask(ctx, task)
sessionID := setupSQLiteTestSession(ctx, repo, task.ID, "session-123")
turnID := setupSQLiteTestTurn(ctx, repo, sessionID, task.ID, "turn-123")
@@ -729,14 +598,12 @@ func TestSQLiteRepository_MessageWithRequestsInput(t *testing.T) {
defer cleanup()
ctx := context.Background()
// Create board and column first (required for foreign key constraints)
// Create board first (required for foreign key constraints)
board := &models.Board{ID: "board-123", Name: "Test Board"}
_ = repo.CreateBoard(ctx, board)
column := &models.Column{ID: "col-123", BoardID: "board-123", Name: "Test Column"}
_ = repo.CreateColumn(ctx, column)
// Create a task
task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task"}
task := &models.Task{ID: "task-123", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Test Task"}
_ = repo.CreateTask(ctx, task)
sessionID := setupSQLiteTestSession(ctx, repo, task.ID, "session-123")
turnID := setupSQLiteTestTurn(ctx, repo, sessionID, task.ID, "turn-123")
@@ -768,12 +635,10 @@ func TestSQLiteRepository_TaskSessionCRUD(t *testing.T) {
defer cleanup()
ctx := context.Background()
// Create board, column, and task first (required for foreign key constraints)
// Create board and task first (required for foreign key constraints)
board := &models.Board{ID: "board-123", Name: "Test Board"}
_ = repo.CreateBoard(ctx, board)
column := &models.Column{ID: "col-123", BoardID: "board-123", Name: "Test Column"}
_ = repo.CreateColumn(ctx, column)
task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task"}
task := &models.Task{ID: "task-123", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Test Task"}
_ = repo.CreateTask(ctx, task)
// Create agent session
@@ -864,12 +729,10 @@ func TestSQLiteRepository_TaskSessionByTaskID(t *testing.T) {
defer cleanup()
ctx := context.Background()
// Create board, column, and task
// Create board and task
board := &models.Board{ID: "board-123", Name: "Test Board"}
_ = repo.CreateBoard(ctx, board)
column := &models.Column{ID: "col-123", BoardID: "board-123", Name: "Test Column"}
_ = repo.CreateColumn(ctx, column)
task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task"}
task := &models.Task{ID: "task-123", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Test Task"}
_ = repo.CreateTask(ctx, task)
// Create multiple sessions for the same task (simulating session history)
@@ -931,14 +794,12 @@ func TestSQLiteRepository_ListTaskSessions(t *testing.T) {
defer cleanup()
ctx := context.Background()
// Create board, column, and tasks
// Create board and tasks
board := &models.Board{ID: "board-123", Name: "Test Board"}
_ = repo.CreateBoard(ctx, board)
column := &models.Column{ID: "col-123", BoardID: "board-123", Name: "Test Column"}
_ = repo.CreateColumn(ctx, column)
task1 := &models.Task{ID: "task-1", BoardID: "board-123", ColumnID: "col-123", Title: "Task 1"}
task1 := &models.Task{ID: "task-1", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Task 1"}
_ = repo.CreateTask(ctx, task1)
task2 := &models.Task{ID: "task-2", BoardID: "board-123", ColumnID: "col-123", Title: "Task 2"}
task2 := &models.Task{ID: "task-2", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Task 2"}
_ = repo.CreateTask(ctx, task2)
// Create sessions for different tasks
@@ -977,12 +838,10 @@ func TestSQLiteRepository_UpdateTaskSessionState(t *testing.T) {
defer cleanup()
ctx := context.Background()
// Create board, column, and task
// Create board and task
board := &models.Board{ID: "board-123", Name: "Test Board"}
_ = repo.CreateBoard(ctx, board)
column := &models.Column{ID: "col-123", BoardID: "board-123", Name: "Test Column"}
_ = repo.CreateColumn(ctx, column)
task := &models.Task{ID: "task-123", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task"}
task := &models.Task{ID: "task-123", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Test Task"}
_ = repo.CreateTask(ctx, task)
// Create an agent session
+272 -223
View File
@@ -48,6 +48,35 @@ type TaskExecutionStopper interface {
StopTask(ctx context.Context, taskID, reason string, force bool) error
}
// WorkflowStepCreator creates workflow steps from a template for a board.
type WorkflowStepCreator interface {
CreateStepsFromTemplate(ctx context.Context, boardID, templateID string) error
}
// WorkflowStepGetter retrieves workflow step information.
type WorkflowStepGetter interface {
GetStep(ctx context.Context, stepID string) (*WorkflowStep, error)
}
// WorkflowStep represents a workflow step (mirrors workflow/models.WorkflowStep).
// Defined here to avoid circular dependency with workflow package.
type WorkflowStep struct {
ID string
BoardID string
Name string
StepType string
Position int
Color string
AutoStartAgent bool
PlanMode bool
RequireApproval bool
PromptPrefix string
PromptSuffix string
AllowManualMove bool
OnCompleteStepID *string
OnApprovalStepID *string
}
var (
ErrActiveTaskSessions = errors.New("active agent sessions exist")
ErrInvalidRepositorySettings = errors.New("invalid repository settings")
@@ -74,12 +103,14 @@ func validateExecutorConfig(config map[string]string) error {
// Service provides task business logic
type Service struct {
repo repository.Repository
eventBus bus.EventBus
logger *logger.Logger
discoveryConfig RepositoryDiscoveryConfig
worktreeCleanup WorktreeCleanup
executionStopper TaskExecutionStopper
repo repository.Repository
eventBus bus.EventBus
logger *logger.Logger
discoveryConfig RepositoryDiscoveryConfig
worktreeCleanup WorktreeCleanup
executionStopper TaskExecutionStopper
workflowStepCreator WorkflowStepCreator
workflowStepGetter WorkflowStepGetter
}
// NewService creates a new task service
@@ -102,6 +133,16 @@ func (s *Service) SetExecutionStopper(stopper TaskExecutionStopper) {
s.executionStopper = stopper
}
// SetWorkflowStepCreator wires the workflow step creator for board creation.
func (s *Service) SetWorkflowStepCreator(creator WorkflowStepCreator) {
s.workflowStepCreator = creator
}
// SetWorkflowStepGetter wires the workflow step getter for MoveTask.
func (s *Service) SetWorkflowStepGetter(getter WorkflowStepGetter) {
s.workflowStepGetter = getter
}
// Request types
// TaskRepositoryInput for creating/updating task repositories
@@ -115,16 +156,16 @@ type TaskRepositoryInput struct {
// CreateTaskRequest contains the data for creating a new task
type CreateTaskRequest struct {
WorkspaceID string `json:"workspace_id"`
BoardID string `json:"board_id"`
ColumnID string `json:"column_id"`
Title string `json:"title"`
Description string `json:"description"`
Priority int `json:"priority"`
State *v1.TaskState `json:"state,omitempty"`
Repositories []TaskRepositoryInput `json:"repositories,omitempty"`
Position int `json:"position"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
WorkspaceID string `json:"workspace_id"`
BoardID string `json:"board_id"`
WorkflowStepID string `json:"workflow_step_id"`
Title string `json:"title"`
Description string `json:"description"`
Priority int `json:"priority"`
State *v1.TaskState `json:"state,omitempty"`
Repositories []TaskRepositoryInput `json:"repositories,omitempty"`
Position int `json:"position"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
// UpdateTaskRequest contains the data for updating a task
@@ -140,9 +181,10 @@ type UpdateTaskRequest struct {
// CreateBoardRequest contains the data for creating a new board
type CreateBoardRequest struct {
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
Description string `json:"description"`
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
Description string `json:"description"`
WorkflowTemplateID *string `json:"workflow_template_id,omitempty"`
}
// UpdateBoardRequest contains the data for updating a board
@@ -170,23 +212,6 @@ type UpdateWorkspaceRequest struct {
DefaultAgentProfileID *string `json:"default_agent_profile_id,omitempty"`
}
// CreateColumnRequest contains the data for creating a new column
type CreateColumnRequest struct {
BoardID string `json:"board_id"`
Name string `json:"name"`
Position int `json:"position"`
State v1.TaskState `json:"state"`
Color string `json:"color"`
}
// UpdateColumnRequest contains the data for updating a column
type UpdateColumnRequest struct {
Name *string `json:"name,omitempty"`
Position *int `json:"position,omitempty"`
State *v1.TaskState `json:"state,omitempty"`
Color *string `json:"color,omitempty"`
}
// CreateRepositoryRequest contains the data for creating a new repository
type CreateRepositoryRequest struct {
WorkspaceID string `json:"workspace_id"`
@@ -293,16 +318,16 @@ func (s *Service) CreateTask(ctx context.Context, req *CreateTaskRequest) (*mode
state = *req.State
}
task := &models.Task{
ID: uuid.New().String(),
WorkspaceID: req.WorkspaceID,
BoardID: req.BoardID,
ColumnID: req.ColumnID,
Title: req.Title,
Description: req.Description,
State: state,
Priority: req.Priority,
Position: req.Position,
Metadata: req.Metadata,
ID: uuid.New().String(),
WorkspaceID: req.WorkspaceID,
BoardID: req.BoardID,
WorkflowStepID: req.WorkflowStepID,
Title: req.Title,
Description: req.Description,
State: state,
Priority: req.Priority,
Position: req.Position,
Metadata: req.Metadata,
}
if err := s.repo.CreateTask(ctx, task); err != nil {
@@ -638,6 +663,133 @@ func (s *Service) GetTaskSession(ctx context.Context, sessionID string) (*models
return s.repo.GetTaskSession(ctx, sessionID)
}
// GetPrimarySession returns the primary session for a task.
func (s *Service) GetPrimarySession(ctx context.Context, taskID string) (*models.TaskSession, error) {
return s.repo.GetPrimarySessionByTaskID(ctx, taskID)
}
// GetPrimarySessionIDsForTasks returns a map of task ID to primary session ID for the given task IDs.
// Tasks without a primary session are not included in the result.
func (s *Service) GetPrimarySessionIDsForTasks(ctx context.Context, taskIDs []string) (map[string]string, error) {
return s.repo.GetPrimarySessionIDsByTaskIDs(ctx, taskIDs)
}
// SetPrimarySession sets a session as the primary session for its task.
// This will unset any existing primary session for the same task.
func (s *Service) SetPrimarySession(ctx context.Context, sessionID string) error {
if err := s.repo.SetSessionPrimary(ctx, sessionID); err != nil {
s.logger.Error("failed to set primary session",
zap.String("session_id", sessionID),
zap.Error(err))
return err
}
return nil
}
// MoveSessionToStep moves a session to a different workflow step.
func (s *Service) MoveSessionToStep(ctx context.Context, sessionID string, stepID string) error {
if err := s.repo.UpdateSessionWorkflowStep(ctx, sessionID, stepID); err != nil {
s.logger.Error("failed to move session to step",
zap.String("session_id", sessionID),
zap.String("step_id", stepID),
zap.Error(err))
return err
}
return nil
}
// UpdateSessionReviewStatus updates the review status of a session.
func (s *Service) UpdateSessionReviewStatus(ctx context.Context, sessionID string, status string) error {
if err := s.repo.UpdateSessionReviewStatus(ctx, sessionID, status); err != nil {
s.logger.Error("failed to update session review status",
zap.String("session_id", sessionID),
zap.String("status", status),
zap.Error(err))
return err
}
return nil
}
// ApproveSessionResult contains the result of approving a session
type ApproveSessionResult struct {
Session *models.TaskSession
Task *models.Task
WorkflowStep *WorkflowStep
}
// ApproveSession approves a session's current step and moves it to the on_approval_step_id
func (s *Service) ApproveSession(ctx context.Context, sessionID string) (*ApproveSessionResult, error) {
// Update review status to approved
if err := s.repo.UpdateSessionReviewStatus(ctx, sessionID, "approved"); err != nil {
return nil, fmt.Errorf("failed to update review status: %w", err)
}
result := &ApproveSessionResult{}
// Reload session to get updated review status
session, err := s.repo.GetTaskSession(ctx, sessionID)
if err != nil {
return nil, fmt.Errorf("failed to reload session: %w", err)
}
result.Session = session
// Get the current workflow step to check for on_approval_step_id
if session.WorkflowStepID != nil && s.workflowStepGetter != nil {
step, err := s.workflowStepGetter.GetStep(ctx, *session.WorkflowStepID)
if err != nil {
s.logger.Warn("failed to get workflow step for approval transition",
zap.String("workflow_step_id", *session.WorkflowStepID),
zap.Error(err))
} else if step.OnApprovalStepID != nil && *step.OnApprovalStepID != "" {
// Move session and task to the on_approval_step_id
newStepID := *step.OnApprovalStepID
if err := s.repo.UpdateSessionWorkflowStep(ctx, sessionID, newStepID); err != nil {
s.logger.Error("failed to move session to approval step",
zap.String("session_id", sessionID),
zap.String("step_id", newStepID),
zap.Error(err))
} else {
// Also move the task to the new step
task, err := s.repo.GetTask(ctx, session.TaskID)
if err != nil {
s.logger.Error("failed to get task for approval transition",
zap.String("task_id", session.TaskID),
zap.Error(err))
} else {
task.WorkflowStepID = newStepID
task.UpdatedAt = time.Now().UTC()
if err := s.repo.UpdateTask(ctx, task); err != nil {
s.logger.Error("failed to move task to approval step",
zap.String("task_id", session.TaskID),
zap.String("step_id", newStepID),
zap.Error(err))
} else {
s.publishTaskEvent(ctx, events.TaskUpdated, task, nil)
result.Task = task
}
}
// Reload session with new step
session, _ = s.repo.GetTaskSession(ctx, sessionID)
result.Session = session
// Get the new workflow step for the response
newStep, err := s.workflowStepGetter.GetStep(ctx, newStepID)
if err == nil {
result.WorkflowStep = newStep
}
s.logger.Info("session approved and moved to next step",
zap.String("session_id", sessionID),
zap.String("from_step", *session.WorkflowStepID),
zap.String("to_step", newStepID))
}
}
}
return result, nil
}
// UpdateTaskState updates the state of a task, moves it to the matching column,
// and publishes a task.state_changed event
func (s *Service) UpdateTaskState(ctx context.Context, id string, state v1.TaskState) (*models.Task, error) {
@@ -653,41 +805,15 @@ func (s *Service) UpdateTaskState(ctx context.Context, id string, state v1.TaskS
return nil, err
}
// Find the column that maps to the new state and move the task there
// This ensures the Kanban board stays in sync with task state
if task.BoardID != "" {
column, err := s.repo.GetColumnByState(ctx, task.BoardID, state)
if err != nil {
s.logger.Warn("no column found for state, task will stay in current column",
zap.String("task_id", id),
zap.String("state", string(state)),
zap.Error(err))
} else if column.ID != task.ColumnID {
// Move task to the new column (keep it at position 0 - top of the column)
if err := s.repo.AddTaskToBoard(ctx, id, task.BoardID, column.ID, 0); err != nil {
s.logger.Error("failed to move task to new column",
zap.String("task_id", id),
zap.String("column_id", column.ID),
zap.Error(err))
} else {
s.logger.Info("task moved to column matching new state",
zap.String("task_id", id),
zap.String("column_id", column.ID),
zap.String("column_name", column.Name),
zap.String("state", string(state)))
}
}
}
// Reload task to get updated state and column
// Reload task to get updated state
task, err = s.repo.GetTask(ctx, id)
if err != nil {
return nil, err
}
s.logger.Info("task reloaded after column move",
s.logger.Info("task state updated",
zap.String("task_id", id),
zap.String("column_id", task.ColumnID),
zap.String("workflow_step_id", task.WorkflowStepID),
zap.String("state", string(task.State)))
s.publishTaskEvent(ctx, events.TaskStateChanged, task, &oldState)
@@ -724,24 +850,40 @@ func (s *Service) UpdateTaskMetadata(ctx context.Context, id string, metadata ma
return task, nil
}
// MoveTask moves a task to a different column and position
func (s *Service) MoveTask(ctx context.Context, id string, boardID string, columnID string, position int) (*models.Task, error) {
// MoveTaskResult contains the result of a MoveTask operation.
type MoveTaskResult struct {
Task *models.Task
WorkflowStep *WorkflowStep
}
// MoveTask moves a task to a different workflow step and position
func (s *Service) MoveTask(ctx context.Context, id string, boardID string, workflowStepID string, position int) (*MoveTaskResult, error) {
task, err := s.repo.GetTask(ctx, id)
if err != nil {
return nil, err
}
// Get the column to determine the new state
column, err := s.repo.GetColumn(ctx, columnID)
if err != nil {
return nil, err
// Check if task's primary session is in a review step with pending approval
// If so, prevent moving forward - user must use Approve button or send a message
if task.WorkflowStepID != workflowStepID {
primarySession, err := s.repo.GetPrimarySessionByTaskID(ctx, id)
if err == nil && primarySession != nil {
if primarySession.ReviewStatus != nil && *primarySession.ReviewStatus == "pending" {
// Check if current step requires approval
if s.workflowStepGetter != nil && primarySession.WorkflowStepID != nil {
currentStep, err := s.workflowStepGetter.GetStep(ctx, *primarySession.WorkflowStepID)
if err == nil && currentStep.RequireApproval {
return nil, fmt.Errorf("task is pending approval - use Approve button to proceed or send a message to request changes")
}
}
}
}
}
oldState := task.State
task.BoardID = boardID
task.ColumnID = columnID
task.WorkflowStepID = workflowStepID
task.Position = position
task.State = column.State
task.UpdatedAt = time.Now().UTC()
if err := s.repo.UpdateTask(ctx, task); err != nil {
@@ -759,10 +901,25 @@ func (s *Service) MoveTask(ctx context.Context, id string, boardID string, colum
s.logger.Info("task moved",
zap.String("task_id", id),
zap.String("board_id", boardID),
zap.String("column_id", columnID),
zap.String("workflow_step_id", workflowStepID),
zap.Int("position", position))
return task, nil
result := &MoveTaskResult{Task: task}
// Fetch the workflow step info if getter is available
if s.workflowStepGetter != nil {
step, err := s.workflowStepGetter.GetStep(ctx, workflowStepID)
if err != nil {
s.logger.Warn("failed to get workflow step for MoveTask response",
zap.String("workflow_step_id", workflowStepID),
zap.Error(err))
// Don't fail the operation, just log and continue
} else {
result.WorkflowStep = step
}
}
return result, nil
}
// Workspace operations
@@ -864,10 +1021,11 @@ func (s *Service) ListWorkspaces(ctx context.Context) ([]*models.Workspace, erro
// CreateBoard creates a new board
func (s *Service) CreateBoard(ctx context.Context, req *CreateBoardRequest) (*models.Board, error) {
board := &models.Board{
ID: uuid.New().String(),
WorkspaceID: req.WorkspaceID,
Name: req.Name,
Description: req.Description,
ID: uuid.New().String(),
WorkspaceID: req.WorkspaceID,
Name: req.Name,
Description: req.Description,
WorkflowTemplateID: req.WorkflowTemplateID,
}
if err := s.repo.CreateBoard(ctx, board); err != nil {
@@ -875,6 +1033,17 @@ func (s *Service) CreateBoard(ctx context.Context, req *CreateBoardRequest) (*mo
return nil, err
}
// Create workflow steps from template if specified
if req.WorkflowTemplateID != nil && *req.WorkflowTemplateID != "" && s.workflowStepCreator != nil {
if err := s.workflowStepCreator.CreateStepsFromTemplate(ctx, board.ID, *req.WorkflowTemplateID); err != nil {
s.logger.Error("failed to create workflow steps from template",
zap.String("board_id", board.ID),
zap.String("template_id", *req.WorkflowTemplateID),
zap.Error(err))
// Don't fail board creation, just log the error
}
}
s.publishBoardEvent(ctx, events.BoardCreated, board)
s.logger.Info("board created", zap.String("board_id", board.ID), zap.String("name", board.Name))
return board, nil
@@ -931,95 +1100,6 @@ func (s *Service) ListBoards(ctx context.Context, workspaceID string) ([]*models
return s.repo.ListBoards(ctx, workspaceID)
}
// Column operations
// CreateColumn creates a new column
func (s *Service) CreateColumn(ctx context.Context, req *CreateColumnRequest) (*models.Column, error) {
color := req.Color
if color == "" {
color = "bg-neutral-400"
}
column := &models.Column{
ID: uuid.New().String(),
BoardID: req.BoardID,
Name: req.Name,
Position: req.Position,
State: req.State,
Color: color,
}
if err := s.repo.CreateColumn(ctx, column); err != nil {
s.logger.Error("failed to create column", zap.Error(err))
return nil, err
}
s.publishColumnEvent(ctx, events.ColumnCreated, column)
s.logger.Info("column created",
zap.String("column_id", column.ID),
zap.String("board_id", column.BoardID),
zap.String("name", column.Name))
return column, nil
}
// GetColumn retrieves a column by ID
func (s *Service) GetColumn(ctx context.Context, id string) (*models.Column, error) {
return s.repo.GetColumn(ctx, id)
}
// ListColumns returns all columns for a board
func (s *Service) ListColumns(ctx context.Context, boardID string) ([]*models.Column, error) {
return s.repo.ListColumns(ctx, boardID)
}
// UpdateColumn updates an existing column
func (s *Service) UpdateColumn(ctx context.Context, id string, req *UpdateColumnRequest) (*models.Column, error) {
column, err := s.repo.GetColumn(ctx, id)
if err != nil {
return nil, err
}
if req.Name != nil {
column.Name = *req.Name
}
if req.Position != nil {
column.Position = *req.Position
}
if req.State != nil {
column.State = *req.State
}
if req.Color != nil {
column.Color = *req.Color
}
column.UpdatedAt = time.Now().UTC()
if err := s.repo.UpdateColumn(ctx, column); err != nil {
s.logger.Error("failed to update column", zap.Error(err))
return nil, err
}
s.publishColumnEvent(ctx, events.ColumnUpdated, column)
s.logger.Info("column updated",
zap.String("column_id", column.ID),
zap.String("board_id", column.BoardID),
zap.String("name", column.Name))
return column, nil
}
// DeleteColumn deletes an existing column
func (s *Service) DeleteColumn(ctx context.Context, id string) error {
column, err := s.repo.GetColumn(ctx, id)
if err != nil {
return err
}
if err := s.repo.DeleteColumn(ctx, id); err != nil {
s.logger.Error("failed to delete column", zap.Error(err))
return err
}
s.publishColumnEvent(ctx, events.ColumnDeleted, column)
s.logger.Info("column deleted", zap.String("column_id", id))
return nil
}
// Repository operations
func (s *Service) CreateRepository(ctx context.Context, req *CreateRepositoryRequest) (*models.Repository, error) {
@@ -1417,16 +1497,16 @@ func (s *Service) publishTaskEvent(ctx context.Context, eventType string, task *
}
data := map[string]interface{}{
"task_id": task.ID,
"board_id": task.BoardID,
"column_id": task.ColumnID,
"title": task.Title,
"description": task.Description,
"state": string(task.State),
"priority": task.Priority,
"position": task.Position,
"created_at": task.CreatedAt.Format(time.RFC3339),
"updated_at": task.UpdatedAt.Format(time.RFC3339),
"task_id": task.ID,
"board_id": task.BoardID,
"workflow_step_id": task.WorkflowStepID,
"title": task.Title,
"description": task.Description,
"state": string(task.State),
"priority": task.Priority,
"position": task.Position,
"created_at": task.CreatedAt.Format(time.RFC3339),
"updated_at": task.UpdatedAt.Format(time.RFC3339),
}
if task.Metadata != nil {
@@ -1440,14 +1520,6 @@ func (s *Service) publishTaskEvent(ctx context.Context, eventType string, task *
event := bus.NewEvent(eventType, "task-service", data)
// Debug logging for state changes
s.logger.Info("publishing task event",
zap.String("event_type", eventType),
zap.String("task_id", task.ID),
zap.String("board_id", task.BoardID),
zap.String("column_id", task.ColumnID),
zap.String("state", string(task.State)))
if err := s.eventBus.Publish(ctx, eventType, event); err != nil {
s.logger.Error("failed to publish task event",
zap.String("event_type", eventType),
@@ -1505,31 +1577,6 @@ func (s *Service) publishBoardEvent(ctx context.Context, eventType string, board
}
}
func (s *Service) publishColumnEvent(ctx context.Context, eventType string, column *models.Column) {
if s.eventBus == nil || column == nil {
return
}
data := map[string]interface{}{
"id": column.ID,
"board_id": column.BoardID,
"name": column.Name,
"position": column.Position,
"state": string(column.State),
"color": column.Color,
"created_at": column.CreatedAt.Format(time.RFC3339),
"updated_at": column.UpdatedAt.Format(time.RFC3339),
}
event := bus.NewEvent(eventType, "task-service", data)
if err := s.eventBus.Publish(ctx, eventType, event); err != nil {
s.logger.Error("failed to publish column event",
zap.String("event_type", eventType),
zap.String("column_id", column.ID),
zap.Error(err))
}
}
func (s *Service) publishExecutorEvent(ctx context.Context, eventType string, executor *models.Executor) {
if s.eventBus == nil || executor == nil {
return
@@ -2064,7 +2111,9 @@ func (s *Service) publishTurnEvent(eventType string, turn *models.Turn) {
}))
}
// publishMessageEvent publishes message events to the event bus
// publishMessageEvent publishes message events to the event bus.
// System-injected content (wrapped in <kandev-system> tags) is stripped from the content
// so users don't see workflow step prompt modifications in the UI.
func (s *Service) publishMessageEvent(ctx context.Context, eventType string, message *models.Message) {
if s.eventBus == nil {
s.logger.Warn("publishMessageEvent: eventBus is nil, skipping")
@@ -2082,7 +2131,7 @@ func (s *Service) publishMessageEvent(ctx context.Context, eventType string, mes
"task_id": message.TaskID,
"author_type": string(message.AuthorType),
"author_id": message.AuthorID,
"content": message.Content,
"content": models.StripSystemContent(message.Content),
"type": messageType,
"requests_input": message.RequestsInput,
"created_at": message.CreatedAt.Format(time.RFC3339),
@@ -105,19 +105,17 @@ func TestService_CreateTask(t *testing.T) {
svc, eventBus, repo := createTestService(t)
ctx := context.Background()
// Create board and column first
// Create board first
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
board := &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Test Board"}
_ = repo.CreateBoard(ctx, board)
column := &models.Column{ID: "col-123", BoardID: "board-123", Name: "To Do", State: v1.TaskStateTODO}
_ = repo.CreateColumn(ctx, column)
repository := &models.Repository{ID: "repo-123", WorkspaceID: "ws-1", Name: "Test Repo"}
_ = repo.CreateRepository(ctx, repository)
req := &CreateTaskRequest{
WorkspaceID: "ws-1",
BoardID: "board-123",
ColumnID: "col-123",
WorkspaceID: "ws-1",
BoardID: "board-123",
WorkflowStepID: "step-123",
Title: "Test Task",
Description: "A test task",
Priority: 5,
@@ -246,8 +244,7 @@ func TestService_UpdateTask(t *testing.T) {
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Board"})
_ = repo.CreateColumn(ctx, &models.Column{ID: "col-123", BoardID: "board-123", Name: "Column", State: v1.TaskStateTODO})
_ = repo.CreateTask(ctx, &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-123", Title: "Original"})
_ = repo.CreateTask(ctx, &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Original"})
eventBus.ClearEvents()
newTitle := "Updated Title"
@@ -277,8 +274,7 @@ func TestService_DeleteTask(t *testing.T) {
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Board"})
_ = repo.CreateColumn(ctx, &models.Column{ID: "col-123", BoardID: "board-123", Name: "Column", State: v1.TaskStateTODO})
_ = repo.CreateTask(ctx, &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-123", Title: "Test"})
_ = repo.CreateTask(ctx, &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Test"})
eventBus.ClearEvents()
err := svc.DeleteTask(ctx, "task-123")
@@ -305,9 +301,8 @@ func TestService_ListTasks(t *testing.T) {
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Board"})
_ = repo.CreateColumn(ctx, &models.Column{ID: "col-123", BoardID: "board-123", Name: "Column", State: v1.TaskStateTODO})
_ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-123", Title: "Task 1"})
_ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-123", Title: "Task 2"})
_ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Task 1"})
_ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Task 2"})
tasks, err := svc.ListTasks(ctx, "board-123")
if err != nil {
@@ -324,8 +319,7 @@ func TestService_UpdateTaskState(t *testing.T) {
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Board"})
_ = repo.CreateColumn(ctx, &models.Column{ID: "col-123", BoardID: "board-123", Name: "Column", State: v1.TaskStateTODO})
task := &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-123", Title: "Test", State: v1.TaskStateTODO}
task := &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Test", State: v1.TaskStateTODO}
_ = repo.CreateTask(ctx, task)
eventBus.ClearEvents()
@@ -354,24 +348,16 @@ func TestService_MoveTask(t *testing.T) {
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Board"})
// Create source column
_ = repo.CreateColumn(ctx, &models.Column{ID: "col-123", BoardID: "board-123", Name: "Todo", State: v1.TaskStateTODO})
// Create destination column with different state
_ = repo.CreateColumn(ctx, &models.Column{ID: "col-done", BoardID: "board-123", Name: "Done", State: v1.TaskStateCompleted})
task := &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-123", Title: "Test", State: v1.TaskStateTODO}
task := &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-todo", Title: "Test", State: v1.TaskStateTODO}
_ = repo.CreateTask(ctx, task)
eventBus.ClearEvents()
moved, err := svc.MoveTask(ctx, "task-123", "board-123", "col-done", 0)
moved, err := svc.MoveTask(ctx, "task-123", "board-123", "step-done", 0)
if err != nil {
t.Fatalf("failed to move task: %v", err)
}
if moved.ColumnID != "col-done" {
t.Errorf("expected column 'col-done', got %s", moved.ColumnID)
}
if moved.State != v1.TaskStateCompleted {
t.Errorf("expected state COMPLETED, got %s", moved.State)
if moved.Task.WorkflowStepID != "step-done" {
t.Errorf("expected workflow step 'step-done', got %s", moved.Task.WorkflowStepID)
}
}
@@ -472,70 +458,7 @@ func TestService_ListBoards(t *testing.T) {
}
}
// Column tests
func TestService_CreateColumn(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
board := &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Test Board"}
_ = repo.CreateBoard(ctx, board)
req := &CreateColumnRequest{
BoardID: "board-123",
Name: "To Do",
Position: 0,
State: v1.TaskStateTODO,
}
column, err := svc.CreateColumn(ctx, req)
if err != nil {
t.Fatalf("failed to create column: %v", err)
}
if column.ID == "" {
t.Error("expected column ID to be set")
}
if column.Name != "To Do" {
t.Errorf("expected name 'To Do', got %s", column.Name)
}
}
func TestService_GetColumn(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Board"})
column := &models.Column{ID: "col-123", BoardID: "board-123", Name: "To Do"}
_ = repo.CreateColumn(ctx, column)
retrieved, err := svc.GetColumn(ctx, "col-123")
if err != nil {
t.Fatalf("failed to get column: %v", err)
}
if retrieved.Name != "To Do" {
t.Errorf("expected name 'To Do', got %s", retrieved.Name)
}
}
func TestService_ListColumns(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Board"})
_ = repo.CreateColumn(ctx, &models.Column{ID: "col-1", BoardID: "board-123", Name: "To Do"})
_ = repo.CreateColumn(ctx, &models.Column{ID: "col-2", BoardID: "board-123", Name: "Done"})
columns, err := svc.ListColumns(ctx, "board-123")
if err != nil {
t.Fatalf("failed to list columns: %v", err)
}
if len(columns) != 2 {
t.Errorf("expected 2 columns, got %d", len(columns))
}
}
// Message tests
@@ -544,8 +467,7 @@ func setupTestTask(t *testing.T, repo repository.Repository) {
ctx := context.Background()
_ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"})
_ = repo.CreateBoard(ctx, &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Board"})
_ = repo.CreateColumn(ctx, &models.Column{ID: "col-123", BoardID: "board-123", Name: "Column", State: v1.TaskStateTODO})
_ = repo.CreateTask(ctx, &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", ColumnID: "col-123", Title: "Test Task"})
_ = repo.CreateTask(ctx, &models.Task{ID: "task-123", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-123", Title: "Test Task"})
}
func setupTestSession(t *testing.T, repo repository.Repository) string {
+2 -34
View File
@@ -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, &notNull, &defaultValue, &pk); err != nil {
return false, err
}
if name == column {
return true, nil
}
}
return false, rows.Err()
return sqlite.EnsureColumn(r.db, "users", "settings", "TEXT NOT NULL DEFAULT '{}'")
}
func (r *sqliteRepository) ensureDefaultUser() error {
@@ -0,0 +1,211 @@
package controller
import (
"context"
"github.com/kandev/kandev/internal/workflow/models"
"github.com/kandev/kandev/internal/workflow/service"
)
// Controller handles workflow-related requests
type Controller struct {
svc *service.Service
}
// NewController creates a new workflow controller
func NewController(svc *service.Service) *Controller {
return &Controller{svc: svc}
}
// Template responses
type ListTemplatesResponse struct {
Templates []*models.WorkflowTemplate `json:"templates"`
}
type GetTemplateResponse struct {
Template *models.WorkflowTemplate `json:"template"`
}
func (c *Controller) ListTemplates(ctx context.Context) (*ListTemplatesResponse, error) {
templates, err := c.svc.ListTemplates(ctx)
if err != nil {
return nil, err
}
return &ListTemplatesResponse{Templates: templates}, nil
}
func (c *Controller) GetTemplate(ctx context.Context, id string) (*GetTemplateResponse, error) {
template, err := c.svc.GetTemplate(ctx, id)
if err != nil {
return nil, err
}
return &GetTemplateResponse{Template: template}, nil
}
// Step responses
type ListStepsRequest struct {
BoardID string `json:"board_id"`
}
type ListStepsResponse struct {
Steps []*models.WorkflowStep `json:"steps"`
}
type GetStepResponse struct {
Step *models.WorkflowStep `json:"step"`
}
type CreateStepsFromTemplateRequest struct {
BoardID string `json:"board_id"`
TemplateID string `json:"template_id"`
}
func (c *Controller) ListStepsByBoard(ctx context.Context, req ListStepsRequest) (*ListStepsResponse, error) {
steps, err := c.svc.ListStepsByBoard(ctx, req.BoardID)
if err != nil {
return nil, err
}
return &ListStepsResponse{Steps: steps}, nil
}
func (c *Controller) GetStep(ctx context.Context, id string) (*GetStepResponse, error) {
step, err := c.svc.GetStep(ctx, id)
if err != nil {
return nil, err
}
return &GetStepResponse{Step: step}, nil
}
func (c *Controller) CreateStepsFromTemplate(ctx context.Context, req CreateStepsFromTemplateRequest) error {
return c.svc.CreateStepsFromTemplate(ctx, req.BoardID, req.TemplateID)
}
// CreateStepRequest is the request for creating a single workflow step.
type CreateStepRequest struct {
BoardID string `json:"board_id"`
Name string `json:"name"`
StepType string `json:"step_type"`
Position int `json:"position"`
Color string `json:"color"`
AutoStartAgent bool `json:"auto_start_agent"`
PlanMode bool `json:"plan_mode"`
RequireApproval bool `json:"require_approval"`
PromptPrefix string `json:"prompt_prefix"`
PromptSuffix string `json:"prompt_suffix"`
AllowManualMove bool `json:"allow_manual_move"`
}
// CreateStep creates a new workflow step.
func (c *Controller) CreateStep(ctx context.Context, req CreateStepRequest) (*GetStepResponse, error) {
step := &models.WorkflowStep{
BoardID: req.BoardID,
Name: req.Name,
StepType: models.StepType(req.StepType),
Position: req.Position,
Color: req.Color,
AutoStartAgent: req.AutoStartAgent,
PlanMode: req.PlanMode,
RequireApproval: req.RequireApproval,
PromptPrefix: req.PromptPrefix,
PromptSuffix: req.PromptSuffix,
AllowManualMove: req.AllowManualMove,
}
if err := c.svc.CreateStep(ctx, step); err != nil {
return nil, err
}
return &GetStepResponse{Step: step}, nil
}
// UpdateStepRequest is the request for updating a workflow step.
type UpdateStepRequest struct {
ID string `json:"id"`
Name *string `json:"name,omitempty"`
StepType *string `json:"step_type,omitempty"`
Position *int `json:"position,omitempty"`
Color *string `json:"color,omitempty"`
AutoStartAgent *bool `json:"auto_start_agent,omitempty"`
PlanMode *bool `json:"plan_mode,omitempty"`
RequireApproval *bool `json:"require_approval,omitempty"`
PromptPrefix *string `json:"prompt_prefix,omitempty"`
PromptSuffix *string `json:"prompt_suffix,omitempty"`
AllowManualMove *bool `json:"allow_manual_move,omitempty"`
}
// UpdateStep updates an existing workflow step.
func (c *Controller) UpdateStep(ctx context.Context, req UpdateStepRequest) (*GetStepResponse, error) {
step, err := c.svc.GetStep(ctx, req.ID)
if err != nil {
return nil, err
}
if req.Name != nil {
step.Name = *req.Name
}
if req.StepType != nil {
step.StepType = models.StepType(*req.StepType)
}
if req.Position != nil {
step.Position = *req.Position
}
if req.Color != nil {
step.Color = *req.Color
}
if req.AutoStartAgent != nil {
step.AutoStartAgent = *req.AutoStartAgent
}
if req.PlanMode != nil {
step.PlanMode = *req.PlanMode
}
if req.RequireApproval != nil {
step.RequireApproval = *req.RequireApproval
}
if req.PromptPrefix != nil {
step.PromptPrefix = *req.PromptPrefix
}
if req.PromptSuffix != nil {
step.PromptSuffix = *req.PromptSuffix
}
if req.AllowManualMove != nil {
step.AllowManualMove = *req.AllowManualMove
}
if err := c.svc.UpdateStep(ctx, step); err != nil {
return nil, err
}
return &GetStepResponse{Step: step}, nil
}
// DeleteStep deletes a workflow step.
func (c *Controller) DeleteStep(ctx context.Context, id string) error {
return c.svc.DeleteStep(ctx, id)
}
// ReorderStepsRequest is the request for reordering workflow steps.
type ReorderStepsRequest struct {
BoardID string `json:"board_id"`
StepIDs []string `json:"step_ids"`
}
// ReorderSteps reorders workflow steps for a board.
func (c *Controller) ReorderSteps(ctx context.Context, req ReorderStepsRequest) error {
return c.svc.ReorderSteps(ctx, req.BoardID, req.StepIDs)
}
// History responses
type ListHistoryRequest struct {
SessionID string `json:"session_id"`
}
type ListHistoryResponse struct {
History []*models.SessionStepHistory `json:"history"`
}
func (c *Controller) ListHistoryBySession(ctx context.Context, req ListHistoryRequest) (*ListHistoryResponse, error) {
history, err := c.svc.ListHistoryBySession(ctx, req.SessionID)
if err != nil {
return nil, err
}
return &ListHistoryResponse{History: history}, nil
}
@@ -0,0 +1,328 @@
package handlers
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/workflow/controller"
ws "github.com/kandev/kandev/pkg/websocket"
)
// Handlers manages workflow HTTP and WebSocket handlers
type Handlers struct {
controller *controller.Controller
logger *logger.Logger
}
// NewHandlers creates new workflow handlers
func NewHandlers(ctrl *controller.Controller, log *logger.Logger) *Handlers {
return &Handlers{
controller: ctrl,
logger: log.WithFields(zap.String("component", "workflow-handlers")),
}
}
// RegisterRoutes registers workflow HTTP and WebSocket handlers
func RegisterRoutes(router *gin.Engine, dispatcher *ws.Dispatcher, ctrl *controller.Controller, log *logger.Logger) {
handlers := NewHandlers(ctrl, log)
handlers.registerHTTP(router)
handlers.registerWS(dispatcher)
}
func (h *Handlers) registerHTTP(router *gin.Engine) {
api := router.Group("/api/v1")
// Template routes
api.GET("/workflow/templates", h.httpListTemplates)
api.GET("/workflow/templates/:id", h.httpGetTemplate)
// Step routes
api.GET("/boards/:id/workflow/steps", h.httpListStepsByBoard)
api.GET("/workflow/steps/:id", h.httpGetStep)
api.POST("/boards/:id/workflow/steps", h.httpCreateStepsFromTemplate)
api.POST("/workflow/steps", h.httpCreateStep)
api.PUT("/workflow/steps/:id", h.httpUpdateStep)
api.DELETE("/workflow/steps/:id", h.httpDeleteStep)
api.PUT("/boards/:id/workflow/steps/reorder", h.httpReorderSteps)
// History routes
api.GET("/sessions/:id/workflow/history", h.httpListHistoryBySession)
}
func (h *Handlers) registerWS(dispatcher *ws.Dispatcher) {
dispatcher.RegisterFunc(ws.ActionWorkflowTemplateList, h.wsListTemplates)
dispatcher.RegisterFunc(ws.ActionWorkflowTemplateGet, h.wsGetTemplate)
dispatcher.RegisterFunc(ws.ActionWorkflowStepList, h.wsListSteps)
dispatcher.RegisterFunc(ws.ActionWorkflowStepGet, h.wsGetStep)
dispatcher.RegisterFunc(ws.ActionWorkflowStepCreate, h.wsCreateStepsFromTemplate)
dispatcher.RegisterFunc(ws.ActionWorkflowHistoryList, h.wsListHistory)
}
// HTTP handlers - Templates
func (h *Handlers) httpListTemplates(c *gin.Context) {
resp, err := h.controller.ListTemplates(c.Request.Context())
if err != nil {
h.logger.Error("failed to list templates", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list templates"})
return
}
c.JSON(http.StatusOK, resp)
}
func (h *Handlers) httpGetTemplate(c *gin.Context) {
resp, err := h.controller.GetTemplate(c.Request.Context(), c.Param("id"))
if err != nil {
h.logger.Error("failed to get template", zap.Error(err))
c.JSON(http.StatusNotFound, gin.H{"error": "Template not found"})
return
}
c.JSON(http.StatusOK, resp)
}
// HTTP handlers - Steps
func (h *Handlers) httpListStepsByBoard(c *gin.Context) {
resp, err := h.controller.ListStepsByBoard(c.Request.Context(), controller.ListStepsRequest{
BoardID: c.Param("id"),
})
if err != nil {
h.logger.Error("failed to list steps", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list steps"})
return
}
c.JSON(http.StatusOK, resp)
}
func (h *Handlers) httpGetStep(c *gin.Context) {
resp, err := h.controller.GetStep(c.Request.Context(), c.Param("id"))
if err != nil {
h.logger.Error("failed to get step", zap.Error(err))
c.JSON(http.StatusNotFound, gin.H{"error": "Step not found"})
return
}
c.JSON(http.StatusOK, resp)
}
type httpCreateStepsRequest struct {
TemplateID string `json:"template_id"`
}
func (h *Handlers) httpCreateStepsFromTemplate(c *gin.Context) {
var req httpCreateStepsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
return
}
if err := h.controller.CreateStepsFromTemplate(c.Request.Context(), controller.CreateStepsFromTemplateRequest{
BoardID: c.Param("id"),
TemplateID: req.TemplateID,
}); err != nil {
h.logger.Error("failed to create steps from template", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create steps"})
return
}
c.JSON(http.StatusCreated, gin.H{"success": true})
}
func (h *Handlers) httpCreateStep(c *gin.Context) {
var req controller.CreateStepRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
return
}
if req.BoardID == "" || req.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "board_id and name are required"})
return
}
resp, err := h.controller.CreateStep(c.Request.Context(), req)
if err != nil {
h.logger.Error("failed to create step", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create step"})
return
}
c.JSON(http.StatusCreated, resp.Step)
}
func (h *Handlers) httpUpdateStep(c *gin.Context) {
var req controller.UpdateStepRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
return
}
req.ID = c.Param("id")
resp, err := h.controller.UpdateStep(c.Request.Context(), req)
if err != nil {
h.logger.Error("failed to update step", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update step"})
return
}
c.JSON(http.StatusOK, resp.Step)
}
func (h *Handlers) httpDeleteStep(c *gin.Context) {
if err := h.controller.DeleteStep(c.Request.Context(), c.Param("id")); err != nil {
h.logger.Error("failed to delete step", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete step"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
type httpReorderStepsRequest struct {
StepIDs []string `json:"step_ids"`
}
func (h *Handlers) httpReorderSteps(c *gin.Context) {
var req httpReorderStepsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
return
}
if err := h.controller.ReorderSteps(c.Request.Context(), controller.ReorderStepsRequest{
BoardID: c.Param("id"),
StepIDs: req.StepIDs,
}); err != nil {
h.logger.Error("failed to reorder steps", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reorder steps"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true})
}
// HTTP handlers - History
func (h *Handlers) httpListHistoryBySession(c *gin.Context) {
resp, err := h.controller.ListHistoryBySession(c.Request.Context(), controller.ListHistoryRequest{
SessionID: c.Param("id"),
})
if err != nil {
h.logger.Error("failed to list history", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list history"})
return
}
c.JSON(http.StatusOK, resp)
}
// WS handlers - Templates
func (h *Handlers) wsListTemplates(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
resp, err := h.controller.ListTemplates(ctx)
if err != nil {
h.logger.Error("failed to list templates", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list templates", nil)
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
type wsGetTemplateRequest struct {
ID string `json:"id"`
}
func (h *Handlers) wsGetTemplate(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req wsGetTemplateRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.ID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil)
}
resp, err := h.controller.GetTemplate(ctx, req.ID)
if err != nil {
h.logger.Error("failed to get template", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Template not found", nil)
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
// WS handlers - Steps
type wsListStepsRequest struct {
BoardID string `json:"board_id"`
}
func (h *Handlers) wsListSteps(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req wsListStepsRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.BoardID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "board_id is required", nil)
}
resp, err := h.controller.ListStepsByBoard(ctx, controller.ListStepsRequest{BoardID: req.BoardID})
if err != nil {
h.logger.Error("failed to list steps", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list steps", nil)
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
type wsGetStepRequest struct {
ID string `json:"id"`
}
func (h *Handlers) wsGetStep(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req wsGetStepRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.ID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil)
}
resp, err := h.controller.GetStep(ctx, req.ID)
if err != nil {
h.logger.Error("failed to get step", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "Step not found", nil)
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
type wsCreateStepsRequest struct {
BoardID string `json:"board_id"`
TemplateID string `json:"template_id"`
}
func (h *Handlers) wsCreateStepsFromTemplate(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req wsCreateStepsRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.BoardID == "" || req.TemplateID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "board_id and template_id are required", nil)
}
if err := h.controller.CreateStepsFromTemplate(ctx, controller.CreateStepsFromTemplateRequest{
BoardID: req.BoardID,
TemplateID: req.TemplateID,
}); err != nil {
h.logger.Error("failed to create steps", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to create steps", nil)
}
return ws.NewResponse(msg.ID, msg.Action, map[string]bool{"success": true})
}
// WS handlers - History
type wsListHistoryRequest struct {
SessionID string `json:"session_id"`
}
func (h *Handlers) wsListHistory(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req wsListHistoryRequest
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.SessionID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil)
}
resp, err := h.controller.ListHistoryBySession(ctx, controller.ListHistoryRequest{SessionID: req.SessionID})
if err != nil {
h.logger.Error("failed to list history", zap.Error(err))
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list history", nil)
}
return ws.NewResponse(msg.ID, msg.Action, resp)
}
@@ -0,0 +1,101 @@
package models
import (
"time"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
// StepType represents the semantic type of a workflow step
type StepType string
const (
StepTypeBacklog StepType = "backlog"
StepTypePlanning StepType = "planning"
StepTypeImplementation StepType = "implementation"
StepTypeReview StepType = "review"
StepTypeVerification StepType = "verification"
StepTypeDone StepType = "done"
StepTypeBlocked StepType = "blocked"
)
// ReviewStatus represents the review state of a session
type ReviewStatus string
const (
ReviewStatusPending ReviewStatus = "pending"
ReviewStatusChangesRequested ReviewStatus = "changes_requested"
ReviewStatusApproved ReviewStatus = "approved"
)
// WorkflowTemplate represents a pre-defined workflow type that boards can adopt
type WorkflowTemplate struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
IsSystem bool `json:"is_system"`
Steps []StepDefinition `json:"steps"` // JSON stored
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// StepDefinition represents a step in a workflow template (stored as JSON in WorkflowTemplate)
type StepDefinition struct {
ID string `json:"id"`
Name string `json:"name"`
StepType StepType `json:"step_type"`
Position int `json:"position"`
Color string `json:"color"`
TaskState v1.TaskState `json:"task_state"`
AutoStartAgent bool `json:"auto_start_agent"`
PlanMode bool `json:"plan_mode"`
RequireApproval bool `json:"require_approval"`
PromptPrefix string `json:"prompt_prefix,omitempty"`
PromptSuffix string `json:"prompt_suffix,omitempty"`
OnCompleteStepID string `json:"on_complete_step_id,omitempty"`
OnApprovalStepID string `json:"on_approval_step_id,omitempty"`
AllowManualMove bool `json:"allow_manual_move"`
}
// WorkflowStep represents a step in a board's workflow (replaces Column)
type WorkflowStep struct {
ID string `json:"id"`
BoardID string `json:"board_id"`
Name string `json:"name"`
StepType StepType `json:"step_type"`
Position int `json:"position"`
Color string `json:"color"`
TaskState v1.TaskState `json:"task_state"`
AutoStartAgent bool `json:"auto_start_agent"`
PlanMode bool `json:"plan_mode"`
RequireApproval bool `json:"require_approval"`
PromptPrefix string `json:"prompt_prefix,omitempty"`
PromptSuffix string `json:"prompt_suffix,omitempty"`
OnCompleteStepID *string `json:"on_complete_step_id,omitempty"`
OnApprovalStepID *string `json:"on_approval_step_id,omitempty"`
AllowManualMove bool `json:"allow_manual_move"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// StepTransitionTrigger represents how a session moved between steps
type StepTransitionTrigger string
const (
StepTransitionTriggerManual StepTransitionTrigger = "manual"
StepTransitionTriggerAutoComplete StepTransitionTrigger = "auto_complete"
StepTransitionTriggerApproval StepTransitionTrigger = "approval"
)
// SessionStepHistory represents an audit trail entry for session step transitions
type SessionStepHistory struct {
ID int64 `json:"id"`
SessionID string `json:"session_id"`
FromStepID *string `json:"from_step_id,omitempty"`
ToStepID string `json:"to_step_id"`
Trigger StepTransitionTrigger `json:"trigger"`
ActorID *string `json:"actor_id,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
@@ -0,0 +1,21 @@
// Package workflow provides workflow management functionality.
package workflow
import (
"database/sql"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/workflow/repository"
"github.com/kandev/kandev/internal/workflow/service"
)
// Provide creates the workflow repository and service using the shared database connection.
func Provide(db *sql.DB, log *logger.Logger) (*repository.Repository, *service.Service, func() error, error) {
repo, err := repository.NewWithDB(db)
if err != nil {
return nil, nil, nil, err
}
svc := service.NewService(repo, log)
return repo, svc, func() error { return nil }, nil
}
@@ -0,0 +1,875 @@
// Package repository provides SQLite-based repository implementations for workflow entities.
package repository
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/kandev/kandev/internal/common/sqlite"
"github.com/kandev/kandev/internal/workflow/models"
)
// Repository provides SQLite-based workflow storage operations.
type Repository struct {
db *sql.DB
}
// NewWithDB creates a new SQLite repository with an existing database connection.
func NewWithDB(db *sql.DB) (*Repository, error) {
repo := &Repository{db: db}
if err := repo.initSchema(); err != nil {
return nil, fmt.Errorf("failed to initialize workflow schema: %w", err)
}
return repo, nil
}
// DB returns the underlying sql.DB instance for shared access.
func (r *Repository) DB() *sql.DB {
return r.db
}
// ensureColumn adds a column to a table if it doesn't exist.
func (r *Repository) ensureColumn(table, column, definition string) error {
return sqlite.EnsureColumn(r.db, table, column, definition)
}
// initSchema creates the database tables if they don't exist.
func (r *Repository) initSchema() error {
// Create workflow_templates table
templatesSchema := `
CREATE TABLE IF NOT EXISTS workflow_templates (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
is_system INTEGER DEFAULT 0,
steps TEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
`
if _, err := r.db.Exec(templatesSchema); err != nil {
return fmt.Errorf("failed to create workflow_templates table: %w", err)
}
// Create workflow_steps table
stepsSchema := `
CREATE TABLE IF NOT EXISTS workflow_steps (
id TEXT PRIMARY KEY,
board_id TEXT NOT NULL,
name TEXT NOT NULL,
step_type TEXT NOT NULL,
position INTEGER NOT NULL,
color TEXT,
auto_start_agent INTEGER DEFAULT 0,
plan_mode INTEGER DEFAULT 0,
require_approval INTEGER DEFAULT 0,
prompt_prefix TEXT,
prompt_suffix TEXT,
on_complete_step_id TEXT,
on_approval_step_id TEXT,
allow_manual_move INTEGER DEFAULT 1,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
FOREIGN KEY (board_id) REFERENCES boards(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_workflow_steps_board ON workflow_steps(board_id);
`
if _, err := r.db.Exec(stepsSchema); err != nil {
return fmt.Errorf("failed to create workflow_steps table: %w", err)
}
// Create session_step_history table
historySchema := `
CREATE TABLE IF NOT EXISTS session_step_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
from_step_id TEXT,
to_step_id TEXT NOT NULL,
trigger TEXT NOT NULL,
actor_id TEXT,
metadata TEXT,
created_at DATETIME NOT NULL,
FOREIGN KEY (session_id) REFERENCES task_sessions(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_session_step_history_session ON session_step_history(session_id);
`
if _, err := r.db.Exec(historySchema); err != nil {
return fmt.Errorf("failed to create session_step_history table: %w", err)
}
// Run migrations for existing tables
if err := r.runMigrations(); err != nil {
return fmt.Errorf("failed to run migrations: %w", err)
}
// Seed system templates
if err := r.seedSystemTemplates(); err != nil {
return fmt.Errorf("failed to seed system templates: %w", err)
}
// Seed default workflow steps for boards that don't have any
if err := r.seedDefaultWorkflowSteps(); err != nil {
return fmt.Errorf("failed to seed default workflow steps: %w", err)
}
return nil
}
// runMigrations adds columns to existing tables.
func (r *Repository) runMigrations() error {
// Add workflow_template_id to boards table
if err := r.ensureColumn("boards", "workflow_template_id", "TEXT"); err != nil {
return fmt.Errorf("failed to add workflow_template_id to boards: %w", err)
}
// Add workflow-related columns to task_sessions table
if err := r.ensureColumn("task_sessions", "is_primary", "INTEGER DEFAULT 0"); err != nil {
return fmt.Errorf("failed to add is_primary to task_sessions: %w", err)
}
if err := r.ensureColumn("task_sessions", "workflow_step_id", "TEXT"); err != nil {
return fmt.Errorf("failed to add workflow_step_id to task_sessions: %w", err)
}
if err := r.ensureColumn("task_sessions", "review_status", "TEXT"); err != nil {
return fmt.Errorf("failed to add review_status to task_sessions: %w", err)
}
return nil
}
// seedDefaultWorkflowSteps creates default workflow steps for boards that don't have any.
func (r *Repository) seedDefaultWorkflowSteps() error {
// Find boards without workflow steps
rows, err := r.db.Query(`
SELECT b.id FROM boards b
LEFT JOIN workflow_steps ws ON ws.board_id = b.id
WHERE ws.id IS NULL
`)
if err != nil {
return err
}
defer func() { _ = rows.Close() }()
var boardIDs []string
for rows.Next() {
var boardID string
if err := rows.Scan(&boardID); err != nil {
return err
}
boardIDs = append(boardIDs, boardID)
}
if err := rows.Err(); err != nil {
return err
}
// Create default workflow steps for each board
now := time.Now()
type stepSeed struct {
name string
position int
stepType string
color string
}
defaultSteps := []stepSeed{
{name: "Todo", position: 0, stepType: "backlog", color: "bg-neutral-400"},
{name: "In Progress", position: 1, stepType: "implementation", color: "bg-blue-500"},
{name: "Review", position: 2, stepType: "review", color: "bg-yellow-500"},
{name: "Done", position: 3, stepType: "done", color: "bg-green-500"},
}
for _, boardID := range boardIDs {
for _, step := range defaultSteps {
if _, err := r.db.Exec(`
INSERT INTO workflow_steps (id, board_id, name, step_type, position, color, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, uuid.New().String(), boardID, step.name, step.stepType, step.position, step.color, now, now); err != nil {
return err
}
}
}
return nil
}
// seedSystemTemplates inserts the default system workflow templates.
func (r *Repository) seedSystemTemplates() error {
templates := r.getSystemTemplates()
for _, tmpl := range templates {
// Check if template already exists
var exists bool
err := r.db.QueryRow("SELECT 1 FROM workflow_templates WHERE id = ?", tmpl.ID).Scan(&exists)
if err != nil && err != sql.ErrNoRows {
return err
}
if exists {
continue
}
stepsJSON, err := json.Marshal(tmpl.Steps)
if err != nil {
return fmt.Errorf("failed to marshal steps for template %s: %w", tmpl.ID, err)
}
_, err = r.db.Exec(`
INSERT INTO workflow_templates (id, name, description, is_system, steps, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, tmpl.ID, tmpl.Name, tmpl.Description, sqlite.BoolToInt(tmpl.IsSystem), string(stepsJSON), tmpl.CreatedAt, tmpl.UpdatedAt)
if err != nil {
return fmt.Errorf("failed to insert template %s: %w", tmpl.ID, err)
}
}
return nil
}
// getSystemTemplates returns the predefined system workflow templates.
func (r *Repository) getSystemTemplates() []*models.WorkflowTemplate {
now := time.Now().UTC()
return []*models.WorkflowTemplate{
r.getDevTemplate(now),
r.getArchitectureTemplate(now),
r.getSimpleTemplate(now),
}
}
// getDevTemplate returns the full development workflow template.
func (r *Repository) getDevTemplate(now time.Time) *models.WorkflowTemplate {
return &models.WorkflowTemplate{
ID: "dev",
Name: "Development",
Description: "Standard software development workflow with planning phase",
IsSystem: true,
CreatedAt: now,
UpdatedAt: now,
Steps: []models.StepDefinition{
{
ID: "backlog",
Name: "Backlog",
StepType: models.StepTypeBacklog,
Position: 0,
Color: "bg-neutral-400",
TaskState: "TODO",
AutoStartAgent: false,
AllowManualMove: true,
},
{
ID: "planning",
Name: "Planning",
StepType: models.StepTypePlanning,
Position: 1,
Color: "bg-purple-500",
TaskState: "IN_PROGRESS",
AutoStartAgent: true,
PlanMode: true,
PromptPrefix: "[PLANNING PHASE]\nAnalyze this task and create a detailed implementation plan.\nDo NOT make any code changes yet - only analyze and plan.\n\nCreate a plan that includes:\n1. Understanding of the requirements\n2. Files that need to be modified or created\n3. Step-by-step implementation approach\n4. Potential risks or considerations\n\nIMPORTANT: Save your plan to ONLY this file: `.kandev/plans/{task_id}.md`\nDo not create any other files during this phase - only the plan file above.",
OnCompleteStepID: "review-plan",
AllowManualMove: true,
},
{
ID: "review-plan",
Name: "Review Plan",
StepType: models.StepTypeReview,
Position: 2,
Color: "bg-yellow-500",
TaskState: "REVIEW",
AutoStartAgent: false,
RequireApproval: true,
OnApprovalStepID: "implementation",
AllowManualMove: true,
},
{
ID: "implementation",
Name: "Implementation",
StepType: models.StepTypeImplementation,
Position: 3,
Color: "bg-blue-500",
TaskState: "IN_PROGRESS",
AutoStartAgent: true,
PromptPrefix: "[IMPLEMENTATION PHASE]\nRead the plan from `.kandev/plans/{task_id}.md` and implement the task according to the plan.\nFollow the plan step by step.",
OnCompleteStepID: "review-code",
AllowManualMove: true,
},
{
ID: "review-code",
Name: "Code Review",
StepType: models.StepTypeReview,
Position: 4,
Color: "bg-orange-500",
TaskState: "REVIEW",
AutoStartAgent: false,
RequireApproval: true,
OnApprovalStepID: "done",
AllowManualMove: true,
},
{
ID: "done",
Name: "Done",
StepType: models.StepTypeDone,
Position: 5,
Color: "bg-green-500",
TaskState: "COMPLETED",
AutoStartAgent: false,
AllowManualMove: true,
},
},
}
}
// getArchitectureTemplate returns the architecture workflow template.
func (r *Repository) getArchitectureTemplate(now time.Time) *models.WorkflowTemplate {
return &models.WorkflowTemplate{
ID: "architecture",
Name: "Architecture",
Description: "Architecture focused: Backlog → Planning → Review → Done",
IsSystem: true,
CreatedAt: now,
UpdatedAt: now,
Steps: []models.StepDefinition{
{
ID: "backlog",
Name: "Ideas",
StepType: models.StepTypeBacklog,
Position: 0,
Color: "bg-neutral-400",
TaskState: "TODO",
AutoStartAgent: false,
AllowManualMove: true,
},
{
ID: "planning",
Name: "Planning",
StepType: models.StepTypePlanning,
Position: 1,
Color: "bg-purple-500",
TaskState: "IN_PROGRESS",
AutoStartAgent: true,
PlanMode: true,
PromptPrefix: "[ARCHITECTURE PHASE]\nAnalyze and design the architecture for this task.\n\nIMPORTANT: Document your design in ONLY this file: `.kandev/designs/{task_id}.md`\nDo not create any other files during this phase - only the design file above.",
OnCompleteStepID: "review",
AllowManualMove: true,
},
{
ID: "review",
Name: "Review",
StepType: models.StepTypeReview,
Position: 2,
Color: "bg-yellow-500",
TaskState: "REVIEW",
AutoStartAgent: false,
RequireApproval: true,
OnApprovalStepID: "done",
AllowManualMove: true,
},
{
ID: "done",
Name: "Approved",
StepType: models.StepTypeDone,
Position: 3,
Color: "bg-green-500",
TaskState: "COMPLETED",
AutoStartAgent: false,
AllowManualMove: true,
},
},
}
}
// getSimpleTemplate returns the simple workflow template.
func (r *Repository) getSimpleTemplate(now time.Time) *models.WorkflowTemplate {
return &models.WorkflowTemplate{
ID: "simple",
Name: "Simple",
Description: "Minimal workflow: Backlog → In Progress → Done",
IsSystem: true,
CreatedAt: now,
UpdatedAt: now,
Steps: []models.StepDefinition{
{
ID: "backlog",
Name: "Backlog",
StepType: models.StepTypeBacklog,
Position: 0,
Color: "bg-neutral-400",
TaskState: "TODO",
AutoStartAgent: false,
AllowManualMove: true,
},
{
ID: "in-progress",
Name: "In Progress",
StepType: models.StepTypeImplementation,
Position: 1,
Color: "bg-blue-500",
TaskState: "IN_PROGRESS",
AutoStartAgent: true,
OnCompleteStepID: "done",
AllowManualMove: true,
},
{
ID: "done",
Name: "Done",
StepType: models.StepTypeDone,
Position: 2,
Color: "bg-green-500",
TaskState: "COMPLETED",
AutoStartAgent: false,
AllowManualMove: true,
},
},
}
}
// ============================================================================
// WorkflowTemplate CRUD Operations
// ============================================================================
// CreateTemplate creates a new workflow template.
func (r *Repository) CreateTemplate(ctx context.Context, template *models.WorkflowTemplate) error {
if template.ID == "" {
template.ID = uuid.New().String()
}
now := time.Now().UTC()
template.CreatedAt = now
template.UpdatedAt = now
stepsJSON, err := json.Marshal(template.Steps)
if err != nil {
return fmt.Errorf("failed to marshal steps: %w", err)
}
_, err = r.db.ExecContext(ctx, `
INSERT INTO workflow_templates (id, name, description, is_system, steps, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, template.ID, template.Name, template.Description, sqlite.BoolToInt(template.IsSystem), string(stepsJSON), template.CreatedAt, template.UpdatedAt)
return err
}
// GetTemplate retrieves a workflow template by ID.
func (r *Repository) GetTemplate(ctx context.Context, id string) (*models.WorkflowTemplate, error) {
template := &models.WorkflowTemplate{}
var stepsJSON string
var isSystem int
err := r.db.QueryRowContext(ctx, `
SELECT id, name, description, is_system, steps, created_at, updated_at
FROM workflow_templates WHERE id = ?
`, id).Scan(&template.ID, &template.Name, &template.Description, &isSystem, &stepsJSON, &template.CreatedAt, &template.UpdatedAt)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("workflow template not found: %s", id)
}
if err != nil {
return nil, err
}
template.IsSystem = isSystem == 1
if err := json.Unmarshal([]byte(stepsJSON), &template.Steps); err != nil {
return nil, fmt.Errorf("failed to unmarshal steps: %w", err)
}
return template, nil
}
// UpdateTemplate updates an existing workflow template.
func (r *Repository) UpdateTemplate(ctx context.Context, template *models.WorkflowTemplate) error {
template.UpdatedAt = time.Now().UTC()
stepsJSON, err := json.Marshal(template.Steps)
if err != nil {
return fmt.Errorf("failed to marshal steps: %w", err)
}
result, err := r.db.ExecContext(ctx, `
UPDATE workflow_templates SET name = ?, description = ?, steps = ?, updated_at = ?
WHERE id = ? AND is_system = 0
`, template.Name, template.Description, string(stepsJSON), template.UpdatedAt, template.ID)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("workflow template not found or is a system template: %s", template.ID)
}
return nil
}
// DeleteTemplate deletes a workflow template by ID.
func (r *Repository) DeleteTemplate(ctx context.Context, id string) error {
result, err := r.db.ExecContext(ctx, `DELETE FROM workflow_templates WHERE id = ? AND is_system = 0`, id)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("workflow template not found or is a system template: %s", id)
}
return nil
}
// ListTemplates returns all workflow templates.
func (r *Repository) ListTemplates(ctx context.Context) ([]*models.WorkflowTemplate, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, name, description, is_system, steps, created_at, updated_at
FROM workflow_templates ORDER BY is_system DESC, name ASC
`)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
var result []*models.WorkflowTemplate
for rows.Next() {
template := &models.WorkflowTemplate{}
var stepsJSON string
var isSystem int
err := rows.Scan(&template.ID, &template.Name, &template.Description, &isSystem, &stepsJSON, &template.CreatedAt, &template.UpdatedAt)
if err != nil {
return nil, err
}
template.IsSystem = isSystem == 1
if err := json.Unmarshal([]byte(stepsJSON), &template.Steps); err != nil {
return nil, fmt.Errorf("failed to unmarshal steps for template %s: %w", template.ID, err)
}
result = append(result, template)
}
return result, rows.Err()
}
// GetSystemTemplates returns only system workflow templates.
func (r *Repository) GetSystemTemplates(ctx context.Context) ([]*models.WorkflowTemplate, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, name, description, is_system, steps, created_at, updated_at
FROM workflow_templates WHERE is_system = 1 ORDER BY name ASC
`)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
var result []*models.WorkflowTemplate
for rows.Next() {
template := &models.WorkflowTemplate{}
var stepsJSON string
var isSystem int
err := rows.Scan(&template.ID, &template.Name, &template.Description, &isSystem, &stepsJSON, &template.CreatedAt, &template.UpdatedAt)
if err != nil {
return nil, err
}
template.IsSystem = isSystem == 1
if err := json.Unmarshal([]byte(stepsJSON), &template.Steps); err != nil {
return nil, fmt.Errorf("failed to unmarshal steps for template %s: %w", template.ID, err)
}
result = append(result, template)
}
return result, rows.Err()
}
// ============================================================================
// WorkflowStep CRUD Operations
// ============================================================================
// CreateStep creates a new workflow step.
func (r *Repository) CreateStep(ctx context.Context, step *models.WorkflowStep) error {
if step.ID == "" {
step.ID = uuid.New().String()
}
now := time.Now().UTC()
step.CreatedAt = now
step.UpdatedAt = now
_, err := r.db.ExecContext(ctx, `
INSERT INTO workflow_steps (
id, board_id, name, step_type, position, color,
auto_start_agent, plan_mode, require_approval,
prompt_prefix, prompt_suffix, on_complete_step_id, on_approval_step_id,
allow_manual_move, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, step.ID, step.BoardID, step.Name, step.StepType, step.Position, step.Color,
sqlite.BoolToInt(step.AutoStartAgent), sqlite.BoolToInt(step.PlanMode), sqlite.BoolToInt(step.RequireApproval),
step.PromptPrefix, step.PromptSuffix, step.OnCompleteStepID, step.OnApprovalStepID,
sqlite.BoolToInt(step.AllowManualMove), step.CreatedAt, step.UpdatedAt)
return err
}
// GetStep retrieves a workflow step by ID.
func (r *Repository) GetStep(ctx context.Context, id string) (*models.WorkflowStep, error) {
step := &models.WorkflowStep{}
var autoStartAgent, planMode, requireApproval, allowManualMove int
var onCompleteStepID, onApprovalStepID, promptPrefix, promptSuffix, color sql.NullString
err := r.db.QueryRowContext(ctx, `
SELECT id, board_id, name, step_type, position, color,
auto_start_agent, plan_mode, require_approval,
prompt_prefix, prompt_suffix, on_complete_step_id, on_approval_step_id,
allow_manual_move, created_at, updated_at
FROM workflow_steps WHERE id = ?
`, id).Scan(&step.ID, &step.BoardID, &step.Name, &step.StepType, &step.Position, &color,
&autoStartAgent, &planMode, &requireApproval,
&promptPrefix, &promptSuffix, &onCompleteStepID, &onApprovalStepID,
&allowManualMove, &step.CreatedAt, &step.UpdatedAt)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("workflow step not found: %s", id)
}
if err != nil {
return nil, err
}
step.AutoStartAgent = autoStartAgent == 1
step.PlanMode = planMode == 1
step.RequireApproval = requireApproval == 1
step.AllowManualMove = allowManualMove == 1
if color.Valid {
step.Color = color.String
}
if promptPrefix.Valid {
step.PromptPrefix = promptPrefix.String
}
if promptSuffix.Valid {
step.PromptSuffix = promptSuffix.String
}
if onCompleteStepID.Valid {
step.OnCompleteStepID = &onCompleteStepID.String
}
if onApprovalStepID.Valid {
step.OnApprovalStepID = &onApprovalStepID.String
}
return step, nil
}
// UpdateStep updates an existing workflow step.
func (r *Repository) UpdateStep(ctx context.Context, step *models.WorkflowStep) error {
step.UpdatedAt = time.Now().UTC()
result, err := r.db.ExecContext(ctx, `
UPDATE workflow_steps SET
name = ?, step_type = ?, position = ?, color = ?,
auto_start_agent = ?, plan_mode = ?, require_approval = ?,
prompt_prefix = ?, prompt_suffix = ?, on_complete_step_id = ?, on_approval_step_id = ?,
allow_manual_move = ?, updated_at = ?
WHERE id = ?
`, step.Name, step.StepType, step.Position, step.Color,
sqlite.BoolToInt(step.AutoStartAgent), sqlite.BoolToInt(step.PlanMode), sqlite.BoolToInt(step.RequireApproval),
step.PromptPrefix, step.PromptSuffix, step.OnCompleteStepID, step.OnApprovalStepID,
sqlite.BoolToInt(step.AllowManualMove), step.UpdatedAt, step.ID)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("workflow step not found: %s", step.ID)
}
return nil
}
// DeleteStep deletes a workflow step by ID.
func (r *Repository) DeleteStep(ctx context.Context, id string) error {
result, err := r.db.ExecContext(ctx, `DELETE FROM workflow_steps WHERE id = ?`, id)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("workflow step not found: %s", id)
}
return nil
}
// ListStepsByBoard returns all workflow steps for a board.
func (r *Repository) ListStepsByBoard(ctx context.Context, boardID string) ([]*models.WorkflowStep, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, board_id, name, step_type, position, color,
auto_start_agent, plan_mode, require_approval,
prompt_prefix, prompt_suffix, on_complete_step_id, on_approval_step_id,
allow_manual_move, created_at, updated_at
FROM workflow_steps WHERE board_id = ? ORDER BY position
`, boardID)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
return r.scanSteps(rows)
}
// GetStepByBoardAndType retrieves a workflow step by board ID and step type.
func (r *Repository) GetStepByBoardAndType(ctx context.Context, boardID string, stepType models.StepType) (*models.WorkflowStep, error) {
step := &models.WorkflowStep{}
var autoStartAgent, planMode, requireApproval, allowManualMove int
var onCompleteStepID, onApprovalStepID, promptPrefix, promptSuffix, color sql.NullString
err := r.db.QueryRowContext(ctx, `
SELECT id, board_id, name, step_type, position, color,
auto_start_agent, plan_mode, require_approval,
prompt_prefix, prompt_suffix, on_complete_step_id, on_approval_step_id,
allow_manual_move, created_at, updated_at
FROM workflow_steps WHERE board_id = ? AND step_type = ?
`, boardID, stepType).Scan(&step.ID, &step.BoardID, &step.Name, &step.StepType, &step.Position, &color,
&autoStartAgent, &planMode, &requireApproval,
&promptPrefix, &promptSuffix, &onCompleteStepID, &onApprovalStepID,
&allowManualMove, &step.CreatedAt, &step.UpdatedAt)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("workflow step not found for board %s with type %s", boardID, stepType)
}
if err != nil {
return nil, err
}
step.AutoStartAgent = autoStartAgent == 1
step.PlanMode = planMode == 1
step.RequireApproval = requireApproval == 1
step.AllowManualMove = allowManualMove == 1
if color.Valid {
step.Color = color.String
}
if promptPrefix.Valid {
step.PromptPrefix = promptPrefix.String
}
if promptSuffix.Valid {
step.PromptSuffix = promptSuffix.String
}
if onCompleteStepID.Valid {
step.OnCompleteStepID = &onCompleteStepID.String
}
if onApprovalStepID.Valid {
step.OnApprovalStepID = &onApprovalStepID.String
}
return step, nil
}
// scanSteps is a helper to scan multiple workflow step rows.
func (r *Repository) scanSteps(rows *sql.Rows) ([]*models.WorkflowStep, error) {
var result []*models.WorkflowStep
for rows.Next() {
step := &models.WorkflowStep{}
var autoStartAgent, planMode, requireApproval, allowManualMove int
var onCompleteStepID, onApprovalStepID, promptPrefix, promptSuffix, color sql.NullString
err := rows.Scan(&step.ID, &step.BoardID, &step.Name, &step.StepType, &step.Position, &color,
&autoStartAgent, &planMode, &requireApproval,
&promptPrefix, &promptSuffix, &onCompleteStepID, &onApprovalStepID,
&allowManualMove, &step.CreatedAt, &step.UpdatedAt)
if err != nil {
return nil, err
}
step.AutoStartAgent = autoStartAgent == 1
step.PlanMode = planMode == 1
step.RequireApproval = requireApproval == 1
step.AllowManualMove = allowManualMove == 1
if color.Valid {
step.Color = color.String
}
if promptPrefix.Valid {
step.PromptPrefix = promptPrefix.String
}
if promptSuffix.Valid {
step.PromptSuffix = promptSuffix.String
}
if onCompleteStepID.Valid {
step.OnCompleteStepID = &onCompleteStepID.String
}
if onApprovalStepID.Valid {
step.OnApprovalStepID = &onApprovalStepID.String
}
result = append(result, step)
}
return result, rows.Err()
}
// ============================================================================
// SessionStepHistory Operations
// ============================================================================
// CreateHistory creates a new session step history entry.
func (r *Repository) CreateHistory(ctx context.Context, history *models.SessionStepHistory) error {
now := time.Now().UTC()
history.CreatedAt = now
var metadataJSON *string
if history.Metadata != nil {
data, err := json.Marshal(history.Metadata)
if err != nil {
return fmt.Errorf("failed to marshal metadata: %w", err)
}
s := string(data)
metadataJSON = &s
}
result, err := r.db.ExecContext(ctx, `
INSERT INTO session_step_history (session_id, from_step_id, to_step_id, trigger, actor_id, metadata, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, history.SessionID, history.FromStepID, history.ToStepID, history.Trigger, history.ActorID, metadataJSON, history.CreatedAt)
if err != nil {
return err
}
// Get the auto-incremented ID
id, err := result.LastInsertId()
if err != nil {
return err
}
history.ID = id
return nil
}
// ListHistoryBySession returns all step history entries for a session.
func (r *Repository) ListHistoryBySession(ctx context.Context, sessionID string) ([]*models.SessionStepHistory, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, session_id, from_step_id, to_step_id, trigger, actor_id, metadata, created_at
FROM session_step_history WHERE session_id = ? ORDER BY created_at
`, sessionID)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
var result []*models.SessionStepHistory
for rows.Next() {
history := &models.SessionStepHistory{}
var fromStepID, actorID, metadataJSON sql.NullString
err := rows.Scan(&history.ID, &history.SessionID, &fromStepID, &history.ToStepID, &history.Trigger, &actorID, &metadataJSON, &history.CreatedAt)
if err != nil {
return nil, err
}
if fromStepID.Valid {
history.FromStepID = &fromStepID.String
}
if actorID.Valid {
history.ActorID = &actorID.String
}
if metadataJSON.Valid && metadataJSON.String != "" {
if err := json.Unmarshal([]byte(metadataJSON.String), &history.Metadata); err != nil {
return nil, fmt.Errorf("failed to unmarshal metadata: %w", err)
}
}
result = append(result, history)
}
return result, rows.Err()
}
@@ -0,0 +1,289 @@
// Package service provides workflow business logic operations.
package service
import (
"context"
"fmt"
"github.com/google/uuid"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/workflow/models"
"github.com/kandev/kandev/internal/workflow/repository"
)
// Service provides workflow business logic
type Service struct {
repo *repository.Repository
logger *logger.Logger
}
// NewService creates a new workflow service
func NewService(repo *repository.Repository, log *logger.Logger) *Service {
return &Service{
repo: repo,
logger: log.WithFields(zap.String("component", "workflow-service")),
}
}
// ============================================================================
// Template Operations
// ============================================================================
// ListTemplates returns all workflow templates.
func (s *Service) ListTemplates(ctx context.Context) ([]*models.WorkflowTemplate, error) {
templates, err := s.repo.ListTemplates(ctx)
if err != nil {
s.logger.Error("failed to list templates", zap.Error(err))
return nil, err
}
return templates, nil
}
// GetTemplate retrieves a workflow template by ID.
func (s *Service) GetTemplate(ctx context.Context, id string) (*models.WorkflowTemplate, error) {
template, err := s.repo.GetTemplate(ctx, id)
if err != nil {
s.logger.Error("failed to get template", zap.String("template_id", id), zap.Error(err))
return nil, err
}
return template, nil
}
// GetSystemTemplates returns only system workflow templates.
func (s *Service) GetSystemTemplates(ctx context.Context) ([]*models.WorkflowTemplate, error) {
templates, err := s.repo.GetSystemTemplates(ctx)
if err != nil {
s.logger.Error("failed to get system templates", zap.Error(err))
return nil, err
}
return templates, nil
}
// ============================================================================
// Step Operations
// ============================================================================
// ListStepsByBoard returns all workflow steps for a board.
func (s *Service) ListStepsByBoard(ctx context.Context, boardID string) ([]*models.WorkflowStep, error) {
steps, err := s.repo.ListStepsByBoard(ctx, boardID)
if err != nil {
s.logger.Error("failed to list steps by board", zap.String("board_id", boardID), zap.Error(err))
return nil, err
}
return steps, nil
}
// GetStep retrieves a workflow step by ID.
func (s *Service) GetStep(ctx context.Context, stepID string) (*models.WorkflowStep, error) {
step, err := s.repo.GetStep(ctx, stepID)
if err != nil {
s.logger.Error("failed to get step", zap.String("step_id", stepID), zap.Error(err))
return nil, err
}
return step, nil
}
// GetNextStep gets the step to transition to based on OnCompleteStepID.
func (s *Service) GetNextStep(ctx context.Context, boardID, currentStepID string) (*models.WorkflowStep, error) {
currentStep, err := s.repo.GetStep(ctx, currentStepID)
if err != nil {
s.logger.Error("failed to get current step", zap.String("step_id", currentStepID), zap.Error(err))
return nil, err
}
if currentStep.OnCompleteStepID == nil {
return nil, nil // No next step configured
}
nextStep, err := s.repo.GetStep(ctx, *currentStep.OnCompleteStepID)
if err != nil {
s.logger.Error("failed to get next step",
zap.String("current_step_id", currentStepID),
zap.String("next_step_id", *currentStep.OnCompleteStepID),
zap.Error(err))
return nil, err
}
return nextStep, nil
}
// GetSourceStep finds the step that has on_complete_step_id pointing to the given step.
// This is used to find the "previous" step when moving back from a review step.
// Returns nil if no source step is found.
func (s *Service) GetSourceStep(ctx context.Context, boardID, targetStepID string) (*models.WorkflowStep, error) {
steps, err := s.repo.ListStepsByBoard(ctx, boardID)
if err != nil {
s.logger.Error("failed to list steps for source lookup",
zap.String("board_id", boardID),
zap.Error(err))
return nil, err
}
for _, step := range steps {
if step.OnCompleteStepID != nil && *step.OnCompleteStepID == targetStepID {
return step, nil
}
}
return nil, nil // No source step found
}
// CreateStepsFromTemplate creates workflow steps for a board from a template.
func (s *Service) CreateStepsFromTemplate(ctx context.Context, boardID, templateID string) error {
template, err := s.repo.GetTemplate(ctx, templateID)
if err != nil {
s.logger.Error("failed to get template for step creation",
zap.String("template_id", templateID),
zap.Error(err))
return fmt.Errorf("failed to get template: %w", err)
}
// Map template step IDs to generated UUIDs for linking
idMap := make(map[string]string)
for _, stepDef := range template.Steps {
idMap[stepDef.ID] = uuid.New().String()
}
// Create each step from the template
for _, stepDef := range template.Steps {
step := &models.WorkflowStep{
ID: idMap[stepDef.ID],
BoardID: boardID,
Name: stepDef.Name,
StepType: stepDef.StepType,
Position: stepDef.Position,
Color: stepDef.Color,
AutoStartAgent: stepDef.AutoStartAgent,
PlanMode: stepDef.PlanMode,
RequireApproval: stepDef.RequireApproval,
PromptPrefix: stepDef.PromptPrefix,
PromptSuffix: stepDef.PromptSuffix,
AllowManualMove: stepDef.AllowManualMove,
}
// Map OnCompleteStepID if set
if stepDef.OnCompleteStepID != "" {
if mappedID, ok := idMap[stepDef.OnCompleteStepID]; ok {
step.OnCompleteStepID = &mappedID
}
}
// Map OnApprovalStepID if set
if stepDef.OnApprovalStepID != "" {
if mappedID, ok := idMap[stepDef.OnApprovalStepID]; ok {
step.OnApprovalStepID = &mappedID
}
}
if err := s.repo.CreateStep(ctx, step); err != nil {
s.logger.Error("failed to create step from template",
zap.String("board_id", boardID),
zap.String("step_name", step.Name),
zap.Error(err))
return fmt.Errorf("failed to create step %s: %w", step.Name, err)
}
}
s.logger.Info("created workflow steps from template",
zap.String("board_id", boardID),
zap.String("template_id", templateID),
zap.Int("step_count", len(template.Steps)))
return nil
}
// CreateStep creates a new workflow step.
func (s *Service) CreateStep(ctx context.Context, step *models.WorkflowStep) error {
step.ID = uuid.New().String()
if err := s.repo.CreateStep(ctx, step); err != nil {
s.logger.Error("failed to create step", zap.String("board_id", step.BoardID), zap.Error(err))
return err
}
s.logger.Info("created workflow step", zap.String("step_id", step.ID), zap.String("board_id", step.BoardID))
return nil
}
// UpdateStep updates an existing workflow step.
func (s *Service) UpdateStep(ctx context.Context, step *models.WorkflowStep) error {
if err := s.repo.UpdateStep(ctx, step); err != nil {
s.logger.Error("failed to update step", zap.String("step_id", step.ID), zap.Error(err))
return err
}
s.logger.Info("updated workflow step", zap.String("step_id", step.ID))
return nil
}
// DeleteStep deletes a workflow step.
func (s *Service) DeleteStep(ctx context.Context, stepID string) error {
if err := s.repo.DeleteStep(ctx, stepID); err != nil {
s.logger.Error("failed to delete step", zap.String("step_id", stepID), zap.Error(err))
return err
}
s.logger.Info("deleted workflow step", zap.String("step_id", stepID))
return nil
}
// ReorderSteps reorders workflow steps for a board.
func (s *Service) ReorderSteps(ctx context.Context, boardID string, stepIDs []string) error {
for i, stepID := range stepIDs {
step, err := s.repo.GetStep(ctx, stepID)
if err != nil {
s.logger.Error("failed to get step for reorder", zap.String("step_id", stepID), zap.Error(err))
return err
}
step.Position = i
if err := s.repo.UpdateStep(ctx, step); err != nil {
s.logger.Error("failed to update step position", zap.String("step_id", stepID), zap.Error(err))
return err
}
}
s.logger.Info("reordered workflow steps", zap.String("board_id", boardID), zap.Int("count", len(stepIDs)))
return nil
}
// ============================================================================
// History Operations
// ============================================================================
// CreateStepTransition creates a new step transition history entry.
func (s *Service) CreateStepTransition(ctx context.Context, sessionID string, fromStepID, toStepID string, trigger models.StepTransitionTrigger, actorID *string) error {
history := &models.SessionStepHistory{
SessionID: sessionID,
ToStepID: toStepID,
Trigger: trigger,
ActorID: actorID,
}
if fromStepID != "" {
history.FromStepID = &fromStepID
}
if err := s.repo.CreateHistory(ctx, history); err != nil {
s.logger.Error("failed to create step transition",
zap.String("session_id", sessionID),
zap.String("to_step_id", toStepID),
zap.Error(err))
return err
}
s.logger.Info("step transition recorded",
zap.String("session_id", sessionID),
zap.Stringp("from_step_id", history.FromStepID),
zap.String("to_step_id", toStepID),
zap.String("trigger", string(trigger)))
return nil
}
// ListHistoryBySession returns all step history entries for a session.
func (s *Service) ListHistoryBySession(ctx context.Context, sessionID string) ([]*models.SessionStepHistory, error) {
history, err := s.repo.ListHistoryBySession(ctx, sessionID)
if err != nil {
s.logger.Error("failed to list history by session", zap.String("session_id", sessionID), zap.Error(err))
return nil, err
}
return history, nil
}
+8 -10
View File
@@ -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"
+2 -2
View File
@@ -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;
+163 -36
View File
@@ -2,10 +2,10 @@
import { getBackendConfig } from '@/lib/config';
import type {
ApproveSessionResponse,
Board,
Column,
ListBoardsResponse,
ListColumnsResponse,
ListWorkflowStepsResponse,
RepositoryDiscoveryResponse,
ListRepositoriesResponse,
RepositoryBranchesResponse,
@@ -14,7 +14,11 @@ import type {
RepositoryPathValidationResponse,
Repository,
RepositoryScript,
StepBehaviors,
Workspace,
WorkflowStep,
WorkflowStepType,
ListWorkflowTemplatesResponse,
} from '@/lib/types/http';
const { apiBaseUrl } = getBackendConfig();
@@ -88,7 +92,12 @@ export async function listBoardsAction(workspaceId: string): Promise<ListBoardsR
return fetchJson<ListBoardsResponse>(url.toString());
}
export async function createBoardAction(payload: { workspace_id: string; name: string; description?: string }) {
export async function createBoardAction(payload: {
workspace_id: string;
name: string;
description?: string;
workflow_template_id?: string;
}) {
return fetchJson<Board>(`${apiBaseUrl}/api/v1/boards`, {
method: 'POST',
body: JSON.stringify(payload),
@@ -106,39 +115,6 @@ export async function deleteBoardAction(id: string) {
await fetchJson<void>(`${apiBaseUrl}/api/v1/boards/${id}`, { method: 'DELETE' });
}
export async function listColumnsAction(boardId: string): Promise<ListColumnsResponse> {
return fetchJson<ListColumnsResponse>(`${apiBaseUrl}/api/v1/boards/${boardId}/columns`);
}
export async function createColumnAction(payload: {
board_id: string;
name: string;
position: number;
state: string;
color: string;
}) {
return fetchJson<Column>(`${apiBaseUrl}/api/v1/boards/${payload.board_id}/columns`, {
method: 'POST',
body: JSON.stringify({
name: payload.name,
position: payload.position,
state: payload.state,
color: payload.color,
}),
});
}
export async function updateColumnAction(id: string, payload: Partial<Pick<Column, 'name' | 'position' | 'state' | 'color'>>) {
return fetchJson<Column>(`${apiBaseUrl}/api/v1/columns/${id}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
}
export async function deleteColumnAction(id: string) {
await fetchJson<void>(`${apiBaseUrl}/api/v1/columns/${id}`, { method: 'DELETE' });
}
export async function listRepositoriesAction(workspaceId: string): Promise<ListRepositoriesResponse> {
return fetchJson<ListRepositoriesResponse>(`${apiBaseUrl}/api/v1/workspaces/${workspaceId}/repositories`);
}
@@ -250,3 +226,154 @@ export async function updateRepositoryScriptAction(id: string, payload: Partial<
export async function deleteRepositoryScriptAction(id: string) {
await fetchJson<void>(`${apiBaseUrl}/api/v1/scripts/${id}`, { method: 'DELETE' });
}
// Workflow Templates
export async function listWorkflowTemplatesAction(): Promise<ListWorkflowTemplatesResponse> {
return fetchJson<ListWorkflowTemplatesResponse>(`${apiBaseUrl}/api/v1/workflow/templates`);
}
// Helper to transform backend workflow step (flat fields) to frontend format (nested behaviors)
type BackendWorkflowStep = {
id: string;
board_id: string;
name: string;
step_type: WorkflowStepType;
position: number;
color: string;
task_state?: string;
auto_start_agent?: boolean;
plan_mode?: boolean;
require_approval?: boolean;
prompt_prefix?: string;
prompt_suffix?: string;
on_complete_step_id?: string;
on_approval_step_id?: string;
allow_manual_move?: boolean;
created_at: string;
updated_at: string;
};
function transformWorkflowStep(step: BackendWorkflowStep): WorkflowStep {
return {
id: step.id,
board_id: step.board_id,
name: step.name,
step_type: step.step_type,
position: step.position,
color: step.color,
behaviors: {
autoStartAgent: step.auto_start_agent ?? false,
planMode: step.plan_mode ?? false,
requireApproval: step.require_approval ?? false,
promptPrefix: step.prompt_prefix ?? '',
promptSuffix: step.prompt_suffix ?? '',
},
created_at: step.created_at,
updated_at: step.updated_at,
};
}
// Workflow Steps
export async function listWorkflowStepsAction(boardId: string): Promise<ListWorkflowStepsResponse> {
const response = await fetchJson<{ steps: BackendWorkflowStep[] | null }>(
`${apiBaseUrl}/api/v1/boards/${boardId}/workflow/steps`
);
return {
steps: (response.steps ?? []).map(transformWorkflowStep),
total: response.steps?.length ?? 0,
};
}
export async function createWorkflowStepAction(payload: {
board_id: string;
name: string;
step_type: WorkflowStepType;
position: number;
color: string;
behaviors?: StepBehaviors;
}): Promise<WorkflowStep> {
// Flatten behaviors into top-level fields for the backend
const body = {
board_id: payload.board_id,
name: payload.name,
step_type: payload.step_type,
position: payload.position,
color: payload.color,
auto_start_agent: payload.behaviors?.autoStartAgent ?? false,
plan_mode: payload.behaviors?.planMode ?? false,
require_approval: payload.behaviors?.requireApproval ?? false,
prompt_prefix: payload.behaviors?.promptPrefix ?? '',
prompt_suffix: payload.behaviors?.promptSuffix ?? '',
allow_manual_move: true,
};
const response = await fetchJson<BackendWorkflowStep>(`${apiBaseUrl}/api/v1/workflow/steps`, {
method: 'POST',
body: JSON.stringify(body),
});
return transformWorkflowStep(response);
}
export async function updateWorkflowStepAction(
stepId: string,
payload: Partial<Pick<WorkflowStep, 'name' | 'step_type' | 'position' | 'color' | 'behaviors'>>
): Promise<WorkflowStep> {
// Flatten behaviors into top-level fields for the backend
const body: Record<string, unknown> = {};
if (payload.name !== undefined) body.name = payload.name;
if (payload.step_type !== undefined) body.step_type = payload.step_type;
if (payload.position !== undefined) body.position = payload.position;
if (payload.color !== undefined) body.color = payload.color;
if (payload.behaviors !== undefined) {
if (payload.behaviors.autoStartAgent !== undefined) body.auto_start_agent = payload.behaviors.autoStartAgent;
if (payload.behaviors.planMode !== undefined) body.plan_mode = payload.behaviors.planMode;
if (payload.behaviors.requireApproval !== undefined) body.require_approval = payload.behaviors.requireApproval;
if (payload.behaviors.promptPrefix !== undefined) body.prompt_prefix = payload.behaviors.promptPrefix;
if (payload.behaviors.promptSuffix !== undefined) body.prompt_suffix = payload.behaviors.promptSuffix;
}
const response = await fetchJson<BackendWorkflowStep>(`${apiBaseUrl}/api/v1/workflow/steps/${stepId}`, {
method: 'PUT',
body: JSON.stringify(body),
});
return transformWorkflowStep(response);
}
export async function deleteWorkflowStepAction(stepId: string) {
await fetchJson<void>(`${apiBaseUrl}/api/v1/workflow/steps/${stepId}`, { method: 'DELETE' });
}
export async function reorderWorkflowStepsAction(boardId: string, stepIds: string[]): Promise<ListWorkflowStepsResponse> {
const response = await fetchJson<{ steps: BackendWorkflowStep[] | null }>(
`${apiBaseUrl}/api/v1/boards/${boardId}/workflow/steps/reorder`,
{
method: 'PUT',
body: JSON.stringify({ step_ids: stepIds }),
}
);
return {
steps: (response.steps ?? []).map(transformWorkflowStep),
total: response.steps?.length ?? 0,
};
}
// Session Workflow Actions
export async function setPrimarySessionAction(taskId: string, sessionId: string) {
return fetchJson(`${apiBaseUrl}/api/v1/tasks/${taskId}/primary-session`, {
method: 'PUT',
body: JSON.stringify({ session_id: sessionId }),
});
}
export async function approveSessionAction(sessionId: string): Promise<ApproveSessionResponse> {
return fetchJson<ApproveSessionResponse>(`${apiBaseUrl}/api/v1/sessions/${sessionId}/approve`, {
method: 'POST',
});
}
export async function moveSessionToStepAction(sessionId: string, stepId: string) {
return fetchJson(`${apiBaseUrl}/api/v1/sessions/${sessionId}/workflow-step`, {
method: 'PUT',
body: JSON.stringify({ step_id: stepId }),
});
}
@@ -65,7 +65,7 @@ export function ProfileMcpConfigCard({
const currentError = isDraft ? draftState?.error ?? null : mcpError;
const currentDirty = isDraft ? draftState?.dirty ?? false : mcpDirty;
const { mcpServerUrl } = getBackendConfig();
const kandevTools = 'Tools: list_workspaces, list_boards, list_columns, list_tasks, create_task, update_task';
const kandevTools = 'Tools: list_workspaces, list_boards, list_workflow_steps, list_tasks, create_task, update_task';
const popularServers: Record<string, Record<string, unknown>> = {
kandev: {
@@ -1,32 +1,36 @@
import {
getWorkspaceAction,
listBoardsAction,
listColumnsAction,
listWorkflowTemplatesAction,
} from '@/app/actions/workspaces';
import { WorkspaceBoardsClient } from '@/app/settings/workspace/workspace-boards-client';
import type { Board, WorkflowTemplate } from '@/lib/types/http';
export default async function WorkspaceBoardsPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
let workspace = null;
let boardsWithColumns: Array<
Awaited<ReturnType<typeof listBoardsAction>>['boards'][number] & {
columns: Awaited<ReturnType<typeof listColumnsAction>>['columns'];
}
> = [];
let boards: Board[] = [];
let workflowTemplates: WorkflowTemplate[] = [];
try {
workspace = await getWorkspaceAction(id);
const boards = await listBoardsAction(id);
boardsWithColumns = await Promise.all(
boards.boards.map(async (board) => {
const columns = await listColumnsAction(board.id);
return { ...board, columns: columns.columns };
})
);
const [boardsResponse, templatesResponse] = await Promise.all([
listBoardsAction(id),
listWorkflowTemplatesAction(),
]);
boards = boardsResponse.boards;
workflowTemplates = templatesResponse.templates ?? [];
} catch {
workspace = null;
boardsWithColumns = [];
boards = [];
workflowTemplates = [];
}
return <WorkspaceBoardsClient workspace={workspace} boards={boardsWithColumns} />;
return (
<WorkspaceBoardsClient
workspace={workspace}
boards={boards}
workflowTemplates={workflowTemplates}
/>
);
}
@@ -3,127 +3,87 @@
import { useMemo, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { IconLayoutColumns, IconPlus } from '@tabler/icons-react';
import { IconLayoutColumns, IconPlus, IconRobot, IconCheck, IconArrowRight } from '@tabler/icons-react';
import { Button } from '@kandev/ui/button';
import { Card, CardContent } from '@kandev/ui/card';
import { Separator } from '@kandev/ui/separator';
import { Label } from '@kandev/ui/label';
import { Input } from '@kandev/ui/input';
import { RadioGroup, RadioGroupItem } from '@kandev/ui/radio-group';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@kandev/ui/dialog';
import { SettingsSection } from '@/components/settings/settings-section';
import { BoardCard } from '@/components/settings/board-card';
import { generateUUID } from '@/lib/utils';
import { cn } from '@kandev/ui/lib/utils';
import {
createBoardAction,
createColumnAction,
deleteBoardAction,
deleteColumnAction,
updateBoardAction,
updateColumnAction,
} from '@/app/actions/workspaces';
import type { Board, Column, Workspace } from '@/lib/types/http';
type BoardWithColumns = Board & { columns: Column[] };
import type { Board, Workspace, WorkflowTemplate } from '@/lib/types/http';
type WorkspaceBoardsClientProps = {
workspace: Workspace | null;
boards: BoardWithColumns[];
boards: Board[];
workflowTemplates: WorkflowTemplate[];
};
export function WorkspaceBoardsClient({ workspace, boards }: WorkspaceBoardsClientProps) {
export function WorkspaceBoardsClient({
workspace,
boards,
workflowTemplates,
}: WorkspaceBoardsClientProps) {
const router = useRouter();
const [boardItems, setBoardItems] = useState<BoardWithColumns[]>(boards);
const [savedBoardItems, setSavedBoardItems] = useState<BoardWithColumns[]>(boards);
const [boardItems, setBoardItems] = useState<Board[]>(boards);
const [savedBoardItems, setSavedBoardItems] = useState<Board[]>(boards);
const cloneBoard = (board: BoardWithColumns): BoardWithColumns => ({
...board,
columns: board.columns.map((column) => ({ ...column })),
});
// Dialog state for creating a new board
const [isAddBoardDialogOpen, setIsAddBoardDialogOpen] = useState(false);
const [newBoardName, setNewBoardName] = useState('');
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null);
// Get selected template steps for preview
const selectedTemplate = workflowTemplates.find((t) => t.id === selectedTemplateId);
const previewSteps = selectedTemplate?.default_steps ?? [];
const savedBoardsById = useMemo(() => {
return new Map(savedBoardItems.map((board) => [board.id, board]));
}, [savedBoardItems]);
const isBoardNameDirty = (board: BoardWithColumns) => {
const isBoardDirty = (board: Board) => {
const saved = savedBoardsById.get(board.id);
if (!saved) return true;
if (board.name !== saved.name || board.description !== saved.description) return true;
return false;
};
const areColumnsDirty = (board: BoardWithColumns) => {
const saved = savedBoardsById.get(board.id);
if (!saved) {
return board.columns.length > 0;
}
if (board.columns.length !== saved.columns.length) return true;
const savedColumns = new Map(saved.columns.map((column) => [column.id, column]));
for (const column of board.columns) {
const savedColumn = savedColumns.get(column.id);
if (!savedColumn) return true;
if (
column.name !== savedColumn.name ||
column.color !== savedColumn.color ||
column.position !== savedColumn.position ||
column.state !== savedColumn.state
) {
return true;
}
}
return false;
const handleOpenAddBoardDialog = () => {
setNewBoardName('');
setSelectedTemplateId(workflowTemplates.length > 0 ? workflowTemplates[0].id : null);
setIsAddBoardDialogOpen(true);
};
const handleAddBoard = () => {
const handleCreateBoard = () => {
if (!workspace) return;
const draftBoardId = `temp-${generateUUID()}`;
const draftBoard: BoardWithColumns = {
id: draftBoardId,
const draftBoard: Board = {
id: `temp-${generateUUID()}`,
workspace_id: workspace.id,
name: 'New Board',
name: newBoardName.trim() || 'New Board',
description: '',
workflow_template_id: selectedTemplateId,
created_at: '',
updated_at: '',
columns: [
{
id: `temp-col-${generateUUID()}`,
board_id: draftBoardId,
name: 'Todo',
position: 0,
state: 'TODO',
color: 'bg-cyan-500',
created_at: '',
updated_at: '',
},
{
id: `temp-col-${generateUUID()}`,
board_id: draftBoardId,
name: 'In Progress',
position: 1,
state: 'IN_PROGRESS',
color: 'bg-yellow-500',
created_at: '',
updated_at: '',
},
{
id: `temp-col-${generateUUID()}`,
board_id: draftBoardId,
name: 'To Review',
position: 2,
state: 'REVIEW',
color: 'bg-green-500',
created_at: '',
updated_at: '',
},
{
id: `temp-col-${generateUUID()}`,
board_id: draftBoardId,
name: 'Done',
position: 3,
state: 'COMPLETED',
color: 'bg-indigo-500',
created_at: '',
updated_at: '',
},
],
};
setBoardItems((prev) => [draftBoard, ...prev]);
setIsAddBoardDialogOpen(false);
};
const handleUpdateBoard = (boardId: string, updates: { name?: string; description?: string }) => {
@@ -142,69 +102,6 @@ export function WorkspaceBoardsClient({ workspace, boards }: WorkspaceBoardsClie
setSavedBoardItems((prev) => prev.filter((board) => board.id !== boardId));
};
const handleCreateColumn = (
boardId: string,
column: Omit<Column, 'id' | 'board_id' | 'created_at' | 'updated_at'>
) => {
const draftColumn: Column = {
id: `temp-col-${generateUUID()}`,
board_id: boardId,
name: column.name,
position: column.position,
state: column.state,
color: column.color,
created_at: '',
updated_at: '',
};
setBoardItems((prev) =>
prev.map((board) =>
board.id === boardId ? { ...board, columns: [...board.columns, draftColumn] } : board
)
);
};
const handleUpdateColumn = (boardId: string, columnId: string, updates: Partial<Column>) => {
setBoardItems((prev) =>
prev.map((board) =>
board.id === boardId
? {
...board,
columns: board.columns.map((column) =>
column.id === columnId ? { ...column, ...updates } : column
),
}
: board
)
);
};
const handleDeleteColumn = async (boardId: string, columnId: string) => {
if (boardId.startsWith('temp-')) {
setBoardItems((prev) =>
prev.map((board) =>
board.id === boardId
? { ...board, columns: board.columns.filter((column) => column.id !== columnId) }
: board
)
);
return;
}
await deleteColumnAction(columnId);
setBoardItems((prev) =>
prev.map((board) =>
board.id === boardId
? { ...board, columns: board.columns.filter((column) => column.id !== columnId) }
: board
)
);
};
const handleReorderColumns = (boardId: string, columns: Column[]) => {
setBoardItems((prev) =>
prev.map((board) => (board.id === boardId ? { ...board, columns } : board))
);
};
const handleSaveBoard = async (boardId: string) => {
const board = boardItems.find((item) => item.id === boardId);
if (!board) return;
@@ -213,26 +110,16 @@ export function WorkspaceBoardsClient({ workspace, boards }: WorkspaceBoardsClie
const createdBoard = await createBoardAction({
workspace_id: workspace?.id ?? board.workspace_id,
name,
workflow_template_id: board.workflow_template_id ?? undefined,
});
const createdColumns = await Promise.all(
board.columns.map((column, index) =>
createColumnAction({
board_id: createdBoard.id,
name: column.name.trim() || 'New Column',
position: column.position ?? index,
state: column.state,
color: column.color,
})
)
);
const nextBoard = {
...createdBoard,
columns: createdColumns,
};
setBoardItems((prev) => prev.map((item) => (item.id === boardId ? nextBoard : item)));
setSavedBoardItems((prev) => [cloneBoard(nextBoard), ...prev]);
// Backend creates workflow steps automatically from the template
setBoardItems((prev) => prev.map((item) => (item.id === boardId ? createdBoard : item)));
setSavedBoardItems((prev) => [{ ...createdBoard }, ...prev]);
// Refresh the page to load the workflow steps created by the backend
router.refresh();
return;
}
// For existing boards, only update board name/description
const updates: { name?: string; description?: string } = {};
if (board.name.trim()) {
updates.name = board.name.trim();
@@ -240,34 +127,15 @@ export function WorkspaceBoardsClient({ workspace, boards }: WorkspaceBoardsClie
if (Object.keys(updates).length) {
await updateBoardAction(boardId, updates);
}
const nextColumns = await Promise.all(
board.columns.map((column, index) => {
if (column.id.startsWith('temp-col-')) {
return createColumnAction({
board_id: boardId,
name: column.name.trim() || 'New Column',
position: column.position ?? index,
state: column.state,
color: column.color,
});
}
return updateColumnAction(column.id, {
name: column.name,
color: column.color,
position: column.position ?? index,
state: column.state,
});
})
);
setBoardItems((prev) =>
prev.map((item) => (item.id === boardId ? { ...item, columns: nextColumns } : item))
prev.map((item) => (item.id === boardId ? { ...item, ...updates } : item))
);
setSavedBoardItems((prev) =>
prev.some((item) => item.id === boardId)
? prev.map((item) =>
item.id === boardId ? cloneBoard({ ...board, columns: nextColumns }) : item
item.id === boardId ? { ...board, ...updates } : item
)
: [...prev, cloneBoard({ ...board, columns: nextColumns })]
: [...prev, { ...board, ...updates }]
);
};
@@ -292,7 +160,7 @@ export function WorkspaceBoardsClient({ workspace, boards }: WorkspaceBoardsClie
<div>
<h2 className="text-2xl font-bold">{workspace.name}</h2>
<p className="text-sm text-muted-foreground mt-1">
Manage boards and columns for this workspace.
Manage boards for this workspace.
</p>
</div>
<Button asChild variant="outline" size="sm">
@@ -305,9 +173,9 @@ export function WorkspaceBoardsClient({ workspace, boards }: WorkspaceBoardsClie
<SettingsSection
icon={<IconLayoutColumns className="h-5 w-5" />}
title="Boards"
description="Boards and columns in this workspace"
description="Boards in this workspace"
action={
<Button size="sm" onClick={handleAddBoard}>
<Button size="sm" onClick={handleOpenAddBoardDialog}>
<IconPlus className="h-4 w-4 mr-2" />
Add Board
</Button>
@@ -318,22 +186,108 @@ export function WorkspaceBoardsClient({ workspace, boards }: WorkspaceBoardsClie
<BoardCard
key={board.id}
board={board}
columns={board.columns}
isBoardDirty={isBoardNameDirty(board)}
areColumnsDirty={areColumnsDirty(board)}
isBoardDirty={isBoardDirty(board)}
onUpdateBoard={(updates) => handleUpdateBoard(board.id, updates)}
onDeleteBoard={() => handleDeleteBoard(board.id)}
onCreateColumn={(column) => handleCreateColumn(board.id, column)}
onUpdateColumn={(columnId, updates) =>
handleUpdateColumn(board.id, columnId, updates)
}
onDeleteColumn={(columnId) => handleDeleteColumn(board.id, columnId)}
onReorderColumns={(columns) => handleReorderColumns(board.id, columns)}
onSaveBoard={() => handleSaveBoard(board.id)}
/>
))}
</div>
</SettingsSection>
{/* Create Board Dialog */}
<Dialog open={isAddBoardDialogOpen} onOpenChange={setIsAddBoardDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Create Board</DialogTitle>
</DialogHeader>
<div className="space-y-6">
<div className="space-y-2">
<Label htmlFor="boardName">Board Name</Label>
<Input
id="boardName"
placeholder="My Project Board"
value={newBoardName}
onChange={(e) => setNewBoardName(e.target.value)}
/>
</div>
{workflowTemplates.length > 0 && (
<div className="space-y-2">
<Label>Workflow Template</Label>
<RadioGroup
value={selectedTemplateId ?? 'custom'}
onValueChange={(value) =>
setSelectedTemplateId(value === 'custom' ? null : value)
}
>
{workflowTemplates.map((template) => (
<div key={template.id} className="flex items-start space-x-3">
<RadioGroupItem value={template.id} id={template.id} className="mt-1" />
<label htmlFor={template.id} className="flex flex-col cursor-pointer">
<span className="font-medium">{template.name}</span>
{template.description && (
<span className="text-sm text-muted-foreground">
{template.description}
</span>
)}
</label>
</div>
))}
<div className="flex items-start space-x-3">
<RadioGroupItem value="custom" id="custom" className="mt-1" />
<label htmlFor="custom" className="flex flex-col cursor-pointer">
<span className="font-medium">Custom</span>
<span className="text-sm text-muted-foreground">
Start with basic columns (Todo, In Progress, Review, Done)
</span>
</label>
</div>
</RadioGroup>
</div>
)}
{/* Workflow Preview */}
{previewSteps.length > 0 && (
<div className="border rounded-lg p-4 bg-muted/50">
<Label className="text-sm">Workflow Preview</Label>
<div className="flex items-center gap-2 mt-2 overflow-x-auto pb-2">
{previewSteps.map((step, i) => (
<div key={`step-${i}`} className="flex items-center gap-2">
<div
className={cn(
'px-3 py-1.5 rounded text-sm whitespace-nowrap flex items-center gap-1',
step.color || 'bg-slate-500',
'text-white'
)}
>
{step.name}
{step.behaviors?.autoStartAgent && (
<IconRobot className="h-3 w-3" />
)}
{step.behaviors?.requireApproval && (
<IconCheck className="h-3 w-3" />
)}
</div>
{i < previewSteps.length - 1 && (
<IconArrowRight className="h-4 w-4 text-muted-foreground shrink-0" />
)}
</div>
))}
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsAddBoardDialogOpen(false)}>
Cancel
</Button>
<Button onClick={handleCreateBoard}>Create Board</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+3 -3
View File
@@ -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));
};
+126 -7
View File
@@ -1,15 +1,29 @@
'use client';
import { useEffect } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Task } from './kanban-card';
import { TaskCreateDialog } from './task-create-dialog';
import { useAppStore, useAppStoreApi } from '@/components/state-provider';
import type { Task as BackendTask } from '@/lib/types/http';
import type { BoardState } from '@/lib/state/slices';
import { useDragAndDrop } from '@/hooks/use-drag-and-drop';
import { useDragAndDrop, type WorkflowAutomation, type MoveTaskError } from '@/hooks/use-drag-and-drop';
import { KanbanBoardGrid } from './kanban-board-grid';
import { KanbanHeader } from './kanban/kanban-header';
import { useKanbanData, useKanbanActions, useKanbanNavigation } from '@/hooks/domains/kanban';
import { getWebSocketClient } from '@/lib/ws/connection';
import { linkToSession } from '@/lib/links';
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogCancel,
AlertDialogAction,
} from '@kandev/ui/alert-dialog';
import { IconAlertTriangle } from '@tabler/icons-react';
interface KanbanBoardProps {
onPreviewTask?: (task: Task) => void;
@@ -19,6 +33,7 @@ interface KanbanBoardProps {
export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {}) {
// Store access
const store = useAppStoreApi();
const router = useRouter();
// Get initial state for actions hook
const kanban = useAppStore((state) => state.kanban);
@@ -66,8 +81,69 @@ export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {}
setTaskSessionAvailability,
});
// Drag and drop
const { activeTask, handleDragStart, handleDragEnd, handleDragCancel } = useDragAndDrop(visibleTasksWithSessions);
// Workflow automation state for session creation dialog
const [workflowAutomation, setWorkflowAutomation] = useState<WorkflowAutomation | null>(null);
const [isWorkflowDialogOpen, setIsWorkflowDialogOpen] = useState(false);
// Move error state for approval warning modal
const [moveError, setMoveError] = useState<MoveTaskError | null>(null);
// Handle workflow automation when task is moved to auto-start step
const handleWorkflowAutomation = useCallback((automation: WorkflowAutomation) => {
setWorkflowAutomation(automation);
setIsWorkflowDialogOpen(true);
}, []);
// Handle move error - show approval warning modal
const handleMoveError = useCallback((error: MoveTaskError) => {
setMoveError(error);
}, []);
// Handle navigation to task session from approval warning modal
const handleGoToTask = useCallback(() => {
if (moveError?.sessionId) {
router.push(linkToSession(moveError.sessionId));
}
setMoveError(null);
}, [moveError, router]);
// Handle session creation from workflow automation dialog
const handleWorkflowSessionCreate = useCallback(
async (data: { prompt: string; agentProfileId: string; executorId: string; environmentId: string }) => {
if (!workflowAutomation) return;
const client = getWebSocketClient();
if (!client) return;
try {
// Start the task with workflow step configuration
await client.request(
'orchestrator.start',
{
task_id: workflowAutomation.taskId,
agent_profile_id: data.agentProfileId,
executor_id: data.executorId,
prompt: data.prompt.trim(),
workflow_step_id: workflowAutomation.workflowStep.id,
},
15000
);
} catch (err) {
console.error('Failed to start session for workflow step:', err);
} finally {
setWorkflowAutomation(null);
setIsWorkflowDialogOpen(false);
}
},
[workflowAutomation]
);
// Drag and drop with workflow automation callback
const { activeTask, handleDragStart, handleDragEnd, handleDragCancel } = useDragAndDrop({
visibleTasks: visibleTasksWithSessions,
onWorkflowAutomation: handleWorkflowAutomation,
onMoveError: handleMoveError,
});
// Board selection effect
useEffect(() => {
@@ -94,7 +170,7 @@ export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {}
}
if (!desiredBoardId) {
store.getState().hydrate({
kanban: { boardId: null, columns: [], tasks: [] },
kanban: { boardId: null, steps: [], tasks: [] },
});
}
}, [
@@ -126,14 +202,14 @@ export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {}
workspaceId={workspaceState.activeId}
boardId={kanban.boardId}
defaultColumnId={activeColumns[0]?.id ?? null}
columns={activeColumns.map((column) => ({ id: column.id, title: column.title }))}
columns={activeColumns.map((column) => ({ id: column.id, title: column.title, autoStartAgent: column.autoStartAgent }))}
editingTask={
editingTask
? {
id: editingTask.id,
title: editingTask.title,
description: editingTask.description,
columnId: editingTask.columnId,
workflowStepId: editingTask.workflowStepId,
state: editingTask.state as BackendTask['state'],
repositoryId: editingTask.repositoryId,
}
@@ -152,6 +228,49 @@ export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {}
}
submitLabel={editingTask ? 'Update' : 'Create'}
/>
{/* Workflow automation session creation dialog */}
<TaskCreateDialog
key={isWorkflowDialogOpen ? 'workflow-open' : 'workflow-closed'}
open={isWorkflowDialogOpen}
onOpenChange={(open) => {
setIsWorkflowDialogOpen(open);
if (!open) setWorkflowAutomation(null);
}}
mode="session"
workspaceId={workspaceState.activeId}
boardId={kanban.boardId}
defaultColumnId={workflowAutomation?.workflowStep.id ?? null}
columns={activeColumns.map((column) => ({ id: column.id, title: column.title, autoStartAgent: column.autoStartAgent }))}
editingTask={null}
onCreateSession={handleWorkflowSessionCreate}
initialValues={{
title: '',
description: workflowAutomation?.taskDescription ?? '',
}}
submitLabel="Start Agent"
/>
{/* Approval warning modal */}
<AlertDialog open={!!moveError} onOpenChange={(open) => !open && setMoveError(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<IconAlertTriangle className="h-5 w-5 text-amber-500" />
Approval Required
</AlertDialogTitle>
<AlertDialogDescription>
{moveError?.message}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Dismiss</AlertDialogCancel>
{moveError?.sessionId && (
<AlertDialogAction onClick={handleGoToTask}>
Go to Task
</AlertDialogAction>
)}
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<KanbanBoardGrid
columns={activeColumns}
tasks={visibleTasksWithSessions}
+20 -1
View File
@@ -5,6 +5,7 @@ import { useDraggable } from '@dnd-kit/core';
import { CSS } from '@dnd-kit/utilities';
import { IconDots, IconArrowsMaximize } from '@tabler/icons-react';
import { Card, CardContent } from '@kandev/ui/card';
import { Badge } from '@kandev/ui/badge';
import {
DropdownMenu,
DropdownMenuContent,
@@ -19,12 +20,16 @@ import { useAppStore } from '@/components/state-provider';
export interface Task {
id: string;
title: string;
columnId: string;
workflowStepId: string;
state?: TaskState;
description?: string;
position?: number;
repositoryId?: string;
hasSession?: boolean;
// Workflow fields
sessionCount?: number;
primarySessionId?: string | null;
reviewStatus?: 'pending' | 'approved' | 'changes_requested' | 'rejected' | null;
}
interface KanbanCardProps {
@@ -62,6 +67,20 @@ function KanbanCardBody({
{task.description}
</p>
)}
{(task.sessionCount && task.sessionCount > 1 || task.reviewStatus === 'changes_requested') && (
<div className="flex items-center justify-between gap-2 mt-1">
{task.sessionCount && task.sessionCount > 1 && (
<Badge variant="secondary" className="text-xs h-5">
{task.sessionCount} sessions
</Badge>
)}
{task.reviewStatus === 'changes_requested' && (
<Badge variant="outline" className="border-amber-500 text-amber-600 bg-amber-50 dark:bg-amber-950/50 text-xs h-5">
Changes Requested
</Badge>
)}
</div>
)}
</>
);
}
+3 -2
View File
@@ -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;
+1 -1
View File
@@ -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,
+131 -200
View File
@@ -1,22 +1,7 @@
'use client';
import { useMemo, useState, useSyncExternalStore } from 'react';
import {
DndContext,
closestCenter,
type DragEndEvent,
} from '@dnd-kit/core';
import {
SortableContext,
arrayMove,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
IconGripVertical,
IconTrash,
IconPlus,
} from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import { IconTrash } from '@tabler/icons-react';
import { Card, CardContent } from '@kandev/ui/card';
import { Button } from '@kandev/ui/button';
import { Input } from '@kandev/ui/input';
@@ -29,173 +14,147 @@ import {
DialogHeader,
DialogTitle,
} from '@kandev/ui/dialog';
import type { Board, Column } from '@/lib/types/http';
import type { Board, WorkflowStep } from '@/lib/types/http';
import { useRequest } from '@/lib/http/use-request';
import { useToast } from '@/components/toast-provider';
import { UnsavedChangesBadge, UnsavedSaveButton } from '@/components/settings/unsaved-indicator';
import { WorkflowStepEditor } from '@/components/settings/workflow-step-editor';
import {
listWorkflowStepsAction,
createWorkflowStepAction,
updateWorkflowStepAction,
deleteWorkflowStepAction,
reorderWorkflowStepsAction,
} from '@/app/actions/workspaces';
type BoardCardProps = {
board: Board;
columns: Column[];
isBoardDirty: boolean;
areColumnsDirty: boolean;
onUpdateBoard: (updates: { name?: string; description?: string }) => void;
onDeleteBoard: () => Promise<unknown>;
onCreateColumn: (column: Omit<Column, 'id' | 'board_id' | 'created_at' | 'updated_at'>) => void;
onUpdateColumn: (columnId: string, updates: Partial<Column>) => void;
onDeleteColumn: (columnId: string) => Promise<unknown>;
onReorderColumns: (columns: Column[]) => void;
onSaveBoard: () => Promise<unknown>;
};
const COLOR_OPTIONS = [
{ value: 'bg-neutral-400', label: 'Gray' },
{ value: 'bg-red-500', label: 'Red' },
{ value: 'bg-orange-500', label: 'Orange' },
{ value: 'bg-yellow-500', label: 'Yellow' },
{ value: 'bg-green-500', label: 'Green' },
{ value: 'bg-blue-500', label: 'Blue' },
{ value: 'bg-indigo-500', label: 'Indigo' },
{ value: 'bg-violet-500', label: 'Violet' },
{ value: 'bg-cyan-500', label: 'Cyan' },
{ value: 'bg-sky-500', label: 'Sky' },
];
function SortableColumnRow({
column,
onUpdate,
onDelete,
}: {
column: Column;
onUpdate: (updates: Partial<Column>) => void;
onDelete: () => Promise<unknown>;
}) {
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
id: column.id,
});
export function BoardCard({
board,
isBoardDirty,
onUpdateBoard,
onDeleteBoard,
onSaveBoard,
}: BoardCardProps) {
const { toast } = useToast();
const deleteRequest = useRequest(onDelete);
const [deleteOpen, setDeleteOpen] = useState(false);
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const saveBoardRequest = useRequest(onSaveBoard);
const deleteBoardRequest = useRequest(onDeleteBoard);
const handleNameChange = (value: string) => {
onUpdate({ name: value });
};
// Workflow state
const [workflowSteps, setWorkflowSteps] = useState<WorkflowStep[]>([]);
const [workflowLoading, setWorkflowLoading] = useState(false);
const handleColorChange = (value: string) => {
onUpdate({ color: value });
};
const isNewBoard = board.id.startsWith('temp-');
const handleDelete = async () => {
// Load workflow steps on mount (only for saved boards)
useEffect(() => {
if (isNewBoard) return;
let cancelled = false;
const load = async () => {
setWorkflowLoading(true);
try {
const res = await listWorkflowStepsAction(board.id);
if (!cancelled) {
setWorkflowSteps(res.steps ?? []);
}
} catch {
if (!cancelled) {
toast({
title: 'Failed to load workflow steps',
variant: 'error',
});
}
} finally {
if (!cancelled) {
setWorkflowLoading(false);
}
}
};
load();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [board.id, isNewBoard]);
const refreshWorkflowSteps = async () => {
try {
await deleteRequest.run();
const res = await listWorkflowStepsAction(board.id);
setWorkflowSteps(res.steps ?? []);
} catch {
// Ignore errors on refresh
}
};
const handleUpdateWorkflowStep = async (stepId: string, updates: Partial<WorkflowStep>) => {
try {
await updateWorkflowStepAction(stepId, updates);
await refreshWorkflowSteps();
} catch (error) {
toast({
title: 'Failed to delete column',
title: 'Failed to update workflow step',
description: error instanceof Error ? error.message : 'Request failed',
variant: 'error',
});
}
};
return (
<div ref={setNodeRef} style={style} className="flex gap-2 items-start">
<button
type="button"
className="mt-2 p-1 rounded-md text-muted-foreground hover:text-foreground cursor-grab"
{...attributes}
{...listeners}
>
<IconGripVertical className="h-4 w-4" />
</button>
<div className="flex-1 grid grid-cols-[1fr_1fr_auto] gap-2 items-start">
<div className="space-y-1">
<Input
placeholder="Column title"
value={column.name}
onChange={(e) => handleNameChange(e.target.value)}
/>
</div>
<div className="space-y-1">
<select
value={column.color}
onChange={(e) => handleColorChange(e.target.value)}
className="w-full h-9 px-3 rounded-md border border-input bg-background text-sm"
>
{COLOR_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleDelete}
disabled={deleteRequest.isLoading}
className="mt-1"
>
<IconTrash className="h-4 w-4" />
</Button>
</div>
</div>
);
}
export function BoardCard({
board,
columns,
isBoardDirty,
areColumnsDirty,
onUpdateBoard,
onDeleteBoard,
onCreateColumn,
onUpdateColumn,
onDeleteColumn,
onReorderColumns,
onSaveBoard,
}: BoardCardProps) {
const columnItems = useMemo(() => columns.map((column) => column.id), [columns]);
const { toast } = useToast();
const [deleteOpen, setDeleteOpen] = useState(false);
const isMounted = useSyncExternalStore(
() => () => {},
() => true,
() => false
);
const saveBoardRequest = useRequest(onSaveBoard);
const deleteBoardRequest = useRequest(onDeleteBoard);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = columns.findIndex((column) => column.id === active.id);
const newIndex = columns.findIndex((column) => column.id === over.id);
if (oldIndex === -1 || newIndex === -1) return;
const nextColumns = arrayMove(columns, oldIndex, newIndex).map((column, index) => ({
...column,
position: index,
}));
onReorderColumns(nextColumns);
const handleAddWorkflowStep = async () => {
try {
await createWorkflowStepAction({
board_id: board.id,
name: 'New Step',
step_type: 'implementation',
position: workflowSteps.length,
color: 'bg-slate-500',
});
await refreshWorkflowSteps();
} catch (error) {
toast({
title: 'Failed to add workflow step',
description: error instanceof Error ? error.message : 'Request failed',
variant: 'error',
});
}
};
const handleAddColumn = async () => {
const lastColor = columns[columns.length - 1]?.color ?? COLOR_OPTIONS[0].value;
const lastIndex = COLOR_OPTIONS.findIndex((option) => option.value === lastColor);
const nextColor =
lastIndex >= 0 ? COLOR_OPTIONS[(lastIndex + 1) % COLOR_OPTIONS.length].value : COLOR_OPTIONS[0].value;
onCreateColumn({
name: '',
position: columns.length,
state: 'TODO',
color: nextColor,
});
const handleRemoveWorkflowStep = async (stepId: string) => {
try {
await deleteWorkflowStepAction(stepId);
await refreshWorkflowSteps();
} catch (error) {
toast({
title: 'Failed to delete workflow step',
description: error instanceof Error ? error.message : 'Request failed',
variant: 'error',
});
}
};
const handleReorderWorkflowSteps = async (reorderedSteps: WorkflowStep[]) => {
// Optimistically update the UI
setWorkflowSteps(reorderedSteps);
try {
const stepIds = reorderedSteps.map((step) => step.id);
await reorderWorkflowStepsAction(board.id, stepIds);
} catch (error) {
toast({
title: 'Failed to reorder workflow steps',
description: error instanceof Error ? error.message : 'Request failed',
variant: 'error',
});
// Refresh to get the actual order
await refreshWorkflowSteps();
}
};
const handleDeleteBoard = async () => {
@@ -239,7 +198,7 @@ export function BoardCard({
onChange={(e) => onUpdateBoard({ name: e.target.value })}
/>
<UnsavedSaveButton
isDirty={isBoardDirty || areColumnsDirty}
isDirty={isBoardDirty}
isLoading={saveBoardRequest.isLoading}
status={saveBoardRequest.status}
onClick={handleSaveBoard}
@@ -248,52 +207,24 @@ export function BoardCard({
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Label className="flex items-center gap-2">
<span>Columns</span>
{areColumnsDirty && <UnsavedChangesBadge />}
</Label>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddColumn}
>
<IconPlus className="h-4 w-4 mr-1" />
Add Column
</Button>
</div>
</div>
{/* Workflow Steps Section - only show for saved boards */}
{!isNewBoard && (
<div className="space-y-2">
{isMounted ? (
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={columnItems}>
{columns.map((column) => (
<SortableColumnRow
key={column.id}
column={column}
onUpdate={(updates) => onUpdateColumn(column.id, updates)}
onDelete={() => onDeleteColumn(column.id)}
/>
))}
</SortableContext>
</DndContext>
<Label>Workflow Steps</Label>
{workflowLoading ? (
<div className="text-sm text-muted-foreground">Loading workflow steps...</div>
) : (
columns.map((column) => (
<SortableColumnRow
key={column.id}
column={column}
onUpdate={(updates) => onUpdateColumn(column.id, updates)}
onDelete={() => onDeleteColumn(column.id)}
/>
))
<WorkflowStepEditor
steps={workflowSteps}
onUpdateStep={handleUpdateWorkflowStep}
onAddStep={handleAddWorkflowStep}
onRemoveStep={handleRemoveWorkflowStep}
onReorderSteps={handleReorderWorkflowSteps}
/>
)}
</div>
</div>
)}
<div className="flex justify-end">
<Button
type="button"
@@ -312,7 +243,7 @@ export function BoardCard({
<DialogHeader>
<DialogTitle>Delete board</DialogTitle>
<DialogDescription>
This will remove the board and its columns. Tasks will not be deleted. This action cannot be undone.
This will remove the board and its workflow steps. Tasks will not be deleted. This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -0,0 +1,351 @@
'use client';
import { useCallback, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import {
DndContext,
closestCenter,
type DragEndEvent,
} from '@dnd-kit/core';
import {
SortableContext,
arrayMove,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
IconGripVertical,
IconPlus,
IconTrash,
IconChevronDown,
IconChevronUp,
IconRobot,
IconCheck,
IconClipboard,
} from '@tabler/icons-react';
import { Card, CardContent } from '@kandev/ui/card';
import { Button } from '@kandev/ui/button';
import { Input } from '@kandev/ui/input';
import { Textarea } from '@kandev/ui/textarea';
import { Checkbox } from '@kandev/ui/checkbox';
import { Label } from '@kandev/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@kandev/ui/select';
import type { WorkflowStep, WorkflowStepType, StepBehaviors } from '@/lib/types/http';
// Debounce hook for text inputs
function useDebouncedCallback(
callback: (value: string) => void,
delay: number
): (value: string) => void {
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
return useCallback(
function debouncedFn(value: string) {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
callback(value);
}, delay);
},
[callback, delay]
);
}
const STEP_COLORS = [
{ value: 'bg-slate-500', label: 'Gray' },
{ value: 'bg-red-500', label: 'Red' },
{ value: 'bg-orange-500', label: 'Orange' },
{ value: 'bg-yellow-500', label: 'Yellow' },
{ value: 'bg-green-500', label: 'Green' },
{ value: 'bg-cyan-500', label: 'Cyan' },
{ value: 'bg-blue-500', label: 'Blue' },
{ value: 'bg-indigo-500', label: 'Indigo' },
{ value: 'bg-purple-500', label: 'Purple' },
];
const STEP_TYPES: { value: WorkflowStepType; label: string }[] = [
{ value: 'backlog', label: 'Backlog' },
{ value: 'planning', label: 'Planning' },
{ value: 'implementation', label: 'Implementation' },
{ value: 'review', label: 'Review' },
{ value: 'verification', label: 'Verification' },
{ value: 'done', label: 'Done' },
{ value: 'blocked', label: 'Blocked' },
];
type WorkflowStepEditorProps = {
steps: WorkflowStep[];
onUpdateStep: (stepId: string, updates: Partial<WorkflowStep>) => void;
onAddStep: () => void;
onRemoveStep: (stepId: string) => void;
onReorderSteps: (steps: WorkflowStep[]) => void;
};
type SortableStepCardProps = {
step: WorkflowStep;
onUpdate: (updates: Partial<WorkflowStep>) => void;
onRemove: () => void;
};
// Inner component that uses step values directly - reset via key prop from parent
function SortableStepCardInner({ step, onUpdate, onRemove }: SortableStepCardProps) {
const [expanded, setExpanded] = useState(false);
// Local state for text inputs to avoid API calls on every keystroke
const [localName, setLocalName] = useState(step.name);
const [localPromptPrefix, setLocalPromptPrefix] = useState(step.behaviors?.promptPrefix ?? '');
const [localPromptSuffix, setLocalPromptSuffix] = useState(step.behaviors?.promptSuffix ?? '');
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
id: step.id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const behaviors = step.behaviors ?? {};
// Debounced update functions
const debouncedUpdateName = useDebouncedCallback((name: string) => {
onUpdate({ name });
}, 500);
const debouncedUpdatePromptPrefix = useDebouncedCallback((promptPrefix: string) => {
onUpdate({ behaviors: { ...behaviors, promptPrefix } });
}, 500);
const debouncedUpdatePromptSuffix = useDebouncedCallback((promptSuffix: string) => {
onUpdate({ behaviors: { ...behaviors, promptSuffix } });
}, 500);
const updateBehaviors = (updates: Partial<StepBehaviors>) => {
onUpdate({ behaviors: { ...behaviors, ...updates } });
};
return (
<Card ref={setNodeRef} style={style} className="mb-2">
<CardContent className="p-3">
<div className="flex items-center gap-2">
<button
type="button"
className="p-1 rounded-md text-muted-foreground hover:text-foreground cursor-grab"
{...attributes}
{...listeners}
>
<IconGripVertical className="h-4 w-4" />
</button>
<div className={`w-4 h-4 rounded ${step.color}`} />
<Input
value={localName}
onChange={(e) => {
setLocalName(e.target.value);
debouncedUpdateName(e.target.value);
}}
className="flex-1"
placeholder="Step name"
/>
<Select
value={step.step_type}
onValueChange={(value: WorkflowStepType) => onUpdate({ step_type: value })}
>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
{STEP_TYPES.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-1 text-muted-foreground">
{behaviors.autoStartAgent && <IconRobot className="h-4 w-4" title="Auto-start agent" />}
{behaviors.requireApproval && <IconCheck className="h-4 w-4" title="Require approval" />}
{behaviors.planMode && <IconClipboard className="h-4 w-4" title="Plan mode" />}
</div>
<Button
type="button"
variant="ghost"
size="icon-sm"
onClick={() => setExpanded(!expanded)}
>
{expanded ? <IconChevronUp className="h-4 w-4" /> : <IconChevronDown className="h-4 w-4" />}
</Button>
<Button type="button" variant="ghost" size="icon-sm" onClick={onRemove}>
<IconTrash className="h-4 w-4" />
</Button>
</div>
{expanded && (
<div className="mt-4 pt-4 border-t space-y-4">
<div className="grid grid-cols-3 gap-4">
<div className="flex items-center space-x-2">
<Checkbox
id={`${step.id}-auto-start`}
checked={behaviors.autoStartAgent ?? false}
onCheckedChange={(checked) => updateBehaviors({ autoStartAgent: checked === true })}
/>
<Label htmlFor={`${step.id}-auto-start`}>Auto-start agent</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id={`${step.id}-plan-mode`}
checked={behaviors.planMode ?? false}
onCheckedChange={(checked) => updateBehaviors({ planMode: checked === true })}
/>
<Label htmlFor={`${step.id}-plan-mode`}>Plan mode</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id={`${step.id}-require-approval`}
checked={behaviors.requireApproval ?? false}
onCheckedChange={(checked) => updateBehaviors({ requireApproval: checked === true })}
/>
<Label htmlFor={`${step.id}-require-approval`}>Require approval</Label>
</div>
</div>
<div className="space-y-2">
<Label htmlFor={`${step.id}-prompt-prefix`}>Prompt Prefix</Label>
<Textarea
id={`${step.id}-prompt-prefix`}
value={localPromptPrefix}
onChange={(e) => {
setLocalPromptPrefix(e.target.value);
debouncedUpdatePromptPrefix(e.target.value);
}}
placeholder="Text prepended to the task description when agent starts..."
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${step.id}-prompt-suffix`}>Prompt Suffix</Label>
<Textarea
id={`${step.id}-prompt-suffix`}
value={localPromptSuffix}
onChange={(e) => {
setLocalPromptSuffix(e.target.value);
debouncedUpdatePromptSuffix(e.target.value);
}}
placeholder="Text appended to the task description when agent starts..."
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor={`${step.id}-color`}>Color</Label>
<Select value={step.color} onValueChange={(value) => onUpdate({ color: value })}>
<SelectTrigger className="w-[180px]">
<div className="flex items-center gap-2">
<div className={`w-3 h-3 rounded ${step.color}`} />
<SelectValue placeholder="Select color" />
</div>
</SelectTrigger>
<SelectContent>
{STEP_COLORS.map((color) => (
<SelectItem key={color.value} value={color.value}>
<div className="flex items-center gap-2">
<div className={`w-3 h-3 rounded ${color.value}`} />
{color.label}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
</CardContent>
</Card>
);
}
// Wrapper component that uses key to reset local state when step data changes externally
function SortableStepCard({ step, onUpdate, onRemove }: SortableStepCardProps) {
// Generate a stable key based on step values that should trigger a reset
// This resets local state when the step is reordered or updated externally
const resetKey = `${step.id}-${step.name}-${step.behaviors?.promptPrefix ?? ''}-${step.behaviors?.promptSuffix ?? ''}`;
return <SortableStepCardInner key={resetKey} step={step} onUpdate={onUpdate} onRemove={onRemove} />;
}
export function WorkflowStepEditor({
steps,
onUpdateStep,
onAddStep,
onRemoveStep,
onReorderSteps,
}: WorkflowStepEditorProps) {
const stepItems = useMemo(() => steps.map((step) => step.id), [steps]);
const isMounted = useSyncExternalStore(
() => () => {},
() => true,
() => false
);
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = steps.findIndex((step) => step.id === active.id);
const newIndex = steps.findIndex((step) => step.id === over.id);
if (oldIndex === -1 || newIndex === -1) return;
const nextSteps = arrayMove(steps, oldIndex, newIndex).map((step, index) => ({
...step,
position: index,
}));
onReorderSteps(nextSteps);
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium">Workflow Steps</h3>
<Button variant="outline" size="sm" onClick={onAddStep}>
<IconPlus className="mr-2 h-4 w-4" />
Add Step
</Button>
</div>
<div className="space-y-2">
{isMounted ? (
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={stepItems}>
{steps.map((step) => (
<SortableStepCard
key={step.id}
step={step}
onUpdate={(updates) => onUpdateStep(step.id, updates)}
onRemove={() => onRemoveStep(step.id)}
/>
))}
</SortableContext>
</DndContext>
) : (
steps.map((step) => (
<SortableStepCard
key={step.id}
step={step}
onUpdate={(updates) => onUpdateStep(step.id, updates)}
onRemove={() => onRemoveStep(step.id)}
/>
))
)}
</div>
</div>
);
}
+7 -8
View File
@@ -53,8 +53,8 @@ interface TaskCreateDialogProps {
workspaceId: string | null;
boardId: string | null;
defaultColumnId: string | null;
columns: Array<{ id: string; title: string }>;
editingTask?: { id: string; title: string; description?: string; columnId: string; state?: Task['state']; repositoryId?: string } | null;
columns: Array<{ id: string; title: string; autoStartAgent?: boolean }>;
editingTask?: { id: string; title: string; description?: string; workflowStepId: string; state?: Task['state']; repositoryId?: string } | null;
onSuccess?: (task: Task, mode: 'create' | 'edit', meta?: { taskSessionId?: string | null }) => void;
onCreateSession?: (data: {
prompt: string;
@@ -538,22 +538,21 @@ export function TaskCreateDialog({
}
if (!workspaceId || !boardId) return;
if (!repositoryId && !selectedLocalRepo) return;
const columnId = editingTask?.columnId ?? defaultColumnId;
const columnId = editingTask?.workflowStepId ?? defaultColumnId;
if (!columnId) return;
let targetColumnId = columnId;
let targetState: Task['state'] = 'CREATED';
if (startAgent && !isEditMode) {
const progressColumn = columns.find((column) =>
column.title.toLowerCase().includes('progress')
);
targetColumnId = progressColumn?.id ?? columnId;
// Find the first step that has auto_start_agent enabled
const autoStartStep = columns.find((column) => column.autoStartAgent);
targetColumnId = autoStartStep?.id ?? columnId;
targetState = 'IN_PROGRESS';
}
try {
const taskResponse = await createTask({
workspace_id: workspaceId,
board_id: boardId,
column_id: targetColumnId,
workflow_step_id: targetColumnId,
title: trimmedTitle,
description: description.trim(),
repositories: repositoryId
+21 -1
View File
@@ -1,6 +1,6 @@
'use client';
import { IconBrain, IconListCheck, IconLoader2, IconPaperclip } from '@tabler/icons-react';
import { IconBrain, IconCheck, IconListCheck, IconLoader2, IconPaperclip } from '@tabler/icons-react';
import { Button } from '@kandev/ui/button';
import {
DropdownMenu,
@@ -27,6 +27,8 @@ type ChatToolbarProps = {
isStarting: boolean;
isSending: boolean;
onCancel: () => void;
showApproveButton?: boolean;
onApprove?: () => void;
};
export function ChatToolbar({
@@ -40,6 +42,8 @@ export function ChatToolbar({
isStarting,
isSending,
onCancel,
showApproveButton,
onApprove,
}: ChatToolbarProps) {
return (
<div className="flex items-center justify-between gap-2">
@@ -102,6 +106,22 @@ export function ChatToolbar({
</TooltipTrigger>
<TooltipContent>Add attachments</TooltipContent>
</Tooltip>
{showApproveButton && onApprove && (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="outline"
className="h-7 gap-1 border-green-300 text-green-700 hover:bg-green-50 hover:text-green-800"
onClick={onApprove}
>
<IconCheck className="h-3.5 w-3.5" />
Approve
</Button>
</TooltipTrigger>
<TooltipContent>Approve and move to next step</TooltipContent>
</Tooltip>
)}
<KeyboardShortcutTooltip shortcut={SHORTCUTS.SUBMIT} enabled={!isAgentBusy && !isStarting}>
<Button
type={isAgentBusy ? 'button' : 'submit'}
@@ -3,7 +3,6 @@
import ReactMarkdown from 'react-markdown';
import remarkBreaks from 'remark-breaks';
import remarkGfm from 'remark-gfm';
import { IconRobot } from '@tabler/icons-react';
import { cn } from '@/lib/utils';
import type { Message } from '@/lib/types/http';
import { RichBlocks } from '@/components/task/chat/messages/rich-blocks';
@@ -62,7 +61,7 @@ export function ChatMessage({ comment, label, className, showRichBlocks }: ChatM
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkBreaks]}
components={{
code: ({ className, children, ...props }) => {
code: ({ className, children }) => {
// Check if it's inline code (no className means inline, className with language-* means code block)
const isInline = !className || !className.startsWith('language-');
@@ -87,7 +87,6 @@ export function CodeBlock({ children, className }: CodeBlockProps) {
const effectiveTheme = theme === 'system' ? systemTheme : theme;
const code = String(children).replace(/\n$/, '');
const language = className?.replace('language-', '') || '';
const languageExtension = getLanguageExtension(className);
// Custom padding theme
@@ -77,12 +77,6 @@ export function ScriptExecutionMessage({ comment }: { comment: Message }) {
? IconCheck
: IconX;
const statusText = isRunning
? 'Running...'
: isSuccess
? 'Completed'
: 'Failed';
const statusColor = isRunning
? 'text-blue-500'
: isSuccess
@@ -1,7 +1,7 @@
'use client';
import { useState } from 'react';
import { IconAlertTriangle, IconInfoCircle, IconPointFilled, IconHandStop, IconChevronDown, IconChevronRight } from '@tabler/icons-react';
import { IconAlertTriangle, IconInfoCircle, IconHandStop, IconChevronDown, IconChevronRight } from '@tabler/icons-react';
import { cn } from '@/lib/utils';
import type { Message } from '@/lib/types/http';
import type { StatusMetadata } from '@/components/task/chat/types';
@@ -2,7 +2,6 @@
import { useState } from 'react';
import { IconBrain, IconChevronDown, IconChevronRight } from '@tabler/icons-react';
import { cn } from '@/lib/utils';
import type { Message } from '@/lib/types/http';
import type { RichMetadata } from '@/components/task/chat/types';
@@ -0,0 +1,46 @@
'use client';
import type { WorkflowStep } from '@/lib/types/http';
import { cn } from '@kandev/ui/lib/utils';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@kandev/ui/tooltip';
type SessionWorkflowProgressProps = {
steps: WorkflowStep[];
currentStepId: string | null;
className?: string;
};
export function SessionWorkflowProgress({
steps,
currentStepId,
className,
}: SessionWorkflowProgressProps) {
const currentStepIndex = currentStepId
? steps.findIndex((step) => step.id === currentStepId)
: -1;
return (
<div className={cn('flex items-center gap-1', className)}>
{steps.map((step, i) => (
<Tooltip key={step.id}>
<TooltipTrigger asChild>
<div
className={cn(
'w-2 h-2 rounded-full',
i < currentStepIndex && 'bg-green-500',
i === currentStepIndex && 'bg-blue-500 ring-2 ring-blue-200',
i > currentStepIndex && 'bg-gray-300'
)}
/>
</TooltipTrigger>
<TooltipContent>{step.name}</TooltipContent>
</Tooltip>
))}
</div>
);
}
+29 -2
View File
@@ -1,7 +1,7 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { IconStack2, IconPlus } from '@tabler/icons-react';
import { IconStack2, IconPlus, IconStar } from '@tabler/icons-react';
import { Button } from '@kandev/ui/button';
import {
DropdownMenu,
@@ -88,6 +88,8 @@ type SessionsDropdownProps = {
activeSessionId?: string | null;
taskTitle?: string;
taskDescription?: string;
primarySessionId?: string | null;
onSetPrimary?: (sessionId: string) => void;
};
export function SessionsDropdown({
@@ -95,6 +97,8 @@ export function SessionsDropdown({
activeSessionId = null,
taskTitle = '',
taskDescription = '',
primarySessionId = null,
onSetPrimary,
}: SessionsDropdownProps) {
const [currentTime, setCurrentTime] = useState(() => Date.now());
const [showNewSessionDialog, setShowNewSessionDialog] = useState(false);
@@ -242,6 +246,8 @@ export function SessionsDropdown({
const showDuration = duration !== '0s';
const number = sortedSessions.length - index;
const isPrimary = session.id === primarySessionId;
return (
<div
key={session.id}
@@ -253,8 +259,11 @@ export function SessionsDropdown({
#{number}
</span>
<span className="text-xs text-foreground flex-1 text-left">
<span className="text-xs text-foreground flex-1 text-left flex items-center gap-1.5">
{resolveAgentLabel(session)}
{isPrimary && (
<IconStar className="h-3.5 w-3.5 text-amber-500 fill-amber-500" />
)}
</span>
{showDuration ?
@@ -263,6 +272,24 @@ export function SessionsDropdown({
</span>
: ''}
{!isPrimary && onSetPrimary && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onSetPrimary(session.id);
}}
className="w-5 shrink-0 flex items-center justify-center text-muted-foreground hover:text-amber-500 transition-colors"
>
<IconStar className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="left">Set as Primary</TooltipContent>
</Tooltip>
)}
<div className="w-5 shrink-0 flex items-center justify-center">
<Tooltip>
<TooltipTrigger asChild>
@@ -3,6 +3,7 @@
import { FormEvent, useCallback, useEffect, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import { getWebSocketClient } from '@/lib/ws/connection';
import { useAppStore } from '@/components/state-provider';
import { useSessionMessages } from '@/hooks/domains/session/use-session-messages';
import { useSettingsData } from '@/hooks/domains/settings/use-settings-data';
import { useSessionState } from '@/hooks/domains/session/use-session-state';
@@ -14,6 +15,7 @@ import { RunningIndicator } from '@/components/task/chat/messages/running-indica
import { TodoSummary } from '@/components/task/chat/todo-summary';
import { VirtualizedMessageList } from '@/components/task/chat/virtualized-message-list';
import { ChatToolbar } from '@/components/task/chat/chat-toolbar';
import { approveSessionAction } from '@/app/actions/workspaces';
type TaskChatPanelProps = {
onSend?: (message: string) => void;
@@ -89,6 +91,48 @@ export function TaskChatPanel({
}
}, [resolvedSessionId]);
// Get store action for updating session
const setTaskSession = useAppStore((state) => state.setTaskSession);
// Review panel handlers
const handleApprove = useCallback(async () => {
if (!resolvedSessionId || !taskId) return;
try {
const response = await approveSessionAction(resolvedSessionId);
// Update the session in the store so the review panel closes
if (response?.session) {
setTaskSession(response.session);
}
// Check if the new step has auto_start_agent enabled
// Use fire-and-forget (send) instead of request since resuming session + sending prompt
// can take a long time. Session state updates will come via WebSocket events anyway.
if (response?.workflow_step?.auto_start_agent) {
const client = getWebSocketClient();
if (client) {
client.send({
type: 'request',
action: 'orchestrator.start',
payload: {
task_id: taskId,
session_id: resolvedSessionId,
workflow_step_id: response.workflow_step.id,
},
});
}
}
} catch (error) {
console.error('Failed to approve session:', error);
}
}, [resolvedSessionId, taskId, setTaskSession]);
// Check if we should show the approve button
// Show when session has a review_status that is not approved (meaning it's in a review step)
const showApproveButton = session?.review_status != null && session.review_status !== 'approved';
const handleSubmit = async (event?: FormEvent) => {
event?.preventDefault();
const trimmed = messageInput.trim();
@@ -155,6 +199,8 @@ export function TaskChatPanel({
isStarting={isStarting}
isSending={isSending}
onCancel={handleCancelTurn}
showApproveButton={showApproveButton}
onApprove={handleApprove}
/>
</form>
</>
@@ -55,7 +55,7 @@ export function TaskPageContent({
...baseTask,
title: kanbanTask.title ?? baseTask.title,
description: kanbanTask.description ?? baseTask.description,
column_id: (kanbanTask.columnId as string | undefined) ?? baseTask.column_id,
workflow_step_id: (kanbanTask.workflowStepId as string | undefined) ?? baseTask.workflow_step_id,
position: kanbanTask.position ?? baseTask.position,
state: (kanbanTask.state as Task['state'] | undefined) ?? baseTask.state,
repositories: baseTask.repositories,
@@ -23,7 +23,7 @@ export function TaskSessionSidebar({ workspaceId, boardId }: TaskSessionSidebarP
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
const sessionsById = useAppStore((state) => state.taskSessions.items);
const { tasks } = useTasks(boardId);
const columns = useAppStore((state) => state.kanban.columns);
const columns = useAppStore((state) => state.kanban.steps);
const workspaces = useAppStore((state) => state.workspaces.items);
const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId);
const setActiveTask = useAppStore((state) => state.setActiveTask);
@@ -52,7 +52,7 @@ export function TaskSessionSidebar({ workspaceId, boardId }: TaskSessionSidebarP
title: task.title,
state: task.state as TaskState | undefined,
description: task.description,
columnId: task.columnId,
workflowStepId: task.workflowStepId,
repositoryPath: task.repositoryId ? repositoryPathsById.get(task.repositoryId) : undefined,
}));
}, [repositoriesByWorkspace, tasks, workspaceId]);
@@ -121,7 +121,7 @@ export function TaskSessionSidebar({ workspaceId, boardId }: TaskSessionSidebarP
<div className="flex-1 min-h-0 overflow-y-auto space-y-4 pt-3">
<TaskSwitcher
tasks={tasksWithRepositories}
columns={columns.map((column: KanbanState['columns'][number]) => ({ id: column.id, title: column.title }))}
columns={columns.map((step: KanbanState['steps'][number]) => ({ id: step.id, title: step.title, color: step.color }))}
activeTaskId={activeTaskId}
selectedTaskId={selectedTaskId}
onSelectTask={(taskId) => {
@@ -137,13 +137,13 @@ export function TaskSessionSidebar({ workspaceId, boardId }: TaskSessionSidebarP
workspaceId={workspaceId}
boardId={boardId}
defaultColumnId={columns[0]?.id ?? null}
columns={columns.map((column: KanbanState['columns'][number]) => ({ id: column.id, title: column.title }))}
columns={columns.map((step: KanbanState['steps'][number]) => ({ id: step.id, title: step.title, color: step.color }))}
onSuccess={(task, _mode, meta) => {
store.setState((state) => {
if (state.kanban.boardId !== task.board_id) return state;
const nextTask = {
id: task.id,
columnId: task.column_id,
workflowStepId: task.workflow_step_id,
title: task.title,
description: task.description,
position: task.position ?? 0,
+9 -6
View File
@@ -19,13 +19,13 @@ type TaskSwitcherItem = {
title: string;
state?: TaskState;
description?: string;
columnId?: string;
workflowStepId?: string;
repositoryPath?: string;
};
type TaskSwitcherProps = {
tasks: TaskSwitcherItem[];
columns: Array<{ id: string; title: string }>;
columns: Array<{ id: string; title: string; color?: string }>;
activeTaskId: string | null;
selectedTaskId: string | null;
onSelectTask: (taskId: string) => void;
@@ -47,7 +47,7 @@ export function TaskSwitcher({
const tasksByColumn = useMemo(() => {
const grouped: Record<string, TaskSwitcherItem[]> = {};
for (const task of sortedTasks) {
const key = task.columnId ?? 'unknown';
const key = task.workflowStepId ?? 'unknown';
if (!grouped[key]) grouped[key] = [];
grouped[key].push(task);
}
@@ -56,9 +56,9 @@ export function TaskSwitcher({
const columnsWithFallback = useMemo(() => {
const seen = new Set(columns.map((column) => column.id));
const extra: Array<{ id: string; title: string }> = [];
const extra: Array<{ id: string; title: string; color?: string }> = [];
if (tasksByColumn.unknown?.length) {
extra.push({ id: 'unknown', title: 'Other' });
extra.push({ id: 'unknown', title: 'Other', color: 'bg-neutral-400' });
}
return [...columns, ...extra].filter((column) => {
if (seen.has(column.id)) return true;
@@ -95,7 +95,10 @@ export function TaskSwitcher({
className="group flex w-full items-center justify-between cursor-pointer"
aria-label="Toggle column"
>
<span className="truncate text-sm font-medium text-muted-foreground">{column.title}</span>
<span className="flex items-center gap-2 truncate text-sm font-medium text-muted-foreground">
<span className={`w-2 h-2 rounded-full shrink-0 ${column.color ?? 'bg-neutral-400'}`} />
{column.title}
</span>
<IconChevronRight className={`h-4 w-4 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity ${isColumnOpen ? 'rotate-90' : ''}`} />
</button>
{isColumnOpen && (
@@ -40,7 +40,7 @@ export function useKanbanActions({ kanban, workspaceState, boardsState }: UseKan
...kanban.tasks,
{
id: task.id,
columnId: task.column_id,
workflowStepId: task.workflow_step_id,
title: task.title,
description: task.description ?? undefined,
position: task.position ?? 0,
@@ -61,7 +61,7 @@ export function useKanbanActions({ kanban, workspaceState, boardsState }: UseKan
...item,
title: task.title,
description: task.description ?? undefined,
columnId: task.column_id ?? item.columnId,
workflowStepId: task.workflow_step_id ?? item.workflowStepId,
position: task.position ?? item.position,
state: task.state ?? item.state,
repositoryId: task.repositories?.[0]?.repository_id ?? item.repositoryId,
@@ -6,7 +6,7 @@ import { useBoards } from '@/hooks/use-boards';
import { useBoardSnapshot } from '@/hooks/use-board-snapshot';
import { useUserDisplaySettings } from '@/hooks/use-user-display-settings';
import { filterTasksByRepositories } from '@/lib/kanban/filters';
import type { Column } from '@/components/kanban-column';
import type { WorkflowStep } from '@/components/kanban-column';
type KanbanDataOptions = {
onWorkspaceChange: (workspaceId: string | null) => void;
@@ -46,26 +46,28 @@ export function useKanbanData({ onWorkspaceChange, onBoardChange }: KanbanDataOp
);
// Derived data
const columns = useMemo<Column[]>(
const columns = useMemo<WorkflowStep[]>(
() =>
[...kanban.columns]
[...kanban.steps]
.sort((a, b) => (a.position ?? 0) - (b.position ?? 0))
.map((column) => ({
id: column.id,
title: column.title,
color: column.color || 'bg-neutral-400',
.map((step) => ({
id: step.id,
title: step.title,
color: step.color || 'bg-neutral-400',
autoStartAgent: step.autoStartAgent,
})),
[kanban.columns]
[kanban.steps]
);
const tasks = kanban.tasks.map((task: typeof kanban.tasks[number]) => ({
id: task.id,
title: task.title,
columnId: task.columnId,
workflowStepId: task.workflowStepId,
state: task.state,
description: task.description,
position: task.position,
repositoryId: task.repositoryId,
primarySessionId: task.primarySessionId,
}));
const activeColumns = kanban.boardId ? columns : [];
+87 -9
View File
@@ -6,15 +6,40 @@ import { useAppStore, useAppStoreApi } from '@/components/state-provider';
import { useTaskActions } from '@/hooks/use-task-actions';
import type { Task } from '@/components/kanban-card';
import type { KanbanState } from '@/lib/state/slices';
import type { WorkflowStepDTO } from '@/lib/types/http';
import { getWebSocketClient } from '@/lib/ws/connection';
export type WorkflowAutomation = {
taskId: string;
sessionId: string | null;
workflowStep: WorkflowStepDTO;
taskDescription: string;
};
export type MoveTaskError = {
message: string;
taskId: string;
sessionId: string | null;
};
export type DragAndDropOptions = {
visibleTasks: Task[];
onWorkflowAutomation?: (automation: WorkflowAutomation) => void;
onMoveError?: (error: MoveTaskError) => void;
};
/**
* Custom hook that extracts drag-and-drop logic from the KanbanBoard component.
* Manages drag state and provides handlers for drag operations.
*
* @param visibleTasks - The list of visible tasks (filtered by repository, etc.)
* @param options - Configuration options for drag and drop
* @returns Object with drag state and drag operation handlers
*/
export function useDragAndDrop(visibleTasks: Task[]) {
export function useDragAndDrop({
visibleTasks,
onWorkflowAutomation,
onMoveError,
}: DragAndDropOptions) {
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
const [isMovingTask, setIsMovingTask] = useState(false);
const { moveTaskById } = useTaskActions();
@@ -39,18 +64,25 @@ export function useDragAndDrop(visibleTasks: Task[]) {
return;
}
// Find the task being moved from visibleTasks (which has session info)
const movedTask = visibleTasks.find((t) => t.id === taskId);
if (!movedTask) return;
const targetTasks = kanban.tasks
.filter((task: KanbanState['tasks'][number]) => task.columnId === newStatus && task.id !== taskId)
.filter((task: KanbanState['tasks'][number]) => task.workflowStepId === newStatus && task.id !== taskId)
.sort((a: KanbanState['tasks'][number], b: KanbanState['tasks'][number]) => a.position - b.position);
const nextPosition = targetTasks.length;
// Save original state for rollback
const originalTasks = kanban.tasks;
// Optimistic update
store.getState().hydrate({
kanban: {
...kanban,
tasks: kanban.tasks.map((task: KanbanState['tasks'][number]) =>
task.id === taskId
? { ...task, columnId: newStatus, position: nextPosition }
? { ...task, workflowStepId: newStatus, position: nextPosition }
: task
),
},
@@ -58,18 +90,64 @@ export function useDragAndDrop(visibleTasks: Task[]) {
try {
setIsMovingTask(true);
await moveTaskById(taskId, {
const response = await moveTaskById(taskId, {
board_id: kanban.boardId,
column_id: newStatus,
workflow_step_id: newStatus,
position: nextPosition,
});
} catch {
// Ignore move errors for now; WS updates or next snapshot will correct.
// Check if target step has auto_start_agent enabled
if (response?.workflow_step?.auto_start_agent) {
const sessionId = movedTask.primarySessionId ?? null;
if (sessionId) {
// Task has a session - auto-start with workflow step config
const client = getWebSocketClient();
if (client) {
try {
await client.request(
'orchestrator.start',
{
task_id: taskId,
session_id: sessionId,
workflow_step_id: response.workflow_step.id,
},
15000
);
} catch (err) {
console.error('Failed to auto-start session for workflow step:', err);
}
}
} else {
// No session - notify parent to show session creation dialog
onWorkflowAutomation?.({
taskId,
sessionId: null,
workflowStep: response.workflow_step,
taskDescription: movedTask.description ?? '',
});
}
}
} catch (error) {
// Revert optimistic update on error
store.getState().hydrate({
kanban: {
...store.getState().kanban,
tasks: originalTasks,
},
});
// Show error to user with task context
const message = error instanceof Error ? error.message : 'Failed to move task';
onMoveError?.({
message,
taskId,
sessionId: movedTask.primarySessionId ?? null,
});
} finally {
setIsMovingTask(false);
}
},
[kanban, isMovingTask, moveTaskById, store]
[kanban, isMovingTask, moveTaskById, store, onWorkflowAutomation, onMoveError, visibleTasks]
);
const handleDragCancel = useCallback(() => {
+1 -1
View File
@@ -1,7 +1,7 @@
import { useCallback } from 'react';
import { deleteTask, moveTask } from '@/lib/api';
type MovePayload = { board_id: string; column_id: string; position: number };
type MovePayload = { board_id: string; workflow_step_id: string; position: number };
export function useTaskActions() {
const moveTaskById = useCallback(async (taskId: string, payload: MovePayload) => {
+11 -1
View File
@@ -25,7 +25,17 @@ export async function fetchJson<T>(pathOrUrl: string, options?: ApiRequestOption
},
});
if (!response.ok) {
throw new Error(`Request failed: ${response.status} ${response.statusText} (${url})`);
// Try to extract error message from response body
let errorMessage = `Request failed: ${response.status} ${response.statusText}`;
try {
const errorBody = await response.json();
if (errorBody?.error) {
errorMessage = errorBody.error;
}
} catch {
// Ignore JSON parse errors, use default message
}
throw new Error(errorMessage);
}
if (response.status === 204) {
return undefined as T;
+4 -3
View File
@@ -5,6 +5,7 @@ import type {
ListBoardsResponse,
CreateTaskResponse,
Task,
MoveTaskResponse,
} from '@/lib/types/http';
// Board operations
@@ -24,7 +25,7 @@ export async function createTask(
payload: {
workspace_id: string;
board_id: string;
column_id: string;
workflow_step_id: string;
title: string;
description?: string;
position?: number;
@@ -77,10 +78,10 @@ export async function deleteTask(taskId: string, options?: ApiRequestOptions) {
export async function moveTask(
taskId: string,
payload: { board_id: string; column_id: string; position: number },
payload: { board_id: string; workflow_step_id: string; position: number },
options?: ApiRequestOptions
) {
return fetchJson<Task>(`/api/v1/tasks/${taskId}/move`, {
return fetchJson<MoveTaskResponse>(`/api/v1/tasks/${taskId}/move`, {
...options,
init: { method: 'POST', body: JSON.stringify(payload), ...(options?.init ?? {}) },
});
+52
View File
@@ -0,0 +1,52 @@
import { fetchJson, type ApiRequestOptions } from '../client';
import type {
ListWorkflowTemplatesResponse,
ListWorkflowStepsResponse,
ListSessionStepHistoryResponse,
WorkflowTemplate,
WorkflowStep,
} from '@/lib/types/http';
// Workflow Template operations
export async function listWorkflowTemplates(options?: ApiRequestOptions) {
return fetchJson<ListWorkflowTemplatesResponse>('/api/v1/workflow/templates', options);
}
export async function getWorkflowTemplate(templateId: string, options?: ApiRequestOptions) {
return fetchJson<WorkflowTemplate>(`/api/v1/workflow/templates/${templateId}`, options);
}
// Workflow Step operations
export async function listWorkflowSteps(boardId: string, options?: ApiRequestOptions) {
return fetchJson<ListWorkflowStepsResponse>(`/api/v1/workflow/steps?board_id=${boardId}`, options);
}
export async function getWorkflowStep(stepId: string, options?: ApiRequestOptions) {
return fetchJson<WorkflowStep>(`/api/v1/workflow/steps/${stepId}`, options);
}
export async function createWorkflowStep(
payload: {
board_id: string;
name: string;
step_type: string;
position: number;
color?: string;
behaviors?: Record<string, unknown>;
},
options?: ApiRequestOptions
) {
return fetchJson<WorkflowStep>('/api/v1/workflow/steps', {
...options,
init: { method: 'POST', body: JSON.stringify(payload), ...(options?.init ?? {}) },
});
}
// Session Step History operations
export async function listSessionStepHistory(sessionId: string, options?: ApiRequestOptions) {
return fetchJson<ListSessionStepHistoryResponse>(
`/api/v1/workflow/history?session_id=${sessionId}`,
options
);
}
+10 -8
View File
@@ -6,16 +6,17 @@ type KanbanTask = KanbanState['tasks'][number];
export function snapshotToState(snapshot: BoardSnapshot): Partial<AppState> {
const tasks = snapshot.tasks
.map((task) => {
const columnId = task.column_id;
if (!columnId) return null;
const workflowStepId = task.workflow_step_id;
if (!workflowStepId) return null;
return {
id: task.id,
columnId,
workflowStepId,
title: task.title,
description: task.description ?? undefined,
position: task.position ?? 0,
state: task.state,
repositoryId: task.repositories?.[0]?.repository_id ?? undefined,
primarySessionId: task.primary_session_id ?? undefined,
} as KanbanTask;
})
.filter((task): task is KanbanTask => task !== null);
@@ -23,11 +24,12 @@ export function snapshotToState(snapshot: BoardSnapshot): Partial<AppState> {
return {
kanban: {
boardId: snapshot.board.id,
columns: snapshot.columns.map((column) => ({
id: column.id,
title: column.name,
color: column.color ?? 'bg-neutral-400',
position: column.position,
steps: snapshot.steps.map((step) => ({
id: step.id,
title: step.name,
color: step.color ?? 'bg-neutral-400',
position: step.position,
autoStartAgent: step.auto_start_agent ?? false,
})),
tasks,
},
@@ -2,7 +2,7 @@ import type { StateCreator } from 'zustand';
import type { KanbanSlice, KanbanSliceState } from './types';
export const defaultKanbanState: KanbanSliceState = {
kanban: { boardId: null, columns: [], tasks: [] },
kanban: { boardId: null, steps: [], tasks: [] },
boards: { items: [], activeId: null },
tasks: { activeTaskId: null, activeSessionId: null },
};
+3 -2
View File
@@ -2,15 +2,16 @@ import type { TaskState as TaskStatus } from '@/lib/types/http';
export type KanbanState = {
boardId: string | null;
columns: Array<{ id: string; title: string; color: string; position: number }>;
steps: Array<{ id: string; title: string; color: string; position: number; autoStartAgent?: boolean }>;
tasks: Array<{
id: string;
columnId: string;
workflowStepId: string;
title: string;
description?: string;
position: number;
state?: TaskStatus;
repositoryId?: string;
primarySessionId?: string | null;
}>;
};
@@ -65,7 +65,10 @@ export const createSessionSlice: StateCreator<
prependMessages: (sessionId, messages, meta) =>
set((draft) => {
const existing = draft.messages.bySession[sessionId] || [];
draft.messages.bySession[sessionId] = [...messages, ...existing];
// Deduplicate - only add messages that don't already exist
const existingIds = new Set(existing.map((m) => m.id));
const newMessages = messages.filter((m) => !existingIds.has(m.id));
draft.messages.bySession[sessionId] = [...newMessages, ...existing];
if (!draft.messages.metaBySession[sessionId]) {
draft.messages.metaBySession[sessionId] = {
isLoading: false,
@@ -142,14 +145,24 @@ export const createSessionSlice: StateCreator<
set((draft) => {
// Merge with existing session data to preserve fields like agent_profile_id and agent_profile_snapshot
const existingSession = draft.taskSessions.items[session.id];
if (existingSession) {
draft.taskSessions.items[session.id] = {
...existingSession,
...session,
agent_profile_snapshot: session.agent_profile_snapshot ?? existingSession.agent_profile_snapshot,
};
} else {
draft.taskSessions.items[session.id] = session;
const mergedSession = existingSession
? {
...existingSession,
...session,
agent_profile_snapshot: session.agent_profile_snapshot ?? existingSession.agent_profile_snapshot,
}
: session;
draft.taskSessions.items[session.id] = mergedSession;
// Also update taskSessionsByTask to keep both stores in sync
const taskId = session.task_id;
const sessionsByTask = draft.taskSessionsByTask.itemsByTaskId[taskId];
if (sessionsByTask) {
const sessionIndex = sessionsByTask.findIndex((s) => s.id === session.id);
if (sessionIndex >= 0) {
sessionsByTask[sessionIndex] = mergedSession;
}
}
}),
setTaskSessionsForTask: (taskId, sessions) =>
+8 -4
View File
@@ -56,10 +56,10 @@ import type { GitEventPayload } from '@/lib/types/git-events';
export type KanbanUpdatePayload = {
boardId: string;
columns: Array<{ id: string; title: string; color?: string; position?: number }>;
steps: Array<{ id: string; title: string; color?: string; position?: number; autoStartAgent?: boolean }>;
tasks: Array<{
id: string;
columnId: string;
workflowStepId: string;
title: string;
position?: number;
description?: string;
@@ -70,7 +70,7 @@ export type KanbanUpdatePayload = {
export type TaskEventPayload = {
task_id: string;
board_id: string;
column_id: string;
workflow_step_id: string;
title: string;
description?: string;
state?: TaskState;
@@ -138,6 +138,7 @@ export type ColumnPayload = {
position: number;
state: string;
color: string;
auto_start_agent?: boolean;
created_at?: string;
updated_at?: string;
};
@@ -159,10 +160,13 @@ export type TaskSessionStateChangedPayload = {
task_id: string;
session_id: string;
old_state?: string;
new_state: string;
new_state?: string;
agent_profile_id?: string;
agent_profile_snapshot?: Record<string, unknown>;
metadata?: Record<string, unknown>;
// Workflow-related fields (sent during workflow transitions)
review_status?: string;
workflow_step_id?: string;
};
export type TaskSessionWaitingForInputPayload = {
+124 -18
View File
@@ -10,6 +10,94 @@ export type TaskState =
| 'FAILED'
| 'CANCELLED';
// Workflow Step Types
export type WorkflowStepType =
| 'backlog'
| 'planning'
| 'implementation'
| 'review'
| 'verification'
| 'done'
| 'blocked';
// Workflow Review Status
export type WorkflowReviewStatus =
| 'pending'
| 'approved'
| 'changes_requested'
| 'rejected';
// Step Behaviors - controls auto-start and other step-specific behaviors
export type StepBehaviors = {
autoStartAgent?: boolean;
planMode?: boolean;
requireApproval?: boolean;
promptPrefix?: string;
promptSuffix?: string;
};
// Workflow Template - pre-defined workflow configurations
export type WorkflowTemplate = {
id: string;
name: string;
description?: string | null;
is_system: boolean;
default_steps?: StepDefinition[];
created_at: string;
updated_at: string;
};
// Step Definition - template step configuration
export type StepDefinition = {
name: string;
step_type: WorkflowStepType;
position: number;
color?: string;
behaviors?: StepBehaviors;
};
// Workflow Step - instance of a step on a board
export type WorkflowStep = {
id: string;
board_id: string;
name: string;
step_type: WorkflowStepType;
position: number;
color: string;
behaviors?: StepBehaviors;
created_at: string;
updated_at: string;
};
// Session Step History - audit trail
export type SessionStepHistory = {
id: string;
session_id: string;
from_step_id?: string;
to_step_id: string;
trigger: string;
actor_id?: string;
notes?: string;
metadata?: Record<string, unknown>;
created_at: string;
};
// Response types for workflow APIs
export type ListWorkflowTemplatesResponse = {
templates: WorkflowTemplate[];
total: number;
};
export type ListWorkflowStepsResponse = {
steps: WorkflowStep[];
total: number;
};
export type ListSessionStepHistoryResponse = {
history: SessionStepHistory[];
total: number;
};
export type TaskSessionState =
| 'CREATED'
| 'STARTING'
@@ -24,6 +112,7 @@ export type Board = {
workspace_id: string;
name: string;
description?: string | null;
workflow_template_id?: string | null;
created_at: string;
updated_at: string;
};
@@ -40,17 +129,6 @@ export type Workspace = {
updated_at: string;
};
export type Column = {
id: string;
board_id: string;
name: string;
position: number;
state: TaskState;
color: string;
created_at: string;
updated_at: string;
};
export type Repository = {
id: string;
workspace_id: string;
@@ -116,13 +194,14 @@ export type Task = {
id: string;
workspace_id: string;
board_id: string;
column_id: string;
workflow_step_id: string;
position: number;
title: string;
description: string;
state: TaskState;
priority: number;
repositories?: TaskRepository[];
primary_session_id?: string | null;
created_at: string;
updated_at: string;
metadata?: Record<string, unknown> | null;
@@ -133,6 +212,28 @@ export type CreateTaskResponse = Task & {
agent_execution_id?: string;
};
// Backend workflow step DTO (flat fields, as returned from API)
export type WorkflowStepDTO = {
id: string;
board_id: string;
name: string;
step_type: string;
position: number;
color: string;
auto_start_agent: boolean;
plan_mode: boolean;
require_approval: boolean;
prompt_prefix?: string;
prompt_suffix?: string;
allow_manual_move: boolean;
};
// Response from moving a task - includes workflow step info for automation
export type MoveTaskResponse = {
task: Task;
workflow_step: WorkflowStepDTO;
};
export type TaskSession = {
id: string;
task_id: string;
@@ -156,6 +257,10 @@ export type TaskSession = {
started_at: string;
completed_at?: string | null;
updated_at: string;
// Workflow fields
is_primary?: boolean;
workflow_step_id?: string;
review_status?: WorkflowReviewStatus;
};
export type TaskSessionsResponse = {
@@ -167,6 +272,12 @@ export type TaskSessionResponse = {
session: TaskSession;
};
export type ApproveSessionResponse = {
success: boolean;
session: TaskSession;
workflow_step?: WorkflowStepDTO;
};
export type NotificationProviderType = 'local' | 'apprise' | 'system';
export type NotificationProvider = {
@@ -247,7 +358,7 @@ export type UserResponse = {
export type BoardSnapshot = {
board: Board;
columns: Column[];
steps: WorkflowStepDTO[];
tasks: Task[];
};
@@ -256,11 +367,6 @@ export type ListBoardsResponse = {
total: number;
};
export type ListColumnsResponse = {
columns: Column[];
total: number;
};
export type ListRepositoriesResponse = {
repositories: Repository[];
total: number;
+20 -13
View File
@@ -7,12 +7,14 @@ export function registerTaskSessionHandlers(store: StoreApi<AppState>): WsHandle
return {
'session.state_changed': (message) => {
const payload = message.payload;
if (!payload?.task_id || !payload?.new_state) {
if (!payload?.task_id) {
return;
}
const taskId = payload.task_id;
const newState = payload.new_state as TaskSessionState;
const newState = payload.new_state as TaskSessionState | undefined;
const sessionId = payload.session_id;
const reviewStatus = payload.review_status as string | undefined;
const workflowStepId = payload.workflow_step_id as string | undefined;
// Also update or create the session object if we have the session ID
if (sessionId) {
@@ -20,16 +22,21 @@ export function registerTaskSessionHandlers(store: StoreApi<AppState>): WsHandle
const agentProfileId = payload.agent_profile_id;
const agentProfileSnapshot = payload.agent_profile_snapshot as Record<string, unknown> | undefined;
// Build the update object with all possible fields
const sessionUpdate: Record<string, unknown> = {};
if (newState) sessionUpdate.state = newState;
if (reviewStatus !== undefined) sessionUpdate.review_status = reviewStatus;
if (workflowStepId !== undefined) sessionUpdate.workflow_step_id = workflowStepId;
if (agentProfileSnapshot) sessionUpdate.agent_profile_snapshot = agentProfileSnapshot;
if (existingSession) {
// Update existing session - preserve all existing fields, but update snapshot if provided
// Update existing session - preserve all existing fields
store.getState().setTaskSession({
...existingSession,
state: newState,
...(agentProfileSnapshot ? { agent_profile_snapshot: agentProfileSnapshot } : {}),
...sessionUpdate,
});
} else {
// Create partial session object
// Include agent_profile_id and agent_profile_snapshot from the payload if provided
} else if (newState) {
// Create partial session object only if we have a state
store.getState().setTaskSession({
id: sessionId,
task_id: taskId,
@@ -37,14 +44,14 @@ export function registerTaskSessionHandlers(store: StoreApi<AppState>): WsHandle
started_at: '',
updated_at: '',
...(agentProfileId ? { agent_profile_id: agentProfileId } : {}),
...(agentProfileSnapshot ? { agent_profile_snapshot: agentProfileSnapshot } : {}),
...sessionUpdate,
});
}
const sessionsByTask = store.getState().taskSessionsByTask.itemsByTaskId[taskId];
if (sessionsByTask) {
const hasSession = sessionsByTask.some((session) => session.id === sessionId);
if (!hasSession) {
if (!hasSession && newState) {
store.getState().setTaskSessionsForTask(taskId, [...sessionsByTask, {
id: sessionId,
task_id: taskId,
@@ -52,12 +59,12 @@ export function registerTaskSessionHandlers(store: StoreApi<AppState>): WsHandle
started_at: '',
updated_at: '',
...(agentProfileId ? { agent_profile_id: agentProfileId } : {}),
...(agentProfileSnapshot ? { agent_profile_snapshot: agentProfileSnapshot } : {}),
...sessionUpdate,
}]);
} else {
} else if (hasSession) {
const nextSessions = sessionsByTask.map((session) =>
session.id === sessionId
? { ...session, state: newState, ...(agentProfileSnapshot ? { agent_profile_snapshot: agentProfileSnapshot } : {}) }
? { ...session, ...sessionUpdate }
: session
);
store.getState().setTaskSessionsForTask(taskId, nextSessions);
+1 -1
View File
@@ -53,7 +53,7 @@ export function registerBoardsHandlers(store: StoreApi<AppState>): WsHandlers {
},
kanban:
state.kanban.boardId === message.payload.id
? { boardId: nextActiveId, columns: [], tasks: [] }
? { boardId: nextActiveId, steps: [], tasks: [] }
: state.kanban,
};
});
-82
View File
@@ -1,82 +0,0 @@
import type { StoreApi } from 'zustand';
import type { AppState } from '@/lib/state/store';
import type { WsHandlers } from '@/lib/ws/handlers/types';
export function registerColumnsHandlers(store: StoreApi<AppState>): WsHandlers {
return {
'column.created': (message) => {
store.setState((state) => {
if (state.kanban.boardId !== message.payload.board_id) {
return state;
}
const nextColumns = [
...state.kanban.columns,
{
id: message.payload.id,
title: message.payload.name,
color: message.payload.color,
position: message.payload.position,
},
].sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
return {
...state,
kanban: {
...state.kanban,
columns: nextColumns,
},
};
});
},
'column.updated': (message) => {
store.setState((state) => {
if (state.kanban.boardId !== message.payload.board_id) {
return state;
}
const exists = state.kanban.columns.some((column) => column.id === message.payload.id);
const updatedColumns = exists
? state.kanban.columns.map((column) =>
column.id === message.payload.id
? {
...column,
title: message.payload.name,
color: message.payload.color,
position: message.payload.position,
}
: column
)
: [
...state.kanban.columns,
{
id: message.payload.id,
title: message.payload.name,
color: message.payload.color,
position: message.payload.position,
},
];
const nextColumns = updatedColumns.sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
return {
...state,
kanban: {
...state.kanban,
columns: nextColumns,
},
};
});
},
'column.deleted': (message) => {
store.setState((state) => {
if (state.kanban.boardId !== message.payload.board_id) {
return state;
}
const nextColumns = state.kanban.columns.filter((column) => column.id !== message.payload.id);
return {
...state,
kanban: {
...state.kanban,
columns: nextColumns,
},
};
});
},
};
}
+7 -6
View File
@@ -9,15 +9,16 @@ export function registerKanbanHandlers(store: StoreApi<AppState>): WsHandlers {
...state,
kanban: {
boardId: message.payload.boardId,
columns: message.payload.columns.map((column, index) => ({
id: column.id,
title: column.title,
color: column.color ?? 'bg-neutral-400',
position: column.position ?? index,
steps: message.payload.steps.map((step, index) => ({
id: step.id,
title: step.title,
color: step.color ?? 'bg-neutral-400',
position: step.position ?? index,
autoStartAgent: step.autoStartAgent ?? false,
})),
tasks: message.payload.tasks.map((task) => ({
id: task.id,
columnId: task.columnId,
workflowStepId: task.workflowStepId,
title: task.title,
description: task.description,
position: task.position ?? 0,
+3 -3
View File
@@ -13,7 +13,7 @@ export function registerTasksHandlers(store: StoreApi<AppState>): WsHandlers {
const existingTask = state.kanban.tasks.find((task) => task.id === message.payload.task_id);
const nextTask = {
id: message.payload.task_id,
columnId: message.payload.column_id,
workflowStepId: message.payload.workflow_step_id,
title: message.payload.title,
description: message.payload.description,
position: message.payload.position ?? 0,
@@ -39,7 +39,7 @@ export function registerTasksHandlers(store: StoreApi<AppState>): WsHandlers {
const existingTask = state.kanban.tasks.find((task) => task.id === message.payload.task_id);
const nextTask = {
id: message.payload.task_id,
columnId: message.payload.column_id,
workflowStepId: message.payload.workflow_step_id,
title: message.payload.title,
description: message.payload.description,
position: message.payload.position ?? 0,
@@ -74,7 +74,7 @@ export function registerTasksHandlers(store: StoreApi<AppState>): WsHandlers {
const existingTask = state.kanban.tasks.find((t) => t.id === message.payload.task_id);
const nextTask = {
id: message.payload.task_id,
columnId: message.payload.column_id,
workflowStepId: message.payload.workflow_step_id,
title: message.payload.title,
description: message.payload.description,
position: message.payload.position ?? 0,
+1 -1
View File
@@ -68,7 +68,7 @@ export function registerWorkspacesHandlers(store: StoreApi<AppState>): WsHandler
activeId,
},
boards: clearBoards ? { items: [], activeId: null } : state.boards,
kanban: clearBoards ? { boardId: null, columns: [], tasks: [] } : state.kanban,
kanban: clearBoards ? { boardId: null, steps: [], tasks: [] } : state.kanban,
};
});
},
+2 -2
View File
@@ -3,7 +3,7 @@ import type { AppState } from '@/lib/state/store';
import { registerAgentsHandlers } from '@/lib/ws/handlers/agents';
import { registerTaskSessionHandlers } from '@/lib/ws/handlers/agent-session';
import { registerBoardsHandlers } from '@/lib/ws/handlers/boards';
import { registerColumnsHandlers } from '@/lib/ws/handlers/columns';
import { registerMessagesHandlers } from '@/lib/ws/handlers/messages';
import { registerNotificationsHandlers } from '@/lib/ws/handlers/notifications';
import { registerDiffsHandlers } from '@/lib/ws/handlers/diffs';
@@ -23,7 +23,7 @@ export function registerWsHandlers(store: StoreApi<AppState>) {
...registerKanbanHandlers(store),
...registerTasksHandlers(store),
...registerBoardsHandlers(store),
...registerColumnsHandlers(store),
...registerWorkspacesHandlers(store),
...registerExecutorsHandlers(store),
...registerEnvironmentsHandlers(store),
File diff suppressed because it is too large Load Diff