diff --git a/apps/backend/cmd/kandev/orchestrator.go b/apps/backend/cmd/kandev/orchestrator.go index 707eb53ac1..49805e27fb 100644 --- a/apps/backend/cmd/kandev/orchestrator.go +++ b/apps/backend/cmd/kandev/orchestrator.go @@ -170,6 +170,11 @@ func (a *orchestratorWorkflowStepGetterAdapter) GetPreviousStepByPosition(ctx co return a.svc.GetPreviousStepByPosition(ctx, workflowID, currentPosition) } +// GetWorkflowAgentProfileID implements orchestrator.WorkflowStepGetter. +func (a *orchestratorWorkflowStepGetterAdapter) GetWorkflowAgentProfileID(ctx context.Context, workflowID string) (string, error) { + return a.svc.GetWorkflowAgentProfileID(ctx, workflowID) +} + // reviewTaskCreatorAdapter adapts the task service to the orchestrator's ReviewTaskCreator interface. type reviewTaskCreatorAdapter struct { svc *taskservice.Service diff --git a/apps/backend/cmd/kandev/services.go b/apps/backend/cmd/kandev/services.go index 18c9a42734..eb845e8b4b 100644 --- a/apps/backend/cmd/kandev/services.go +++ b/apps/backend/cmd/kandev/services.go @@ -75,6 +75,12 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories // Wire workflow provider to workflow service for export/import workflowSvc.SetWorkflowProvider(&workflowProviderAdapter{svc: taskSvc}) + // Wire agent profile resolver/matcher for workflow export/import + workflowSvc.SetAgentProfileFuncs( + buildAgentProfileResolver(repos), + buildAgentProfileMatcher(repos), + ) + // Initialize GitHub service secretsAdapter := &githubSecretAdapter{store: repos.Secrets} githubSvc, _, err := github.Provide(dbPool.Writer(), dbPool.Reader(), secretsAdapter, eventBus, log) @@ -204,3 +210,53 @@ func (a *workflowProviderAdapter) CreateWorkflow(ctx context.Context, workspaceI Description: description, }) } + +// UpdateWorkflow implements workflowservice.WorkflowProvider. +func (a *workflowProviderAdapter) UpdateWorkflow(ctx context.Context, workflow *taskmodels.Workflow) error { + _, err := a.svc.UpdateWorkflow(ctx, workflow.ID, &taskservice.UpdateWorkflowRequest{ + Name: &workflow.Name, + Description: &workflow.Description, + AgentProfileID: &workflow.AgentProfileID, + }) + return err +} + +// buildAgentProfileResolver creates a resolver that converts profile IDs to portable form for export. +func buildAgentProfileResolver(repos *Repositories) wfmodels.AgentProfileResolver { + return func(profileID string) *wfmodels.AgentProfilePortable { + if profileID == "" { + return nil + } + profile, err := repos.AgentSettings.GetAgentProfile(context.Background(), profileID) + if err != nil || profile == nil { + return nil + } + return &wfmodels.AgentProfilePortable{ + AgentName: profile.AgentDisplayName, + Model: profile.Model, + Mode: profile.Mode, + } + } +} + +// buildAgentProfileMatcher creates a matcher that finds profiles by agent name, model, and mode for import. +func buildAgentProfileMatcher(repos *Repositories) wfmodels.AgentProfileMatcher { + return func(agentName, model, mode string) string { + agents, err := repos.AgentSettings.ListAgents(context.Background()) + if err != nil { + return "" + } + for _, agent := range agents { + profiles, pErr := repos.AgentSettings.ListAgentProfiles(context.Background(), agent.ID) + if pErr != nil { + continue + } + for _, p := range profiles { + if p.AgentDisplayName == agentName && p.Model == model && p.Mode == mode { + return p.ID + } + } + } + return "" + } +} diff --git a/apps/backend/internal/agent/settings/store/sqlite.go b/apps/backend/internal/agent/settings/store/sqlite.go index d107159276..7ebdf4ef69 100644 --- a/apps/backend/internal/agent/settings/store/sqlite.go +++ b/apps/backend/internal/agent/settings/store/sqlite.go @@ -114,7 +114,7 @@ func (r *sqliteRepository) initSchema() error { } // migrateDropModelCheckConstraint recreates agent_profiles without the legacy -// CHECK(model != ”) constraint. Existing databases created before the ACP-first +// CHECK(model != '') constraint. Existing databases created before the ACP-first // migration carry this constraint, which prevents empty model values. New // databases (created by the CREATE TABLE IF NOT EXISTS above) never have it. // diff --git a/apps/backend/internal/orchestrator/event_handlers_test.go b/apps/backend/internal/orchestrator/event_handlers_test.go index 777775df3d..01f39cd9f6 100644 --- a/apps/backend/internal/orchestrator/event_handlers_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_test.go @@ -33,7 +33,8 @@ import ( // mockStepGetter implements WorkflowStepGetter for testing. type mockStepGetter struct { - steps map[string]*wfmodels.WorkflowStep // stepID -> step + steps map[string]*wfmodels.WorkflowStep // stepID -> step + workflowAgentProfileID string // returned by GetWorkflowAgentProfileID } func newMockStepGetter() *mockStepGetter { @@ -71,10 +72,18 @@ func (m *mockStepGetter) GetPreviousStepByPosition(_ context.Context, workflowID return best, nil } +func (m *mockStepGetter) GetWorkflowAgentProfileID(_ context.Context, workflowID string) (string, error) { + if m.workflowAgentProfileID != "" { + return m.workflowAgentProfileID, nil + } + return "", nil +} + // mockTaskRepo implements scheduler.TaskRepository for testing. type mockTaskRepo struct { tasks map[string]*v1.Task updatedStates map[string]v1.TaskState + getTaskErr error // if set, GetTask returns this error } func newMockTaskRepo() *mockTaskRepo { @@ -85,6 +94,9 @@ func newMockTaskRepo() *mockTaskRepo { } func (m *mockTaskRepo) GetTask(_ context.Context, taskID string) (*v1.Task, error) { + if m.getTaskErr != nil { + return nil, m.getTaskErr + } if t, ok := m.tasks[taskID]; ok { return t, nil } diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow.go b/apps/backend/internal/orchestrator/event_handlers_workflow.go index 7c885c5a40..af1c67f94d 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow.go @@ -317,7 +317,10 @@ func (s *Service) handleTaskMovedNoSession(ctx context.Context, data watcher.Tas return } - agentProfileID, _ := task.Metadata[models.MetaKeyAgentProfileID].(string) + agentProfileID := s.resolveStepAgentProfile(ctx, step) + if agentProfileID == "" { + agentProfileID, _ = task.Metadata[models.MetaKeyAgentProfileID].(string) + } executorProfileID, _ := task.Metadata[models.MetaKeyExecutorProfileID].(string) planMode := step.HasOnEnterAction(wfmodels.OnEnterEnablePlanMode) @@ -419,10 +422,123 @@ func (s *Service) resolveStepPlanMode(ctx context.Context, session *models.TaskS return hasPlanMode } +// resolveStepAgentProfile returns the effective agent profile ID for a step. +// Resolution order: step override -> workflow default -> empty (use current session's profile). +func (s *Service) resolveStepAgentProfile(ctx context.Context, step *wfmodels.WorkflowStep) string { + if step.AgentProfileID != "" { + return step.AgentProfileID + } + if s.workflowStepGetter != nil && step.WorkflowID != "" { + wfProfileID, err := s.workflowStepGetter.GetWorkflowAgentProfileID(ctx, step.WorkflowID) + if err != nil { + s.logger.Warn("failed to resolve workflow agent profile, falling back to task defaults", + zap.String("workflow_id", step.WorkflowID), + zap.String("step_id", step.ID), + zap.Error(err)) + } else if wfProfileID != "" { + return wfProfileID + } + } + return "" +} + +// switchSessionForStep stops the current session and creates a new one with a different agent profile. +// Returns the new session, or nil + error if the switch fails. +// The new session is prepared BEFORE completing the old one: if PrepareSession fails, the old +// session remains active and the task stays recoverable. +func (s *Service) switchSessionForStep(ctx context.Context, taskID string, currentSession *models.TaskSession, newAgentProfileID string) (*models.TaskSession, error) { + s.logger.Info("switching session for workflow step agent profile change", + zap.String("task_id", taskID), + zap.String("current_session", currentSession.ID), + zap.String("current_profile", currentSession.AgentProfileID), + zap.String("new_profile", newAgentProfileID)) + + // Prepare the new session BEFORE touching the old one. + // If any step below fails, the old session remains active and the task stays recoverable. + task, err := s.scheduler.GetTask(ctx, taskID) + if err != nil { + return nil, fmt.Errorf("failed to get task for session switch: %w", err) + } + if task == nil { + return nil, fmt.Errorf("task %s not found for session switch", taskID) + } + dbTask, err := s.repo.GetTask(ctx, taskID) + if err != nil { + return nil, fmt.Errorf("failed to get db task for session switch: %w", err) + } + + // Create a new session with the new agent profile. + // Reuse the same executor profile from the current session. + sessionID, err := s.executor.PrepareSession(ctx, task, newAgentProfileID, currentSession.ExecutorID, currentSession.ExecutorProfileID, dbTask.WorkflowStepID) + if err != nil { + return nil, fmt.Errorf("failed to prepare new session: %w", err) + } + + newSession, err := s.repo.GetTaskSession(ctx, sessionID) + if err != nil { + return nil, fmt.Errorf("failed to get new session: %w", err) + } + + // New session is ready — now safe to stop the old agent and complete the old session. + if currentSession.AgentExecutionID != "" { + if err := s.agentManager.StopAgent(ctx, currentSession.AgentExecutionID, false); err != nil { + s.logger.Warn("failed to stop agent for session switch", + zap.String("session_id", currentSession.ID), + zap.Error(err)) + } + } + + // Mark the current session as completed. + currentSession.State = models.TaskSessionStateCompleted + now := time.Now().UTC() + currentSession.CompletedAt = &now + currentSession.UpdatedAt = now + if err := s.repo.UpdateTaskSession(ctx, currentSession); err != nil { + // New session is already created; log but don't abort. + s.logger.Warn("failed to complete old session after switch", + zap.String("session_id", currentSession.ID), + zap.Error(err)) + } + + return newSession, nil +} + +// maybySwitchSessionForProfile checks whether the step requires a different agent profile +// and switches the session if so. Passthrough sessions are returned unchanged. +// Returns the effective session (new or original) and whether processing should continue. +// A false return means the switch failed; the caller should return immediately. +func (s *Service) maybySwitchSessionForProfile( + ctx context.Context, taskID string, session *models.TaskSession, step *wfmodels.WorkflowStep, +) (*models.TaskSession, bool) { + if s.agentManager.IsPassthroughSession(ctx, session.ID) { + return session, true + } + effectiveProfile := s.resolveStepAgentProfile(ctx, step) + if effectiveProfile == "" || effectiveProfile == session.AgentProfileID { + return session, true + } + newSession, err := s.switchSessionForStep(ctx, taskID, session, effectiveProfile) + if err != nil { + s.logger.Error("failed to switch session for step agent profile", + zap.String("task_id", taskID), + zap.String("step_id", step.ID), + zap.Error(err)) + s.setSessionWaitingForInput(ctx, taskID, session.ID, session) + return nil, false + } + return newSession, true +} + // processOnEnter processes the on_enter events for a step after transitioning to it. func (s *Service) processOnEnter(ctx context.Context, taskID string, session *models.TaskSession, step *wfmodels.WorkflowStep, taskDescription string) { + // Switch session if this step requires a different agent profile. + var ok bool + if session, ok = s.maybySwitchSessionForProfile(ctx, taskID, session, step); !ok { + return + } sessionID := session.ID isPassthrough := s.agentManager.IsPassthroughSession(ctx, sessionID) + hasPlanMode := s.resolveStepPlanMode(ctx, session, step, isPassthrough) if len(step.Events.OnEnter) == 0 { diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow_profile_test.go b/apps/backend/internal/orchestrator/event_handlers_workflow_profile_test.go new file mode 100644 index 0000000000..74832551ca --- /dev/null +++ b/apps/backend/internal/orchestrator/event_handlers_workflow_profile_test.go @@ -0,0 +1,521 @@ +package orchestrator + +import ( + "context" + "errors" + "testing" + "time" + + v1 "github.com/kandev/kandev/pkg/api/v1" + + "github.com/kandev/kandev/internal/orchestrator/executor" + "github.com/kandev/kandev/internal/orchestrator/messagequeue" + "github.com/kandev/kandev/internal/orchestrator/queue" + "github.com/kandev/kandev/internal/orchestrator/scheduler" + "github.com/kandev/kandev/internal/task/models" + wfmodels "github.com/kandev/kandev/internal/workflow/models" +) + +func TestResolveStepAgentProfile(t *testing.T) { + t.Run("returns step profile when set", func(t *testing.T) { + svc := createTestService(setupTestRepo(t), newMockStepGetter(), newMockTaskRepo()) + step := &wfmodels.WorkflowStep{ + ID: "step1", + WorkflowID: "wf1", + AgentProfileID: "profile-step", + } + got := svc.resolveStepAgentProfile(context.Background(), step) + if got != "profile-step" { + t.Errorf("expected profile-step, got %q", got) + } + }) + + t.Run("falls back to workflow profile when step has none", func(t *testing.T) { + sg := newMockStepGetter() + sg.workflowAgentProfileID = "profile-workflow" + svc := createTestService(setupTestRepo(t), sg, newMockTaskRepo()) + step := &wfmodels.WorkflowStep{ + ID: "step1", + WorkflowID: "wf1", + } + got := svc.resolveStepAgentProfile(context.Background(), step) + if got != "profile-workflow" { + t.Errorf("expected profile-workflow, got %q", got) + } + }) + + t.Run("returns empty when neither step nor workflow has profile", func(t *testing.T) { + svc := createTestService(setupTestRepo(t), newMockStepGetter(), newMockTaskRepo()) + step := &wfmodels.WorkflowStep{ + ID: "step1", + WorkflowID: "wf1", + } + got := svc.resolveStepAgentProfile(context.Background(), step) + if got != "" { + t.Errorf("expected empty, got %q", got) + } + }) + + t.Run("step profile takes precedence over workflow profile", func(t *testing.T) { + sg := newMockStepGetter() + sg.workflowAgentProfileID = "profile-workflow" + svc := createTestService(setupTestRepo(t), sg, newMockTaskRepo()) + step := &wfmodels.WorkflowStep{ + ID: "step1", + WorkflowID: "wf1", + AgentProfileID: "profile-step", + } + got := svc.resolveStepAgentProfile(context.Background(), step) + if got != "profile-step" { + t.Errorf("expected profile-step, got %q", got) + } + }) +} + +func TestSwitchSessionForStep(t *testing.T) { + ctx := context.Background() + + t.Run("completes old session and creates new one", func(t *testing.T) { + repo := setupTestRepo(t) + now := time.Now().UTC() + + // Seed workspace + workflow + task + ws := &models.Workspace{ID: "ws1", Name: "Test", CreatedAt: now, UpdatedAt: now} + _ = repo.CreateWorkspace(ctx, ws) + wf := &models.Workflow{ID: "wf1", WorkspaceID: "ws1", Name: "WF", CreatedAt: now, UpdatedAt: now} + _ = repo.CreateWorkflow(ctx, wf) + task := &models.Task{ + ID: "t1", WorkflowID: "wf1", WorkflowStepID: "step2", + Title: "Test", Description: "Test", State: v1.TaskStateInProgress, + CreatedAt: now, UpdatedAt: now, + } + _ = repo.CreateTask(ctx, task) + + // Create current session with profile-A + session := &models.TaskSession{ + ID: "s1", + TaskID: "t1", + AgentProfileID: "profile-a", + ExecutorID: "exec-local", + ExecutorProfileID: "ep1", + AgentExecutionID: "ae1", + State: models.TaskSessionStateRunning, + IsPrimary: true, + StartedAt: now, + UpdatedAt: now, + } + _ = repo.CreateTaskSession(ctx, session) + + // Set up task repo mock with v1 task for scheduler + taskRepo := newMockTaskRepo() + taskRepo.tasks["t1"] = &v1.Task{ + ID: "t1", + WorkspaceID: "ws1", + WorkflowID: "wf1", + Title: "Test", + Description: "Test", + State: v1.TaskStateInProgress, + } + + agentMgr := &mockAgentManager{} + log := testLogger() + exec := executor.NewExecutor(agentMgr, repo, log, executor.ExecutorConfig{}) + sched := scheduler.NewScheduler(queue.NewTaskQueue(100), exec, taskRepo, log, scheduler.SchedulerConfig{}) + svc := &Service{ + logger: log, + repo: repo, + workflowStepGetter: newMockStepGetter(), + taskRepo: taskRepo, + agentManager: agentMgr, + messageQueue: messagequeue.NewService(log), + executor: exec, + scheduler: sched, + } + + newSession, err := svc.switchSessionForStep(ctx, "t1", session, "profile-b") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify old session is completed + oldSession, err := repo.GetTaskSession(ctx, "s1") + if err != nil { + t.Fatalf("failed to get old session: %v", err) + } + if oldSession.State != models.TaskSessionStateCompleted { + t.Errorf("expected old session state completed, got %s", oldSession.State) + } + if oldSession.CompletedAt == nil { + t.Error("expected old session to have CompletedAt set") + } + + // Verify new session exists with correct profile + if newSession == nil { + t.Fatal("expected new session, got nil") + } + if newSession.AgentProfileID != "profile-b" { + t.Errorf("expected new session profile profile-b, got %q", newSession.AgentProfileID) + } + if newSession.ID == "s1" { + t.Error("expected new session to have a different ID from old session") + } + }) +} + +func TestProcessOnEnter_ProfileSwitch(t *testing.T) { + ctx := context.Background() + + t.Run("switches session when step has different profile", func(t *testing.T) { + repo := setupTestRepo(t) + now := time.Now().UTC() + + ws := &models.Workspace{ID: "ws1", Name: "Test", CreatedAt: now, UpdatedAt: now} + _ = repo.CreateWorkspace(ctx, ws) + wf := &models.Workflow{ID: "wf1", WorkspaceID: "ws1", Name: "WF", CreatedAt: now, UpdatedAt: now} + _ = repo.CreateWorkflow(ctx, wf) + task := &models.Task{ + ID: "t1", WorkflowID: "wf1", WorkflowStepID: "step2", + Title: "Test", Description: "desc", State: v1.TaskStateInProgress, + CreatedAt: now, UpdatedAt: now, + } + _ = repo.CreateTask(ctx, task) + + session := &models.TaskSession{ + ID: "s1", + TaskID: "t1", + AgentProfileID: "profile-a", + ExecutorID: "exec-local", + ExecutorProfileID: "ep1", + State: models.TaskSessionStateRunning, + IsPrimary: true, + StartedAt: now, + UpdatedAt: now, + } + _ = repo.CreateTaskSession(ctx, session) + + taskRepo := newMockTaskRepo() + taskRepo.tasks["t1"] = &v1.Task{ + ID: "t1", + WorkspaceID: "ws1", + WorkflowID: "wf1", + Title: "Test", + Description: "desc", + State: v1.TaskStateInProgress, + } + + sg := newMockStepGetter() + step := &wfmodels.WorkflowStep{ + ID: "step2", + WorkflowID: "wf1", + Name: "Review", + AgentProfileID: "profile-b", + } + sg.steps["step2"] = step + + agentMgr := &mockAgentManager{} + log := testLogger() + exec := executor.NewExecutor(agentMgr, repo, log, executor.ExecutorConfig{}) + sched := scheduler.NewScheduler(queue.NewTaskQueue(100), exec, taskRepo, log, scheduler.SchedulerConfig{}) + svc := &Service{ + logger: log, + repo: repo, + workflowStepGetter: sg, + taskRepo: taskRepo, + agentManager: agentMgr, + messageQueue: messagequeue.NewService(log), + executor: exec, + scheduler: sched, + } + + svc.processOnEnter(ctx, "t1", session, step, "desc") + + // The old session should be completed + oldSession, err := repo.GetTaskSession(ctx, "s1") + if err != nil { + t.Fatalf("failed to get old session: %v", err) + } + if oldSession.State != models.TaskSessionStateCompleted { + t.Errorf("expected old session completed, got %s", oldSession.State) + } + + // There should be a new session with profile-b + sessions, err := repo.ListTaskSessions(ctx, "t1") + if err != nil { + t.Fatalf("failed to list sessions: %v", err) + } + var newSession *models.TaskSession + for _, s := range sessions { + if s.ID != "s1" { + newSession = s + break + } + } + if newSession == nil { + t.Fatal("expected a new session to be created") + } + if newSession.AgentProfileID != "profile-b" { + t.Errorf("expected new session profile profile-b, got %q", newSession.AgentProfileID) + } + }) + + t.Run("no switch when step has same profile as session", func(t *testing.T) { + repo := setupTestRepo(t) + now := time.Now().UTC() + + ws := &models.Workspace{ID: "ws1", Name: "Test", CreatedAt: now, UpdatedAt: now} + _ = repo.CreateWorkspace(ctx, ws) + wf := &models.Workflow{ID: "wf1", WorkspaceID: "ws1", Name: "WF", CreatedAt: now, UpdatedAt: now} + _ = repo.CreateWorkflow(ctx, wf) + task := &models.Task{ + ID: "t1", WorkflowID: "wf1", WorkflowStepID: "step1", + Title: "Test", Description: "desc", State: v1.TaskStateInProgress, + CreatedAt: now, UpdatedAt: now, + } + _ = repo.CreateTask(ctx, task) + + session := &models.TaskSession{ + ID: "s1", + TaskID: "t1", + AgentProfileID: "profile-a", + State: models.TaskSessionStateRunning, + IsPrimary: true, + StartedAt: now, + UpdatedAt: now, + } + _ = repo.CreateTaskSession(ctx, session) + + sg := newMockStepGetter() + step := &wfmodels.WorkflowStep{ + ID: "step1", + WorkflowID: "wf1", + Name: "Develop", + AgentProfileID: "profile-a", + } + sg.steps["step1"] = step + + svc := createTestService(repo, sg, newMockTaskRepo()) + svc.processOnEnter(ctx, "t1", session, step, "desc") + + // Session should remain running (not completed) + updatedSession, err := repo.GetTaskSession(ctx, "s1") + if err != nil { + t.Fatalf("failed to get session: %v", err) + } + if updatedSession.State == models.TaskSessionStateCompleted { + t.Error("session should not be completed when profile matches") + } + + // No new sessions should be created + sessions, err := repo.ListTaskSessions(ctx, "t1") + if err != nil { + t.Fatalf("failed to list sessions: %v", err) + } + if len(sessions) != 1 { + t.Errorf("expected 1 session, got %d", len(sessions)) + } + }) + + t.Run("no switch for passthrough sessions", func(t *testing.T) { + repo := setupTestRepo(t) + now := time.Now().UTC() + + ws := &models.Workspace{ID: "ws1", Name: "Test", CreatedAt: now, UpdatedAt: now} + _ = repo.CreateWorkspace(ctx, ws) + wf := &models.Workflow{ID: "wf1", WorkspaceID: "ws1", Name: "WF", CreatedAt: now, UpdatedAt: now} + _ = repo.CreateWorkflow(ctx, wf) + task := &models.Task{ + ID: "t1", WorkflowID: "wf1", WorkflowStepID: "step2", + Title: "Test", Description: "desc", State: v1.TaskStateInProgress, + CreatedAt: now, UpdatedAt: now, + } + _ = repo.CreateTask(ctx, task) + + session := &models.TaskSession{ + ID: "s1", + TaskID: "t1", + AgentProfileID: "profile-a", + State: models.TaskSessionStateRunning, + IsPrimary: true, + StartedAt: now, + UpdatedAt: now, + } + _ = repo.CreateTaskSession(ctx, session) + + sg := newMockStepGetter() + step := &wfmodels.WorkflowStep{ + ID: "step2", + WorkflowID: "wf1", + Name: "Review", + AgentProfileID: "profile-b", + } + sg.steps["step2"] = step + + agentMgr := &mockAgentManager{isPassthrough: true} + svc := createTestServiceWithAgent(repo, sg, newMockTaskRepo(), agentMgr) + svc.processOnEnter(ctx, "t1", session, step, "desc") + + // Session should NOT be completed (passthrough skips profile switch) + updatedSession, err := repo.GetTaskSession(ctx, "s1") + if err != nil { + t.Fatalf("failed to get session: %v", err) + } + if updatedSession.State == models.TaskSessionStateCompleted { + t.Error("passthrough session should not be completed for profile switch") + } + }) + + t.Run("no switch when step has no profile", func(t *testing.T) { + repo := setupTestRepo(t) + now := time.Now().UTC() + + ws := &models.Workspace{ID: "ws1", Name: "Test", CreatedAt: now, UpdatedAt: now} + _ = repo.CreateWorkspace(ctx, ws) + wf := &models.Workflow{ID: "wf1", WorkspaceID: "ws1", Name: "WF", CreatedAt: now, UpdatedAt: now} + _ = repo.CreateWorkflow(ctx, wf) + task := &models.Task{ + ID: "t1", WorkflowID: "wf1", WorkflowStepID: "step1", + Title: "Test", Description: "desc", State: v1.TaskStateInProgress, + CreatedAt: now, UpdatedAt: now, + } + _ = repo.CreateTask(ctx, task) + + session := &models.TaskSession{ + ID: "s1", + TaskID: "t1", + AgentProfileID: "profile-a", + State: models.TaskSessionStateRunning, + IsPrimary: true, + StartedAt: now, + UpdatedAt: now, + } + _ = repo.CreateTaskSession(ctx, session) + + sg := newMockStepGetter() + step := &wfmodels.WorkflowStep{ + ID: "step1", + WorkflowID: "wf1", + Name: "Develop", + // No AgentProfileID + } + sg.steps["step1"] = step + + svc := createTestService(repo, sg, newMockTaskRepo()) + svc.processOnEnter(ctx, "t1", session, step, "desc") + + // Session should remain running + sessions, err := repo.ListTaskSessions(ctx, "t1") + if err != nil { + t.Fatalf("failed to list sessions: %v", err) + } + if len(sessions) != 1 { + t.Errorf("expected 1 session, got %d", len(sessions)) + } + }) +} + +func TestSwitchSessionForStep_PreservesOldSessionOnFailure(t *testing.T) { + ctx := context.Background() + + t.Run("old session not completed when scheduler.GetTask fails", func(t *testing.T) { + repo := setupTestRepo(t) + now := time.Now().UTC() + + ws := &models.Workspace{ID: "ws1", Name: "Test", CreatedAt: now, UpdatedAt: now} + _ = repo.CreateWorkspace(ctx, ws) + wf := &models.Workflow{ID: "wf1", WorkspaceID: "ws1", Name: "WF", CreatedAt: now, UpdatedAt: now} + _ = repo.CreateWorkflow(ctx, wf) + task := &models.Task{ + ID: "t1", WorkflowID: "wf1", WorkflowStepID: "step2", + Title: "Test", Description: "Test", State: v1.TaskStateInProgress, + CreatedAt: now, UpdatedAt: now, + } + _ = repo.CreateTask(ctx, task) + + session := &models.TaskSession{ + ID: "s1", + TaskID: "t1", + AgentProfileID: "profile-a", + ExecutorID: "exec-local", + ExecutorProfileID: "ep1", + AgentExecutionID: "ae1", + State: models.TaskSessionStateRunning, + IsPrimary: true, + StartedAt: now, + UpdatedAt: now, + } + _ = repo.CreateTaskSession(ctx, session) + + // Make scheduler.GetTask fail — the old session must stay untouched. + taskRepo := newMockTaskRepo() + taskRepo.getTaskErr = errors.New("task store unavailable") + + agentMgr := &mockAgentManager{} + log := testLogger() + exec := executor.NewExecutor(agentMgr, repo, log, executor.ExecutorConfig{}) + sched := scheduler.NewScheduler(queue.NewTaskQueue(100), exec, taskRepo, log, scheduler.SchedulerConfig{}) + svc := &Service{ + logger: log, + repo: repo, + workflowStepGetter: newMockStepGetter(), + taskRepo: taskRepo, + agentManager: agentMgr, + messageQueue: messagequeue.NewService(log), + executor: exec, + scheduler: sched, + } + + _, err := svc.switchSessionForStep(ctx, "t1", session, "profile-b") + if err == nil { + t.Fatal("expected error when scheduler.GetTask fails") + } + + // The old session must NOT be completed — failure happened before touching it. + oldSession, getErr := repo.GetTaskSession(ctx, "s1") + if getErr != nil { + t.Fatalf("failed to get old session: %v", getErr) + } + if oldSession.State == models.TaskSessionStateCompleted { + t.Error("old session must not be marked completed when PrepareSession fails before it") + } + if oldSession.CompletedAt != nil { + t.Error("old session must not have CompletedAt set when PrepareSession fails before it") + } + }) +} + +func TestResolveStepAgentProfile_UsedByHandleTaskMovedNoSession(t *testing.T) { + // This test verifies that resolveStepAgentProfile correctly prioritizes + // step profile over workflow profile. The actual handleTaskMovedNoSession + // integration is covered by the resolution order tests above. + + t.Run("step profile beats workflow default", func(t *testing.T) { + sg := newMockStepGetter() + sg.workflowAgentProfileID = "profile-workflow" + svc := createTestService(setupTestRepo(t), sg, newMockTaskRepo()) + + step := &wfmodels.WorkflowStep{ + ID: "step1", + WorkflowID: "wf1", + AgentProfileID: "profile-step", + } + got := svc.resolveStepAgentProfile(context.Background(), step) + if got != "profile-step" { + t.Errorf("expected profile-step, got %q", got) + } + }) + + t.Run("workflow profile used when step has none", func(t *testing.T) { + sg := newMockStepGetter() + sg.workflowAgentProfileID = "profile-workflow" + svc := createTestService(setupTestRepo(t), sg, newMockTaskRepo()) + + step := &wfmodels.WorkflowStep{ + ID: "step1", + WorkflowID: "wf1", + } + got := svc.resolveStepAgentProfile(context.Background(), step) + if got != "profile-workflow" { + t.Errorf("expected profile-workflow, got %q", got) + } + }) +} diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index 0d6312d29d..a171e32343 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -98,6 +98,7 @@ type WorkflowStepGetter interface { GetStep(ctx context.Context, stepID string) (*wfmodels.WorkflowStep, error) GetNextStepByPosition(ctx context.Context, workflowID string, currentPosition int) (*wfmodels.WorkflowStep, error) GetPreviousStepByPosition(ctx context.Context, workflowID string, currentPosition int) (*wfmodels.WorkflowStep, error) + GetWorkflowAgentProfileID(ctx context.Context, workflowID string) (string, error) } // repoStore is the repository interface accepted by NewService. diff --git a/apps/backend/internal/task/dto/converters.go b/apps/backend/internal/task/dto/converters.go index 631221c112..afff4b63c9 100644 --- a/apps/backend/internal/task/dto/converters.go +++ b/apps/backend/internal/task/dto/converters.go @@ -19,6 +19,7 @@ func FromWorkflowStep(step *wfmodels.WorkflowStep) WorkflowStepDTO { IsStartStep: step.IsStartStep, ShowInCommandPanel: step.ShowInCommandPanel, AutoArchiveAfterHours: step.AutoArchiveAfterHours, + AgentProfileID: step.AgentProfileID, } if len(step.Events.OnEnter) > 0 || len(step.Events.OnTurnComplete) > 0 { events := &StepEventsDTO{} diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index 6255f611ff..ea88aca21b 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -9,13 +9,14 @@ import ( ) type WorkflowDTO struct { - ID string `json:"id"` - WorkspaceID string `json:"workspace_id"` - Name string `json:"name"` - Description *string `json:"description,omitempty"` - SortOrder int `json:"sort_order"` - 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,omitempty"` + AgentProfileID string `json:"agent_profile_id,omitempty"` + SortOrder int `json:"sort_order"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type WorkspaceDTO struct { @@ -330,13 +331,14 @@ func FromWorkflow(workflow *models.Workflow) WorkflowDTO { } return WorkflowDTO{ - ID: workflow.ID, - WorkspaceID: workflow.WorkspaceID, - Name: workflow.Name, - Description: description, - SortOrder: workflow.SortOrder, - CreatedAt: workflow.CreatedAt, - UpdatedAt: workflow.UpdatedAt, + ID: workflow.ID, + WorkspaceID: workflow.WorkspaceID, + Name: workflow.Name, + Description: description, + AgentProfileID: workflow.AgentProfileID, + SortOrder: workflow.SortOrder, + CreatedAt: workflow.CreatedAt, + UpdatedAt: workflow.UpdatedAt, } } @@ -606,6 +608,7 @@ type WorkflowStepDTO struct { IsStartStep bool `json:"is_start_step"` ShowInCommandPanel bool `json:"show_in_command_panel"` AutoArchiveAfterHours int `json:"auto_archive_after_hours,omitempty"` + AgentProfileID string `json:"agent_profile_id,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/apps/backend/internal/task/handlers/workflow_handlers.go b/apps/backend/internal/task/handlers/workflow_handlers.go index 267f7a268f..6ba82acd86 100644 --- a/apps/backend/internal/task/handlers/workflow_handlers.go +++ b/apps/backend/internal/task/handlers/workflow_handlers.go @@ -150,8 +150,9 @@ func (h *WorkflowHandlers) httpCreateWorkflow(c *gin.Context) { } type httpUpdateWorkflowRequest struct { - Name *string `json:"name"` - Description *string `json:"description"` + Name *string `json:"name"` + Description *string `json:"description"` + AgentProfileID *string `json:"agent_profile_id"` } func (h *WorkflowHandlers) httpUpdateWorkflow(c *gin.Context) { @@ -162,8 +163,9 @@ func (h *WorkflowHandlers) httpUpdateWorkflow(c *gin.Context) { } id := c.Param("id") workflow, err := h.service.UpdateWorkflow(c.Request.Context(), id, &service.UpdateWorkflowRequest{ - Name: body.Name, - Description: body.Description, + Name: body.Name, + Description: body.Description, + AgentProfileID: body.AgentProfileID, }) if err != nil { handleNotFound(c, h.logger, err, "workflow not found") @@ -368,9 +370,10 @@ func (h *WorkflowHandlers) wsGetWorkflow(ctx context.Context, msg *ws.Message) ( } type wsUpdateWorkflowRequest struct { - ID string `json:"id"` - Name *string `json:"name,omitempty"` - Description *string `json:"description,omitempty"` + ID string `json:"id"` + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + AgentProfileID *string `json:"agent_profile_id,omitempty"` } func (h *WorkflowHandlers) wsUpdateWorkflow(ctx context.Context, msg *ws.Message) (*ws.Message, error) { @@ -383,8 +386,9 @@ func (h *WorkflowHandlers) wsUpdateWorkflow(ctx context.Context, msg *ws.Message } workflow, err := h.service.UpdateWorkflow(ctx, req.ID, &service.UpdateWorkflowRequest{ - Name: req.Name, - Description: req.Description, + Name: req.Name, + Description: req.Description, + AgentProfileID: req.AgentProfileID, }) if err != nil { h.logger.Error("failed to update workflow", zap.Error(err)) diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index b180d39d20..40d6bdae69 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -52,6 +52,7 @@ type Workflow struct { WorkspaceID string `json:"workspace_id"` Name string `json:"name"` Description string `json:"description"` + AgentProfileID string `json:"agent_profile_id,omitempty"` WorkflowTemplateID *string `json:"workflow_template_id,omitempty"` SortOrder int `json:"sort_order"` CreatedAt time.Time `json:"created_at"` diff --git a/apps/backend/internal/task/repository/sqlite/base.go b/apps/backend/internal/task/repository/sqlite/base.go index 4f7aa788f5..0d2c59e9c1 100644 --- a/apps/backend/internal/task/repository/sqlite/base.go +++ b/apps/backend/internal/task/repository/sqlite/base.go @@ -158,6 +158,8 @@ func (r *Repository) runMigrations() error { } // Add sort_order column to workflows for user-defined ordering (ignore error if already exists) _, _ = r.db.Exec(`ALTER TABLE workflows ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0`) + // Add agent_profile_id column to workflows for per-workflow agent profile override (ignore error if already exists) + _, _ = r.db.Exec(`ALTER TABLE workflows ADD COLUMN agent_profile_id TEXT DEFAULT ''`) return nil } diff --git a/apps/backend/internal/task/repository/sqlite/workflow.go b/apps/backend/internal/task/repository/sqlite/workflow.go index 8cff6a3bf2..b309410ab1 100644 --- a/apps/backend/internal/task/repository/sqlite/workflow.go +++ b/apps/backend/internal/task/repository/sqlite/workflow.go @@ -51,9 +51,9 @@ func (r *Repository) CreateWorkflow(ctx context.Context, workflow *models.Workfl workflow.SortOrder = maxOrder + 1 _, err = r.db.ExecContext(ctx, r.db.Rebind(` - INSERT INTO workflows (id, workspace_id, name, description, workflow_template_id, sort_order, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `), workflow.ID, workflow.WorkspaceID, workflow.Name, workflow.Description, workflow.WorkflowTemplateID, workflow.SortOrder, workflow.CreatedAt, workflow.UpdatedAt) + INSERT INTO workflows (id, workspace_id, name, description, agent_profile_id, workflow_template_id, sort_order, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `), workflow.ID, workflow.WorkspaceID, workflow.Name, workflow.Description, workflow.AgentProfileID, workflow.WorkflowTemplateID, workflow.SortOrder, workflow.CreatedAt, workflow.UpdatedAt) return err } @@ -61,12 +61,12 @@ func (r *Repository) CreateWorkflow(ctx context.Context, workflow *models.Workfl // GetWorkflow retrieves a workflow by ID func (r *Repository) GetWorkflow(ctx context.Context, id string) (*models.Workflow, error) { workflow := &models.Workflow{} - var workflowTemplateID sql.NullString + var workflowTemplateID, agentProfileID sql.NullString err := r.ro.QueryRowContext(ctx, r.ro.Rebind(` - SELECT id, workspace_id, name, description, workflow_template_id, sort_order, created_at, updated_at + SELECT id, workspace_id, name, description, agent_profile_id, workflow_template_id, sort_order, created_at, updated_at FROM workflows WHERE id = ? - `), id).Scan(&workflow.ID, &workflow.WorkspaceID, &workflow.Name, &workflow.Description, &workflowTemplateID, &workflow.SortOrder, &workflow.CreatedAt, &workflow.UpdatedAt) + `), id).Scan(&workflow.ID, &workflow.WorkspaceID, &workflow.Name, &workflow.Description, &agentProfileID, &workflowTemplateID, &workflow.SortOrder, &workflow.CreatedAt, &workflow.UpdatedAt) if err == sql.ErrNoRows { return nil, fmt.Errorf("workflow not found: %s", id) @@ -75,6 +75,9 @@ func (r *Repository) GetWorkflow(ctx context.Context, id string) (*models.Workfl return nil, err } + if agentProfileID.Valid { + workflow.AgentProfileID = agentProfileID.String + } if workflowTemplateID.Valid { workflow.WorkflowTemplateID = &workflowTemplateID.String } @@ -86,8 +89,8 @@ func (r *Repository) UpdateWorkflow(ctx context.Context, workflow *models.Workfl workflow.UpdatedAt = time.Now().UTC() result, err := r.db.ExecContext(ctx, r.db.Rebind(` - UPDATE workflows SET name = ?, description = ?, workflow_template_id = ?, updated_at = ? WHERE id = ? - `), workflow.Name, workflow.Description, workflow.WorkflowTemplateID, workflow.UpdatedAt, workflow.ID) + UPDATE workflows SET name = ?, description = ?, agent_profile_id = ?, workflow_template_id = ?, updated_at = ? WHERE id = ? + `), workflow.Name, workflow.Description, workflow.AgentProfileID, workflow.WorkflowTemplateID, workflow.UpdatedAt, workflow.ID) if err != nil { return err } @@ -140,7 +143,7 @@ func (r *Repository) DeleteWorkflow(ctx context.Context, id string) error { // ListWorkflows returns all workflows func (r *Repository) ListWorkflows(ctx context.Context, workspaceID string) ([]*models.Workflow, error) { query := ` - SELECT id, workspace_id, name, description, workflow_template_id, sort_order, created_at, updated_at FROM workflows + SELECT id, workspace_id, name, description, agent_profile_id, workflow_template_id, sort_order, created_at, updated_at FROM workflows ` var args []interface{} if workspaceID != "" { @@ -158,11 +161,14 @@ func (r *Repository) ListWorkflows(ctx context.Context, workspaceID string) ([]* var result []*models.Workflow for rows.Next() { workflow := &models.Workflow{} - var workflowTemplateID sql.NullString - err := rows.Scan(&workflow.ID, &workflow.WorkspaceID, &workflow.Name, &workflow.Description, &workflowTemplateID, &workflow.SortOrder, &workflow.CreatedAt, &workflow.UpdatedAt) + var agentProfileID, workflowTemplateID sql.NullString + err := rows.Scan(&workflow.ID, &workflow.WorkspaceID, &workflow.Name, &workflow.Description, &agentProfileID, &workflowTemplateID, &workflow.SortOrder, &workflow.CreatedAt, &workflow.UpdatedAt) if err != nil { return nil, err } + if agentProfileID.Valid { + workflow.AgentProfileID = agentProfileID.String + } if workflowTemplateID.Valid { workflow.WorkflowTemplateID = &workflowTemplateID.String } diff --git a/apps/backend/internal/task/repository/workflow_repository_test.go b/apps/backend/internal/task/repository/workflow_repository_test.go index 1adbbab3ac..ee15654643 100644 --- a/apps/backend/internal/task/repository/workflow_repository_test.go +++ b/apps/backend/internal/task/repository/workflow_repository_test.go @@ -214,6 +214,74 @@ func TestSQLiteRepository_ReorderWorkflows_WrongWorkspace(t *testing.T) { } } +func TestSQLiteRepository_WorkflowAgentProfileID(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}) + + // Create workflow with agent_profile_id + workflow := &models.Workflow{ + WorkspaceID: "ws-1", + Name: "Profile Workflow", + AgentProfileID: "profile-123", + } + if err := repo.CreateWorkflow(ctx, workflow); err != nil { + t.Fatalf("failed to create workflow: %v", err) + } + + // Get and verify agent_profile_id persists + retrieved, err := repo.GetWorkflow(ctx, workflow.ID) + if err != nil { + t.Fatalf("failed to get workflow: %v", err) + } + if retrieved.AgentProfileID != "profile-123" { + t.Errorf("expected agent_profile_id 'profile-123', got %q", retrieved.AgentProfileID) + } + + // Update agent_profile_id + workflow.AgentProfileID = "profile-456" + if err := repo.UpdateWorkflow(ctx, workflow); err != nil { + t.Fatalf("failed to update workflow: %v", err) + } + retrieved, _ = repo.GetWorkflow(ctx, workflow.ID) + if retrieved.AgentProfileID != "profile-456" { + t.Errorf("expected agent_profile_id 'profile-456', got %q", retrieved.AgentProfileID) + } + + // Clear agent_profile_id + workflow.AgentProfileID = "" + if err := repo.UpdateWorkflow(ctx, workflow); err != nil { + t.Fatalf("failed to update workflow: %v", err) + } + retrieved, _ = repo.GetWorkflow(ctx, workflow.ID) + if retrieved.AgentProfileID != "" { + t.Errorf("expected empty agent_profile_id, got %q", retrieved.AgentProfileID) + } + + // Verify ListWorkflows includes agent_profile_id + _ = repo.CreateWorkflow(ctx, &models.Workflow{ + WorkspaceID: "ws-1", + Name: "Another Workflow", + AgentProfileID: "profile-789", + }) + workflows, err := repo.ListWorkflows(ctx, "ws-1") + if err != nil { + t.Fatalf("failed to list workflows: %v", err) + } + found := false + for _, wf := range workflows { + if wf.AgentProfileID == "profile-789" { + found = true + break + } + } + if !found { + t.Error("expected to find workflow with agent_profile_id 'profile-789' in list") + } +} + func TestSQLiteRepository_WorkflowSortOrder_Isolation(t *testing.T) { repo, cleanup := createTestSQLiteRepo(t) defer cleanup() diff --git a/apps/backend/internal/task/service/service_events.go b/apps/backend/internal/task/service/service_events.go index 2575861441..edfb5eef75 100644 --- a/apps/backend/internal/task/service/service_events.go +++ b/apps/backend/internal/task/service/service_events.go @@ -147,12 +147,13 @@ func (s *Service) publishWorkflowEvent(ctx context.Context, eventType string, wo } data := map[string]interface{}{ - "id": workflow.ID, - "workspace_id": workflow.WorkspaceID, - "name": workflow.Name, - "description": workflow.Description, - "created_at": workflow.CreatedAt.Format(time.RFC3339), - "updated_at": workflow.UpdatedAt.Format(time.RFC3339), + "id": workflow.ID, + "workspace_id": workflow.WorkspaceID, + "name": workflow.Name, + "description": workflow.Description, + "agent_profile_id": workflow.AgentProfileID, + "created_at": workflow.CreatedAt.Format(time.RFC3339), + "updated_at": workflow.UpdatedAt.Format(time.RFC3339), } s.publishEventToBus(ctx, eventType, "workflow", workflow.ID, data) diff --git a/apps/backend/internal/task/service/service_requests.go b/apps/backend/internal/task/service/service_requests.go index e5009896ef..851cb1f094 100644 --- a/apps/backend/internal/task/service/service_requests.go +++ b/apps/backend/internal/task/service/service_requests.go @@ -57,8 +57,9 @@ type CreateWorkflowRequest struct { // UpdateWorkflowRequest contains the data for updating a workflow type UpdateWorkflowRequest struct { - Name *string `json:"name,omitempty"` - Description *string `json:"description,omitempty"` + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + AgentProfileID *string `json:"agent_profile_id,omitempty"` } // CreateWorkspaceRequest contains the data for creating a new workspace diff --git a/apps/backend/internal/task/service/service_resources.go b/apps/backend/internal/task/service/service_resources.go index 09deb8e20a..f3f3eb39d8 100644 --- a/apps/backend/internal/task/service/service_resources.go +++ b/apps/backend/internal/task/service/service_resources.go @@ -165,6 +165,9 @@ func (s *Service) UpdateWorkflow(ctx context.Context, id string, req *UpdateWork if req.Description != nil { workflow.Description = *req.Description } + if req.AgentProfileID != nil { + workflow.AgentProfileID = strings.TrimSpace(*req.AgentProfileID) + } workflow.UpdatedAt = time.Now().UTC() if err := s.workflows.UpdateWorkflow(ctx, workflow); err != nil { diff --git a/apps/backend/internal/workflow/controller/controller.go b/apps/backend/internal/workflow/controller/controller.go index 94cbf516fe..603b9c8a86 100644 --- a/apps/backend/internal/workflow/controller/controller.go +++ b/apps/backend/internal/workflow/controller/controller.go @@ -2,6 +2,7 @@ package controller import ( "context" + "strings" "github.com/kandev/kandev/internal/workflow/models" "github.com/kandev/kandev/internal/workflow/service" @@ -143,6 +144,7 @@ type UpdateStepRequest struct { IsStartStep *bool `json:"is_start_step,omitempty"` ShowInCommandPanel *bool `json:"show_in_command_panel,omitempty"` AutoArchiveAfterHours *int `json:"auto_archive_after_hours,omitempty"` + AgentProfileID *string `json:"agent_profile_id,omitempty"` } // UpdateStep updates an existing workflow step. @@ -178,6 +180,9 @@ func (c *Controller) UpdateStep(ctx context.Context, req UpdateStepRequest) (*Ge if req.AutoArchiveAfterHours != nil { step.AutoArchiveAfterHours = *req.AutoArchiveAfterHours } + if req.AgentProfileID != nil { + step.AgentProfileID = strings.TrimSpace(*req.AgentProfileID) + } if err := c.svc.UpdateStep(ctx, step); err != nil { return nil, err } diff --git a/apps/backend/internal/workflow/models/export.go b/apps/backend/internal/workflow/models/export.go index ef550c71e7..76ebda2b49 100644 --- a/apps/backend/internal/workflow/models/export.go +++ b/apps/backend/internal/workflow/models/export.go @@ -19,33 +19,49 @@ type WorkflowExport struct { Workflows []WorkflowPortable `json:"workflows" yaml:"workflows"` } +// AgentProfilePortable stores enough agent profile info for cross-workspace matching. +type AgentProfilePortable struct { + AgentName string `json:"agent_name" yaml:"agent_name"` + Model string `json:"model,omitempty" yaml:"model,omitempty"` + Mode string `json:"mode,omitempty" yaml:"mode,omitempty"` +} + +// AgentProfileResolver resolves an agent profile ID to its portable representation. +type AgentProfileResolver func(profileID string) *AgentProfilePortable + +// AgentProfileMatcher finds a matching agent profile ID by agent name, model, and mode. +type AgentProfileMatcher func(agentName, model, mode string) string + // WorkflowPortable is a workflow without instance-specific fields (IDs, timestamps). type WorkflowPortable struct { - Name string `json:"name" yaml:"name"` - Description string `json:"description,omitempty" yaml:"description,omitempty"` - Steps []StepPortable `json:"steps" yaml:"steps"` + Name string `json:"name" yaml:"name"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + AgentProfile *AgentProfilePortable `json:"agent_profile,omitempty" yaml:"agent_profile,omitempty"` + Steps []StepPortable `json:"steps" yaml:"steps"` } // StepPortable is a workflow step without instance-specific fields. type StepPortable struct { - Name string `json:"name" yaml:"name"` - Position int `json:"position" yaml:"position"` - Color string `json:"color" yaml:"color"` - Prompt string `json:"prompt,omitempty" yaml:"prompt,omitempty"` - Events StepEvents `json:"events" yaml:"events"` - IsStartStep bool `json:"is_start_step" yaml:"is_start_step"` - ShowInCommandPanel bool `json:"show_in_command_panel" yaml:"show_in_command_panel"` - AllowManualMove bool `json:"allow_manual_move" yaml:"allow_manual_move"` - AutoArchiveAfterHours int `json:"auto_archive_after_hours,omitempty" yaml:"auto_archive_after_hours,omitempty"` + Name string `json:"name" yaml:"name"` + Position int `json:"position" yaml:"position"` + Color string `json:"color" yaml:"color"` + Prompt string `json:"prompt,omitempty" yaml:"prompt,omitempty"` + Events StepEvents `json:"events" yaml:"events"` + IsStartStep bool `json:"is_start_step" yaml:"is_start_step"` + ShowInCommandPanel bool `json:"show_in_command_panel" yaml:"show_in_command_panel"` + AllowManualMove bool `json:"allow_manual_move" yaml:"allow_manual_move"` + AutoArchiveAfterHours int `json:"auto_archive_after_hours,omitempty" yaml:"auto_archive_after_hours,omitempty"` + AgentProfile *AgentProfilePortable `json:"agent_profile,omitempty" yaml:"agent_profile,omitempty"` } // BuildWorkflowExport builds a portable WorkflowExport from domain models. // stepsByWorkflow maps workflow ID → its steps (ordered by position). -func BuildWorkflowExport(workflows []*taskmodels.Workflow, stepsByWorkflow map[string][]*WorkflowStep) *WorkflowExport { +// resolveProfile converts agent profile IDs to portable form (may be nil). +func BuildWorkflowExport(workflows []*taskmodels.Workflow, stepsByWorkflow map[string][]*WorkflowStep, resolveProfile AgentProfileResolver) *WorkflowExport { portable := make([]WorkflowPortable, 0, len(workflows)) for _, wf := range workflows { steps := stepsByWorkflow[wf.ID] - portable = append(portable, buildWorkflowPortable(wf, steps)) + portable = append(portable, buildWorkflowPortable(wf, steps, resolveProfile)) } return &WorkflowExport{ Version: ExportVersion, @@ -54,7 +70,7 @@ func BuildWorkflowExport(workflows []*taskmodels.Workflow, stepsByWorkflow map[s } } -func buildWorkflowPortable(wf *taskmodels.Workflow, steps []*WorkflowStep) WorkflowPortable { +func buildWorkflowPortable(wf *taskmodels.Workflow, steps []*WorkflowStep, resolveProfile AgentProfileResolver) WorkflowPortable { portableSteps := make([]StepPortable, 0, len(steps)) // Build step ID → position map for converting move_to_step references. idToPos := make(map[string]int, len(steps)) @@ -62,7 +78,7 @@ func buildWorkflowPortable(wf *taskmodels.Workflow, steps []*WorkflowStep) Workf idToPos[s.ID] = s.Position } for _, s := range steps { - portableSteps = append(portableSteps, StepPortable{ + sp := StepPortable{ Name: s.Name, Position: s.Position, Color: s.Color, @@ -72,13 +88,22 @@ func buildWorkflowPortable(wf *taskmodels.Workflow, steps []*WorkflowStep) Workf ShowInCommandPanel: s.ShowInCommandPanel, AllowManualMove: s.AllowManualMove, AutoArchiveAfterHours: s.AutoArchiveAfterHours, - }) + } + if resolveProfile != nil && s.AgentProfileID != "" { + sp.AgentProfile = resolveProfile(s.AgentProfileID) + } + portableSteps = append(portableSteps, sp) } - return WorkflowPortable{ + + wp := WorkflowPortable{ Name: wf.Name, Description: wf.Description, Steps: portableSteps, } + if resolveProfile != nil && wf.AgentProfileID != "" { + wp.AgentProfile = resolveProfile(wf.AgentProfileID) + } + return wp } // Validate checks that the export data is well-formed. diff --git a/apps/backend/internal/workflow/models/export_test.go b/apps/backend/internal/workflow/models/export_test.go index 8ba065ab43..9074a92c64 100644 --- a/apps/backend/internal/workflow/models/export_test.go +++ b/apps/backend/internal/workflow/models/export_test.go @@ -25,7 +25,7 @@ func TestBuildWorkflowExport(t *testing.T) { } stepMap := map[string][]*WorkflowStep{"wf-1": steps} - export := BuildWorkflowExport([]*taskmodels.Workflow{wf}, stepMap) + export := BuildWorkflowExport([]*taskmodels.Workflow{wf}, stepMap, nil) require.Equal(t, ExportVersion, export.Version) require.Equal(t, ExportType, export.Type) @@ -57,7 +57,7 @@ func TestBuildWorkflowExport(t *testing.T) { }, }, } - export := BuildWorkflowExport([]*taskmodels.Workflow{wf}, map[string][]*WorkflowStep{"wf-1": steps}) + export := BuildWorkflowExport([]*taskmodels.Workflow{wf}, map[string][]*WorkflowStep{"wf-1": steps}, nil) sp := export.Workflows[0].Steps[0] assert.Len(t, sp.Events.OnEnter, 1) @@ -312,7 +312,7 @@ func TestRoundTrip(t *testing.T) { }, } wf := &taskmodels.Workflow{ID: "wf-1", Name: "Pipeline"} - export := BuildWorkflowExport([]*taskmodels.Workflow{wf}, map[string][]*WorkflowStep{"wf-1": steps}) + export := BuildWorkflowExport([]*taskmodels.Workflow{wf}, map[string][]*WorkflowStep{"wf-1": steps}, nil) require.NoError(t, export.Validate()) @@ -346,6 +346,7 @@ func TestShowInCommandPanelExport(t *testing.T) { export := BuildWorkflowExport( []*taskmodels.Workflow{wf}, map[string][]*WorkflowStep{"wf-1": steps}, + nil, ) require.Len(t, export.Workflows[0].Steps, 3) @@ -355,6 +356,89 @@ func TestShowInCommandPanelExport(t *testing.T) { }) } +func TestAgentProfileExport(t *testing.T) { + resolver := func(profileID string) *AgentProfilePortable { + profiles := map[string]*AgentProfilePortable{ + "prof-1": {AgentName: "Claude Code", Model: "opus", Mode: "code"}, + "prof-2": {AgentName: "Codex", Model: "o3"}, + } + return profiles[profileID] + } + + t.Run("includes agent profile on workflow and steps", func(t *testing.T) { + wf := &taskmodels.Workflow{ID: "wf-1", Name: "WithProfiles", AgentProfileID: "prof-1"} + steps := []*WorkflowStep{ + {ID: "s1", Name: "Dev", Position: 0, Color: "blue", AgentProfileID: "prof-2"}, + {ID: "s2", Name: "Review", Position: 1, Color: "green"}, + } + export := BuildWorkflowExport( + []*taskmodels.Workflow{wf}, + map[string][]*WorkflowStep{"wf-1": steps}, + resolver, + ) + + pw := export.Workflows[0] + require.NotNil(t, pw.AgentProfile) + assert.Equal(t, "Claude Code", pw.AgentProfile.AgentName) + assert.Equal(t, "opus", pw.AgentProfile.Model) + assert.Equal(t, "code", pw.AgentProfile.Mode) + + require.NotNil(t, pw.Steps[0].AgentProfile) + assert.Equal(t, "Codex", pw.Steps[0].AgentProfile.AgentName) + assert.Equal(t, "o3", pw.Steps[0].AgentProfile.Model) + assert.Empty(t, pw.Steps[0].AgentProfile.Mode) + + assert.Nil(t, pw.Steps[1].AgentProfile, "step without profile should be nil") + }) + + t.Run("omits agent profile when resolver is nil", func(t *testing.T) { + wf := &taskmodels.Workflow{ID: "wf-1", Name: "NoResolver", AgentProfileID: "prof-1"} + steps := []*WorkflowStep{ + {ID: "s1", Name: "Step", Position: 0, Color: "gray", AgentProfileID: "prof-2"}, + } + export := BuildWorkflowExport( + []*taskmodels.Workflow{wf}, + map[string][]*WorkflowStep{"wf-1": steps}, + nil, + ) + + pw := export.Workflows[0] + assert.Nil(t, pw.AgentProfile) + assert.Nil(t, pw.Steps[0].AgentProfile) + }) + + t.Run("omits agent profile when IDs are empty", func(t *testing.T) { + wf := &taskmodels.Workflow{ID: "wf-1", Name: "EmptyIDs"} + steps := []*WorkflowStep{ + {ID: "s1", Name: "Step", Position: 0, Color: "gray"}, + } + export := BuildWorkflowExport( + []*taskmodels.Workflow{wf}, + map[string][]*WorkflowStep{"wf-1": steps}, + resolver, + ) + + pw := export.Workflows[0] + assert.Nil(t, pw.AgentProfile) + assert.Nil(t, pw.Steps[0].AgentProfile) + }) + + t.Run("handles unknown profile ID gracefully", func(t *testing.T) { + wf := &taskmodels.Workflow{ID: "wf-1", Name: "Unknown", AgentProfileID: "prof-unknown"} + steps := []*WorkflowStep{ + {ID: "s1", Name: "Step", Position: 0, Color: "gray"}, + } + export := BuildWorkflowExport( + []*taskmodels.Workflow{wf}, + map[string][]*WorkflowStep{"wf-1": steps}, + resolver, + ) + + pw := export.Workflows[0] + assert.Nil(t, pw.AgentProfile, "unknown profile should resolve to nil") + }) +} + func TestToInt(t *testing.T) { t.Run("float64", func(t *testing.T) { v, ok := toInt(float64(42)) diff --git a/apps/backend/internal/workflow/models/models.go b/apps/backend/internal/workflow/models/models.go index cf83bebc19..fa980b5ed7 100644 --- a/apps/backend/internal/workflow/models/models.go +++ b/apps/backend/internal/workflow/models/models.go @@ -104,6 +104,7 @@ type StepDefinition struct { IsStartStep bool `json:"is_start_step"` ShowInCommandPanel bool `json:"show_in_command_panel"` AutoArchiveAfterHours int `json:"auto_archive_after_hours,omitempty"` + AgentProfileID string `json:"agent_profile_id,omitempty"` } // WorkflowStep represents a step in a workflow @@ -119,6 +120,7 @@ type WorkflowStep struct { IsStartStep bool `json:"is_start_step"` ShowInCommandPanel bool `json:"show_in_command_panel"` AutoArchiveAfterHours int `json:"auto_archive_after_hours,omitempty"` + AgentProfileID string `json:"agent_profile_id,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/apps/backend/internal/workflow/repository/sqlite.go b/apps/backend/internal/workflow/repository/sqlite.go index ac950fc0e1..10cd8d4309 100644 --- a/apps/backend/internal/workflow/repository/sqlite.go +++ b/apps/backend/internal/workflow/repository/sqlite.go @@ -102,6 +102,9 @@ func (r *Repository) initSchema() error { // Add show_in_command_panel column if it doesn't exist (idempotent migration) _, _ = r.db.Exec(`ALTER TABLE workflow_steps ADD COLUMN show_in_command_panel INTEGER DEFAULT 1`) + // Add agent_profile_id column to workflow_steps for per-step agent profile override (ignore error if already exists) + _, _ = r.db.Exec(`ALTER TABLE workflow_steps ADD COLUMN agent_profile_id TEXT DEFAULT ''`) + // Seed system templates if err := r.seedSystemTemplates(); err != nil { return fmt.Errorf("failed to seed system templates: %w", err) @@ -540,11 +543,11 @@ func (r *Repository) CreateStep(ctx context.Context, step *models.WorkflowStep) _, err = r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO workflow_steps ( id, workflow_id, name, position, color, - prompt, events, allow_manual_move, is_start_step, show_in_command_panel, auto_archive_after_hours, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + prompt, events, allow_manual_move, is_start_step, show_in_command_panel, auto_archive_after_hours, agent_profile_id, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), step.ID, step.WorkflowID, step.Name, step.Position, step.Color, step.Prompt, string(eventsJSON), dialect.BoolToInt(step.AllowManualMove), - dialect.BoolToInt(step.IsStartStep), dialect.BoolToInt(step.ShowInCommandPanel), step.AutoArchiveAfterHours, step.CreatedAt, step.UpdatedAt) + dialect.BoolToInt(step.IsStartStep), dialect.BoolToInt(step.ShowInCommandPanel), step.AutoArchiveAfterHours, step.AgentProfileID, step.CreatedAt, step.UpdatedAt) return err } @@ -556,10 +559,10 @@ func (r *Repository) scanStep(row interface { step := &models.WorkflowStep{} var allowManualMove, isStartStep, showInCommandPanel int var autoArchiveAfterHours sql.NullInt64 - var color, prompt, eventsJSON sql.NullString + var color, prompt, eventsJSON, agentProfileID sql.NullString err := row.Scan(&step.ID, &step.WorkflowID, &step.Name, &step.Position, &color, - &prompt, &eventsJSON, &allowManualMove, &isStartStep, &showInCommandPanel, &autoArchiveAfterHours, &step.CreatedAt, &step.UpdatedAt) + &prompt, &eventsJSON, &allowManualMove, &isStartStep, &showInCommandPanel, &autoArchiveAfterHours, &agentProfileID, &step.CreatedAt, &step.UpdatedAt) if err != nil { return nil, err @@ -571,6 +574,9 @@ func (r *Repository) scanStep(row interface { if autoArchiveAfterHours.Valid { step.AutoArchiveAfterHours = int(autoArchiveAfterHours.Int64) } + if agentProfileID.Valid { + step.AgentProfileID = agentProfileID.String + } if color.Valid { step.Color = color.String } @@ -586,7 +592,7 @@ func (r *Repository) scanStep(row interface { return step, nil } -const stepSelectColumns = `id, workflow_id, name, position, color, prompt, events, allow_manual_move, is_start_step, show_in_command_panel, auto_archive_after_hours, created_at, updated_at` +const stepSelectColumns = `id, workflow_id, name, position, color, prompt, events, allow_manual_move, is_start_step, show_in_command_panel, auto_archive_after_hours, agent_profile_id, created_at, updated_at` // GetStep retrieves a workflow step by ID. func (r *Repository) GetStep(ctx context.Context, id string) (*models.WorkflowStep, error) { @@ -618,11 +624,11 @@ func (r *Repository) UpdateStep(ctx context.Context, step *models.WorkflowStep) UPDATE workflow_steps SET name = ?, position = ?, color = ?, prompt = ?, events = ?, - allow_manual_move = ?, is_start_step = ?, show_in_command_panel = ?, auto_archive_after_hours = ?, updated_at = ? + allow_manual_move = ?, is_start_step = ?, show_in_command_panel = ?, auto_archive_after_hours = ?, agent_profile_id = ?, updated_at = ? WHERE id = ? `), step.Name, step.Position, step.Color, step.Prompt, string(eventsJSON), - dialect.BoolToInt(step.AllowManualMove), dialect.BoolToInt(step.IsStartStep), dialect.BoolToInt(step.ShowInCommandPanel), step.AutoArchiveAfterHours, step.UpdatedAt, step.ID) + dialect.BoolToInt(step.AllowManualMove), dialect.BoolToInt(step.IsStartStep), dialect.BoolToInt(step.ShowInCommandPanel), step.AutoArchiveAfterHours, step.AgentProfileID, step.UpdatedAt, step.ID) if err != nil { return err } @@ -725,7 +731,7 @@ func (r *Repository) ListStepsByWorkflow(ctx context.Context, workflowID string) func (r *Repository) ListStepsByWorkspaceID(ctx context.Context, workspaceID string) ([]*models.WorkflowStep, error) { rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` SELECT ws.id, ws.workflow_id, ws.name, ws.position, ws.color, ws.prompt, ws.events, - ws.allow_manual_move, ws.is_start_step, ws.show_in_command_panel, ws.auto_archive_after_hours, ws.created_at, ws.updated_at + ws.allow_manual_move, ws.is_start_step, ws.show_in_command_panel, ws.auto_archive_after_hours, ws.agent_profile_id, ws.created_at, ws.updated_at FROM workflow_steps ws JOIN workflows w ON ws.workflow_id = w.id WHERE w.workspace_id = ? diff --git a/apps/backend/internal/workflow/repository/sqlite_test.go b/apps/backend/internal/workflow/repository/sqlite_test.go new file mode 100644 index 0000000000..0841de0e61 --- /dev/null +++ b/apps/backend/internal/workflow/repository/sqlite_test.go @@ -0,0 +1,187 @@ +package repository + +import ( + "context" + "database/sql" + "testing" + + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" + + "github.com/kandev/kandev/internal/workflow/models" +) + +func setupTestRepo(t *testing.T) *Repository { + t.Helper() + rawDB, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("failed to open sqlite: %v", err) + } + sqlxDB := sqlx.NewDb(rawDB, "sqlite3") + t.Cleanup(func() { _ = sqlxDB.Close() }) + + // Create workflows table (normally created by task repo) + _, err = sqlxDB.Exec(`CREATE TABLE IF NOT EXISTS workflows ( + id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL DEFAULT '', + workflow_template_id TEXT DEFAULT '', name TEXT NOT NULL, + description TEXT DEFAULT '', created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL + )`) + if err != nil { + t.Fatalf("failed to create workflows table: %v", err) + } + + // Create task_sessions table (referenced by session_step_history FK) + _, err = sqlxDB.Exec(`CREATE TABLE IF NOT EXISTS task_sessions ( + id TEXT PRIMARY KEY + )`) + if err != nil { + t.Fatalf("failed to create task_sessions table: %v", err) + } + + // Insert a test workflow + _, err = sqlxDB.Exec(`INSERT INTO workflows (id, workspace_id, name, created_at, updated_at) + VALUES ('wf-test', '', 'Test', datetime('now'), datetime('now'))`) + if err != nil { + t.Fatalf("failed to insert test workflow: %v", err) + } + + repo, err := NewWithDB(sqlxDB, sqlxDB) + if err != nil { + t.Fatalf("failed to create repo: %v", err) + } + return repo +} + +func TestStepAgentProfileID_CreateAndGet(t *testing.T) { + repo := setupTestRepo(t) + ctx := context.Background() + + step := &models.WorkflowStep{ + WorkflowID: "wf-test", + Name: "Test Step", + Position: 0, + Color: "#000000", + AgentProfileID: "agent-profile-abc", + } + + if err := repo.CreateStep(ctx, step); err != nil { + t.Fatalf("failed to create step: %v", err) + } + if step.ID == "" { + t.Fatal("expected step ID to be set") + } + + retrieved, err := repo.GetStep(ctx, step.ID) + if err != nil { + t.Fatalf("failed to get step: %v", err) + } + if retrieved.AgentProfileID != "agent-profile-abc" { + t.Errorf("expected agent_profile_id 'agent-profile-abc', got %q", retrieved.AgentProfileID) + } +} + +func TestStepAgentProfileID_Update(t *testing.T) { + repo := setupTestRepo(t) + ctx := context.Background() + + step := &models.WorkflowStep{ + WorkflowID: "wf-test", + Name: "Update Step", + Position: 0, + AgentProfileID: "profile-original", + } + if err := repo.CreateStep(ctx, step); err != nil { + t.Fatalf("failed to create step: %v", err) + } + + // Update agent_profile_id + step.AgentProfileID = "profile-updated" + if err := repo.UpdateStep(ctx, step); err != nil { + t.Fatalf("failed to update step: %v", err) + } + + retrieved, err := repo.GetStep(ctx, step.ID) + if err != nil { + t.Fatalf("failed to get step: %v", err) + } + if retrieved.AgentProfileID != "profile-updated" { + t.Errorf("expected agent_profile_id 'profile-updated', got %q", retrieved.AgentProfileID) + } + + // Clear agent_profile_id + step.AgentProfileID = "" + if err := repo.UpdateStep(ctx, step); err != nil { + t.Fatalf("failed to update step: %v", err) + } + retrieved, _ = repo.GetStep(ctx, step.ID) + if retrieved.AgentProfileID != "" { + t.Errorf("expected empty agent_profile_id, got %q", retrieved.AgentProfileID) + } +} + +func TestStepAgentProfileID_ListByWorkflow(t *testing.T) { + repo := setupTestRepo(t) + ctx := context.Background() + + step1 := &models.WorkflowStep{ + WorkflowID: "wf-test", + Name: "Step 1", + Position: 0, + AgentProfileID: "profile-a", + } + step2 := &models.WorkflowStep{ + WorkflowID: "wf-test", + Name: "Step 2", + Position: 1, + AgentProfileID: "profile-b", + } + if err := repo.CreateStep(ctx, step1); err != nil { + t.Fatalf("failed to create step1: %v", err) + } + if err := repo.CreateStep(ctx, step2); err != nil { + t.Fatalf("failed to create step2: %v", err) + } + + steps, err := repo.ListStepsByWorkflow(ctx, "wf-test") + if err != nil { + t.Fatalf("failed to list steps: %v", err) + } + // Filter out any seeded steps (migration may have seeded defaults) + var testSteps []*models.WorkflowStep + for _, s := range steps { + if s.AgentProfileID == "profile-a" || s.AgentProfileID == "profile-b" { + testSteps = append(testSteps, s) + } + } + if len(testSteps) != 2 { + t.Fatalf("expected 2 steps with agent profiles, got %d", len(testSteps)) + } + if testSteps[0].AgentProfileID != "profile-a" { + t.Errorf("expected first step agent_profile_id 'profile-a', got %q", testSteps[0].AgentProfileID) + } + if testSteps[1].AgentProfileID != "profile-b" { + t.Errorf("expected second step agent_profile_id 'profile-b', got %q", testSteps[1].AgentProfileID) + } +} + +func TestStepAgentProfileID_EmptyByDefault(t *testing.T) { + repo := setupTestRepo(t) + ctx := context.Background() + + step := &models.WorkflowStep{ + WorkflowID: "wf-test", + Name: "No Profile Step", + Position: 0, + } + if err := repo.CreateStep(ctx, step); err != nil { + t.Fatalf("failed to create step: %v", err) + } + + retrieved, err := repo.GetStep(ctx, step.ID) + if err != nil { + t.Fatalf("failed to get step: %v", err) + } + if retrieved.AgentProfileID != "" { + t.Errorf("expected empty agent_profile_id by default, got %q", retrieved.AgentProfileID) + } +} diff --git a/apps/backend/internal/workflow/service/service.go b/apps/backend/internal/workflow/service/service.go index e46f04a14d..ccab311774 100644 --- a/apps/backend/internal/workflow/service/service.go +++ b/apps/backend/internal/workflow/service/service.go @@ -19,6 +19,7 @@ type WorkflowProvider interface { ListWorkflows(ctx context.Context, workspaceID string) ([]*taskmodels.Workflow, error) GetWorkflow(ctx context.Context, id string) (*taskmodels.Workflow, error) CreateWorkflow(ctx context.Context, workspaceID, name, description string) (*taskmodels.Workflow, error) + UpdateWorkflow(ctx context.Context, workflow *taskmodels.Workflow) error } // Service provides workflow business logic @@ -26,6 +27,8 @@ type Service struct { repo *repository.Repository logger *logger.Logger workflowProvider WorkflowProvider + resolveProfile models.AgentProfileResolver + matchProfile models.AgentProfileMatcher } // SetWorkflowProvider wires the workflow provider (set during service init to break circular deps). @@ -33,6 +36,12 @@ func (s *Service) SetWorkflowProvider(wp WorkflowProvider) { s.workflowProvider = wp } +// SetAgentProfileFuncs wires the agent profile resolver and matcher for export/import. +func (s *Service) SetAgentProfileFuncs(resolve models.AgentProfileResolver, match models.AgentProfileMatcher) { + s.resolveProfile = resolve + s.matchProfile = match +} + // NewService creates a new workflow service func NewService(repo *repository.Repository, log *logger.Logger) *Service { return &Service{ @@ -131,6 +140,15 @@ func (s *Service) GetNextStepByPosition(ctx context.Context, workflowID string, return nil, nil // No next step found (current step is the last one) } +// GetWorkflowAgentProfileID returns the default agent profile ID for a workflow. +func (s *Service) GetWorkflowAgentProfileID(ctx context.Context, workflowID string) (string, error) { + wf, err := s.workflowProvider.GetWorkflow(ctx, workflowID) + if err != nil { + return "", err + } + return wf.AgentProfileID, nil +} + // GetPreviousStepByPosition returns the previous step before the given position for a workflow. // Returns nil if there is no previous step (i.e., current step is the first one). func (s *Service) GetPreviousStepByPosition(ctx context.Context, workflowID string, currentPosition int) (*models.WorkflowStep, error) { @@ -372,7 +390,7 @@ func (s *Service) ExportWorkflow(ctx context.Context, workflowID string) (*model return nil, fmt.Errorf("failed to list steps: %w", err) } stepMap := map[string][]*models.WorkflowStep{wf.ID: steps} - return models.BuildWorkflowExport([]*taskmodels.Workflow{wf}, stepMap), nil + return models.BuildWorkflowExport([]*taskmodels.Workflow{wf}, stepMap, s.resolveProfile), nil } // ExportWorkflows exports all workflows for a workspace. @@ -389,7 +407,7 @@ func (s *Service) ExportWorkflows(ctx context.Context, workspaceID string) (*mod } stepMap[wf.ID] = steps } - return models.BuildWorkflowExport(workflows, stepMap), nil + return models.BuildWorkflowExport(workflows, stepMap, s.resolveProfile), nil } // ImportWorkflows imports workflows into a workspace. Deduplicates by name. @@ -432,6 +450,16 @@ func (s *Service) importSingleWorkflow(ctx context.Context, workspaceID string, return fmt.Errorf("create workflow: %w", err) } + // Match workflow-level agent profile if present. + if pw.AgentProfile != nil && s.matchProfile != nil { + if profileID := s.matchProfile(pw.AgentProfile.AgentName, pw.AgentProfile.Model, pw.AgentProfile.Mode); profileID != "" { + wf.AgentProfileID = profileID + if err := s.workflowProvider.UpdateWorkflow(ctx, wf); err != nil { + return fmt.Errorf("set workflow agent profile: %w", err) + } + } + } + // Generate UUIDs for all steps and build position→ID map. posToID := make(map[int]string, len(pw.Steps)) for _, sp := range pw.Steps { @@ -453,6 +481,10 @@ func (s *Service) importSingleWorkflow(ctx context.Context, workspaceID string, AllowManualMove: sp.AllowManualMove, AutoArchiveAfterHours: sp.AutoArchiveAfterHours, } + // Match step-level agent profile if present. + if sp.AgentProfile != nil && s.matchProfile != nil { + step.AgentProfileID = s.matchProfile(sp.AgentProfile.AgentName, sp.AgentProfile.Model, sp.AgentProfile.Mode) + } if err := s.repo.CreateStep(ctx, step); err != nil { return fmt.Errorf("create step %q: %w", sp.Name, err) } diff --git a/apps/backend/internal/workflow/service/service_test.go b/apps/backend/internal/workflow/service/service_test.go index cdeca58100..43395b01e8 100644 --- a/apps/backend/internal/workflow/service/service_test.go +++ b/apps/backend/internal/workflow/service/service_test.go @@ -83,6 +83,16 @@ func (m *mockWorkflowProvider) CreateWorkflow(_ context.Context, workspaceID, na return wf, nil } +func (m *mockWorkflowProvider) UpdateWorkflow(_ context.Context, workflow *taskmodels.Workflow) error { + for i, wf := range m.workflows { + if wf.ID == workflow.ID { + m.workflows[i] = workflow + return nil + } + } + return fmt.Errorf("workflow %s not found", workflow.ID) +} + func (m *mockWorkflowProvider) addWorkflow(id, workspaceID, name string) { now := time.Now().UTC() m.workflows = append(m.workflows, &taskmodels.Workflow{ @@ -610,4 +620,86 @@ func TestImportWorkflows(t *testing.T) { _, err := svc.ImportWorkflows(ctx, "ws-1", export) assert.ErrorContains(t, err, "invalid export data") }) + + t.Run("matches agent profiles on import", func(t *testing.T) { + svc, _, _ := setupTestServiceWithProvider(t) + ctx := context.Background() + + matcher := func(agentName, model, mode string) string { + if agentName == "Claude Code" && model == "opus" { + return "matched-prof-1" + } + return "" + } + svc.SetAgentProfileFuncs(nil, matcher) + + export := &models.WorkflowExport{ + Version: models.ExportVersion, + Type: models.ExportType, + Workflows: []models.WorkflowPortable{ + { + Name: "ProfileWF", + AgentProfile: &models.AgentProfilePortable{AgentName: "Claude Code", Model: "opus", Mode: "code"}, + Steps: []models.StepPortable{ + { + Name: "Dev", Position: 0, Color: "blue", + AgentProfile: &models.AgentProfilePortable{AgentName: "Claude Code", Model: "opus"}, + }, + { + Name: "Review", Position: 1, Color: "green", + AgentProfile: &models.AgentProfilePortable{AgentName: "Unknown Agent"}, + }, + }, + }, + }, + } + + result, err := svc.ImportWorkflows(ctx, "ws-1", export) + require.NoError(t, err) + require.Len(t, result.Created, 1) + + // Verify workflow-level agent profile was persisted + imported, err := svc.workflowProvider.GetWorkflow(ctx, "imported-ProfileWF") + require.NoError(t, err) + assert.Equal(t, "matched-prof-1", imported.AgentProfileID, "workflow-level agent profile should be persisted") + + steps, err := svc.repo.ListStepsByWorkflow(ctx, "imported-ProfileWF") + require.NoError(t, err) + require.Len(t, steps, 2) + + devStep := findStepByName(steps, "Dev") + require.NotNil(t, devStep) + assert.Equal(t, "matched-prof-1", devStep.AgentProfileID, "should match known profile") + + reviewStep := findStepByName(steps, "Review") + require.NotNil(t, reviewStep) + assert.Empty(t, reviewStep.AgentProfileID, "should not match unknown profile") + }) + + t.Run("skips agent profile matching when no matcher set", func(t *testing.T) { + svc, _, _ := setupTestServiceWithProvider(t) + ctx := context.Background() + + export := &models.WorkflowExport{ + Version: models.ExportVersion, + Type: models.ExportType, + Workflows: []models.WorkflowPortable{ + { + Name: "NoMatcher", + AgentProfile: &models.AgentProfilePortable{AgentName: "Claude Code"}, + Steps: []models.StepPortable{ + {Name: "S", Position: 0, Color: "gray", AgentProfile: &models.AgentProfilePortable{AgentName: "Claude Code"}}, + }, + }, + }, + } + + result, err := svc.ImportWorkflows(ctx, "ws-1", export) + require.NoError(t, err) + require.Len(t, result.Created, 1) + + steps, err := svc.repo.ListStepsByWorkflow(ctx, "imported-NoMatcher") + require.NoError(t, err) + assert.Empty(t, steps[0].AgentProfileID, "no matcher means no profile ID set") + }) } diff --git a/apps/packages/ui/src/sidebar.tsx b/apps/packages/ui/src/sidebar.tsx index 54115d26fc..90929455c2 100644 --- a/apps/packages/ui/src/sidebar.tsx +++ b/apps/packages/ui/src/sidebar.tsx @@ -289,7 +289,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
(`${apiBaseUrl}/api/v1/workflows/${id}`, { method: "PATCH", @@ -330,6 +330,7 @@ type BackendWorkflowStep = { is_start_step?: boolean; show_in_command_panel?: boolean; auto_archive_after_hours?: number; + agent_profile_id?: string; created_at: string; updated_at: string; }; @@ -346,6 +347,7 @@ const transformWorkflowStep = (step: BackendWorkflowStep): WorkflowStep => ({ is_start_step: step.is_start_step, show_in_command_panel: step.show_in_command_panel, auto_archive_after_hours: step.auto_archive_after_hours, + agent_profile_id: step.agent_profile_id, created_at: step.created_at, updated_at: step.updated_at, }); @@ -417,6 +419,7 @@ export async function updateWorkflowStepAction( | "is_start_step" | "show_in_command_panel" | "auto_archive_after_hours" + | "agent_profile_id" > >, ): Promise { @@ -432,6 +435,7 @@ export async function updateWorkflowStepAction( body.show_in_command_panel = payload.show_in_command_panel; if (payload.auto_archive_after_hours !== undefined) body.auto_archive_after_hours = payload.auto_archive_after_hours; + if (payload.agent_profile_id !== undefined) body.agent_profile_id = payload.agent_profile_id; const response = await fetchJson( `${apiBaseUrl}/api/v1/workflow/steps/${stepId}`, { diff --git a/apps/web/app/settings/workspace/workspace-workflows-client.tsx b/apps/web/app/settings/workspace/workspace-workflows-client.tsx index 96771f24ef..bf6853d368 100644 --- a/apps/web/app/settings/workspace/workspace-workflows-client.tsx +++ b/apps/web/app/settings/workspace/workspace-workflows-client.tsx @@ -6,7 +6,7 @@ import { useRouter } from "next/navigation"; import { IconDownload, IconGripVertical, - IconLayoutColumns, + IconArrowsShuffle, IconPlus, IconUpload, } from "@tabler/icons-react"; @@ -207,7 +207,7 @@ function useWorkflowActions({ const handleUpdateWorkflow = ( workflowId: string, - updates: { name?: string; description?: string }, + updates: { name?: string; description?: string; agent_profile_id?: string }, ) => { setWorkflowItems((prev) => prev.map((wf) => (wf.id === workflowId ? { ...wf, ...updates } : wf)), @@ -234,8 +234,9 @@ function useWorkflowActions({ const handleSaveWorkflow = async (workflowId: string) => { const workflow = workflowItems.find((item) => item.id === workflowId); if (!workflow) return; - const updates: { name?: string; description?: string } = {}; + const updates: { name?: string; description?: string; agent_profile_id?: string } = {}; if (workflow.name.trim()) updates.name = workflow.name.trim(); + updates.agent_profile_id = workflow.agent_profile_id ?? ""; if (Object.keys(updates).length) await updateWorkflowAction(workflowId, updates); setWorkflowItems((prev) => prev.map((item) => (item.id === workflowId ? { ...item, ...updates } : item)), @@ -298,7 +299,10 @@ type WorkflowListProps = { workspaceId: string; templateStepsById: Map; isWorkflowDirty: (wf: Workflow) => boolean; - onUpdate: (id: string, u: { name?: string; description?: string }) => void; + onUpdate: ( + id: string, + u: { name?: string; description?: string; agent_profile_id?: string }, + ) => void; onDelete: (id: string) => void; onSave: (id: string) => void; onCreated: (id: string, wf: Workflow) => void; @@ -321,7 +325,7 @@ function SortableWorkflowItem({ opacity: isDragging ? 0.5 : 1, }; return ( -
+
wf.id)} strategy={verticalListSortingStrategy} > -
+
{workflowItems.map((workflow) => { const isTempWorkflow = workflow.id.startsWith("temp-"); const templateSteps = @@ -464,7 +468,7 @@ export function WorkspaceWorkflowsClient({
} + icon={} title="Workflows" description="Create autonomous pipelines with automated transitions or manual workflows where you move tasks yourself" action={ diff --git a/apps/web/components/github/review-watch-dialog.tsx b/apps/web/components/github/review-watch-dialog.tsx index ec08175b0b..e7efe694e6 100644 --- a/apps/web/components/github/review-watch-dialog.tsx +++ b/apps/web/components/github/review-watch-dialog.tsx @@ -21,7 +21,10 @@ import { useAppStore } from "@/components/state-provider"; import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; import { useWorkflows } from "@/hooks/use-workflows"; import { listWorkflowSteps } from "@/lib/api/domains/workflow-api"; -import { ScriptEditor } from "@/components/settings/profile-edit/script-editor"; +import { + ScriptEditor, + computeEditorHeight, +} from "@/components/settings/profile-edit/script-editor"; import { REVIEW_WATCH_PLACEHOLDERS, DEFAULT_REVIEW_WATCH_PROMPT, @@ -459,8 +462,8 @@ function PromptField({ setForm((prev) => ({ ...prev, prompt: v }))} - language="plaintext" - height="120px" + language="markdown" + height={computeEditorHeight(form.prompt)} lineNumbers="off" placeholders={REVIEW_WATCH_PLACEHOLDERS} /> diff --git a/apps/web/components/settings/profile-edit/script-editor.tsx b/apps/web/components/settings/profile-edit/script-editor.tsx index 3eb7bb6b58..1e5f344831 100644 --- a/apps/web/components/settings/profile-edit/script-editor.tsx +++ b/apps/web/components/settings/profile-edit/script-editor.tsx @@ -15,17 +15,18 @@ const MonacoEditor = dynamic(() => import("@monaco-editor/react").then((m) => m. ), }); +type Monaco = typeof import("monaco-editor"); + // Module-level singleton: only one completion provider registered for "shell" at a time. let globalDisposable: { dispose: () => void } | null = null; let registeredInstanceCount = 0; -function registerProvider( - monaco: typeof import("monaco-editor"), +function swapProvider( + monaco: Monaco, language: string, placeholders: ScriptPlaceholder[], executorType?: string, ) { - // Always dispose previous to avoid duplicates, then re-register with latest data globalDisposable?.dispose(); import("./script-editor-completions").then(({ createPlaceholderCompletionProvider }) => { globalDisposable?.dispose(); @@ -34,9 +35,28 @@ function registerProvider( createPlaceholderCompletionProvider(monaco, placeholders, executorType), ); }); +} + +function registerProvider( + monaco: Monaco, + language: string, + placeholders: ScriptPlaceholder[], + executorType?: string, +) { + swapProvider(monaco, language, placeholders, executorType); registeredInstanceCount++; } +// Re-register without incrementing the lifecycle counter (e.g., when placeholders change) +function reRegisterProvider( + monaco: Monaco, + language: string, + placeholders: ScriptPlaceholder[], + executorType?: string, +) { + swapProvider(monaco, language, placeholders, executorType); +} + function unregisterProvider() { registeredInstanceCount--; if (registeredInstanceCount <= 0) { @@ -46,6 +66,15 @@ function unregisterProvider() { } } +/** Compute editor height from content lines (min 80px, max 400px). */ +export function computeEditorHeight(value: string, minLines = 3): string { + const lineCount = Math.max((value || "").split("\n").length, minLines); + const lineHeight = 19; + const padding = 16; + const height = Math.min(Math.max(lineCount * lineHeight + padding, 80), 400); + return `${height}px`; +} + type ScriptEditorProps = { value: string; onChange: (value: string) => void; @@ -68,6 +97,7 @@ export function ScriptEditor({ lineNumbers = "on", }: ScriptEditorProps) { const mountedRef = useRef(false); + const monacoRef = useRef(null); useEffect(() => { return () => { @@ -78,18 +108,36 @@ export function ScriptEditor({ }; }, []); + // Register/re-register the provider whenever Monaco is mounted and placeholders + // change. The singleton `registerProvider` disposes any previous registration, + // so swapping is idempotent. `mountedRef` guards the lifecycle counter. + const ensureProviderRegistered = useCallback( + (monaco: Monaco) => { + if (!placeholders || placeholders.length === 0) return; + if (!mountedRef.current) { + mountedRef.current = true; + registerProvider(monaco, language, placeholders, executorType); + } else { + reRegisterProvider(monaco, language, placeholders, executorType); + } + }, + [placeholders, executorType, language], + ); + + useEffect(() => { + if (monacoRef.current) ensureProviderRegistered(monacoRef.current); + }, [ensureProviderRegistered]); + const handleBeforeMount: BeforeMount = useCallback((monaco) => { monaco.editor.defineTheme("kandev-dark", KANDEV_MONACO_DARK); }, []); const handleMount: OnMount = useCallback( (_editor, monaco) => { - if (placeholders && placeholders.length > 0) { - mountedRef.current = true; - registerProvider(monaco, language, placeholders, executorType); - } + monacoRef.current = monaco; + ensureProviderRegistered(monaco); }, - [placeholders, executorType, language], + [ensureProviderRegistered], ); return ( diff --git a/apps/web/components/settings/settings-app-sidebar.tsx b/apps/web/components/settings/settings-app-sidebar.tsx index 3d82e12a26..1673bf68f5 100644 --- a/apps/web/components/settings/settings-app-sidebar.tsx +++ b/apps/web/components/settings/settings-app-sidebar.tsx @@ -15,6 +15,8 @@ import { IconBrandGithub, IconSparkles, IconWand, + IconGitBranch, + IconArrowsShuffle, } from "@tabler/icons-react"; import { Sidebar, @@ -120,6 +122,7 @@ function WorkspacesSidebarSection({ pathname, workspaces }: WorkspacesSidebarSec isActive={pathname === repositoriesPath} > + Repositories @@ -127,6 +130,7 @@ function WorkspacesSidebarSection({ pathname, workspaces }: WorkspacesSidebarSec + Workflows diff --git a/apps/web/components/settings/settings-layout-client.tsx b/apps/web/components/settings/settings-layout-client.tsx index 33ec26581d..74f4668154 100644 --- a/apps/web/components/settings/settings-layout-client.tsx +++ b/apps/web/components/settings/settings-layout-client.tsx @@ -33,7 +33,7 @@ export function SettingsLayoutClient({ children }: { children: React.ReactNode }
-
{children}
+
{children}
diff --git a/apps/web/components/settings/unsaved-indicator.tsx b/apps/web/components/settings/unsaved-indicator.tsx index d37d6cde19..0cff9537c0 100644 --- a/apps/web/components/settings/unsaved-indicator.tsx +++ b/apps/web/components/settings/unsaved-indicator.tsx @@ -30,7 +30,7 @@ export function UnsavedSaveButton({ return (