feat: add task-specific workflow changes (#3895)
This commit is contained in:
@@ -16,7 +16,7 @@ import (
|
||||
const (
|
||||
spriteUploadTimeout = 10 * time.Minute // bundles can be large
|
||||
spriteStepTimeout = 2 * time.Minute
|
||||
spriteControlRetries = 3
|
||||
spriteControlRetries = 5
|
||||
spriteUploadRetries = 3
|
||||
spriteBackoffInit = 700 * time.Millisecond
|
||||
spriteRetryMaxDelay = 30 * time.Second
|
||||
|
||||
@@ -55,6 +55,33 @@ func TestGetOrCreateSpriteRetriesTransientGet(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOrCreateSpriteRetriesBeyondThreeTransientErrors(t *testing.T) {
|
||||
getCalls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
getCalls++
|
||||
if getCalls <= 3 {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"name":"kandev-pr-3895","status":"created"}`))
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
client := sprites.New("token", sprites.WithBaseURL(server.URL))
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
|
||||
sprite, err := getOrCreateSprite(t.Context(), client, "kandev-pr-3895")
|
||||
if err != nil {
|
||||
t.Fatalf("getOrCreateSprite() error after transient outage = %v", err)
|
||||
}
|
||||
if sprite.Name() != "kandev-pr-3895" {
|
||||
t.Errorf("sprite.Name() = %q", sprite.Name())
|
||||
}
|
||||
if getCalls != 4 {
|
||||
t.Errorf("get calls = %d, want 4 after three transient failures", getCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOrCreateSpriteReconcilesTransientCreateFailure(t *testing.T) {
|
||||
getCalls := 0
|
||||
createCalls := 0
|
||||
|
||||
@@ -53,7 +53,10 @@ func TestStatsRepositoryAndDailyAggregatesAvoidTurnsMessagesProduct(t *testing.T
|
||||
t.Fatalf("NewWithDB failed: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
// Keep every seeded turn inside the current UTC day so the daily query's
|
||||
// date range includes the complete fixture even when the test runs near UTC
|
||||
// midnight.
|
||||
now := time.Now().UTC().Truncate(24 * time.Hour).Add(12 * time.Hour)
|
||||
execOrFatal(t, dbConn, `INSERT INTO workspaces (id, name, created_at, updated_at) VALUES ('ws-heavy', 'Heavy', ?, ?)`, now, now)
|
||||
execOrFatal(t, dbConn, `INSERT INTO repositories (id, workspace_id, name, created_at, updated_at) VALUES ('repo-heavy', 'ws-heavy', 'Heavy', ?, ?)`, now, now)
|
||||
execOrFatal(t, dbConn, `INSERT INTO tasks (id, workspace_id, board_id, title, is_ephemeral, created_at, updated_at) VALUES ('task-heavy', 'ws-heavy', 'board-heavy', 'Heavy', 0, ?, ?)`, now, now)
|
||||
|
||||
@@ -76,6 +76,7 @@ type lspLease struct {
|
||||
detachedAt time.Time
|
||||
expiryTimer *time.Timer
|
||||
closed bool
|
||||
expectedUpstreamClose bool
|
||||
readDone chan struct{}
|
||||
ready bool
|
||||
readyStatus map[string]any
|
||||
@@ -615,6 +616,12 @@ func (l *lspLease) readUpstream() {
|
||||
for {
|
||||
messageType, message, err := l.upstream.ReadMessage()
|
||||
if err != nil {
|
||||
l.mu.Lock()
|
||||
expectedClose := l.expectedUpstreamClose
|
||||
l.mu.Unlock()
|
||||
if expectedClose {
|
||||
return
|
||||
}
|
||||
code, text, reason := classifyLSPUpstreamClose(err)
|
||||
l.terminate(code, text, reason)
|
||||
return
|
||||
|
||||
@@ -31,6 +31,9 @@ func (l *lspLease) gracefulRelease(generation uint64, reason, requestID string)
|
||||
if _, err := l.sendBrokerRequest(ctx, "shutdown", nil); err != nil {
|
||||
l.manager.logger.Debug("LSP shutdown request did not complete before release", zap.String("language", l.language), zap.Error(err))
|
||||
}
|
||||
l.mu.Lock()
|
||||
l.expectedUpstreamClose = true
|
||||
l.mu.Unlock()
|
||||
_ = l.writeUpstream(jsonRPCNotification("exit", nil))
|
||||
if err := l.writeBrowser(generation, map[string]any{
|
||||
lspControlField: lspControlKind,
|
||||
|
||||
@@ -648,9 +648,16 @@ func TestLSPContinuityReconnectsToSameTaskHostStream(t *testing.T) {
|
||||
logger: testLogger(),
|
||||
}
|
||||
var fenceHeld atomic.Bool
|
||||
fenceReleased := make(chan struct{}, 1)
|
||||
handler.EnableContinuity(func(string) func() {
|
||||
fenceHeld.Store(true)
|
||||
return func() { fenceHeld.Store(false) }
|
||||
return func() {
|
||||
fenceHeld.Store(false)
|
||||
select {
|
||||
case fenceReleased <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}, nil)
|
||||
t.Cleanup(func() { _ = handler.Close() })
|
||||
|
||||
@@ -660,6 +667,11 @@ func TestLSPContinuityReconnectsToSameTaskHostStream(t *testing.T) {
|
||||
if leaseID == "" || ready["resumed"] != false {
|
||||
t.Fatalf("first ready status = %v, want an initial lease ID and resumed=false", ready)
|
||||
}
|
||||
select {
|
||||
case <-fenceReleased:
|
||||
case <-time.After(wsTestTimeout):
|
||||
t.Fatal("session lifecycle fence was not released after lease admission")
|
||||
}
|
||||
if fenceHeld.Load() {
|
||||
t.Fatal("session lifecycle fence remained held after lease admission")
|
||||
}
|
||||
|
||||
@@ -4135,35 +4135,69 @@ func (s *Service) preflightWorkflowStepCredentials(
|
||||
taskID string,
|
||||
currentSession *models.TaskSession,
|
||||
targetStep *wfmodels.WorkflowStep,
|
||||
) error {
|
||||
return s.preflightWorkflowStepCredentialsWithCandidate(ctx, taskID, nil, currentSession, targetStep)
|
||||
}
|
||||
|
||||
func (s *Service) preflightWorkflowStepCredentialsWithCandidate(
|
||||
ctx context.Context,
|
||||
taskID string,
|
||||
candidate *models.Task,
|
||||
currentSession *models.TaskSession,
|
||||
targetStep *wfmodels.WorkflowStep,
|
||||
) error {
|
||||
if currentSession == nil || targetStep == nil {
|
||||
return nil
|
||||
}
|
||||
ctx = withWorkflowMetaCache(ctx)
|
||||
if targetStep.SessionTarget != nil {
|
||||
resolution, err := s.resolveWorkflowSessionTarget(ctx, taskID, targetStep)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
targetSession := currentSession
|
||||
if s.resolveStepProfileSessionStartPolicy(targetStep) == models.WorkflowProfileSessionStartPolicyReuse && resolution.session != nil {
|
||||
targetSession = resolution.session
|
||||
}
|
||||
task, err := s.repo.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get task for explicit credential preflight: %w", err)
|
||||
}
|
||||
if task == nil {
|
||||
return fmt.Errorf("task %s not found for explicit credential preflight", taskID)
|
||||
}
|
||||
return s.executor.PreflightManagedGitCredentials(
|
||||
ctx, task.WorkspaceID, taskID, targetSession.ExecutorID, targetSession.ExecutorProfileID,
|
||||
)
|
||||
return s.preflightExplicitWorkflowStepCredentials(ctx, taskID, candidate, currentSession, targetStep)
|
||||
}
|
||||
effectiveProfile, err := s.resolveStepAgentProfileForTaskID(ctx, taskID, targetStep)
|
||||
effectiveProfile, err := s.resolveWorkflowMovePreflightProfile(ctx, taskID, candidate, targetStep)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.preflightFixedWorkflowStepCredentials(ctx, taskID, candidate, currentSession, targetStep, effectiveProfile)
|
||||
}
|
||||
|
||||
func (s *Service) preflightExplicitWorkflowStepCredentials(
|
||||
ctx context.Context,
|
||||
taskID string,
|
||||
candidate *models.Task,
|
||||
currentSession *models.TaskSession,
|
||||
targetStep *wfmodels.WorkflowStep,
|
||||
) error {
|
||||
var resolution workflowSessionTargetResolution
|
||||
var err error
|
||||
if candidate != nil {
|
||||
resolution, err = s.resolveWorkflowSessionTargetWithTask(ctx, taskID, targetStep, candidate)
|
||||
} else {
|
||||
resolution, err = s.resolveWorkflowSessionTarget(ctx, taskID, targetStep)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
targetSession := currentSession
|
||||
if s.resolveStepProfileSessionStartPolicy(targetStep) == models.WorkflowProfileSessionStartPolicyReuse && resolution.session != nil {
|
||||
targetSession = resolution.session
|
||||
}
|
||||
task, err := s.preflightTaskProjection(ctx, taskID, candidate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get task for explicit credential preflight: %w", err)
|
||||
}
|
||||
return s.executor.PreflightManagedGitCredentials(
|
||||
ctx, task.WorkspaceID, taskID, targetSession.ExecutorID, targetSession.ExecutorProfileID,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Service) preflightFixedWorkflowStepCredentials(
|
||||
ctx context.Context,
|
||||
taskID string,
|
||||
candidate *models.Task,
|
||||
currentSession *models.TaskSession,
|
||||
targetStep *wfmodels.WorkflowStep,
|
||||
effectiveProfile string,
|
||||
) error {
|
||||
startPolicy := s.resolveStepProfileSessionStartPolicy(targetStep)
|
||||
if shouldKeepCurrentWorkflowStepSession(effectiveProfile, currentSession.AgentProfileID, startPolicy) {
|
||||
if effectiveProfile == "" || startPolicy != models.WorkflowProfileSessionStartPolicyReuse {
|
||||
@@ -4187,18 +4221,41 @@ func (s *Service) preflightWorkflowStepCredentials(
|
||||
targetSession = existing
|
||||
}
|
||||
}
|
||||
task, err := s.repo.GetTask(ctx, taskID)
|
||||
task, err := s.preflightTaskProjection(ctx, taskID, candidate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get task for credential preflight: %w", err)
|
||||
}
|
||||
if task == nil {
|
||||
return fmt.Errorf("task %s not found for credential preflight", taskID)
|
||||
}
|
||||
return s.executor.PreflightManagedGitCredentials(
|
||||
ctx, task.WorkspaceID, taskID, targetSession.ExecutorID, targetSession.ExecutorProfileID,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Service) preflightTaskProjection(ctx context.Context, taskID string, candidate *models.Task) (*models.Task, error) {
|
||||
if candidate != nil {
|
||||
return candidate, nil
|
||||
}
|
||||
task, err := s.repo.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if task == nil {
|
||||
return nil, fmt.Errorf("task %s not found for credential preflight", taskID)
|
||||
}
|
||||
return task, nil
|
||||
}
|
||||
|
||||
func (s *Service) resolveWorkflowMovePreflightProfile(
|
||||
ctx context.Context,
|
||||
taskID string,
|
||||
candidate *models.Task,
|
||||
targetStep *wfmodels.WorkflowStep,
|
||||
) (string, error) {
|
||||
if candidate != nil {
|
||||
return s.resolveStepAgentProfileForTask(ctx, candidate, targetStep), nil
|
||||
}
|
||||
return s.resolveStepAgentProfileForTaskID(ctx, taskID, targetStep)
|
||||
}
|
||||
|
||||
// PreflightWorkflowStepMove exposes the destination lifecycle preflight to
|
||||
// the task service, which must run it before committing a manual move.
|
||||
func (s *Service) PreflightWorkflowStepMove(
|
||||
@@ -4210,6 +4267,20 @@ func (s *Service) PreflightWorkflowStepMove(
|
||||
return s.preflightWorkflowStepCredentials(ctx, taskID, currentSession, targetStep)
|
||||
}
|
||||
|
||||
// PreflightWorkflowStepChange validates credentials against a candidate task
|
||||
// carrying the form's normalized destination profile choices.
|
||||
func (s *Service) PreflightWorkflowStepChange(
|
||||
ctx context.Context,
|
||||
candidate *models.Task,
|
||||
currentSession *models.TaskSession,
|
||||
targetStep *wfmodels.WorkflowStep,
|
||||
) error {
|
||||
if candidate == nil {
|
||||
return fmt.Errorf("workflow change candidate is required")
|
||||
}
|
||||
return s.preflightWorkflowStepCredentialsWithCandidate(ctx, candidate.ID, candidate, currentSession, targetStep)
|
||||
}
|
||||
|
||||
// maybySwitchSessionForProfile preserves the legacy processOnEnter failure
|
||||
// handling while sharing the error-returning step-entry preflight with direct
|
||||
// workflow-engine dispatch.
|
||||
|
||||
@@ -224,7 +224,8 @@ var (
|
||||
// concurrent terminal session transition won the persistence race. Callers
|
||||
// must not start the process and must arbitrate exact-execution teardown
|
||||
// ownership before deciding whether to force-stop the registered runtime.
|
||||
ErrSessionStateSuperseded = errors.New("session state superseded by terminal transition")
|
||||
ErrSessionStateSuperseded = errors.New("session state superseded by terminal transition")
|
||||
errSessionAdvancedToRunning = errors.New("session state advanced to RUNNING before runtime persistence")
|
||||
// ErrOrphanRecoveryIncomplete means StopByTaskID stopped every session it
|
||||
// found but could not load at least one registry-only orphan's row, so the
|
||||
// task-scoped stop is not fully confirmed. Callers that already observed a
|
||||
|
||||
@@ -849,6 +849,15 @@ func (e *Executor) persistSessionFullRowIfCurrentState(
|
||||
if isStopTerminalSessionState(current.State) {
|
||||
return &SessionStateSupersededError{SessionID: session.ID, State: current.State}
|
||||
}
|
||||
if expected == models.TaskSessionStateStarting && current.State == models.TaskSessionStateRunning {
|
||||
return fmt.Errorf(
|
||||
"%w: session %s state changed from %s to %s before runtime persistence",
|
||||
errSessionAdvancedToRunning,
|
||||
session.ID,
|
||||
expected,
|
||||
current.State,
|
||||
)
|
||||
}
|
||||
return fmt.Errorf(
|
||||
"session %s state changed from %s to %s before runtime persistence",
|
||||
session.ID,
|
||||
@@ -1753,10 +1762,6 @@ func (e *Executor) LaunchPreparedSession(ctx context.Context, task *v1.Task, ses
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if startAgent {
|
||||
e.observeSessionCoresidency(launchCtx, sessionCoresidencySiteLaunch, task.ID, sessionID)
|
||||
}
|
||||
|
||||
// Fast path: workspace already launched (executors_running row exists).
|
||||
// The selected-environment recovery gate above must run first, including
|
||||
// when this path only starts an agent process on an existing workspace.
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPersistLaunchStateKeepsRunningSessionAndPrepareResult(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newMockRepository()
|
||||
repo.sessions["session-1"] = &models.TaskSession{
|
||||
ID: "session-1",
|
||||
TaskID: "task-1",
|
||||
State: models.TaskSessionStateRunning,
|
||||
Metadata: map[string]interface{}{"existing": "preserved"},
|
||||
}
|
||||
exec := newTestExecutor(t, &mockAgentManager{}, repo)
|
||||
launchSession := &models.TaskSession{
|
||||
ID: "session-1",
|
||||
TaskID: "task-1",
|
||||
State: models.TaskSessionStateStarting,
|
||||
}
|
||||
prepareResult := &lifecycle.EnvPrepareResult{Success: true, WorkspacePath: "/workspace/task-1"}
|
||||
|
||||
err := exec.persistLaunchState(
|
||||
ctx,
|
||||
"task-1",
|
||||
"session-1",
|
||||
launchSession,
|
||||
&LaunchAgentResponse{PrepareResult: prepareResult},
|
||||
true,
|
||||
time.Now(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
updated, err := repo.GetTaskSession(ctx, "session-1")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, models.TaskSessionStateRunning, updated.State)
|
||||
require.Equal(t, "preserved", updated.Metadata["existing"])
|
||||
prepareMetadata, ok := updated.Metadata["prepare_result"].(map[string]interface{})
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "completed", prepareMetadata["status"])
|
||||
}
|
||||
@@ -985,6 +985,17 @@ func (e *Executor) persistLaunchState(ctx context.Context, taskID, sessionID str
|
||||
updateErr = e.persistSessionFullRowIfCurrentState(ctx, session, expectedState)
|
||||
}
|
||||
if updateErr != nil {
|
||||
if startAgent && errors.Is(updateErr, errSessionAdvancedToRunning) {
|
||||
if resp.PrepareResult == nil || !resp.PrepareResult.Success {
|
||||
return nil
|
||||
}
|
||||
return e.repo.SetSessionMetadataKey(
|
||||
ctx,
|
||||
sessionID,
|
||||
"prepare_result",
|
||||
buildPrepareResultMetadata(resp.PrepareResult),
|
||||
)
|
||||
}
|
||||
e.logger.Error("failed to update agent session after launch",
|
||||
zap.String("task_id", taskID),
|
||||
zap.String("session_id", sessionID),
|
||||
|
||||
@@ -149,10 +149,9 @@ func TestObserveSessionCoresidency_SiblingReadFailureRecordsSkipNotAbsence(t *te
|
||||
}
|
||||
|
||||
// TestLaunchPreparedSession_ObservesWorkingSiblingOnAgentStart pins the
|
||||
// wiring half of AC-004.1: LaunchPreparedSession calls the observation
|
||||
// before the agent process starts, for a real launch that otherwise
|
||||
// succeeds, not just the extracted helper. Ordering is enforced by
|
||||
// production code structure, not a synchronized assertion here.
|
||||
// LaunchPreparedSession wiring through the shared process-start hook. It waits
|
||||
// until StartAgentProcess is called, after the observation has run, and checks
|
||||
// that this launch recorded exactly one local warning.
|
||||
func TestLaunchPreparedSession_ObservesWorkingSiblingOnAgentStart(t *testing.T) {
|
||||
repo := newMockRepository()
|
||||
repo.sessions["session-123"] = &models.TaskSession{
|
||||
@@ -170,6 +169,7 @@ func TestLaunchPreparedSession_ObservesWorkingSiblingOnAgentStart(t *testing.T)
|
||||
if err != nil {
|
||||
t.Fatalf("NewFromZap: %v", err)
|
||||
}
|
||||
startCalled := make(chan struct{}, 1)
|
||||
agentManager := &mockAgentManager{
|
||||
launchAgentFunc: func(ctx context.Context, req *LaunchAgentRequest) (*LaunchAgentResponse, error) {
|
||||
return &LaunchAgentResponse{
|
||||
@@ -177,6 +177,10 @@ func TestLaunchPreparedSession_ObservesWorkingSiblingOnAgentStart(t *testing.T)
|
||||
ContainerID: "container-123",
|
||||
}, nil
|
||||
},
|
||||
startAgentProcessFunc: func(context.Context, string) error {
|
||||
startCalled <- struct{}{}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
executor := NewExecutor(agentManager, repo, log, ExecutorConfig{ShellPrefs: &mockShellPrefs{}})
|
||||
executor.SetCapabilities(&mockCapabilities{})
|
||||
@@ -196,13 +200,24 @@ func TestLaunchPreparedSession_ObservesWorkingSiblingOnAgentStart(t *testing.T)
|
||||
}); err != nil {
|
||||
t.Fatalf("LaunchPreparedSession failed: %v", err)
|
||||
}
|
||||
|
||||
if after := counterValue(sessionCoresidencyAdmittedTotalVar, sessionCoresidencySiteLaunch); after != before+1 {
|
||||
t.Fatalf("admitted[launch] counter = %d, want %d", after, before+1)
|
||||
select {
|
||||
case <-startCalled:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for StartAgentProcess")
|
||||
}
|
||||
|
||||
if after := counterValue(sessionCoresidencyAdmittedTotalVar, sessionCoresidencySiteLaunch); after < before+1 {
|
||||
t.Fatalf("admitted[launch] counter = %d, want at least %d", after, before+1)
|
||||
}
|
||||
const observationMessage = "starting an agent while another session of this task is already working in the shared worktree"
|
||||
var warnings []observer.LoggedEntry
|
||||
for _, entry := range logs.All() {
|
||||
if strings.HasPrefix(entry.Message, observationMessage) {
|
||||
warnings = append(warnings, entry)
|
||||
}
|
||||
}
|
||||
warnings := logs.FilterLevelExact(zapcore.WarnLevel).All()
|
||||
if len(warnings) != 1 {
|
||||
t.Fatalf("warning entries = %d, want 1; all=%v", len(warnings), logs.All())
|
||||
t.Fatalf("co-residency warning entries = %d, want 1; all=%v", len(warnings), logs.All())
|
||||
}
|
||||
fields := warnings[0].ContextMap()
|
||||
if fields["site"] != sessionCoresidencySiteLaunch {
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"github.com/kandev/kandev/internal/agent/agents"
|
||||
"github.com/kandev/kandev/internal/orchestrator/executor"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
"github.com/kandev/kandev/internal/task/repository/repoerrors"
|
||||
taskservice "github.com/kandev/kandev/internal/task/service"
|
||||
wfmodels "github.com/kandev/kandev/internal/workflow/models"
|
||||
workflowmove "github.com/kandev/kandev/internal/workflow/move"
|
||||
)
|
||||
@@ -19,10 +21,12 @@ import (
|
||||
// source session field: the server resolves the current session exactly as
|
||||
// the move path does.
|
||||
type WorkflowMovePreviewRequest struct {
|
||||
TaskID string
|
||||
WorkflowID string
|
||||
WorkflowStepID string
|
||||
EntryOptions *workflowmove.EntryOptions
|
||||
TaskID string
|
||||
WorkflowID string
|
||||
WorkflowStepID string
|
||||
EntryOptions *workflowmove.EntryOptions
|
||||
WorkflowChange *models.WorkflowChangeRequest
|
||||
CandidateWorkflowOverrides *models.WorkflowAgentOverrides
|
||||
}
|
||||
|
||||
type WorkflowMovePreviewOutcome string
|
||||
@@ -177,6 +181,16 @@ func (s *Service) PreviewWorkflowMove(ctx context.Context, request WorkflowMoveP
|
||||
if task.ArchivedAt != nil {
|
||||
return nil, fmt.Errorf("archived tasks cannot be moved")
|
||||
}
|
||||
if request.WorkflowChange != nil {
|
||||
if task.WorkflowID != request.WorkflowChange.ExpectedWorkflowID ||
|
||||
task.WorkflowStepID != request.WorkflowChange.ExpectedStepID ||
|
||||
!task.UpdatedAt.Equal(request.WorkflowChange.ExpectedUpdatedAt) {
|
||||
return nil, repoerrors.ErrWorkflowChangeConflict
|
||||
}
|
||||
if request.CandidateWorkflowOverrides != nil && request.CandidateWorkflowOverrides.WorkflowID != request.WorkflowID {
|
||||
return nil, &taskservice.WorkflowChangeValidationError{Code: taskservice.WorkflowChangeErrorInvalid}
|
||||
}
|
||||
}
|
||||
|
||||
destination, err := s.workflowStepGetter.GetStep(ctx, request.WorkflowStepID)
|
||||
if err != nil {
|
||||
@@ -236,20 +250,28 @@ func (s *Service) resolveWorkflowMovePreviewInput(
|
||||
input.OriginalSession = previewOriginalTaskSessionFromTask(task, sessions)
|
||||
}
|
||||
|
||||
s.resolveWorkflowMovePreviewRecipient(ctx, request.TaskID, task, destination, &input)
|
||||
profileTask := task
|
||||
if request.WorkflowChange != nil {
|
||||
candidate := *task
|
||||
candidate.WorkflowID = request.WorkflowID
|
||||
candidate.WorkflowStepID = request.WorkflowStepID
|
||||
candidate.WorkflowAgentOverrides = request.CandidateWorkflowOverrides
|
||||
profileTask = &candidate
|
||||
}
|
||||
s.resolveWorkflowMovePreviewRecipient(ctx, request.TaskID, profileTask, destination, &input)
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func (s *Service) resolveWorkflowMovePreviewRecipient(
|
||||
ctx context.Context,
|
||||
taskID string,
|
||||
task *models.Task,
|
||||
profileTask *models.Task,
|
||||
destination *wfmodels.WorkflowStep,
|
||||
input *workflowMovePreviewInput,
|
||||
) {
|
||||
if destination.SessionTarget != nil {
|
||||
input.ExplicitTarget = true
|
||||
target, profileID, err := s.resolvePreviewWorkflowSessionTarget(ctx, taskID, task, destination)
|
||||
target, profileID, err := s.resolvePreviewWorkflowSessionTarget(ctx, taskID, profileTask, destination)
|
||||
if err != nil {
|
||||
input.Notices = append(input.Notices, workflowMovePreviewNotice("target_unavailable", nil))
|
||||
} else {
|
||||
@@ -257,7 +279,7 @@ func (s *Service) resolveWorkflowMovePreviewRecipient(
|
||||
input.TargetProfileID = profileID
|
||||
}
|
||||
} else {
|
||||
profileID, err := s.previewStepAgentProfile(ctx, destination, task, input.SourceSession == nil)
|
||||
profileID, err := s.previewStepAgentProfile(ctx, destination, profileTask, input.SourceSession == nil)
|
||||
input.TargetProfileID = profileID
|
||||
if err != nil {
|
||||
input.Notices = append(input.Notices, workflowMovePreviewNotice("profile_unavailable", nil))
|
||||
@@ -333,11 +355,13 @@ func (s *Service) resolvePreviewWorkflowSessionTarget(
|
||||
if err := validateWorkflowSessionTargetSource(step, sourceStep); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
session, err := s.resolveBoundSourceWorkflowSession(ctx, taskID, step.WorkflowID, sourceStep)
|
||||
profileID, err := s.previewStepAgentProfile(ctx, sourceStep, task, true)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
profileID, err := s.previewStepAgentProfile(ctx, sourceStep, task, true)
|
||||
session, err := s.resolveBoundSourceWorkflowSessionWithProfile(
|
||||
ctx, taskID, step.WorkflowID, sourceStep, profileID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/kandev/kandev/internal/orchestrator/executor"
|
||||
"github.com/kandev/kandev/internal/orchestrator/watcher"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
taskservice "github.com/kandev/kandev/internal/task/service"
|
||||
wfmodels "github.com/kandev/kandev/internal/workflow/models"
|
||||
workflowmove "github.com/kandev/kandev/internal/workflow/move"
|
||||
v1 "github.com/kandev/kandev/pkg/api/v1"
|
||||
@@ -33,6 +34,96 @@ func TestPreviewStepAgentProfileUsesTaskWorkflowOverride(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewWorkflowMoveUsesDraftOverrideWithoutWriting(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := setupTestRepo(t)
|
||||
now := time.Now().UTC()
|
||||
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "workspace-preview-change", Name: "Preview change", CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatalf("CreateWorkspace: %v", err)
|
||||
}
|
||||
for _, workflow := range []*models.Workflow{
|
||||
{ID: "workflow-preview-source", WorkspaceID: "workspace-preview-change", Name: "Source"},
|
||||
{ID: "workflow-preview-target", WorkspaceID: "workspace-preview-change", Name: "Target"},
|
||||
} {
|
||||
if err := repo.CreateWorkflow(ctx, workflow); err != nil {
|
||||
t.Fatalf("CreateWorkflow(%s): %v", workflow.ID, err)
|
||||
}
|
||||
}
|
||||
if err := repo.CreateTask(ctx, &models.Task{
|
||||
ID: "task-preview-change", WorkspaceID: "workspace-preview-change",
|
||||
WorkflowID: "workflow-preview-source", WorkflowStepID: "preview-source-step",
|
||||
Title: "Preview task", State: v1.TaskStateCreated,
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateTask: %v", err)
|
||||
}
|
||||
task, err := repo.GetTask(ctx, "task-preview-change")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTask: %v", err)
|
||||
}
|
||||
steps := newMockStepGetter()
|
||||
steps.steps["preview-source-step"] = &wfmodels.WorkflowStep{ID: "preview-source-step", WorkflowID: task.WorkflowID}
|
||||
steps.steps["preview-target-step"] = &wfmodels.WorkflowStep{
|
||||
ID: "preview-target-step", WorkflowID: "workflow-preview-target", AgentProfileID: "workflow-default-profile",
|
||||
Events: wfmodels.StepEvents{OnEnter: []wfmodels.OnEnterAction{{Type: wfmodels.OnEnterAutoStartAgent}}},
|
||||
}
|
||||
svc := createTestServiceWithAgent(repo, steps, newMockTaskRepo(), &mockAgentManager{
|
||||
resolveProfileInfo: &executor.AgentProfileInfo{ProfileID: "replacement-profile", ProfileName: "Replacement", Model: "model-b"},
|
||||
})
|
||||
draftOverrides, err := models.NewWorkflowAgentOverrides("workflow-preview-target", []models.WorkflowAgentOverrideBinding{{
|
||||
StepID: "preview-target-step", SourceProfileID: "workflow-default-profile", ReplacementProfileID: "replacement-profile",
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("NewWorkflowAgentOverrides: %v", err)
|
||||
}
|
||||
preview, err := svc.PreviewWorkflowMove(ctx, WorkflowMovePreviewRequest{
|
||||
TaskID: "task-preview-change", WorkflowID: "workflow-preview-target", WorkflowStepID: "preview-target-step",
|
||||
WorkflowChange: &models.WorkflowChangeRequest{
|
||||
ExpectedWorkflowID: task.WorkflowID, ExpectedStepID: task.WorkflowStepID, ExpectedUpdatedAt: task.UpdatedAt,
|
||||
AgentOverrides: map[string]string{"workflow-default-profile": "replacement-profile"},
|
||||
},
|
||||
CandidateWorkflowOverrides: draftOverrides,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PreviewWorkflowMove: %v", err)
|
||||
}
|
||||
if preview.Recipient == nil || preview.Recipient.ProfileID != "replacement-profile" || preview.Recipient.ProfileName != "Replacement" {
|
||||
t.Fatalf("draft preview recipient = %#v, want replacement profile", preview.Recipient)
|
||||
}
|
||||
stored, err := repo.GetTask(ctx, task.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTask after preview: %v", err)
|
||||
}
|
||||
if stored.WorkflowID != task.WorkflowID || stored.WorkflowStepID != task.WorkflowStepID || stored.WorkflowAgentOverrides != nil {
|
||||
t.Fatalf("preview persisted draft state: %+v", stored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewWorkflowMoveRejectsCandidateOverridesForAnotherWorkflowAsValidation(t *testing.T) {
|
||||
fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyReuse, models.WorkflowProfileSessionEndPolicyPark)
|
||||
task, err := fixture.repo.GetTask(context.Background(), "t1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTask: %v", err)
|
||||
}
|
||||
wrongWorkflowOverrides, err := models.NewWorkflowAgentOverrides("other-workflow", []models.WorkflowAgentOverrideBinding{{
|
||||
StepID: "some-step", SourceProfileID: "profile-a", ReplacementProfileID: "profile-b",
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("NewWorkflowAgentOverrides: %v", err)
|
||||
}
|
||||
_, err = fixture.svc.PreviewWorkflowMove(context.Background(), WorkflowMovePreviewRequest{
|
||||
TaskID: "t1", WorkflowID: "wf1", WorkflowStepID: "step-a",
|
||||
WorkflowChange: &models.WorkflowChangeRequest{
|
||||
ExpectedWorkflowID: task.WorkflowID, ExpectedStepID: task.WorkflowStepID,
|
||||
ExpectedUpdatedAt: task.UpdatedAt, AgentOverrides: map[string]string{},
|
||||
},
|
||||
CandidateWorkflowOverrides: wrongWorkflowOverrides,
|
||||
})
|
||||
var validationErr *taskservice.WorkflowChangeValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Code != taskservice.WorkflowChangeErrorInvalid {
|
||||
t.Fatalf("PreviewWorkflowMove error = %#v, want invalid workflow-change validation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewWorkflowSessionTargetUsesTaskWorkflowOverride(t *testing.T) {
|
||||
fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyReuse, models.WorkflowProfileSessionEndPolicyPark)
|
||||
overrides, err := models.NewWorkflowAgentOverrides("wf1", []models.WorkflowAgentOverrideBinding{
|
||||
@@ -69,6 +160,133 @@ func TestPreviewWorkflowSessionTargetUsesTaskWorkflowOverride(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewWorkflowMoveUsesDraftProfileForExplicitStepTarget(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
bindingProfileID string
|
||||
wantSessionID string
|
||||
wantPreviewOutcome WorkflowMovePreviewOutcome
|
||||
}{
|
||||
{name: "no existing binding", wantPreviewOutcome: WorkflowMovePreviewOutcomeCreateNew},
|
||||
{name: "candidate profile binding", bindingProfileID: "profile-b", wantSessionID: "session-b", wantPreviewOutcome: WorkflowMovePreviewOutcomeReuseOther},
|
||||
{name: "stale source profile binding", bindingProfileID: "profile-a", wantPreviewOutcome: WorkflowMovePreviewOutcomeCreateNew},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyReuse, models.WorkflowProfileSessionEndPolicyPark)
|
||||
now := time.Now().UTC()
|
||||
if err := fixture.repo.CreateWorkflow(ctx, &models.Workflow{
|
||||
ID: "wf-destination", WorkspaceID: "ws1", Name: "Destination", CreatedAt: now, UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateWorkflow: %v", err)
|
||||
}
|
||||
task, err := fixture.repo.GetTask(ctx, "t1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTask: %v", err)
|
||||
}
|
||||
draftOverrides, err := models.NewWorkflowAgentOverrides("wf-destination", []models.WorkflowAgentOverrideBinding{{
|
||||
StepID: "implement", SourceProfileID: "profile-a", ReplacementProfileID: "profile-b",
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("NewWorkflowAgentOverrides: %v", err)
|
||||
}
|
||||
source := &wfmodels.WorkflowStep{
|
||||
ID: "implement", WorkflowID: "wf-destination", Position: 0, AgentProfileID: "profile-a",
|
||||
}
|
||||
destination := &wfmodels.WorkflowStep{
|
||||
ID: "pr", WorkflowID: "wf-destination", Position: 1,
|
||||
ProfileSessionStartPolicy: models.WorkflowProfileSessionStartPolicyReuse,
|
||||
SessionTarget: &wfmodels.WorkflowSessionTarget{Kind: wfmodels.WorkflowSessionTargetStep, StepID: source.ID},
|
||||
}
|
||||
fixture.stepGetter.steps[source.ID] = source
|
||||
fixture.stepGetter.steps[destination.ID] = destination
|
||||
fixture.agentMgr.resolveProfileInfo = &executor.AgentProfileInfo{
|
||||
ProfileID: "profile-b", ProfileName: "Agent B", Model: "model-b",
|
||||
}
|
||||
|
||||
if tc.bindingProfileID != "" {
|
||||
session := fixture.current
|
||||
if tc.bindingProfileID == "profile-b" {
|
||||
session = &models.TaskSession{
|
||||
ID: "session-b", TaskID: task.ID, AgentProfileID: "profile-b",
|
||||
State: models.TaskSessionStateWaitingForInput,
|
||||
AgentProfileSnapshot: map[string]interface{}{"name": "Agent B", "model": "model-b"},
|
||||
}
|
||||
if err := fixture.repo.CreateTaskSession(ctx, session); err != nil {
|
||||
t.Fatalf("CreateTaskSession: %v", err)
|
||||
}
|
||||
}
|
||||
if _, err := fixture.repo.UpsertWorkflowSessionBinding(ctx, &models.WorkflowSessionBinding{
|
||||
TaskID: task.ID, TargetKey: workflowSessionBindingTargetKey(source.ID),
|
||||
WorkflowID: "wf-destination", AgentProfileID: tc.bindingProfileID,
|
||||
SessionID: session.ID, OperationID: "preview-test-binding", UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpsertWorkflowSessionBinding: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
preview, err := fixture.svc.PreviewWorkflowMove(ctx, WorkflowMovePreviewRequest{
|
||||
TaskID: task.ID, WorkflowID: "wf-destination", WorkflowStepID: destination.ID,
|
||||
WorkflowChange: &models.WorkflowChangeRequest{
|
||||
ExpectedWorkflowID: task.WorkflowID, ExpectedStepID: task.WorkflowStepID,
|
||||
ExpectedUpdatedAt: task.UpdatedAt,
|
||||
AgentOverrides: map[string]string{"profile-a": "profile-b"},
|
||||
},
|
||||
CandidateWorkflowOverrides: draftOverrides,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PreviewWorkflowMove: %v", err)
|
||||
}
|
||||
storedBeforeCommit, err := fixture.repo.GetTask(ctx, task.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTask after read-only preview: %v", err)
|
||||
}
|
||||
if storedBeforeCommit.WorkflowID != task.WorkflowID || storedBeforeCommit.WorkflowAgentOverrides != nil {
|
||||
t.Fatalf("preview persisted candidate state: %+v", storedBeforeCommit)
|
||||
}
|
||||
|
||||
committedTask := *task
|
||||
committedTask.WorkflowID = "wf-destination"
|
||||
committedTask.WorkflowStepID = destination.ID
|
||||
committedTask.WorkflowAgentOverrides = draftOverrides
|
||||
if err := fixture.repo.UpdateTask(ctx, &committedTask); err != nil {
|
||||
t.Fatalf("UpdateTask to committed candidate: %v", err)
|
||||
}
|
||||
routed, err := fixture.svc.resolveWorkflowSessionTarget(ctx, task.ID, destination)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve committed workflow target: %v", err)
|
||||
}
|
||||
|
||||
if preview.Outcome != tc.wantPreviewOutcome {
|
||||
t.Errorf("preview outcome = %q, want %q", preview.Outcome, tc.wantPreviewOutcome)
|
||||
}
|
||||
if preview.Recipient == nil || preview.Recipient.ProfileID != routed.profileID {
|
||||
t.Errorf("preview recipient = %#v, committed profile = %q", preview.Recipient, routed.profileID)
|
||||
}
|
||||
if preview.Model.After.ID != "model-b" {
|
||||
t.Errorf("preview destination model = %q, want model-b", preview.Model.After.ID)
|
||||
}
|
||||
gotSessionID := ""
|
||||
if preview.Recipient != nil {
|
||||
gotSessionID = preview.Recipient.SessionID
|
||||
}
|
||||
if routed.session != nil && routed.session.ID != tc.wantSessionID {
|
||||
t.Errorf("committed target session = %q, want %q", routed.session.ID, tc.wantSessionID)
|
||||
}
|
||||
if gotSessionID != sessionID(routed.session) {
|
||||
t.Errorf("preview target session = %q, committed target session = %q", gotSessionID, sessionID(routed.session))
|
||||
}
|
||||
stored, err := fixture.repo.GetTask(ctx, task.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTask after preview and simulated commit: %v", err)
|
||||
}
|
||||
if stored.WorkflowID != "wf-destination" || stored.WorkflowAgentOverrides.WorkflowID != "wf-destination" {
|
||||
t.Errorf("committed task candidate was not stored as expected: %+v", stored)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildWorkflowMovePreview_SelectsTheSameReusableSessionAsMove(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
current := &models.TaskSession{
|
||||
|
||||
@@ -90,6 +90,18 @@ func (s *Service) resolveWorkflowSessionTarget(
|
||||
ctx context.Context,
|
||||
taskID string,
|
||||
step *wfmodels.WorkflowStep,
|
||||
) (workflowSessionTargetResolution, error) {
|
||||
return s.resolveWorkflowSessionTargetWithTask(ctx, taskID, step, nil)
|
||||
}
|
||||
|
||||
// resolveWorkflowSessionTargetWithTask resolves the recipient using a task
|
||||
// projection when a move is being validated against an uncommitted profile map.
|
||||
// Initial targets remain tied to the task's immutable initial-session record.
|
||||
func (s *Service) resolveWorkflowSessionTargetWithTask(
|
||||
ctx context.Context,
|
||||
taskID string,
|
||||
step *wfmodels.WorkflowStep,
|
||||
effectiveTask *models.Task,
|
||||
) (workflowSessionTargetResolution, error) {
|
||||
if step == nil || step.SessionTarget == nil {
|
||||
return workflowSessionTargetResolution{}, fmt.Errorf("workflow session target is required")
|
||||
@@ -105,13 +117,14 @@ func (s *Service) resolveWorkflowSessionTarget(
|
||||
return workflowSessionTargetResolution{session: session, profileID: profileID}, nil
|
||||
}
|
||||
|
||||
return s.resolveSourceWorkflowSessionTarget(ctx, taskID, step)
|
||||
return s.resolveSourceWorkflowSessionTargetWithTask(ctx, taskID, step, effectiveTask)
|
||||
}
|
||||
|
||||
func (s *Service) resolveSourceWorkflowSessionTarget(
|
||||
func (s *Service) resolveSourceWorkflowSessionTargetWithTask(
|
||||
ctx context.Context,
|
||||
taskID string,
|
||||
step *wfmodels.WorkflowStep,
|
||||
effectiveTask *models.Task,
|
||||
) (workflowSessionTargetResolution, error) {
|
||||
sourceStep, err := s.loadWorkflowStepForLifecycle(ctx, step.SessionTarget.StepID, "session target source")
|
||||
if err != nil {
|
||||
@@ -120,11 +133,13 @@ func (s *Service) resolveSourceWorkflowSessionTarget(
|
||||
if err := validateWorkflowSessionTargetSource(step, sourceStep); err != nil {
|
||||
return workflowSessionTargetResolution{}, err
|
||||
}
|
||||
session, err := s.resolveBoundSourceWorkflowSession(ctx, taskID, step.WorkflowID, sourceStep)
|
||||
profileID, err := s.resolveWorkflowSessionTargetProfile(ctx, taskID, effectiveTask, sourceStep)
|
||||
if err != nil {
|
||||
return workflowSessionTargetResolution{}, err
|
||||
}
|
||||
profileID, err := s.resolveStepAgentProfileForTaskID(ctx, taskID, sourceStep)
|
||||
session, err := s.resolveBoundSourceWorkflowSessionWithProfile(
|
||||
ctx, taskID, step.WorkflowID, sourceStep, profileID,
|
||||
)
|
||||
if err != nil {
|
||||
return workflowSessionTargetResolution{}, err
|
||||
}
|
||||
@@ -147,11 +162,24 @@ func validateWorkflowSessionTargetSource(destination, source *wfmodels.WorkflowS
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) resolveBoundSourceWorkflowSession(
|
||||
func (s *Service) resolveWorkflowSessionTargetProfile(
|
||||
ctx context.Context,
|
||||
taskID string,
|
||||
effectiveTask *models.Task,
|
||||
sourceStep *wfmodels.WorkflowStep,
|
||||
) (string, error) {
|
||||
if effectiveTask != nil {
|
||||
return s.resolveStepAgentProfileForTask(ctx, effectiveTask, sourceStep), nil
|
||||
}
|
||||
return s.resolveStepAgentProfileForTaskID(ctx, taskID, sourceStep)
|
||||
}
|
||||
|
||||
func (s *Service) resolveBoundSourceWorkflowSessionWithProfile(
|
||||
ctx context.Context,
|
||||
taskID string,
|
||||
workflowID string,
|
||||
sourceStep *wfmodels.WorkflowStep,
|
||||
effectiveProfileID string,
|
||||
) (*models.TaskSession, error) {
|
||||
store, ok := s.repo.(workflowSessionBindingStore)
|
||||
if !ok {
|
||||
@@ -161,10 +189,6 @@ func (s *Service) resolveBoundSourceWorkflowSession(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
effectiveProfileID, err := s.resolveStepAgentProfileForTaskID(ctx, taskID, sourceStep)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if binding == nil || binding.TaskID != taskID || binding.WorkflowID != workflowID ||
|
||||
binding.AgentProfileID != effectiveProfileID || binding.SessionID == "" {
|
||||
return nil, nil
|
||||
@@ -176,6 +200,9 @@ func (s *Service) resolveBoundSourceWorkflowSession(
|
||||
if session == nil || session.TaskID != taskID || session.AgentProfileID != effectiveProfileID {
|
||||
return nil, nil
|
||||
}
|
||||
if isTerminalSessionState(session.State) {
|
||||
return nil, nil
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -7,11 +7,18 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/gitcredentials"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
wfmodels "github.com/kandev/kandev/internal/workflow/models"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type workflowTargetTestCredentialIssuer struct{}
|
||||
|
||||
func (workflowTargetTestCredentialIssuer) Issue(context.Context, gitcredentials.Scope) (gitcredentials.Lease, error) {
|
||||
return gitcredentials.Lease{Token: "opaque-test-lease"}, nil
|
||||
}
|
||||
|
||||
type failWorkflowRouteMetadataRepo struct {
|
||||
repoStore
|
||||
fail bool
|
||||
@@ -124,6 +131,66 @@ func TestSelectExplicitWorkflowStartSessionFallsBackFromTerminalTarget(t *testin
|
||||
require.Equal(t, "profile-a", profileID)
|
||||
}
|
||||
|
||||
func TestCandidatePreflightIgnoresTerminalStepBinding(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyReuse, models.WorkflowProfileSessionEndPolicyPark)
|
||||
task, err := fixture.repo.GetTask(ctx, "t1")
|
||||
require.NoError(t, err)
|
||||
overrides, err := models.NewWorkflowAgentOverrides("wf1", []models.WorkflowAgentOverrideBinding{{
|
||||
StepID: "implement", SourceProfileID: "profile-a", ReplacementProfileID: "profile-b",
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
source := &wfmodels.WorkflowStep{ID: "implement", WorkflowID: "wf1", Position: 0, AgentProfileID: "profile-a"}
|
||||
target := &wfmodels.WorkflowStep{
|
||||
ID: "pr", WorkflowID: "wf1", Position: 1,
|
||||
ProfileSessionStartPolicy: models.WorkflowProfileSessionStartPolicyReuse,
|
||||
SessionTarget: &wfmodels.WorkflowSessionTarget{Kind: wfmodels.WorkflowSessionTargetStep, StepID: source.ID},
|
||||
}
|
||||
fixture.stepGetter.steps[source.ID] = source
|
||||
fixture.stepGetter.steps[target.ID] = target
|
||||
|
||||
require.NoError(t, fixture.repo.CreateExecutor(ctx, &models.Executor{
|
||||
ID: "exec-ssh", Name: "SSH", Type: models.ExecutorTypeSSH,
|
||||
}))
|
||||
require.NoError(t, fixture.repo.CreateExecutorProfile(ctx, &models.ExecutorProfile{
|
||||
ID: "executor-profile-a", ExecutorID: "exec-ssh", Name: "A with GitHub token",
|
||||
Config: map[string]string{"remote_auth_secrets": `{"gh_cli_env":"test-token"}`},
|
||||
}))
|
||||
require.NoError(t, fixture.repo.CreateExecutorProfile(ctx, &models.ExecutorProfile{
|
||||
ID: "executor-profile-b", ExecutorID: "exec-ssh", Name: "B without GitHub token",
|
||||
}))
|
||||
fixture.current.ExecutorID = "exec-ssh"
|
||||
fixture.current.ExecutorProfileID = "executor-profile-a"
|
||||
require.NoError(t, fixture.repo.UpdateTaskSession(ctx, fixture.current))
|
||||
terminal := &models.TaskSession{
|
||||
ID: "terminal-profile-b", TaskID: task.ID, AgentProfileID: "profile-b",
|
||||
ExecutorID: "exec-ssh", ExecutorProfileID: "executor-profile-b",
|
||||
State: models.TaskSessionStateCompleted,
|
||||
}
|
||||
require.NoError(t, fixture.repo.CreateTaskSession(ctx, terminal))
|
||||
_, err = fixture.repo.UpsertWorkflowSessionBinding(ctx, &models.WorkflowSessionBinding{
|
||||
TaskID: task.ID, TargetKey: workflowSessionBindingTargetKey(source.ID),
|
||||
WorkflowID: "wf1", AgentProfileID: "profile-b", SessionID: terminal.ID,
|
||||
OperationID: "candidate-terminal-binding", UpdatedAt: time.Now().UTC(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, fixture.repo.CreateRepository(ctx, &models.Repository{
|
||||
ID: "repo-invalid-github", WorkspaceID: "ws1", Name: "Invalid GitHub remote",
|
||||
SourceType: "local", Provider: "github", RemoteURL: "https://forge.example/acme/repo.git",
|
||||
}))
|
||||
require.NoError(t, fixture.repo.CreateTaskRepository(ctx, &models.TaskRepository{
|
||||
ID: "task-repo-invalid-github", TaskID: task.ID, RepositoryID: "repo-invalid-github",
|
||||
}))
|
||||
fixture.svc.executor.SetGitHubCredentialBroker(
|
||||
workflowTargetTestCredentialIssuer{}, "https://kandev.example/api/v1/github/credentials/resolve",
|
||||
)
|
||||
candidate := *task
|
||||
candidate.WorkflowAgentOverrides = overrides
|
||||
|
||||
require.NoError(t, fixture.svc.PreflightWorkflowStepChange(ctx, &candidate, fixture.current, target),
|
||||
"the terminal binding is not reused; preflight must use the current session's executor profile")
|
||||
}
|
||||
|
||||
func TestRecordWorkflowSourceBindingIgnoresDelayedEntryAfterTaskMoves(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyReuse, models.WorkflowProfileSessionEndPolicyPark)
|
||||
@@ -173,6 +240,73 @@ func TestWorkflowSessionTargetUsesTaskEffectiveSourceProfile(t *testing.T) {
|
||||
require.Equal(t, "profile-b", resolution.profileID)
|
||||
}
|
||||
|
||||
func TestWorkflowChangeCredentialPreflightUsesCandidateBoundSession(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyReuse, models.WorkflowProfileSessionEndPolicyPark)
|
||||
now := time.Now().UTC()
|
||||
require.NoError(t, fixture.repo.CreateWorkflow(ctx, &models.Workflow{
|
||||
ID: "wf-destination", WorkspaceID: "ws1", Name: "Destination", CreatedAt: now, UpdatedAt: now,
|
||||
}))
|
||||
task, err := fixture.repo.GetTask(ctx, "t1")
|
||||
require.NoError(t, err)
|
||||
candidateOverrides, err := models.NewWorkflowAgentOverrides("wf-destination", []models.WorkflowAgentOverrideBinding{{
|
||||
StepID: "implement", SourceProfileID: "profile-a", ReplacementProfileID: "profile-b",
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
source := &wfmodels.WorkflowStep{
|
||||
ID: "implement", WorkflowID: "wf-destination", Position: 0, AgentProfileID: "profile-a",
|
||||
}
|
||||
target := &wfmodels.WorkflowStep{
|
||||
ID: "pr", WorkflowID: "wf-destination", Position: 1,
|
||||
ProfileSessionStartPolicy: models.WorkflowProfileSessionStartPolicyReuse,
|
||||
SessionTarget: &wfmodels.WorkflowSessionTarget{Kind: wfmodels.WorkflowSessionTargetStep, StepID: source.ID},
|
||||
}
|
||||
fixture.stepGetter.steps[source.ID] = source
|
||||
fixture.stepGetter.steps[target.ID] = target
|
||||
|
||||
require.NoError(t, fixture.repo.CreateExecutor(ctx, &models.Executor{
|
||||
ID: "exec-ssh", Name: "SSH", Type: models.ExecutorTypeSSH,
|
||||
}))
|
||||
require.NoError(t, fixture.repo.CreateExecutorProfile(ctx, &models.ExecutorProfile{
|
||||
ID: "executor-profile-a", ExecutorID: "exec-ssh", Name: "A without GitHub token",
|
||||
}))
|
||||
require.NoError(t, fixture.repo.CreateExecutorProfile(ctx, &models.ExecutorProfile{
|
||||
ID: "executor-profile-b", ExecutorID: "exec-ssh", Name: "B with GitHub token",
|
||||
Config: map[string]string{"remote_auth_secrets": `{"gh_cli_env":"test-token"}`},
|
||||
}))
|
||||
fixture.current.ExecutorID = "exec-ssh"
|
||||
fixture.current.ExecutorProfileID = "executor-profile-a"
|
||||
require.NoError(t, fixture.repo.UpdateTaskSession(ctx, fixture.current))
|
||||
bound := &models.TaskSession{
|
||||
ID: "session-profile-b", TaskID: task.ID, AgentProfileID: "profile-b",
|
||||
ExecutorID: "exec-ssh", ExecutorProfileID: "executor-profile-b",
|
||||
State: models.TaskSessionStateWaitingForInput,
|
||||
}
|
||||
require.NoError(t, fixture.repo.CreateTaskSession(ctx, bound))
|
||||
_, err = fixture.repo.UpsertWorkflowSessionBinding(ctx, &models.WorkflowSessionBinding{
|
||||
TaskID: task.ID, TargetKey: workflowSessionBindingTargetKey(source.ID),
|
||||
WorkflowID: "wf-destination", AgentProfileID: "profile-b", SessionID: bound.ID,
|
||||
OperationID: "preflight-candidate-binding", UpdatedAt: now,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, fixture.repo.CreateRepository(ctx, &models.Repository{
|
||||
ID: "repo-invalid-github", WorkspaceID: "ws1", Name: "Invalid GitHub remote",
|
||||
SourceType: "local", Provider: "github", RemoteURL: "https://forge.example/acme/repo.git",
|
||||
}))
|
||||
require.NoError(t, fixture.repo.CreateTaskRepository(ctx, &models.TaskRepository{
|
||||
ID: "task-repo-invalid-github", TaskID: task.ID, RepositoryID: "repo-invalid-github",
|
||||
}))
|
||||
fixture.svc.executor.SetGitHubCredentialBroker(
|
||||
workflowTargetTestCredentialIssuer{}, "https://kandev.example/api/v1/github/credentials/resolve",
|
||||
)
|
||||
candidate := *task
|
||||
candidate.WorkflowID = "wf-destination"
|
||||
candidate.WorkflowAgentOverrides = candidateOverrides
|
||||
|
||||
err = fixture.svc.PreflightWorkflowStepChange(ctx, &candidate, fixture.current, target)
|
||||
require.NoError(t, err, "candidate binding B supplies its own remote Git credential profile")
|
||||
}
|
||||
|
||||
func TestRecordWorkflowSourceBindingDoesNotOverwriteRevisitedSourceEntry(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyReuse, models.WorkflowProfileSessionEndPolicyPark)
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
"github.com/kandev/kandev/internal/task/service"
|
||||
ws "github.com/kandev/kandev/pkg/websocket"
|
||||
)
|
||||
|
||||
func TestHTTPWorkflowChangeRejectsStaleSourceBeforeMoving(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
log := newTestLogger(t)
|
||||
updatedAt := time.Date(2026, time.September, 23, 10, 0, 0, 0, time.UTC)
|
||||
task := &models.Task{
|
||||
ID: "task-workflow-change",
|
||||
WorkspaceID: "workspace-1",
|
||||
WorkflowID: "wf-source",
|
||||
WorkflowStepID: "step-source",
|
||||
UpdatedAt: updatedAt,
|
||||
}
|
||||
repo := &moveTaskConflictRepo{task: task}
|
||||
svc := service.NewService(service.Repos{
|
||||
Workspaces: repo, Tasks: repo, TaskRepos: repo,
|
||||
Workflows: repo, Messages: repo, Turns: repo,
|
||||
Sessions: repo, GitSnapshots: repo, RepoEntities: repo,
|
||||
Executors: repo, Environments: repo, TaskEnvironments: repo,
|
||||
Reviews: repo,
|
||||
}, nil, log, service.RepositoryDiscoveryConfig{})
|
||||
h := &TaskHandlers{service: svc, logger: log}
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Params = gin.Params{{Key: "id", Value: task.ID}}
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/tasks/"+task.ID+"/move", strings.NewReader(`{
|
||||
"workflow_id": "wf-target",
|
||||
"workflow_step_id": "step-target",
|
||||
"workflow_change": {
|
||||
"expected_workflow_id": "wf-stale",
|
||||
"expected_step_id": "step-source",
|
||||
"expected_updated_at": "2026-09-23T10:00:00Z",
|
||||
"agent_overrides": {}
|
||||
}
|
||||
}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.httpMoveTask(c)
|
||||
|
||||
require.Equal(t, http.StatusConflict, rec.Code, "body: %s", rec.Body.String())
|
||||
require.Equal(t, "wf-source", repo.task.WorkflowID)
|
||||
require.Equal(t, "step-source", repo.task.WorkflowStepID)
|
||||
}
|
||||
|
||||
func TestHTTPWorkflowChangeRequiresAnAgentOverridesObject(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
log := newTestLogger(t)
|
||||
updatedAt := time.Date(2026, time.September, 23, 10, 0, 0, 0, time.UTC)
|
||||
task := &models.Task{
|
||||
ID: "task-workflow-change-missing-map",
|
||||
WorkspaceID: "workspace-1",
|
||||
WorkflowID: "wf-source",
|
||||
WorkflowStepID: "step-source",
|
||||
UpdatedAt: updatedAt,
|
||||
}
|
||||
repo := &moveTaskConflictRepo{task: task}
|
||||
svc := service.NewService(service.Repos{
|
||||
Workspaces: repo, Tasks: repo, TaskRepos: repo,
|
||||
Workflows: repo, Messages: repo, Turns: repo,
|
||||
Sessions: repo, GitSnapshots: repo, RepoEntities: repo,
|
||||
Executors: repo, Environments: repo, TaskEnvironments: repo,
|
||||
Reviews: repo,
|
||||
}, nil, log, service.RepositoryDiscoveryConfig{})
|
||||
h := &TaskHandlers{service: svc, logger: log}
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Params = gin.Params{{Key: "id", Value: task.ID}}
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/tasks/"+task.ID+"/move", strings.NewReader(`{
|
||||
"workflow_id": "wf-target",
|
||||
"workflow_step_id": "step-target",
|
||||
"workflow_change": {
|
||||
"expected_workflow_id": "wf-source",
|
||||
"expected_step_id": "step-source",
|
||||
"expected_updated_at": "2026-09-23T10:00:00Z"
|
||||
}
|
||||
}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.httpMoveTask(c)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String())
|
||||
require.Equal(t, "wf-source", repo.task.WorkflowID)
|
||||
require.Equal(t, "step-source", repo.task.WorkflowStepID)
|
||||
}
|
||||
|
||||
func TestWSWorkflowChangeReturnsStableConflictCode(t *testing.T) {
|
||||
log := newTestLogger(t)
|
||||
updatedAt := time.Date(2026, time.September, 23, 10, 0, 0, 0, time.UTC)
|
||||
task := &models.Task{
|
||||
ID: "task-ws-workflow-change", WorkspaceID: "workspace-1",
|
||||
WorkflowID: "wf-source", WorkflowStepID: "step-source", UpdatedAt: updatedAt,
|
||||
}
|
||||
repo := &moveTaskConflictRepo{task: task}
|
||||
svc := service.NewService(service.Repos{
|
||||
Workspaces: repo, Tasks: repo, TaskRepos: repo,
|
||||
Workflows: repo, Messages: repo, Turns: repo,
|
||||
Sessions: repo, GitSnapshots: repo, RepoEntities: repo,
|
||||
Executors: repo, Environments: repo, TaskEnvironments: repo,
|
||||
Reviews: repo,
|
||||
}, nil, log, service.RepositoryDiscoveryConfig{})
|
||||
h := &TaskHandlers{service: svc, logger: log}
|
||||
request, err := ws.NewRequest("move-1", ws.ActionTaskMove, map[string]any{
|
||||
"id": task.ID, "workflow_id": "wf-target", "workflow_step_id": "step-target",
|
||||
"workflow_change": map[string]any{
|
||||
"expected_workflow_id": "wf-outdated", "expected_step_id": "step-source",
|
||||
"expected_updated_at": updatedAt, "agent_overrides": map[string]string{},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
response, err := h.wsMoveTask(context.Background(), request)
|
||||
require.NoError(t, err)
|
||||
payload := wsWorkflowError(t, response)
|
||||
require.Equal(t, string(ws.ErrorCodeConflict), payload.Code)
|
||||
require.Equal(t, "workflow_change_conflict", payload.Message)
|
||||
require.Equal(t, "wf-source", repo.task.WorkflowID)
|
||||
}
|
||||
@@ -26,6 +26,7 @@ const (
|
||||
moveConflictCodeWorkflowStep = "task_move_workflow_step"
|
||||
moveConflictCodeWIPLimit = "task_move_wip_limit"
|
||||
moveConflictCodePending = "task_move_pending"
|
||||
moveConflictCodeWorkflowChange = "workflow_change_conflict"
|
||||
)
|
||||
|
||||
// errorDetailKeyErrorCode is the JSON key every machine-readable error detail
|
||||
@@ -148,6 +149,15 @@ func handleSelectedMoveError(c *gin.Context, log *logger.Logger, err error) {
|
||||
}
|
||||
c.JSON(http.StatusConflict, body)
|
||||
case isValidationError(err):
|
||||
var changeErr *service.WorkflowChangeValidationError
|
||||
if errors.As(err, &changeErr) {
|
||||
body := gin.H{"code": changeErr.Code}
|
||||
if changeErr.SourceProfileID != "" {
|
||||
body["source_profile_id"] = changeErr.SourceProfileID
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, body)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
default:
|
||||
log.Error("task move failed", zap.Error(err))
|
||||
@@ -197,6 +207,8 @@ func isMoveConflict(err error) bool {
|
||||
// error code and the safe error message (which never carries option values).
|
||||
func moveEntryOptionsWSError(err error) (string, string, bool) {
|
||||
switch {
|
||||
case errors.Is(err, service.ErrWorkflowChangeConflict):
|
||||
return ws.ErrorCodeConflict, moveConflictCodeWorkflowChange, true
|
||||
case errors.Is(err, workflowmove.ErrMoveConflict):
|
||||
return ws.ErrorCodeConflict, err.Error(), true
|
||||
case errors.Is(err, workflowmove.ErrConflictingInstructions),
|
||||
@@ -205,6 +217,10 @@ func moveEntryOptionsWSError(err error) (string, string, bool) {
|
||||
errors.Is(err, workflowmove.ErrEntryTargetUnavailable):
|
||||
return ws.ErrorCodeValidation, err.Error(), true
|
||||
default:
|
||||
var changeErr *service.WorkflowChangeValidationError
|
||||
if errors.As(err, &changeErr) {
|
||||
return ws.ErrorCodeValidation, changeErr.Code, true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
}
|
||||
@@ -216,6 +232,9 @@ func moveConflictCode(err error) string {
|
||||
if errors.Is(err, workflowmove.ErrMoveConflict) {
|
||||
return moveConflictCodePending
|
||||
}
|
||||
if errors.Is(err, service.ErrWorkflowChangeConflict) {
|
||||
return moveConflictCodeWorkflowChange
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
switch {
|
||||
case strings.Contains(msg, "active session"):
|
||||
@@ -240,6 +259,9 @@ func isValidationError(err error) bool {
|
||||
if errors.Is(err, service.ErrInvalidParent) || errors.Is(err, service.ErrAutoTitleUnsupportedForOffice) {
|
||||
return true
|
||||
}
|
||||
if errors.Is(err, service.ErrInvalidWorkflowChange) {
|
||||
return true
|
||||
}
|
||||
if errors.Is(err, service.ErrTaskTitleTooLong) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1779,10 +1779,11 @@ func (h *TaskHandlers) httpUpdateTaskRepository(c *gin.Context) {
|
||||
}
|
||||
|
||||
type httpMoveTaskRequest struct {
|
||||
WorkflowID string `json:"workflow_id"`
|
||||
WorkflowStepID string `json:"workflow_step_id"`
|
||||
Position int `json:"position"`
|
||||
EntryOptions *workflowmove.EntryOptions `json:"entry_options,omitempty"`
|
||||
WorkflowID string `json:"workflow_id"`
|
||||
WorkflowStepID string `json:"workflow_step_id"`
|
||||
Position int `json:"position"`
|
||||
EntryOptions *workflowmove.EntryOptions `json:"entry_options,omitempty"`
|
||||
WorkflowChange *models.WorkflowChangeRequest `json:"workflow_change,omitempty"`
|
||||
}
|
||||
|
||||
// httpReorderStepTasksRequest is the frozen reorder request contract
|
||||
@@ -1862,6 +1863,7 @@ func (h *TaskHandlers) httpMoveTask(c *gin.Context) {
|
||||
AllowActivePrimarySession: true,
|
||||
StepHistoryActor: wfmodels.StepTransitionActorHuman,
|
||||
EntryOptions: body.EntryOptions,
|
||||
WorkflowChange: body.WorkflowChange,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/kandev/kandev/internal/orchestrator"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
workflowmove "github.com/kandev/kandev/internal/workflow/move"
|
||||
)
|
||||
|
||||
@@ -58,12 +59,24 @@ func (h *TaskHandlers) httpMoveTaskPreview(c *gin.Context) {
|
||||
handleSelectedMoveError(c, h.logger, err)
|
||||
return
|
||||
}
|
||||
var candidateOverrides *models.WorkflowAgentOverrides
|
||||
if body.WorkflowChange != nil {
|
||||
candidateOverrides, err = h.service.ValidateWorkflowChange(
|
||||
c.Request.Context(), taskID, body.WorkflowID, body.WorkflowStepID, body.WorkflowChange,
|
||||
)
|
||||
if err != nil {
|
||||
handleSelectedMoveError(c, h.logger, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
preview, err := h.movePreviewer.PreviewWorkflowMove(c.Request.Context(), orchestrator.WorkflowMovePreviewRequest{
|
||||
TaskID: taskID,
|
||||
WorkflowID: body.WorkflowID,
|
||||
WorkflowStepID: body.WorkflowStepID,
|
||||
EntryOptions: entryOptions,
|
||||
TaskID: taskID,
|
||||
WorkflowID: body.WorkflowID,
|
||||
WorkflowStepID: body.WorkflowStepID,
|
||||
EntryOptions: entryOptions,
|
||||
WorkflowChange: body.WorkflowChange,
|
||||
CandidateWorkflowOverrides: candidateOverrides,
|
||||
})
|
||||
if err != nil {
|
||||
handleSelectedMoveError(c, h.logger, err)
|
||||
|
||||
@@ -460,11 +460,12 @@ func (h *TaskHandlers) wsArchiveTask(ctx context.Context, msg *ws.Message) (*ws.
|
||||
}
|
||||
|
||||
type wsMoveTaskRequest struct {
|
||||
ID string `json:"id"`
|
||||
WorkflowID string `json:"workflow_id"`
|
||||
WorkflowStepID string `json:"workflow_step_id"`
|
||||
Position int `json:"position"`
|
||||
EntryOptions *workflowmove.EntryOptions `json:"entry_options,omitempty"`
|
||||
ID string `json:"id"`
|
||||
WorkflowID string `json:"workflow_id"`
|
||||
WorkflowStepID string `json:"workflow_step_id"`
|
||||
Position int `json:"position"`
|
||||
EntryOptions *workflowmove.EntryOptions `json:"entry_options,omitempty"`
|
||||
WorkflowChange *models.WorkflowChangeRequest `json:"workflow_change,omitempty"`
|
||||
}
|
||||
|
||||
func (h *TaskHandlers) wsMoveTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
|
||||
@@ -492,6 +493,7 @@ func (h *TaskHandlers) wsMoveTask(ctx context.Context, msg *ws.Message) (*ws.Mes
|
||||
AllowActivePrimarySession: true,
|
||||
StepHistoryActor: wfmodels.StepTransitionActorHuman,
|
||||
EntryOptions: req.EntryOptions,
|
||||
WorkflowChange: req.WorkflowChange,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -32,6 +33,24 @@ type WorkflowAgentOverrides struct {
|
||||
Steps []WorkflowAgentOverrideBinding `json:"steps"`
|
||||
}
|
||||
|
||||
// WorkflowChangeRequest is the optimistic source guard and grouped profile
|
||||
// choice accepted by the explicit change-workflow action. A non-nil empty
|
||||
// AgentOverrides map means use every destination workflow default.
|
||||
type WorkflowChangeRequest struct {
|
||||
ExpectedWorkflowID string `json:"expected_workflow_id"`
|
||||
ExpectedStepID string `json:"expected_step_id"`
|
||||
ExpectedUpdatedAt time.Time `json:"expected_updated_at"`
|
||||
AgentOverrides map[string]string `json:"agent_overrides"`
|
||||
}
|
||||
|
||||
// WorkflowChangeSource pins a guarded workflow-change write to the source
|
||||
// task row observed by the user before submitting the form.
|
||||
type WorkflowChangeSource struct {
|
||||
WorkflowID string
|
||||
StepID string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// NewWorkflowAgentOverrides validates and canonicalizes bindings. A self
|
||||
// replacement is equivalent to no override and is omitted from the record.
|
||||
func NewWorkflowAgentOverrides(workflowID string, bindings []WorkflowAgentOverrideBinding) (*WorkflowAgentOverrides, error) {
|
||||
|
||||
@@ -98,6 +98,10 @@ var ErrTaskCleanupInProgress = errors.New("task cleanup in progress")
|
||||
// contract.
|
||||
var ErrWorkflowResolutionConflict = errors.New("task workflow changed since resolution")
|
||||
|
||||
// ErrWorkflowChangeConflict reports that an explicit change-workflow request
|
||||
// no longer matches the task source or task version it was opened against.
|
||||
var ErrWorkflowChangeConflict = errors.New("task changed since workflow change was opened")
|
||||
|
||||
// ErrRunnerMutabilityConflict wraps one of the ten ordered mutability reason
|
||||
// codes rejecting a runner switch. Reason is always a member of the same
|
||||
// closed vocabulary the projection uses, never "eligible" and never empty.
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
"github.com/kandev/kandev/internal/task/repository/repoerrors"
|
||||
)
|
||||
|
||||
func TestPostgresWorkflowChangeAtomicAdmissionGuardsSourceVersion(t *testing.T) {
|
||||
repoA, repoB, _ := newTaskPostgresRepoPair(t)
|
||||
ctx := context.Background()
|
||||
source := seedWorkflowChangeRows(t, repoA, "task-workflow-change-postgres")
|
||||
guard := &models.WorkflowChangeSource{WorkflowID: source.WorkflowID, StepID: source.WorkflowStepID, UpdatedAt: source.UpdatedAt}
|
||||
candidate := workflowChangeCandidate(t, source)
|
||||
|
||||
concurrent, err := repoB.GetTask(ctx, source.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTask before concurrent edit: %v", err)
|
||||
}
|
||||
concurrent.Description = "Concurrent PostgreSQL edit"
|
||||
if err := repoB.UpdateTask(ctx, concurrent); err != nil {
|
||||
t.Fatalf("UpdateTask concurrent edit: %v", err)
|
||||
}
|
||||
if _, err := repoA.UpdateTaskWithWorkflowChangeAdmissionAndState(
|
||||
ctx, candidate, source.WorkflowStepID, candidate.WorkflowStepID, 0, nil, true, guard,
|
||||
); !errors.Is(err, repoerrors.ErrWorkflowChangeConflict) {
|
||||
t.Fatalf("stale guarded write error = %v, want ErrWorkflowChangeConflict", err)
|
||||
}
|
||||
|
||||
current, err := repoA.GetTask(ctx, source.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTask after stale change: %v", err)
|
||||
}
|
||||
if current.WorkflowID != source.WorkflowID || current.WorkflowStepID != source.WorkflowStepID || current.Description != "Concurrent PostgreSQL edit" {
|
||||
t.Fatalf("stale change overwrote current task: %+v", current)
|
||||
}
|
||||
|
||||
currentGuard := &models.WorkflowChangeSource{WorkflowID: current.WorkflowID, StepID: current.WorkflowStepID, UpdatedAt: current.UpdatedAt}
|
||||
currentCandidate := workflowChangeCandidate(t, current)
|
||||
if _, err := repoA.UpdateTaskWithWorkflowChangeAdmissionAndState(
|
||||
ctx, currentCandidate, current.WorkflowStepID, currentCandidate.WorkflowStepID, 0, nil, true, currentGuard,
|
||||
); err != nil {
|
||||
t.Fatalf("current guarded write: %v", err)
|
||||
}
|
||||
stored, err := repoB.GetTask(ctx, source.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTask after successful change: %v", err)
|
||||
}
|
||||
if stored.WorkflowID != currentCandidate.WorkflowID || stored.WorkflowStepID != currentCandidate.WorkflowStepID {
|
||||
t.Fatalf("stored destination = %s/%s", stored.WorkflowID, stored.WorkflowStepID)
|
||||
}
|
||||
if stored.WorkflowAgentOverrides == nil || stored.WorkflowAgentOverrides.WorkflowID != currentCandidate.WorkflowID {
|
||||
t.Fatalf("stored destination overrides = %+v", stored.WorkflowAgentOverrides)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
"github.com/kandev/kandev/internal/task/repository/repoerrors"
|
||||
)
|
||||
|
||||
func seedWorkflowChangeRows(t *testing.T, repo *Repository, taskID string) *models.Task {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
seedWorkspace(t, repo, "workspace-workflow-change")
|
||||
for _, workflow := range []*models.Workflow{
|
||||
{ID: "workflow-change-source", WorkspaceID: "workspace-workflow-change", Name: "Source"},
|
||||
{ID: "workflow-change-target", WorkspaceID: "workspace-workflow-change", Name: "Target"},
|
||||
} {
|
||||
if err := repo.CreateWorkflow(ctx, workflow); err != nil {
|
||||
t.Fatalf("CreateWorkflow(%s): %v", workflow.ID, err)
|
||||
}
|
||||
}
|
||||
seedCASWorkflowStep(t, repo, "workflow-change-source", "workflow-change-source-step", 0)
|
||||
seedCASWorkflowStep(t, repo, "workflow-change-target", "workflow-change-target-step", 0)
|
||||
oldOverrides, err := models.NewWorkflowAgentOverrides("workflow-change-source", []models.WorkflowAgentOverrideBinding{{
|
||||
StepID: "workflow-change-source-step", SourceProfileID: "source-old", ReplacementProfileID: "replacement-old",
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("NewWorkflowAgentOverrides: %v", err)
|
||||
}
|
||||
if err := repo.CreateTask(ctx, &models.Task{
|
||||
ID: taskID, WorkspaceID: "workspace-workflow-change",
|
||||
WorkflowID: "workflow-change-source", WorkflowStepID: "workflow-change-source-step",
|
||||
Title: "Original title", Description: "Original description", Priority: "medium",
|
||||
Metadata: map[string]interface{}{"sentinel": "keep"}, WorkflowAgentOverrides: oldOverrides,
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateTask: %v", err)
|
||||
}
|
||||
task, err := repo.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTask: %v", err)
|
||||
}
|
||||
return task
|
||||
}
|
||||
|
||||
func workflowChangeCandidate(t *testing.T, task *models.Task) *models.Task {
|
||||
t.Helper()
|
||||
candidate := *task
|
||||
candidate.WorkflowID = "workflow-change-target"
|
||||
candidate.WorkflowStepID = "workflow-change-target-step"
|
||||
candidate.WorkflowAgentOverrides, _ = models.NewWorkflowAgentOverrides("workflow-change-target", []models.WorkflowAgentOverrideBinding{{
|
||||
StepID: "workflow-change-target-step", SourceProfileID: "source-new", ReplacementProfileID: "replacement-new",
|
||||
}})
|
||||
return &candidate
|
||||
}
|
||||
|
||||
func TestWorkflowChangeAtomicAdmissionPersistsMembershipAndOverridesTogether(t *testing.T) {
|
||||
repo := newRepoForEntityTests(t)
|
||||
ctx := context.Background()
|
||||
source := seedWorkflowChangeRows(t, repo, "task-workflow-change-atomic")
|
||||
guard := &models.WorkflowChangeSource{WorkflowID: source.WorkflowID, StepID: source.WorkflowStepID, UpdatedAt: source.UpdatedAt}
|
||||
candidate := workflowChangeCandidate(t, source)
|
||||
if _, err := repo.UpdateTaskWithWorkflowChangeAdmissionAndState(
|
||||
ctx, candidate, source.WorkflowStepID, candidate.WorkflowStepID, 0, nil, true, guard,
|
||||
); err != nil {
|
||||
t.Fatalf("UpdateTaskWithWorkflowChangeAdmissionAndState: %v", err)
|
||||
}
|
||||
stored, err := repo.GetTask(ctx, source.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTask after change: %v", err)
|
||||
}
|
||||
if stored.WorkflowID != candidate.WorkflowID || stored.WorkflowStepID != candidate.WorkflowStepID {
|
||||
t.Fatalf("stored destination = %s/%s", stored.WorkflowID, stored.WorkflowStepID)
|
||||
}
|
||||
if stored.WorkflowAgentOverrides == nil || stored.WorkflowAgentOverrides.WorkflowID != candidate.WorkflowID {
|
||||
t.Fatalf("stored overrides = %+v", stored.WorkflowAgentOverrides)
|
||||
}
|
||||
if replacement, ok := stored.WorkflowAgentOverrides.ReplacementFor(candidate.WorkflowID, candidate.WorkflowStepID); !ok || replacement != "replacement-new" {
|
||||
t.Fatalf("stored destination replacement = %q, %v", replacement, ok)
|
||||
}
|
||||
if stored.Title != "Original title" || stored.Description != "Original description" || stored.Metadata["sentinel"] != "keep" {
|
||||
t.Fatalf("task context changed: %+v", stored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkflowChangeAtomicAdmissionRejectsConcurrentTaskEdit(t *testing.T) {
|
||||
repo := newRepoForEntityTests(t)
|
||||
ctx := context.Background()
|
||||
source := seedWorkflowChangeRows(t, repo, "task-workflow-change-stale")
|
||||
staleGuard := &models.WorkflowChangeSource{WorkflowID: source.WorkflowID, StepID: source.WorkflowStepID, UpdatedAt: source.UpdatedAt}
|
||||
candidate := workflowChangeCandidate(t, source)
|
||||
|
||||
current, err := repo.GetTask(ctx, source.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTask before concurrent edit: %v", err)
|
||||
}
|
||||
current.Description = "Concurrent edit"
|
||||
if err := repo.UpdateTask(ctx, current); err != nil {
|
||||
t.Fatalf("UpdateTask concurrent edit: %v", err)
|
||||
}
|
||||
if _, err := repo.UpdateTaskWithWorkflowChangeAdmissionAndState(
|
||||
ctx, candidate, source.WorkflowStepID, candidate.WorkflowStepID, 0, nil, true, staleGuard,
|
||||
); !errors.Is(err, repoerrors.ErrWorkflowChangeConflict) {
|
||||
t.Fatalf("stale guarded write error = %v, want ErrWorkflowChangeConflict", err)
|
||||
}
|
||||
stored, err := repo.GetTask(ctx, source.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTask after conflict: %v", err)
|
||||
}
|
||||
if stored.WorkflowID != source.WorkflowID || stored.WorkflowStepID != source.WorkflowStepID || stored.Description != "Concurrent edit" {
|
||||
t.Fatalf("stale change overwrote current task: %+v", stored)
|
||||
}
|
||||
if stored.WorkflowAgentOverrides == nil || stored.WorkflowAgentOverrides.WorkflowID != source.WorkflowID {
|
||||
t.Fatalf("stale change replaced source overrides: %+v", stored.WorkflowAgentOverrides)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkflowChangeAtomicAdmissionRollsBackOnTaskWriteFailure(t *testing.T) {
|
||||
repo := newRepoForEntityTests(t)
|
||||
ctx := context.Background()
|
||||
source := seedWorkflowChangeRows(t, repo, "task-workflow-change-write-failure")
|
||||
guard := &models.WorkflowChangeSource{WorkflowID: source.WorkflowID, StepID: source.WorkflowStepID, UpdatedAt: source.UpdatedAt}
|
||||
candidate := workflowChangeCandidate(t, source)
|
||||
if _, err := repo.db.Exec(`CREATE TRIGGER fail_workflow_change BEFORE UPDATE ON tasks
|
||||
WHEN OLD.id = 'task-workflow-change-write-failure'
|
||||
BEGIN SELECT RAISE(ABORT, 'injected workflow change failure'); END`); err != nil {
|
||||
t.Fatalf("create injected failure trigger: %v", err)
|
||||
}
|
||||
_, err := repo.UpdateTaskWithWorkflowChangeAdmissionAndState(
|
||||
ctx, candidate, source.WorkflowStepID, candidate.WorkflowStepID, 0, nil, true, guard,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("guarded write succeeded despite injected task update failure")
|
||||
}
|
||||
if _, err := repo.db.Exec(`DROP TRIGGER fail_workflow_change`); err != nil {
|
||||
t.Fatalf("drop injected failure trigger: %v", err)
|
||||
}
|
||||
stored, err := repo.GetTask(ctx, source.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTask after rollback: %v", err)
|
||||
}
|
||||
if stored.WorkflowID != source.WorkflowID || stored.WorkflowStepID != source.WorkflowStepID ||
|
||||
stored.WorkflowAgentOverrides == nil || stored.WorkflowAgentOverrides.WorkflowID != source.WorkflowID {
|
||||
t.Fatalf("failed change partially committed: %+v", stored)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/kandev/kandev/internal/steptelemetry"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
"github.com/kandev/kandev/internal/task/recoveryclaim"
|
||||
"github.com/kandev/kandev/internal/task/repository/repoerrors"
|
||||
usermodels "github.com/kandev/kandev/internal/user/models"
|
||||
wfmodels "github.com/kandev/kandev/internal/workflow/models"
|
||||
workflowmove "github.com/kandev/kandev/internal/workflow/move"
|
||||
@@ -1043,7 +1044,7 @@ func (r *Repository) updateTaskCommit(ctx context.Context, task *models.Task, ex
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
entryID, markerEntryID, err := r.updateTaskTx(ctx, tx, task, metadata, expectedWorkflowID, preservePosition, protectDeferredLaunch)
|
||||
entryID, markerEntryID, err := r.updateTaskTx(ctx, tx, task, metadata, expectedWorkflowID, preservePosition, protectDeferredLaunch, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1102,6 +1103,40 @@ func (r *Repository) readTaskPositionInTx(ctx context.Context, tx *sql.Tx, taskI
|
||||
return position, true, nil
|
||||
}
|
||||
|
||||
func (r *Repository) readTaskUpdatedAtInTx(ctx context.Context, tx *sql.Tx, taskID string) (time.Time, error) {
|
||||
query := `SELECT updated_at FROM tasks WHERE id = ?`
|
||||
if dialect.IsPostgres(r.db.DriverName()) {
|
||||
query += forUpdateClause
|
||||
}
|
||||
var updatedAt time.Time
|
||||
if err := tx.QueryRowContext(ctx, r.db.Rebind(query), taskID).Scan(&updatedAt); err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return updatedAt, nil
|
||||
}
|
||||
|
||||
func (r *Repository) validateWorkflowChangeSourceInTx(
|
||||
ctx context.Context,
|
||||
tx *sql.Tx,
|
||||
taskID, workflowID, stepID string,
|
||||
source *models.WorkflowChangeSource,
|
||||
) error {
|
||||
if source == nil || workflowID != source.WorkflowID || stepID != source.StepID {
|
||||
return repoerrors.ErrWorkflowChangeConflict
|
||||
}
|
||||
updatedAt, err := r.readTaskUpdatedAtInTx(ctx, tx, taskID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("%w: %s", ErrTaskNotFound, taskID)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !updatedAt.Equal(source.UpdatedAt) {
|
||||
return repoerrors.ErrWorkflowChangeConflict
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyPreservedPositionInTx overwrites task.Position with the value read
|
||||
// fresh inside this transaction when preservePosition is set, so a caller's
|
||||
// task object read before the transaction began does not clobber a
|
||||
@@ -1195,32 +1230,21 @@ func encodeWorkflowAgentOverridesValue(overrides *models.WorkflowAgentOverrides)
|
||||
// set only by UpdateTaskPreservingDeferredLaunch, re-merges the row's own
|
||||
// current deferred_launch value into the write so a stale in-memory snapshot
|
||||
// can never resurrect or clobber a concurrent session-ceiling CAS write.
|
||||
func (r *Repository) updateTaskTx(ctx context.Context, tx *sql.Tx, task *models.Task, metadata []byte, expectedWorkflowID string, preservePosition, protectDeferredLaunch bool) (entryID string, markerEntryID int64, err error) {
|
||||
fromWorkflowID, fromStepID, found, err := r.readTaskStepInTx(ctx, tx, task.ID)
|
||||
func (r *Repository) updateTaskTx(
|
||||
ctx context.Context,
|
||||
tx *sql.Tx,
|
||||
task *models.Task,
|
||||
metadata []byte,
|
||||
expectedWorkflowID string,
|
||||
preservePosition, protectDeferredLaunch bool,
|
||||
workflowChangeSource *models.WorkflowChangeSource,
|
||||
) (entryID string, markerEntryID int64, err error) {
|
||||
fromWorkflowID, fromStepID, err := r.readAndValidateTaskUpdateSourceInTx(
|
||||
ctx, tx, task, expectedWorkflowID, preservePosition, workflowChangeSource,
|
||||
)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if !found {
|
||||
// A concurrently deleted task must surface as ErrTaskNotFound, not
|
||||
// fall through to the CAS comparison below: with fromWorkflowID=""
|
||||
// (never equal to a non-empty expectedWorkflowID) that branch would
|
||||
// misreport the deletion as a workflow-resolution conflict. NotFound
|
||||
// is reserved for the addressed resource and wins the precedence
|
||||
// ladder over every other case (design's error-mapping table).
|
||||
return "", 0, fmt.Errorf("%w: %s", ErrTaskNotFound, task.ID)
|
||||
}
|
||||
if err := r.applyPreservedPositionInTx(ctx, tx, task, preservePosition); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if expectedWorkflowID != "" && fromWorkflowID != expectedWorkflowID {
|
||||
// Checked here, immediately before the UPDATE below and using the
|
||||
// same in-transaction, lock-protected read the ledger's "from" value
|
||||
// already comes from — this is the narrowest possible point to close
|
||||
// the race a caller-side pre-read (GetTask, well before this write)
|
||||
// cannot rule out on its own. See ErrWorkflowResolutionConflict (errors.go).
|
||||
return "", 0, fmt.Errorf("%w: expected %q, task is now in %q",
|
||||
ErrWorkflowResolutionConflict, expectedWorkflowID, fromWorkflowID)
|
||||
}
|
||||
metadata, err = r.preserveLiveHandoffProvenance(ctx, tx, task.ID, metadata)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
@@ -1239,12 +1263,20 @@ func (r *Repository) updateTaskTx(ctx context.Context, tx *sql.Tx, task *models.
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, r.db.Rebind(updateQuery), task.WorkspaceID, task.WorkflowID, task.WorkflowStepID, workflowAgentOverrides, task.Title, task.Description, task.State, task.Priority, task.Position, dialect.BoolToInt(task.WIPAdmitted), task.QueuedForStepID, task.QueuedAt, string(metadata), task.ParentID, task.UpdatedAt, task.Origin, task.ProjectID, task.Labels, task.Identifier, task.AssigneeUserID, task.ID)
|
||||
args := []interface{}{task.WorkspaceID, task.WorkflowID, task.WorkflowStepID, workflowAgentOverrides, task.Title, task.Description, task.State, task.Priority, task.Position, dialect.BoolToInt(task.WIPAdmitted), task.QueuedForStepID, task.QueuedAt, string(metadata), task.ParentID, task.UpdatedAt, task.Origin, task.ProjectID, task.Labels, task.Identifier, task.AssigneeUserID, task.ID}
|
||||
if workflowChangeSource != nil {
|
||||
updateQuery += ` AND workflow_id = ? AND workflow_step_id = ? AND updated_at = ?`
|
||||
args = append(args, workflowChangeSource.WorkflowID, workflowChangeSource.StepID, workflowChangeSource.UpdatedAt)
|
||||
}
|
||||
result, err := tx.ExecContext(ctx, r.db.Rebind(updateQuery), args...)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
if workflowChangeSource != nil {
|
||||
return "", 0, repoerrors.ErrWorkflowChangeConflict
|
||||
}
|
||||
return "", 0, fmt.Errorf("%w: %s", ErrTaskNotFound, task.ID)
|
||||
}
|
||||
|
||||
@@ -1286,6 +1318,36 @@ func (r *Repository) updateTaskTx(ctx context.Context, tx *sql.Tx, task *models.
|
||||
return entryID, markerEntryID, nil
|
||||
}
|
||||
|
||||
func (r *Repository) readAndValidateTaskUpdateSourceInTx(
|
||||
ctx context.Context,
|
||||
tx *sql.Tx,
|
||||
task *models.Task,
|
||||
expectedWorkflowID string,
|
||||
preservePosition bool,
|
||||
workflowChangeSource *models.WorkflowChangeSource,
|
||||
) (string, string, error) {
|
||||
fromWorkflowID, fromStepID, found, err := r.readTaskStepInTx(ctx, tx, task.ID)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if !found {
|
||||
return "", "", fmt.Errorf("%w: %s", ErrTaskNotFound, task.ID)
|
||||
}
|
||||
if err := r.applyPreservedPositionInTx(ctx, tx, task, preservePosition); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if expectedWorkflowID != "" && fromWorkflowID != expectedWorkflowID {
|
||||
return "", "", fmt.Errorf("%w: expected %q, task is now in %q",
|
||||
ErrWorkflowResolutionConflict, expectedWorkflowID, fromWorkflowID)
|
||||
}
|
||||
if workflowChangeSource != nil {
|
||||
if err := r.validateWorkflowChangeSourceInTx(ctx, tx, task.ID, fromWorkflowID, fromStepID, workflowChangeSource); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
return fromWorkflowID, fromStepID, nil
|
||||
}
|
||||
|
||||
// UpdateTaskWithWorkflowStepAdmission atomically moves a task into a workflow
|
||||
// step. A limited full target stores the task in that destination as queued;
|
||||
// it never rejects the move for WIP capacity. sourceStepID is the step the
|
||||
@@ -1298,7 +1360,7 @@ func (r *Repository) UpdateTaskWithWorkflowStepAdmission(
|
||||
targetStepID string,
|
||||
limit int,
|
||||
) (bool, error) {
|
||||
admitted, _, err := r.updateTaskWithWorkflowStepAdmission(ctx, task, sourceStepID, targetStepID, limit, nil, false, "", "", nil)
|
||||
admitted, _, err := r.updateTaskWithWorkflowStepAdmission(ctx, task, sourceStepID, targetStepID, limit, nil, false, "", "", nil, nil)
|
||||
return admitted, err
|
||||
}
|
||||
|
||||
@@ -1323,7 +1385,28 @@ func (r *Repository) UpdateTaskWithWorkflowStepAdmissionAndState(
|
||||
expectedWorkflowID string,
|
||||
) (bool, error) {
|
||||
admitted, _, err := r.updateTaskWithWorkflowStepAdmission(
|
||||
ctx, task, sourceStepID, targetStepID, limit, admittedState, queueExitPending, "", expectedWorkflowID, nil,
|
||||
ctx, task, sourceStepID, targetStepID, limit, admittedState, queueExitPending, "", expectedWorkflowID, nil, nil,
|
||||
)
|
||||
return admitted, err
|
||||
}
|
||||
|
||||
// UpdateTaskWithWorkflowChangeAdmissionAndState writes a change-workflow
|
||||
// transition only while the persisted source identity and version still match.
|
||||
func (r *Repository) UpdateTaskWithWorkflowChangeAdmissionAndState(
|
||||
ctx context.Context,
|
||||
task *models.Task,
|
||||
sourceStepID string,
|
||||
targetStepID string,
|
||||
limit int,
|
||||
admittedState *v1.TaskState,
|
||||
queueExitPending bool,
|
||||
source *models.WorkflowChangeSource,
|
||||
) (bool, error) {
|
||||
if source == nil {
|
||||
return false, fmt.Errorf("workflow change source guard is required")
|
||||
}
|
||||
admitted, _, err := r.updateTaskWithWorkflowStepAdmission(
|
||||
ctx, task, sourceStepID, targetStepID, limit, admittedState, queueExitPending, "", "", nil, source,
|
||||
)
|
||||
return admitted, err
|
||||
}
|
||||
@@ -1346,7 +1429,7 @@ func (r *Repository) UpdateTaskWithWorkflowStepAdmissionIfAtStep(
|
||||
) (applied bool, err error) {
|
||||
// expectedStepID doubles as the source step to lock: it is, by
|
||||
// construction, the step this task is expected to currently occupy.
|
||||
_, applied, err = r.updateTaskWithWorkflowStepAdmission(ctx, task, expectedStepID, targetStepID, limit, nil, false, expectedStepID, "", nil)
|
||||
_, applied, err = r.updateTaskWithWorkflowStepAdmission(ctx, task, expectedStepID, targetStepID, limit, nil, false, expectedStepID, "", nil, nil)
|
||||
return applied, err
|
||||
}
|
||||
|
||||
@@ -1360,7 +1443,7 @@ func (r *Repository) UpdateTaskWithWorkflowStepAdmissionForDeferredMove(
|
||||
// expectedStepID doubles as the source step to lock: it is, by
|
||||
// construction, the step this task is expected to currently occupy.
|
||||
return r.updateTaskWithWorkflowStepAdmission(
|
||||
ctx, task, expectedStepID, targetStepID, limit, nil, false, expectedStepID, "", &record,
|
||||
ctx, task, expectedStepID, targetStepID, limit, nil, false, expectedStepID, "", &record, nil,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1404,7 +1487,7 @@ func (r *Repository) MarkDeferredMoveAppliedForSession(
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if _, _, err := r.updateTaskTx(ctx, tx, task, metadata, "", true, false); err != nil {
|
||||
if _, _, err := r.updateTaskTx(ctx, tx, task, metadata, "", true, false, nil); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := r.deleteDeferredMoveGuardTx(ctx, tx, record); err != nil {
|
||||
@@ -1600,6 +1683,7 @@ func (r *Repository) updateTaskWithWorkflowStepAdmission(
|
||||
expectedStepID string,
|
||||
expectedWorkflowID string,
|
||||
deferredMove *messagequeue.PendingMoveRecord,
|
||||
workflowChangeSource *models.WorkflowChangeSource,
|
||||
) (admitted bool, applied bool, err error) {
|
||||
currentSourceStepID := sourceStepID
|
||||
for attempt := 0; attempt < admissionSourceRetryLimit; attempt++ {
|
||||
@@ -1615,6 +1699,7 @@ func (r *Repository) updateTaskWithWorkflowStepAdmission(
|
||||
expectedStepID,
|
||||
expectedWorkflowID,
|
||||
deferredMove,
|
||||
workflowChangeSource,
|
||||
)
|
||||
unlock()
|
||||
|
||||
@@ -1622,6 +1707,9 @@ func (r *Repository) updateTaskWithWorkflowStepAdmission(
|
||||
if !errors.As(err, &changed) {
|
||||
return admitted, applied, err
|
||||
}
|
||||
if workflowChangeSource != nil {
|
||||
return false, false, repoerrors.ErrWorkflowChangeConflict
|
||||
}
|
||||
if expectedStepID != "" {
|
||||
// CAS callers preserve their existing applied=false contract when
|
||||
// the task has left the expected step. They must not retry against
|
||||
@@ -1645,6 +1733,7 @@ func (r *Repository) updateTaskWithWorkflowStepAdmissionAttempt(
|
||||
expectedStepID string,
|
||||
expectedWorkflowID string,
|
||||
deferredMove *messagequeue.PendingMoveRecord,
|
||||
workflowChangeSource *models.WorkflowChangeSource,
|
||||
) (admitted bool, applied bool, err error) {
|
||||
now := time.Now().UTC()
|
||||
task.UpdatedAt = now
|
||||
@@ -1691,7 +1780,7 @@ func (r *Repository) updateTaskWithWorkflowStepAdmissionAttempt(
|
||||
// source so it acquires the complete sorted lock set before assigning an
|
||||
// arrival position. This closes the single-move TOCTOU window without
|
||||
// acquiring a newly discovered source beneath an already-held lock.
|
||||
_, actualSourceStepID, found, err := r.readTaskStepInTx(ctx, tx, task.ID)
|
||||
actualWorkflowID, actualSourceStepID, found, err := r.readTaskStepInTx(ctx, tx, task.ID)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
@@ -1699,8 +1788,16 @@ func (r *Repository) updateTaskWithWorkflowStepAdmissionAttempt(
|
||||
return false, false, fmt.Errorf("%w: %s", ErrTaskNotFound, task.ID)
|
||||
}
|
||||
if actualSourceStepID != sourceStepID {
|
||||
if workflowChangeSource != nil {
|
||||
return false, false, repoerrors.ErrWorkflowChangeConflict
|
||||
}
|
||||
return false, false, &admissionSourceChangedError{stepID: actualSourceStepID}
|
||||
}
|
||||
if workflowChangeSource != nil {
|
||||
if err := r.validateWorkflowChangeSourceInTx(ctx, tx, task.ID, actualWorkflowID, actualSourceStepID, workflowChangeSource); err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
}
|
||||
|
||||
// AC-46/48 compare-and-swap precondition, only for CAS callers (see
|
||||
// rebaseTaskForStepAdmissionCAS for the full rationale). A mismatch means
|
||||
@@ -1773,7 +1870,7 @@ func (r *Repository) updateTaskWithWorkflowStepAdmissionAttempt(
|
||||
if err != nil {
|
||||
metadata = []byte("{}")
|
||||
}
|
||||
entryID, markerEntryID, err := r.updateTaskTx(ctx, tx, task, metadata, expectedWorkflowID, false, false)
|
||||
entryID, markerEntryID, err := r.updateTaskTx(ctx, tx, task, metadata, expectedWorkflowID, false, false, workflowChangeSource)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,326 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
settingsmodels "github.com/kandev/kandev/internal/agent/settings/models"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite"
|
||||
wfmodels "github.com/kandev/kandev/internal/workflow/models"
|
||||
"github.com/kandev/kandev/internal/workflow/repository"
|
||||
)
|
||||
|
||||
type workflowChangeExecutorValidator struct {
|
||||
executorID string
|
||||
profiles []string
|
||||
}
|
||||
|
||||
func (v *workflowChangeExecutorValidator) ValidateAgentProfileForExecutor(
|
||||
_ context.Context,
|
||||
profile *settingsmodels.AgentProfile,
|
||||
executor *models.Executor,
|
||||
_ *models.ExecutorProfile,
|
||||
) error {
|
||||
v.profiles = append(v.profiles, profile.ID)
|
||||
if executor == nil || executor.ID != v.executorID {
|
||||
return errors.New("unexpected executor")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type workflowChangePreflightRecorder struct {
|
||||
candidate *models.Task
|
||||
}
|
||||
|
||||
func (*workflowChangePreflightRecorder) PreflightWorkflowStepMove(
|
||||
context.Context,
|
||||
string,
|
||||
*models.TaskSession,
|
||||
*wfmodels.WorkflowStep,
|
||||
) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *workflowChangePreflightRecorder) PreflightWorkflowStepChange(
|
||||
_ context.Context,
|
||||
candidate *models.Task,
|
||||
_ *models.TaskSession,
|
||||
_ *wfmodels.WorkflowStep,
|
||||
) error {
|
||||
copy := *candidate
|
||||
r.candidate = ©
|
||||
return nil
|
||||
}
|
||||
|
||||
type workflowChangeFixture struct {
|
||||
svc *Service
|
||||
repo *sqliterepo.Repository
|
||||
task *models.Task
|
||||
request *models.WorkflowChangeRequest
|
||||
validator *workflowChangeExecutorValidator
|
||||
}
|
||||
|
||||
func newWorkflowChangeFixture(t *testing.T) workflowChangeFixture {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
svc, _, repo := createTestService(t)
|
||||
workspaceDefaultExecutor := "executor-workspace-default"
|
||||
for _, executorID := range []string{workspaceDefaultExecutor, "executor-task"} {
|
||||
if err := repo.CreateExecutor(ctx, &models.Executor{
|
||||
ID: executorID, Name: executorID, Type: models.ExecutorTypeLocal,
|
||||
Status: models.ExecutorStatusActive, Resumable: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateExecutor(%s): %v", executorID, err)
|
||||
}
|
||||
}
|
||||
if err := repo.CreateWorkspace(ctx, &models.Workspace{
|
||||
ID: "workspace-change", Name: "Change workflow",
|
||||
DefaultExecutorID: &workspaceDefaultExecutor,
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateWorkspace: %v", err)
|
||||
}
|
||||
for _, workflow := range []*models.Workflow{
|
||||
{ID: "workflow-source", WorkspaceID: "workspace-change", Name: "Source"},
|
||||
{ID: "workflow-target", WorkspaceID: "workspace-change", Name: "Target"},
|
||||
} {
|
||||
if err := repo.CreateWorkflow(ctx, workflow); err != nil {
|
||||
t.Fatalf("CreateWorkflow(%s): %v", workflow.ID, err)
|
||||
}
|
||||
}
|
||||
wfDB := sqlx.NewDb(repo.DB(), "sqlite3")
|
||||
wfRepo, err := repository.NewWithDB(wfDB, wfDB, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("New workflow repository: %v", err)
|
||||
}
|
||||
steps := []*wfmodels.WorkflowStep{
|
||||
{ID: "source-step", WorkflowID: "workflow-source", Name: "Review", Position: 0, AgentProfileID: "old-source-profile"},
|
||||
{ID: "target-analysis", WorkflowID: "workflow-target", Name: "Analysis", Position: 0, AgentProfileID: "source-profile"},
|
||||
{ID: "target-implement", WorkflowID: "workflow-target", Name: "Implement", Position: 1, AgentProfileID: "source-profile"},
|
||||
{ID: "target-review", WorkflowID: "workflow-target", Name: "Review", Position: 2, AgentProfileID: "source-profile", SessionTarget: &wfmodels.WorkflowSessionTarget{Kind: wfmodels.WorkflowSessionTargetInitial}},
|
||||
}
|
||||
for _, step := range steps {
|
||||
if err := wfRepo.CreateStep(ctx, step); err != nil {
|
||||
t.Fatalf("CreateStep(%s): %v", step.ID, err)
|
||||
}
|
||||
}
|
||||
stepMap := make(map[string]*wfmodels.WorkflowStep, len(steps))
|
||||
for _, step := range steps {
|
||||
stepMap[step.ID] = step
|
||||
}
|
||||
svc.SetWorkflowStepGetter(&fakeWorkflowStepGetter{steps: stepMap})
|
||||
oldOverrides, err := models.NewWorkflowAgentOverrides("workflow-source", []models.WorkflowAgentOverrideBinding{{
|
||||
StepID: "source-step", SourceProfileID: "old-source-profile", ReplacementProfileID: "old-replacement-profile",
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("NewWorkflowAgentOverrides: %v", err)
|
||||
}
|
||||
if err := repo.CreateTask(ctx, &models.Task{
|
||||
ID: "task-workflow-change", WorkspaceID: "workspace-change",
|
||||
WorkflowID: "workflow-source", WorkflowStepID: "source-step",
|
||||
Title: "Preserved title", Description: "Preserved description",
|
||||
State: "TODO", Priority: "medium", Metadata: map[string]interface{}{"sentinel": "keep"},
|
||||
WorkflowAgentOverrides: oldOverrides,
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateTask: %v", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if err := repo.CreateTaskSession(ctx, &models.TaskSession{
|
||||
ID: "session-workflow-change", TaskID: "task-workflow-change",
|
||||
ExecutorID: "executor-task", State: models.TaskSessionStateWaitingForInput,
|
||||
StartedAt: now, UpdatedAt: now, IsPrimary: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateTaskSession: %v", err)
|
||||
}
|
||||
task, err := repo.GetTask(ctx, "task-workflow-change")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTask: %v", err)
|
||||
}
|
||||
validator := &workflowChangeExecutorValidator{executorID: "executor-task"}
|
||||
svc.agentProfiles = workflowAgentOverrideProfilesStub{profiles: map[string]*settingsmodels.AgentProfile{
|
||||
"source-profile": {ID: "source-profile", Enabled: true, WorkspaceID: "workspace-change"},
|
||||
"replacement-profile": {ID: "replacement-profile", Enabled: true, WorkspaceID: "workspace-change"},
|
||||
"old-source-profile": {ID: "old-source-profile", Enabled: true, WorkspaceID: "workspace-change"},
|
||||
"old-replacement-profile": {ID: "old-replacement-profile", Enabled: true, WorkspaceID: "workspace-change"},
|
||||
}}
|
||||
svc.agentProfileExecutorValidator = validator
|
||||
return workflowChangeFixture{
|
||||
svc: svc, repo: repo, task: task, validator: validator,
|
||||
request: &models.WorkflowChangeRequest{
|
||||
ExpectedWorkflowID: task.WorkflowID,
|
||||
ExpectedStepID: task.WorkflowStepID,
|
||||
ExpectedUpdatedAt: task.UpdatedAt,
|
||||
AgentOverrides: map[string]string{"source-profile": "replacement-profile"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveTaskWithWorkflowChangeReplacesOverridesAndPreservesTaskContext(t *testing.T) {
|
||||
fixture := newWorkflowChangeFixture(t)
|
||||
preflight := &workflowChangePreflightRecorder{}
|
||||
fixture.svc.SetWorkflowMovePreflight(preflight)
|
||||
|
||||
result, err := fixture.svc.MoveTaskWithOptions(
|
||||
context.Background(), fixture.task.ID, "workflow-target", "target-analysis", 0,
|
||||
MoveTaskOptions{AllowActivePrimarySession: true, WorkflowChange: fixture.request},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("MoveTaskWithOptions: %v", err)
|
||||
}
|
||||
if result.Task.ID != fixture.task.ID || result.Task.WorkflowID != "workflow-target" || result.Task.WorkflowStepID != "target-analysis" {
|
||||
t.Fatalf("moved task identity = %+v", result.Task)
|
||||
}
|
||||
if result.Task.Title != "Preserved title" || result.Task.Description != "Preserved description" || result.Task.Metadata["sentinel"] != "keep" {
|
||||
t.Fatalf("task context changed: %+v", result.Task)
|
||||
}
|
||||
if result.Task.WorkflowAgentOverrides == nil || result.Task.WorkflowAgentOverrides.WorkflowID != "workflow-target" {
|
||||
t.Fatalf("destination overrides = %+v", result.Task.WorkflowAgentOverrides)
|
||||
}
|
||||
for _, stepID := range []string{"target-analysis", "target-implement"} {
|
||||
if replacement, ok := result.Task.WorkflowAgentOverrides.ReplacementFor("workflow-target", stepID); !ok || replacement != "replacement-profile" {
|
||||
t.Fatalf("replacement for %s = %q, %v", stepID, replacement, ok)
|
||||
}
|
||||
}
|
||||
if _, ok := result.Task.WorkflowAgentOverrides.ReplacementFor("workflow-target", "target-review"); ok {
|
||||
t.Fatal("explicit initial-session target received a fixed-profile replacement")
|
||||
}
|
||||
if _, ok := result.Task.WorkflowAgentOverrides.ReplacementFor("workflow-source", "source-step"); ok {
|
||||
t.Fatal("source workflow override remained active after explicit change")
|
||||
}
|
||||
if preflight.candidate == nil || preflight.candidate.WorkflowID != "workflow-target" ||
|
||||
preflight.candidate.WorkflowAgentOverrides == nil ||
|
||||
preflight.candidate.WorkflowAgentOverrides.WorkflowID != "workflow-target" {
|
||||
t.Fatalf("preflight did not receive candidate destination state: %+v", preflight.candidate)
|
||||
}
|
||||
if len(fixture.validator.profiles) != 1 || fixture.validator.profiles[0] != "replacement-profile" {
|
||||
t.Fatalf("validated profiles = %v, want replacement on task executor", fixture.validator.profiles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveTaskWithWorkflowChangeAcceptsEquivalentOffsetTimestamp(t *testing.T) {
|
||||
fixture := newWorkflowChangeFixture(t)
|
||||
fixture.request.ExpectedUpdatedAt = fixture.request.ExpectedUpdatedAt.In(time.FixedZone("EET", 2*60*60))
|
||||
|
||||
result, err := fixture.svc.MoveTaskWithOptions(
|
||||
context.Background(), fixture.task.ID, "workflow-target", "target-analysis", 0,
|
||||
MoveTaskOptions{AllowActivePrimarySession: true, WorkflowChange: fixture.request},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("MoveTaskWithOptions with equivalent offset timestamp: %v", err)
|
||||
}
|
||||
if result.Task.WorkflowID != "workflow-target" || result.Task.WorkflowStepID != "target-analysis" {
|
||||
t.Fatalf("moved assignment = %s/%s, want workflow-target/target-analysis", result.Task.WorkflowID, result.Task.WorkflowStepID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkflowChangeValidatesDefaultsAndRejectsUnavailableAgent(t *testing.T) {
|
||||
fixture := newWorkflowChangeFixture(t)
|
||||
fixture.request.AgentOverrides = map[string]string{}
|
||||
overrides, err := fixture.svc.ValidateWorkflowChange(context.Background(), fixture.task.ID, "workflow-target", "target-analysis", fixture.request)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateWorkflowChange with defaults: %v", err)
|
||||
}
|
||||
if overrides != nil {
|
||||
t.Fatalf("default selections produced overrides: %+v", overrides)
|
||||
}
|
||||
if len(fixture.validator.profiles) != 1 || fixture.validator.profiles[0] != "source-profile" {
|
||||
t.Fatalf("default profile validation = %v, want destination workflow default", fixture.validator.profiles)
|
||||
}
|
||||
|
||||
fixture.request.AgentOverrides = map[string]string{"source-profile": "disabled-profile"}
|
||||
_, err = fixture.svc.ValidateWorkflowChange(context.Background(), fixture.task.ID, "workflow-target", "target-analysis", fixture.request)
|
||||
var validationErr *WorkflowChangeValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Code != WorkflowChangeErrorAgentInvalid || validationErr.SourceProfileID != "source-profile" {
|
||||
t.Fatalf("unavailable replacement error = %#v, want row validation for source-profile", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkflowChangeRejectsStaleTaskSource(t *testing.T) {
|
||||
tests := map[string]func(*models.WorkflowChangeRequest){
|
||||
"workflow": func(request *models.WorkflowChangeRequest) { request.ExpectedWorkflowID = "another-workflow" },
|
||||
"step": func(request *models.WorkflowChangeRequest) { request.ExpectedStepID = "another-step" },
|
||||
"version": func(request *models.WorkflowChangeRequest) {
|
||||
request.ExpectedUpdatedAt = request.ExpectedUpdatedAt.Add(-time.Second)
|
||||
},
|
||||
}
|
||||
for name, alter := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
fixture := newWorkflowChangeFixture(t)
|
||||
alter(fixture.request)
|
||||
_, err := fixture.svc.ValidateWorkflowChange(
|
||||
context.Background(), fixture.task.ID, "workflow-target", "target-analysis", fixture.request,
|
||||
)
|
||||
if !errors.Is(err, ErrWorkflowChangeConflict) {
|
||||
t.Fatalf("ValidateWorkflowChange error = %v, want source conflict", err)
|
||||
}
|
||||
stored, loadErr := fixture.repo.GetTask(context.Background(), fixture.task.ID)
|
||||
if loadErr != nil {
|
||||
t.Fatalf("GetTask after source conflict: %v", loadErr)
|
||||
}
|
||||
if stored.WorkflowID != fixture.task.WorkflowID || stored.WorkflowStepID != fixture.task.WorkflowStepID {
|
||||
t.Fatalf("stale form changed task assignment: %s/%s", stored.WorkflowID, stored.WorkflowStepID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkflowChangeRejectsProjectOwnedTask(t *testing.T) {
|
||||
fixture := newWorkflowChangeFixture(t)
|
||||
officeTask := *fixture.task
|
||||
officeTask.ProjectID = "office-project"
|
||||
|
||||
_, err := fixture.svc.prepareWorkflowChange(
|
||||
context.Background(), &officeTask, "workflow-target", "target-analysis", fixture.request,
|
||||
)
|
||||
var validationErr *WorkflowChangeValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Code != WorkflowChangeErrorInvalid {
|
||||
t.Fatalf("project-owned task change error = %#v, want invalid workflow change", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveTaskWithWorkflowChangePreservesSessionGuards(t *testing.T) {
|
||||
t.Run("running primary remains allowed", func(t *testing.T) {
|
||||
fixture := newWorkflowChangeFixture(t)
|
||||
repo := fixture.repo
|
||||
if err := repo.UpdateTaskSessionState(context.Background(), "session-workflow-change", models.TaskSessionStateRunning, ""); err != nil {
|
||||
t.Fatalf("UpdateTaskSessionState: %v", err)
|
||||
}
|
||||
if _, err := fixture.svc.MoveTaskWithOptions(
|
||||
context.Background(), fixture.task.ID, "workflow-target", "target-analysis", 0,
|
||||
MoveTaskOptions{AllowActivePrimarySession: true, WorkflowChange: fixture.request},
|
||||
); err != nil {
|
||||
t.Fatalf("MoveTaskWithOptions with running primary: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("running sibling remains blocked", func(t *testing.T) {
|
||||
fixture := newWorkflowChangeFixture(t)
|
||||
repo := fixture.repo
|
||||
now := time.Now().UTC()
|
||||
if err := repo.CreateTaskSession(context.Background(), &models.TaskSession{
|
||||
ID: "session-workflow-change-sibling", TaskID: fixture.task.ID,
|
||||
ExecutorID: "executor-task", State: models.TaskSessionStateRunning,
|
||||
StartedAt: now, UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateTaskSession sibling: %v", err)
|
||||
}
|
||||
_, err := fixture.svc.MoveTaskWithOptions(
|
||||
context.Background(), fixture.task.ID, "workflow-target", "target-analysis", 0,
|
||||
MoveTaskOptions{AllowActivePrimarySession: true, WorkflowChange: fixture.request},
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "active session") {
|
||||
t.Fatalf("MoveTaskWithOptions error = %v, want active sibling conflict", err)
|
||||
}
|
||||
stored, loadErr := repo.GetTask(context.Background(), fixture.task.ID)
|
||||
if loadErr != nil {
|
||||
t.Fatalf("GetTask after blocked move: %v", loadErr)
|
||||
}
|
||||
if stored.WorkflowID != fixture.task.WorkflowID || stored.WorkflowStepID != fixture.task.WorkflowStepID {
|
||||
t.Fatalf("blocked move changed assignment: %s/%s", stored.WorkflowID, stored.WorkflowStepID)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -343,6 +343,12 @@ type WorkflowMovePreflight interface {
|
||||
PreflightWorkflowStepMove(ctx context.Context, taskID string, currentSession *models.TaskSession, targetStep *wfmodels.WorkflowStep) error
|
||||
}
|
||||
|
||||
// WorkflowChangeMovePreflight accepts the candidate task projection so
|
||||
// destination routing checks see the draft override map before it is persisted.
|
||||
type WorkflowChangeMovePreflight interface {
|
||||
PreflightWorkflowStepChange(ctx context.Context, candidate *models.Task, currentSession *models.TaskSession, targetStep *wfmodels.WorkflowStep) error
|
||||
}
|
||||
|
||||
// workflowStepLister is an optional extension used to find WIP steps that
|
||||
// pull work from a feeder when new work arrives in that feeder.
|
||||
type workflowStepLister interface {
|
||||
|
||||
@@ -469,6 +469,9 @@ type MoveTaskOptions struct {
|
||||
// target workflow step. They are persisted privately on a transient task
|
||||
// marker and are never included in task.moved event payloads.
|
||||
EntryOptions *workflowmove.EntryOptions
|
||||
// WorkflowChange opts this single-task move into source/version checks and
|
||||
// atomically replaces the task's destination workflow agent overrides.
|
||||
WorkflowChange *models.WorkflowChangeRequest
|
||||
}
|
||||
|
||||
// ErrWorkflowResolutionConflict indicates a caller's pre-resolved "current
|
||||
@@ -514,6 +517,19 @@ type workflowMoveAdmissionWithStateRepository interface {
|
||||
) (bool, error)
|
||||
}
|
||||
|
||||
type workflowChangeAdmissionRepository interface {
|
||||
UpdateTaskWithWorkflowChangeAdmissionAndState(
|
||||
context.Context,
|
||||
*models.Task,
|
||||
string,
|
||||
string,
|
||||
int,
|
||||
*v1.TaskState,
|
||||
bool,
|
||||
*models.WorkflowChangeSource,
|
||||
) (bool, error)
|
||||
}
|
||||
|
||||
// workflowMoveConflictRepository is the same-step counterpart of
|
||||
// workflowMoveAdmissionWithStateRepository's expectedWorkflowID guard: when a
|
||||
// move does not change workflow step, updateMovedTask writes through plain
|
||||
@@ -582,11 +598,23 @@ func (s *Service) MoveTaskWithOptions(
|
||||
return nil, fmt.Errorf("%w: resolved %q, task is now in %q",
|
||||
ErrWorkflowResolutionConflict, *opts.ExpectedWorkflowID, task.WorkflowID)
|
||||
}
|
||||
if opts.WorkflowChange != nil {
|
||||
if err := validateWorkflowChangeSource(task, workflowID, workflowStepID, opts.WorkflowChange); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
targetStep, err := s.validateTaskMove(ctx, task, workflowID, workflowStepID, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var candidateOverrides *models.WorkflowAgentOverrides
|
||||
if opts.WorkflowChange != nil {
|
||||
candidateOverrides, err = s.prepareWorkflowChange(ctx, task, workflowID, workflowStepID, opts.WorkflowChange)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
oldWorkflowID := task.WorkflowID
|
||||
oldStepID := task.WorkflowStepID
|
||||
@@ -628,8 +656,22 @@ func (s *Service) MoveTaskWithOptions(
|
||||
|
||||
if stepChanged && targetStep != nil && s.workflowMovePreflight != nil {
|
||||
currentSession := s.resolvePrimaryOrActiveSession(ctx, id)
|
||||
if err := s.workflowMovePreflight.PreflightWorkflowStepMove(ctx, id, currentSession, targetStep); err != nil {
|
||||
return nil, fmt.Errorf("failed to preflight workflow move: %w", err)
|
||||
var preflightErr error
|
||||
if opts.WorkflowChange != nil {
|
||||
preflight, ok := s.workflowMovePreflight.(WorkflowChangeMovePreflight)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("workflow change preflight does not accept candidate task state")
|
||||
}
|
||||
candidate := *task
|
||||
candidate.WorkflowID = workflowID
|
||||
candidate.WorkflowStepID = workflowStepID
|
||||
candidate.WorkflowAgentOverrides = candidateOverrides
|
||||
preflightErr = preflight.PreflightWorkflowStepChange(ctx, &candidate, currentSession, targetStep)
|
||||
} else {
|
||||
preflightErr = s.workflowMovePreflight.PreflightWorkflowStepMove(ctx, id, currentSession, targetStep)
|
||||
}
|
||||
if preflightErr != nil {
|
||||
return nil, fmt.Errorf("failed to preflight workflow move: %w", preflightErr)
|
||||
}
|
||||
}
|
||||
stateAfterAdmission := *task
|
||||
@@ -641,6 +683,9 @@ func (s *Service) MoveTaskWithOptions(
|
||||
|
||||
task.WorkflowID = workflowID
|
||||
task.WorkflowStepID = workflowStepID
|
||||
if opts.WorkflowChange != nil {
|
||||
task.WorkflowAgentOverrides = candidateOverrides
|
||||
}
|
||||
// A move naming the task's current step is not an arrival
|
||||
// (REQ-TASKS-KANBAN-TASK-REORDERING-001.28): it keeps the position it
|
||||
// already holds rather than the caller-supplied literal, which the
|
||||
@@ -1357,6 +1402,16 @@ func (s *Service) updateMovedTaskCrossStep(
|
||||
admittedState *v1.TaskState,
|
||||
opts MoveTaskOptions,
|
||||
) (bool, error) {
|
||||
if opts.WorkflowChange != nil {
|
||||
changeRepo, ok := s.tasks.(workflowChangeAdmissionRepository)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("workflow change admission repository unavailable for step %s", targetStep.ID)
|
||||
}
|
||||
return changeRepo.UpdateTaskWithWorkflowChangeAdmissionAndState(
|
||||
ctx, task, oldStepID, targetStep.ID, targetStep.WIPLimit,
|
||||
admittedState, true, workflowChangeGuard(opts.WorkflowChange),
|
||||
)
|
||||
}
|
||||
admissionRepo, ok := s.tasks.(workflowMoveAdmissionRepository)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("workflow step admission repository unavailable for step %s", targetStep.ID)
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
settingsmodels "github.com/kandev/kandev/internal/agent/settings/models"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
"github.com/kandev/kandev/internal/task/repository/repoerrors"
|
||||
wfmodels "github.com/kandev/kandev/internal/workflow/models"
|
||||
)
|
||||
|
||||
const (
|
||||
WorkflowChangeErrorInvalid = "invalid_workflow_change"
|
||||
WorkflowChangeErrorAgentInvalid = "workflow_agent_unavailable"
|
||||
WorkflowChangeErrorAgentIncompat = "workflow_agent_incompatible"
|
||||
)
|
||||
|
||||
var ErrWorkflowChangeConflict = repoerrors.ErrWorkflowChangeConflict
|
||||
|
||||
type WorkflowChangeValidationError struct {
|
||||
Code string
|
||||
SourceProfileID string
|
||||
}
|
||||
|
||||
func (e *WorkflowChangeValidationError) Error() string {
|
||||
return "invalid workflow change"
|
||||
}
|
||||
|
||||
func (e *WorkflowChangeValidationError) Unwrap() error {
|
||||
return ErrInvalidWorkflowChange
|
||||
}
|
||||
|
||||
var ErrInvalidWorkflowChange = errors.New("invalid workflow change")
|
||||
|
||||
func invalidWorkflowChange(code, sourceProfileID string) error {
|
||||
return &WorkflowChangeValidationError{Code: code, SourceProfileID: sourceProfileID}
|
||||
}
|
||||
|
||||
// ValidateWorkflowChange checks a read-only change form submission and returns
|
||||
// its normalized destination override record. MoveTaskWithOptions repeats this
|
||||
// validation and adds the transactional source guard before committing.
|
||||
func (s *Service) ValidateWorkflowChange(
|
||||
ctx context.Context,
|
||||
taskID, workflowID, workflowStepID string,
|
||||
request *models.WorkflowChangeRequest,
|
||||
) (*models.WorkflowAgentOverrides, error) {
|
||||
task, err := s.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.prepareWorkflowChange(ctx, task, workflowID, workflowStepID, request)
|
||||
}
|
||||
|
||||
func (s *Service) prepareWorkflowChange(
|
||||
ctx context.Context,
|
||||
task *models.Task,
|
||||
workflowID, workflowStepID string,
|
||||
request *models.WorkflowChangeRequest,
|
||||
) (*models.WorkflowAgentOverrides, error) {
|
||||
if err := validateWorkflowChangeSource(task, workflowID, workflowStepID, request); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Project-backed tasks remain Office-owned if the IsFromOffice projection is missing.
|
||||
if task.ArchivedAt != nil || task.IsEphemeral || task.IsFromOffice || task.ProjectID != "" {
|
||||
return nil, invalidWorkflowChange(WorkflowChangeErrorInvalid, "")
|
||||
}
|
||||
workflow, err := s.workflows.GetWorkflow(ctx, workflowID)
|
||||
if err != nil {
|
||||
return nil, invalidWorkflowChange(WorkflowChangeErrorInvalid, "")
|
||||
}
|
||||
if workflow == nil || workflow.Hidden || workflow.WorkspaceID != task.WorkspaceID {
|
||||
return nil, invalidWorkflowChange(WorkflowChangeErrorInvalid, "")
|
||||
}
|
||||
lister, ok := s.workflowStepGetter.(workflowStepLister)
|
||||
if !ok || lister == nil {
|
||||
return nil, invalidWorkflowChange(WorkflowChangeErrorInvalid, "")
|
||||
}
|
||||
steps, err := lister.ListStepsByWorkflow(ctx, workflowID)
|
||||
if err != nil {
|
||||
return nil, invalidWorkflowChange(WorkflowChangeErrorInvalid, "")
|
||||
}
|
||||
if !containsWorkflowChangeStep(steps, workflowStepID) {
|
||||
return nil, invalidWorkflowChange(WorkflowChangeErrorInvalid, "")
|
||||
}
|
||||
return s.normalizeWorkflowChangeAgentOverrides(ctx, task, workflowID, steps, request.AgentOverrides)
|
||||
}
|
||||
|
||||
func validateWorkflowChangeSource(
|
||||
task *models.Task,
|
||||
workflowID, workflowStepID string,
|
||||
request *models.WorkflowChangeRequest,
|
||||
) error {
|
||||
if request == nil || task == nil || workflowID == "" || workflowStepID == "" ||
|
||||
strings.TrimSpace(request.ExpectedWorkflowID) == "" ||
|
||||
strings.TrimSpace(request.ExpectedStepID) == "" || request.ExpectedUpdatedAt.IsZero() || request.AgentOverrides == nil {
|
||||
return invalidWorkflowChange(WorkflowChangeErrorInvalid, "")
|
||||
}
|
||||
if task.WorkflowID != request.ExpectedWorkflowID || task.WorkflowStepID != request.ExpectedStepID ||
|
||||
!task.UpdatedAt.Equal(request.ExpectedUpdatedAt) {
|
||||
return ErrWorkflowChangeConflict
|
||||
}
|
||||
if workflowID == task.WorkflowID {
|
||||
return invalidWorkflowChange(WorkflowChangeErrorInvalid, "")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func containsWorkflowChangeStep(steps []*wfmodels.WorkflowStep, stepID string) bool {
|
||||
for _, step := range steps {
|
||||
if step != nil && step.ID == stepID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Service) normalizeWorkflowChangeAgentOverrides(
|
||||
ctx context.Context,
|
||||
task *models.Task,
|
||||
workflowID string,
|
||||
steps []*wfmodels.WorkflowStep,
|
||||
requested map[string]string,
|
||||
) (*models.WorkflowAgentOverrides, error) {
|
||||
fixedSteps := fixedWorkflowAgentOverrideSteps(workflowID, steps)
|
||||
requestedBySource, err := validateWorkflowChangeSources(requested, fixedSteps)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(fixedSteps) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
executor, executorProfile, err := s.workflowChangeExecutor(ctx, task)
|
||||
if err != nil {
|
||||
return nil, invalidWorkflowChange(WorkflowChangeErrorInvalid, "")
|
||||
}
|
||||
bindings := make([]models.WorkflowAgentOverrideBinding, 0, len(requested))
|
||||
validatedProfiles := make(map[string]struct{})
|
||||
sources := sortedWorkflowChangeSources(fixedSteps)
|
||||
for _, sourceID := range sources {
|
||||
replacementID := sourceID
|
||||
if requestedID, ok := requestedBySource[sourceID]; ok {
|
||||
replacementID = requestedID
|
||||
}
|
||||
profile, err := s.loadWorkflowChangeProfile(ctx, task.WorkspaceID, replacementID, sourceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, ok := validatedProfiles[replacementID]; !ok {
|
||||
if s.agentProfileExecutorValidator == nil || s.agentProfileExecutorValidator.ValidateAgentProfileForExecutor(ctx, profile, executor, executorProfile) != nil {
|
||||
return nil, invalidWorkflowChange(WorkflowChangeErrorAgentIncompat, sourceID)
|
||||
}
|
||||
validatedProfiles[replacementID] = struct{}{}
|
||||
}
|
||||
if replacementID == sourceID {
|
||||
continue
|
||||
}
|
||||
for _, step := range fixedSteps[sourceID] {
|
||||
bindings = append(bindings, models.WorkflowAgentOverrideBinding{
|
||||
StepID: step.ID, SourceProfileID: sourceID, ReplacementProfileID: replacementID,
|
||||
})
|
||||
}
|
||||
}
|
||||
overrides, err := models.NewWorkflowAgentOverrides(workflowID, bindings)
|
||||
if err != nil {
|
||||
return nil, invalidWorkflowChange(WorkflowChangeErrorInvalid, "")
|
||||
}
|
||||
return overrides, nil
|
||||
}
|
||||
|
||||
func validateWorkflowChangeSources(
|
||||
requested map[string]string,
|
||||
fixedSteps map[string][]*wfmodels.WorkflowStep,
|
||||
) (map[string]string, error) {
|
||||
requestedBySource := make(map[string]string, len(requested))
|
||||
for rawSource, rawReplacement := range requested {
|
||||
source := strings.TrimSpace(rawSource)
|
||||
replacement := strings.TrimSpace(rawReplacement)
|
||||
if source == "" || replacement == "" || len(fixedSteps[source]) == 0 {
|
||||
return nil, invalidWorkflowChange(WorkflowChangeErrorAgentInvalid, source)
|
||||
}
|
||||
if _, exists := requestedBySource[source]; exists {
|
||||
return nil, invalidWorkflowChange(WorkflowChangeErrorAgentInvalid, source)
|
||||
}
|
||||
requestedBySource[source] = replacement
|
||||
}
|
||||
return requestedBySource, nil
|
||||
}
|
||||
|
||||
func sortedWorkflowChangeSources(fixedSteps map[string][]*wfmodels.WorkflowStep) []string {
|
||||
sources := make([]string, 0, len(fixedSteps))
|
||||
for source := range fixedSteps {
|
||||
sources = append(sources, source)
|
||||
}
|
||||
sort.Strings(sources)
|
||||
return sources
|
||||
}
|
||||
|
||||
func (s *Service) loadWorkflowChangeProfile(
|
||||
ctx context.Context,
|
||||
workspaceID, profileID, sourceID string,
|
||||
) (*settingsmodels.AgentProfile, error) {
|
||||
if s.agentProfiles == nil {
|
||||
return nil, invalidWorkflowChange(WorkflowChangeErrorAgentInvalid, sourceID)
|
||||
}
|
||||
profile, err := s.agentProfiles.GetAgentProfile(ctx, profileID)
|
||||
if err != nil || profile == nil || profile.DeletedAt != nil || !profile.Enabled ||
|
||||
(profile.WorkspaceID != "" && profile.WorkspaceID != workspaceID) {
|
||||
return nil, invalidWorkflowChange(WorkflowChangeErrorAgentInvalid, sourceID)
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func (s *Service) workflowChangeExecutor(
|
||||
ctx context.Context,
|
||||
task *models.Task,
|
||||
) (*models.Executor, *models.ExecutorProfile, error) {
|
||||
executorID := ""
|
||||
executorProfileID := ""
|
||||
if s.sessions != nil {
|
||||
if session, _ := s.sessions.GetPrimarySessionByTaskID(ctx, task.ID); session != nil {
|
||||
executorID = session.ExecutorID
|
||||
executorProfileID = session.ExecutorProfileID
|
||||
} else if session, _ := s.sessions.GetActiveTaskSessionByTaskID(ctx, task.ID); session != nil {
|
||||
executorID = session.ExecutorID
|
||||
executorProfileID = session.ExecutorProfileID
|
||||
}
|
||||
}
|
||||
if task.Metadata != nil {
|
||||
executorID = firstNonEmptyWorkflowAgentOverrideValue(executorID, models.StringFromAny(task.Metadata[models.MetaKeyExecutorID]))
|
||||
executorProfileID = firstNonEmptyWorkflowAgentOverrideValue(executorProfileID, models.StringFromAny(task.Metadata[models.MetaKeyExecutorProfileID]))
|
||||
}
|
||||
if s.executors == nil {
|
||||
return nil, nil, fmt.Errorf("executor repository unavailable")
|
||||
}
|
||||
executorProfile, executorID, err := s.resolveWorkflowAgentOverrideExecutorProfile(ctx, executorID, executorProfileID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if executorID == "" {
|
||||
executorID, err = s.resolveWorkflowAgentOverrideWorkspaceExecutor(ctx, task.WorkspaceID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
executor, err := s.executors.GetExecutor(ctx, executorID)
|
||||
if err != nil || executor == nil || executor.DeletedAt != nil || executor.Status == models.ExecutorStatusDisabled {
|
||||
return nil, nil, fmt.Errorf("task executor is unavailable")
|
||||
}
|
||||
return executor, executorProfile, nil
|
||||
}
|
||||
|
||||
func workflowChangeGuard(request *models.WorkflowChangeRequest) *models.WorkflowChangeSource {
|
||||
if request == nil {
|
||||
return nil
|
||||
}
|
||||
return &models.WorkflowChangeSource{
|
||||
WorkflowID: request.ExpectedWorkflowID,
|
||||
StepID: request.ExpectedStepID,
|
||||
UpdatedAt: request.ExpectedUpdatedAt.UTC(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
} from "@kandev/ui/context-menu";
|
||||
import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
} from "@kandev/ui/dropdown-menu";
|
||||
import type { KanbanCardMenuEntry } from "./kanban-card-menu-items";
|
||||
|
||||
function ContextEntry({ entry }: { entry: KanbanCardMenuEntry }) {
|
||||
if (entry.kind === "separator") return <ContextMenuSeparator />;
|
||||
if (entry.kind === "submenu") {
|
||||
return (
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger data-testid={entry.testId} disabled={entry.disabled}>
|
||||
{entry.icon}
|
||||
{entry.label}
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent className={entry.className}>
|
||||
{entry.children.map((child) => (
|
||||
<ContextEntry key={child.key} entry={child} />
|
||||
))}
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextMenuItem
|
||||
data-testid={entry.testId}
|
||||
disabled={entry.disabled}
|
||||
className={entry.destructive ? "text-destructive focus:text-destructive" : undefined}
|
||||
// Portal events bubble through the React tree, so stop them before the card's click handler.
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onSelect={() => {
|
||||
if (!entry.disabled) entry.onSelect?.();
|
||||
}}
|
||||
>
|
||||
{entry.icon}
|
||||
{entry.leading}
|
||||
{entry.label}
|
||||
{entry.trailing}
|
||||
</ContextMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownEntry({ entry }: { entry: KanbanCardMenuEntry }) {
|
||||
if (entry.kind === "separator") return <DropdownMenuSeparator />;
|
||||
if (entry.kind === "submenu") {
|
||||
return (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger
|
||||
data-testid={entry.testId}
|
||||
disabled={entry.disabled}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{entry.icon}
|
||||
{entry.label}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent className={entry.className}>
|
||||
{entry.children.map((child) => (
|
||||
<DropdownEntry key={child.key} entry={child} />
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
data-testid={entry.testId}
|
||||
disabled={entry.disabled}
|
||||
className={entry.destructive ? "text-destructive focus:text-destructive" : undefined}
|
||||
// Portal events bubble through the React tree, including to dnd-kit listeners.
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onSelect={(event) => {
|
||||
event.stopPropagation();
|
||||
if (!entry.disabled) entry.onSelect?.();
|
||||
}}
|
||||
>
|
||||
{entry.icon}
|
||||
{entry.leading}
|
||||
{entry.label}
|
||||
{entry.trailing}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
export function KanbanCardContextMenuItems({ entries }: { entries: KanbanCardMenuEntry[] }) {
|
||||
return (
|
||||
<>
|
||||
{entries.map((entry) => (
|
||||
<ContextEntry key={entry.key} entry={entry} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function KanbanCardDropdownMenuItems({ entries }: { entries: KanbanCardMenuEntry[] }) {
|
||||
return (
|
||||
<>
|
||||
{entries.map((entry) => (
|
||||
<DropdownEntry key={entry.key} entry={entry} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { pluginRegistry } from "@/lib/plugins/registry";
|
||||
const PLUGIN_ID = "kandev-plugin-tags";
|
||||
const ACTION_ID = "quick-tag";
|
||||
const ACTION_LABEL = "Quick tag";
|
||||
const CHANGE_WORKFLOW_ENTRY_KEY = "change-workflow";
|
||||
const WORKFLOW_ONE = "wf-1";
|
||||
const WORKFLOW_TWO = "wf-2";
|
||||
|
||||
@@ -52,20 +53,20 @@ describe("buildKanbanCardMenuEntries — 'primary' group plugin actions", () =>
|
||||
|
||||
const entries = buildKanbanCardMenuEntries({
|
||||
...movementArgs(),
|
||||
onSendToWorkflow: vi.fn(),
|
||||
onChangeWorkflow: vi.fn(),
|
||||
onLinkPullRequest: vi.fn(),
|
||||
});
|
||||
|
||||
const keys = entryKeys(entries);
|
||||
const sendToIndex = keys.indexOf("send-to-workflow");
|
||||
const changeWorkflowIndex = keys.indexOf(CHANGE_WORKFLOW_ENTRY_KEY);
|
||||
const primaryIndex = keys.indexOf(`plugin-primary-${PLUGIN_ID}:${ACTION_ID}`);
|
||||
const linkIndex = keys.indexOf("link");
|
||||
|
||||
expect(sendToIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(changeWorkflowIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(primaryIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(linkIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(linkIndex).toBeLessThan(sendToIndex);
|
||||
expect(sendToIndex).toBeLessThan(primaryIndex);
|
||||
expect(linkIndex).toBeLessThan(changeWorkflowIndex);
|
||||
expect(changeWorkflowIndex).toBeLessThan(primaryIndex);
|
||||
|
||||
const archiveIndex = keys.indexOf("archive");
|
||||
expect(primaryIndex).toBeLessThan(archiveIndex);
|
||||
@@ -91,7 +92,7 @@ describe("buildKanbanCardMenuEntries — 'primary' group plugin actions", () =>
|
||||
parentTaskId: "parent-1",
|
||||
onDetach: vi.fn(),
|
||||
onMoveToStep: vi.fn(),
|
||||
onSendToWorkflow: vi.fn(),
|
||||
onChangeWorkflow: vi.fn(),
|
||||
onArchive: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
});
|
||||
@@ -105,7 +106,7 @@ describe("buildKanbanCardMenuEntries — 'primary' group plugin actions", () =>
|
||||
"detach",
|
||||
"move-separator",
|
||||
"move-to",
|
||||
"send-to-workflow",
|
||||
CHANGE_WORKFLOW_ENTRY_KEY,
|
||||
"plugins-separator",
|
||||
`plugin-primary-${PLUGIN_ID}:${ACTION_ID}`,
|
||||
"remove-separator",
|
||||
@@ -114,6 +115,23 @@ describe("buildKanbanCardMenuEntries — 'primary' group plugin actions", () =>
|
||||
]);
|
||||
});
|
||||
|
||||
it("offers one single-task Change workflow action instead of a workflow submenu", () => {
|
||||
const onChangeWorkflow = vi.fn();
|
||||
const entries = buildKanbanCardMenuEntries({
|
||||
...movementArgs(),
|
||||
currentStepId: "s1",
|
||||
onMoveToStep: vi.fn(),
|
||||
onChangeWorkflow,
|
||||
});
|
||||
|
||||
expect(entryKeys(entries)).toContain(CHANGE_WORKFLOW_ENTRY_KEY);
|
||||
expect(entryKeys(entries)).not.toContain("send-to-workflow");
|
||||
const entry = entries.find((candidate) => candidate.key === CHANGE_WORKFLOW_ENTRY_KEY);
|
||||
expect(entry?.kind).toBe("item");
|
||||
if (entry?.kind === "item") entry.onSelect?.();
|
||||
expect(onChangeWorkflow).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not add a 'primary' entry when visible(context) returns false", () => {
|
||||
registerPrimaryAction(() => false);
|
||||
|
||||
|
||||
@@ -323,20 +323,20 @@ describe("buildKanbanCardMenuEntries — 'primary' group plugin actions", () =>
|
||||
],
|
||||
"wf-2": [{ id: "s3", title: "Step 3" }],
|
||||
},
|
||||
onSendToWorkflow: vi.fn(),
|
||||
onChangeWorkflow: vi.fn(),
|
||||
onLinkPullRequest: vi.fn(),
|
||||
onArchive: vi.fn(),
|
||||
});
|
||||
|
||||
const keys = entryKeys(entries);
|
||||
const sendToIndex = keys.indexOf("send-to-workflow");
|
||||
const changeWorkflowIndex = keys.indexOf("change-workflow");
|
||||
const primaryIndex = keys.indexOf(`plugin-primary-${PLUGIN_ID}:quick-tag`);
|
||||
const archiveIndex = keys.indexOf("archive");
|
||||
|
||||
expect(sendToIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(changeWorkflowIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(primaryIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(archiveIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(sendToIndex).toBeLessThan(primaryIndex);
|
||||
expect(changeWorkflowIndex).toBeLessThan(primaryIndex);
|
||||
expect(primaryIndex).toBeLessThan(archiveIndex);
|
||||
|
||||
const primaryEntry = entries[primaryIndex];
|
||||
@@ -666,7 +666,7 @@ describe("buildKanbanCardMenuEntries — move-only disabled state", () => {
|
||||
onEdit: vi.fn(),
|
||||
onSelectPriority: vi.fn(),
|
||||
onMoveToStep: vi.fn(),
|
||||
onSendToWorkflow: vi.fn(),
|
||||
onChangeWorkflow: vi.fn(),
|
||||
onLinkPullRequest: vi.fn(),
|
||||
onArchive: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
@@ -682,7 +682,7 @@ describe("buildKanbanCardMenuEntries — move-only disabled state", () => {
|
||||
expect(disabled("edit")).toBe(false);
|
||||
expect(disabled("priority")).toBe(false);
|
||||
expect(disabled("move-to")).toBe(true);
|
||||
expect(disabled("send-to-workflow")).toBe(true);
|
||||
expect(disabled("change-workflow")).toBe(true);
|
||||
expect(disabled("link")).toBe(false);
|
||||
expect(disabled("archive")).toBe(false);
|
||||
expect(disabled("delete")).toBe(false);
|
||||
|
||||
@@ -10,21 +10,6 @@ import {
|
||||
IconTrash,
|
||||
IconUnlink,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
} from "@kandev/ui/context-menu";
|
||||
import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
} from "@kandev/ui/dropdown-menu";
|
||||
import {
|
||||
stepHasAutoStart,
|
||||
type TaskMoveStep,
|
||||
@@ -43,6 +28,11 @@ import { buildEditMenuEntry } from "./kanban-card-edit-submenu";
|
||||
import { buildPrimaryPluginEntries } from "./plugins/task-menu-actions";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { buildSingleTaskWorkflowEntry } from "./kanban-card-single-workflow-entry";
|
||||
export {
|
||||
KanbanCardContextMenuItems,
|
||||
KanbanCardDropdownMenuItems,
|
||||
} from "./kanban-card-menu-entry-renderers";
|
||||
export { useKanbanCardMoveTargets } from "@/hooks/use-kanban-card-move-targets";
|
||||
|
||||
type ItemEntry = {
|
||||
@@ -113,7 +103,7 @@ export type BuildKanbanCardMenuEntriesArgs = {
|
||||
currentStepId?: string | null;
|
||||
workflows: TaskMoveWorkflow[];
|
||||
stepsByWorkflowId: Record<string, TaskMoveStep[]>;
|
||||
/** Disables only move and send-to-workflow entries while a row-local move runs. */
|
||||
/** Disables only move and change-workflow entries while a row-local move runs. */
|
||||
moveDisabled?: boolean;
|
||||
disabled?: boolean;
|
||||
isDeleting?: boolean;
|
||||
@@ -135,7 +125,9 @@ export type BuildKanbanCardMenuEntriesArgs = {
|
||||
onLinkSentryIssue?: () => void;
|
||||
pluginLinkActions?: KanbanPluginLinkAction[];
|
||||
onMoveToStep?: (stepId: string) => void;
|
||||
onChangeWorkflow?: () => void;
|
||||
onSendToWorkflow?: (workflowId: string, stepId: string) => void;
|
||||
isBulkSelection?: boolean;
|
||||
/** Defaults to an empty-id context (no visible plugin actions match it in practice). */
|
||||
pluginMenuContext?: PluginTaskMenuContext;
|
||||
/**
|
||||
@@ -372,10 +364,10 @@ function buildSendToWorkflowSubmenu({
|
||||
if (!onSendToWorkflow || !currentWorkflowId || targets.length === 0) return null;
|
||||
return {
|
||||
kind: "submenu",
|
||||
key: "send-to-workflow",
|
||||
testId: "task-context-send-to-workflow",
|
||||
key: "change-workflow-selection",
|
||||
testId: "task-context-change-workflow-selection",
|
||||
icon: <IconLogicBuffer className="mr-2 h-4 w-4" />,
|
||||
label: t("kanban:sendToWorkflow"),
|
||||
label: t("task:changeWorkflowForSelectedTasks"),
|
||||
disabled,
|
||||
className: "w-56",
|
||||
children: targets.map((workflow) =>
|
||||
@@ -389,6 +381,72 @@ function buildSendToWorkflowSubmenu({
|
||||
};
|
||||
}
|
||||
|
||||
function buildWorkflowMenuEntry({
|
||||
currentWorkflowId,
|
||||
workflows,
|
||||
stepsByWorkflowId,
|
||||
disabled,
|
||||
onChangeWorkflow,
|
||||
onSendToWorkflow,
|
||||
isBulkSelection,
|
||||
}: Pick<
|
||||
BuildKanbanCardMenuEntriesArgs,
|
||||
| "currentWorkflowId"
|
||||
| "workflows"
|
||||
| "stepsByWorkflowId"
|
||||
| "onChangeWorkflow"
|
||||
| "onSendToWorkflow"
|
||||
| "isBulkSelection"
|
||||
> & { disabled: boolean }): KanbanCardMenuEntry | null {
|
||||
const visibleWorkflows = workflows.filter((workflow) => !workflow.hidden);
|
||||
if (isBulkSelection) {
|
||||
return buildSendToWorkflowSubmenu({
|
||||
currentWorkflowId,
|
||||
workflows: visibleWorkflows,
|
||||
stepsByWorkflowId,
|
||||
disabled,
|
||||
onSendToWorkflow,
|
||||
});
|
||||
}
|
||||
return buildSingleTaskWorkflowEntry({
|
||||
currentWorkflowId,
|
||||
hasOtherWorkflows: visibleWorkflows.some((workflow) => workflow.id !== currentWorkflowId),
|
||||
disabled,
|
||||
onChangeWorkflow,
|
||||
});
|
||||
}
|
||||
|
||||
function buildCurrentWorkflowMoveEntry(
|
||||
currentSteps: TaskMoveStep[],
|
||||
currentStepId: string | null | undefined,
|
||||
disabled: boolean,
|
||||
onMoveToStep: ((stepId: string) => void) | undefined,
|
||||
) {
|
||||
return buildMoveToCurrentWorkflowSubmenu({
|
||||
steps: currentSteps,
|
||||
currentStepId,
|
||||
disabled,
|
||||
onMoveToStep,
|
||||
});
|
||||
}
|
||||
|
||||
// A flat Edit surface cannot reuse plugin entries that include the card-only Edit group.
|
||||
function resolveCardPluginEntries({
|
||||
pluginEntries,
|
||||
forceFlatEdit,
|
||||
disabled,
|
||||
onEdit,
|
||||
pluginMenuContext,
|
||||
}: Pick<
|
||||
BuildKanbanCardMenuEntriesArgs,
|
||||
"pluginEntries" | "forceFlatEdit" | "onEdit" | "pluginMenuContext"
|
||||
> & {
|
||||
disabled: boolean;
|
||||
}): CardPluginEntries {
|
||||
if (pluginEntries && !forceFlatEdit) return pluginEntries;
|
||||
return buildCardPluginEntries({ disabled, onEdit, forceFlatEdit, pluginMenuContext });
|
||||
}
|
||||
|
||||
export function buildKanbanCardMenuEntries({
|
||||
currentWorkflowId,
|
||||
currentStepId,
|
||||
@@ -414,12 +472,13 @@ export function buildKanbanCardMenuEntries({
|
||||
onLinkSentryIssue,
|
||||
pluginLinkActions,
|
||||
onMoveToStep,
|
||||
onChangeWorkflow,
|
||||
onSendToWorkflow,
|
||||
isBulkSelection,
|
||||
pluginMenuContext,
|
||||
pluginEntries,
|
||||
forceFlatEdit,
|
||||
}: BuildKanbanCardMenuEntriesArgs): KanbanCardMenuEntry[] {
|
||||
const visibleWorkflows = workflows.filter((workflow) => !workflow.hidden);
|
||||
const currentSteps = currentWorkflowId ? (stepsByWorkflowId[currentWorkflowId] ?? []) : [];
|
||||
const isProcessing = Boolean(disabled || isDeleting || isArchiving || isDetaching);
|
||||
const priorityEntry = buildPriorityMenuEntry({
|
||||
@@ -428,30 +487,29 @@ export function buildKanbanCardMenuEntries({
|
||||
onSelectPriority,
|
||||
});
|
||||
|
||||
const moveToEntry = buildMoveToCurrentWorkflowSubmenu({
|
||||
steps: currentSteps,
|
||||
const moveToEntry = buildCurrentWorkflowMoveEntry(
|
||||
currentSteps,
|
||||
currentStepId,
|
||||
disabled: moveDisabled || isProcessing,
|
||||
Boolean(moveDisabled || isProcessing),
|
||||
onMoveToStep,
|
||||
});
|
||||
const sendToEntry = buildSendToWorkflowSubmenu({
|
||||
);
|
||||
const sendToEntry = buildWorkflowMenuEntry({
|
||||
currentWorkflowId,
|
||||
workflows: visibleWorkflows,
|
||||
workflows,
|
||||
stepsByWorkflowId,
|
||||
disabled: moveDisabled || isProcessing,
|
||||
disabled: Boolean(moveDisabled || isProcessing),
|
||||
onChangeWorkflow,
|
||||
onSendToWorkflow,
|
||||
isBulkSelection,
|
||||
});
|
||||
|
||||
// `forceFlatEdit` outranks a prebuilt bundle: those surfaces must never show the submenu form.
|
||||
const pluginContributions =
|
||||
pluginEntries && !forceFlatEdit
|
||||
? pluginEntries
|
||||
: buildCardPluginEntries({
|
||||
disabled: isProcessing,
|
||||
onEdit,
|
||||
forceFlatEdit,
|
||||
pluginMenuContext,
|
||||
});
|
||||
const pluginContributions = resolveCardPluginEntries({
|
||||
pluginEntries,
|
||||
forceFlatEdit,
|
||||
disabled: isProcessing,
|
||||
onEdit,
|
||||
pluginMenuContext,
|
||||
});
|
||||
|
||||
const linkEntry = buildLinkSubmenu({
|
||||
disabled: isProcessing,
|
||||
@@ -565,106 +623,3 @@ function buildDetachEntry({
|
||||
onSelect: onDetach,
|
||||
};
|
||||
}
|
||||
|
||||
function ContextEntry({ entry }: { entry: KanbanCardMenuEntry }) {
|
||||
if (entry.kind === "separator") return <ContextMenuSeparator />;
|
||||
if (entry.kind === "submenu") {
|
||||
return (
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger data-testid={entry.testId} disabled={entry.disabled}>
|
||||
{entry.icon}
|
||||
{entry.label}
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent className={entry.className}>
|
||||
{entry.children.map((child) => (
|
||||
<ContextEntry key={child.key} entry={child} />
|
||||
))}
|
||||
</ContextMenuSubContent>
|
||||
</ContextMenuSub>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextMenuItem
|
||||
data-testid={entry.testId}
|
||||
disabled={entry.disabled}
|
||||
className={entry.destructive ? "text-destructive focus:text-destructive" : undefined}
|
||||
// React events bubble through the React tree even from a portal — stop here so the card's onClick doesn't navigate.
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onSelect={() => {
|
||||
if (!entry.disabled) entry.onSelect?.();
|
||||
}}
|
||||
>
|
||||
{entry.icon}
|
||||
{entry.leading}
|
||||
{entry.label}
|
||||
{entry.trailing}
|
||||
</ContextMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownEntry({ entry }: { entry: KanbanCardMenuEntry }) {
|
||||
if (entry.kind === "separator") return <DropdownMenuSeparator />;
|
||||
if (entry.kind === "submenu") {
|
||||
return (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger
|
||||
data-testid={entry.testId}
|
||||
disabled={entry.disabled}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{entry.icon}
|
||||
{entry.label}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent className={entry.className}>
|
||||
{entry.children.map((child) => (
|
||||
<DropdownEntry key={child.key} entry={child} />
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
data-testid={entry.testId}
|
||||
disabled={entry.disabled}
|
||||
className={entry.destructive ? "text-destructive focus:text-destructive" : undefined}
|
||||
// React events bubble through the React tree even from a portal - stop here so click/pointer don't reach the parent Card's onClick or dnd-kit listeners.
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onSelect={(event) => {
|
||||
event.stopPropagation();
|
||||
if (!entry.disabled) entry.onSelect?.();
|
||||
}}
|
||||
>
|
||||
{entry.icon}
|
||||
{entry.leading}
|
||||
{entry.label}
|
||||
{entry.trailing}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
export function KanbanCardContextMenuItems({ entries }: { entries: KanbanCardMenuEntry[] }) {
|
||||
return (
|
||||
<>
|
||||
{entries.map((entry) => (
|
||||
<ContextEntry key={entry.key} entry={entry} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function KanbanCardDropdownMenuItems({ entries }: { entries: KanbanCardMenuEntry[] }) {
|
||||
return (
|
||||
<>
|
||||
{entries.map((entry) => (
|
||||
<DropdownEntry key={entry.key} entry={entry} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { useTaskPluginLinkActions } from "@/components/task/task-session-sidebar-link-actions";
|
||||
import { cleanupSharesParentWorkspace } from "@/components/task/task-cleanup-summary";
|
||||
import { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog";
|
||||
import { ChangeWorkflowDialog } from "@/components/task/change-workflow-dialog";
|
||||
import {
|
||||
TaskExternalLinkDialog,
|
||||
type ExternalLinkProvider,
|
||||
@@ -36,7 +37,7 @@ export interface TaskCardMenuParams {
|
||||
steps?: WorkflowStep[];
|
||||
isDeleting?: boolean;
|
||||
isArchiving?: boolean;
|
||||
/** Row-local in-flight move guard: disables move/send-to-workflow entries. */
|
||||
/** Row-local in-flight move guard: disables move/change-workflow entries. */
|
||||
isMoving?: boolean;
|
||||
isSelected?: boolean;
|
||||
selectedIds?: Set<string>;
|
||||
@@ -217,6 +218,9 @@ export function useKanbanCardMenus({
|
||||
onDelete: onDelete ? () => dialogs.setShowDeleteConfirm(true) : undefined,
|
||||
onDetach: task.parentTaskId && !actingOnMultiSelection ? requestDetachConfirmation : undefined,
|
||||
...buildLinkDialogHandlers(externalLinkAvailability, dialogs),
|
||||
onChangeWorkflow: () => {
|
||||
window.setTimeout(() => dialogs.setShowChangeWorkflow(true), 300);
|
||||
},
|
||||
pluginLinkActions,
|
||||
};
|
||||
|
||||
@@ -239,14 +243,15 @@ export function useKanbanCardMenus({
|
||||
dropdownMenuEntries: buildKanbanCardMenuEntries({
|
||||
...menuBase,
|
||||
onMoveToStep: moveMenu.moveToStepFromDropdown,
|
||||
onSendToWorkflow: moveMenu.sendTaskToWorkflow,
|
||||
pluginMenuContext,
|
||||
pluginEntries,
|
||||
}),
|
||||
contextMenuEntries: buildKanbanCardMenuEntries({
|
||||
...menuBase,
|
||||
onMoveToStep: moveMenu.moveSelectedToStep,
|
||||
onSendToWorkflow: moveMenu.sendSelectionToWorkflow,
|
||||
onChangeWorkflow: actingOnMultiSelection ? undefined : menuBase.onChangeWorkflow,
|
||||
onSendToWorkflow: actingOnMultiSelection ? moveMenu.sendSelectionToWorkflow : undefined,
|
||||
isBulkSelection: actingOnMultiSelection,
|
||||
pluginMenuContext,
|
||||
pluginEntries,
|
||||
}),
|
||||
@@ -279,6 +284,13 @@ export function KanbanCardDialogs({
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<ChangeWorkflowDialog
|
||||
open={menu.showChangeWorkflow}
|
||||
onOpenChange={menu.setShowChangeWorkflow}
|
||||
taskId={task.id}
|
||||
workspaceId={workspaceId}
|
||||
focusReturnRef={menu.detachFocusReturnRef}
|
||||
/>
|
||||
<TaskDeleteConfirmDialog
|
||||
open={menu.showDeleteConfirm}
|
||||
onOpenChange={menu.setShowDeleteConfirm}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { IconLogicBuffer } from "@tabler/icons-react";
|
||||
import { t } from "@/lib/i18n";
|
||||
import type { KanbanCardMenuEntry } from "./kanban-card-menu-items";
|
||||
|
||||
export function buildSingleTaskWorkflowEntry({
|
||||
currentWorkflowId,
|
||||
hasOtherWorkflows,
|
||||
disabled,
|
||||
onChangeWorkflow,
|
||||
}: {
|
||||
currentWorkflowId?: string | null;
|
||||
hasOtherWorkflows: boolean;
|
||||
disabled?: boolean;
|
||||
onChangeWorkflow?: () => void;
|
||||
}): KanbanCardMenuEntry | null {
|
||||
if (!currentWorkflowId || !hasOtherWorkflows || !onChangeWorkflow) return null;
|
||||
return {
|
||||
kind: "item",
|
||||
key: "change-workflow",
|
||||
testId: "task-context-change-workflow",
|
||||
icon: <IconLogicBuffer className="mr-2 h-4 w-4" />,
|
||||
label: t("task:changeWorkflow"),
|
||||
disabled,
|
||||
onSelect: onChangeWorkflow,
|
||||
};
|
||||
}
|
||||
@@ -4,10 +4,10 @@ import { pluginCommandChoices, useTaskMoveChoices } from "./task-command-choices
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
it("omits workflows without destinations while preserving both destination actions", () => {
|
||||
it("offers one task-scoped workflow-change command for every other workflow", () => {
|
||||
const openMoveOptions = vi.fn();
|
||||
const moveImmediately = vi.fn();
|
||||
const target = { id: "review", title: "Review", color: "bg-blue-500" };
|
||||
const onChangeWorkflow = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useTaskMoveChoices({
|
||||
task: { id: "task", title: "Task", workflowId: "current" },
|
||||
@@ -17,17 +17,21 @@ it("omits workflows without destinations while preserving both destination actio
|
||||
{ id: "missing", name: "Missing" },
|
||||
{ id: "target", name: "Target" },
|
||||
],
|
||||
stepsByWorkflowId: { empty: [], target: [target] },
|
||||
stepsByWorkflowId: { empty: [], target: [{ id: "review", title: "Review" }] },
|
||||
openMoveOptions,
|
||||
moveImmediately,
|
||||
onChangeWorkflow,
|
||||
}),
|
||||
);
|
||||
expect(result.current.workflows.map((item) => item.label)).toEqual(["Target"]);
|
||||
const destination = result.current.workflows[0].children![0];
|
||||
destination.action?.();
|
||||
destination.immediateAction?.();
|
||||
expect(openMoveOptions).toHaveBeenCalledWith({ ...target, workflow_id: "target" });
|
||||
expect(moveImmediately).toHaveBeenCalledWith({ ...target, workflow_id: "target" });
|
||||
expect(result.current.workflows).toHaveLength(1);
|
||||
expect(result.current.workflows[0]).toMatchObject({
|
||||
id: "task-change-workflow",
|
||||
label: "Change workflow...",
|
||||
});
|
||||
result.current.workflows[0].action?.();
|
||||
expect(onChangeWorkflow).toHaveBeenCalledOnce();
|
||||
expect(openMoveOptions).not.toHaveBeenCalled();
|
||||
expect(moveImmediately).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createElement } from "react";
|
||||
import { IconLogicBuffer } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { CommandItem } from "@/lib/commands/types";
|
||||
import { useSetTaskColor, useTaskColor } from "@/hooks/use-task-color";
|
||||
@@ -33,6 +34,7 @@ type ChoiceOptions = {
|
||||
linkHandlers: TaskLinkHandlers;
|
||||
openMoveOptions: (step: TaskMoveStep) => void;
|
||||
moveImmediately: (step: TaskMoveStep) => void;
|
||||
onChangeWorkflow?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -175,6 +177,7 @@ export function useTaskMoveChoices({
|
||||
stepsByWorkflowId,
|
||||
openMoveOptions,
|
||||
moveImmediately,
|
||||
onChangeWorkflow,
|
||||
}: Omit<ChoiceOptions, "linkHandlers">) {
|
||||
const { t } = useTranslation();
|
||||
const { currentSteps, targets } = taskMoveOptions(task.workflowId, workflows, stepsByWorkflowId);
|
||||
@@ -198,15 +201,16 @@ export function useTaskMoveChoices({
|
||||
const steps = currentSteps
|
||||
.filter((step) => step.id !== task.workflowStepId)
|
||||
.map((step) => choice(step, task.workflowId!, group));
|
||||
const workflowChoices: CommandItem[] = targets
|
||||
.filter((workflow) => (stepsByWorkflowId[workflow.id]?.length ?? 0) > 0)
|
||||
.map((workflow) => ({
|
||||
id: `task-workflow-${workflow.id}`,
|
||||
label: workflow.name,
|
||||
group: t("task:sendToWorkflow"),
|
||||
children: (stepsByWorkflowId[workflow.id] ?? []).map((step) =>
|
||||
choice(step, workflow.id, workflow.name),
|
||||
),
|
||||
}));
|
||||
const workflowChoices: CommandItem[] = targets.length
|
||||
? [
|
||||
{
|
||||
id: "task-change-workflow",
|
||||
label: t("task:changeWorkflow"),
|
||||
group,
|
||||
icon: <IconLogicBuffer className="size-3.5" />,
|
||||
action: onChangeWorkflow,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
return { steps, workflows: workflowChoices };
|
||||
}
|
||||
|
||||
@@ -77,7 +77,16 @@ export function buildSidebarTaskCommands(ctx: TaskCommandContext): CommandItem[]
|
||||
items.push(command("task-detach", t("task:detachFromParent"), IconUnlink, ctx.onDetach));
|
||||
if (available.move) {
|
||||
choices("task-move", t("task:moveTo"), IconArrowRight, ctx.steps);
|
||||
choices("task-send-workflow", t("task:sendToWorkflow"), IconLogicBuffer, ctx.workflows);
|
||||
if (ctx.workflows.length) {
|
||||
items.push(
|
||||
command(
|
||||
"task-change-workflow",
|
||||
t("task:changeWorkflow"),
|
||||
IconLogicBuffer,
|
||||
ctx.workflows[0]?.action,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
items.push(...ctx.plugins);
|
||||
items.push({
|
||||
|
||||
@@ -43,7 +43,7 @@ describe("sidebar task command parity", () => {
|
||||
"task-link",
|
||||
"task-detach",
|
||||
"task-move",
|
||||
"task-send-workflow",
|
||||
"task-change-workflow",
|
||||
PLUGIN_ACTION,
|
||||
"task-delete",
|
||||
]);
|
||||
@@ -94,4 +94,17 @@ describe("sidebar task command parity", () => {
|
||||
expect(buildSidebarTaskCommands(ctx).map((c) => c.id)).not.toContain(PLUGIN_ACTION);
|
||||
expect(buildSidebarTaskCommands(ctx).map((c) => c.id)).not.toContain("task-move");
|
||||
});
|
||||
|
||||
it("does not offer workflow moves for Office-owned tasks", () => {
|
||||
const commands = buildSidebarTaskCommands(
|
||||
context({
|
||||
task: {
|
||||
...context().task,
|
||||
isFromOffice: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(commands.map((command) => command.id)).not.toContain("task-move");
|
||||
expect(commands.map((command) => command.id)).not.toContain("task-change-workflow");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppStore, useAppStoreApi } from "./state-provider";
|
||||
import { useRegisterCommands } from "@/hooks/use-register-commands";
|
||||
@@ -13,6 +13,7 @@ import { buildSidebarTaskCommands } from "./task-command-items";
|
||||
import { useTaskCommandChoices } from "./task-command-choices";
|
||||
import type { TaskSwitcherItem } from "./task/task-switcher-types";
|
||||
import type { CommandItem } from "@/lib/commands/types";
|
||||
import { ChangeWorkflowDialog } from "@/components/task/change-workflow-dialog";
|
||||
|
||||
/** One task action host, shared by desktop and phone and independent of sessions. */
|
||||
export function TaskCommands() {
|
||||
@@ -43,6 +44,7 @@ function TaskCommandsForTask({ task, workspaceId, data }: TaskCommandsForTaskPro
|
||||
const prefs = useSidebarTaskPrefs();
|
||||
const linkHandlers = useSidebarTaskLinking(workspaceId, actions);
|
||||
const repositories = useAppStore((state) => state.repositories.itemsByWorkspaceId[workspaceId]);
|
||||
const [changeWorkflowOpen, setChangeWorkflowOpen] = useState(false);
|
||||
const move = useTaskMoveOptions({
|
||||
taskId: task.id,
|
||||
workflowId: task.workflowId,
|
||||
@@ -54,6 +56,7 @@ function TaskCommandsForTask({ task, workspaceId, data }: TaskCommandsForTaskPro
|
||||
stepsByWorkflowId: data.stepsByWorkflowId,
|
||||
linkHandlers,
|
||||
openMoveOptions: move.openMoveOptionsStep,
|
||||
onChangeWorkflow: () => window.setTimeout(() => setChangeWorkflowOpen(true), 300),
|
||||
moveImmediately: (step) => {
|
||||
void move.moveImmediately(step);
|
||||
},
|
||||
@@ -101,6 +104,12 @@ function TaskCommandsForTask({ task, workspaceId, data }: TaskCommandsForTaskPro
|
||||
return move.submitMoveOptions(options);
|
||||
}}
|
||||
/>
|
||||
<ChangeWorkflowDialog
|
||||
open={changeWorkflowOpen}
|
||||
onOpenChange={setChangeWorkflowOpen}
|
||||
taskId={task.id}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { useChangeWorkflow } from "@/hooks/domains/kanban/use-change-workflow";
|
||||
import { ChangeWorkflowDialog } from "./change-workflow-dialog";
|
||||
|
||||
const CURRENT_AGENT_LABEL = "Current agent";
|
||||
const CURRENT_PROFILE_ID = "profile-current";
|
||||
const SUBMIT_BUTTON_TEST_ID = "change-workflow-submit";
|
||||
|
||||
const { useChangeWorkflowMock, responsiveMock } = vi.hoisted(() => ({
|
||||
useChangeWorkflowMock: vi.fn(),
|
||||
responsiveMock: { isMobile: false, isFinePointer: true },
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/domains/kanban/use-change-workflow", () => ({
|
||||
useChangeWorkflow: useChangeWorkflowMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
|
||||
useResponsiveBreakpoint: () => responsiveMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/task/workflow-move-preview-footer", () => ({
|
||||
WorkflowMovePreviewFooter: () => <div data-testid="workflow-entry-preview" />,
|
||||
}));
|
||||
|
||||
type HookState = ReturnType<typeof useChangeWorkflow>;
|
||||
|
||||
function makeHookState(): HookState {
|
||||
const task = {
|
||||
id: "task-1",
|
||||
title: "Keep the thread",
|
||||
workflow_id: "source-workflow",
|
||||
workflow_step_id: "source-step",
|
||||
updated_at: "2026-09-22T12:00:00Z",
|
||||
workflow_agent_overrides: {
|
||||
workflow_id: "source-workflow",
|
||||
steps: [{ source_profile_id: "profile-old", replacement_profile_id: "profile-retired" }],
|
||||
},
|
||||
} as unknown as HookState["task"];
|
||||
const step = (
|
||||
id: string,
|
||||
name: string,
|
||||
sessionTarget?: { kind: "initial" } | { kind: "step"; step_id: string },
|
||||
) => ({
|
||||
id,
|
||||
name,
|
||||
position: id === "build" ? 0 : 1,
|
||||
color: "#abcdef",
|
||||
session_target: sessionTarget,
|
||||
});
|
||||
return {
|
||||
task,
|
||||
taskStatus: "success",
|
||||
taskError: null,
|
||||
workflows: [],
|
||||
workflowsStatus: "success",
|
||||
workflowsError: null,
|
||||
destinations: [{ id: "destination", name: "Product", workspace_id: "workspace-1" }],
|
||||
selectedWorkflowId: "destination",
|
||||
selectedWorkflow: { id: "destination", name: "Product", workspace_id: "workspace-1" },
|
||||
changeWorkflow: vi.fn(),
|
||||
snapshot: {
|
||||
workflow: { id: "destination", name: "Product" },
|
||||
steps: [
|
||||
step("build", "Build"),
|
||||
step("review", "Review", { kind: "step", step_id: "build" }),
|
||||
step("wrap-up", "Wrap-up", { kind: "initial" }),
|
||||
],
|
||||
tasks: [],
|
||||
} as unknown as HookState["snapshot"],
|
||||
snapshotStatus: "success",
|
||||
snapshotError: null,
|
||||
retrySnapshot: vi.fn(),
|
||||
profiles: [],
|
||||
profilesStatus: "success",
|
||||
profilesError: null,
|
||||
profileOptions: [
|
||||
{
|
||||
value: CURRENT_PROFILE_ID,
|
||||
label: CURRENT_AGENT_LABEL,
|
||||
renderLabel: () => CURRENT_AGENT_LABEL,
|
||||
},
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
sourceProfileId: CURRENT_PROFILE_ID,
|
||||
sourceLabel: CURRENT_AGENT_LABEL,
|
||||
sourceAgentName: "agent",
|
||||
stepIds: ["build", "review"],
|
||||
stepNames: ["Build", "Review"],
|
||||
replacementProfileId: "profile-retired",
|
||||
replacementLabel: "Retired agent",
|
||||
replacementAvailable: false,
|
||||
},
|
||||
],
|
||||
setOverride: vi.fn(),
|
||||
overrides: {},
|
||||
selectedStepId: "build",
|
||||
setSelectedStepId: vi.fn(),
|
||||
sourceWorkflowName: "Backlog",
|
||||
sourceStepName: "Todo",
|
||||
sourceChanged: false,
|
||||
uncertainResult: false,
|
||||
submitError: null,
|
||||
sourceProfileErrorId: undefined,
|
||||
isSubmitting: false,
|
||||
canSubmit: false,
|
||||
submit: vi.fn(),
|
||||
retryTask: vi.fn(),
|
||||
retryWorkflows: vi.fn(),
|
||||
retryProfiles: vi.fn(),
|
||||
workflowChange: {
|
||||
expected_workflow_id: "source-workflow",
|
||||
expected_step_id: "source-step",
|
||||
expected_updated_at: "2026-09-22T12:00:00Z",
|
||||
agent_overrides: {},
|
||||
},
|
||||
} as unknown as HookState;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
responsiveMock.isMobile = false;
|
||||
responsiveMock.isFinePointer = true;
|
||||
useChangeWorkflowMock.mockReset();
|
||||
useChangeWorkflowMock.mockReturnValue(makeHookState());
|
||||
});
|
||||
|
||||
describe("ChangeWorkflowDialog", () => {
|
||||
it("shows the destination form, task-scoped agent mapping, and conversation relationships", () => {
|
||||
render(
|
||||
<ChangeWorkflowDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
taskId="task-1"
|
||||
workspaceId="workspace-1"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("dialog", { name: "Change workflow..." })).toBeTruthy();
|
||||
expect(screen.getByText("Backlog · Todo")).toBeTruthy();
|
||||
expect(screen.getByTestId("change-workflow-destination")).toBeTruthy();
|
||||
expect(screen.getByTestId("change-workflow-step")).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Changing workflows replaces this task's existing workflow agent overrides.",
|
||||
),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText("Review uses the conversation from Build.")).toBeTruthy();
|
||||
expect(screen.getByText("Wrap-up uses the initial conversation.")).toBeTruthy();
|
||||
expect(screen.getByTestId("workflow-entry-preview")).toBeTruthy();
|
||||
expect(screen.getByRole("alert").textContent).toContain("This profile is unavailable");
|
||||
expect((screen.getByTestId(SUBMIT_BUTTON_TEST_ID) as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("uses touch sizing on coarse-pointer tablets and always discloses override replacement", () => {
|
||||
responsiveMock.isFinePointer = false;
|
||||
const state = makeHookState();
|
||||
state.task = { ...state.task!, workflow_agent_overrides: undefined };
|
||||
useChangeWorkflowMock.mockReturnValue(state);
|
||||
render(
|
||||
<ChangeWorkflowDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
taskId="task-1"
|
||||
workspaceId="workspace-1"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("dialog", { name: "Change workflow..." })).toBeTruthy();
|
||||
expect(screen.queryByTestId("change-workflow-drawer")).toBeNull();
|
||||
expect(screen.getByTestId("change-workflow-close").className).toContain("size-11");
|
||||
expect(screen.getByTestId(SUBMIT_BUTTON_TEST_ID).className).toContain("min-h-12");
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Changing workflows replaces this task's existing workflow agent overrides.",
|
||||
),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("resets a replacement to the workflow profile and returns cancel to the caller", () => {
|
||||
const state = makeHookState();
|
||||
useChangeWorkflowMock.mockReturnValue(state);
|
||||
const onOpenChange = vi.fn();
|
||||
render(
|
||||
<ChangeWorkflowDialog
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
taskId="task-1"
|
||||
workspaceId="workspace-1"
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId(`change-workflow-profile-reset-${CURRENT_PROFILE_ID}`));
|
||||
expect(state.setOverride).toHaveBeenCalledWith(CURRENT_PROFILE_ID, CURRENT_PROFILE_ID);
|
||||
fireEvent.click(screen.getByTestId("change-workflow-cancel"));
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("uses a full-height phone surface with one scroll area and safe-area action space", () => {
|
||||
responsiveMock.isMobile = true;
|
||||
render(
|
||||
<ChangeWorkflowDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
taskId="task-1"
|
||||
workspaceId="workspace-1"
|
||||
/>,
|
||||
);
|
||||
|
||||
const drawer = screen.getByTestId("change-workflow-drawer");
|
||||
const body = screen.getByTestId("change-workflow-scroll");
|
||||
const footer = screen.getByTestId(SUBMIT_BUTTON_TEST_ID).parentElement;
|
||||
expect(drawer.className).toContain("100dvh");
|
||||
expect(body.className).toContain("overflow-y-auto");
|
||||
expect(footer?.className).toContain("safe-area-inset-bottom");
|
||||
expect(screen.getByTestId(SUBMIT_BUTTON_TEST_ID).className).toContain("min-h-12");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
import { useId, type RefObject } from "react";
|
||||
import { IconX } from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@kandev/ui/dialog";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@kandev/ui/drawer";
|
||||
import {
|
||||
ChangeWorkflowActionFooter,
|
||||
ChangeWorkflowAgentSection,
|
||||
ChangeWorkflowCurrentTask,
|
||||
ChangeWorkflowEntryPreview,
|
||||
ChangeWorkflowSelectors,
|
||||
ChangeWorkflowSubmitError,
|
||||
} from "@/components/task/change-workflow-form-sections";
|
||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||
import { createFocusReturnHandler } from "@/lib/dialog-focus-return";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useChangeWorkflow } from "@/hooks/domains/kanban/use-change-workflow";
|
||||
|
||||
type ChangeWorkflowDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
taskId: string | null;
|
||||
workspaceId: string | null;
|
||||
focusReturnRef?: RefObject<HTMLElement | null>;
|
||||
onSuccess?: () => void;
|
||||
};
|
||||
|
||||
function ChangeWorkflowForm({
|
||||
taskId,
|
||||
workspaceId,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
isTouchSurface,
|
||||
}: {
|
||||
taskId: string | null;
|
||||
workspaceId: string | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess?: () => void;
|
||||
isTouchSurface: boolean;
|
||||
}) {
|
||||
const workflowSelectId = useId();
|
||||
const stepSelectId = useId();
|
||||
const state = useChangeWorkflow({ open, taskId, workspaceId, onOpenChange, onSuccess });
|
||||
const sectionClass = cn("grid gap-3", isTouchSurface && "gap-4");
|
||||
|
||||
return (
|
||||
<form
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
data-testid="change-workflow-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void state.submit();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-testid="change-workflow-scroll"
|
||||
className={cn(
|
||||
"min-h-0 flex-1 overflow-y-auto overscroll-contain px-5 py-4",
|
||||
isTouchSurface && "px-4 py-3",
|
||||
)}
|
||||
>
|
||||
<div className={sectionClass}>
|
||||
<ChangeWorkflowCurrentTask state={state} />
|
||||
<ChangeWorkflowSelectors
|
||||
state={state}
|
||||
isTouchSurface={isTouchSurface}
|
||||
workflowSelectId={workflowSelectId}
|
||||
stepSelectId={stepSelectId}
|
||||
/>
|
||||
<ChangeWorkflowAgentSection state={state} isTouchSurface={isTouchSurface} />
|
||||
<ChangeWorkflowEntryPreview state={state} isTouchSurface={isTouchSurface} />
|
||||
<ChangeWorkflowSubmitError state={state} />
|
||||
</div>
|
||||
</div>
|
||||
<ChangeWorkflowActionFooter
|
||||
state={state}
|
||||
isTouchSurface={isTouchSurface}
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChangeWorkflowDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
taskId,
|
||||
workspaceId,
|
||||
focusReturnRef,
|
||||
onSuccess,
|
||||
}: ChangeWorkflowDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const { isFinePointer, isMobile } = useResponsiveBreakpoint();
|
||||
const isTouchSurface = isMobile || !isFinePointer;
|
||||
const closeFocus = createFocusReturnHandler(focusReturnRef);
|
||||
const surfaceContent = (
|
||||
<>
|
||||
<div className="flex shrink-0 items-start justify-between gap-3 border-b border-border/70 px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
{isMobile ? (
|
||||
<DrawerHeader className="p-0 text-left">
|
||||
<DrawerTitle className="text-base">{t("task:changeWorkflowTitle")}</DrawerTitle>
|
||||
<DrawerDescription>{t("task:changeWorkflowDescription")}</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
) : (
|
||||
<DialogHeader className="text-left">
|
||||
<DialogTitle className="text-base">{t("task:changeWorkflowTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("task:changeWorkflowDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("shrink-0", isTouchSurface && "size-11")}
|
||||
aria-label={t("common:close")}
|
||||
onClick={() => onOpenChange(false)}
|
||||
data-testid="change-workflow-close"
|
||||
>
|
||||
<IconX className="size-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
<ChangeWorkflowForm
|
||||
taskId={taskId}
|
||||
workspaceId={workspaceId}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
onSuccess={onSuccess}
|
||||
isTouchSurface={isTouchSurface}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={onOpenChange}>
|
||||
<DrawerContent
|
||||
className="!top-0 !bottom-auto !mt-0 !h-[calc(100dvh-16px-env(safe-area-inset-bottom,0px))] !max-h-[calc(100dvh-16px-env(safe-area-inset-bottom,0px))] min-h-0 overflow-hidden p-0 outline-none"
|
||||
data-testid="change-workflow-drawer"
|
||||
onCloseAutoFocus={closeFocus}
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl bg-background">
|
||||
{surfaceContent}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="flex max-h-[min(90dvh,900px)] min-h-0 flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl"
|
||||
data-testid="change-workflow-dialog"
|
||||
showCloseButton={false}
|
||||
onCloseAutoFocus={closeFocus}
|
||||
>
|
||||
{surfaceContent}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
import { IconLoader2, IconRefresh } from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Combobox } from "@/components/combobox";
|
||||
import type { ComboboxOption } from "@/components/combobox";
|
||||
import { WorkflowMovePreviewFooter } from "@/components/task/workflow-move-preview-footer";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useChangeWorkflow } from "@/hooks/domains/kanban/use-change-workflow";
|
||||
import type { WorkflowStepDTO } from "@/lib/types/http";
|
||||
|
||||
export type ChangeWorkflowState = ReturnType<typeof useChangeWorkflow>;
|
||||
type TouchProps = { isTouchSurface: boolean };
|
||||
|
||||
function RetryButton({
|
||||
onClick,
|
||||
label,
|
||||
testId,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
testId?: string;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="min-h-9 gap-2"
|
||||
onClick={onClick}
|
||||
data-testid={testId}
|
||||
>
|
||||
<IconRefresh className="size-3.5" aria-hidden="true" />
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChangeWorkflowCurrentTask({ state }: { state: ChangeWorkflowState }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
{state.taskStatus === "loading" && (
|
||||
<div
|
||||
role="status"
|
||||
className="flex min-h-9 items-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<IconLoader2 className="size-4 animate-spin" aria-hidden="true" />
|
||||
{t("task:changeWorkflowLoadingTask")}
|
||||
</div>
|
||||
)}
|
||||
{state.taskStatus === "error" && (
|
||||
<div
|
||||
role="alert"
|
||||
className="grid justify-items-start gap-2 rounded-md border border-destructive/40 p-3 text-sm"
|
||||
>
|
||||
<span>{t("task:changeWorkflowLoadTaskFailed")}</span>
|
||||
<RetryButton onClick={state.retryTask} label={t("task:changeWorkflowRetry")} />
|
||||
</div>
|
||||
)}
|
||||
{state.task && (
|
||||
<section
|
||||
aria-label={t("task:changeWorkflowCurrentLabel")}
|
||||
className="grid gap-1 rounded-md bg-muted/50 p-3"
|
||||
>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t("task:changeWorkflowCurrentLabel")}
|
||||
</span>
|
||||
<span className="break-words text-sm font-medium">
|
||||
{t("task:changeWorkflowCurrentAssignment", {
|
||||
workflow: state.sourceWorkflowName ?? state.task.workflow_id,
|
||||
step: state.sourceStepName ?? state.task.workflow_step_id,
|
||||
})}
|
||||
</span>
|
||||
<span className="break-words text-xs text-muted-foreground">{state.task.title}</span>
|
||||
</section>
|
||||
)}
|
||||
{state.sourceChanged && (
|
||||
<div
|
||||
role="status"
|
||||
className="rounded-md border border-amber-500/50 bg-amber-500/10 p-3 text-sm"
|
||||
>
|
||||
{t("task:changeWorkflowSourceChanged")}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function stepOptions(steps: readonly WorkflowStepDTO[]): ComboboxOption[] {
|
||||
return [...steps]
|
||||
.sort((left, right) => left.position - right.position || left.id.localeCompare(right.id))
|
||||
.map((step) => ({
|
||||
value: step.id,
|
||||
label: step.name,
|
||||
renderLabel: () => (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="size-2 shrink-0 rounded-full" style={{ backgroundColor: step.color }} />
|
||||
<span className="truncate">{step.name}</span>
|
||||
</span>
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
function WorkflowPickerSection({
|
||||
state,
|
||||
isTouchSurface,
|
||||
workflowSelectId,
|
||||
}: {
|
||||
state: ChangeWorkflowState;
|
||||
isTouchSurface: boolean;
|
||||
workflowSelectId: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const rowClass = cn("grid gap-1.5", isTouchSurface && "gap-2");
|
||||
const controlClass = isTouchSurface ? "h-11 w-full" : "h-7 w-full";
|
||||
const workflows: ComboboxOption[] = state.destinations.map((workflow) => ({
|
||||
value: workflow.id,
|
||||
label: workflow.name,
|
||||
}));
|
||||
return (
|
||||
<>
|
||||
{state.workflowsStatus === "loading" && (
|
||||
<LoadingStatus label={t("task:changeWorkflowLoadingWorkflows")} />
|
||||
)}
|
||||
{state.workflowsStatus === "error" && (
|
||||
<LoadError
|
||||
label={t("task:changeWorkflowLoadWorkflowsFailed")}
|
||||
onRetry={state.retryWorkflows}
|
||||
/>
|
||||
)}
|
||||
{state.workflowsStatus === "success" && state.destinations.length === 0 && (
|
||||
<p role="status" className="text-sm text-muted-foreground">
|
||||
{t("task:changeWorkflowNoDestinations")}
|
||||
</p>
|
||||
)}
|
||||
{state.workflowsStatus === "success" && state.destinations.length > 0 && (
|
||||
<div className={rowClass}>
|
||||
<span className="text-sm font-medium">{t("task:changeWorkflowDestinationLabel")}</span>
|
||||
<Combobox
|
||||
options={workflows}
|
||||
value={state.selectedWorkflowId}
|
||||
onValueChange={state.changeWorkflow}
|
||||
ariaLabel={t("task:changeWorkflowDestinationLabel")}
|
||||
triggerId={workflowSelectId}
|
||||
placeholder={t("task:changeWorkflowDestinationPlaceholder")}
|
||||
searchPlaceholder={t("task:changeWorkflowSearchWorkflows")}
|
||||
emptyMessage={t("task:changeWorkflowNoDestinationFound")}
|
||||
dropdownLabel={t("task:changeWorkflowDestinationLabel")}
|
||||
disabled={state.workflowsStatus !== "success" || state.isSubmitting}
|
||||
touchTarget={isTouchSurface}
|
||||
triggerClassName={controlClass}
|
||||
testId="change-workflow-destination"
|
||||
popoverPortal
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function StepPickerSection({
|
||||
state,
|
||||
isTouchSurface,
|
||||
stepSelectId,
|
||||
}: {
|
||||
state: ChangeWorkflowState;
|
||||
isTouchSurface: boolean;
|
||||
stepSelectId: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const steps = state.snapshot?.steps ?? [];
|
||||
const rowClass = cn("grid gap-1.5", isTouchSurface && "gap-2");
|
||||
const controlClass = isTouchSurface ? "h-11 w-full" : "h-7 w-full";
|
||||
return (
|
||||
<>
|
||||
{state.selectedWorkflowId && state.snapshotStatus === "loading" && (
|
||||
<LoadingStatus label={t("task:changeWorkflowLoadingSteps")} />
|
||||
)}
|
||||
{state.selectedWorkflowId && state.snapshotStatus === "error" && (
|
||||
<LoadError label={t("task:changeWorkflowLoadStepsFailed")} onRetry={state.retrySnapshot} />
|
||||
)}
|
||||
{state.snapshotStatus === "success" && steps.length === 0 && (
|
||||
<p
|
||||
role="status"
|
||||
data-testid="change-workflow-no-steps"
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
{t("task:changeWorkflowNoSteps")}
|
||||
</p>
|
||||
)}
|
||||
{state.snapshotStatus === "success" && steps.length > 0 && (
|
||||
<div className={rowClass}>
|
||||
<span className="text-sm font-medium">{t("task:changeWorkflowStepLabel")}</span>
|
||||
<Combobox
|
||||
options={stepOptions(steps)}
|
||||
value={state.selectedStepId}
|
||||
onValueChange={state.setSelectedStepId}
|
||||
ariaLabel={t("task:changeWorkflowStepLabel")}
|
||||
triggerId={stepSelectId}
|
||||
placeholder={t("task:changeWorkflowStepPlaceholder")}
|
||||
searchPlaceholder={t("task:changeWorkflowSearchSteps")}
|
||||
emptyMessage={t("task:changeWorkflowNoStepFound")}
|
||||
dropdownLabel={t("task:changeWorkflowStepLabel")}
|
||||
disabled={state.snapshotStatus !== "success" || state.isSubmitting}
|
||||
touchTarget={isTouchSurface}
|
||||
triggerClassName={controlClass}
|
||||
testId="change-workflow-step"
|
||||
popoverPortal
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChangeWorkflowSelectors({
|
||||
state,
|
||||
isTouchSurface,
|
||||
workflowSelectId,
|
||||
stepSelectId,
|
||||
}: {
|
||||
state: ChangeWorkflowState;
|
||||
isTouchSurface: boolean;
|
||||
workflowSelectId: string;
|
||||
stepSelectId: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<WorkflowPickerSection
|
||||
state={state}
|
||||
isTouchSurface={isTouchSurface}
|
||||
workflowSelectId={workflowSelectId}
|
||||
/>
|
||||
<StepPickerSection
|
||||
state={state}
|
||||
isTouchSurface={isTouchSurface}
|
||||
stepSelectId={stepSelectId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadingStatus({ label }: { label: string }) {
|
||||
return (
|
||||
<div role="status" className="flex min-h-9 items-center gap-2 text-sm text-muted-foreground">
|
||||
<IconLoader2 className="size-4 animate-spin" aria-hidden="true" />
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadError({ label, onRetry }: { label: string; onRetry: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="grid justify-items-start gap-2 rounded-md border border-destructive/40 p-3 text-sm"
|
||||
>
|
||||
<span>{label}</span>
|
||||
<RetryButton onClick={onRetry} label={t("task:changeWorkflowRetry")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ConversationRelationship =
|
||||
| { id: string; stepName: string; kind: "initial" }
|
||||
| { id: string; stepName: string; kind: "step"; targetName: string };
|
||||
|
||||
function conversationRelationships(steps: readonly WorkflowStepDTO[]): ConversationRelationship[] {
|
||||
return steps.flatMap<ConversationRelationship>((step) => {
|
||||
const target = step.session_target;
|
||||
if (target?.kind === "initial") return [{ id: step.id, stepName: step.name, kind: "initial" }];
|
||||
if (target?.kind !== "step") return [];
|
||||
const targetStep = steps.find((candidate) => candidate.id === target.step_id);
|
||||
return [
|
||||
{
|
||||
id: step.id,
|
||||
stepName: step.name,
|
||||
kind: "step",
|
||||
targetName: targetStep?.name ?? "",
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function WorkflowAgentRow({
|
||||
state,
|
||||
row,
|
||||
isTouchSurface,
|
||||
}: {
|
||||
state: ChangeWorkflowState;
|
||||
row: ChangeWorkflowState["rows"][number];
|
||||
isTouchSurface: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const invalid = !row.replacementAvailable || state.sourceProfileErrorId === row.sourceProfileId;
|
||||
const options: ComboboxOption[] = state.profileOptions.map((option) => ({
|
||||
...option,
|
||||
disabled: false,
|
||||
}));
|
||||
if (!options.some((option) => option.value === row.replacementProfileId)) {
|
||||
options.unshift({
|
||||
value: row.replacementProfileId,
|
||||
label: row.replacementLabel ?? row.sourceLabel,
|
||||
disabled: true,
|
||||
disabledReason: t("task:changeWorkflowProfileUnavailable"),
|
||||
});
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={cn("grid gap-2 rounded-md border p-3", invalid && "border-destructive/50")}
|
||||
data-testid={`change-workflow-profile-${row.sourceProfileId}`}
|
||||
>
|
||||
<div className="grid gap-0.5">
|
||||
<span className="text-sm font-medium">{row.sourceLabel}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("task:changeWorkflowProfileSteps", { steps: row.stepNames.join(", ") })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Combobox
|
||||
options={options}
|
||||
value={row.replacementProfileId}
|
||||
onValueChange={(value) => state.setOverride(row.sourceProfileId, value)}
|
||||
ariaLabel={t("task:changeWorkflowReplacementLabel", { profile: row.sourceLabel })}
|
||||
placeholder={t("task:changeWorkflowProfilePlaceholder")}
|
||||
searchPlaceholder={t("task:searchAgents")}
|
||||
emptyMessage={t("task:noAgentFound")}
|
||||
dropdownLabel={t("task:agentProfile2")}
|
||||
disabled={state.isSubmitting || state.profilesStatus !== "success"}
|
||||
touchTarget={isTouchSurface}
|
||||
triggerClassName={cn(isTouchSurface ? "h-11 w-full" : "h-7 w-full", "min-w-0 flex-1")}
|
||||
testId={`change-workflow-profile-selector-${row.sourceProfileId}`}
|
||||
popoverPortal
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={cn("shrink-0", isTouchSurface ? "min-h-11" : "h-7")}
|
||||
disabled={state.isSubmitting || row.replacementProfileId === row.sourceProfileId}
|
||||
onClick={() => state.setOverride(row.sourceProfileId, row.sourceProfileId)}
|
||||
data-testid={`change-workflow-profile-reset-${row.sourceProfileId}`}
|
||||
>
|
||||
{t("task:changeWorkflowResetProfile")}
|
||||
</Button>
|
||||
</div>
|
||||
{invalid && (
|
||||
<p role="alert" className="text-xs text-destructive">
|
||||
{t("task:changeWorkflowProfileUnavailable")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConversationSection({ steps }: { steps: readonly WorkflowStepDTO[] }) {
|
||||
const { t } = useTranslation();
|
||||
const relationships = conversationRelationships(steps);
|
||||
if (relationships.length === 0) return null;
|
||||
return (
|
||||
<section
|
||||
className="grid gap-2 rounded-md bg-muted/40 p-3"
|
||||
aria-labelledby="change-workflow-conversations-heading"
|
||||
>
|
||||
<h4 id="change-workflow-conversations-heading" className="text-sm font-medium">
|
||||
{t("task:changeWorkflowConversationSection")}
|
||||
</h4>
|
||||
{relationships.map((relationship) => (
|
||||
<p key={relationship.id} className="break-words text-xs text-muted-foreground">
|
||||
{relationship.kind === "initial"
|
||||
? t("task:changeWorkflowInitialConversation", { step: relationship.stepName })
|
||||
: t("task:changeWorkflowStepConversation", {
|
||||
step: relationship.stepName,
|
||||
target: relationship.targetName,
|
||||
})}
|
||||
</p>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChangeWorkflowAgentSection({
|
||||
state,
|
||||
isTouchSurface,
|
||||
}: { state: ChangeWorkflowState } & TouchProps) {
|
||||
const { t } = useTranslation();
|
||||
if (!state.selectedWorkflowId) return null;
|
||||
const steps = state.snapshot?.steps ?? [];
|
||||
return (
|
||||
<section
|
||||
className="grid gap-3 border-t border-border/70 pt-3"
|
||||
aria-labelledby="change-workflow-agents-heading"
|
||||
>
|
||||
<div className="grid gap-1">
|
||||
<h3 id="change-workflow-agents-heading" className="text-sm font-semibold">
|
||||
{t("task:changeWorkflowAgentSection")}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">{t("task:changeWorkflowAgentScope")}</p>
|
||||
</div>
|
||||
{state.profilesStatus === "loading" && (
|
||||
<LoadingStatus label={t("task:changeWorkflowLoadingProfiles")} />
|
||||
)}
|
||||
{state.profilesStatus === "error" && (
|
||||
<LoadError
|
||||
label={t("task:changeWorkflowLoadProfilesFailed")}
|
||||
onRetry={state.retryProfiles}
|
||||
/>
|
||||
)}
|
||||
{state.profilesStatus === "success" && state.rows.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{t("task:changeWorkflowNoFixedProfiles")}</p>
|
||||
)}
|
||||
{state.profilesStatus === "success" &&
|
||||
state.rows.map((row) => (
|
||||
<WorkflowAgentRow
|
||||
key={row.sourceProfileId}
|
||||
state={state}
|
||||
row={row}
|
||||
isTouchSurface={isTouchSurface}
|
||||
/>
|
||||
))}
|
||||
{state.snapshotStatus === "success" && <ConversationSection steps={steps} />}
|
||||
<p className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-xs">
|
||||
{t("task:changeWorkflowReplacePreviousOverrides")}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChangeWorkflowEntryPreview({
|
||||
state,
|
||||
isTouchSurface,
|
||||
}: { state: ChangeWorkflowState } & TouchProps) {
|
||||
const { t } = useTranslation();
|
||||
if (
|
||||
state.snapshotStatus !== "success" ||
|
||||
!state.selectedStepId ||
|
||||
!state.task ||
|
||||
!state.workflowChange
|
||||
)
|
||||
return null;
|
||||
return (
|
||||
<section
|
||||
className="grid gap-2 border-t border-border/70 pt-3"
|
||||
aria-labelledby="change-workflow-preview-heading"
|
||||
>
|
||||
<h3 id="change-workflow-preview-heading" className="text-sm font-semibold">
|
||||
{t("task:changeWorkflowEntryPreview")}
|
||||
</h3>
|
||||
<WorkflowMovePreviewFooter
|
||||
target={{
|
||||
taskId: state.task.id,
|
||||
workflowId: state.selectedWorkflowId,
|
||||
workflowStepId: state.selectedStepId,
|
||||
}}
|
||||
workflowChange={state.workflowChange}
|
||||
isTouchSurface={isTouchSurface}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChangeWorkflowSubmitError({ state }: { state: ChangeWorkflowState }) {
|
||||
const { t } = useTranslation();
|
||||
const error = state.submitError;
|
||||
if (!error) return null;
|
||||
let message = t("task:changeWorkflowFailed");
|
||||
if (error.code === "workflow_change_conflict") message = t("task:changeWorkflowSourceChanged");
|
||||
if (error.code === "workflow_change_uncertain") message = t("task:changeWorkflowUncertain");
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="grid justify-items-start gap-2 rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm"
|
||||
>
|
||||
<span>{message}</span>
|
||||
{error.code === "workflow_change_uncertain" && (
|
||||
<RetryButton
|
||||
onClick={state.retryTask}
|
||||
label={t("task:changeWorkflowRefreshTask")}
|
||||
testId="change-workflow-refresh-task"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChangeWorkflowActionFooter({
|
||||
state,
|
||||
isTouchSurface,
|
||||
onOpenChange,
|
||||
}: TouchProps & { state: ChangeWorkflowState; onOpenChange: (open: boolean) => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-end gap-2 border-t border-border/70 px-5 py-3",
|
||||
isTouchSurface &&
|
||||
"justify-stretch px-4 pb-[max(12px,env(safe-area-inset-bottom,0px))] pt-3",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className={cn(isTouchSurface && "min-h-12 flex-1")}
|
||||
disabled={state.isSubmitting}
|
||||
onClick={() => onOpenChange(false)}
|
||||
data-testid="change-workflow-cancel"
|
||||
>
|
||||
{t("task:changeWorkflowCancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className={cn(isTouchSurface && "min-h-12 flex-1")}
|
||||
disabled={!state.canSubmit}
|
||||
data-testid="change-workflow-submit"
|
||||
>
|
||||
{state.isSubmitting && <IconLoader2 className="size-4 animate-spin" aria-hidden="true" />}
|
||||
{t("task:changeWorkflowSubmit")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { TaskExternalLinkDialog } from "@/components/task/task-external-link-dia
|
||||
import { TaskGitHubIssueDialog } from "@/components/task/task-github-issue-dialog";
|
||||
import { TaskGitHubPRDialog } from "@/components/task/task-github-pr-dialog";
|
||||
import { TaskMRLinkDialog } from "@/components/gitlab/task-mr-link-dialog";
|
||||
import { ChangeWorkflowDialog } from "@/components/task/change-workflow-dialog";
|
||||
import type { Task } from "@/components/kanban-card";
|
||||
import type { useTaskActionsMenu, TaskActionsMenuBoardRow } from "@/hooks/use-task-actions-menu";
|
||||
import { hydrateEditedTask } from "@/hooks/domains/kanban/use-kanban-actions";
|
||||
@@ -170,6 +171,7 @@ export function TaskActionsMenuDialogs({
|
||||
menu={menu}
|
||||
/>
|
||||
)}
|
||||
<ChangeWorkflowDialogSurface taskId={taskId} workspaceId={workspaceId} menu={menu} />
|
||||
<TaskArchiveConfirmation
|
||||
{...confirmationTarget}
|
||||
open={menu.showArchiveConfirm}
|
||||
@@ -237,3 +239,19 @@ export function TaskActionsMenuDialogs({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ChangeWorkflowDialogSurface({
|
||||
taskId,
|
||||
workspaceId,
|
||||
menu,
|
||||
}: Pick<TaskActionsMenuDialogsProps, "taskId" | "workspaceId" | "menu">) {
|
||||
return (
|
||||
<ChangeWorkflowDialog
|
||||
open={menu.showChangeWorkflow}
|
||||
onOpenChange={menu.setShowChangeWorkflow}
|
||||
taskId={taskId}
|
||||
workspaceId={workspaceId}
|
||||
focusReturnRef={menu.triggerRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,8 +6,9 @@ import { TaskManagementDrawer } from "./task-management-drawer";
|
||||
afterEach(cleanup);
|
||||
describe("task drawer navigation", () => {
|
||||
// @covers AC-TASKS-THREADS-ACTIONS-004.3, AC-TASKS-THREADS-ACTIONS-004.6, AC-TASKS-THREADS-ACTIONS-001.3
|
||||
it("chooses a workflow step in one surface and backs through nested pages", () => {
|
||||
it("opens the change form directly and keeps same-workflow step navigation separate", () => {
|
||||
const onMove = vi.fn();
|
||||
const onChangeWorkflow = vi.fn();
|
||||
render(
|
||||
<StateProvider>
|
||||
<TaskManagementDrawer
|
||||
@@ -24,6 +25,7 @@ describe("task drawer navigation", () => {
|
||||
two: [{ id: "s3", title: "Build" }],
|
||||
}}
|
||||
onMove={onMove}
|
||||
onChangeWorkflow={onChangeWorkflow}
|
||||
onPriority={vi.fn()}
|
||||
onArchive={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
@@ -33,15 +35,11 @@ describe("task drawer navigation", () => {
|
||||
/>
|
||||
</StateProvider>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send to workflow" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Two" }));
|
||||
expect(screen.getAllByRole("dialog")).toHaveLength(1);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Back" }));
|
||||
expect(screen.getByRole("button", { name: "Two" })).toBeTruthy();
|
||||
expect(document.activeElement).toBe(screen.getByRole("button", { name: "Two" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Two" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Build" }));
|
||||
expect(onMove).toHaveBeenCalledWith("two", "s3");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Change workflow..." }));
|
||||
expect(onChangeWorkflow).toHaveBeenCalledOnce();
|
||||
fireEvent.click(screen.getByTestId("task-management-page-steps"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Review" }));
|
||||
expect(onMove).toHaveBeenCalledWith("one", "s2");
|
||||
});
|
||||
|
||||
it("keeps current lifecycle status outside the disabled step choice", () => {
|
||||
@@ -64,6 +62,7 @@ describe("task drawer navigation", () => {
|
||||
],
|
||||
}}
|
||||
onMove={vi.fn()}
|
||||
onChangeWorkflow={vi.fn()}
|
||||
onPriority={vi.fn()}
|
||||
onArchive={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
export type TaskManagementDrawerProps = TaskManagementMenuProps & {
|
||||
onCloseAutoFocus: (event: Event) => void;
|
||||
};
|
||||
type Page = "root" | "priority" | "steps" | "workflows" | "links" | { workflowId: string };
|
||||
type Page = "root" | "priority" | "steps" | "links";
|
||||
|
||||
export function TaskManagementSheet({
|
||||
title,
|
||||
@@ -256,7 +256,7 @@ function DrawerChoices({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { task, workflows, stepsByWorkflowId, disabled, onMove, linkActions, closeMenu } = props;
|
||||
const { currentSteps, targets } = taskMoveOptions(task.workflowId, workflows, stepsByWorkflowId);
|
||||
const { currentSteps } = taskMoveOptions(task.workflowId, workflows, stepsByWorkflowId);
|
||||
const links = taskLinkMenuOptions(linkActions);
|
||||
const plugins = useTaskPluginLinkActions(task.id, task.repositoryLinks ?? []);
|
||||
if (page === "priority") return <PriorityChoices {...props} />;
|
||||
@@ -273,31 +273,6 @@ function DrawerChoices({
|
||||
}}
|
||||
/>
|
||||
);
|
||||
if (typeof page === "object")
|
||||
return (
|
||||
<StepChoices
|
||||
steps={stepsByWorkflowId[page.workflowId] ?? []}
|
||||
disabled={disabled}
|
||||
progressByStepId={progressByStepId}
|
||||
agentLabelsByProfileId={agentLabelsByProfileId}
|
||||
onSelect={(stepId) => onMove(page.workflowId, stepId)}
|
||||
/>
|
||||
);
|
||||
if (page === "workflows")
|
||||
return targets.map((workflow) => (
|
||||
<Choice
|
||||
key={workflow.id}
|
||||
testId={`task-context-workflow-${workflow.id}`}
|
||||
nested
|
||||
disabled={disabled || !stepsByWorkflowId[workflow.id]?.length}
|
||||
onClick={() => setPage({ workflowId: workflow.id })}
|
||||
>
|
||||
{workflow.name}
|
||||
{!stepsByWorkflowId[workflow.id]?.length && (
|
||||
<span className="ml-2 text-xs">{t("task:noSteps")}</span>
|
||||
)}
|
||||
</Choice>
|
||||
));
|
||||
if (page === "links")
|
||||
return (
|
||||
<>
|
||||
@@ -345,7 +320,8 @@ function RootChoices({
|
||||
hasLinks: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { task, workflows, stepsByWorkflowId, disabled, onArchive, onDelete } = props;
|
||||
const { task, workflows, stepsByWorkflowId, disabled, onArchive, onDelete, onChangeWorkflow } =
|
||||
props;
|
||||
const { canMove, canSend } = taskMoveOptions(task.workflowId, workflows, stepsByWorkflowId);
|
||||
return (
|
||||
<>
|
||||
@@ -372,12 +348,11 @@ function RootChoices({
|
||||
{canSend && (
|
||||
<Choice
|
||||
testId="task-management-page-workflows"
|
||||
nested
|
||||
disabled={disabled}
|
||||
icon={<IconLogicBuffer className="size-4" />}
|
||||
onClick={() => setPage("workflows")}
|
||||
onClick={onChangeWorkflow}
|
||||
>
|
||||
{t("task:sendToWorkflow")}
|
||||
{t("task:changeWorkflow")}
|
||||
</Choice>
|
||||
)}
|
||||
{hasLinks && (
|
||||
@@ -430,26 +405,17 @@ export function TaskManagementDrawer(props: TaskManagementDrawerProps) {
|
||||
setPage(next);
|
||||
};
|
||||
const back = () => {
|
||||
setFocusChoiceId(
|
||||
typeof page === "object"
|
||||
? `task-context-workflow-${page.workflowId}`
|
||||
: `task-management-page-${page}`,
|
||||
);
|
||||
setPage(typeof page === "object" ? "workflows" : "root");
|
||||
setFocusChoiceId(`task-management-page-${page}`);
|
||||
setPage("root");
|
||||
};
|
||||
const title =
|
||||
typeof page === "object"
|
||||
? (props.workflows.find((workflow) => workflow.id === page.workflowId)?.name ??
|
||||
t("task:sendToWorkflow"))
|
||||
: t(
|
||||
{
|
||||
root: "task:taskActions",
|
||||
priority: "kanban:priority",
|
||||
steps: "task:moveTo",
|
||||
workflows: "task:sendToWorkflow",
|
||||
links: "task:link",
|
||||
}[page],
|
||||
);
|
||||
const title = t(
|
||||
{
|
||||
root: "task:taskActions",
|
||||
priority: "kanban:priority",
|
||||
steps: "task:moveTo",
|
||||
links: "task:link",
|
||||
}[page],
|
||||
);
|
||||
return (
|
||||
<TaskManagementSheet
|
||||
title={title}
|
||||
|
||||
@@ -33,6 +33,7 @@ describe("focused shared task actions", () => {
|
||||
],
|
||||
}}
|
||||
onMove={vi.fn()}
|
||||
onChangeWorkflow={vi.fn()}
|
||||
onPriority={onPriority}
|
||||
onArchive={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
@@ -44,7 +45,7 @@ describe("focused shared task actions", () => {
|
||||
</StateProvider>,
|
||||
);
|
||||
fireEvent.contextMenu(screen.getByText("Open"));
|
||||
expect(screen.queryByText("Send to workflow")).toBeNull();
|
||||
expect(screen.queryByText("Change workflow...")).toBeNull();
|
||||
fireEvent.pointerMove(screen.getByTestId("task-context-move-to"), { pointerType: "mouse" });
|
||||
const current = await screen.findByTestId("task-context-step-s1");
|
||||
expect(current.getAttribute("aria-disabled")).toBe("true");
|
||||
|
||||
@@ -16,6 +16,7 @@ export type TaskManagementMenuProps = {
|
||||
disabled?: boolean;
|
||||
onPriority: (priority: TaskPriority) => void;
|
||||
onMove: (workflowId: string, stepId: string) => void;
|
||||
onChangeWorkflow?: () => void;
|
||||
onArchive: () => void;
|
||||
onDelete: () => void;
|
||||
closeMenu: () => void;
|
||||
@@ -30,6 +31,7 @@ export function TaskManagementMenu({
|
||||
disabled,
|
||||
onPriority,
|
||||
onMove,
|
||||
onChangeWorkflow,
|
||||
onArchive,
|
||||
onDelete,
|
||||
closeMenu,
|
||||
@@ -52,7 +54,7 @@ export function TaskManagementMenu({
|
||||
onMoveToStep={(stepId) => {
|
||||
if (task.workflowId) onMove(task.workflowId, stepId);
|
||||
}}
|
||||
onSendToWorkflow={onMove}
|
||||
onChangeWorkflow={onChangeWorkflow}
|
||||
/>
|
||||
<TaskPluginLinkMenu
|
||||
task={task}
|
||||
|
||||
@@ -193,7 +193,7 @@ it("allows moves within the current hidden workflow without offering other hidde
|
||||
}));
|
||||
render(<Harness />);
|
||||
fireEvent.click(screen.getByText("Open A"));
|
||||
expect(screen.queryByRole("button", { name: "Send to workflow" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Change workflow..." })).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Move to" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "next" }));
|
||||
await waitFor(() =>
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useSidebarLinkActions } from "./task-session-sidebar-link-actions";
|
||||
import { useSidebarTaskLinking } from "./task-session-sidebar-task-linking";
|
||||
import { SidebarLinkDialogs } from "./task-session-sidebar-dialogs";
|
||||
import { selectTaskLinkActions, type TaskLinkHandlers } from "./task-switcher-link-menu";
|
||||
import { ChangeWorkflowDialog } from "./change-workflow-dialog";
|
||||
|
||||
type Flow = ReturnType<typeof useTaskManagementFlow>;
|
||||
export type TaskMenuPoint = { x: number; y: number };
|
||||
@@ -297,6 +298,7 @@ function TaskManagementChoices({
|
||||
disabled: mutations.disabled,
|
||||
onPriority: mutations.onPriority,
|
||||
onMove: mutations.onMove,
|
||||
onChangeWorkflow: () => flow.setStage("change-workflow"),
|
||||
onArchive: () => flow.setStage("archive"),
|
||||
onDelete: () => flow.setStage("delete"),
|
||||
closeMenu,
|
||||
@@ -325,6 +327,18 @@ function TaskManagementChoices({
|
||||
onCloseAutoFocus={onCloseAutoFocus}
|
||||
touch={touch}
|
||||
/>
|
||||
{flow.stage === "change-workflow" && flow.identity && (
|
||||
<ChangeWorkflowDialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) flow.close();
|
||||
}}
|
||||
taskId={flow.identity.taskId}
|
||||
workspaceId={flow.identity.workspaceId}
|
||||
focusReturnRef={focusReturnRef}
|
||||
onSuccess={flow.close}
|
||||
/>
|
||||
)}
|
||||
{flow.stage === "link" && (
|
||||
<SidebarLinkDialogs
|
||||
actions={links.actions}
|
||||
|
||||
@@ -170,8 +170,10 @@ type TaskMoveContextMenuItemsProps = {
|
||||
entryOptions: WorkflowMoveEntryOptions | undefined,
|
||||
) => Promise<boolean>;
|
||||
isMoving?: boolean;
|
||||
isBulkSelection?: boolean;
|
||||
progressByStepId?: Readonly<Record<string, WorkflowStepProgress>>;
|
||||
agentLabelsByProfileId?: Readonly<Record<string, string>>;
|
||||
onChangeWorkflow?: () => void;
|
||||
onSendToWorkflow?: (workflowId: string, stepId: string) => void;
|
||||
};
|
||||
|
||||
@@ -526,11 +528,11 @@ function SendToWorkflowSubmenu({
|
||||
<ContextMenuSub>
|
||||
<ContextMenuSubTrigger
|
||||
className="[@media(pointer:coarse)]:min-h-11"
|
||||
data-testid="task-context-send-to-workflow"
|
||||
data-testid="task-context-change-workflow-selection"
|
||||
disabled={disabled}
|
||||
>
|
||||
<IconLogicBuffer className="mr-2 h-4 w-4" />
|
||||
{t("task:sendToWorkflow")}
|
||||
{t("task:changeWorkflowForSelectedTasks")}
|
||||
</ContextMenuSubTrigger>
|
||||
<ContextMenuSubContent className="w-56">
|
||||
{workflows.map((workflow) => (
|
||||
@@ -558,10 +560,13 @@ export function TaskMoveContextMenuItems({
|
||||
onMoveToStepWithOptions,
|
||||
onSubmitWithOptions,
|
||||
isMoving,
|
||||
isBulkSelection = false,
|
||||
progressByStepId,
|
||||
agentLabelsByProfileId,
|
||||
onChangeWorkflow,
|
||||
onSendToWorkflow,
|
||||
}: TaskMoveContextMenuItemsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { currentSteps, targets, canMove, canSend } = taskMoveOptions(
|
||||
currentWorkflowId,
|
||||
workflows,
|
||||
@@ -570,7 +575,9 @@ export function TaskMoveContextMenuItems({
|
||||
const hasSameWorkflowMove = Boolean(
|
||||
(onMoveToStep || onMoveToStepWithOptions || onSubmitWithOptions) && canMove,
|
||||
);
|
||||
const hasCrossWorkflowMove = Boolean(onSendToWorkflow && canSend);
|
||||
const hasCrossWorkflowMove = Boolean(
|
||||
canSend && (isBulkSelection ? onSendToWorkflow : onChangeWorkflow),
|
||||
);
|
||||
|
||||
if (!hasSameWorkflowMove && !hasCrossWorkflowMove) return null;
|
||||
|
||||
@@ -588,12 +595,25 @@ export function TaskMoveContextMenuItems({
|
||||
progressByStepId={progressByStepId}
|
||||
agentLabelsByProfileId={agentLabelsByProfileId}
|
||||
/>
|
||||
<SendToWorkflowSubmenu
|
||||
workflows={targets}
|
||||
stepsByWorkflowId={stepsByWorkflowId}
|
||||
disabled={disabled}
|
||||
onSendToWorkflow={onSendToWorkflow}
|
||||
/>
|
||||
{hasCrossWorkflowMove &&
|
||||
(isBulkSelection ? (
|
||||
<SendToWorkflowSubmenu
|
||||
workflows={targets}
|
||||
stepsByWorkflowId={stepsByWorkflowId}
|
||||
disabled={disabled}
|
||||
onSendToWorkflow={onSendToWorkflow}
|
||||
/>
|
||||
) : (
|
||||
<ContextMenuItem
|
||||
data-testid="task-context-change-workflow"
|
||||
className="[@media(pointer:coarse)]:min-h-11"
|
||||
disabled={disabled}
|
||||
onSelect={onChangeWorkflow}
|
||||
>
|
||||
<IconLogicBuffer className="mr-2 h-4 w-4" />
|
||||
{t("task:changeWorkflow")}
|
||||
</ContextMenuItem>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ export function taskRowActionAvailability(task: TaskSwitcherItem) {
|
||||
mark: !task.isArchived,
|
||||
edit: !task.isArchived && Boolean(task.workflowId && task.workflowStepId),
|
||||
nest: !task.isArchived && Boolean(task.workflowId),
|
||||
move: !task.isArchived && Boolean(task.workflowId),
|
||||
move: !task.isArchived && !task.isFromOffice && Boolean(task.workflowId),
|
||||
detach: Boolean(task.parentTaskId),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export function TaskMoveItems({
|
||||
isMixedWorkflowSelection,
|
||||
closeMenu,
|
||||
moveTasks,
|
||||
onChangeWorkflow,
|
||||
}: Omit<TaskContextMenuItemsProps, "onRenameTask" | "onArchiveTask" | "onDeleteTask"> & {
|
||||
actingIds: string[];
|
||||
actingOnSelection: boolean;
|
||||
@@ -78,16 +79,22 @@ export function TaskMoveItems({
|
||||
isMoving={moveOptionsBusy}
|
||||
progressByStepId={progressByStepId}
|
||||
agentLabelsByProfileId={agentLabelsByProfileId}
|
||||
onSendToWorkflow={(targetWorkflowId, stepId) => {
|
||||
if (actingOnSelection) {
|
||||
runSelectionMove(targetWorkflowId, stepId, "workflow");
|
||||
return;
|
||||
}
|
||||
closeMenu();
|
||||
void moveTasks([task.id], targetWorkflowId, stepId, "workflow").catch(() => {
|
||||
// useTaskWorkflowMove already shows the failure toast.
|
||||
});
|
||||
}}
|
||||
isBulkSelection={actingIds.length > 1}
|
||||
onChangeWorkflow={actingIds.length === 1 ? onChangeWorkflow : undefined}
|
||||
onSendToWorkflow={
|
||||
actingIds.length > 1
|
||||
? (targetWorkflowId, stepId) => {
|
||||
if (actingOnSelection) {
|
||||
runSelectionMove(targetWorkflowId, stepId, "workflow");
|
||||
return;
|
||||
}
|
||||
closeMenu();
|
||||
void moveTasks([task.id], targetWorkflowId, stepId, "workflow").catch(() => {
|
||||
// useTaskWorkflowMove already shows the failure toast.
|
||||
});
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { StateProvider } from "@/components/state-provider";
|
||||
import { ToastProvider } from "@/components/toast-provider";
|
||||
import { TaskItemWithContextMenu } from "./task-switcher-context-menu";
|
||||
import type { TaskSwitcherItem } from "./task-switcher-types";
|
||||
|
||||
const WORKFLOW_ID = "workflow-1";
|
||||
const STEP_ID = "step-1";
|
||||
const workflowMoveMock = vi.hoisted(() => ({ move: vi.fn(), isMoving: false }));
|
||||
const touchDrawerMock = vi.hoisted(() => ({ enabled: false }));
|
||||
|
||||
vi.mock("@/hooks/domains/kanban/use-workflow-move", () => ({
|
||||
useWorkflowMove: () => workflowMoveMock,
|
||||
}));
|
||||
vi.mock("@/hooks/use-compact-task-chrome", () => ({
|
||||
useTouchDrawer: () => touchDrawerMock.enabled,
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
Object.defineProperty(window, "innerWidth", { configurable: true, value: 1024 });
|
||||
});
|
||||
|
||||
function task(overrides: Partial<TaskSwitcherItem> = {}): TaskSwitcherItem {
|
||||
return { id: "task-1", title: "Task 1", state: "IN_PROGRESS", ...overrides };
|
||||
}
|
||||
|
||||
describe("TaskItemWithContextMenu for Office-owned tasks", () => {
|
||||
it("hides workflow actions", async () => {
|
||||
render(
|
||||
<StateProvider>
|
||||
<ToastProvider>
|
||||
<TaskItemWithContextMenu
|
||||
task={task({
|
||||
workflowId: WORKFLOW_ID,
|
||||
workflowStepId: STEP_ID,
|
||||
isFromOffice: true,
|
||||
})}
|
||||
workflows={[{ id: "workflow-2", name: "Workflow 2" }]}
|
||||
stepsByWorkflowId={{
|
||||
[WORKFLOW_ID]: [
|
||||
{ id: STEP_ID, title: "Step 1" },
|
||||
{ id: "step-2", title: "Step 2" },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<div data-testid="office-task-row">Office task</div>
|
||||
</TaskItemWithContextMenu>
|
||||
</ToastProvider>
|
||||
</StateProvider>,
|
||||
);
|
||||
|
||||
fireEvent.contextMenu(screen.getByTestId("office-task-row"));
|
||||
const menu = await screen.findByRole("menu");
|
||||
expect(within(menu).queryByRole("menuitem", { name: "Move to" })).toBeNull();
|
||||
expect(within(menu).queryByRole("menuitem", { name: "Change workflow..." })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -215,7 +215,7 @@ describe("TaskItemWithContextMenu — grouped single-task actions", () => {
|
||||
"Link",
|
||||
"Detach from parent",
|
||||
"Move to",
|
||||
"Send to workflow",
|
||||
"Change workflow...",
|
||||
PLUGIN_ACTION_LABEL,
|
||||
"Archive",
|
||||
"Delete",
|
||||
@@ -277,7 +277,7 @@ describe("TaskItemWithContextMenu — grouped bulk actions", () => {
|
||||
"Pin 2 tasks",
|
||||
"Color",
|
||||
"Move to",
|
||||
"Send to workflow",
|
||||
"Change workflow for selected tasks",
|
||||
"Archive 2 tasks",
|
||||
"Delete 2 tasks",
|
||||
]);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { cloneElement, isValidElement, useState, type ReactNode } from "react";
|
||||
import { cloneElement, isValidElement, useRef, useState, type ReactNode } from "react";
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from "@kandev/ui/context-menu";
|
||||
import {
|
||||
TaskMoveOptionsSurface,
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
SingleSelectionMenuItems,
|
||||
} from "./task-switcher-context-menu-items";
|
||||
import { useMenuTouchDragCancel } from "./task-switcher-touch-drag-cancel";
|
||||
import { ChangeWorkflowDialog } from "./change-workflow-dialog";
|
||||
|
||||
export type { StepDef } from "./task-switcher-types";
|
||||
export { createTaskLinkSelectAction } from "./task-switcher-link-menu";
|
||||
@@ -79,6 +80,39 @@ function useContextMenuOpenState(props: ContextMenuProps) {
|
||||
return { contextOpen, setContextOpen, openedNestSources, handleContextOpenChange };
|
||||
}
|
||||
|
||||
// The portal belongs to the sortable row's React tree; these guards stop events before they reach dnd-kit.
|
||||
function stopMenuEvent(event: { stopPropagation: () => void }) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
function keepWorkflowMoveOpen(event: { target: EventTarget | null; preventDefault: () => void }) {
|
||||
if (event.target && isWorkflowMoveOptionsTarget(event.target)) event.preventDefault();
|
||||
}
|
||||
|
||||
function createCloseMenu(
|
||||
setContextOpen: (open: boolean) => void,
|
||||
setMenuKey: (update: (key: number) => number) => void,
|
||||
) {
|
||||
return () => {
|
||||
setContextOpen(false);
|
||||
setMenuKey((key) => key + 1);
|
||||
};
|
||||
}
|
||||
|
||||
function createChangeWorkflowOpenHandler(
|
||||
taskId: string,
|
||||
currentTaskId: { current: string },
|
||||
closeMenu: () => void,
|
||||
setOpen: (open: boolean) => void,
|
||||
) {
|
||||
return () => {
|
||||
closeMenu();
|
||||
window.setTimeout(() => {
|
||||
if (currentTaskId.current === taskId) setOpen(true);
|
||||
}, 300);
|
||||
};
|
||||
}
|
||||
|
||||
// This component coordinates the context menu and drag cancellation. Archive
|
||||
// state lives in its focused adapter so unavailable actions stay unavailable.
|
||||
export function TaskItemWithContextMenu(props: ContextMenuProps) {
|
||||
@@ -87,11 +121,11 @@ export function TaskItemWithContextMenu(props: ContextMenuProps) {
|
||||
const { contextOpen, setContextOpen, openedNestSources, handleContextOpenChange } =
|
||||
useContextMenuOpenState(props);
|
||||
const [menuKey, setMenuKey] = useState(0);
|
||||
const [changeWorkflowOpen, setChangeWorkflowOpen] = useState(false);
|
||||
const taskIdRef = useRef(task.id);
|
||||
taskIdRef.current = task.id;
|
||||
const moveTasks = useTaskWorkflowMove();
|
||||
const closeMenu = () => {
|
||||
setContextOpen(false);
|
||||
setMenuKey((k) => k + 1);
|
||||
};
|
||||
const closeMenu = createCloseMenu(setContextOpen, setMenuKey);
|
||||
const { handleOpenChange, triggerProps } = useMenuTouchDragCancel(handleContextOpenChange);
|
||||
const { isFinePointer, isMobile } = useResponsiveBreakpoint();
|
||||
const usesTouchDrawer = useTouchDrawer();
|
||||
@@ -101,17 +135,10 @@ export function TaskItemWithContextMenu(props: ContextMenuProps) {
|
||||
isArchiving: menuProps.isArchiving,
|
||||
closeMenu,
|
||||
});
|
||||
const changeWorkflowTriggerRef = archive.archiveAnchorRef;
|
||||
const archiveConfirmation = archive.archiveOpen ? archive.archiveConfirmation : undefined;
|
||||
const inlineArchiveConfirmation = isMobile || isFinePointer ? undefined : archiveConfirmation;
|
||||
const portaledArchiveConfirmation = isMobile || isFinePointer ? archiveConfirmation : undefined;
|
||||
const {
|
||||
moveOptionsStep,
|
||||
isMoving,
|
||||
openMoveOptions,
|
||||
submitMoveOptions,
|
||||
submitMoveOptionsForStep,
|
||||
closeMoveOptions,
|
||||
} = useTaskMoveOptions({
|
||||
const moveOptions = useTaskMoveOptions({
|
||||
taskId: task.id,
|
||||
workflowId: task.workflowId,
|
||||
steps: task.workflowId ? (stepsByWorkflowId?.[task.workflowId] ?? steps) : steps,
|
||||
@@ -124,14 +151,10 @@ export function TaskItemWithContextMenu(props: ContextMenuProps) {
|
||||
return;
|
||||
}
|
||||
onBeforeMoveOptionsOpen?.();
|
||||
openMoveOptions(targetStepId);
|
||||
moveOptions.openMoveOptions(targetStepId);
|
||||
};
|
||||
// Fine pointers get the options inline in the "Move to" submenu; coarse/touch
|
||||
// pointers keep the long-press Drawer surface driven by
|
||||
// handleMoveToStepWithOptions. A wired onRequestMoveOptions (mobile sheet)
|
||||
// owns its own drawer, so the inline path stays off there too.
|
||||
const inlineSubmitWithOptions =
|
||||
!usesTouchDrawer && !onRequestMoveOptions ? submitMoveOptionsForStep : undefined;
|
||||
!usesTouchDrawer && !onRequestMoveOptions ? moveOptions.submitMoveOptionsForStep : undefined;
|
||||
const contextMenuProps = buildTaskContextMenuItemsProps(
|
||||
props,
|
||||
openedNestSources,
|
||||
@@ -140,7 +163,7 @@ export function TaskItemWithContextMenu(props: ContextMenuProps) {
|
||||
{
|
||||
onMoveToStepWithOptions: handleMoveToStepWithOptions,
|
||||
onSubmitWithOptions: inlineSubmitWithOptions,
|
||||
isMoving,
|
||||
isMoving: moveOptions.isMoving,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -150,37 +173,42 @@ export function TaskItemWithContextMenu(props: ContextMenuProps) {
|
||||
<ContextMenuTrigger asChild>
|
||||
<div ref={archive.archiveAnchorRef} tabIndex={-1} {...triggerProps}>
|
||||
{cloneWithMenuOpen(children, contextOpen, inlineArchiveConfirmation)}
|
||||
{portaledArchiveConfirmation}
|
||||
{(isMobile || isFinePointer) && archiveConfirmation}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent
|
||||
onCloseAutoFocus={archive.handleMenuCloseAutoFocus}
|
||||
className="w-48"
|
||||
onInteractOutside={(event) => {
|
||||
if (isWorkflowMoveOptionsTarget(event.target)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
// The menu renders in a portal whose fiber ancestors include the
|
||||
// dnd-kit drag handle that wraps the row. React synthetic events
|
||||
// bubble through the fiber tree, not the DOM, so without these guards
|
||||
// a mousedown/pointerdown/touchstart on any menu item reaches the
|
||||
// handle's sensor listeners and starts a row drag, and a click
|
||||
// activates the row. Bubble-phase guards run after the item's own
|
||||
// handlers, so menu actions still work.
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onTouchStart={(event) => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onInteractOutside={keepWorkflowMoveOpen}
|
||||
onMouseDown={stopMenuEvent}
|
||||
onPointerDown={stopMenuEvent}
|
||||
onTouchStart={stopMenuEvent}
|
||||
onClick={stopMenuEvent}
|
||||
>
|
||||
<TaskContextMenuItems {...contextMenuProps} onArchiveTask={archive.requestArchive} />
|
||||
<TaskContextMenuItems
|
||||
{...contextMenuProps}
|
||||
onArchiveTask={archive.requestArchive}
|
||||
onChangeWorkflow={createChangeWorkflowOpenHandler(
|
||||
task.id,
|
||||
taskIdRef,
|
||||
closeMenu,
|
||||
setChangeWorkflowOpen,
|
||||
)}
|
||||
/>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
<TaskMoveOptionsSurface
|
||||
step={moveOptionsStep}
|
||||
isMoving={isMoving}
|
||||
onClose={closeMoveOptions}
|
||||
onSubmit={submitMoveOptions}
|
||||
step={moveOptions.moveOptionsStep}
|
||||
isMoving={moveOptions.isMoving}
|
||||
onClose={moveOptions.closeMoveOptions}
|
||||
onSubmit={moveOptions.submitMoveOptions}
|
||||
/>
|
||||
<ChangeWorkflowDialog
|
||||
open={changeWorkflowOpen}
|
||||
onOpenChange={setChangeWorkflowOpen}
|
||||
taskId={task.id}
|
||||
workspaceId={task.workspaceId ?? null}
|
||||
focusReturnRef={changeWorkflowTriggerRef}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -194,6 +222,7 @@ export type TaskContextMenuItemsProps = Omit<
|
||||
| "getNestCandidateTasks"
|
||||
| "getNestHierarchyTasks"
|
||||
> & {
|
||||
onChangeWorkflow?: () => void;
|
||||
closeMenu: () => void;
|
||||
moveTasks: ReturnType<typeof useTaskWorkflowMove>;
|
||||
onMoveToStepWithOptions?: (targetStepId: string) => void;
|
||||
|
||||
@@ -455,7 +455,7 @@ describe("TaskSwitcher — archived rows action menu", () => {
|
||||
expect(screen.queryByText("Duplicate")).toBeNull();
|
||||
expect(screen.queryByText("Color")).toBeNull();
|
||||
expect(screen.queryByTestId("task-context-priority")).toBeNull();
|
||||
expect(screen.queryByText("Send to workflow")).toBeNull();
|
||||
expect(screen.queryByText("Change workflow...")).toBeNull();
|
||||
expect(screen.getByRole("menuitem", { name: "Delete" })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useArchiveAndSwitchTask, useDeleteAndSwitchTask } from "@/hooks/use-tas
|
||||
|
||||
/**
|
||||
* The detail surface has no board of its own to lean on, so a workflow move
|
||||
* (Move to / Send to workflow) landing the subject in a workflow this client
|
||||
* (Move to / Change workflow) landing the subject in a workflow this client
|
||||
* has never tracked would otherwise leave it absent from every board
|
||||
* collection even though it still exists. Subscribing to the task directly
|
||||
* guarantees its `task.updated` reaches this client regardless of workflow
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { WorkflowMoveEntryOptions } from "@/lib/api";
|
||||
import type { WorkflowChangePayload, WorkflowMoveEntryOptions } from "@/lib/api";
|
||||
import { useWorkflowMovePreview } from "@/hooks/domains/kanban/use-workflow-move-preview";
|
||||
import { useWorkflowMovePreviewRevision } from "@/hooks/domains/kanban/use-workflow-move-preview-revision";
|
||||
import { WorkflowMovePreviewDisclosure } from "./workflow-move-preview";
|
||||
@@ -14,10 +14,12 @@ export type WorkflowMovePreviewTarget = {
|
||||
export function WorkflowMovePreviewFooter({
|
||||
target,
|
||||
entryOptions,
|
||||
workflowChange,
|
||||
isTouchSurface,
|
||||
}: {
|
||||
target: WorkflowMovePreviewTarget;
|
||||
entryOptions?: WorkflowMoveEntryOptions;
|
||||
workflowChange?: WorkflowChangePayload;
|
||||
isTouchSurface: boolean;
|
||||
}) {
|
||||
const invalidationKey = useWorkflowMovePreviewRevision(
|
||||
@@ -25,6 +27,11 @@ export function WorkflowMovePreviewFooter({
|
||||
target.workflowId,
|
||||
target.workflowStepId,
|
||||
);
|
||||
const state = useWorkflowMovePreview({ ...target, entryOptions, invalidationKey });
|
||||
const state = useWorkflowMovePreview({
|
||||
...target,
|
||||
entryOptions,
|
||||
workflowChange,
|
||||
invalidationKey,
|
||||
});
|
||||
return <WorkflowMovePreviewDisclosure state={state} isTouchSurface={isTouchSurface} />;
|
||||
}
|
||||
|
||||
@@ -2628,6 +2628,18 @@ export class ApiClient {
|
||||
return this.request("GET", `/api/v1/tasks/${taskId}/sessions`);
|
||||
}
|
||||
|
||||
async getTaskSession(sessionId: string): Promise<{
|
||||
session: {
|
||||
id: string;
|
||||
task_id: string;
|
||||
agent_profile_id?: string;
|
||||
agent_profile_snapshot?: Record<string, unknown> | null;
|
||||
state: string;
|
||||
};
|
||||
}> {
|
||||
return this.request("GET", `/api/v1/task-sessions/${sessionId}`);
|
||||
}
|
||||
|
||||
async getQueueSessionIdentity(
|
||||
taskId: string,
|
||||
sessionId: string,
|
||||
@@ -2719,6 +2731,8 @@ export class ApiClient {
|
||||
|
||||
async getTask(taskId: string): Promise<{
|
||||
id: string;
|
||||
workspace_id?: string;
|
||||
workflow_id?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
autopilot?: boolean;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
export class ChangeWorkflowPage {
|
||||
readonly form: Locator;
|
||||
readonly desktopDialog: Locator;
|
||||
readonly phoneDrawer: Locator;
|
||||
|
||||
constructor(
|
||||
readonly page: Page,
|
||||
readonly mobile = false,
|
||||
) {
|
||||
this.form = page.getByTestId("change-workflow-form");
|
||||
this.desktopDialog = page.getByTestId("change-workflow-dialog");
|
||||
this.phoneDrawer = page.getByTestId("change-workflow-drawer");
|
||||
}
|
||||
|
||||
async chooseWorkflow(workflowId: string) {
|
||||
const trigger = this.form.getByTestId("change-workflow-destination");
|
||||
await expect(trigger).toBeVisible();
|
||||
if (this.mobile) await trigger.tap();
|
||||
else await trigger.click();
|
||||
const option = this.option(workflowId);
|
||||
if (this.mobile) await option.tap();
|
||||
else await option.click();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const stepPicker = this.form.getByTestId("change-workflow-step");
|
||||
const noSteps = this.form.getByTestId("change-workflow-no-steps");
|
||||
return (await stepPicker.isVisible()) || (await noSteps.isVisible());
|
||||
})
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
async chooseStep(stepId: string) {
|
||||
const trigger = this.form.getByTestId("change-workflow-step");
|
||||
await expect(trigger).toBeEnabled();
|
||||
if (this.mobile) await trigger.tap();
|
||||
else await trigger.click();
|
||||
const option = this.option(stepId);
|
||||
if (this.mobile) await option.tap();
|
||||
else await option.click();
|
||||
}
|
||||
|
||||
async chooseProfile(sourceProfileId: string, replacementProfileName: string) {
|
||||
const trigger = this.form.getByTestId(`change-workflow-profile-selector-${sourceProfileId}`);
|
||||
await expect(trigger).toBeVisible();
|
||||
if (this.mobile) await trigger.tap();
|
||||
else await trigger.click();
|
||||
const option = this.page.getByRole("option").filter({ hasText: replacementProfileName }).last();
|
||||
if (this.mobile) await option.tap();
|
||||
else await option.click();
|
||||
}
|
||||
|
||||
async submit() {
|
||||
const button = this.form.getByTestId("change-workflow-submit");
|
||||
await expect(button).toBeEnabled();
|
||||
if (this.mobile) await button.tap();
|
||||
else await button.click();
|
||||
}
|
||||
|
||||
private option(value: string) {
|
||||
return this.page.locator(`[role="option"][data-value="${value}"]`);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
import { ChangeWorkflowPage } from "./change-workflow-page";
|
||||
|
||||
export class KanbanPage {
|
||||
readonly board: Locator;
|
||||
@@ -68,8 +69,12 @@ export class KanbanPage {
|
||||
return this.page.getByTestId("task-context-move-to");
|
||||
}
|
||||
|
||||
contextSendToWorkflow(): Locator {
|
||||
return this.page.getByTestId("task-context-send-to-workflow");
|
||||
contextChangeWorkflow(): Locator {
|
||||
return this.page.getByTestId("task-context-change-workflow");
|
||||
}
|
||||
|
||||
contextChangeWorkflowSelection(): Locator {
|
||||
return this.page.getByTestId("task-context-change-workflow-selection");
|
||||
}
|
||||
|
||||
contextWorkflow(workflowId: string): Locator {
|
||||
@@ -138,21 +143,38 @@ export class KanbanPage {
|
||||
}
|
||||
|
||||
async sendTaskToWorkflow(taskId: string, workflowId: string, stepId: string) {
|
||||
await this.selectFromTaskContextMenu(taskId, () => this.selectWorkflowStep(workflowId, stepId));
|
||||
await this.selectFromTaskContextMenu(taskId, async () => {
|
||||
await expect(
|
||||
this.contextChangeWorkflow().or(this.contextChangeWorkflowSelection()),
|
||||
).toBeVisible();
|
||||
if (await this.contextChangeWorkflowSelection().isVisible()) {
|
||||
await this.selectBulkWorkflowStep(workflowId, stepId);
|
||||
return;
|
||||
}
|
||||
await this.selectChangeWorkflowForm(workflowId, stepId);
|
||||
});
|
||||
}
|
||||
|
||||
async sendTaskToWorkflowFromActions(taskId: string, workflowId: string, stepId: string) {
|
||||
await this.selectFromTaskActionsMenu(taskId, () => this.selectWorkflowStep(workflowId, stepId));
|
||||
await this.selectFromTaskActionsMenu(taskId, () =>
|
||||
this.selectChangeWorkflowForm(workflowId, stepId),
|
||||
);
|
||||
}
|
||||
|
||||
async openSendToWorkflowTargets(workflowId: string) {
|
||||
await this.openSubmenu(this.contextSendToWorkflow(), this.contextWorkflow(workflowId));
|
||||
await this.openChangeWorkflowForm();
|
||||
await this.changeWorkflowPage().chooseWorkflow(workflowId);
|
||||
}
|
||||
|
||||
async openSendToWorkflowStep(workflowId: string, stepId: string) {
|
||||
const workflow = this.contextWorkflow(workflowId);
|
||||
await this.openSubmenu(this.contextSendToWorkflow(), workflow);
|
||||
await this.openSubmenu(workflow, this.contextStep(stepId));
|
||||
await this.openChangeWorkflowForm();
|
||||
await this.changeWorkflowPage().chooseWorkflow(workflowId);
|
||||
await this.changeWorkflowPage().chooseStep(stepId);
|
||||
}
|
||||
|
||||
async openChangeWorkflowForm() {
|
||||
await this.contextChangeWorkflow().click();
|
||||
await expect(this.page.getByTestId("change-workflow-form")).toBeVisible();
|
||||
}
|
||||
|
||||
private async selectFromTaskContextMenu(taskId: string, select: () => Promise<void>) {
|
||||
@@ -178,14 +200,26 @@ export class KanbanPage {
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
private async selectWorkflowStep(workflowId: string, stepId: string) {
|
||||
private async selectChangeWorkflowForm(workflowId: string, stepId: string) {
|
||||
await this.openChangeWorkflowForm();
|
||||
const form = this.changeWorkflowPage();
|
||||
await form.chooseWorkflow(workflowId);
|
||||
await form.chooseStep(stepId);
|
||||
await form.submit();
|
||||
}
|
||||
|
||||
private async selectBulkWorkflowStep(workflowId: string, stepId: string) {
|
||||
const workflow = this.contextWorkflow(workflowId);
|
||||
const step = this.contextStep(stepId);
|
||||
await this.openSubmenu(this.contextSendToWorkflow(), workflow);
|
||||
await this.openSubmenu(this.contextChangeWorkflowSelection(), workflow);
|
||||
await this.openSubmenu(workflow, step);
|
||||
await this.selectMenuItem(step);
|
||||
}
|
||||
|
||||
private changeWorkflowPage() {
|
||||
return new ChangeWorkflowPage(this.page);
|
||||
}
|
||||
|
||||
private async openSubmenu(trigger: Locator, child: Locator) {
|
||||
await expect(trigger).toBeVisible({ timeout: 2_000 });
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type Locator, type Page, expect } from "@playwright/test";
|
||||
import { FileTreePage } from "./file-tree-page";
|
||||
import { NewSessionDialogPage } from "./new-session-dialog-page";
|
||||
import { ChangeWorkflowPage } from "./change-workflow-page";
|
||||
import { dwell } from "../helpers/causal-waits";
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
@@ -402,9 +403,12 @@ export class SessionPage {
|
||||
stepId: string,
|
||||
): Promise<void> {
|
||||
await this.openSidebarTaskContextMenu(title);
|
||||
await this.page.getByTestId("task-context-send-to-workflow").hover();
|
||||
await this.page.getByTestId(`task-context-workflow-${workflowId}`).hover();
|
||||
await this.page.getByTestId(`task-context-step-${stepId}`).click();
|
||||
await this.page.getByTestId("task-context-change-workflow").click();
|
||||
const changeWorkflow = new ChangeWorkflowPage(this.page);
|
||||
await changeWorkflow.form.waitFor({ state: "visible" });
|
||||
await changeWorkflow.chooseWorkflow(workflowId);
|
||||
await changeWorkflow.chooseStep(stepId);
|
||||
await changeWorkflow.submit();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -91,6 +91,7 @@ test("mobile oversized previews stay bounded, downloadable, and touch-sized", as
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
const { source, tail, firstLine } = oversizedMessage("MOBILE-OVERSIZED");
|
||||
const slowPrompt = "/slow 30s";
|
||||
const firstTitle = `Mobile oversized message ${Date.now()}`;
|
||||
const task = await createTask(apiClient, seedData, firstTitle);
|
||||
if (!task.session_id) throw new Error("oversized mobile task has no session_id");
|
||||
@@ -156,7 +157,7 @@ test("mobile oversized previews stay bounded, downloadable, and touch-sized", as
|
||||
1,
|
||||
);
|
||||
|
||||
await session.sendMessageViaButton("/slow 10s");
|
||||
await session.sendMessageViaButton(slowPrompt);
|
||||
await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 });
|
||||
await waitForComposerQueueMode(testPage);
|
||||
const queued = oversizedMessage("MOBILE-QUEUED");
|
||||
@@ -210,7 +211,7 @@ test("mobile oversized previews stay bounded, downloadable, and touch-sized", as
|
||||
async () => {
|
||||
const { messages } = await apiClient.listSessionMessages(task.session_id!);
|
||||
return messages.some(
|
||||
(message) => message.author_type === "user" && message.content === "/slow 10s",
|
||||
(message) => message.author_type === "user" && message.content === slowPrompt,
|
||||
);
|
||||
},
|
||||
{ timeout: 30_000, message: "the mobile follow-up prompt should be stored" },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { ChangeWorkflowPage } from "../../pages/change-workflow-page";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
|
||||
test.describe("Cross-workflow task move from home", () => {
|
||||
@@ -22,7 +23,7 @@ test.describe("Cross-workflow task move from home", () => {
|
||||
|
||||
await kanban.openTaskContextMenu(task.id);
|
||||
await expect(kanban.contextMoveTo()).toBeVisible();
|
||||
await expect(kanban.contextSendToWorkflow()).not.toBeVisible();
|
||||
await expect(kanban.contextChangeWorkflow()).not.toBeVisible();
|
||||
await testPage.keyboard.press("Escape");
|
||||
await kanban.moveTaskWithinWorkflow(task.id, targetStep.id);
|
||||
|
||||
@@ -52,7 +53,7 @@ test.describe("Cross-workflow task move from home", () => {
|
||||
await kanban.sendTaskToWorkflow(task.id, targetWorkflow.id, targetStep.id);
|
||||
|
||||
await expect(kanban.taskCard(task.id)).not.toBeVisible({ timeout: 10_000 });
|
||||
await expect(testPage.getByText(/Moved task to/i)).toBeVisible({ timeout: 10_000 });
|
||||
await expect(testPage.getByText("Workflow changed.")).toBeVisible({ timeout: 10_000 });
|
||||
expect(testPage.url()).toBe(beforeUrl);
|
||||
|
||||
await testPage.goto(`/?workflowId=${targetWorkflow.id}`);
|
||||
@@ -90,7 +91,7 @@ test.describe("Cross-workflow task move from home", () => {
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
|
||||
const expectedLabels = ["Edit", "Move to", "Send to workflow", "Archive", "Delete"];
|
||||
const expectedLabels = ["Edit", "Move to", "Change workflow...", "Archive", "Delete"];
|
||||
await kanban.openTaskContextMenu(task.id);
|
||||
for (const label of expectedLabels) {
|
||||
await expect(testPage.getByRole("menuitem", { name: label })).toBeVisible();
|
||||
@@ -105,7 +106,7 @@ test.describe("Cross-workflow task move from home", () => {
|
||||
await kanban.sendTaskToWorkflowFromActions(task.id, targetWorkflow.id, targetStep.id);
|
||||
|
||||
await expect(kanban.taskCard(task.id)).not.toBeVisible({ timeout: 10_000 });
|
||||
await expect(testPage.getByText(/Moved task to/i)).toBeVisible({ timeout: 10_000 });
|
||||
await expect(testPage.getByText("Workflow changed.")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await testPage.goto(`/?workflowId=${targetWorkflow.id}`);
|
||||
await expect(kanban.taskCardInColumn("Cross Workflow Actions Task", targetStep.id)).toBeVisible(
|
||||
@@ -113,7 +114,7 @@ test.describe("Cross-workflow task move from home", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("shows no-step workflows as disabled send targets", async ({
|
||||
test("shows an empty-state form for workflows without steps", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
@@ -130,13 +131,14 @@ test.describe("Cross-workflow task move from home", () => {
|
||||
await kanban.goto();
|
||||
|
||||
await kanban.openTaskContextMenu(task.id);
|
||||
await kanban.openSendToWorkflowTargets(emptyWorkflow.id);
|
||||
|
||||
await expect(kanban.contextWorkflow(emptyWorkflow.id)).toBeVisible();
|
||||
await expect(kanban.contextWorkflow(emptyWorkflow.id)).toBeDisabled();
|
||||
await expect(
|
||||
kanban.contextWorkflow(emptyWorkflow.id).getByTestId("task-context-disabled-reason"),
|
||||
).toContainText("No steps");
|
||||
await kanban.openChangeWorkflowForm();
|
||||
const changeWorkflow = new ChangeWorkflowPage(testPage);
|
||||
await changeWorkflow.chooseWorkflow(emptyWorkflow.id);
|
||||
await expect(testPage.getByTestId("change-workflow-no-steps")).toContainText(
|
||||
"This workflow has no steps",
|
||||
);
|
||||
await expect(testPage.getByTestId("change-workflow-submit")).toBeDisabled();
|
||||
await testPage.getByTestId("change-workflow-cancel").click();
|
||||
});
|
||||
|
||||
test("marks auto-start target steps before moving", async ({ testPage, apiClient, seedData }) => {
|
||||
@@ -159,7 +161,10 @@ test.describe("Cross-workflow task move from home", () => {
|
||||
await kanban.goto();
|
||||
|
||||
await kanban.openTaskContextMenu(task.id);
|
||||
await kanban.openSendToWorkflowStep(targetWorkflow.id, targetStep.id);
|
||||
await expect(kanban.contextAutoStartStep(targetStep.id)).toBeVisible();
|
||||
await kanban.openChangeWorkflowForm();
|
||||
const changeWorkflow = new ChangeWorkflowPage(testPage);
|
||||
await changeWorkflow.chooseWorkflow(targetWorkflow.id);
|
||||
await changeWorkflow.chooseStep(targetStep.id);
|
||||
await expect(testPage.getByTestId("workflow-move-preview")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import { assertNoDocumentHorizontalOverflow, requireBox } from "../../helpers/layout-assertions";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
|
||||
@@ -50,6 +51,7 @@ test("adds a GitHub PR to the merge queue from mobile Review", async ({
|
||||
checks_passing: 3,
|
||||
});
|
||||
await apiClient.mockGitHubSetMergeOutcome("northstar-labs", "relay-console", 843, "queued");
|
||||
await seedEligiblePRFeedback(apiClient);
|
||||
|
||||
await testPage.goto(`/t/${task.id}`);
|
||||
const session = new SessionPage(testPage);
|
||||
@@ -79,6 +81,7 @@ test("adds a GitHub PR to the merge queue from mobile Review", async ({
|
||||
checks_total: 3,
|
||||
checks_passing: 3,
|
||||
});
|
||||
await seedEligiblePRFeedback(apiClient);
|
||||
const merge = panel.getByRole("button", { name: "Merge PR" });
|
||||
await expect(merge).toBeVisible({ timeout: 15_000 });
|
||||
expect((await requireBox(merge, "mobile merge queue action")).height).toBeGreaterThanOrEqual(44);
|
||||
@@ -87,6 +90,23 @@ test("adds a GitHub PR to the merge queue from mobile Review", async ({
|
||||
await expect(testPage.getByText("PR added to merge queue", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
async function seedEligiblePRFeedback(apiClient: ApiClient) {
|
||||
await apiClient.mockGitHubSeedPRFeedback({
|
||||
owner: "northstar-labs",
|
||||
repo: "relay-console",
|
||||
pr_number: 843,
|
||||
checks: [
|
||||
{ name: "Required check 1", status: "completed", conclusion: "success" },
|
||||
{ name: "Required check 2", status: "completed", conclusion: "success" },
|
||||
{ name: "Required check 3", status: "completed", conclusion: "success" },
|
||||
],
|
||||
reviews: [
|
||||
{ id: 1, author: "reviewer-one", state: "APPROVED", created_at: "2026-09-24T10:00:00Z" },
|
||||
{ id: 2, author: "reviewer-two", state: "APPROVED", created_at: "2026-09-24T11:00:00Z" },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
test("surfaces queued PR metadata in the mobile drawer and Review", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
|
||||
@@ -189,4 +189,18 @@ async function seedEligiblePR(apiClient: ApiClient, taskId: string) {
|
||||
checks_passing: 3,
|
||||
});
|
||||
await apiClient.mockGitHubSetMergeOutcome(OWNER, REPO, PR_NUMBER, "queued");
|
||||
await apiClient.mockGitHubSeedPRFeedback({
|
||||
owner: OWNER,
|
||||
repo: REPO,
|
||||
pr_number: PR_NUMBER,
|
||||
checks: [
|
||||
{ name: "Required check 1", status: "completed", conclusion: "success" },
|
||||
{ name: "Required check 2", status: "completed", conclusion: "success" },
|
||||
{ name: "Required check 3", status: "completed", conclusion: "success" },
|
||||
],
|
||||
reviews: [
|
||||
{ id: 1, author: "reviewer-one", state: "APPROVED", created_at: "2026-09-24T10:00:00Z" },
|
||||
{ id: 2, author: "reviewer-two", state: "APPROVED", created_at: "2026-09-24T11:00:00Z" },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { ChangeWorkflowPage } from "../../pages/change-workflow-page";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
import {
|
||||
seedWorkflowAgentOverrideFixture,
|
||||
waitForNewWorkflowProfileSession,
|
||||
waitForWorkflowMoveLifecycle,
|
||||
waitForWorkflowStep,
|
||||
} from "./task-workflow-agent-overrides-helpers";
|
||||
|
||||
test.describe("Change workflow", () => {
|
||||
test("maps a task profile for a later destination step and preserves its context", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
prCapture,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
const fixture = await seedWorkflowAgentOverrideFixture(
|
||||
apiClient,
|
||||
seedData,
|
||||
"Change Workflow Desktop",
|
||||
);
|
||||
const task = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
"Change workflow source task",
|
||||
fixture.profileA.id,
|
||||
{
|
||||
description: "Keep this task brief and repository context",
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
executor_profile_id: seedData.worktreeExecutorProfileId,
|
||||
},
|
||||
);
|
||||
const existingSessionId = await waitForNewWorkflowProfileSession(
|
||||
apiClient,
|
||||
task.id,
|
||||
fixture.profileA.id,
|
||||
);
|
||||
const before = await apiClient.getTask(task.id);
|
||||
const previewChanges: Array<Record<string, unknown>> = [];
|
||||
testPage.on("request", (request) => {
|
||||
if (request.url().includes(`/api/v1/tasks/${task.id}/move-preview`)) {
|
||||
const body = request.postDataJSON() as {
|
||||
workflow_change?: { agent_overrides?: Record<string, string> };
|
||||
};
|
||||
if (body.workflow_change)
|
||||
previewChanges.push(body.workflow_change as unknown as Record<string, unknown>);
|
||||
}
|
||||
});
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
await kanban.openTaskActionsMenu(task.id);
|
||||
|
||||
await expect(testPage.getByRole("menuitem", { name: "Change workflow..." })).toBeVisible();
|
||||
await kanban.contextChangeWorkflow().click();
|
||||
const changeWorkflow = new ChangeWorkflowPage(testPage);
|
||||
await expect(changeWorkflow.desktopDialog).toBeVisible();
|
||||
await changeWorkflow.chooseWorkflow(fixture.workflow.id);
|
||||
await changeWorkflow.chooseStep(fixture.prStep.id);
|
||||
await changeWorkflow.chooseProfile(fixture.profileA.id, fixture.profileB.name);
|
||||
await expect
|
||||
.poll(() =>
|
||||
previewChanges.some((change) =>
|
||||
JSON.stringify(change.agent_overrides).includes(fixture.profileB.id),
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
await expect(testPage.getByTestId("workflow-move-preview")).toBeVisible();
|
||||
const preview = testPage.getByTestId("workflow-move-preview");
|
||||
await expect(preview).toContainText("New session");
|
||||
await preview.getByTestId("workflow-move-preview-details-toggle").click();
|
||||
const previewDetails = testPage.getByTestId("workflow-move-preview-details");
|
||||
await expect(previewDetails).toContainText(fixture.profileB.name);
|
||||
await expect(previewDetails).toContainText("mock-slow");
|
||||
await changeWorkflow.form
|
||||
.getByTestId("change-workflow-scroll")
|
||||
.evaluate((element) => element.scrollTo({ top: 0 }));
|
||||
await testPage.screenshot({ path: testInfo.outputPath("desktop-change-workflow-form.png") });
|
||||
if (prCapture.capturing) {
|
||||
await testPage.evaluate(async () => {
|
||||
await Promise.all(
|
||||
document
|
||||
.getAnimations()
|
||||
.filter((animation) => animation.playState === "running")
|
||||
.map((animation) => animation.finished.catch(() => undefined)),
|
||||
);
|
||||
});
|
||||
await prCapture.screenshot("desktop-change-workflow-form", {
|
||||
caption: "Desktop Change workflow form with its predicted destination recipient.",
|
||||
});
|
||||
}
|
||||
await changeWorkflow.submit();
|
||||
await expect(changeWorkflow.desktopDialog).toBeHidden();
|
||||
|
||||
await waitForWorkflowStep(apiClient, task.id, fixture.prStep.id);
|
||||
const destinationSessionId = await waitForNewWorkflowProfileSession(
|
||||
apiClient,
|
||||
task.id,
|
||||
fixture.profileB.id,
|
||||
[existingSessionId],
|
||||
);
|
||||
await waitForWorkflowMoveLifecycle(apiClient, task.id);
|
||||
const { session: routedSession } = await apiClient.getTaskSession(destinationSessionId);
|
||||
expect(routedSession.agent_profile_id).toBe(fixture.profileB.id);
|
||||
expect(routedSession.agent_profile_snapshot?.model).toBe("mock-slow");
|
||||
const changed = await apiClient.getTask(task.id);
|
||||
expect(changed).toMatchObject({
|
||||
id: before.id,
|
||||
title: before.title,
|
||||
description: before.description,
|
||||
workflow_id: fixture.workflow.id,
|
||||
workflow_step_id: fixture.prStep.id,
|
||||
repositories: before.repositories,
|
||||
workflow_agent_overrides: {
|
||||
workflow_id: fixture.workflow.id,
|
||||
steps: [
|
||||
{
|
||||
step_id: fixture.implementStep.id,
|
||||
source_profile_id: fixture.profileA.id,
|
||||
replacement_profile_id: fixture.profileB.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const { sessions } = await apiClient.listTaskSessions(task.id);
|
||||
expect(sessions.some((session) => session.id === existingSessionId)).toBe(true);
|
||||
});
|
||||
|
||||
test("single-task context action opens the same form from right-click", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const targetWorkflow = await apiClient.createWorkflow(
|
||||
seedData.workspaceId,
|
||||
"Change workflow target",
|
||||
);
|
||||
await apiClient.createWorkflowStep(targetWorkflow.id, "Incoming", 0);
|
||||
const task = await apiClient.createTask(seedData.workspaceId, "Change workflow context task", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
await kanban.openTaskContextMenu(task.id);
|
||||
await expect(kanban.contextChangeWorkflow()).toBeVisible();
|
||||
await kanban.openChangeWorkflowForm();
|
||||
await expect(testPage.getByRole("dialog", { name: "Change workflow..." })).toBeVisible();
|
||||
await testPage.getByTestId("change-workflow-cancel").click();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("coarse-pointer tablet Change workflow", () => {
|
||||
test.use({ hasTouch: true, viewport: { width: 900, height: 1000 } });
|
||||
|
||||
test("keeps touch-sized form controls in the desktop dialog", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const destination = await apiClient.createWorkflow(seedData.workspaceId, "Tablet target");
|
||||
const step = await apiClient.createWorkflowStep(destination.id, "Incoming", 0);
|
||||
const task = await apiClient.createTask(seedData.workspaceId, "Tablet change workflow task", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
await kanban.openTaskActionsMenu(task.id);
|
||||
await expect(kanban.contextChangeWorkflow()).toBeVisible();
|
||||
await kanban.openChangeWorkflowForm();
|
||||
|
||||
const form = new ChangeWorkflowPage(testPage);
|
||||
await expect(form.desktopDialog).toBeVisible();
|
||||
await expect(form.phoneDrawer).toHaveCount(0);
|
||||
expect(await testPage.evaluate(() => window.innerWidth)).toBe(900);
|
||||
expect(await testPage.evaluate(() => matchMedia("(pointer: coarse)").matches)).toBe(true);
|
||||
await form.chooseWorkflow(destination.id);
|
||||
await form.chooseStep(step.id);
|
||||
|
||||
for (const control of [
|
||||
testPage.getByTestId("change-workflow-close"),
|
||||
form.form.getByTestId("change-workflow-destination"),
|
||||
form.form.getByTestId("change-workflow-step"),
|
||||
form.form.getByTestId("change-workflow-submit"),
|
||||
]) {
|
||||
const box = await control.boundingBox();
|
||||
expect(box?.height).toBeGreaterThanOrEqual(44);
|
||||
const receivesPointer = await control.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return element.contains(
|
||||
document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2),
|
||||
);
|
||||
});
|
||||
expect(receivesPointer).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { ChangeWorkflowPage } from "../../pages/change-workflow-page";
|
||||
import {
|
||||
seedMoveOverrideFixture,
|
||||
waitForMoveRequest,
|
||||
@@ -143,3 +144,29 @@ test("shows destination colors and moves immediately with the modified Enter sho
|
||||
.poll(async () => (await apiClient.getTask(fixture.taskId)).workflow_step_id)
|
||||
.toBe(fixture.targetStepId);
|
||||
});
|
||||
|
||||
test("opens the shared change workflow form from the command palette", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const fixture = await seedMoveOverrideFixture(
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
"Keyboard change workflow",
|
||||
);
|
||||
const destination = await apiClient.createWorkflow(seedData.workspaceId, "Keyboard destination");
|
||||
const destinationStep = await apiClient.createWorkflowStep(destination.id, "Incoming", 0);
|
||||
await testPage.reload();
|
||||
|
||||
await chooseCommand(testPage, "Change workflow...");
|
||||
const form = new ChangeWorkflowPage(testPage);
|
||||
await expect(form.desktopDialog).toBeVisible();
|
||||
await form.chooseWorkflow(destination.id);
|
||||
await form.chooseStep(destinationStep.id);
|
||||
await form.form.getByTestId("change-workflow-cancel").click();
|
||||
|
||||
expect((await apiClient.getTask(fixture.taskId)).workflow_id).toBe(fixture.workflowId);
|
||||
await expect(testPage).toHaveURL(new RegExp(`/t/${fixture.taskId}$`));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
|
||||
import { waitForLatestSessionDone } from "../../helpers/session";
|
||||
import { ChangeWorkflowPage } from "../../pages/change-workflow-page";
|
||||
import { ThreadActionsPage } from "./threads-task-actions-helpers";
|
||||
import {
|
||||
seedWorkflowAgentOverrideFixture,
|
||||
waitForNewWorkflowProfileSession,
|
||||
waitForWorkflowMoveLifecycle,
|
||||
waitForWorkflowStep,
|
||||
} from "./task-workflow-agent-overrides-helpers";
|
||||
|
||||
test("changes workflow from phone task actions with task-local agent routing", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
prCapture,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(180_000);
|
||||
await testPage.setViewportSize({ width: 360, height: 640 });
|
||||
const fixture = await seedWorkflowAgentOverrideFixture(
|
||||
apiClient,
|
||||
seedData,
|
||||
"Change Workflow Phone",
|
||||
);
|
||||
const task = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
"Phone change workflow task",
|
||||
fixture.profileA.id,
|
||||
{
|
||||
description: "/e2e:simple-message",
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
executor_profile_id: seedData.worktreeExecutorProfileId,
|
||||
},
|
||||
);
|
||||
await waitForLatestSessionDone(apiClient, task.id, 1, "phone change workflow source task");
|
||||
const originalSessionId = await waitForNewWorkflowProfileSession(
|
||||
apiClient,
|
||||
task.id,
|
||||
fixture.profileA.id,
|
||||
);
|
||||
const before = await apiClient.getTask(task.id);
|
||||
await testPage.goto(`/threads?workspace=${seedData.workspaceId}&taskId=${task.id}`);
|
||||
const actions = new ThreadActionsPage(testPage, true);
|
||||
await expect(actions.trigger(task.id)).toBeVisible();
|
||||
await actions.expectHeaderActionAlignment(task.id);
|
||||
|
||||
const openChangeForm = async () => {
|
||||
await actions.open(task.id);
|
||||
await expect(actions.choice("Change workflow...")).toBeVisible();
|
||||
await actions.pick("Change workflow...");
|
||||
};
|
||||
const chooseDraft = async () => {
|
||||
const form = new ChangeWorkflowPage(testPage, true);
|
||||
await expect(form.phoneDrawer).toBeVisible();
|
||||
await form.chooseWorkflow(fixture.workflow.id);
|
||||
await form.chooseStep(fixture.prStep.id);
|
||||
await form.chooseProfile(fixture.profileA.id, fixture.profileB.name);
|
||||
return form;
|
||||
};
|
||||
|
||||
await openChangeForm();
|
||||
const form = await chooseDraft();
|
||||
await actions.contained(form.phoneDrawer);
|
||||
const scroll = form.form.getByTestId("change-workflow-scroll");
|
||||
await expect(scroll).toHaveCSS("overflow-y", "auto");
|
||||
expect(
|
||||
await form.phoneDrawer.evaluate(
|
||||
(element) =>
|
||||
[element, ...element.querySelectorAll("*")].filter((node) =>
|
||||
/auto|scroll/.test(getComputedStyle(node).overflowY),
|
||||
).length,
|
||||
),
|
||||
).toBe(1);
|
||||
const footer = testPage.getByTestId("change-workflow-submit").locator("xpath=..");
|
||||
await expect(footer).toHaveClass(/safe-area-inset-bottom/);
|
||||
|
||||
for (const selector of [
|
||||
form.form.getByTestId("change-workflow-destination"),
|
||||
form.form.getByTestId("change-workflow-step"),
|
||||
form.form.getByTestId(`change-workflow-profile-selector-${fixture.profileA.id}`),
|
||||
form.form.getByTestId("change-workflow-submit"),
|
||||
]) {
|
||||
await selector.scrollIntoViewIfNeeded();
|
||||
const target = await selector.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
receivesTouch: element.contains(
|
||||
document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2),
|
||||
),
|
||||
};
|
||||
});
|
||||
expect(target.width).toBeGreaterThanOrEqual(44);
|
||||
expect(target.height).toBeGreaterThanOrEqual(44);
|
||||
expect(target.receivesTouch).toBe(true);
|
||||
}
|
||||
await assertNoDocumentHorizontalOverflow(testPage, "phone change workflow form");
|
||||
await scroll.evaluate((element) => element.scrollTo({ top: 0 }));
|
||||
await testPage.screenshot({ path: testInfo.outputPath("phone-change-workflow-form.png") });
|
||||
if (prCapture.capturing) {
|
||||
await testPage.evaluate(async () => {
|
||||
await Promise.all(
|
||||
document
|
||||
.getAnimations()
|
||||
.filter((animation) => animation.playState === "running")
|
||||
.map((animation) => animation.finished.catch(() => undefined)),
|
||||
);
|
||||
});
|
||||
await prCapture.screenshot("phone-change-workflow-form", {
|
||||
caption: "Phone Change workflow form with contained scrolling and a fixed submit action.",
|
||||
});
|
||||
}
|
||||
await actions.press(form.form.getByTestId("change-workflow-cancel"));
|
||||
await expect(form.phoneDrawer).toBeHidden();
|
||||
await expect(actions.trigger(task.id)).toBeFocused();
|
||||
|
||||
await openChangeForm();
|
||||
const submitForm = await chooseDraft();
|
||||
await submitForm.submit();
|
||||
await expect(submitForm.phoneDrawer).toBeHidden();
|
||||
await waitForWorkflowStep(apiClient, task.id, fixture.prStep.id);
|
||||
await waitForNewWorkflowProfileSession(apiClient, task.id, fixture.profileB.id, [
|
||||
originalSessionId,
|
||||
]);
|
||||
await waitForWorkflowMoveLifecycle(apiClient, task.id);
|
||||
const changed = await apiClient.getTask(task.id);
|
||||
expect(changed).toMatchObject({
|
||||
id: before.id,
|
||||
title: before.title,
|
||||
description: before.description,
|
||||
workflow_id: fixture.workflow.id,
|
||||
workflow_step_id: fixture.prStep.id,
|
||||
repositories: before.repositories,
|
||||
workflow_agent_overrides: {
|
||||
workflow_id: fixture.workflow.id,
|
||||
steps: [
|
||||
{
|
||||
step_id: fixture.implementStep.id,
|
||||
source_profile_id: fixture.profileA.id,
|
||||
replacement_profile_id: fixture.profileB.id,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
const { sessions } = await apiClient.listTaskSessions(task.id);
|
||||
expect(sessions.some((session) => session.id === originalSessionId)).toBe(true);
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
|
||||
import { ChangeWorkflowPage } from "../../pages/change-workflow-page";
|
||||
import {
|
||||
seedMoveOverrideFixture,
|
||||
waitForMoveRequest,
|
||||
@@ -68,3 +69,43 @@ test("uses nested task commands and the move drawer on a phone", async ({
|
||||
await testPage.screenshot({ path: "test-results/mobile-task-command-archive.png" });
|
||||
await cancel.tap();
|
||||
});
|
||||
|
||||
test("opens the shared change workflow form from the phone command palette", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
await testPage.setViewportSize({ width: 360, height: 780 });
|
||||
const fixture = await seedMoveOverrideFixture(
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
"Phone palette change workflow",
|
||||
);
|
||||
const destination = await apiClient.createWorkflow(seedData.workspaceId, "Phone destination");
|
||||
const destinationStep = await apiClient.createWorkflowStep(destination.id, "Incoming", 0);
|
||||
await testPage.reload();
|
||||
|
||||
await testPage.keyboard.press("Control+k");
|
||||
const palette = testPage.getByRole("dialog").filter({ has: testPage.getByRole("combobox") });
|
||||
const search = palette.getByRole("combobox");
|
||||
await search.fill("Change workflow...");
|
||||
const command = palette
|
||||
.getByRole("option")
|
||||
.filter({ has: testPage.getByText("Change workflow...", { exact: true }) });
|
||||
await expect(command).toBeVisible();
|
||||
expect((await command.boundingBox())!.height).toBeGreaterThanOrEqual(44);
|
||||
await command.tap();
|
||||
|
||||
const form = new ChangeWorkflowPage(testPage, true);
|
||||
await expect(form.phoneDrawer).toBeVisible();
|
||||
await form.chooseWorkflow(destination.id);
|
||||
await form.chooseStep(destinationStep.id);
|
||||
const submit = form.form.getByTestId("change-workflow-submit");
|
||||
await submit.scrollIntoViewIfNeeded();
|
||||
expect((await submit.boundingBox())!.height).toBeGreaterThanOrEqual(44);
|
||||
await assertNoDocumentHorizontalOverflow(testPage, "phone command palette change workflow");
|
||||
await form.form.getByTestId("change-workflow-cancel").tap();
|
||||
|
||||
expect((await apiClient.getTask(fixture.taskId)).workflow_id).toBe(fixture.workflowId);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
|
||||
import { ChangeWorkflowPage } from "../../pages/change-workflow-page";
|
||||
import {
|
||||
allTaskActionOutcomes,
|
||||
seedActionThreads,
|
||||
@@ -55,7 +57,7 @@ test("completes all six task actions by touch and cancels destructive choices",
|
||||
});
|
||||
|
||||
// @covers AC-TASKS-THREADS-ACTIONS-004.2 through AC-TASKS-THREADS-ACTIONS-004.7
|
||||
test("contains nested choices with long labels and preserves native swiping after dismissal", async ({
|
||||
test("contains the mobile change form and preserves native swiping after dismissal", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
@@ -65,8 +67,18 @@ test("contains nested choices with long labels and preserves native swiping afte
|
||||
const destinationName = "Workflow".repeat(12);
|
||||
await apiClient.updateWorkflow(destination.id, { name: destinationName });
|
||||
const longName = "A deliberately long workflow step " + "unbroken".repeat(24);
|
||||
for (let index = 0; index < 18; index++)
|
||||
await apiClient.createWorkflowStep(destination.id, `${index} ${longName}`, index + 1);
|
||||
let finalStepId = "";
|
||||
for (let index = 0; index < 18; index++) {
|
||||
const step = await apiClient.createWorkflowStep(
|
||||
destination.id,
|
||||
`${index} ${longName}`,
|
||||
index + 1,
|
||||
{
|
||||
session_target: { kind: "initial" },
|
||||
},
|
||||
);
|
||||
if (index === 17) finalStepId = step.id;
|
||||
}
|
||||
await apiClient.updateTaskTitle(a.id, "Task".repeat(15));
|
||||
await testPage.setViewportSize({ width: 360, height: 780 });
|
||||
await testPage.goto(`/threads?workspace=${seedData.workspaceId}`);
|
||||
@@ -85,32 +97,45 @@ test("contains nested choices with long labels and preserves native swiping afte
|
||||
await ui.contained(drawer);
|
||||
await expect(ui.choice("Close")).toHaveCSS("cursor", "pointer");
|
||||
await testPage.screenshot({ path: testInfo.outputPath("phone-root-360.png") });
|
||||
await ui.nested("Send to workflow");
|
||||
await expect(ui.choice("Back")).toHaveCSS("cursor", "pointer");
|
||||
await ui.nested(destinationName);
|
||||
await ui.contained(drawer);
|
||||
await expect(testPage.getByRole("dialog")).toHaveCount(1);
|
||||
const scroll = testPage.getByTestId("task-management-scroll");
|
||||
expect(await scroll.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(
|
||||
true,
|
||||
);
|
||||
await ui.pick("Change workflow...");
|
||||
const form = new ChangeWorkflowPage(testPage, true);
|
||||
await expect(form.phoneDrawer).toBeVisible();
|
||||
await ui.contained(form.phoneDrawer);
|
||||
await form.chooseWorkflow(destination.id);
|
||||
const scroll = form.form.getByTestId("change-workflow-scroll");
|
||||
expect(await scroll.evaluate((element) => getComputedStyle(element).overflowY)).toBe("auto");
|
||||
expect(
|
||||
await drawer.evaluate(
|
||||
(element) =>
|
||||
[element, ...element.querySelectorAll("*")].filter(
|
||||
await form.phoneDrawer.evaluate((element) =>
|
||||
[element, ...element.querySelectorAll("*")]
|
||||
.filter(
|
||||
(node) =>
|
||||
/auto|scroll/.test(getComputedStyle(node).overflowY) &&
|
||||
node.scrollHeight > node.clientHeight,
|
||||
).length,
|
||||
)
|
||||
.map((node) => node.getAttribute("data-testid")),
|
||||
),
|
||||
).toBe(1);
|
||||
await ui.choice(`17 ${longName}`).scrollIntoViewIfNeeded();
|
||||
await expect(ui.choice(`17 ${longName}`)).toBeVisible();
|
||||
).toEqual(["change-workflow-scroll"]);
|
||||
await form.form.getByTestId("change-workflow-step").tap();
|
||||
const finalStep = testPage.locator(`[role="option"][data-value="${finalStepId}"]`);
|
||||
await finalStep.scrollIntoViewIfNeeded();
|
||||
await expect(finalStep).toBeVisible();
|
||||
await expect(finalStep.locator("xpath=ancestor::*[@data-slot='popover-content']")).toHaveCSS(
|
||||
"opacity",
|
||||
"1",
|
||||
);
|
||||
expect(
|
||||
await finalStep.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const topmost = document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2);
|
||||
return topmost === element || element.contains(topmost);
|
||||
}),
|
||||
).toBe(true);
|
||||
await assertNoDocumentHorizontalOverflow(testPage, "mobile change workflow form");
|
||||
await testPage.screenshot({ path: testInfo.outputPath("phone-deep-360.png") });
|
||||
await ui.press(ui.choice("Back"));
|
||||
await expect(ui.choice(destinationName)).toBeFocused();
|
||||
await testPage.keyboard.press("Escape");
|
||||
await expect(ui.choice("Send to workflow")).toBeFocused();
|
||||
await ui.press(form.form.getByTestId("change-workflow-cancel"));
|
||||
await expect(ui.trigger(firstId)).toBeFocused();
|
||||
await ui.open(firstId);
|
||||
await ui.pick("Delete");
|
||||
await ui.contained(testPage.getByRole("alertdialog"));
|
||||
await testPage.screenshot({ path: testInfo.outputPath("phone-delete-360.png") });
|
||||
|
||||
@@ -38,7 +38,7 @@ test.describe("Task sidebar send to workflow", () => {
|
||||
targetStep.id,
|
||||
);
|
||||
|
||||
await expect(testPage.getByText(/Moved task to/i)).toBeVisible({ timeout: 10_000 });
|
||||
await expect(testPage.getByText("Workflow changed.")).toBeVisible({ timeout: 10_000 });
|
||||
expect(testPage.url()).toBe(beforeUrl);
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
|
||||
@@ -47,14 +47,14 @@ test.describe("Desktop task-row menu grouping", () => {
|
||||
"Nest under",
|
||||
"Link",
|
||||
"Move to",
|
||||
"Send to workflow",
|
||||
"Change workflow...",
|
||||
"Archive",
|
||||
"Delete",
|
||||
]);
|
||||
|
||||
const separators = menu.locator(":scope > [data-slot='context-menu-separator']");
|
||||
await expect(separators).toHaveCount(4);
|
||||
await expect(menu.getByTestId("task-context-send-to-workflow")).toBeVisible();
|
||||
await expect(menu.getByTestId("task-context-change-workflow")).toBeVisible();
|
||||
await prCapture.screenshot("desktop-task-row-menu-grouping", {
|
||||
caption: "Desktop task-row actions grouped by purpose",
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { expect, type Page, type Route } from "@playwright/test";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import type { SeedData } from "../../fixtures/test-base";
|
||||
import { seedActionThreads, ThreadActionsPage } from "./threads-task-actions-helpers";
|
||||
import { ChangeWorkflowPage } from "../../pages/change-workflow-page";
|
||||
|
||||
// @covers AC-TASKS-THREADS-ACTIONS-002.5, AC-TASKS-THREADS-ACTIONS-003.4
|
||||
export async function failedActionOutcomes(
|
||||
@@ -10,7 +11,7 @@ export async function failedActionOutcomes(
|
||||
seed: SeedData,
|
||||
mobile: boolean,
|
||||
) {
|
||||
const { a, b, destination } = await seedActionThreads(api, seed);
|
||||
const { a, b, destination, destinationStep } = await seedActionThreads(api, seed);
|
||||
const before = await api.getTask(a.id);
|
||||
const ui = new ThreadActionsPage(page, mobile);
|
||||
await api.saveUserSettings({ confirm_task_archive: true });
|
||||
@@ -55,14 +56,18 @@ export async function failedActionOutcomes(
|
||||
},
|
||||
},
|
||||
{
|
||||
action: "Send to workflow",
|
||||
path: "/api/v1/tasks/bulk-move",
|
||||
action: "Change workflow",
|
||||
path: `/api/v1/tasks/${a.id}/move`,
|
||||
method: "POST",
|
||||
error: "Failed to move task",
|
||||
error:
|
||||
"The result is not confirmed. The task was refreshed. Review the current assignment before trying again.",
|
||||
choose: async () => {
|
||||
await ui.nested("Send to workflow");
|
||||
await ui.nested(destination.name);
|
||||
await ui.pick("Incoming");
|
||||
await ui.pick("Change workflow...");
|
||||
const changeWorkflow = new ChangeWorkflowPage(page, mobile);
|
||||
await expect(changeWorkflow.form).toBeVisible();
|
||||
await changeWorkflow.chooseWorkflow(destination.id);
|
||||
await changeWorkflow.chooseStep(destinationStep.id);
|
||||
await changeWorkflow.submit();
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -100,6 +105,10 @@ export async function failedActionOutcomes(
|
||||
workflow_step_id: before.workflow_step_id,
|
||||
});
|
||||
await expect(ui.column(b.id)).toHaveCount(1);
|
||||
if (scenario.action === "Change workflow") {
|
||||
await ui.press(page.getByTestId("change-workflow-refresh-task"));
|
||||
await ui.press(page.getByTestId("change-workflow-cancel"));
|
||||
}
|
||||
}
|
||||
await ui.open(a.id);
|
||||
await ui.nested("Link");
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { SeedData } from "../../fixtures/test-base";
|
||||
import { createStandardProfile } from "../../helpers/git-helper";
|
||||
import { waitForLatestSessionDone } from "../../helpers/session";
|
||||
import { assertNoDocumentHorizontalOverflow, requireBox } from "../../helpers/layout-assertions";
|
||||
import { ChangeWorkflowPage } from "../../pages/change-workflow-page";
|
||||
|
||||
export async function withTaskActionSettings(api: ApiClient, run: () => Promise<void>) {
|
||||
const { settings } = await api.getUserSettings();
|
||||
@@ -208,7 +209,7 @@ export async function allTaskActionOutcomes(
|
||||
await expect(root.getByRole(mobile ? "button" : "menuitem")).toHaveText([
|
||||
"Priority",
|
||||
"Move to",
|
||||
"Send to workflow",
|
||||
"Change workflow...",
|
||||
"Link",
|
||||
"Archive",
|
||||
"Delete",
|
||||
@@ -216,10 +217,15 @@ export async function allTaskActionOutcomes(
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await ui.open(a.id);
|
||||
await ui.nested("Send to workflow");
|
||||
await expect(page.getByTestId(`task-context-workflow-${emptyWorkflow.id}`)).toBeDisabled();
|
||||
await page.keyboard.press("Escape");
|
||||
if (mobile) await page.keyboard.press("Escape");
|
||||
await ui.pick("Change workflow...");
|
||||
const emptyForm = new ChangeWorkflowPage(page, mobile);
|
||||
await expect(emptyForm.form).toBeVisible();
|
||||
await emptyForm.chooseWorkflow(emptyWorkflow.id);
|
||||
await expect(page.getByTestId("change-workflow-no-steps")).toContainText(
|
||||
"This workflow has no steps",
|
||||
);
|
||||
await expect(page.getByTestId("change-workflow-submit")).toBeDisabled();
|
||||
await ui.press(page.getByTestId("change-workflow-cancel"));
|
||||
|
||||
await ui.open(a.id);
|
||||
await ui.nested("Priority");
|
||||
@@ -246,9 +252,12 @@ export async function allTaskActionOutcomes(
|
||||
await expect.poll(async () => (await api.getTask(a.id)).workflow_step_id).toBe(localStep.id);
|
||||
|
||||
await ui.open(a.id);
|
||||
await ui.nested("Send to workflow");
|
||||
await ui.nested(destination.name);
|
||||
await ui.pick("Incoming");
|
||||
await ui.pick("Change workflow...");
|
||||
const changeWorkflow = new ChangeWorkflowPage(page, mobile);
|
||||
await expect(changeWorkflow.form).toBeVisible();
|
||||
await changeWorkflow.chooseWorkflow(destination.id);
|
||||
await changeWorkflow.chooseStep(destinationStep.id);
|
||||
await changeWorkflow.submit();
|
||||
await expect
|
||||
.poll(async () => (await api.getTask(a.id)).workflow_step_id)
|
||||
.toBe(destinationStep.id);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { ChangeWorkflowPage } from "../../pages/change-workflow-page";
|
||||
import {
|
||||
allTaskActionOutcomes,
|
||||
seedActionThreads,
|
||||
@@ -36,33 +37,48 @@ for (const [name, outcome] of [
|
||||
});
|
||||
}
|
||||
|
||||
test("contains long desktop workflow submenus and keeps final steps reachable", async ({
|
||||
test("searches long desktop workflow steps in the change form", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
}, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
const { a, destination } = await seedActionThreads(apiClient, seedData);
|
||||
for (let index = 0; index < 30; index++)
|
||||
await apiClient.createWorkflowStep(
|
||||
let finalStepId = "";
|
||||
for (let index = 0; index < 30; index++) {
|
||||
const step = await apiClient.createWorkflowStep(
|
||||
destination.id,
|
||||
`${index} ${"LongStep".repeat(20)}`,
|
||||
index + 1,
|
||||
);
|
||||
if (index === 29) finalStepId = step.id;
|
||||
}
|
||||
await testPage.setViewportSize({ width: 900, height: 500 });
|
||||
await testPage.goto(`/threads?workspace=${seedData.workspaceId}`);
|
||||
const ui = new ThreadActionsPage(testPage);
|
||||
await ui.open(a.id);
|
||||
await ui.nested("Send to workflow");
|
||||
await ui.nested(destination.name);
|
||||
const submenu = testPage
|
||||
.getByRole("menu")
|
||||
.filter({ has: ui.choice(`29 ${"LongStep".repeat(20)}`) })
|
||||
.last();
|
||||
await ui.contained(submenu);
|
||||
await ui.choice(`29 ${"LongStep".repeat(20)}`).scrollIntoViewIfNeeded();
|
||||
await expect(ui.choice(`29 ${"LongStep".repeat(20)}`)).toBeInViewport();
|
||||
await ui.pick("Change workflow...");
|
||||
const form = new ChangeWorkflowPage(testPage);
|
||||
await expect(form.desktopDialog).toBeVisible();
|
||||
await form.chooseWorkflow(destination.id);
|
||||
await form.form.getByTestId("change-workflow-step").click();
|
||||
const finalStep = testPage.locator(`[role="option"][data-value="${finalStepId}"]`);
|
||||
await finalStep.scrollIntoViewIfNeeded();
|
||||
await expect(finalStep).toBeVisible();
|
||||
await expect(finalStep.locator("xpath=ancestor::*[@data-slot='popover-content']")).toHaveCSS(
|
||||
"opacity",
|
||||
"1",
|
||||
);
|
||||
expect(
|
||||
await finalStep.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const topmost = document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2);
|
||||
return topmost === element || element.contains(topmost);
|
||||
}),
|
||||
).toBe(true);
|
||||
await testPage.screenshot({ path: testInfo.outputPath("desktop-change-workflow-long-step.png") });
|
||||
await testPage.keyboard.press("Escape");
|
||||
await form.form.getByTestId("change-workflow-cancel").click();
|
||||
await expect(ui.trigger(a.id)).toBeFocused();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Task } from "@/lib/types/http";
|
||||
import { useChangeWorkflowTask } from "./use-change-workflow-data";
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({ fetchTask: vi.fn() }));
|
||||
const TASK_ID = "task-1";
|
||||
const WORKSPACE_ID = "workspace-1";
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
fetchTask: apiMocks.fetchTask,
|
||||
fetchWorkflowSnapshot: vi.fn(),
|
||||
listAgents: vi.fn(),
|
||||
listExecutors: vi.fn(),
|
||||
listWorkflows: vi.fn(),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
apiMocks.fetchTask.mockReset();
|
||||
});
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function task(id: string, workspaceId = WORKSPACE_ID): Task {
|
||||
return { id, workspace_id: workspaceId } as Task;
|
||||
}
|
||||
|
||||
describe("useChangeWorkflowTask", () => {
|
||||
it("ignores an older task fetch after the task identity changes", async () => {
|
||||
const oldTask = deferred<Task>();
|
||||
apiMocks.fetchTask.mockReturnValueOnce(oldTask.promise).mockResolvedValueOnce(task("task-2"));
|
||||
const { result, rerender } = renderHook((props) => useChangeWorkflowTask(props), {
|
||||
initialProps: { open: true, taskId: TASK_ID, workspaceId: WORKSPACE_ID },
|
||||
});
|
||||
|
||||
rerender({ open: true, taskId: "task-2", workspaceId: WORKSPACE_ID });
|
||||
await waitFor(() => expect(result.current.task?.id).toBe("task-2"));
|
||||
await act(async () => oldTask.resolve(task(TASK_ID)));
|
||||
|
||||
expect(result.current.task?.id).toBe("task-2");
|
||||
expect(result.current.status).toBe("success");
|
||||
});
|
||||
|
||||
it("rejects a task returned from a different workspace", async () => {
|
||||
apiMocks.fetchTask.mockResolvedValueOnce(task(TASK_ID, "other-workspace"));
|
||||
const { result } = renderHook(() =>
|
||||
useChangeWorkflowTask({ open: true, taskId: TASK_ID, workspaceId: WORKSPACE_ID }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.status).toBe("error"));
|
||||
expect(result.current.task).toBeNull();
|
||||
expect(result.current.error).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it("discards a refresh response after the task hook is disposed", async () => {
|
||||
const refresh = deferred<Task>();
|
||||
apiMocks.fetchTask.mockResolvedValueOnce(task(TASK_ID)).mockReturnValueOnce(refresh.promise);
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useChangeWorkflowTask({ open: true, taskId: TASK_ID, workspaceId: WORKSPACE_ID }),
|
||||
);
|
||||
await waitFor(() => expect(result.current.status).toBe("success"));
|
||||
let pendingRefresh!: Promise<Task | null>;
|
||||
act(() => {
|
||||
pendingRefresh = result.current.refreshTask();
|
||||
});
|
||||
unmount();
|
||||
|
||||
await act(async () => refresh.resolve(task(TASK_ID)));
|
||||
await expect(pendingRefresh).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the newest same-task refresh when requests resolve out of order", async () => {
|
||||
const firstRefresh = deferred<Task>();
|
||||
const secondRefresh = deferred<Task>();
|
||||
apiMocks.fetchTask
|
||||
.mockResolvedValueOnce(task(TASK_ID))
|
||||
.mockReturnValueOnce(firstRefresh.promise)
|
||||
.mockReturnValueOnce(secondRefresh.promise);
|
||||
const { result } = renderHook(() =>
|
||||
useChangeWorkflowTask({ open: true, taskId: TASK_ID, workspaceId: WORKSPACE_ID }),
|
||||
);
|
||||
await waitFor(() => expect(result.current.status).toBe("success"));
|
||||
|
||||
let firstResult: Promise<Task | null>;
|
||||
let secondResult: Promise<Task | null>;
|
||||
act(() => {
|
||||
firstResult = result.current.refreshTask();
|
||||
secondResult = result.current.refreshTask();
|
||||
});
|
||||
await act(async () => secondRefresh.resolve(task(TASK_ID, WORKSPACE_ID)));
|
||||
await expect(secondResult!).resolves.toMatchObject({ id: TASK_ID });
|
||||
await act(async () => firstRefresh.resolve({ ...task(TASK_ID), title: "stale" }));
|
||||
|
||||
await expect(firstResult!).resolves.toBeNull();
|
||||
expect(result.current.task?.title).not.toBe("stale");
|
||||
expect(result.current.status).toBe("success");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
fetchTask,
|
||||
fetchWorkflowSnapshot,
|
||||
listAgents,
|
||||
listExecutors,
|
||||
listWorkflows,
|
||||
} from "@/lib/api";
|
||||
import type { AgentProfileOption } from "@/lib/state/slices";
|
||||
import type { Task, Workflow, WorkflowSnapshot } from "@/lib/types/http";
|
||||
import { toAgentProfileOption } from "@/lib/state/slices/settings/types";
|
||||
|
||||
export type ChangeWorkflowLoadStatus = "idle" | "loading" | "success" | "error";
|
||||
type OpenTaskArgs = { open: boolean; taskId: string | null; workspaceId: string | null };
|
||||
|
||||
export function useChangeWorkflowTask({ open, taskId, workspaceId }: OpenTaskArgs) {
|
||||
const [task, setTask] = useState<Task | null>(null);
|
||||
const [status, setStatus] = useState<ChangeWorkflowLoadStatus>("idle");
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
const generationRef = useRef(0);
|
||||
const refreshGenerationRef = useRef(0);
|
||||
const taskIdRef = useRef(taskId);
|
||||
const workspaceIdRef = useRef(workspaceId);
|
||||
taskIdRef.current = taskId;
|
||||
workspaceIdRef.current = workspaceId;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !taskId || !workspaceId) return;
|
||||
const generation = ++generationRef.current;
|
||||
setTask(null);
|
||||
setStatus("loading");
|
||||
setError(null);
|
||||
void fetchTask(taskId, { cache: "no-store" }).then(
|
||||
(result) => {
|
||||
if (generationRef.current !== generation) return;
|
||||
if (result.workspace_id !== workspaceId) {
|
||||
setError(new Error());
|
||||
setStatus("error");
|
||||
return;
|
||||
}
|
||||
setTask(result);
|
||||
setStatus("success");
|
||||
},
|
||||
(nextError: unknown) => {
|
||||
if (generationRef.current !== generation) return;
|
||||
setError(nextError);
|
||||
setStatus("error");
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
generationRef.current += 1;
|
||||
};
|
||||
}, [open, taskId, workspaceId]);
|
||||
|
||||
const refreshTask = useCallback(async () => {
|
||||
const currentTaskId = taskIdRef.current;
|
||||
const currentWorkspaceId = workspaceIdRef.current;
|
||||
if (!currentTaskId || !currentWorkspaceId) return null;
|
||||
const generation = generationRef.current;
|
||||
const refreshGeneration = ++refreshGenerationRef.current;
|
||||
setStatus("loading");
|
||||
setError(null);
|
||||
try {
|
||||
const latest = await fetchTask(currentTaskId, { cache: "no-store" });
|
||||
if (
|
||||
generationRef.current !== generation ||
|
||||
refreshGenerationRef.current !== refreshGeneration ||
|
||||
taskIdRef.current !== currentTaskId ||
|
||||
workspaceIdRef.current !== currentWorkspaceId
|
||||
)
|
||||
return null;
|
||||
if (latest.workspace_id !== currentWorkspaceId) throw new Error();
|
||||
setTask(latest);
|
||||
setStatus("success");
|
||||
return latest;
|
||||
} catch (nextError) {
|
||||
if (
|
||||
generationRef.current === generation &&
|
||||
refreshGenerationRef.current === refreshGeneration &&
|
||||
taskIdRef.current === currentTaskId &&
|
||||
workspaceIdRef.current === currentWorkspaceId
|
||||
) {
|
||||
setError(nextError);
|
||||
setStatus("error");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { task, status, error, setTask, setStatus, setError, refreshTask };
|
||||
}
|
||||
|
||||
export function useChangeWorkflowCatalog(open: boolean, workspaceId: string | null) {
|
||||
const [workflows, setWorkflows] = useState<Workflow[]>([]);
|
||||
const [status, setStatus] = useState<ChangeWorkflowLoadStatus>("idle");
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
const [loadVersion, setLoadVersion] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !workspaceId) return;
|
||||
let active = true;
|
||||
setStatus("loading");
|
||||
setError(null);
|
||||
void listWorkflows(workspaceId, { cache: "no-store" }).then(
|
||||
(result) => {
|
||||
if (!active) return;
|
||||
setWorkflows(
|
||||
result.workflows.filter(
|
||||
(workflow) => workflow.workspace_id === workspaceId && !workflow.hidden,
|
||||
),
|
||||
);
|
||||
setStatus("success");
|
||||
},
|
||||
(nextError: unknown) => {
|
||||
if (!active) return;
|
||||
setError(nextError);
|
||||
setStatus("error");
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [open, workspaceId, loadVersion]);
|
||||
|
||||
const retry = useCallback(() => setLoadVersion((version) => version + 1), []);
|
||||
return { workflows, status, error, retry };
|
||||
}
|
||||
|
||||
export function useChangeWorkflowProfiles(open: boolean, workspaceId: string | null) {
|
||||
const [profiles, setProfiles] = useState<AgentProfileOption[]>([]);
|
||||
const [executors, setExecutors] = useState<
|
||||
Awaited<ReturnType<typeof listExecutors>>["executors"]
|
||||
>([]);
|
||||
const [status, setStatus] = useState<ChangeWorkflowLoadStatus>("idle");
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
const [loadVersion, setLoadVersion] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !workspaceId) return;
|
||||
let active = true;
|
||||
setStatus("loading");
|
||||
setError(null);
|
||||
Promise.all([listAgents({ cache: "no-store" }), listExecutors({ cache: "no-store" })]).then(
|
||||
([agents, executorResponse]) => {
|
||||
if (!active) return;
|
||||
setProfiles(
|
||||
agents.agents.flatMap((agent) =>
|
||||
agent.profiles.map((profile) => toAgentProfileOption(agent, profile)),
|
||||
),
|
||||
);
|
||||
setExecutors(executorResponse.executors);
|
||||
setStatus("success");
|
||||
},
|
||||
(nextError: unknown) => {
|
||||
if (!active) return;
|
||||
setError(nextError);
|
||||
setStatus("error");
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [open, workspaceId, loadVersion]);
|
||||
|
||||
const retry = useCallback(() => setLoadVersion((version) => version + 1), []);
|
||||
return { profiles, executors, status, error, retry };
|
||||
}
|
||||
|
||||
export function useChangeWorkflowDestination(open: boolean) {
|
||||
const [selectedWorkflowId, setSelectedWorkflowId] = useState("");
|
||||
const [selectedStepId, setSelectedStepId] = useState("");
|
||||
const [overrides, setOverrides] = useState<Record<string, string>>({});
|
||||
const [snapshot, setSnapshot] = useState<WorkflowSnapshot | null>(null);
|
||||
const [status, setStatus] = useState<ChangeWorkflowLoadStatus>("idle");
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
const generationRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
generationRef.current += 1;
|
||||
setSelectedWorkflowId("");
|
||||
setSelectedStepId("");
|
||||
setOverrides({});
|
||||
setSnapshot(null);
|
||||
setStatus("idle");
|
||||
setError(null);
|
||||
}, [open]);
|
||||
|
||||
const loadSnapshot = useCallback((workflowId: string) => {
|
||||
const generation = ++generationRef.current;
|
||||
setSnapshot(null);
|
||||
setError(null);
|
||||
if (!workflowId) {
|
||||
setStatus("idle");
|
||||
return;
|
||||
}
|
||||
setStatus("loading");
|
||||
void fetchWorkflowSnapshot(workflowId, { cache: "no-store" }).then(
|
||||
(result) => {
|
||||
if (generationRef.current !== generation) return;
|
||||
setSnapshot(result);
|
||||
setStatus("success");
|
||||
},
|
||||
(nextError: unknown) => {
|
||||
if (generationRef.current !== generation) return;
|
||||
setError(nextError);
|
||||
setStatus("error");
|
||||
},
|
||||
);
|
||||
}, []);
|
||||
|
||||
const changeWorkflow = useCallback(
|
||||
(workflowId: string) => {
|
||||
setSelectedWorkflowId(workflowId);
|
||||
setSelectedStepId("");
|
||||
setOverrides({});
|
||||
loadSnapshot(workflowId);
|
||||
},
|
||||
[loadSnapshot],
|
||||
);
|
||||
|
||||
const setOverride = useCallback((sourceProfileId: string, replacementProfileId: string) => {
|
||||
setOverrides((current) => {
|
||||
const next = { ...current };
|
||||
if (!replacementProfileId || replacementProfileId === sourceProfileId)
|
||||
delete next[sourceProfileId];
|
||||
else next[sourceProfileId] = replacementProfileId;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const retry = useCallback(
|
||||
() => loadSnapshot(selectedWorkflowId),
|
||||
[loadSnapshot, selectedWorkflowId],
|
||||
);
|
||||
const chooseStep = useCallback((stepId: string) => setSelectedStepId(stepId), []);
|
||||
return {
|
||||
selectedWorkflowId,
|
||||
selectedStepId,
|
||||
overrides,
|
||||
snapshot,
|
||||
status,
|
||||
error,
|
||||
changeWorkflow,
|
||||
chooseStep,
|
||||
setOverride,
|
||||
retry,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { act, cleanup, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WorkflowChangePayload } from "@/lib/api/domains/kanban-api";
|
||||
import type { Task, Workflow } from "@/lib/types/http";
|
||||
import { ApiError } from "@/lib/api/client";
|
||||
import { useChangeWorkflowSubmit } from "./use-change-workflow-submit";
|
||||
|
||||
const SERVER_ERROR_MESSAGE = "server error";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
moveTask: vi.fn(),
|
||||
toast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({ moveTask: mocks.moveTask }));
|
||||
vi.mock("@/components/toast-provider", () => ({ useToast: () => ({ toast: mocks.toast }) }));
|
||||
vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key }) }));
|
||||
|
||||
afterEach(() => cleanup());
|
||||
beforeEach(() => {
|
||||
mocks.moveTask.mockReset();
|
||||
mocks.toast.mockReset();
|
||||
});
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function matchingTask(): Task {
|
||||
return {
|
||||
id: "task-1",
|
||||
workflow_id: "workflow-target",
|
||||
workflow_step_id: "step-pr",
|
||||
workflow_agent_overrides: {
|
||||
workflow_id: "workflow-target",
|
||||
steps: [{ source_profile_id: "profile-a", replacement_profile_id: "profile-b" }],
|
||||
},
|
||||
} as Task;
|
||||
}
|
||||
|
||||
function input(overrides: Partial<Parameters<typeof useChangeWorkflowSubmit>[0]> = {}) {
|
||||
return {
|
||||
open: true,
|
||||
canSubmit: true,
|
||||
task: { id: "task-1", workflow_id: "workflow-source", workflow_step_id: "step-source" } as Task,
|
||||
selectedWorkflow: { id: "workflow-target", name: "Target" } as Workflow,
|
||||
selectedStepId: "step-pr",
|
||||
workflowChange: {
|
||||
expected_workflow_id: "workflow-source",
|
||||
expected_step_id: "step-source",
|
||||
expected_updated_at: "2026-09-23T12:00:00Z",
|
||||
agent_overrides: { "profile-a": "profile-b" },
|
||||
} as WorkflowChangePayload,
|
||||
refreshTask: vi.fn().mockResolvedValue(null),
|
||||
onOpenChange: vi.fn(),
|
||||
onSuccess: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useChangeWorkflowSubmit", () => {
|
||||
it("refreshes and reports a source conflict without treating it as uncertain", async () => {
|
||||
const params = input();
|
||||
mocks.moveTask.mockRejectedValueOnce(
|
||||
new ApiError("stale source", 409, { code: "workflow_change_conflict" }),
|
||||
);
|
||||
const { result } = renderHook(() => useChangeWorkflowSubmit(params));
|
||||
|
||||
await act(async () => expect(result.current.submit()).resolves.toBe(false));
|
||||
|
||||
expect(params.refreshTask).toHaveBeenCalledOnce();
|
||||
expect(result.current.submitError).toEqual({ code: "workflow_change_conflict" });
|
||||
expect(result.current.uncertainResult).toBe(false);
|
||||
expect(result.current.isSubmitting).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a validation error local and does not refresh on a client error", async () => {
|
||||
const params = input();
|
||||
mocks.moveTask.mockRejectedValueOnce(
|
||||
new ApiError("profile invalid", 400, {
|
||||
code: "workflow_agent_unavailable",
|
||||
source_profile_id: "profile-a",
|
||||
}),
|
||||
);
|
||||
const { result } = renderHook(() => useChangeWorkflowSubmit(params));
|
||||
|
||||
await act(async () => expect(result.current.submit()).resolves.toBe(false));
|
||||
|
||||
expect(params.refreshTask).not.toHaveBeenCalled();
|
||||
expect(result.current.submitError).toEqual({
|
||||
code: "workflow_agent_unavailable",
|
||||
source_profile_id: "profile-a",
|
||||
});
|
||||
expect(result.current.sourceProfileErrorId).toBe("profile-a");
|
||||
expect(result.current.isSubmitting).toBe(false);
|
||||
});
|
||||
|
||||
it("recognizes a server-side commit after an uncertain response", async () => {
|
||||
const params = input({ refreshTask: vi.fn().mockResolvedValue(matchingTask()) });
|
||||
mocks.moveTask.mockRejectedValueOnce(new ApiError(SERVER_ERROR_MESSAGE, 503, null));
|
||||
const { result } = renderHook(() => useChangeWorkflowSubmit(params));
|
||||
|
||||
await act(async () => expect(result.current.submit()).resolves.toBe(true));
|
||||
|
||||
expect(mocks.toast).toHaveBeenCalledWith({
|
||||
title: "task:changeWorkflowObservedSuccess",
|
||||
variant: "success",
|
||||
});
|
||||
expect(params.onSuccess).toHaveBeenCalledOnce();
|
||||
expect(params.onOpenChange).toHaveBeenCalledWith(false);
|
||||
expect(result.current.uncertainResult).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves a nonmatching server-error result gated for refresh", async () => {
|
||||
const params = input();
|
||||
mocks.moveTask.mockRejectedValueOnce(new ApiError(SERVER_ERROR_MESSAGE, 503, null));
|
||||
const { result } = renderHook(() => useChangeWorkflowSubmit(params));
|
||||
|
||||
await act(async () => expect(result.current.submit()).resolves.toBe(false));
|
||||
|
||||
expect(result.current.uncertainResult).toBe(true);
|
||||
expect(result.current.submitError).toEqual({ code: "workflow_change_uncertain" });
|
||||
expect(params.onOpenChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useChangeWorkflowSubmit explicit refresh", () => {
|
||||
it("treats a matching assignment found by the explicit refresh as success", async () => {
|
||||
const params = input({
|
||||
refreshTask: vi.fn().mockResolvedValueOnce(null).mockResolvedValueOnce(matchingTask()),
|
||||
});
|
||||
mocks.moveTask.mockRejectedValueOnce(new ApiError(SERVER_ERROR_MESSAGE, 503, null));
|
||||
const { result } = renderHook(() => useChangeWorkflowSubmit(params));
|
||||
await act(async () => expect(result.current.submit()).resolves.toBe(false));
|
||||
|
||||
await act(async () => result.current.retryAfterRefresh());
|
||||
|
||||
expect(mocks.toast).toHaveBeenLastCalledWith({
|
||||
title: "task:changeWorkflowObservedSuccess",
|
||||
variant: "success",
|
||||
});
|
||||
expect(params.onSuccess).toHaveBeenCalledOnce();
|
||||
expect(params.onOpenChange).toHaveBeenCalledWith(false);
|
||||
expect(result.current.uncertainResult).toBe(false);
|
||||
expect(result.current.submitError).toBeNull();
|
||||
});
|
||||
|
||||
it("prevents concurrent submissions and clears the busy state after failure", async () => {
|
||||
const params = input();
|
||||
const pending = deferred<void>();
|
||||
mocks.moveTask.mockReturnValueOnce(pending.promise);
|
||||
const { result } = renderHook(() => useChangeWorkflowSubmit(params));
|
||||
let firstSubmit!: Promise<boolean>;
|
||||
let secondSubmit!: Promise<boolean>;
|
||||
act(() => {
|
||||
firstSubmit = result.current.submit();
|
||||
secondSubmit = result.current.submit();
|
||||
});
|
||||
|
||||
await expect(secondSubmit).resolves.toBe(false);
|
||||
expect(mocks.moveTask).toHaveBeenCalledOnce();
|
||||
expect(result.current.isSubmitting).toBe(true);
|
||||
await act(async () => {
|
||||
pending.reject(new ApiError("invalid", 400, { code: "invalid_workflow_change" }));
|
||||
await expect(firstSubmit).resolves.toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.isSubmitting).toBe(false);
|
||||
expect(result.current.submitError).toEqual({ code: "invalid_workflow_change" });
|
||||
});
|
||||
|
||||
it("keeps uncertain state when refresh cannot load the task", async () => {
|
||||
const params = input({ refreshTask: vi.fn().mockResolvedValue(null) });
|
||||
mocks.moveTask.mockRejectedValueOnce(new ApiError(SERVER_ERROR_MESSAGE, 503, null));
|
||||
const { result } = renderHook(() => useChangeWorkflowSubmit(params));
|
||||
await act(async () => expect(result.current.submit()).resolves.toBe(false));
|
||||
|
||||
await act(async () => result.current.retryAfterRefresh());
|
||||
|
||||
expect(result.current.uncertainResult).toBe(true);
|
||||
expect(result.current.submitError).toEqual({ code: "workflow_change_uncertain" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { moveTask } from "@/lib/api";
|
||||
import { ApiError } from "@/lib/api/client";
|
||||
import type { WorkflowChangePayload } from "@/lib/api/domains/kanban-api";
|
||||
import type { Task, Workflow } from "@/lib/types/http";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { taskMatchesWorkflowChange } from "./use-change-workflow-utils";
|
||||
|
||||
type WorkflowChangeError = { code?: string; source_profile_id?: string };
|
||||
type WorkflowChangeRequestSnapshot = {
|
||||
workflowId: string;
|
||||
stepId: string;
|
||||
overrides: Record<string, string>;
|
||||
};
|
||||
|
||||
function apiErrorDetails(error: unknown): WorkflowChangeError {
|
||||
if (!(error instanceof ApiError) || !error.body || typeof error.body !== "object") return {};
|
||||
const body = error.body as WorkflowChangeError;
|
||||
return {
|
||||
code: typeof body.code === "string" ? body.code : undefined,
|
||||
source_profile_id:
|
||||
typeof body.source_profile_id === "string" ? body.source_profile_id : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
type SubmitArgs = {
|
||||
open: boolean;
|
||||
canSubmit: boolean;
|
||||
task: Task | null;
|
||||
selectedWorkflow: Workflow | null;
|
||||
selectedStepId: string;
|
||||
workflowChange: WorkflowChangePayload | undefined;
|
||||
refreshTask: () => Promise<Task | null>;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess?: () => void;
|
||||
};
|
||||
|
||||
export function useChangeWorkflowSubmit({
|
||||
open,
|
||||
canSubmit,
|
||||
task,
|
||||
selectedWorkflow,
|
||||
selectedStepId,
|
||||
workflowChange,
|
||||
refreshTask,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}: SubmitArgs) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const pendingRef = useRef(false);
|
||||
const lastRequestRef = useRef<WorkflowChangeRequestSnapshot | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<WorkflowChangeError | null>(null);
|
||||
const [uncertainResult, setUncertainResult] = useState(false);
|
||||
|
||||
useEffect(() => resetOnOpen(open, lastRequestRef, setSubmitError, setUncertainResult), [open]);
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
if (!canSubmit || !task || !selectedWorkflow || !selectedStepId || !workflowChange)
|
||||
return false;
|
||||
if (pendingRef.current) return false;
|
||||
pendingRef.current = true;
|
||||
setIsSubmitting(true);
|
||||
setSubmitError(null);
|
||||
setUncertainResult(false);
|
||||
const targetWorkflowId = selectedWorkflow.id;
|
||||
const requestOverrides = workflowChange.agent_overrides;
|
||||
lastRequestRef.current = {
|
||||
workflowId: targetWorkflowId,
|
||||
stepId: selectedStepId,
|
||||
overrides: { ...requestOverrides },
|
||||
};
|
||||
try {
|
||||
await moveTask(task.id, {
|
||||
workflow_id: targetWorkflowId,
|
||||
workflow_step_id: selectedStepId,
|
||||
workflow_change: workflowChange,
|
||||
});
|
||||
toast({ title: t("task:changeWorkflowSuccess"), variant: "success" });
|
||||
onSuccess?.();
|
||||
onOpenChange(false);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return handleSubmitError({
|
||||
error,
|
||||
workflowId: targetWorkflowId,
|
||||
stepId: selectedStepId,
|
||||
overrides: requestOverrides,
|
||||
refreshTask,
|
||||
setSubmitError,
|
||||
setUncertainResult,
|
||||
toast,
|
||||
t,
|
||||
onSuccess,
|
||||
onOpenChange,
|
||||
});
|
||||
} finally {
|
||||
pendingRef.current = false;
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [
|
||||
canSubmit,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
refreshTask,
|
||||
selectedStepId,
|
||||
selectedWorkflow,
|
||||
t,
|
||||
task,
|
||||
toast,
|
||||
workflowChange,
|
||||
]);
|
||||
|
||||
const retryAfterRefresh = useCallback(
|
||||
() =>
|
||||
retryWorkflowChangeAfterRefresh({
|
||||
lastRequest: lastRequestRef.current,
|
||||
refreshTask,
|
||||
setSubmitError,
|
||||
setUncertainResult,
|
||||
toast,
|
||||
t,
|
||||
onSuccess,
|
||||
onOpenChange,
|
||||
}),
|
||||
[onOpenChange, onSuccess, refreshTask, t, toast],
|
||||
);
|
||||
|
||||
const clearSubmitError = useCallback(() => setSubmitError(null), []);
|
||||
|
||||
return {
|
||||
isSubmitting,
|
||||
submitError,
|
||||
sourceProfileErrorId: submitError?.source_profile_id,
|
||||
uncertainResult,
|
||||
submit,
|
||||
retryAfterRefresh,
|
||||
clearSubmitError,
|
||||
};
|
||||
}
|
||||
|
||||
async function retryWorkflowChangeAfterRefresh({
|
||||
lastRequest,
|
||||
refreshTask,
|
||||
setSubmitError,
|
||||
setUncertainResult,
|
||||
toast,
|
||||
t,
|
||||
onSuccess,
|
||||
onOpenChange,
|
||||
}: {
|
||||
lastRequest: WorkflowChangeRequestSnapshot | null;
|
||||
refreshTask: () => Promise<Task | null>;
|
||||
setSubmitError: (error: WorkflowChangeError | null) => void;
|
||||
setUncertainResult: (value: boolean) => void;
|
||||
toast: ReturnType<typeof useToast>["toast"];
|
||||
t: ReturnType<typeof useTranslation>["t"];
|
||||
onSuccess?: () => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const refreshed = await refreshTask();
|
||||
if (!refreshed) return;
|
||||
const matchesLastRequest =
|
||||
lastRequest &&
|
||||
taskMatchesWorkflowChange(
|
||||
refreshed,
|
||||
lastRequest.workflowId,
|
||||
lastRequest.stepId,
|
||||
lastRequest.overrides,
|
||||
);
|
||||
setUncertainResult(false);
|
||||
setSubmitError(null);
|
||||
if (!matchesLastRequest) return;
|
||||
toast({ title: t("task:changeWorkflowObservedSuccess"), variant: "success" });
|
||||
onSuccess?.();
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
function resetOnOpen(
|
||||
open: boolean,
|
||||
lastRequestRef: { current: WorkflowChangeRequestSnapshot | null },
|
||||
setSubmitError: (error: WorkflowChangeError | null) => void,
|
||||
setUncertainResult: (value: boolean) => void,
|
||||
) {
|
||||
if (!open) return;
|
||||
lastRequestRef.current = null;
|
||||
setSubmitError(null);
|
||||
setUncertainResult(false);
|
||||
}
|
||||
|
||||
async function handleSubmitError({
|
||||
error,
|
||||
workflowId,
|
||||
stepId,
|
||||
overrides,
|
||||
refreshTask,
|
||||
setSubmitError,
|
||||
setUncertainResult,
|
||||
toast,
|
||||
t,
|
||||
onSuccess,
|
||||
onOpenChange,
|
||||
}: {
|
||||
error: unknown;
|
||||
workflowId: string;
|
||||
stepId: string;
|
||||
overrides: Record<string, string>;
|
||||
refreshTask: () => Promise<Task | null>;
|
||||
setSubmitError: (error: WorkflowChangeError | null) => void;
|
||||
setUncertainResult: (value: boolean) => void;
|
||||
toast: ReturnType<typeof useToast>["toast"];
|
||||
t: ReturnType<typeof useTranslation>["t"];
|
||||
onSuccess?: () => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}): Promise<boolean> {
|
||||
const apiError = apiErrorDetails(error);
|
||||
if (apiError.code === "workflow_change_conflict") {
|
||||
await refreshTask();
|
||||
setSubmitError({ code: apiError.code });
|
||||
return false;
|
||||
}
|
||||
if (!(error instanceof ApiError) || error.status >= 500) {
|
||||
const latest = await refreshTask();
|
||||
if (latest && taskMatchesWorkflowChange(latest, workflowId, stepId, overrides)) {
|
||||
toast({ title: t("task:changeWorkflowObservedSuccess"), variant: "success" });
|
||||
onSuccess?.();
|
||||
onOpenChange(false);
|
||||
return true;
|
||||
}
|
||||
setUncertainResult(true);
|
||||
setSubmitError({ code: "workflow_change_uncertain" });
|
||||
return false;
|
||||
}
|
||||
setSubmitError(apiError.code ? apiError : { code: "invalid_workflow_change" });
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { WorkflowChangePayload } from "@/lib/api/domains/kanban-api";
|
||||
import type { Task } from "@/lib/types/http";
|
||||
|
||||
export function normalizeChangeWorkflowOverrides(
|
||||
overrides: Readonly<Record<string, string>>,
|
||||
): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(overrides)
|
||||
.map(([source, replacement]) => [source.trim(), replacement.trim()])
|
||||
.filter(([source, replacement]) => source && replacement && source !== replacement)
|
||||
.sort(([left], [right]) => left.localeCompare(right)),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildWorkflowChangePayload(
|
||||
task: Task,
|
||||
overrides: Readonly<Record<string, string>>,
|
||||
): WorkflowChangePayload {
|
||||
return {
|
||||
expected_workflow_id: task.workflow_id,
|
||||
expected_step_id: task.workflow_step_id,
|
||||
expected_updated_at: task.updated_at,
|
||||
agent_overrides: normalizeChangeWorkflowOverrides(overrides),
|
||||
};
|
||||
}
|
||||
|
||||
export function taskMatchesWorkflowChange(
|
||||
task: Task,
|
||||
workflowId: string,
|
||||
stepId: string,
|
||||
expectedOverrides: Readonly<Record<string, string>>,
|
||||
): boolean {
|
||||
if (task.workflow_id !== workflowId || task.workflow_step_id !== stepId) return false;
|
||||
const persisted = task.workflow_agent_overrides;
|
||||
const actualOverrides =
|
||||
persisted?.workflow_id === workflowId
|
||||
? Object.fromEntries(
|
||||
persisted.steps.map((binding) => [
|
||||
binding.source_profile_id,
|
||||
binding.replacement_profile_id,
|
||||
]),
|
||||
)
|
||||
: {};
|
||||
return (
|
||||
JSON.stringify(normalizeChangeWorkflowOverrides(actualOverrides)) ===
|
||||
JSON.stringify(normalizeChangeWorkflowOverrides(expectedOverrides))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AgentProfileOption } from "@/lib/state/slices";
|
||||
import { agentProfileId, workflowId, type Task, type WorkflowStepDTO } from "@/lib/types/http";
|
||||
import {
|
||||
buildWorkflowChangePayload,
|
||||
normalizeChangeWorkflowOverrides,
|
||||
taskMatchesWorkflowChange,
|
||||
useChangeWorkflow,
|
||||
} from "./use-change-workflow";
|
||||
|
||||
type HookStore = {
|
||||
workspaces: { items: Array<{ id: string; default_executor_id: string | null }> };
|
||||
kanban: { workflowId: string; steps: Array<{ id: string; title: string }> };
|
||||
kanbanMulti: { snapshots: Record<string, { steps: Array<{ id: string; title: string }> }> };
|
||||
};
|
||||
|
||||
const changeWorkflowHookMocks = vi.hoisted(() => {
|
||||
const ids = {
|
||||
task: "task-1",
|
||||
workspace: "workspace-1",
|
||||
sourceWorkflow: "workflow-source",
|
||||
destinationWorkflow: "workflow-destination",
|
||||
sourceStep: "step-source",
|
||||
destinationStep: "step-pr",
|
||||
profileA: "profile-a",
|
||||
profileB: "profile-b",
|
||||
};
|
||||
return {
|
||||
ids,
|
||||
task: null as Task | null,
|
||||
profiles: [] as AgentProfileOption[],
|
||||
steps: [] as WorkflowStepDTO[],
|
||||
store: {} as HookStore,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/components/state-provider", () => ({
|
||||
useAppStore: (selector: (state: HookStore) => unknown) => selector(changeWorkflowHookMocks.store),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/task-create-dialog-options", () => ({
|
||||
useAgentProfileOptions: (profiles: AgentProfileOption[]) =>
|
||||
profiles.map((profile) => ({
|
||||
value: profile.id,
|
||||
label: profile.label,
|
||||
renderLabel: () => profile.label,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/domains/session/use-compatible-agent-profiles", () => ({
|
||||
useCompatibleAgentProfiles: (profiles: AgentProfileOption[]) => profiles,
|
||||
}));
|
||||
|
||||
vi.mock("./use-change-workflow-data", () => ({
|
||||
useChangeWorkflowTask: () => ({
|
||||
task: changeWorkflowHookMocks.task,
|
||||
status: "success",
|
||||
error: null,
|
||||
refreshTask: async () => null,
|
||||
}),
|
||||
useChangeWorkflowCatalog: () => ({
|
||||
workflows: [
|
||||
{
|
||||
id: changeWorkflowHookMocks.ids.sourceWorkflow,
|
||||
workspace_id: changeWorkflowHookMocks.ids.workspace,
|
||||
name: "Source",
|
||||
},
|
||||
{
|
||||
id: changeWorkflowHookMocks.ids.destinationWorkflow,
|
||||
workspace_id: changeWorkflowHookMocks.ids.workspace,
|
||||
name: "Destination",
|
||||
},
|
||||
],
|
||||
status: "success",
|
||||
error: null,
|
||||
retry: () => {},
|
||||
}),
|
||||
useChangeWorkflowProfiles: () => ({
|
||||
profiles: changeWorkflowHookMocks.profiles,
|
||||
executors: [],
|
||||
status: "success",
|
||||
error: null,
|
||||
retry: () => {},
|
||||
}),
|
||||
useChangeWorkflowDestination: () => ({
|
||||
selectedWorkflowId: changeWorkflowHookMocks.ids.destinationWorkflow,
|
||||
selectedStepId: changeWorkflowHookMocks.ids.destinationStep,
|
||||
overrides: {},
|
||||
snapshot: { steps: changeWorkflowHookMocks.steps },
|
||||
status: "success",
|
||||
error: null,
|
||||
changeWorkflow: () => {},
|
||||
chooseStep: () => {},
|
||||
setOverride: () => {},
|
||||
retry: () => {},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./use-change-workflow-submit", () => ({
|
||||
useChangeWorkflowSubmit: () => ({
|
||||
isSubmitting: false,
|
||||
submitError: null,
|
||||
sourceProfileErrorId: undefined,
|
||||
uncertainResult: false,
|
||||
submit: async () => true,
|
||||
retryAfterRefresh: async () => {},
|
||||
clearSubmitError: () => {},
|
||||
}),
|
||||
}));
|
||||
|
||||
const DESTINATION_WORKFLOW_ID = workflowId("workflow-destination");
|
||||
const task = {
|
||||
id: "task-1",
|
||||
workspace_id: "workspace-1",
|
||||
workflow_id: "workflow-source",
|
||||
workflow_step_id: "step-source",
|
||||
updated_at: "2026-09-22T12:00:00Z",
|
||||
workflow_agent_overrides: {
|
||||
workflow_id: DESTINATION_WORKFLOW_ID,
|
||||
steps: [{ source_profile_id: "profile-a", replacement_profile_id: "profile-b" }],
|
||||
},
|
||||
} as unknown as Task;
|
||||
|
||||
beforeEach(() => {
|
||||
changeWorkflowHookMocks.task = {
|
||||
id: changeWorkflowHookMocks.ids.task,
|
||||
workspace_id: changeWorkflowHookMocks.ids.workspace,
|
||||
workflow_id: changeWorkflowHookMocks.ids.sourceWorkflow,
|
||||
workflow_step_id: changeWorkflowHookMocks.ids.sourceStep,
|
||||
updated_at: "2026-09-22T12:00:00Z",
|
||||
} as unknown as Task;
|
||||
changeWorkflowHookMocks.profiles = [
|
||||
{
|
||||
id: changeWorkflowHookMocks.ids.profileA,
|
||||
label: "Agent A",
|
||||
agent_id: "agent-a",
|
||||
agent_name: "agent-a",
|
||||
cli_passthrough: false,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: changeWorkflowHookMocks.ids.profileB,
|
||||
label: "Agent B",
|
||||
agent_id: "agent-b",
|
||||
agent_name: "agent-b",
|
||||
cli_passthrough: false,
|
||||
enabled: true,
|
||||
},
|
||||
];
|
||||
changeWorkflowHookMocks.steps = [];
|
||||
changeWorkflowHookMocks.store = {
|
||||
workspaces: {
|
||||
items: [{ id: changeWorkflowHookMocks.ids.workspace, default_executor_id: null }],
|
||||
},
|
||||
kanban: { workflowId: "", steps: [] },
|
||||
kanbanMulti: { snapshots: {} },
|
||||
};
|
||||
});
|
||||
|
||||
describe("change-workflow request helpers", () => {
|
||||
it("builds a canonical payload from the current task version and draft mapping", () => {
|
||||
expect(
|
||||
buildWorkflowChangePayload(task, {
|
||||
" profile-z ": " profile-y ",
|
||||
"profile-a": "profile-a",
|
||||
"profile-empty": " ",
|
||||
}),
|
||||
).toEqual({
|
||||
expected_workflow_id: "workflow-source",
|
||||
expected_step_id: "step-source",
|
||||
expected_updated_at: "2026-09-22T12:00:00Z",
|
||||
agent_overrides: { "profile-z": "profile-y" },
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes map keys in stable order and drops self or empty mappings", () => {
|
||||
expect(
|
||||
normalizeChangeWorkflowOverrides({
|
||||
"profile-b": "replacement-b",
|
||||
"profile-a": "replacement-a",
|
||||
" profile-self ": " profile-self ",
|
||||
"profile-empty": "",
|
||||
}),
|
||||
).toEqual({ "profile-a": "replacement-a", "profile-b": "replacement-b" });
|
||||
});
|
||||
|
||||
it("recognizes a refreshed task that already has the requested destination and map", () => {
|
||||
const refreshedTask = {
|
||||
...task,
|
||||
workflow_id: DESTINATION_WORKFLOW_ID,
|
||||
workflow_step_id: "step-target",
|
||||
} as Task;
|
||||
expect(
|
||||
taskMatchesWorkflowChange(refreshedTask, DESTINATION_WORKFLOW_ID, "step-target", {
|
||||
"profile-a": "profile-b",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
taskMatchesWorkflowChange(refreshedTask, DESTINATION_WORKFLOW_ID, "step-target", {}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useChangeWorkflow agent source derivation", () => {
|
||||
it("groups earlier-step recipients with their fixed source and ignores stale target profiles", () => {
|
||||
const implementStepId = "step-implement";
|
||||
changeWorkflowHookMocks.steps = [
|
||||
{
|
||||
id: implementStepId,
|
||||
workflow_id: workflowId(changeWorkflowHookMocks.ids.destinationWorkflow),
|
||||
name: "Implement",
|
||||
position: 0,
|
||||
agent_profile_id: agentProfileId(changeWorkflowHookMocks.ids.profileA),
|
||||
color: "",
|
||||
allow_manual_move: true,
|
||||
complete_task_on_enter: false,
|
||||
auto_advance_requires_signal: false,
|
||||
cancel_triggers_turn_complete: false,
|
||||
},
|
||||
{
|
||||
id: changeWorkflowHookMocks.ids.destinationStep,
|
||||
workflow_id: workflowId(changeWorkflowHookMocks.ids.destinationWorkflow),
|
||||
name: "PR",
|
||||
position: 1,
|
||||
agent_profile_id: agentProfileId("profile-pr-leftover"),
|
||||
session_target: { kind: "step", step_id: implementStepId },
|
||||
color: "",
|
||||
allow_manual_move: true,
|
||||
complete_task_on_enter: false,
|
||||
auto_advance_requires_signal: false,
|
||||
cancel_triggers_turn_complete: false,
|
||||
},
|
||||
{
|
||||
id: "step-initial",
|
||||
workflow_id: workflowId(changeWorkflowHookMocks.ids.destinationWorkflow),
|
||||
name: "Initial context",
|
||||
position: 2,
|
||||
agent_profile_id: agentProfileId("profile-initial-leftover"),
|
||||
session_target: { kind: "initial" },
|
||||
color: "",
|
||||
allow_manual_move: true,
|
||||
complete_task_on_enter: false,
|
||||
auto_advance_requires_signal: false,
|
||||
cancel_triggers_turn_complete: false,
|
||||
},
|
||||
];
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useChangeWorkflow({
|
||||
open: true,
|
||||
taskId: changeWorkflowHookMocks.ids.task,
|
||||
workspaceId: changeWorkflowHookMocks.ids.workspace,
|
||||
onOpenChange: () => {},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.rows).toEqual([
|
||||
expect.objectContaining({
|
||||
sourceProfileId: changeWorkflowHookMocks.ids.profileA,
|
||||
stepIds: [implementStepId, changeWorkflowHookMocks.ids.destinationStep],
|
||||
stepNames: ["Implement", "PR"],
|
||||
}),
|
||||
]);
|
||||
expect(result.current.canSubmit).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,318 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { AgentProfileOption } from "@/lib/state/slices";
|
||||
import type { ExecutorProfile, Task, Workflow, WorkflowStepDTO } from "@/lib/types/http";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useAgentProfileOptions } from "@/components/task-create-dialog-options";
|
||||
import {
|
||||
buildWorkflowAgentOverrideRows,
|
||||
type WorkflowAgentOverrideRow,
|
||||
} from "@/components/task-create-dialog-workflow-agent-overrides";
|
||||
import { useCompatibleAgentProfiles } from "@/hooks/domains/session/use-compatible-agent-profiles";
|
||||
import {
|
||||
useChangeWorkflowCatalog,
|
||||
useChangeWorkflowDestination,
|
||||
useChangeWorkflowProfiles,
|
||||
useChangeWorkflowTask,
|
||||
type ChangeWorkflowLoadStatus,
|
||||
} from "./use-change-workflow-data";
|
||||
import { useChangeWorkflowSubmit } from "./use-change-workflow-submit";
|
||||
import { buildWorkflowChangePayload } from "./use-change-workflow-utils";
|
||||
|
||||
const NO_SOURCE_STEPS: never[] = [];
|
||||
|
||||
export {
|
||||
buildWorkflowChangePayload,
|
||||
normalizeChangeWorkflowOverrides,
|
||||
taskMatchesWorkflowChange,
|
||||
} from "./use-change-workflow-utils";
|
||||
export type { ChangeWorkflowLoadStatus } from "./use-change-workflow-data";
|
||||
|
||||
function fixedProfileSteps(steps: readonly WorkflowStepDTO[]) {
|
||||
return steps
|
||||
.filter((step) => step.agent_profile_id || step.session_target)
|
||||
.map((step) => ({
|
||||
id: step.id,
|
||||
title: step.name,
|
||||
position: step.position,
|
||||
agent_profile_id: step.agent_profile_id,
|
||||
session_target: step.session_target,
|
||||
}));
|
||||
}
|
||||
|
||||
function profileForTaskExecutor(
|
||||
task: Task | null,
|
||||
workspaceDefaultExecutorId: string | null | undefined,
|
||||
executors: Awaited<ReturnType<typeof import("@/lib/api").listExecutors>>["executors"],
|
||||
): ExecutorProfile | null {
|
||||
if (!task) return null;
|
||||
const profiles = executors.flatMap((executor) => executor.profiles ?? []);
|
||||
if (task.primary_executor_profile_id) {
|
||||
return profiles.find((profile) => profile.id === task.primary_executor_profile_id) ?? null;
|
||||
}
|
||||
const executor = executors.find((item) => item.id === workspaceDefaultExecutorId);
|
||||
return executor?.profiles?.[0] ?? null;
|
||||
}
|
||||
|
||||
function buildOverrideRows(
|
||||
steps: readonly WorkflowStepDTO[],
|
||||
profiles: readonly AgentProfileOption[],
|
||||
options: ReturnType<typeof useAgentProfileOptions>,
|
||||
overrides: Readonly<Record<string, string>>,
|
||||
): WorkflowAgentOverrideRow[] {
|
||||
const rows = buildWorkflowAgentOverrideRows({
|
||||
steps: fixedProfileSteps(steps),
|
||||
profiles,
|
||||
replacementOptions: options,
|
||||
overrides,
|
||||
});
|
||||
const selectableIds = new Set(options.map((option) => option.value));
|
||||
return rows.map((row) => {
|
||||
const selectedProfileId = overrides[row.sourceProfileId] || row.sourceProfileId;
|
||||
return {
|
||||
...row,
|
||||
replacementProfileId: selectedProfileId,
|
||||
replacementLabel:
|
||||
profiles.find((profile) => profile.id === selectedProfileId)?.label ?? selectedProfileId,
|
||||
replacementAvailable: selectableIds.has(selectedProfileId),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function useChangeWorkflowDraftState({
|
||||
task,
|
||||
workflows,
|
||||
profiles,
|
||||
executors,
|
||||
workspaceId,
|
||||
selectedWorkflowId,
|
||||
selectedStepId,
|
||||
overrides,
|
||||
snapshotSteps,
|
||||
taskStatus,
|
||||
profilesStatus,
|
||||
workflowsStatus,
|
||||
snapshotStatus,
|
||||
open,
|
||||
}: {
|
||||
task: Task | null;
|
||||
workflows: Workflow[];
|
||||
profiles: AgentProfileOption[];
|
||||
executors: Awaited<ReturnType<typeof import("@/lib/api").listExecutors>>["executors"];
|
||||
workspaceId: string | null;
|
||||
selectedWorkflowId: string;
|
||||
selectedStepId: string;
|
||||
overrides: Record<string, string>;
|
||||
snapshotSteps: WorkflowStepDTO[];
|
||||
taskStatus: ChangeWorkflowLoadStatus;
|
||||
profilesStatus: ChangeWorkflowLoadStatus;
|
||||
workflowsStatus: ChangeWorkflowLoadStatus;
|
||||
snapshotStatus: ChangeWorkflowLoadStatus;
|
||||
open: boolean;
|
||||
}) {
|
||||
const workspace = useAppStore((state) =>
|
||||
state.workspaces.items.find((item) => item.id === workspaceId),
|
||||
);
|
||||
const selectedWorkflow = workflows.find((workflow) => workflow.id === selectedWorkflowId) ?? null;
|
||||
const executorProfile = profileForTaskExecutor(task, workspace?.default_executor_id, executors);
|
||||
const compatibleProfiles = useCompatibleAgentProfiles(profiles, executorProfile);
|
||||
const profileOptions = useAgentProfileOptions(compatibleProfiles);
|
||||
const rows = useMemo(
|
||||
() => buildOverrideRows(snapshotSteps, profiles, profileOptions, overrides),
|
||||
[snapshotSteps, profiles, profileOptions, overrides],
|
||||
);
|
||||
const workflowChange = useMemo(
|
||||
() => (task ? buildWorkflowChangePayload(task, overrides) : undefined),
|
||||
[task, overrides],
|
||||
);
|
||||
const sourceSteps = useAppStore((state) => {
|
||||
if (!task) return NO_SOURCE_STEPS;
|
||||
if (state.kanban.workflowId === task.workflow_id) return state.kanban.steps;
|
||||
return state.kanbanMulti.snapshots[task.workflow_id]?.steps ?? NO_SOURCE_STEPS;
|
||||
});
|
||||
const sourceWorkflowName = workflows.find((workflow) => workflow.id === task?.workflow_id)?.name;
|
||||
const sourceStepName = sourceSteps.find((step) => step.id === task?.workflow_step_id)?.title;
|
||||
const destinations = workflows.filter((workflow) => workflow.id !== task?.workflow_id);
|
||||
const selectableIds = new Set(profileOptions.map((option) => option.value));
|
||||
const invalidRows =
|
||||
profilesStatus === "success"
|
||||
? rows.filter((row) => !selectableIds.has(row.replacementProfileId))
|
||||
: [];
|
||||
const canSubmit = Boolean(
|
||||
open &&
|
||||
task &&
|
||||
taskStatus === "success" &&
|
||||
workflowsStatus === "success" &&
|
||||
profilesStatus === "success" &&
|
||||
snapshotStatus === "success" &&
|
||||
selectedWorkflow &&
|
||||
selectedWorkflow.id !== task.workflow_id &&
|
||||
snapshotSteps.some((step) => step.id === selectedStepId) &&
|
||||
invalidRows.length === 0,
|
||||
);
|
||||
return {
|
||||
selectedWorkflow,
|
||||
destinations,
|
||||
profileOptions,
|
||||
rows,
|
||||
workflowChange,
|
||||
sourceWorkflowName,
|
||||
sourceStepName,
|
||||
canSubmit,
|
||||
};
|
||||
}
|
||||
|
||||
export function useChangeWorkflow({
|
||||
open,
|
||||
taskId,
|
||||
workspaceId,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}: {
|
||||
open: boolean;
|
||||
taskId: string | null;
|
||||
workspaceId: string | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess?: () => void;
|
||||
}) {
|
||||
const taskData = useChangeWorkflowTask({ open, taskId, workspaceId });
|
||||
const workflowData = useChangeWorkflowCatalog(open, workspaceId);
|
||||
const profileData = useChangeWorkflowProfiles(open, workspaceId);
|
||||
const destination = useChangeWorkflowDestination(open);
|
||||
const [sourceChanged, setSourceChanged] = useState(false);
|
||||
const draft = useChangeWorkflowDraftState({
|
||||
task: taskData.task,
|
||||
workflows: workflowData.workflows,
|
||||
profiles: profileData.profiles,
|
||||
executors: profileData.executors,
|
||||
workspaceId,
|
||||
selectedWorkflowId: destination.selectedWorkflowId,
|
||||
selectedStepId: destination.selectedStepId,
|
||||
overrides: destination.overrides,
|
||||
snapshotSteps: destination.snapshot?.steps ?? [],
|
||||
taskStatus: taskData.status,
|
||||
profilesStatus: profileData.status,
|
||||
workflowsStatus: workflowData.status,
|
||||
snapshotStatus: destination.status,
|
||||
open,
|
||||
});
|
||||
const refreshTask = useCallback(async () => {
|
||||
const refreshed = await taskData.refreshTask();
|
||||
if (refreshed) setSourceChanged(true);
|
||||
return refreshed;
|
||||
}, [taskData.refreshTask]);
|
||||
const submitState = useChangeWorkflowSubmit({
|
||||
open,
|
||||
canSubmit: draft.canSubmit,
|
||||
task: taskData.task,
|
||||
selectedWorkflow: draft.selectedWorkflow,
|
||||
selectedStepId: destination.selectedStepId,
|
||||
workflowChange: draft.workflowChange,
|
||||
refreshTask,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
});
|
||||
|
||||
const setOverride = useCallback(
|
||||
(sourceId: string, replacementId: string) => {
|
||||
destination.setOverride(sourceId, replacementId);
|
||||
submitState.clearSubmitError();
|
||||
},
|
||||
[destination.setOverride, submitState.clearSubmitError],
|
||||
);
|
||||
const setSelectedStepId = useCallback(
|
||||
(stepId: string) => {
|
||||
destination.chooseStep(stepId);
|
||||
submitState.clearSubmitError();
|
||||
},
|
||||
[destination.chooseStep, submitState.clearSubmitError],
|
||||
);
|
||||
const changeWorkflow = useCallback(
|
||||
(workflowId: string) => {
|
||||
destination.changeWorkflow(workflowId);
|
||||
submitState.clearSubmitError();
|
||||
},
|
||||
[destination.changeWorkflow, submitState.clearSubmitError],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setSourceChanged(false);
|
||||
}, [open, taskId]);
|
||||
|
||||
return createChangeWorkflowViewModel({
|
||||
taskData,
|
||||
workflowData,
|
||||
profileData,
|
||||
destination,
|
||||
sourceChanged,
|
||||
draft,
|
||||
submitState,
|
||||
setOverride,
|
||||
setSelectedStepId,
|
||||
changeWorkflow,
|
||||
});
|
||||
}
|
||||
|
||||
function createChangeWorkflowViewModel({
|
||||
taskData,
|
||||
workflowData,
|
||||
profileData,
|
||||
destination,
|
||||
sourceChanged,
|
||||
draft,
|
||||
submitState,
|
||||
setOverride,
|
||||
setSelectedStepId,
|
||||
changeWorkflow,
|
||||
}: {
|
||||
taskData: ReturnType<typeof useChangeWorkflowTask>;
|
||||
workflowData: ReturnType<typeof useChangeWorkflowCatalog>;
|
||||
profileData: ReturnType<typeof useChangeWorkflowProfiles>;
|
||||
destination: ReturnType<typeof useChangeWorkflowDestination>;
|
||||
sourceChanged: boolean;
|
||||
draft: ReturnType<typeof useChangeWorkflowDraftState>;
|
||||
submitState: ReturnType<typeof useChangeWorkflowSubmit>;
|
||||
setOverride: (sourceId: string, replacementId: string) => void;
|
||||
setSelectedStepId: (stepId: string) => void;
|
||||
changeWorkflow: (workflowId: string) => void;
|
||||
}) {
|
||||
return {
|
||||
task: taskData.task,
|
||||
taskStatus: taskData.status,
|
||||
taskError: taskData.error,
|
||||
workflows: workflowData.workflows,
|
||||
workflowsStatus: workflowData.status,
|
||||
workflowsError: workflowData.error,
|
||||
destinations: draft.destinations,
|
||||
selectedWorkflowId: destination.selectedWorkflowId,
|
||||
selectedWorkflow: draft.selectedWorkflow,
|
||||
changeWorkflow,
|
||||
snapshot: destination.snapshot,
|
||||
snapshotStatus: destination.status,
|
||||
snapshotError: destination.error,
|
||||
retrySnapshot: destination.retry,
|
||||
profiles: profileData.profiles,
|
||||
profilesStatus: profileData.status,
|
||||
profilesError: profileData.error,
|
||||
profileOptions: draft.profileOptions,
|
||||
rows: draft.rows,
|
||||
setOverride,
|
||||
overrides: destination.overrides,
|
||||
selectedStepId: destination.selectedStepId,
|
||||
setSelectedStepId,
|
||||
sourceWorkflowName: draft.sourceWorkflowName,
|
||||
sourceStepName: draft.sourceStepName,
|
||||
sourceChanged,
|
||||
uncertainResult: submitState.uncertainResult,
|
||||
submitError: submitState.submitError,
|
||||
sourceProfileErrorId: submitState.sourceProfileErrorId,
|
||||
isSubmitting: submitState.isSubmitting,
|
||||
canSubmit: draft.canSubmit && !submitState.isSubmitting && !submitState.uncertainResult,
|
||||
submit: submitState.submit,
|
||||
retryTask: submitState.retryAfterRefresh,
|
||||
retryWorkflows: workflowData.retry,
|
||||
retryProfiles: profileData.retry,
|
||||
workflowChange: draft.workflowChange,
|
||||
};
|
||||
}
|
||||
@@ -143,6 +143,36 @@ describe("useWorkflowMovePreview request lifecycle", () => {
|
||||
expect(result.current.status).toBe("success");
|
||||
expect(result.current.preview?.workflow_step_id).toBe(FIRST_STEP_ID);
|
||||
});
|
||||
|
||||
it("includes the task's draft workflow-agent mapping in the preview request", async () => {
|
||||
previewWorkflowMoveMock.mockResolvedValueOnce(makePreview(FIRST_STEP_ID));
|
||||
const workflowChange = {
|
||||
expected_workflow_id: "source-workflow",
|
||||
expected_step_id: "source-step",
|
||||
expected_updated_at: "2026-09-14T00:00:00Z",
|
||||
agent_overrides: { "profile-source": "profile-replacement" },
|
||||
};
|
||||
renderHook(() =>
|
||||
useWorkflowMovePreview({
|
||||
taskId: TASK_ID,
|
||||
workflowId: WORKFLOW_ID,
|
||||
workflowStepId: FIRST_STEP_ID,
|
||||
workflowChange,
|
||||
enabled: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(150);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(previewWorkflowMoveMock).toHaveBeenCalledWith(
|
||||
TASK_ID,
|
||||
expect.objectContaining({ workflow_change: workflowChange }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useWorkflowMovePreview invalidation", () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
previewWorkflowMove,
|
||||
type WorkflowMoveEntryOptions,
|
||||
type WorkflowMovePreviewResponse,
|
||||
type WorkflowChangePayload,
|
||||
} from "@/lib/api";
|
||||
import { normalizeWorkflowMoveEntryOptions } from "@/lib/api/domains/kanban-api";
|
||||
|
||||
@@ -25,6 +26,7 @@ type WorkflowMovePreviewParams = {
|
||||
workflowId?: string | null;
|
||||
workflowStepId?: string | null;
|
||||
entryOptions?: WorkflowMoveEntryOptions | null;
|
||||
workflowChange?: WorkflowChangePayload | null;
|
||||
enabled?: boolean;
|
||||
/** Changes when an external task/session/workflow projection is known stale. */
|
||||
invalidationKey?: string | number | null;
|
||||
@@ -47,6 +49,7 @@ type QueuedPreview = {
|
||||
workflowId: string;
|
||||
workflowStepId: string;
|
||||
entryOptions: WorkflowMoveEntryOptions | undefined;
|
||||
workflowChange: WorkflowChangePayload | undefined;
|
||||
};
|
||||
|
||||
const inFlightPreviews = new Map<string, PreviewRequestEntry>();
|
||||
@@ -65,18 +68,35 @@ function previewOptionKey(options: WorkflowMoveEntryOptions | null | undefined):
|
||||
});
|
||||
}
|
||||
|
||||
function previewRequestKey(
|
||||
taskId: string | null | undefined,
|
||||
workflowId: string | null | undefined,
|
||||
workflowStepId: string | null | undefined,
|
||||
options: WorkflowMoveEntryOptions | null | undefined,
|
||||
invalidationKey: string | number | null | undefined,
|
||||
): string {
|
||||
function previewRequestKey({
|
||||
taskId,
|
||||
workflowId,
|
||||
workflowStepId,
|
||||
entryOptions: options,
|
||||
workflowChange,
|
||||
invalidationKey,
|
||||
}: Pick<
|
||||
WorkflowMovePreviewParams,
|
||||
"taskId" | "workflowId" | "workflowStepId" | "entryOptions" | "workflowChange" | "invalidationKey"
|
||||
>): string {
|
||||
const normalizedChange = workflowChange
|
||||
? {
|
||||
expected_workflow_id: workflowChange.expected_workflow_id,
|
||||
expected_step_id: workflowChange.expected_step_id,
|
||||
expected_updated_at: workflowChange.expected_updated_at,
|
||||
agent_overrides: Object.fromEntries(
|
||||
Object.entries(workflowChange.agent_overrides).sort(([left], [right]) =>
|
||||
left.localeCompare(right),
|
||||
),
|
||||
),
|
||||
}
|
||||
: undefined;
|
||||
return JSON.stringify([
|
||||
taskId ?? "",
|
||||
workflowId ?? "",
|
||||
workflowStepId ?? "",
|
||||
previewOptionKey(options),
|
||||
normalizedChange,
|
||||
invalidationKey ?? "",
|
||||
]);
|
||||
}
|
||||
@@ -116,15 +136,16 @@ function pumpPreviewQueue(): void {
|
||||
activePreviewCount += 1;
|
||||
let request: Promise<WorkflowMovePreviewResponse>;
|
||||
try {
|
||||
request = previewWorkflowMove(
|
||||
queued.taskId,
|
||||
{
|
||||
workflow_id: queued.workflowId,
|
||||
workflow_step_id: queued.workflowStepId,
|
||||
entry_options: queued.entryOptions,
|
||||
},
|
||||
{ cache: "no-store", init: { signal: entry.controller.signal } },
|
||||
);
|
||||
const payload = {
|
||||
workflow_id: queued.workflowId,
|
||||
workflow_step_id: queued.workflowStepId,
|
||||
entry_options: queued.entryOptions,
|
||||
...(queued.workflowChange ? { workflow_change: queued.workflowChange } : {}),
|
||||
};
|
||||
request = previewWorkflowMove(queued.taskId, payload, {
|
||||
cache: "no-store",
|
||||
init: { signal: entry.controller.signal },
|
||||
});
|
||||
} catch (error) {
|
||||
entry.reject(error);
|
||||
finishPreview(key, entry);
|
||||
@@ -144,13 +165,17 @@ function pumpPreviewQueue(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function acquirePreview(
|
||||
key: string,
|
||||
taskId: string,
|
||||
workflowId: string,
|
||||
workflowStepId: string,
|
||||
entryOptions: WorkflowMoveEntryOptions | undefined,
|
||||
): { promise: Promise<WorkflowMovePreviewResponse>; release: () => void } {
|
||||
function acquirePreview({
|
||||
key,
|
||||
taskId,
|
||||
workflowId,
|
||||
workflowStepId,
|
||||
entryOptions,
|
||||
workflowChange,
|
||||
}: Pick<
|
||||
QueuedPreview,
|
||||
"key" | "taskId" | "workflowId" | "workflowStepId" | "entryOptions" | "workflowChange"
|
||||
>): { promise: Promise<WorkflowMovePreviewResponse>; release: () => void } {
|
||||
let entry = inFlightPreviews.get(key);
|
||||
if (!entry || entry.controller.signal.aborted) {
|
||||
const controller = new AbortController();
|
||||
@@ -174,7 +199,15 @@ function acquirePreview(
|
||||
// rejection handler. Keep cancellation from becoming an unhandled
|
||||
// rejection while the hook's generation guard handles the result.
|
||||
void entry.promise.catch(() => undefined);
|
||||
previewQueue.push({ key, entry, taskId, workflowId, workflowStepId, entryOptions });
|
||||
previewQueue.push({
|
||||
key,
|
||||
entry,
|
||||
taskId,
|
||||
workflowId,
|
||||
workflowStepId,
|
||||
entryOptions,
|
||||
workflowChange,
|
||||
});
|
||||
pumpPreviewQueue();
|
||||
}
|
||||
|
||||
@@ -201,6 +234,7 @@ export function useWorkflowMovePreview({
|
||||
workflowId,
|
||||
workflowStepId,
|
||||
entryOptions,
|
||||
workflowChange,
|
||||
enabled = true,
|
||||
invalidationKey,
|
||||
}: WorkflowMovePreviewParams): WorkflowMovePreviewState {
|
||||
@@ -214,13 +248,14 @@ export function useWorkflowMovePreview({
|
||||
error: null,
|
||||
});
|
||||
const generationRef = useRef(0);
|
||||
const requestKey = previewRequestKey(
|
||||
const requestKey = previewRequestKey({
|
||||
taskId,
|
||||
workflowId,
|
||||
workflowStepId,
|
||||
normalizedEntryOptions,
|
||||
entryOptions: normalizedEntryOptions,
|
||||
workflowChange,
|
||||
invalidationKey,
|
||||
);
|
||||
});
|
||||
|
||||
const retry = useCallback(() => setRetrySequence((current) => current + 1), []);
|
||||
|
||||
@@ -238,13 +273,14 @@ export function useWorkflowMovePreview({
|
||||
setState({ status: "loading", preview: null, error: null });
|
||||
const timer = window.setTimeout(() => {
|
||||
if (generationRef.current !== generation) return;
|
||||
const request = acquirePreview(
|
||||
requestKey,
|
||||
const request = acquirePreview({
|
||||
key: requestKey,
|
||||
taskId,
|
||||
workflowId,
|
||||
workflowStepId,
|
||||
entryOptionsRef.current,
|
||||
);
|
||||
entryOptions: entryOptionsRef.current,
|
||||
workflowChange: workflowChange ?? undefined,
|
||||
});
|
||||
release = request.release;
|
||||
request.promise.then(
|
||||
(preview) => {
|
||||
|
||||
@@ -86,7 +86,7 @@ type ComputeEntriesArgs = {
|
||||
linkHandlers: ReturnType<typeof buildLinkHandlers>;
|
||||
pluginLinkActions: ReturnType<typeof useTaskPluginLinkActions>;
|
||||
onMoveToStep: (stepId: string) => void;
|
||||
onSendToWorkflow: (workflowId: string, stepId: string) => void;
|
||||
onChangeWorkflow: () => void;
|
||||
pluginMenuContext: PluginTaskMenuContext;
|
||||
onSelectPriority: (priority: TaskPriority) => void;
|
||||
};
|
||||
@@ -124,7 +124,7 @@ function computeTaskActionsMenuEntries(args: ComputeEntriesArgs) {
|
||||
...args.linkHandlers,
|
||||
pluginLinkActions: args.pluginLinkActions,
|
||||
onMoveToStep: args.onMoveToStep,
|
||||
onSendToWorkflow: args.onSendToWorkflow,
|
||||
onChangeWorkflow: args.onChangeWorkflow,
|
||||
pluginMenuContext: args.pluginMenuContext,
|
||||
});
|
||||
}
|
||||
@@ -176,7 +176,20 @@ function useSubjectScopedDialogs(
|
||||
}, 300);
|
||||
}, [dialogs, taskId]);
|
||||
|
||||
return { closeDialogs, requestDetachConfirmation, requestArchiveConfirmation };
|
||||
const requestChangeWorkflow = useCallback(() => {
|
||||
const requestedForTaskId = taskId;
|
||||
window.setTimeout(() => {
|
||||
if (taskIdRef.current !== requestedForTaskId) return;
|
||||
dialogs.setShowChangeWorkflow(true);
|
||||
}, 300);
|
||||
}, [dialogs.setShowChangeWorkflow, taskId]);
|
||||
|
||||
return {
|
||||
closeDialogs,
|
||||
requestDetachConfirmation,
|
||||
requestArchiveConfirmation,
|
||||
requestChangeWorkflow,
|
||||
};
|
||||
}
|
||||
|
||||
type UseTaskActionsMenuArgs = {
|
||||
@@ -231,8 +244,12 @@ export function useTaskActionsMenu({
|
||||
|
||||
const disabled = Boolean(isArchiving || isDeleting || isDetaching);
|
||||
|
||||
const { closeDialogs, requestDetachConfirmation, requestArchiveConfirmation } =
|
||||
useSubjectScopedDialogs(taskId, dialogs, editDialog);
|
||||
const {
|
||||
closeDialogs,
|
||||
requestDetachConfirmation,
|
||||
requestArchiveConfirmation,
|
||||
requestChangeWorkflow,
|
||||
} = useSubjectScopedDialogs(taskId, dialogs, editDialog);
|
||||
|
||||
const handleDetachConfirm = useCallback(async () => {
|
||||
if (!taskId) return;
|
||||
@@ -254,16 +271,6 @@ export function useTaskActionsMenu({
|
||||
[moveTasks, moveTargets.currentWorkflowId, taskId],
|
||||
);
|
||||
|
||||
const onSendToWorkflow = useCallback(
|
||||
(workflowId: string, stepId: string) => {
|
||||
if (!taskId) return;
|
||||
void moveTasks([taskId], workflowId, stepId, "workflow").catch(() => {
|
||||
// useTaskWorkflowMove already shows the failure toast.
|
||||
});
|
||||
},
|
||||
[moveTasks, taskId],
|
||||
);
|
||||
|
||||
const pluginMenuContext: PluginTaskMenuContext = {
|
||||
workspaceId: workspaceId ?? "",
|
||||
taskId: taskId ?? "",
|
||||
@@ -291,7 +298,7 @@ export function useTaskActionsMenu({
|
||||
linkHandlers,
|
||||
pluginLinkActions,
|
||||
onMoveToStep,
|
||||
onSendToWorkflow,
|
||||
onChangeWorkflow: requestChangeWorkflow,
|
||||
pluginMenuContext,
|
||||
onSelectPriority,
|
||||
});
|
||||
|
||||
@@ -2,7 +2,13 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
|
||||
import { resolveTaskMenuTarget, type TaskMenuIdentity } from "@/lib/tasks/task-menu-target";
|
||||
|
||||
export type TaskManagementStage = "closed" | "menu" | "archive" | "delete" | "link";
|
||||
export type TaskManagementStage =
|
||||
| "closed"
|
||||
| "menu"
|
||||
| "change-workflow"
|
||||
| "archive"
|
||||
| "delete"
|
||||
| "link";
|
||||
|
||||
export function useTaskManagementFlow() {
|
||||
const store = useAppStoreApi();
|
||||
|
||||
@@ -14,6 +14,7 @@ export function useTaskMenuDialogState() {
|
||||
const [showPRDialog, setShowPRDialog] = useState(false);
|
||||
const [showIssueDialog, setShowIssueDialog] = useState(false);
|
||||
const [showMRDialog, setShowMRDialog] = useState(false);
|
||||
const [showChangeWorkflow, setShowChangeWorkflow] = useState(false);
|
||||
const [externalLinkProvider, setExternalLinkProvider] = useState<ExternalLinkProvider | null>(
|
||||
null,
|
||||
);
|
||||
@@ -24,6 +25,7 @@ export function useTaskMenuDialogState() {
|
||||
setShowPRDialog(false);
|
||||
setShowIssueDialog(false);
|
||||
setShowMRDialog(false);
|
||||
setShowChangeWorkflow(false);
|
||||
setExternalLinkProvider(null);
|
||||
}, []);
|
||||
return {
|
||||
@@ -39,6 +41,8 @@ export function useTaskMenuDialogState() {
|
||||
setShowIssueDialog,
|
||||
showMRDialog,
|
||||
setShowMRDialog,
|
||||
showChangeWorkflow,
|
||||
setShowChangeWorkflow,
|
||||
externalLinkProvider,
|
||||
setExternalLinkProvider,
|
||||
closeAll,
|
||||
|
||||
@@ -271,12 +271,20 @@ export type WorkflowMoveEntryOptions = {
|
||||
skip_step_prompt?: boolean;
|
||||
};
|
||||
|
||||
export type WorkflowChangePayload = {
|
||||
expected_workflow_id: string;
|
||||
expected_step_id: string;
|
||||
expected_updated_at: string;
|
||||
agent_overrides: Record<string, string>;
|
||||
};
|
||||
|
||||
export type MoveTaskPayload = {
|
||||
workflow_id: string;
|
||||
workflow_step_id: string;
|
||||
/** @deprecated Server computes arrival position per AC.28; this field is transmitted but ignored. */
|
||||
position?: number;
|
||||
entry_options?: WorkflowMoveEntryOptions | null;
|
||||
workflow_change?: WorkflowChangePayload | null;
|
||||
};
|
||||
|
||||
/** Move response fields added by the one-shot entry-options transport. */
|
||||
|
||||
@@ -35,7 +35,7 @@ const baseArgs = {
|
||||
currentPriority: "high",
|
||||
onSelectPriority: vi.fn(),
|
||||
onMoveToStep: vi.fn(),
|
||||
onSendToWorkflow: vi.fn(),
|
||||
onChangeWorkflow: vi.fn(),
|
||||
// A real card always wires at least one link handler; omitting it here
|
||||
// would silently drop "link" from the entries array and let the order
|
||||
// assertion below pass without ever exercising its actual position.
|
||||
@@ -102,7 +102,7 @@ describe("buildTaskActionsMenuEntries — normal tier", () => {
|
||||
"detach",
|
||||
"move-separator",
|
||||
"move-to",
|
||||
"send-to-workflow",
|
||||
"change-workflow",
|
||||
REMOVE_SEPARATOR,
|
||||
"archive",
|
||||
"delete",
|
||||
@@ -172,10 +172,10 @@ describe("buildTaskActionsMenuEntries — archived tier", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits Edit, Move to, Send to workflow, Link, Detach from parent, and Archive", () => {
|
||||
it("omits Edit, Move to, Change workflow, Link, Detach from parent, and Archive", () => {
|
||||
const entries = buildTaskActionsMenuEntries("archived", baseArgs);
|
||||
const keys = itemKeys(entries);
|
||||
for (const omitted of ["edit", "move-to", "send-to-workflow", "link", "archive", "detach"]) {
|
||||
for (const omitted of ["edit", "move-to", "change-workflow", "link", "archive", "detach"]) {
|
||||
expect(keys).not.toContain(omitted);
|
||||
}
|
||||
});
|
||||
@@ -206,10 +206,10 @@ describe("buildTaskActionsMenuEntries — unresolved board row tier", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits Edit, Move to, Send to workflow, Link, and Detach from parent even with a parentTaskId", () => {
|
||||
it("omits Edit, Move to, Change workflow, Link, and Detach from parent even with a parentTaskId", () => {
|
||||
const entries = buildTaskActionsMenuEntries("unresolved-row", baseArgs);
|
||||
const keys = itemKeys(entries);
|
||||
for (const omitted of ["edit", "move-to", "send-to-workflow", "link", "detach"]) {
|
||||
for (const omitted of ["edit", "move-to", "change-workflow", "link", "detach"]) {
|
||||
expect(keys).not.toContain(omitted);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -30,7 +30,8 @@
|
||||
},
|
||||
"workflows": {
|
||||
"profileSessionLifecycleIntro": "這些設定適用於工作流程更改代理程式設定檔的情況。",
|
||||
"profileSessionStartReuseDescription": "繼續使用此代理程式設定檔最近可用的工作階段。如果沒有可用工作階段,則開始新工作階段。"
|
||||
"profileSessionStartReuseDescription": "繼續使用此代理程式設定檔最近可用的工作階段。如果沒有可用工作階段,則開始新工作階段。",
|
||||
"openAgentSettings": "開啟代理程式設定"
|
||||
},
|
||||
"system": {
|
||||
"diskRefreshHelp": "遍歷 Kandev 資料目錄內的所有子目錄並累計檔案大小。結果會快取幾小時;點選可立即重新計算。大型工作區可能需要幾秒。"
|
||||
@@ -63,7 +64,8 @@
|
||||
},
|
||||
"workflows": {
|
||||
"profileSessionLifecycleIntro": "這些設定適用於工作流程更改代理程式設定檔的情況。",
|
||||
"profileSessionStartReuseDescription": "繼續使用此代理程式設定檔最近可用的工作階段。如果沒有可用工作階段,則開始新工作階段。"
|
||||
"profileSessionStartReuseDescription": "繼續使用此代理程式設定檔最近可用的工作階段。如果沒有可用工作階段,則開始新工作階段。",
|
||||
"openAgentSettings": "開啟代理程式設定"
|
||||
},
|
||||
"system": {
|
||||
"diskRefreshHelp": "遍歷 Kandev 數據目錄內的所有子目錄並累計檔案大小。結果會快取幾小時;點擊可立即重新計算。大型工作區可能需要幾秒。"
|
||||
|
||||
@@ -94,7 +94,6 @@
|
||||
"selectRepository": "Select repository",
|
||||
"selectTask": "Select task {{title}}",
|
||||
"selectWorkflow": "Select workflow",
|
||||
"sendToWorkflow": "Send to workflow",
|
||||
"selectedFilterCount_one": "{{label}}: {{count}} selected",
|
||||
"selectedFilterCount_other": "{{label}}: {{count}} selected",
|
||||
"sentryIssue": "Sentry Issue",
|
||||
|
||||
@@ -1631,7 +1631,6 @@
|
||||
"sendComment": "Send comment",
|
||||
"sendToAgent": "Send to agent",
|
||||
"sendToAgentHelp": "Hit <1>Send to agent</1> inside the panel to deliver them to the CLI agent right away, or just open the chat box and type a follow-up; the comments will be prepended.",
|
||||
"sendToWorkflow": "Send to workflow",
|
||||
"senderWithSession": "{{base}} · {{sessionName}}",
|
||||
"sentAsNewMessage": "· sent as new message",
|
||||
"sentryInstance": "Sentry instance",
|
||||
@@ -2380,5 +2379,51 @@
|
||||
"bulkColorAutomaticHint": "Automatic rules may override the visible color.",
|
||||
"bulkColorTitle_one": "Color for {{count}} task",
|
||||
"bulkColorTitle_other": "Color for {{count}} tasks",
|
||||
"editorActions": "Editor and folder actions"
|
||||
"editorActions": "Editor and folder actions",
|
||||
"changeWorkflowTitle": "Change workflow...",
|
||||
"changeWorkflowDescription": "Choose a destination workflow, step, and task-specific agent profiles.",
|
||||
"changeWorkflowCurrentLabel": "Current",
|
||||
"changeWorkflowCurrentAssignment": "{{workflow}} · {{step}}",
|
||||
"changeWorkflowLoadingTask": "Loading task...",
|
||||
"changeWorkflowLoadTaskFailed": "Could not load this task.",
|
||||
"changeWorkflowRetry": "Retry",
|
||||
"changeWorkflowLoadingWorkflows": "Loading workflows...",
|
||||
"changeWorkflowLoadWorkflowsFailed": "Could not load workflows.",
|
||||
"changeWorkflowNoDestinations": "No other visible workflows are available.",
|
||||
"changeWorkflowDestinationLabel": "Destination workflow",
|
||||
"changeWorkflowDestinationPlaceholder": "Select a workflow",
|
||||
"changeWorkflowSearchWorkflows": "Search workflows...",
|
||||
"changeWorkflowNoDestinationFound": "No workflow found.",
|
||||
"changeWorkflowLoadingSteps": "Loading workflow steps...",
|
||||
"changeWorkflowLoadStepsFailed": "Could not load workflow steps.",
|
||||
"changeWorkflowNoSteps": "This workflow has no steps.",
|
||||
"changeWorkflowStepLabel": "Destination step",
|
||||
"changeWorkflowStepPlaceholder": "Select a step",
|
||||
"changeWorkflowSearchSteps": "Search steps...",
|
||||
"changeWorkflowNoStepFound": "No step found.",
|
||||
"changeWorkflowAgentSection": "Workflow agents",
|
||||
"changeWorkflowAgentScope": "Choose replacements for this task only.",
|
||||
"changeWorkflowLoadingProfiles": "Loading agent profiles...",
|
||||
"changeWorkflowLoadProfilesFailed": "Could not load agent profiles.",
|
||||
"changeWorkflowNoFixedProfiles": "This workflow has no fixed agent profiles to replace.",
|
||||
"changeWorkflowProfileSteps": "Steps: {{steps}}",
|
||||
"changeWorkflowProfilePlaceholder": "Use workflow profile",
|
||||
"changeWorkflowReplacementLabel": "Replacement for {{profile}}",
|
||||
"changeWorkflowResetProfile": "Reset",
|
||||
"changeWorkflowProfileUnavailable": "This profile is unavailable for the task's executor. Choose an eligible profile or reset.",
|
||||
"changeWorkflowConversationSection": "Conversation relationships",
|
||||
"changeWorkflowInitialConversation": "{{step}} uses the initial conversation.",
|
||||
"changeWorkflowStepConversation": "{{step}} uses the conversation from {{target}}.",
|
||||
"changeWorkflowReplacePreviousOverrides": "Changing workflows replaces this task's existing workflow agent overrides.",
|
||||
"changeWorkflowEntryPreview": "On entry",
|
||||
"changeWorkflowSourceChanged": "The task changed while this form was open. Review the current assignment, then submit again.",
|
||||
"changeWorkflowUncertain": "The result is not confirmed. The task was refreshed. Review the current assignment before trying again.",
|
||||
"changeWorkflowFailed": "The workflow could not be changed. Review the choices and try again.",
|
||||
"changeWorkflowSubmit": "Change workflow",
|
||||
"changeWorkflowCancel": "Cancel",
|
||||
"changeWorkflowSuccess": "Workflow changed.",
|
||||
"changeWorkflowObservedSuccess": "The workflow change was confirmed after refreshing the task.",
|
||||
"changeWorkflow": "Change workflow...",
|
||||
"changeWorkflowForSelectedTasks": "Change workflow for selected tasks",
|
||||
"changeWorkflowRefreshTask": "Refresh task"
|
||||
}
|
||||
|
||||
@@ -94,7 +94,6 @@
|
||||
"selectRepository": "リポジトリを選択",
|
||||
"selectTask": "タスク {{title}} を選択",
|
||||
"selectWorkflow": "ワークフローを選択",
|
||||
"sendToWorkflow": "ワークフローに送信",
|
||||
"selectedFilterCount_one": "{{label}}: {{count}}件選択",
|
||||
"selectedFilterCount_other": "{{label}}: {{count}}件選択",
|
||||
"sentryIssue": "Sentryの問題",
|
||||
|
||||
@@ -1631,7 +1631,6 @@
|
||||
"sendComment": "コメントを送信",
|
||||
"sendToAgent": "エージェントに送信",
|
||||
"sendToAgentHelp": "パネル内の <1>エージェントに送信</1> を押せば、すぐ CLI エージェントへ渡せる。チャットボックスを開いて続けて入力してもいい。コメントは先頭に付く。",
|
||||
"sendToWorkflow": "ワークフローに送信",
|
||||
"senderWithSession": "{{base}} · {{sessionName}}",
|
||||
"sentAsNewMessage": "· 新規メッセージとして送信",
|
||||
"sentryInstance": "Sentry インスタンス",
|
||||
@@ -2380,5 +2379,51 @@
|
||||
"bulkColorAutomaticHint": "自動ルールにより表示色が上書きされる場合があります。",
|
||||
"bulkColorTitle_one": "{{count}}件のタスクの色",
|
||||
"bulkColorTitle_other": "{{count}}件のタスクの色",
|
||||
"editorActions": "エディターとフォルダーの操作"
|
||||
"editorActions": "エディターとフォルダーの操作",
|
||||
"changeWorkflowTitle": "ワークフローを変更...",
|
||||
"changeWorkflowDescription": "移動先のワークフロー、ステップ、このタスクだけで使うエージェントプロファイルを選びます。",
|
||||
"changeWorkflowCurrentLabel": "現在",
|
||||
"changeWorkflowCurrentAssignment": "{{workflow}} · {{step}}",
|
||||
"changeWorkflowLoadingTask": "タスクを読み込んでいます...",
|
||||
"changeWorkflowLoadTaskFailed": "このタスクを読み込めませんでした。",
|
||||
"changeWorkflowRetry": "再試行",
|
||||
"changeWorkflowLoadingWorkflows": "ワークフローを読み込んでいます...",
|
||||
"changeWorkflowLoadWorkflowsFailed": "ワークフローを読み込めませんでした。",
|
||||
"changeWorkflowNoDestinations": "ほかに表示できるワークフローはありません。",
|
||||
"changeWorkflowDestinationLabel": "移動先のワークフロー",
|
||||
"changeWorkflowDestinationPlaceholder": "ワークフローを選択",
|
||||
"changeWorkflowSearchWorkflows": "ワークフローを検索...",
|
||||
"changeWorkflowNoDestinationFound": "ワークフローが見つかりません。",
|
||||
"changeWorkflowLoadingSteps": "ワークフローのステップを読み込んでいます...",
|
||||
"changeWorkflowLoadStepsFailed": "ワークフローのステップを読み込めませんでした。",
|
||||
"changeWorkflowNoSteps": "このワークフローにはステップがありません。",
|
||||
"changeWorkflowStepLabel": "移動先のステップ",
|
||||
"changeWorkflowStepPlaceholder": "ステップを選択",
|
||||
"changeWorkflowSearchSteps": "ステップを検索...",
|
||||
"changeWorkflowNoStepFound": "ステップが見つかりません。",
|
||||
"changeWorkflowAgentSection": "ワークフローのエージェント",
|
||||
"changeWorkflowAgentScope": "このタスクだけに適用する代替プロファイルを選びます。",
|
||||
"changeWorkflowLoadingProfiles": "エージェントプロファイルを読み込んでいます...",
|
||||
"changeWorkflowLoadProfilesFailed": "エージェントプロファイルを読み込めませんでした。",
|
||||
"changeWorkflowNoFixedProfiles": "このワークフローには置き換え可能な固定プロファイルがありません。",
|
||||
"changeWorkflowProfileSteps": "対象ステップ: {{steps}}",
|
||||
"changeWorkflowProfilePlaceholder": "ワークフローのプロファイルを使用",
|
||||
"changeWorkflowReplacementLabel": "{{profile}} の代替プロファイル",
|
||||
"changeWorkflowResetProfile": "リセット",
|
||||
"changeWorkflowProfileUnavailable": "このプロファイルはタスクの実行環境では利用できません。利用可能なプロファイルを選ぶか、リセットしてください。",
|
||||
"changeWorkflowConversationSection": "会話の関係",
|
||||
"changeWorkflowInitialConversation": "{{step}} は最初の会話を使います。",
|
||||
"changeWorkflowStepConversation": "{{step}} は {{target}} の会話を使います。",
|
||||
"changeWorkflowReplacePreviousOverrides": "ワークフローを変更すると、このタスクの既存のエージェント置換設定は置き換えられます。",
|
||||
"changeWorkflowEntryPreview": "移動時の動作",
|
||||
"changeWorkflowSourceChanged": "フォームを開いている間にタスクが変更されました。現在の割り当てを確認してから、もう一度送信してください。",
|
||||
"changeWorkflowUncertain": "結果を確認できませんでした。タスクを再読み込みしました。再試行する前に現在の割り当てを確認してください。",
|
||||
"changeWorkflowFailed": "ワークフローを変更できませんでした。選択内容を確認して、もう一度お試しください。",
|
||||
"changeWorkflowSubmit": "ワークフローを変更",
|
||||
"changeWorkflowCancel": "キャンセル",
|
||||
"changeWorkflowSuccess": "ワークフローを変更しました。",
|
||||
"changeWorkflowObservedSuccess": "タスクを再読み込みし、ワークフローの変更を確認しました。",
|
||||
"changeWorkflow": "ワークフローを変更...",
|
||||
"changeWorkflowForSelectedTasks": "選択したタスクのワークフローを変更",
|
||||
"changeWorkflowRefreshTask": "タスクを再読み込み"
|
||||
}
|
||||
|
||||
@@ -94,7 +94,6 @@
|
||||
"selectRepository": "Śēĺēćţ ŕēƥōśĩţōŕŷ",
|
||||
"selectTask": "Śēĺēćţ ţàśķ {{title}}",
|
||||
"selectWorkflow": "Śēĺēćţ ŵōŕķƒĺōŵ",
|
||||
"sendToWorkflow": "Śēńď ţō ŵōŕķƒĺōŵ",
|
||||
"selectedFilterCount_one": "{{label}}: {{count}} śēĺēćţēď",
|
||||
"selectedFilterCount_other": "{{label}}: {{count}} śēĺēćţēď",
|
||||
"sentryIssue": "Śēńţŕŷ Ĩśśũē",
|
||||
|
||||
@@ -1631,7 +1631,6 @@
|
||||
"sendComment": "Śēńď ćōḿḿēńţ",
|
||||
"sendToAgent": "Śēńď ţō àĝēńţ",
|
||||
"sendToAgentHelp": "Ĥĩţ <1>Śēńď ţō àĝēńţ</1> ĩńśĩďē ţĥē ƥàńēĺ ţō ďēĺĩvēŕ ţĥēḿ ţō ţĥē ĆĹĨ àĝēńţ ŕĩĝĥţ àŵàŷ, ōŕ ĵũśţ ōƥēń ţĥē ćĥàţ ƀōx àńď ţŷƥē à ƒōĺĺōŵ-ũƥ; ţĥē ćōḿḿēńţś ŵĩĺĺ ƀē ƥŕēƥēńďēď.",
|
||||
"sendToWorkflow": "Śēńď ţō ŵōŕķƒĺōŵ",
|
||||
"senderWithSession": "{{base}} · {{sessionName}}",
|
||||
"sentAsNewMessage": "· śēńţ àś ńēŵ ḿēśśàĝē",
|
||||
"sentryInstance": "Śēńţŕŷ ĩńśţàńćē",
|
||||
@@ -2380,5 +2379,51 @@
|
||||
"bulkColorAutomaticHint": "Àũţōḿàţĩć ŕũĺēś ḿàŷ ōvēŕŕĩďē ţĥē vĩśĩƀĺē ćōĺōŕ.",
|
||||
"bulkColorTitle_one": "Ćōĺōŕ ƒōŕ {{count}} ţàśķ",
|
||||
"bulkColorTitle_other": "Ćōĺōŕ ƒōŕ {{count}} ţàśķś",
|
||||
"editorActions": "Ēďĩţōŕ àńď ƒōĺďēŕ àćţĩōńś"
|
||||
"editorActions": "Ēďĩţōŕ àńď ƒōĺďēŕ àćţĩōńś",
|
||||
"changeWorkflowTitle": "Ćĥàńĝē ŵōŕķƒĺōŵ...",
|
||||
"changeWorkflowDescription": "Ćĥōōśē à ďēśţĩńàţĩōń ŵōŕķƒĺōŵ, śţēƥ, àńď ţàśķ-śƥēćĩƒĩć àĝēńţ ƥŕōƒĩĺēś.",
|
||||
"changeWorkflowCurrentLabel": "Ćũŕŕēńţ",
|
||||
"changeWorkflowCurrentAssignment": "{{workflow}} · {{step}}",
|
||||
"changeWorkflowLoadingTask": "Ĺōàďĩńĝ ţàśķ...",
|
||||
"changeWorkflowLoadTaskFailed": "Ćōũĺď ńōţ ĺōàď ţĥĩś ţàśķ.",
|
||||
"changeWorkflowRetry": "Ŕēţŕŷ",
|
||||
"changeWorkflowLoadingWorkflows": "Ĺōàďĩńĝ ŵōŕķƒĺōŵś...",
|
||||
"changeWorkflowLoadWorkflowsFailed": "Ćōũĺď ńōţ ĺōàď ŵōŕķƒĺōŵś.",
|
||||
"changeWorkflowNoDestinations": "Ńō ōţĥēŕ vĩśĩƀĺē ŵōŕķƒĺōŵś àŕē àvàĩĺàƀĺē.",
|
||||
"changeWorkflowDestinationLabel": "Ďēśţĩńàţĩōń ŵōŕķƒĺōŵ",
|
||||
"changeWorkflowDestinationPlaceholder": "Śēĺēćţ à ŵōŕķƒĺōŵ",
|
||||
"changeWorkflowSearchWorkflows": "Śēàŕćĥ ŵōŕķƒĺōŵś...",
|
||||
"changeWorkflowNoDestinationFound": "Ńō ŵōŕķƒĺōŵ ƒōũńď.",
|
||||
"changeWorkflowLoadingSteps": "Ĺōàďĩńĝ ŵōŕķƒĺōŵ śţēƥś...",
|
||||
"changeWorkflowLoadStepsFailed": "Ćōũĺď ńōţ ĺōàď ŵōŕķƒĺōŵ śţēƥś.",
|
||||
"changeWorkflowNoSteps": "Ţĥĩś ŵōŕķƒĺōŵ ĥàś ńō śţēƥś.",
|
||||
"changeWorkflowStepLabel": "Ďēśţĩńàţĩōń śţēƥ",
|
||||
"changeWorkflowStepPlaceholder": "Śēĺēćţ à śţēƥ",
|
||||
"changeWorkflowSearchSteps": "Śēàŕćĥ śţēƥś...",
|
||||
"changeWorkflowNoStepFound": "Ńō śţēƥ ƒōũńď.",
|
||||
"changeWorkflowAgentSection": "Ŵōŕķƒĺōŵ àĝēńţś",
|
||||
"changeWorkflowAgentScope": "Ćĥōōśē ŕēƥĺàćēḿēńţś ƒōŕ ţĥĩś ţàśķ ōńĺŷ.",
|
||||
"changeWorkflowLoadingProfiles": "Ĺōàďĩńĝ àĝēńţ ƥŕōƒĩĺēś...",
|
||||
"changeWorkflowLoadProfilesFailed": "Ćōũĺď ńōţ ĺōàď àĝēńţ ƥŕōƒĩĺēś.",
|
||||
"changeWorkflowNoFixedProfiles": "Ţĥĩś ŵōŕķƒĺōŵ ĥàś ńō ƒĩxēď àĝēńţ ƥŕōƒĩĺēś ţō ŕēƥĺàćē.",
|
||||
"changeWorkflowProfileSteps": "Śţēƥś: {{steps}}",
|
||||
"changeWorkflowProfilePlaceholder": "Ũśē ŵōŕķƒĺōŵ ƥŕōƒĩĺē",
|
||||
"changeWorkflowReplacementLabel": "Ŕēƥĺàćēḿēńţ ƒōŕ {{profile}}",
|
||||
"changeWorkflowResetProfile": "Ŕēśēţ",
|
||||
"changeWorkflowProfileUnavailable": "Ţĥĩś ƥŕōƒĩĺē ĩś ũńàvàĩĺàƀĺē ƒōŕ ţĥē ţàśķ'ś ēxēćũţōŕ. Ćĥōōśē àń ēĺĩĝĩƀĺē ƥŕōƒĩĺē ōŕ ŕēśēţ.",
|
||||
"changeWorkflowConversationSection": "Ćōńvēŕśàţĩōń ŕēĺàţĩōńśĥĩƥś",
|
||||
"changeWorkflowInitialConversation": "{{step}} ũśēś ţĥē ĩńĩţĩàĺ ćōńvēŕśàţĩōń.",
|
||||
"changeWorkflowStepConversation": "{{step}} ũśēś ţĥē ćōńvēŕśàţĩōń ƒŕōḿ {{target}}.",
|
||||
"changeWorkflowReplacePreviousOverrides": "Ćĥàńĝĩńĝ ŵōŕķƒĺōŵś ŕēƥĺàćēś ţĥĩś ţàśķ'ś ēxĩśţĩńĝ ŵōŕķƒĺōŵ àĝēńţ ōvēŕŕĩďēś.",
|
||||
"changeWorkflowEntryPreview": "Ōń ēńţŕŷ",
|
||||
"changeWorkflowSourceChanged": "Ţĥē ţàśķ ćĥàńĝēď ŵĥĩĺē ţĥĩś ƒōŕḿ ŵàś ōƥēń. Ŕēvĩēŵ ţĥē ćũŕŕēńţ àśśĩĝńḿēńţ, ţĥēń śũƀḿĩţ àĝàĩń.",
|
||||
"changeWorkflowUncertain": "Ţĥē ŕēśũĺţ ĩś ńōţ ćōńƒĩŕḿēď. Ţĥē ţàśķ ŵàś ŕēƒŕēśĥēď. Ŕēvĩēŵ ţĥē ćũŕŕēńţ àśśĩĝńḿēńţ ƀēƒōŕē ţŕŷĩńĝ àĝàĩń.",
|
||||
"changeWorkflowFailed": "Ţĥē ŵōŕķƒĺōŵ ćōũĺď ńōţ ƀē ćĥàńĝēď. Ŕēvĩēŵ ţĥē ćĥōĩćēś àńď ţŕŷ àĝàĩń.",
|
||||
"changeWorkflowSubmit": "Ćĥàńĝē ŵōŕķƒĺōŵ",
|
||||
"changeWorkflowCancel": "Ćàńćēĺ",
|
||||
"changeWorkflowSuccess": "Ŵōŕķƒĺōŵ ćĥàńĝēď.",
|
||||
"changeWorkflowObservedSuccess": "Ţĥē ŵōŕķƒĺōŵ ćĥàńĝē ŵàś ćōńƒĩŕḿēď àƒţēŕ ŕēƒŕēśĥĩńĝ ţĥē ţàśķ.",
|
||||
"changeWorkflow": "Ćĥàńĝē ŵōŕķƒĺōŵ...",
|
||||
"changeWorkflowForSelectedTasks": "Ćĥàńĝē ŵōŕķƒĺōŵ ƒōŕ śēĺēćţēď ţàśķś",
|
||||
"changeWorkflowRefreshTask": "Ŕēƒŕēśĥ ţàśķ"
|
||||
}
|
||||
|
||||
@@ -89,7 +89,6 @@
|
||||
"selectRepository": "Selecionar repositório",
|
||||
"selectTask": "Selecionar a tarefa {{title}}",
|
||||
"selectWorkflow": "Selecionar fluxo de trabalho",
|
||||
"sendToWorkflow": "Enviar para o fluxo de trabalho",
|
||||
"selectedFilterCount_one": "{{label}}: {{count}} selecionado",
|
||||
"selectedFilterCount_other": "{{label}}: {{count}} selecionados",
|
||||
"sentryIssue": "Issue do Sentry",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user