feat: add task-specific workflow changes (#3895)

This commit is contained in:
Carlos Florêncio
2026-09-25 10:30:33 +01:00
committed by GitHub
parent e58807d5a9
commit 04b84aa9ef
124 changed files with 7213 additions and 717 deletions
+1 -1
View File
@@ -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 = &copy
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);
+95 -140
View File
@@ -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} />
))}
</>
);
}
+15 -3
View File
@@ -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();
});
/**
+14 -10
View File
@@ -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 };
}
+10 -1
View File
@@ -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({
+14 -1
View File
@@ -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");
});
});
+10 -1
View File
@@ -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} />;
}
+14
View File
@@ -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}"]`);
}
}
+44 -10
View File
@@ -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++) {
+7 -3
View File
@@ -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) => {
+23 -16
View File
@@ -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,
});
+7 -1
View File
@@ -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,
+8
View File
@@ -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);
}
});
+4 -2
View File
@@ -30,7 +30,8 @@
},
"workflows": {
"profileSessionLifecycleIntro": "這些設定適用於工作流程更改代理程式設定檔的情況。",
"profileSessionStartReuseDescription": "繼續使用此代理程式設定檔最近可用的工作階段。如果沒有可用工作階段,則開始新工作階段。"
"profileSessionStartReuseDescription": "繼續使用此代理程式設定檔最近可用的工作階段。如果沒有可用工作階段,則開始新工作階段。",
"openAgentSettings": "開啟代理程式設定"
},
"system": {
"diskRefreshHelp": "遍歷 Kandev 資料目錄內的所有子目錄並累計檔案大小。結果會快取幾小時;點選可立即重新計算。大型工作區可能需要幾秒。"
@@ -63,7 +64,8 @@
},
"workflows": {
"profileSessionLifecycleIntro": "這些設定適用於工作流程更改代理程式設定檔的情況。",
"profileSessionStartReuseDescription": "繼續使用此代理程式設定檔最近可用的工作階段。如果沒有可用工作階段,則開始新工作階段。"
"profileSessionStartReuseDescription": "繼續使用此代理程式設定檔最近可用的工作階段。如果沒有可用工作階段,則開始新工作階段。",
"openAgentSettings": "開啟代理程式設定"
},
"system": {
"diskRefreshHelp": "遍歷 Kandev 數據目錄內的所有子目錄並累計檔案大小。結果會快取幾小時;點擊可立即重新計算。大型工作區可能需要幾秒。"
-1
View File
@@ -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",
+47 -2
View File
@@ -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"
}
-1
View File
@@ -94,7 +94,6 @@
"selectRepository": "リポジトリを選択",
"selectTask": "タスク {{title}} を選択",
"selectWorkflow": "ワークフローを選択",
"sendToWorkflow": "ワークフローに送信",
"selectedFilterCount_one": "{{label}}: {{count}}件選択",
"selectedFilterCount_other": "{{label}}: {{count}}件選択",
"sentryIssue": "Sentryの問題",
+47 -2
View File
@@ -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": "タスクを再読み込み"
}
-1
View File
@@ -94,7 +94,6 @@
"selectRepository": "Śēĺēćţ ŕēƥōśĩţōŕŷ",
"selectTask": "Śēĺēćţ ţàśķ {{title}}",
"selectWorkflow": "Śēĺēćţ ŵōŕķƒĺōŵ",
"sendToWorkflow": "Śēńď ţō ŵōŕķƒĺōŵ",
"selectedFilterCount_one": "{{label}}: {{count}} śēĺēćţēď",
"selectedFilterCount_other": "{{label}}: {{count}} śēĺēćţēď",
"sentryIssue": "Śēńţŕŷ Ĩśśũē",
+47 -2
View File
@@ -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": "Ŕēƒŕēśĥ ţàśķ"
}
-1
View File
@@ -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