feat: associate agent profiles with workflows and steps (#597)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)]">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user