feat: associate agent profiles with workflows and steps (#597)

This commit is contained in:
Carlos Florêncio
2026-04-16 10:14:45 +01:00
committed by GitHub
parent cafbb63845
commit a222854422
59 changed files with 2272 additions and 203 deletions
+5
View File
@@ -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
+56
View File
@@ -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 ""
}
}
@@ -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.
//
@@ -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
}
@@ -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 {
@@ -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)
}
})
}
@@ -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.
@@ -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{}
+17 -14
View File
@@ -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"`
}
@@ -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))
@@ -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"`
@@ -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
}
@@ -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
}
@@ -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()
@@ -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)
@@ -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
@@ -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 {
@@ -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
}
+43 -18
View File
@@ -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.
@@ -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))
@@ -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"`
}
@@ -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 = ?
@@ -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)
}
}
@@ -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)
}
@@ -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")
})
}
+1 -1
View File
@@ -289,7 +289,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
<main
data-slot="sidebar-inset"
className={cn(
"bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2 relative flex w-full flex-1 flex-col",
"bg-background md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2 relative flex min-w-0 w-full flex-1 flex-col",
className,
)}
{...props}
+5 -1
View File
@@ -123,7 +123,7 @@ export async function createWorkflowAction(payload: {
export async function updateWorkflowAction(
id: string,
payload: { name?: string; description?: string },
payload: { name?: string; description?: string; agent_profile_id?: string },
) {
return fetchJson<Workflow>(`${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<WorkflowStep> {
@@ -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<BackendWorkflowStep>(
`${apiBaseUrl}/api/v1/workflow/steps/${stepId}`,
{
@@ -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<string, StepDefinition[]>;
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 (
<div ref={setNodeRef} style={style} className="relative">
<div ref={setNodeRef} style={style} className="relative min-w-0">
<div
className="absolute left-0 top-6 -ml-8 flex items-center cursor-grab active:cursor-grabbing z-10"
data-testid={`workflow-drag-handle-${workflow.id}`}
@@ -369,7 +373,7 @@ function WorkflowList({
items={workflowItems.map((wf) => wf.id)}
strategy={verticalListSortingStrategy}
>
<div className="grid gap-3 pl-8">
<div className="grid min-w-0 gap-3 pl-8">
{workflowItems.map((workflow) => {
const isTempWorkflow = workflow.id.startsWith("temp-");
const templateSteps =
@@ -464,7 +468,7 @@ export function WorkspaceWorkflowsClient({
</div>
<Separator />
<SettingsSection
icon={<IconLayoutColumns className="h-5 w-5" />}
icon={<IconArrowsShuffle className="h-5 w-5" />}
title="Workflows"
description="Create autonomous pipelines with automated transitions or manual workflows where you move tasks yourself"
action={
@@ -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({
<ScriptEditor
value={form.prompt}
onChange={(v) => setForm((prev) => ({ ...prev, prompt: v }))}
language="plaintext"
height="120px"
language="markdown"
height={computeEditorHeight(form.prompt)}
lineNumbers="off"
placeholders={REVIEW_WATCH_PLACEHOLDERS}
/>
@@ -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<Monaco | null>(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 (
@@ -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}
>
<Link href={repositoriesPath}>
<IconGitBranch className="h-3.5 w-3.5" />
<span>Repositories</span>
</Link>
</SidebarMenuSubButton>
@@ -127,6 +130,7 @@ function WorkspacesSidebarSection({ pathname, workspaces }: WorkspacesSidebarSec
<SidebarMenuSubItem>
<SidebarMenuSubButton asChild size="sm" isActive={pathname === workflowsPath}>
<Link href={workflowsPath}>
<IconArrowsShuffle className="h-3.5 w-3.5" />
<span>Workflows</span>
</Link>
</SidebarMenuSubButton>
@@ -33,7 +33,7 @@ export function SettingsLayoutClient({ children }: { children: React.ReactNode }
</Breadcrumb>
</div>
</header>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0 mb-20">{children}</div>
<div className="flex min-w-0 flex-1 flex-col gap-4 p-4 pt-0 mb-20">{children}</div>
</SidebarInset>
</SidebarProvider>
</TooltipProvider>
@@ -30,7 +30,7 @@ export function UnsavedSaveButton({
return (
<Button
type="button"
size="lg"
size="default"
variant={isDirty ? "secondary" : "outline"}
onClick={onClick}
disabled={isLoading || Boolean(disabled)}
+107 -48
View File
@@ -6,13 +6,16 @@ import { Card, CardContent } from "@kandev/ui/card";
import { Button } from "@kandev/ui/button";
import { Input } from "@kandev/ui/input";
import { Label } from "@kandev/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import type { Workflow, WorkflowStep } from "@/lib/types/http";
import { useHealthyAgentProfiles } from "@/hooks/domains/settings/use-healthy-agent-profiles";
import { useRequest } from "@/lib/http/use-request";
import { useToast } from "@/components/toast-provider";
import { WorkflowExportDialog } from "@/components/settings/workflow-export-dialog";
import { UnsavedChangesBadge, UnsavedSaveButton } from "@/components/settings/unsaved-indicator";
import { WorkflowPipelineEditor } from "@/components/settings/workflow-pipeline-editor";
import { listWorkflowStepsAction } from "@/app/actions/workspaces";
import { HelpTip } from "./workflow-pipeline-editor-helpers";
import { WorkflowDeleteDialog, StepDeleteDialog } from "./workflow-card-dialogs";
import {
useWorkflowStepActions,
@@ -28,7 +31,11 @@ type WorkflowCardProps = {
initialWorkflowSteps?: WorkflowStep[];
templateStepCount?: number;
otherWorkflows?: Workflow[];
onUpdateWorkflow: (updates: { name?: string; description?: string }) => void;
onUpdateWorkflow: (updates: {
name?: string;
description?: string;
agent_profile_id?: string;
}) => void;
onDeleteWorkflow: () => Promise<unknown>;
onSaveWorkflow: () => Promise<unknown>;
onWorkflowCreated?: (created: Workflow) => void;
@@ -224,7 +231,11 @@ type WorkflowCardDialogsProps = {
type WorkflowCardBodyProps = {
workflow: Workflow;
isWorkflowDirty: boolean;
onUpdateWorkflow: (updates: { name?: string; description?: string }) => void;
onUpdateWorkflow: (updates: {
name?: string;
description?: string;
agent_profile_id?: string;
}) => void;
activeSaveRequest: { isLoading: boolean; status: "idle" | "loading" | "success" | "error" };
handleSaveWorkflow: () => Promise<void>;
workflowLoading: boolean;
@@ -247,27 +258,56 @@ function WorkflowCardBody({
workflowSteps,
stepActions,
}: WorkflowCardBodyProps) {
const healthyProfiles = useHealthyAgentProfiles(workflow.agent_profile_id);
return (
<>
<div className="flex items-center justify-between gap-3">
<div className="space-y-2 flex-1">
<div className="flex items-end gap-2">
<div className="flex-1 space-y-1.5">
<Label className="flex items-center gap-2">
<span>Workflow Name</span>
{isWorkflowDirty && <UnsavedChangesBadge />}
</Label>
<div className="flex items-center gap-2">
<Input
value={workflow.name}
onChange={(e) => onUpdateWorkflow({ name: e.target.value })}
/>
<UnsavedSaveButton
isDirty={isWorkflowDirty}
isLoading={activeSaveRequest.isLoading}
status={activeSaveRequest.status}
onClick={handleSaveWorkflow}
/>
</div>
<Input
value={workflow.name}
onChange={(e) => onUpdateWorkflow({ name: e.target.value })}
/>
</div>
<div className="w-[240px] shrink-0 space-y-1.5">
<Label className="flex items-center gap-1">
<span>Agent Profile</span>
<HelpTip text="Default agent profile for tasks in this workflow. When set, the agent selector is locked in the task creation dialog." />
</Label>
<Select
value={workflow.agent_profile_id || "none"}
onValueChange={(value) =>
onUpdateWorkflow({ agent_profile_id: value === "none" ? "" : value })
}
>
<SelectTrigger
className="w-full cursor-pointer"
data-testid="workflow-agent-profile-select"
>
<SelectValue placeholder="None (use task default)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none" className="cursor-pointer">
None (use task default)
</SelectItem>
{healthyProfiles.map((p) => (
<SelectItem key={p.id} value={p.id} className="cursor-pointer">
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<UnsavedSaveButton
isDirty={isWorkflowDirty}
isLoading={activeSaveRequest.isLoading}
status={activeSaveRequest.status}
onClick={handleSaveWorkflow}
/>
</div>
<div className="space-y-2">
<Label>Workflow Steps</Label>
@@ -337,17 +377,9 @@ function WorkflowCardDialogs({
);
}
export function WorkflowCard({
workflow,
isWorkflowDirty,
initialWorkflowSteps,
templateStepCount = 0,
otherWorkflows = [],
onUpdateWorkflow,
onDeleteWorkflow,
onSaveWorkflow,
onWorkflowCreated,
}: WorkflowCardProps) {
function useWorkflowCardState(props: WorkflowCardProps) {
const { workflow, initialWorkflowSteps, templateStepCount = 0, otherWorkflows = [] } = props;
const { onDeleteWorkflow, onSaveWorkflow, onWorkflowCreated } = props;
const { toast } = useToast();
const [exportOpen, setExportOpen] = useState(false);
const [exportYaml, setExportYaml] = useState("");
@@ -395,43 +427,70 @@ export function WorkflowCard({
const stepsForStepMigration = stepDel.stepToDelete
? workflowSteps.filter((s) => s.id !== stepDel.stepToDelete)
: [];
return {
toast,
exportOpen,
setExportOpen,
exportYaml,
setExportYaml,
wfDel,
stepDel,
isNewWorkflow,
deleteWorkflowRequest,
workflowSteps,
workflowLoading,
stepActions,
activeSaveRequest,
handleSaveWorkflow,
wfDeleteHandlers,
stepDeleteHandlers,
stepsForStepMigration,
};
}
export function WorkflowCard(props: WorkflowCardProps) {
const { workflow, isWorkflowDirty, otherWorkflows = [], onUpdateWorkflow } = props;
const s = useWorkflowCardState(props);
return (
<Card data-testid={`workflow-card-${workflow.id}`}>
<Card
data-testid={`workflow-card-${workflow.id}`}
className={isWorkflowDirty ? "ring-yellow-500/50" : undefined}
>
<CardContent className="pt-6">
<div className="space-y-4">
<WorkflowCardBody
workflow={workflow}
isWorkflowDirty={isWorkflowDirty}
onUpdateWorkflow={onUpdateWorkflow}
activeSaveRequest={activeSaveRequest}
handleSaveWorkflow={handleSaveWorkflow}
workflowLoading={workflowLoading}
workflowSteps={workflowSteps}
stepActions={stepActions}
activeSaveRequest={s.activeSaveRequest}
handleSaveWorkflow={s.handleSaveWorkflow}
workflowLoading={s.workflowLoading}
workflowSteps={s.workflowSteps}
stepActions={s.stepActions}
/>
<WorkflowCardActions
isNewWorkflow={isNewWorkflow}
isNewWorkflow={s.isNewWorkflow}
workflowId={workflow.id}
setExportYaml={setExportYaml}
setExportOpen={setExportOpen}
toast={toast}
onDeleteClick={wfDeleteHandlers.handleDeleteWorkflowClick}
deleteDisabled={deleteWorkflowRequest.isLoading || wfDel.workflowDeleteLoading}
setExportYaml={s.setExportYaml}
setExportOpen={s.setExportOpen}
toast={s.toast}
onDeleteClick={s.wfDeleteHandlers.handleDeleteWorkflowClick}
deleteDisabled={s.deleteWorkflowRequest.isLoading || s.wfDel.workflowDeleteLoading}
/>
</div>
</CardContent>
<WorkflowCardDialogs
wfDel={wfDel}
wfDel={s.wfDel}
otherWorkflows={otherWorkflows}
deleteWorkflowLoading={deleteWorkflowRequest.isLoading}
wfDeleteHandlers={wfDeleteHandlers}
exportOpen={exportOpen}
setExportOpen={setExportOpen}
exportYaml={exportYaml}
stepDel={stepDel}
stepsForStepMigration={stepsForStepMigration}
stepDeleteHandlers={stepDeleteHandlers}
deleteWorkflowLoading={s.deleteWorkflowRequest.isLoading}
wfDeleteHandlers={s.wfDeleteHandlers}
exportOpen={s.exportOpen}
setExportOpen={s.setExportOpen}
exportYaml={s.exportYaml}
stepDel={s.stepDel}
stepsForStepMigration={s.stepsForStepMigration}
stepDeleteHandlers={s.stepDeleteHandlers}
/>
</Card>
);
@@ -1,16 +1,22 @@
"use client";
import { useState } from "react";
import { IconTrash } from "@tabler/icons-react";
import { IconRobot, IconTrash } from "@tabler/icons-react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip";
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 } from "@/lib/types/http";
import { useHealthyAgentProfiles } from "@/hooks/domains/settings/use-healthy-agent-profiles";
import { useDebouncedCallback } from "@/hooks/use-debounce";
import { cn } from "@/lib/utils";
import {
ScriptEditor,
computeEditorHeight,
} from "@/components/settings/profile-edit/script-editor";
import type { ScriptPlaceholder } from "@/components/settings/profile-edit/script-editor-completions";
import {
HelpTip,
STEP_COLORS,
@@ -24,6 +30,70 @@ import {
TurnCompleteSelect,
} from "./workflow-pipeline-editor-step-actions";
const STEP_PROMPT_PLACEHOLDERS: ScriptPlaceholder[] = [
{
key: "task_prompt",
description: "The original task description provided by the user",
example: "Implement user authentication with OAuth2",
executor_types: [],
},
];
// --- StepAgentProfileSelect ---
function StepAgentProfileSelect({
step,
onUpdate,
readOnly,
}: {
step: WorkflowStep;
onUpdate: (updates: Partial<WorkflowStep>) => void;
readOnly: boolean;
}) {
const healthyProfiles = useHealthyAgentProfiles(step.agent_profile_id);
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center">
<Select
value={step.agent_profile_id || "none"}
onValueChange={(value) => {
if (readOnly) return;
onUpdate({ agent_profile_id: value === "none" ? "" : value });
}}
disabled={readOnly}
>
<SelectTrigger
className="w-[220px] h-8 cursor-pointer"
data-testid="step-agent-profile-select"
>
<IconRobot className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<SelectValue placeholder="No profile override" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none" className="cursor-pointer">
No profile override
</SelectItem>
{healthyProfiles.map((p) => (
<SelectItem key={p.id} value={p.id} className="cursor-pointer">
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
Override the agent profile for this step. A different profile creates a new session with
fresh context when entering this step.
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
// --- StepConfigHeader ---
type StepConfigHeaderProps = {
@@ -82,6 +152,7 @@ function StepConfigHeader({
))}
</SelectContent>
</Select>
<StepAgentProfileSelect step={step} onUpdate={onUpdate} readOnly={readOnly} />
</div>
<Button
type="button"
@@ -226,9 +297,13 @@ function StepBehaviorSection({
id={`${step.id}-reset-context`}
checked={hasOnEnterAction(step, "reset_agent_context")}
onCheckedChange={() => !readOnly && toggleOnEnterAction("reset_agent_context")}
disabled={readOnly}
disabled={readOnly || !!step.agent_profile_id}
label="Reset agent context"
helpText="Restart the agent with a fresh conversation context when entering this step. Useful for review steps that need an unbiased perspective."
helpText={
step.agent_profile_id
? "Not needed — switching agent profiles already creates a new session with fresh context."
: "Restart the agent with a fresh conversation context when entering this step. Useful for review steps that need an unbiased perspective."
}
/>
<StepCheckboxRow
id={`${step.id}-manual-move`}
@@ -376,25 +451,25 @@ function StepPromptSection({
))}
</div>
)}
<Textarea
id={`${step.id}-prompt`}
value={localPrompt}
onChange={(e) => {
if (readOnly) return;
onLocalPromptChange(e.target.value);
debouncedUpdatePrompt(e.target.value);
}}
placeholder={
"Instructions for the agent on this step.\nUse {{task_prompt}} to include the task description."
}
rows={3}
disabled={readOnly}
className="font-mono text-xs max-h-[200px] overflow-y-auto resize-y"
/>
<div className="rounded-md border overflow-hidden">
<ScriptEditor
value={localPrompt}
onChange={(v) => {
if (readOnly) return;
onLocalPromptChange(v);
debouncedUpdatePrompt(v);
}}
language="markdown"
height={computeEditorHeight(localPrompt)}
lineNumbers="off"
readOnly={readOnly}
placeholders={STEP_PROMPT_PLACEHOLDERS}
/>
</div>
<p className="text-[11px] text-muted-foreground/60">
If set, this prompt will be used instead of the task description. Use{" "}
Type {"{{"} to see available placeholders. Use{" "}
<code className="bg-muted px-1 py-0.5 rounded text-[10px]">{"{{task_prompt}}"}</code> to
include the original task description within it.
include the original task description.
</p>
</div>
);
@@ -134,6 +134,7 @@ function PipelineNode({
</div>
<StepCapabilityIcons
events={step.events}
agentProfileId={step.agent_profile_id}
fallback={<span className="text-xs text-muted-foreground/50">manual</span>}
/>
</div>
@@ -322,7 +323,7 @@ export function WorkflowPipelineEditor({
return (
<div className="space-y-3">
<ScrollArea className="w-full">
<ScrollArea className="w-full pb-1">
{isMounted ? (
<DndContext
collisionDetection={closestCenter}
@@ -336,7 +337,7 @@ export function WorkflowPipelineEditor({
) : (
pipelineArea
)}
<ScrollBar orientation="horizontal" />
<ScrollBar orientation="horizontal" forceMount className="mt-1" />
</ScrollArea>
{selectedStep && (
<StepConfigPanel
+20 -2
View File
@@ -7,12 +7,14 @@ import {
IconMessageForward,
IconRefresh,
IconRobot,
IconUserCog,
} from "@tabler/icons-react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip";
import type { KanbanStepEvents } from "@/lib/state/slices/kanban/types";
type StepCapabilityIconsProps = {
events?: KanbanStepEvents;
agentProfileId?: string;
className?: string;
fallback?: React.ReactNode;
};
@@ -65,16 +67,32 @@ const CAPABILITIES: CapabilityDef[] = [
},
];
export function StepCapabilityIcons({ events, className, fallback }: StepCapabilityIconsProps) {
export function StepCapabilityIcons({
events,
agentProfileId,
className,
fallback,
}: StepCapabilityIconsProps) {
const defaultClassName = "flex items-center gap-1.5 text-muted-foreground";
const activeCapabilities = events ? CAPABILITIES.filter((cap) => cap.check(events)) : [];
const hasAgentProfile = Boolean(agentProfileId);
if (activeCapabilities.length === 0) {
if (activeCapabilities.length === 0 && !hasAgentProfile) {
return fallback ? <div className={className ?? defaultClassName}>{fallback}</div> : null;
}
return (
<div className={className ?? defaultClassName}>
{hasAgentProfile && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<IconUserCog className="h-3.5 w-3.5" />
</TooltipTrigger>
<TooltipContent>Custom agent profile</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{activeCapabilities.map((cap) => {
const IconComponent = cap.icon;
return (
@@ -20,6 +20,29 @@ import type {
} from "@/components/task-create-dialog-types";
import { autoSelectBranch } from "@/components/task-create-dialog-helpers";
export function useWorkflowAgentProfileEffect(
fs: DialogFormState,
workflows: Array<{ id: string; agent_profile_id?: string }>,
) {
const { selectedWorkflowId, setAgentProfileId, setWorkflowAgentProfileId } = fs;
useEffect(() => {
if (!selectedWorkflowId) {
setWorkflowAgentProfileId("");
return;
}
const workflow = workflows.find((w) => w.id === selectedWorkflowId);
if (workflow?.agent_profile_id) {
setAgentProfileId(workflow.agent_profile_id);
setWorkflowAgentProfileId(workflow.agent_profile_id);
} else {
setWorkflowAgentProfileId("");
// Restore the user's last-used agent profile when unlocking
const lastId = getLocalStorage<string | null>(STORAGE_KEYS.LAST_AGENT_PROFILE_ID, null);
setAgentProfileId(lastId ?? "");
}
}, [selectedWorkflowId, workflows, setAgentProfileId, setWorkflowAgentProfileId]);
}
export function useWorkflowStepsEffect(fs: DialogFormState, workflowId: string | null) {
const { selectedWorkflowId, setFetchedSteps } = fs;
useEffect(() => {
@@ -155,6 +178,7 @@ export function useDefaultSelectionsEffect(
const { agentProfiles, executors, workspaceDefaults } = sel;
const {
agentProfileId,
workflowAgentProfileId,
executorId,
executorProfileId,
setAgentProfileId,
@@ -162,7 +186,7 @@ export function useDefaultSelectionsEffect(
setExecutorProfileId,
} = fs;
useEffect(() => {
if (!open || agentProfileId || agentProfiles.length === 0) return;
if (!open || agentProfileId || workflowAgentProfileId || agentProfiles.length === 0) return;
const lastId = getLocalStorage<string | null>(STORAGE_KEYS.LAST_AGENT_PROFILE_ID, null);
if (lastId && agentProfiles.some((p: AgentProfileOption) => p.id === lastId)) {
void Promise.resolve().then(() => setAgentProfileId(lastId));
@@ -174,7 +198,14 @@ export function useDefaultSelectionsEffect(
return;
}
void Promise.resolve().then(() => setAgentProfileId(agentProfiles[0].id));
}, [open, agentProfileId, agentProfiles, workspaceDefaults, setAgentProfileId]);
}, [
open,
agentProfileId,
workflowAgentProfileId,
agentProfiles,
workspaceDefaults,
setAgentProfileId,
]);
useEffect(() => {
if (!open || executorId || executors.length === 0) return;
@@ -351,8 +382,9 @@ export function useGitHubUrlBranchesEffect(fs: DialogFormState, open: boolean) {
export function useTaskCreateDialogEffects(fs: DialogFormState, args: TaskCreateEffectsArgs) {
const { open, workspaceId, workflowId, repositories, repositoriesLoading, branches } = args;
const { agentProfiles, executors, workspaceDefaults, toast } = args;
const { agentProfiles, executors, workspaceDefaults, toast, workflows } = args;
useWorkflowStepsEffect(fs, workflowId);
useWorkflowAgentProfileEffect(fs, workflows);
useRepositoryAutoSelectEffect(fs, open, workspaceId, repositories);
useDiscoverReposEffect(fs, open, workspaceId, repositoriesLoading, toast);
useBranchAutoSelectEffect(fs, branches);
@@ -5,6 +5,9 @@ import Link from "next/link";
import type { AgentProfileOption } from "@/lib/state/slices";
import type { WorkflowSnapshotData } from "@/lib/state/slices/kanban/types";
import { WorkflowSelectorRow } from "@/components/workflow-selector-row";
import type { DialogFormState } from "@/components/task-create-dialog-types";
import type { useKeyboardShortcutHandler } from "@/hooks/use-keyboard-shortcut";
import { TaskFormInputs } from "@/components/task-create-dialog-selectors";
type SelectorOption = {
value: string;
@@ -61,6 +64,7 @@ type CreateEditSelectorsProps = {
}>;
isLocalExecutor: boolean;
useGitHubUrl: boolean;
workflowAgentProfileId?: string;
};
export const CreateEditSelectors = memo(function CreateEditSelectors({
@@ -83,12 +87,15 @@ export const CreateEditSelectors = memo(function CreateEditSelectors({
executorsLoading,
isLocalExecutor,
useGitHubUrl,
workflowAgentProfileId,
BranchSelectorComponent,
AgentSelectorComponent,
ExecutorProfileSelectorComponent,
}: CreateEditSelectorsProps) {
if (isTaskStarted) return null;
const agentLockedByWorkflow = Boolean(workflowAgentProfileId);
const isLocalWithoutGitHubUrl = isLocalExecutor && !useGitHubUrl;
const branchPlaceholder = (() => {
@@ -133,13 +140,18 @@ export const CreateEditSelectors = memo(function CreateEditSelectors({
</Link>
</div>
) : (
<AgentSelectorComponent
options={agentProfileOptions}
value={agentProfileId}
onValueChange={onAgentProfileChange}
placeholder={agentPlaceholder}
disabled={agentProfilesLoading || isCreatingSession}
/>
<>
<AgentSelectorComponent
options={agentProfileOptions}
value={agentProfileId}
onValueChange={onAgentProfileChange}
placeholder={agentPlaceholder}
disabled={agentProfilesLoading || isCreatingSession || agentLockedByWorkflow}
/>
{agentLockedByWorkflow && (
<p className="text-[11px] text-muted-foreground mt-1">Agent set by workflow</p>
)}
</>
)}
</div>
<div>
@@ -258,3 +270,47 @@ export const WorkflowSection = memo(function WorkflowSection({
/>
);
});
export type DialogPromptSectionProps = {
isSessionMode: boolean;
isTaskStarted: boolean;
isPassthroughProfile: boolean;
initialDescription: string;
hasDescription: boolean;
fs: DialogFormState;
handleKeyDown: ReturnType<typeof useKeyboardShortcutHandler>;
enhance?: { onEnhance: () => void; isLoading: boolean; isConfigured: boolean };
};
export function DialogPromptSection({
isSessionMode,
isTaskStarted,
isPassthroughProfile,
initialDescription,
hasDescription,
fs,
handleKeyDown,
enhance,
}: DialogPromptSectionProps) {
return (
<>
<TaskFormInputs
key={fs.openCycle}
isSessionMode={isSessionMode}
autoFocus={!isTaskStarted}
initialDescription={initialDescription}
onDescriptionChange={fs.setHasDescription}
onKeyDown={handleKeyDown}
descriptionValueRef={fs.descriptionInputRef}
disabled={isTaskStarted || isPassthroughProfile}
placeholder={isPassthroughProfile ? "Passthrough mode — prompt not supported" : undefined}
onEnhancePrompt={enhance?.onEnhance}
isEnhancingPrompt={enhance?.isLoading}
isUtilityConfigured={enhance?.isConfigured}
/>
{isPassthroughProfile && hasDescription && (
<p className="text-xs text-amber-500">Prompt ignored — passthrough mode active</p>
)}
</>
);
}
@@ -217,6 +217,11 @@ function useDraftPersistence(
return { clearDraft };
}
function useWorkflowAgentProfileState() {
const [workflowAgentProfileId, setWorkflowAgentProfileId] = useState("");
return { workflowAgentProfileId, setWorkflowAgentProfileId };
}
function useGitHubUrlState() {
const [useGitHubUrl, setUseGitHubUrl] = useState(false);
const [githubUrl, setGitHubUrl] = useState("");
@@ -345,6 +350,7 @@ export function useDialogFormState(
const form = useFormStateValues(workflowId, workspaceId, open, initialValues);
const discovery = useDiscoveryState();
const ghUrl = useGitHubUrlState();
const wfAgent = useWorkflowAgentProfileState();
useFormResetEffects({
open,
@@ -387,7 +393,7 @@ export function useDialogFormState(
form.descriptionInputRef,
);
return { ...form, ...discovery, ...ghUrl, clearDraft };
return { ...form, ...discovery, ...ghUrl, ...wfAgent, clearDraft };
}
export type { DialogFormState } from "@/components/task-create-dialog-types";
@@ -79,6 +79,7 @@ export type TaskCreateEffectsArgs = {
executors: Executor[];
workspaceDefaults: Workspace | null | undefined;
toast: ReturnType<typeof useToast>["toast"];
workflows: Array<{ id: string; agent_profile_id?: string }>;
};
import type { FileAttachment } from "@/components/task/chat/file-attachment";
@@ -147,6 +148,9 @@ export type DialogFormState = {
setGitHubUrlError: (v: string | null) => void;
githubPrHeadBranch: string | null;
setGitHubPrHeadBranch: (v: string | null) => void;
/** When non-empty, the selected workflow overrides the agent profile */
workflowAgentProfileId: string;
setWorkflowAgentProfileId: (v: string) => void;
/** Clear draft on successful submission (before closing dialog) */
clearDraft: () => void;
};
+13 -15
View File
@@ -13,6 +13,7 @@ import {
CreateEditSelectors,
SessionSelectors,
WorkflowSection,
DialogPromptSection,
} from "@/components/task-create-dialog-form-body";
import {
useRepositoryOptions,
@@ -25,7 +26,6 @@ import {
AgentSelector,
ExecutorProfileSelector,
InlineTaskName,
TaskFormInputs,
} from "@/components/task-create-dialog-selectors";
import { useTaskSubmitHandlers } from "@/components/task-create-dialog-submit";
import { useToast } from "@/components/toast-provider";
@@ -263,6 +263,7 @@ type DialogFormBodyProps = {
hasRepositorySelection: boolean;
isLocalExecutor: boolean;
enhance?: { onEnhance: () => void; isLoading: boolean; isConfigured: boolean };
workflowAgentProfileId?: string;
};
function DialogFormBody({
@@ -292,26 +293,20 @@ function DialogFormBody({
hasRepositorySelection,
isLocalExecutor,
enhance,
workflowAgentProfileId,
}: DialogFormBodyProps) {
return (
<div className="flex-1 space-y-4 overflow-y-auto pr-1">
<TaskFormInputs
key={fs.openCycle}
<DialogPromptSection
isSessionMode={isSessionMode}
autoFocus={isTaskStarted ? false : true}
isTaskStarted={isTaskStarted}
isPassthroughProfile={isPassthroughProfile}
initialDescription={initialDescription}
onDescriptionChange={fs.setHasDescription}
onKeyDown={handleKeyDown}
descriptionValueRef={fs.descriptionInputRef}
disabled={isTaskStarted || isPassthroughProfile}
placeholder={isPassthroughProfile ? "Passthrough mode — prompt not supported" : undefined}
onEnhancePrompt={enhance?.onEnhance}
isEnhancingPrompt={enhance?.isLoading}
isUtilityConfigured={enhance?.isConfigured}
hasDescription={hasDescription}
fs={fs}
handleKeyDown={handleKeyDown}
enhance={enhance}
/>
{isPassthroughProfile && hasDescription && (
<p className="text-xs text-amber-500">Prompt ignored — passthrough mode active</p>
)}
{!isSessionMode && (
<CreateEditSelectors
isTaskStarted={isTaskStarted}
@@ -333,6 +328,7 @@ function DialogFormBody({
executorsLoading={executorsLoading}
isLocalExecutor={isLocalExecutor}
useGitHubUrl={fs.useGitHubUrl}
workflowAgentProfileId={workflowAgentProfileId}
BranchSelectorComponent={BranchSelector}
AgentSelectorComponent={AgentSelector}
ExecutorProfileSelectorComponent={ExecutorProfileSelector}
@@ -415,6 +411,7 @@ function useTaskCreateDialogSetup(props: TaskCreateDialogProps) {
executors,
workspaceDefaults: computed.workspaceDefaults,
toast,
workflows,
});
const handlers = useDialogHandlers(fs, repositories);
const submitHandlers = useTaskSubmitHandlers({
@@ -543,6 +540,7 @@ export function TaskCreateDialog(props: TaskCreateDialogProps) {
hasRepositorySelection={computed.hasRepositorySelection}
isLocalExecutor={computed.isLocalExecutor}
enhance={setup.enhance}
workflowAgentProfileId={fs.workflowAgentProfileId}
/>
<DialogFooter className="border-t border-border pt-3 flex-col gap-3 sm:flex-row sm:gap-2">
<TaskCreateDialogFooter
@@ -115,6 +115,7 @@ function useWorkflowStepsMapped() {
allow_manual_move: s.allow_manual_move,
prompt: s.prompt,
is_start_step: s.is_start_step,
agent_profile_id: s.agent_profile_id,
})),
[kanbanSteps],
);
@@ -22,6 +22,7 @@ type Step = {
allow_manual_move?: boolean;
prompt?: string;
is_start_step?: boolean;
agent_profile_id?: string;
};
const PLAN_CONTEXT_PATH = "plan:context";
@@ -250,7 +251,7 @@ function StepHoverContent({
</Button>
)}
{isCurrent && <div className="text-[11px] text-muted-foreground">Current step</div>}
<StepCapabilityIcons events={step.events} />
<StepCapabilityIcons events={step.events} agentProfileId={step.agent_profile_id} />
</HoverCardContent>
);
}
+18 -2
View File
@@ -1,7 +1,8 @@
"use client";
import { Fragment, memo, useMemo, useState } from "react";
import { IconCheck, IconChevronDown, IconLogicBuffer } from "@tabler/icons-react";
import { IconCheck, IconChevronDown, IconLogicBuffer, IconUserCog } from "@tabler/icons-react";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip";
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
import { Button } from "@kandev/ui/button";
import type { WorkflowSnapshotData } from "@/lib/state/slices/kanban/types";
@@ -29,7 +30,12 @@ function InlineSteps({ steps }: { steps: StepItem[] }) {
}
type WorkflowSelectorRowProps = {
workflows: Array<{ id: string; name: string; description?: string | null }>;
workflows: Array<{
id: string;
name: string;
description?: string | null;
agent_profile_id?: string;
}>;
snapshots: Record<string, WorkflowSnapshotData>;
selectedWorkflowId: string | null;
onWorkflowChange: (workflowId: string) => void;
@@ -88,6 +94,16 @@ export const WorkflowSelectorRow = memo(function WorkflowSelectorRow({
<div className="flex items-center gap-2">
<IconLogicBuffer className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="text-sm">{wf.name}</span>
{wf.agent_profile_id && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<IconUserCog className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>Custom agent profile</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
{steps.length > 0 && (
<div className="pl-[calc(0.875rem+0.5rem)]">
+9
View File
@@ -425,10 +425,18 @@ export class ApiClient {
});
}
async updateWorkflow(
workflowId: string,
updates: { name?: string; description?: string; agent_profile_id?: string },
): Promise<Workflow> {
return this.request("PATCH", `/api/v1/workflows/${workflowId}`, updates);
}
async updateWorkflowStep(
stepId: string,
updates: {
prompt?: string;
agent_profile_id?: string;
events?: {
on_enter?: Array<{ type: string; config?: Record<string, unknown> }>;
on_turn_start?: Array<{ type: string; config?: Record<string, unknown> }>;
@@ -652,6 +660,7 @@ export class ApiClient {
sessions: Array<{
id: string;
task_id: string;
agent_profile_id?: string;
state: string;
started_at: string;
task_environment_id?: string;
@@ -114,6 +114,16 @@ export class WorkflowSettingsPage {
await expect(this.createDialog).not.toBeVisible();
}
/** The workflow-level agent profile select trigger within a workflow card. */
workflowAgentProfileSelect(card: Locator): Locator {
return card.getByTestId("workflow-agent-profile-select");
}
/** The step agent profile override select trigger in the step config panel within a workflow card. */
stepAgentProfileSelect(card: Locator): Locator {
return card.getByTestId("step-agent-profile-select");
}
/** Hover over a step node to reveal the trash button, then click it. */
async clickDeleteStepButton(card: Locator, stepName: string) {
const node = this.stepNodeByName(card, stepName);
@@ -0,0 +1,146 @@
import { test, expect } from "../../fixtures/test-base";
import { WorkflowSettingsPage } from "../../pages/workflow-settings-page";
import { KanbanPage } from "../../pages/kanban-page";
test.describe("Workflow agent profile", () => {
test("set workflow-level agent profile in settings and persist after save", async ({
testPage,
seedData,
apiClient,
}) => {
const page = new WorkflowSettingsPage(testPage);
await page.goto(seedData.workspaceId);
// Find the seeded "E2E Workflow" card
const card = await page.findWorkflowCard("E2E Workflow");
await expect(card).toBeVisible();
// The "Default Agent Profile" select should initially show "None (use task default)"
const profileSelect = page.workflowAgentProfileSelect(card);
await expect(profileSelect).toBeVisible();
// Get the available agent profiles from the API so we know the label to select
const { agents } = await apiClient.listAgents();
const agentProfile = agents.flatMap((a) => a.profiles ?? [])[0];
expect(agentProfile).toBeDefined();
const profileLabel = `${agentProfile.agent_display_name} \u2022 ${agentProfile.name}`;
// Open the select and pick the agent profile
await profileSelect.click();
await testPage.getByRole("option", { name: profileLabel }).click();
// Save the workflow
await page.saveButton(card).click();
await testPage.waitForTimeout(1000);
// Reload and verify the selection persists
await page.goto(seedData.workspaceId);
const reloadedCard = await page.findWorkflowCard("E2E Workflow");
await expect(reloadedCard).toBeVisible();
const reloadedSelect = page.workflowAgentProfileSelect(reloadedCard);
await expect(reloadedSelect).toContainText(profileLabel);
});
test("set per-step agent profile override in settings", async ({
testPage,
seedData,
apiClient,
}) => {
const page = new WorkflowSettingsPage(testPage);
await page.goto(seedData.workspaceId);
// Find the seeded workflow card
const card = await page.findWorkflowCard("E2E Workflow");
await expect(card).toBeVisible();
// Click on the first step to open the config panel
const firstStepName = seedData.steps[0]?.name;
expect(firstStepName).toBeDefined();
const stepNode = page.stepNodeByName(card, firstStepName!);
await stepNode.click();
// The step config panel should be visible with the "Agent Profile Override" select
const stepProfileSelect = page.stepAgentProfileSelect(card);
await expect(stepProfileSelect).toBeVisible();
// Get agent profile info
const { agents } = await apiClient.listAgents();
const agentProfile = agents.flatMap((a) => a.profiles ?? [])[0];
expect(agentProfile).toBeDefined();
const profileLabel = `${agentProfile.agent_display_name} \u2022 ${agentProfile.name}`;
// Select an agent profile for this step
await stepProfileSelect.click();
await testPage.getByRole("option", { name: profileLabel }).click();
// Wait for debounced update to propagate
await testPage.waitForTimeout(600);
// Save the workflow
await page.saveButton(card).click();
await testPage.waitForTimeout(1000);
// Reload and verify - the step should now show the agent profile icon (IconUserCog)
await page.goto(seedData.workspaceId);
const reloadedCard = await page.findWorkflowCard("E2E Workflow");
await expect(reloadedCard).toBeVisible();
// The step node should have a UserCog icon indicating a custom agent profile
const reloadedStepNode = page.stepNodeByName(reloadedCard, firstStepName!);
await expect(reloadedStepNode.locator(".tabler-icon-user-cog")).toBeVisible();
});
test("task creation dialog locks agent selector when workflow has agent profile", async ({
testPage,
seedData,
apiClient,
}) => {
// Set an agent profile on the seeded workflow via API
const { agents } = await apiClient.listAgents();
const agentProfile = agents.flatMap((a) => a.profiles ?? [])[0];
expect(agentProfile).toBeDefined();
await apiClient.updateWorkflow(seedData.workflowId, {
agent_profile_id: agentProfile.id,
});
let noProfileWorkflowId: string | undefined;
try {
// Create a second workflow without an agent profile for comparison
const noProfileWorkflow = await apiClient.createWorkflow(
seedData.workspaceId,
"No Profile Workflow",
"simple",
);
noProfileWorkflowId = noProfileWorkflow.id;
// Open the kanban page — reload to pick up the updated workflow agent_profile_id
const kanban = new KanbanPage(testPage);
await kanban.goto();
await testPage.reload();
await kanban.createTaskButton.first().click();
const dialog = testPage.getByTestId("create-task-dialog");
await expect(dialog).toBeVisible();
// Fill title so the selectors become visible
await testPage.getByTestId("task-title-input").fill("Agent Lock Test");
await testPage.getByTestId("task-description-input").fill("testing agent lock");
// The seeded workflow should be selected by default (from user settings).
// The agent selector should be disabled because the workflow has an agent profile.
const agentSelector = testPage.getByTestId("agent-profile-selector");
await expect(agentSelector).toBeVisible({ timeout: 15_000 });
// "Agent set by workflow" text confirms the selector is locked — sufficient verification
await expect(testPage.getByText("Agent set by workflow")).toBeVisible({ timeout: 10_000 });
} finally {
// Always clean up, even if assertions fail
if (noProfileWorkflowId) {
await apiClient.deleteWorkflow(noProfileWorkflowId).catch(() => {});
}
await apiClient.updateWorkflow(seedData.workflowId, {
agent_profile_id: "",
});
}
});
});
@@ -0,0 +1,188 @@
import { test, expect } from "../../fixtures/test-base";
import { WorkflowSettingsPage } from "../../pages/workflow-settings-page";
async function createProfiles(
apiClient: InstanceType<typeof import("../../helpers/api-client").ApiClient>,
) {
const { agents } = await apiClient.listAgents();
if (agents.length === 0) throw new Error("no agents available in test fixtures");
const agentId = agents[0].id;
const profileA = await apiClient.createAgentProfile(agentId, "Profile A (fast)", {
model: "mock-fast",
});
const profileB = await apiClient.createAgentProfile(agentId, "Profile B (slow)", {
model: "mock-slow",
});
return { agentId, profileA, profileB };
}
async function pollSessions(
apiClient: InstanceType<typeof import("../../helpers/api-client").ApiClient>,
taskId: string,
expectedCount: number,
timeoutMs = 30_000,
) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const { sessions } = await apiClient.listTaskSessions(taskId);
if (sessions.length >= expectedCount) return sessions;
await new Promise((r) => setTimeout(r, 500));
}
const { sessions } = await apiClient.listTaskSessions(taskId);
return sessions;
}
test.describe("Workflow agent profile switching", () => {
test("manual step move creates new session with step's agent profile", async ({
apiClient,
seedData,
}) => {
test.setTimeout(60_000);
const { profileA, profileB } = await createProfiles(apiClient);
// Create workflow: Inbox → Step1 (profileA, auto_start) → Step2 (profileB, auto_start) → Done
const workflow = await apiClient.createWorkflow(seedData.workspaceId, "Agent Switch Manual");
const inbox = await apiClient.createWorkflowStep(workflow.id, "Inbox", 0);
const step1 = await apiClient.createWorkflowStep(workflow.id, "Step1", 1);
const step2 = await apiClient.createWorkflowStep(workflow.id, "Step2", 2);
await apiClient.createWorkflowStep(workflow.id, "Done", 3);
await apiClient.updateWorkflowStep(step1.id, {
agent_profile_id: profileA.id,
events: { on_enter: [{ type: "auto_start_agent" }] },
});
await apiClient.updateWorkflowStep(step2.id, {
agent_profile_id: profileB.id,
events: { on_enter: [{ type: "auto_start_agent" }] },
});
// Create task in Inbox (no auto_start)
const task = await apiClient.createTask(seedData.workspaceId, "Manual Switch Task", {
workflow_id: workflow.id,
workflow_step_id: inbox.id,
agent_profile_id: profileA.id,
repository_ids: [seedData.repositoryId],
});
// Move to Step1 — triggers auto_start with profileA
await apiClient.moveTask(task.id, workflow.id, step1.id);
// Wait for first session
const initialSessions = await pollSessions(apiClient, task.id, 1);
expect(initialSessions.length).toBeGreaterThanOrEqual(1);
expect(initialSessions[0].agent_profile_id).toBe(profileA.id);
// Wait for agent to be ready before moving
await new Promise((r) => setTimeout(r, 3000));
// Move task to Step2 — should create new session with profileB
await apiClient.moveTask(task.id, workflow.id, step2.id);
// Poll for second session
const finalSessions = await pollSessions(apiClient, task.id, 2);
expect(finalSessions.length).toBeGreaterThanOrEqual(2);
// Sort by started_at to get chronological order
finalSessions.sort((a, b) => a.started_at.localeCompare(b.started_at));
// First session should be profileA (completed), second should be profileB
expect(finalSessions[0].agent_profile_id).toBe(profileA.id);
expect(finalSessions[1].agent_profile_id).toBe(profileB.id);
});
test("on_turn_complete transition creates new session with next step's agent profile", async ({
apiClient,
seedData,
}) => {
test.setTimeout(60_000);
const { profileA, profileB } = await createProfiles(apiClient);
// Create workflow: Inbox → Step1 (profileA, auto_start, move_to_next) → Step2 (profileB, auto_start) → Done
const workflow = await apiClient.createWorkflow(seedData.workspaceId, "Agent Switch Auto");
const inbox = await apiClient.createWorkflowStep(workflow.id, "Inbox", 0);
const step1 = await apiClient.createWorkflowStep(workflow.id, "Step1", 1);
const step2 = await apiClient.createWorkflowStep(workflow.id, "Step2", 2);
await apiClient.createWorkflowStep(workflow.id, "Done", 3);
await apiClient.updateWorkflowStep(step1.id, {
agent_profile_id: profileA.id,
prompt: 'e2e:delay(1000)\ne2e:message("step1 done")',
events: {
on_enter: [{ type: "auto_start_agent" }],
on_turn_complete: [{ type: "move_to_next" }],
},
});
await apiClient.updateWorkflowStep(step2.id, {
agent_profile_id: profileB.id,
events: { on_enter: [{ type: "auto_start_agent" }] },
});
// Create task in Inbox
const task = await apiClient.createTask(seedData.workspaceId, "Auto Switch Task", {
workflow_id: workflow.id,
workflow_step_id: inbox.id,
agent_profile_id: profileA.id,
repository_ids: [seedData.repositoryId],
});
// Move to Step1 — triggers auto_start with profileA, then on_turn_complete → Step2
await apiClient.moveTask(task.id, workflow.id, step1.id);
// Poll for second session (Step2 with profileB)
const finalSessions = await pollSessions(apiClient, task.id, 2, 45_000);
expect(finalSessions.length).toBeGreaterThanOrEqual(2);
finalSessions.sort((a, b) => a.started_at.localeCompare(b.started_at));
expect(finalSessions[0].agent_profile_id).toBe(profileA.id);
expect(finalSessions[1].agent_profile_id).toBe(profileB.id);
});
test("reset context checkbox is disabled when step has agent profile override", async ({
testPage,
apiClient,
seedData,
}) => {
const { profileA } = await createProfiles(apiClient);
const stepId = seedData.steps[0].id;
try {
// Ensure clean state
await apiClient.updateWorkflowStep(stepId, { agent_profile_id: "" });
const page = new WorkflowSettingsPage(testPage);
await page.goto(seedData.workspaceId);
const card = await page.findWorkflowCard("E2E Workflow");
await expect(card).toBeVisible();
// Click first step to open config panel
const stepNodes = card.locator(".group.relative");
await stepNodes.first().click();
await testPage.waitForTimeout(500);
// Reset context checkbox should be enabled (no agent profile set)
const resetCheckbox = card.getByRole("checkbox", { name: "Reset agent context" });
await expect(resetCheckbox).toBeEnabled();
// Set an agent profile on this step via API
await apiClient.updateWorkflowStep(stepId, { agent_profile_id: profileA.id });
// Reload and re-open the step
await page.goto(seedData.workspaceId);
const reloadedCard = await page.findWorkflowCard("E2E Workflow");
const reloadedSteps = reloadedCard.locator(".group.relative");
await reloadedSteps.first().click();
await testPage.waitForTimeout(500);
// Reset context checkbox should be disabled
const reloadedCheckbox = reloadedCard.getByRole("checkbox", {
name: "Reset agent context",
});
await expect(reloadedCheckbox).toBeDisabled();
} finally {
// Always clean up the seeded step to avoid leaking into other tests
await apiClient.updateWorkflowStep(stepId, { agent_profile_id: "" });
}
});
});
@@ -0,0 +1,110 @@
import { test, expect } from "../../fixtures/test-base";
import { WorkflowSettingsPage } from "../../pages/workflow-settings-page";
test.describe("Workflow step prompt autocomplete", () => {
test("shows autocomplete suggestions when typing {{ in step prompt editor", async ({
testPage,
seedData,
}) => {
const page = new WorkflowSettingsPage(testPage);
await page.goto(seedData.workspaceId);
const card = await page.findWorkflowCard("E2E Workflow");
await expect(card).toBeVisible();
// Click first step to open config panel
const stepNodes = card.locator(".group.relative");
await stepNodes.first().click();
await testPage.waitForTimeout(500);
// Wait for the ScriptEditor (Monaco) to mount inside the step config panel
const monacoEditor = card.locator(".monaco-editor");
await expect(monacoEditor).toBeVisible({ timeout: 10_000 });
// Click into the editor to focus it
await monacoEditor.click();
await testPage.waitForTimeout(300);
// Type {{ to trigger autocomplete
await testPage.keyboard.type("{{");
await testPage.waitForTimeout(500);
// The Monaco suggest widget should appear with {{task_prompt}}
const suggestWidget = testPage.locator(".monaco-editor .suggest-widget");
await expect(suggestWidget).toBeVisible({ timeout: 5_000 });
// Should contain task_prompt suggestion
const suggestion = suggestWidget.locator(".monaco-list-row").first();
await expect(suggestion).toBeVisible();
await expect(suggestion).toContainText("task_prompt");
});
test("persists step agent profile selection after change", async ({
testPage,
seedData,
apiClient,
}) => {
const page = new WorkflowSettingsPage(testPage);
await page.goto(seedData.workspaceId);
const card = await page.findWorkflowCard("E2E Workflow");
await expect(card).toBeVisible();
// Click first step to open config panel
const stepNodes = card.locator(".group.relative");
await stepNodes.first().click();
await testPage.waitForTimeout(500);
// Find the step agent profile select
const agentSelect = card.getByTestId("step-agent-profile-select");
await expect(agentSelect).toBeVisible();
// Get current value
const initialText = await agentSelect.textContent();
expect(initialText).toContain("No profile override");
// Click to open the dropdown
await agentSelect.click();
await testPage.waitForTimeout(300);
// Select the first non-"none" option (skip "No profile override")
const options = testPage.getByRole("option");
const optionCount = await options.count();
// Need at least 2 options (none + at least one profile)
if (optionCount < 2) {
test.skip(true, "No agent profiles available to test with");
return;
}
const profileOption = options.nth(1);
const profileName = await profileOption.textContent();
await profileOption.click();
await testPage.waitForTimeout(1000);
// The select should now show the selected profile, not revert to "No profile override"
const updatedText = await agentSelect.textContent();
expect(updatedText).toContain(profileName?.trim() ?? "");
expect(updatedText).not.toContain("No profile override");
// Reload the page and verify it persisted
await page.goto(seedData.workspaceId);
const reloadedCard = await page.findWorkflowCard("E2E Workflow");
await expect(reloadedCard).toBeVisible();
// Click the same step again
const reloadedSteps = reloadedCard.locator(".group.relative");
await reloadedSteps.first().click();
await testPage.waitForTimeout(500);
const reloadedSelect = reloadedCard.getByTestId("step-agent-profile-select");
await expect(reloadedSelect).toBeVisible();
const persistedText = await reloadedSelect.textContent();
expect(persistedText).toContain(profileName?.trim() ?? "");
// Clean up: reset the step agent profile
const stepId = seedData.steps[0]?.id;
if (stepId) {
await apiClient.updateWorkflowStep(stepId, { agent_profile_id: "" });
}
});
});
@@ -0,0 +1,20 @@
"use client";
import { useAppStore } from "@/components/state-provider";
import type { AgentProfileOption } from "@/lib/state/slices";
/**
* Returns agent profiles that are healthy (no capability issues). If `selectedId`
* is provided and the selected profile is unhealthy, it is still included so the
* user can see what's currently set instead of seeing a blank select.
*/
export function useHealthyAgentProfiles(selectedId?: string): AgentProfileOption[] {
const agentProfiles = useAppStore((s) => s.agentProfiles.items);
return agentProfiles.filter(
(p) =>
!p.capability_status ||
p.capability_status === "ok" ||
p.capability_status === "probing" ||
p.id === selectedId,
);
}
@@ -102,7 +102,11 @@ export function useWorkflowSettings(initialWorkflows: Workflow[]) {
const isWorkflowDirty = (workflow: Workflow) => {
const saved = savedWorkflowsById.get(workflow.id);
if (!saved) return true;
return workflow.name !== saved.name || workflow.description !== saved.description;
return (
workflow.name !== saved.name ||
workflow.description !== saved.description ||
(workflow.agent_profile_id ?? "") !== (saved.agent_profile_id ?? "")
);
};
return {
+1
View File
@@ -16,6 +16,7 @@ export function useWorkflows(workspaceId: string | null, enabled = true) {
name: workflow.name,
description: workflow.description,
sortOrder: workflow.sort_order ?? 0,
agent_profile_id: workflow.agent_profile_id,
}));
setWorkflows(mapped);
})
+1
View File
@@ -53,6 +53,7 @@ export function snapshotToState(snapshot: WorkflowSnapshot): Partial<AppState> {
prompt: step.prompt,
is_start_step: step.is_start_step,
show_in_command_panel: step.show_in_command_panel,
agent_profile_id: step.agent_profile_id,
})),
tasks,
},
@@ -19,6 +19,7 @@ export type KanbanState = {
prompt?: string;
is_start_step?: boolean;
show_in_command_panel?: boolean;
agent_profile_id?: string;
}>;
tasks: Array<{
id: string;
@@ -62,6 +63,7 @@ export type WorkflowsState = {
name: string;
description?: string | null;
sortOrder?: number;
agent_profile_id?: string;
}>;
activeId: string | null;
};
+2
View File
@@ -179,6 +179,7 @@ export type WorkflowPayload = {
workspace_id: string;
name: string;
description?: string;
agent_profile_id?: string;
created_at?: string;
updated_at?: string;
};
@@ -196,6 +197,7 @@ export type StepPayload = {
allow_manual_move?: boolean;
show_in_command_panel?: boolean;
auto_archive_after_hours?: number;
agent_profile_id?: string;
created_at?: string;
updated_at?: string;
};
+4
View File
@@ -76,6 +76,7 @@ export type StepDefinition = {
events?: StepEvents;
is_start_step?: boolean;
show_in_command_panel?: boolean;
agent_profile_id?: string;
};
// Workflow Step - instance of a step on a workflow
@@ -91,6 +92,7 @@ export type WorkflowStep = {
is_start_step?: boolean;
show_in_command_panel?: boolean;
auto_archive_after_hours?: number;
agent_profile_id?: string;
created_at: string;
updated_at: string;
};
@@ -139,6 +141,7 @@ export type Workflow = {
name: string;
description?: string | null;
workflow_template_id?: string | null;
agent_profile_id?: string;
sort_order?: number;
created_at: string;
updated_at: string;
@@ -266,6 +269,7 @@ export type WorkflowStepDTO = {
is_start_step?: boolean;
show_in_command_panel?: boolean;
auto_archive_after_hours?: number;
agent_profile_id?: string;
created_at?: string;
updated_at?: string;
};
+1
View File
@@ -18,6 +18,7 @@ export function registerKanbanHandlers(store: StoreApi<AppState>): WsHandlers {
position: step.position ?? index,
events: step.events,
show_in_command_panel: step.show_in_command_panel,
agent_profile_id: step.agent_profile_id,
}));
const tasks: KanbanTask[] = message.payload.tasks
// Filter out ephemeral tasks (e.g., quick chat)
+8 -1
View File
@@ -14,6 +14,7 @@ function stepFromPayload(step: any) {
allow_manual_move: step.allow_manual_move,
prompt: step.prompt,
is_start_step: step.is_start_step,
agent_profile_id: step.agent_profile_id,
};
}
@@ -50,7 +51,13 @@ export function registerWorkflowsHandlers(store: StoreApi<AppState>): WsHandlers
workflows: {
...state.workflows,
items: state.workflows.items.map((item) =>
item.id === message.payload.id ? { ...item, name: message.payload.name } : item,
item.id === message.payload.id
? {
...item,
name: message.payload.name,
agent_profile_id: message.payload.agent_profile_id,
}
: item,
),
},
}));