@@ -0,0 +1,12 @@
|
||||
package taskdependencies
|
||||
|
||||
import "sync"
|
||||
|
||||
var edgeMutationMu sync.Mutex
|
||||
|
||||
// AcquireMutationLock serializes dependency graph validation and mutation
|
||||
// across task and Office services in this backend process.
|
||||
func AcquireMutationLock() func() {
|
||||
edgeMutationMu.Lock()
|
||||
return edgeMutationMu.Unlock
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package taskdependencies
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAcquireMutationLockSerializesCallers(t *testing.T) {
|
||||
unlock := AcquireMutationLock()
|
||||
acquired := make(chan struct{})
|
||||
go func() {
|
||||
secondUnlock := AcquireMutationLock()
|
||||
close(acquired)
|
||||
secondUnlock()
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-acquired:
|
||||
t.Fatal("second caller acquired the mutation lock before the first released it")
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
|
||||
unlock()
|
||||
select {
|
||||
case <-acquired:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("second caller did not acquire the mutation lock after release")
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/taskdependencies"
|
||||
"github.com/kandev/kandev/internal/events"
|
||||
"github.com/kandev/kandev/internal/events/bus"
|
||||
"github.com/kandev/kandev/internal/office/models"
|
||||
@@ -177,11 +178,17 @@ func joinPath(path []string) string {
|
||||
// of any length. On cycle detection returns a *BlockerCycleError whose
|
||||
// Path lists the cycle for the caller to surface.
|
||||
func (s *DashboardService) AddTaskBlocker(ctx context.Context, taskID, blockerTaskID string) error {
|
||||
if err := s.validateBlockerPair(ctx, taskID, blockerTaskID); err != nil {
|
||||
return err
|
||||
}
|
||||
blocker := &models.TaskBlocker{TaskID: taskID, BlockerTaskID: blockerTaskID}
|
||||
if err := s.repo.CreateTaskBlocker(ctx, blocker); err != nil {
|
||||
var err error
|
||||
func() {
|
||||
unlock := taskdependencies.AcquireMutationLock()
|
||||
defer unlock()
|
||||
if err = s.validateBlockerPair(ctx, taskID, blockerTaskID); err != nil {
|
||||
return
|
||||
}
|
||||
blocker := &models.TaskBlocker{TaskID: taskID, BlockerTaskID: blockerTaskID}
|
||||
err = s.repo.CreateTaskBlocker(ctx, blocker)
|
||||
}()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.publishTaskUpdated(ctx, taskID, []string{fieldBlockers})
|
||||
@@ -193,7 +200,13 @@ func (s *DashboardService) AddTaskBlocker(ctx context.Context, taskID, blockerTa
|
||||
// row is a no-op at the DB level; the event/activity entry are still
|
||||
// emitted so the UI re-fetches.
|
||||
func (s *DashboardService) RemoveTaskBlocker(ctx context.Context, taskID, blockerTaskID string) error {
|
||||
if err := s.repo.DeleteTaskBlocker(ctx, taskID, blockerTaskID); err != nil {
|
||||
var err error
|
||||
func() {
|
||||
unlock := taskdependencies.AcquireMutationLock()
|
||||
defer unlock()
|
||||
err = s.repo.DeleteTaskBlocker(ctx, taskID, blockerTaskID)
|
||||
}()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.publishTaskUpdated(ctx, taskID, []string{fieldBlockers})
|
||||
|
||||
@@ -39,6 +39,64 @@ func (r *Repository) DeleteTaskBlocker(ctx context.Context, taskID, blockerTaskI
|
||||
return err
|
||||
}
|
||||
|
||||
// ReplaceTaskBlockers atomically replaces the direct blockers for taskID.
|
||||
// Existing edges stay in place so their creation timestamps remain stable;
|
||||
// only omitted edges are deleted and new edges are inserted.
|
||||
func (r *Repository) ReplaceTaskBlockers(ctx context.Context, taskID string, blockerTaskIDs []string) error {
|
||||
tx, err := r.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
var existing []string
|
||||
if err := tx.SelectContext(ctx, &existing, tx.Rebind(
|
||||
`SELECT blocker_task_id FROM task_blockers WHERE task_id = ?`), taskID); err != nil {
|
||||
return err
|
||||
}
|
||||
existingSet := make(map[string]struct{}, len(existing))
|
||||
for _, blockerTaskID := range existing {
|
||||
existingSet[blockerTaskID] = struct{}{}
|
||||
}
|
||||
desiredSet := make(map[string]struct{}, len(blockerTaskIDs))
|
||||
for _, blockerTaskID := range blockerTaskIDs {
|
||||
desiredSet[blockerTaskID] = struct{}{}
|
||||
}
|
||||
|
||||
for blockerTaskID := range existingSet {
|
||||
if _, keep := desiredSet[blockerTaskID]; keep {
|
||||
continue
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, tx.Rebind(
|
||||
`DELETE FROM task_blockers WHERE task_id = ? AND blocker_task_id = ?`),
|
||||
taskID, blockerTaskID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, blockerTaskID := range blockerTaskIDs {
|
||||
if _, alreadyExists := existingSet[blockerTaskID]; alreadyExists {
|
||||
continue
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, tx.Rebind(`
|
||||
INSERT INTO task_blockers (task_id, blocker_task_id, created_at)
|
||||
VALUES (?, ?, ?)
|
||||
`), taskID, blockerTaskID, time.Now().UTC()); err != nil {
|
||||
return err
|
||||
}
|
||||
existingSet[blockerTaskID] = struct{}{}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
committed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListTasksBlockedBy returns task IDs that are blocked by the given task.
|
||||
// This is the reverse direction of ListTaskBlockers; results are ordered by
|
||||
// insertion time so callers see a stable list.
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/office/models"
|
||||
)
|
||||
@@ -327,3 +328,88 @@ func TestTaskBlocker_SelfReferenceBlocked(t *testing.T) {
|
||||
t.Fatal("expected CHECK constraint error for self-reference")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceTaskBlockers_ReplacesSetAndPreservesExistingEdges(t *testing.T) {
|
||||
repo := newTestRepo(t)
|
||||
ctx := context.Background()
|
||||
|
||||
for _, blocker := range []*models.TaskBlocker{
|
||||
{TaskID: "task-1", BlockerTaskID: "task-2"},
|
||||
{TaskID: "task-1", BlockerTaskID: "task-4"},
|
||||
} {
|
||||
if err := repo.CreateTaskBlocker(ctx, blocker); err != nil {
|
||||
t.Fatalf("create blocker: %v", err)
|
||||
}
|
||||
}
|
||||
before, err := repo.ListTaskBlockers(ctx, "task-1")
|
||||
if err != nil {
|
||||
t.Fatalf("list before replacement: %v", err)
|
||||
}
|
||||
if len(before) != 2 {
|
||||
t.Fatalf("before replacement count = %d, want 2", len(before))
|
||||
}
|
||||
var preservedCreatedAt = before[0].CreatedAt
|
||||
for _, blocker := range before {
|
||||
if blocker.BlockerTaskID == "task-2" {
|
||||
preservedCreatedAt = blocker.CreatedAt
|
||||
}
|
||||
}
|
||||
|
||||
if err := repo.ReplaceTaskBlockers(ctx, "task-1", []string{"task-2", "task-3"}); err != nil {
|
||||
t.Fatalf("replace blockers: %v", err)
|
||||
}
|
||||
|
||||
after, err := repo.ListTaskBlockers(ctx, "task-1")
|
||||
if err != nil {
|
||||
t.Fatalf("list after replacement: %v", err)
|
||||
}
|
||||
if len(after) != 2 {
|
||||
t.Fatalf("after replacement count = %d, want 2", len(after))
|
||||
}
|
||||
got := make(map[string]time.Time, len(after))
|
||||
for _, blocker := range after {
|
||||
got[blocker.BlockerTaskID] = blocker.CreatedAt
|
||||
}
|
||||
if _, ok := got["task-4"]; ok {
|
||||
t.Error("omitted blocker task-4 remains after replacement")
|
||||
}
|
||||
if got["task-2"] != preservedCreatedAt {
|
||||
t.Errorf("preserved task-2 timestamp changed from %v to %v", preservedCreatedAt, got["task-2"])
|
||||
}
|
||||
if got["task-3"].IsZero() {
|
||||
t.Error("new task-3 blocker has no created timestamp")
|
||||
}
|
||||
|
||||
if err := repo.ReplaceTaskBlockers(ctx, "task-1", nil); err != nil {
|
||||
t.Fatalf("clear blockers: %v", err)
|
||||
}
|
||||
cleared, err := repo.ListTaskBlockers(ctx, "task-1")
|
||||
if err != nil {
|
||||
t.Fatalf("list after clear: %v", err)
|
||||
}
|
||||
if len(cleared) != 0 {
|
||||
t.Fatalf("after clear count = %d, want 0", len(cleared))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceTaskBlockers_RollsBackPartialDiffOnWriteError(t *testing.T) {
|
||||
repo := newTestRepo(t)
|
||||
ctx := context.Background()
|
||||
if err := repo.CreateTaskBlocker(ctx, &models.TaskBlocker{
|
||||
TaskID: "task-1", BlockerTaskID: "task-2",
|
||||
}); err != nil {
|
||||
t.Fatalf("create blocker: %v", err)
|
||||
}
|
||||
|
||||
if err := repo.ReplaceTaskBlockers(ctx, "task-1", []string{"task-3", "task-1"}); err == nil {
|
||||
t.Fatal("expected self-reference write to fail")
|
||||
}
|
||||
|
||||
blockers, err := repo.ListTaskBlockers(ctx, "task-1")
|
||||
if err != nil {
|
||||
t.Fatalf("list after failed replacement: %v", err)
|
||||
}
|
||||
if len(blockers) != 1 || blockers[0].BlockerTaskID != "task-2" {
|
||||
t.Fatalf("failed replacement changed the set: %+v", blockers)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,8 +37,9 @@ func dependencyRefs(refs []service.DependencyRef) []dto.TaskDependencyRefDTO {
|
||||
|
||||
// Response keys repeated across the dependency handlers.
|
||||
const (
|
||||
dependencyKeyError = "error"
|
||||
dependencyKeyTaskID = "task_id"
|
||||
dependencyKeyError = "error"
|
||||
dependencyKeyTaskID = "task_id"
|
||||
maxDependencyRequestBytes = 128 << 10
|
||||
)
|
||||
|
||||
// addTaskDependencyBody is the POST /tasks/:id/dependencies payload.
|
||||
@@ -46,6 +47,13 @@ type addTaskDependencyBody struct {
|
||||
DependsOnTaskID string `json:"depends_on_task_id" binding:"required"`
|
||||
}
|
||||
|
||||
// replaceTaskDependenciesBody is the PUT /tasks/:id/dependencies payload.
|
||||
// A pointer distinguishes an explicit empty list, which clears all edges,
|
||||
// from a request that omitted the complete-set field.
|
||||
type replaceTaskDependenciesBody struct {
|
||||
DependsOnTaskIDs *[]string `json:"depends_on_task_ids"`
|
||||
}
|
||||
|
||||
// httpAddTaskDependency handles POST /api/v1/tasks/:id/dependencies —
|
||||
// "this task is blocked by depends_on_task_id".
|
||||
//
|
||||
@@ -53,6 +61,7 @@ type addTaskDependencyBody struct {
|
||||
// "A → B → C → A"; that body shape is what BlockersPicker already parses.
|
||||
func (h *TaskHandlers) httpAddTaskDependency(c *gin.Context) {
|
||||
taskID := c.Param("id")
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxDependencyRequestBytes)
|
||||
var body addTaskDependencyBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{dependencyKeyError: "depends_on_task_id is required"})
|
||||
@@ -75,6 +84,37 @@ func (h *TaskHandlers) httpAddTaskDependency(c *gin.Context) {
|
||||
handleNotFound(c, h.logger, err, "task not found")
|
||||
}
|
||||
|
||||
// httpReplaceTaskDependencies handles PUT /api/v1/tasks/:id/dependencies.
|
||||
// The request is a complete predecessor set, including an empty list to clear
|
||||
// all direct dependencies.
|
||||
func (h *TaskHandlers) httpReplaceTaskDependencies(c *gin.Context) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxDependencyRequestBytes)
|
||||
var body replaceTaskDependenciesBody
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.DependsOnTaskIDs == nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{dependencyKeyError: "depends_on_task_ids is required"})
|
||||
return
|
||||
}
|
||||
err := h.service.ReplaceDependencies(c.Request.Context(), c.Param("id"), *body.DependsOnTaskIDs)
|
||||
if err == nil {
|
||||
h.respondWithDependencies(c, c.Param("id"))
|
||||
return
|
||||
}
|
||||
var cycle *service.CycleError
|
||||
if errors.As(err, &cycle) {
|
||||
c.JSON(http.StatusConflict, gin.H{dependencyKeyError: cycle.Error(), "cycle": cycle.Path})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrInvalidDependencySet) {
|
||||
c.JSON(http.StatusBadRequest, taskErrorBody(err))
|
||||
return
|
||||
}
|
||||
if errors.Is(err, service.ErrDependencyRepositoryUnavailable) {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{dependencyKeyError: err.Error()})
|
||||
return
|
||||
}
|
||||
handleNotFound(c, h.logger, err, "task not found")
|
||||
}
|
||||
|
||||
// httpRemoveTaskDependency handles DELETE /api/v1/tasks/:id/dependencies/:depId.
|
||||
// Removing an absent edge is a success no-op.
|
||||
func (h *TaskHandlers) httpRemoveTaskDependency(c *gin.Context) {
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
orchmodels "github.com/kandev/kandev/internal/office/models"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
"github.com/kandev/kandev/internal/task/service"
|
||||
v1 "github.com/kandev/kandev/pkg/api/v1"
|
||||
)
|
||||
|
||||
type dependencyHandlerRepo struct {
|
||||
mockRepository
|
||||
tasks map[string]*models.Task
|
||||
blockers []*orchmodels.TaskBlocker
|
||||
}
|
||||
|
||||
func (r *dependencyHandlerRepo) GetTask(_ context.Context, id string) (*models.Task, error) {
|
||||
return r.tasks[id], nil
|
||||
}
|
||||
|
||||
func (r *dependencyHandlerRepo) GetTasksByIDs(_ context.Context, ids []string) ([]*models.Task, error) {
|
||||
result := make([]*models.Task, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if task := r.tasks[id]; task != nil {
|
||||
result = append(result, task)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *dependencyHandlerRepo) CreateTaskBlocker(_ context.Context, blocker *orchmodels.TaskBlocker) error {
|
||||
r.blockers = append(r.blockers, blocker)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *dependencyHandlerRepo) ListTaskBlockers(_ context.Context, taskID string) ([]*orchmodels.TaskBlocker, error) {
|
||||
result := make([]*orchmodels.TaskBlocker, 0)
|
||||
for _, blocker := range r.blockers {
|
||||
if blocker.TaskID == taskID {
|
||||
result = append(result, blocker)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *dependencyHandlerRepo) DeleteTaskBlocker(_ context.Context, taskID, blockerTaskID string) error {
|
||||
for index, blocker := range r.blockers {
|
||||
if blocker.TaskID == taskID && blocker.BlockerTaskID == blockerTaskID {
|
||||
r.blockers = append(r.blockers[:index], r.blockers[index+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *dependencyHandlerRepo) ListTasksBlockedBy(_ context.Context, blockerTaskID string) ([]string, error) {
|
||||
ids := make([]string, 0)
|
||||
for _, blocker := range r.blockers {
|
||||
if blocker.BlockerTaskID == blockerTaskID {
|
||||
ids = append(ids, blocker.TaskID)
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (r *dependencyHandlerRepo) ListBlockersForTasks(_ context.Context, taskIDs []string) (map[string][]string, error) {
|
||||
wanted := make(map[string]struct{}, len(taskIDs))
|
||||
for _, id := range taskIDs {
|
||||
wanted[id] = struct{}{}
|
||||
}
|
||||
result := make(map[string][]string)
|
||||
for _, blocker := range r.blockers {
|
||||
if _, ok := wanted[blocker.TaskID]; ok {
|
||||
result[blocker.TaskID] = append(result[blocker.TaskID], blocker.BlockerTaskID)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *dependencyHandlerRepo) ListDependentsForTasks(_ context.Context, taskIDs []string) (map[string][]string, error) {
|
||||
wanted := make(map[string]struct{}, len(taskIDs))
|
||||
for _, id := range taskIDs {
|
||||
wanted[id] = struct{}{}
|
||||
}
|
||||
result := make(map[string][]string)
|
||||
for _, blocker := range r.blockers {
|
||||
if _, ok := wanted[blocker.BlockerTaskID]; ok {
|
||||
result[blocker.BlockerTaskID] = append(result[blocker.BlockerTaskID], blocker.TaskID)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *dependencyHandlerRepo) ReplaceTaskBlockers(_ context.Context, taskID string, blockerTaskIDs []string) error {
|
||||
next := make([]*orchmodels.TaskBlocker, 0, len(r.blockers)+len(blockerTaskIDs))
|
||||
for _, blocker := range r.blockers {
|
||||
if blocker.TaskID != taskID {
|
||||
next = append(next, blocker)
|
||||
}
|
||||
}
|
||||
for _, blockerTaskID := range blockerTaskIDs {
|
||||
next = append(next, &orchmodels.TaskBlocker{TaskID: taskID, BlockerTaskID: blockerTaskID})
|
||||
}
|
||||
r.blockers = next
|
||||
return nil
|
||||
}
|
||||
|
||||
func newDependencyHandlers(t *testing.T, repo *dependencyHandlerRepo) *TaskHandlers {
|
||||
t.Helper()
|
||||
log := newTestLogger(t)
|
||||
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{})
|
||||
svc.SetBlockerRepository(repo)
|
||||
return &TaskHandlers{service: svc, logger: log}
|
||||
}
|
||||
|
||||
func dependencyRequest(t *testing.T, h *TaskHandlers, taskID, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
recorder := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(recorder)
|
||||
context.Request = httptest.NewRequest(
|
||||
http.MethodPut,
|
||||
"/api/v1/tasks/"+taskID+"/dependencies",
|
||||
strings.NewReader(body),
|
||||
)
|
||||
context.Params = gin.Params{{Key: "id", Value: taskID}}
|
||||
h.httpReplaceTaskDependencies(context)
|
||||
return recorder
|
||||
}
|
||||
|
||||
func dependencyTask(id, title string) *models.Task {
|
||||
return &models.Task{
|
||||
ID: id, WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-1",
|
||||
Title: title, State: v1.TaskStateTODO,
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPReplaceTaskDependenciesReturnsProjection(t *testing.T) {
|
||||
repo := &dependencyHandlerRepo{tasks: map[string]*models.Task{
|
||||
"task-1": dependencyTask("task-1", "Dependent"),
|
||||
"task-2": dependencyTask("task-2", "Predecessor"),
|
||||
}}
|
||||
h := newDependencyHandlers(t, repo)
|
||||
|
||||
recorder := dependencyRequest(t, h, "task-1", `{"depends_on_task_ids":["task-2"]}`)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
var response map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response))
|
||||
require.Equal(t, "task-1", response["task_id"])
|
||||
require.Len(t, response["depends_on"], 1)
|
||||
require.Len(t, repo.blockers, 1)
|
||||
require.Equal(t, "task-2", repo.blockers[0].BlockerTaskID)
|
||||
}
|
||||
|
||||
func TestHTTPReplaceTaskDependenciesRejectsCycleAndPreservesSet(t *testing.T) {
|
||||
repo := &dependencyHandlerRepo{tasks: map[string]*models.Task{
|
||||
"task-a": dependencyTask("task-a", "A"),
|
||||
"task-b": dependencyTask("task-b", "B"),
|
||||
"task-c": dependencyTask("task-c", "C"),
|
||||
}, blockers: []*orchmodels.TaskBlocker{
|
||||
{TaskID: "task-a", BlockerTaskID: "task-b"},
|
||||
{TaskID: "task-b", BlockerTaskID: "task-c"},
|
||||
}}
|
||||
h := newDependencyHandlers(t, repo)
|
||||
|
||||
recorder := dependencyRequest(t, h, "task-c", `{"depends_on_task_ids":["task-a"]}`)
|
||||
|
||||
require.Equal(t, http.StatusConflict, recorder.Code)
|
||||
var response struct {
|
||||
Cycle []string `json:"cycle"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response))
|
||||
require.Equal(t, []string{"task-c", "task-a", "task-b", "task-c"}, response.Cycle)
|
||||
require.Len(t, repo.blockers, 2)
|
||||
}
|
||||
|
||||
func TestHTTPReplaceTaskDependenciesRequiresCompleteList(t *testing.T) {
|
||||
repo := &dependencyHandlerRepo{tasks: map[string]*models.Task{
|
||||
"task-1": dependencyTask("task-1", "Dependent"),
|
||||
}}
|
||||
h := newDependencyHandlers(t, repo)
|
||||
|
||||
recorder := dependencyRequest(t, h, "task-1", `{}`)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
require.Contains(t, recorder.Body.String(), "depends_on_task_ids")
|
||||
}
|
||||
|
||||
func TestHTTPReplaceTaskDependenciesRejectsTooManyIDs(t *testing.T) {
|
||||
repo := &dependencyHandlerRepo{tasks: map[string]*models.Task{
|
||||
"task-1": dependencyTask("task-1", "Dependent"),
|
||||
}}
|
||||
h := newDependencyHandlers(t, repo)
|
||||
ids := make([]string, 513)
|
||||
for index := range ids {
|
||||
ids[index] = "task-2"
|
||||
}
|
||||
body, err := json.Marshal(map[string][]string{"depends_on_task_ids": ids})
|
||||
require.NoError(t, err)
|
||||
|
||||
recorder := dependencyRequest(t, h, "task-1", string(body))
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
require.Empty(t, repo.blockers)
|
||||
}
|
||||
|
||||
func TestHTTPReplaceTaskDependenciesRejectsOversizedBody(t *testing.T) {
|
||||
repo := &dependencyHandlerRepo{tasks: map[string]*models.Task{
|
||||
"task-1": dependencyTask("task-1", "Dependent"),
|
||||
}}
|
||||
h := newDependencyHandlers(t, repo)
|
||||
body := `{"depends_on_task_ids":["` + strings.Repeat("x", maxDependencyRequestBytes) + `"]}`
|
||||
|
||||
recorder := dependencyRequest(t, h, "task-1", body)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
require.Empty(t, repo.blockers)
|
||||
}
|
||||
@@ -204,6 +204,7 @@ func (h *TaskHandlers) registerHTTP(router *gin.Engine) {
|
||||
// equivalents of the Office-only blocker routes; both go through the single
|
||||
// validator in the task service.
|
||||
api.POST("/tasks/:id/dependencies", h.httpAddTaskDependency)
|
||||
api.PUT("/tasks/:id/dependencies", h.httpReplaceTaskDependencies)
|
||||
api.DELETE("/tasks/:id/dependencies/:depId", h.httpRemoveTaskDependency)
|
||||
|
||||
api.POST("/tasks/bulk-move", h.httpBulkMoveTasks)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/common/taskdependencies"
|
||||
orchmodels "github.com/kandev/kandev/internal/office/models"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
"github.com/kandev/kandev/internal/task/repository"
|
||||
@@ -487,6 +488,8 @@ func (s *HandoffService) attachSequentialBlocker(ctx context.Context, taskID, pa
|
||||
mu := s.parentLock.lockFor(parentID)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
edgesUnlock := taskdependencies.AcquireMutationLock()
|
||||
defer edgesUnlock()
|
||||
|
||||
siblings, err := s.tasks.ListChildren(ctx, parentID)
|
||||
if err != nil {
|
||||
|
||||
@@ -376,17 +376,13 @@ type Service struct {
|
||||
repositorySelectionResolver RepositorySelectionResolver
|
||||
repoCloneLocation RepoCloneLocation
|
||||
blockers BlockerRepository
|
||||
// dependencyEdgeMu serializes validate-then-insert for dependency edges so
|
||||
// two concurrent adds cannot each pass a cycle walk that predates the
|
||||
// other's insert and commit a cycle between them.
|
||||
dependencyEdgeMu sync.Mutex
|
||||
comments CommentRepository
|
||||
taskStateActivity TaskStateActivityLogger
|
||||
secretStore secrets.SecretStore
|
||||
workspaceSecretDeleter WorkspaceSecretDeleter
|
||||
baseBranchPusher AgentBaseBranchPusher
|
||||
comparisonTargetPusher AgentComparisonTargetPusher
|
||||
runtimeOverridesMu sync.Mutex
|
||||
comments CommentRepository
|
||||
taskStateActivity TaskStateActivityLogger
|
||||
secretStore secrets.SecretStore
|
||||
workspaceSecretDeleter WorkspaceSecretDeleter
|
||||
baseBranchPusher AgentBaseBranchPusher
|
||||
comparisonTargetPusher AgentComparisonTargetPusher
|
||||
runtimeOverridesMu sync.Mutex
|
||||
|
||||
workspaceSourceProviderRefresher WorkspaceSourceProviderRefresher
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/taskdependencies"
|
||||
"github.com/kandev/kandev/internal/events"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
taskrepo "github.com/kandev/kandev/internal/task/repository"
|
||||
@@ -55,6 +56,11 @@ const (
|
||||
// exceeds it rejects the edge rather than accepting an unverified one.
|
||||
const dependencyCycleWalkLimit = 1000
|
||||
|
||||
const (
|
||||
maxTaskDependencyCount = 512
|
||||
maxTaskDependencyIDLength = 128
|
||||
)
|
||||
|
||||
// DependencyRef is one end of a dependency edge, carrying enough detail for the
|
||||
// dependency chip to render without fetching each related task.
|
||||
type DependencyRef struct {
|
||||
@@ -91,6 +97,13 @@ func (e *CycleError) Error() string {
|
||||
// not wired. Callers must treat it as "cannot determine", not "not blocked".
|
||||
var ErrDependencyRepositoryUnavailable = fmt.Errorf("dependency repository not configured")
|
||||
|
||||
// ErrInvalidDependencySet identifies malformed full-set replacement input.
|
||||
// Authorization errors intentionally do not use this sentinel, so foreign
|
||||
// task IDs remain indistinguishable from missing IDs.
|
||||
var ErrInvalidDependencySet = errors.New("invalid dependency set")
|
||||
|
||||
var errDependencyCrossWorkspace = errors.New("dependency tasks must share a workspace")
|
||||
|
||||
// ResolveStartWhenUnblocked decides whether a create request's agent start
|
||||
// should become a start-when-unblocked intent instead of an immediate launch.
|
||||
//
|
||||
@@ -129,7 +142,7 @@ func (s *Service) validateDependencyPair(ctx context.Context, taskID, dependsOnT
|
||||
return fmt.Errorf("%w: %s", taskrepo.ErrTaskNotFound, dependsOnTaskID)
|
||||
}
|
||||
if task.WorkspaceID != "" && dep.WorkspaceID != "" && task.WorkspaceID != dep.WorkspaceID {
|
||||
return fmt.Errorf("task %s belongs to a different workspace", dependsOnTaskID)
|
||||
return fmt.Errorf("%w: task %s belongs to a different workspace", errDependencyCrossWorkspace, dependsOnTaskID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -149,10 +162,10 @@ func (s *Service) authorizeDependencyPair(ctx context.Context, taskID, dependsOn
|
||||
|
||||
// AddDependency records "taskID depends on dependsOnTaskID".
|
||||
//
|
||||
// This is the single validator for dependency edges. Self-edges,
|
||||
// cross-workspace edges, and cycles of any length are rejected here, and both
|
||||
// the task-scoped routes and the Office blocker routes go through it — a second
|
||||
// validator would let a cycle in through whichever path was weakest.
|
||||
// This is the task-service validator for dependency edges. Self-edges,
|
||||
// cross-workspace edges, and cycles of any length are rejected here. The task
|
||||
// and Office mutation surfaces share the same process-wide mutation lock while
|
||||
// they validate and write their respective repository adapters.
|
||||
func (s *Service) AddDependency(ctx context.Context, taskID, dependsOnTaskID string) error {
|
||||
if s.blockers == nil {
|
||||
return ErrDependencyRepositoryUnavailable
|
||||
@@ -163,6 +176,9 @@ func (s *Service) AddDependency(ctx context.Context, taskID, dependsOnTaskID str
|
||||
if taskID == dependsOnTaskID {
|
||||
return fmt.Errorf("a task cannot depend on itself")
|
||||
}
|
||||
if len(dependsOnTaskID) > maxTaskDependencyIDLength {
|
||||
return fmt.Errorf("dependency task IDs cannot exceed %d characters", maxTaskDependencyIDLength)
|
||||
}
|
||||
if err := s.authorizeDependencyPair(ctx, taskID, dependsOnTaskID); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -184,11 +200,116 @@ func (s *Service) AddDependency(ctx context.Context, taskID, dependsOnTaskID str
|
||||
return nil
|
||||
}
|
||||
|
||||
// insertDependencyEdge holds dependencyEdgeMu across exactly the validate,
|
||||
// cycle-walk and insert, and nothing else.
|
||||
// ReplaceDependencies replaces every direct predecessor of taskID in one
|
||||
// validated operation. The complete desired set is checked before storage is
|
||||
// changed, and the repository applies its edge diff in one transaction.
|
||||
func (s *Service) ReplaceDependencies(ctx context.Context, taskID string, dependsOnTaskIDs []string) error {
|
||||
if s.blockers == nil {
|
||||
return ErrDependencyRepositoryUnavailable
|
||||
}
|
||||
if taskID == "" {
|
||||
return fmt.Errorf("%w: task_id is required", ErrInvalidDependencySet)
|
||||
}
|
||||
if err := s.authorizeTaskID(ctx, taskID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateDependencyIDs(taskID, dependsOnTaskIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, dependsOnTaskID := range dependsOnTaskIDs {
|
||||
if err := s.authorizeTaskID(ctx, dependsOnTaskID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
replacer, ok := s.blockers.(taskDependencyReplacer)
|
||||
if !ok {
|
||||
return ErrDependencyRepositoryUnavailable
|
||||
}
|
||||
changed, err := s.replaceDependencyEdges(ctx, taskID, dependsOnTaskIDs, replacer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(changed) == 0 {
|
||||
return nil
|
||||
}
|
||||
s.publishDependencyChange(ctx, append([]string{taskID}, changed...)...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDependencyIDs(taskID string, dependsOnTaskIDs []string) error {
|
||||
if len(dependsOnTaskIDs) > maxTaskDependencyCount {
|
||||
return fmt.Errorf("%w: at most %d dependency task IDs are allowed", ErrInvalidDependencySet, maxTaskDependencyCount)
|
||||
}
|
||||
seen := make(map[string]struct{}, len(dependsOnTaskIDs))
|
||||
for _, dependsOnTaskID := range dependsOnTaskIDs {
|
||||
switch dependsOnTaskID {
|
||||
case "":
|
||||
return fmt.Errorf("%w: dependency task IDs cannot be empty", ErrInvalidDependencySet)
|
||||
case taskID:
|
||||
return fmt.Errorf("%w: a task cannot depend on itself", ErrInvalidDependencySet)
|
||||
}
|
||||
if len(dependsOnTaskID) > maxTaskDependencyIDLength {
|
||||
return fmt.Errorf("%w: dependency task IDs cannot exceed %d characters", ErrInvalidDependencySet, maxTaskDependencyIDLength)
|
||||
}
|
||||
if _, duplicate := seen[dependsOnTaskID]; duplicate {
|
||||
return fmt.Errorf("%w: dependency %s is listed more than once", ErrInvalidDependencySet, dependsOnTaskID)
|
||||
}
|
||||
seen[dependsOnTaskID] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// replaceDependencyEdges holds the dependency lock across all reads that
|
||||
// validate the graph and the repository's atomic replacement.
|
||||
func (s *Service) replaceDependencyEdges(
|
||||
ctx context.Context,
|
||||
taskID string,
|
||||
dependsOnTaskIDs []string,
|
||||
replacer taskDependencyReplacer,
|
||||
) ([]string, error) {
|
||||
unlock := taskdependencies.AcquireMutationLock()
|
||||
defer unlock()
|
||||
|
||||
if err := s.validateReplacementSet(ctx, taskID, dependsOnTaskIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
existing, err := s.blockers.ListTaskBlockers(ctx, taskID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list task dependencies: %w", err)
|
||||
}
|
||||
for _, dependsOnTaskID := range dependsOnTaskIDs {
|
||||
cycle, err := s.checkDependencyCycle(ctx, taskID, dependsOnTaskID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("check dependency cycle: %w", err)
|
||||
}
|
||||
if cycle != nil {
|
||||
return nil, cycle
|
||||
}
|
||||
}
|
||||
changed := changedDependencyIDs(existing, dependsOnTaskIDs)
|
||||
if err := replacer.ReplaceTaskBlockers(ctx, taskID, dependsOnTaskIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
func (s *Service) validateReplacementSet(ctx context.Context, taskID string, dependsOnTaskIDs []string) error {
|
||||
for _, dependsOnTaskID := range dependsOnTaskIDs {
|
||||
if err := s.validateDependencyPair(ctx, taskID, dependsOnTaskID); err != nil {
|
||||
if errors.Is(err, errDependencyCrossWorkspace) {
|
||||
return fmt.Errorf("%w: %w", ErrInvalidDependencySet, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// insertDependencyEdge holds the shared mutation lock across exactly the
|
||||
// validate, cycle-walk and insert, and nothing else.
|
||||
func (s *Service) insertDependencyEdge(ctx context.Context, taskID, dependsOnTaskID string) error {
|
||||
s.dependencyEdgeMu.Lock()
|
||||
defer s.dependencyEdgeMu.Unlock()
|
||||
unlock := taskdependencies.AcquireMutationLock()
|
||||
defer unlock()
|
||||
if err := s.validateDependencyPair(ctx, taskID, dependsOnTaskID); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -222,10 +343,10 @@ func (s *Service) RemoveDependency(ctx context.Context, taskID, dependsOnTaskID
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteDependencyEdge holds the edge lock across the delete only.
|
||||
// deleteDependencyEdge holds the shared mutation lock across the delete only.
|
||||
func (s *Service) deleteDependencyEdge(ctx context.Context, taskID, dependsOnTaskID string) error {
|
||||
s.dependencyEdgeMu.Lock()
|
||||
defer s.dependencyEdgeMu.Unlock()
|
||||
unlock := taskdependencies.AcquireMutationLock()
|
||||
defer unlock()
|
||||
return s.blockers.DeleteTaskBlocker(ctx, taskID, dependsOnTaskID)
|
||||
}
|
||||
|
||||
@@ -600,6 +721,8 @@ func (s *Service) pruneDanglingEdge(ctx context.Context, taskID, missingTaskID s
|
||||
if s.blockers == nil {
|
||||
return
|
||||
}
|
||||
unlock := taskdependencies.AcquireMutationLock()
|
||||
defer unlock()
|
||||
if err := s.blockers.DeleteTaskBlocker(ctx, taskID, missingTaskID); err != nil {
|
||||
s.logger.Warn("failed to prune dangling dependency edge",
|
||||
zap.String("task_id", taskID),
|
||||
@@ -626,14 +749,22 @@ func (s *Service) deleteDependencyEdgesForTask(ctx context.Context, taskID strin
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
dependents, err := s.blockers.ListTasksBlockedBy(ctx, taskID)
|
||||
if err != nil {
|
||||
s.logger.Warn("failed to list dependents before edge cleanup",
|
||||
zap.String("task_id", taskID), zap.Error(err))
|
||||
}
|
||||
if err := cleaner.DeleteTaskBlockersForTask(ctx, taskID); err != nil {
|
||||
var dependents []string
|
||||
var cleanupErr error
|
||||
func() {
|
||||
unlock := taskdependencies.AcquireMutationLock()
|
||||
defer unlock()
|
||||
var err error
|
||||
dependents, err = s.blockers.ListTasksBlockedBy(ctx, taskID)
|
||||
if err != nil {
|
||||
s.logger.Warn("failed to list dependents before edge cleanup",
|
||||
zap.String("task_id", taskID), zap.Error(err))
|
||||
}
|
||||
cleanupErr = cleaner.DeleteTaskBlockersForTask(ctx, taskID)
|
||||
}()
|
||||
if cleanupErr != nil {
|
||||
s.logger.Warn("failed to clean up dependency edges for deleted task",
|
||||
zap.String("task_id", taskID), zap.Error(err))
|
||||
zap.String("task_id", taskID), zap.Error(cleanupErr))
|
||||
return
|
||||
}
|
||||
// Dependents may now be unblocked, so refresh them. Deliberately no
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -297,35 +298,11 @@ func TestDependencyMutationsAreScopedToTheCaller(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// gateBlockerRepo forces the interleaving the cycle race needs: each cycle walk
|
||||
// announces itself and then waits for the other walk to start. Two bare
|
||||
// goroutines practically never land inside the validate-then-insert window, so
|
||||
// a test without this barrier passes even with the serialization removed and
|
||||
// proves nothing.
|
||||
type gateBlockerRepo struct {
|
||||
*mockBlockerRepo
|
||||
arrived chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (g *gateBlockerRepo) ListTaskBlockers(ctx context.Context, taskID string) ([]*orchmodels.TaskBlocker, error) {
|
||||
g.once.Do(func() { close(g.arrived) })
|
||||
select {
|
||||
case <-g.arrived:
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
// Give the other goroutine room to finish its own walk before this one
|
||||
// inserts. Serialization must make that impossible; without it both walks
|
||||
// complete against a pre-insert view.
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
return g.mockBlockerRepo.ListTaskBlockers(ctx, taskID)
|
||||
}
|
||||
|
||||
// Two concurrent adds must not each pass a cycle walk that predates the other's
|
||||
// insert and commit a cycle between them.
|
||||
func TestAddDependencyConcurrentInsertsCannotCreateACycle(t *testing.T) {
|
||||
svc, _ := setupOfficeTest(t)
|
||||
repo := &gateBlockerRepo{mockBlockerRepo: &mockBlockerRepo{}, arrived: make(chan struct{})}
|
||||
repo := &mockBlockerRepo{}
|
||||
svc.SetBlockerRepository(repo)
|
||||
ctx := context.Background()
|
||||
a := mustSeedTask(t, svc, "A").ID
|
||||
@@ -440,3 +417,22 @@ func TestDependencyEventFieldsCarryTheProjection(t *testing.T) {
|
||||
t.Errorf("depends_on = %+v; want one entry for %s", deps, predecessor.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDependencyIDsRejectsOversizedSets(t *testing.T) {
|
||||
ids := make([]string, maxTaskDependencyCount+1)
|
||||
for index := range ids {
|
||||
ids[index] = "dependency"
|
||||
}
|
||||
|
||||
err := validateDependencyIDs("task", ids)
|
||||
if !errors.Is(err, ErrInvalidDependencySet) {
|
||||
t.Fatalf("error = %v, want ErrInvalidDependencySet", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDependencyIDsRejectsOversizedID(t *testing.T) {
|
||||
err := validateDependencyIDs("task", []string{strings.Repeat("x", maxTaskDependencyIDLength+1)})
|
||||
if !errors.Is(err, ErrInvalidDependencySet) {
|
||||
t.Fatalf("error = %v, want ErrInvalidDependencySet", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/internal/events"
|
||||
orchmodels "github.com/kandev/kandev/internal/office/models"
|
||||
taskrepo "github.com/kandev/kandev/internal/task/repository"
|
||||
)
|
||||
|
||||
type replacementBlockerRepo struct {
|
||||
*mockBlockerRepo
|
||||
replaceErr error
|
||||
}
|
||||
|
||||
func (r *replacementBlockerRepo) ReplaceTaskBlockers(
|
||||
_ context.Context,
|
||||
taskID string,
|
||||
blockerTaskIDs []string,
|
||||
) error {
|
||||
if r.replaceErr != nil {
|
||||
return r.replaceErr
|
||||
}
|
||||
next := make([]*orchmodels.TaskBlocker, 0, len(r.blockers)+len(blockerTaskIDs))
|
||||
for _, blocker := range r.blockers {
|
||||
if blocker.TaskID != taskID {
|
||||
next = append(next, blocker)
|
||||
}
|
||||
}
|
||||
for _, blockerTaskID := range blockerTaskIDs {
|
||||
next = append(next, &orchmodels.TaskBlocker{
|
||||
TaskID: taskID,
|
||||
BlockerTaskID: blockerTaskID,
|
||||
})
|
||||
}
|
||||
r.blockers = next
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestReplaceDependencies_ReplacesSetAndPublishesChangedTasks(t *testing.T) {
|
||||
svc, repo := setupOfficeTest(t)
|
||||
blockers := &replacementBlockerRepo{mockBlockerRepo: &mockBlockerRepo{}}
|
||||
svc.SetBlockerRepository(blockers)
|
||||
ctx := context.Background()
|
||||
oldPredecessor := mustSeedTask(t, svc, "Old predecessor")
|
||||
newPredecessor := mustSeedTask(t, svc, "New predecessor")
|
||||
dependent := mustSeedTask(t, svc, "Dependent")
|
||||
if err := blockers.CreateTaskBlocker(ctx, &orchmodels.TaskBlocker{
|
||||
TaskID: dependent.ID, BlockerTaskID: oldPredecessor.ID,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed dependency: %v", err)
|
||||
}
|
||||
eventBus, ok := svc.eventBus.(*MockEventBus)
|
||||
if !ok {
|
||||
t.Fatal("test service does not use MockEventBus")
|
||||
}
|
||||
eventBus.ClearEvents()
|
||||
|
||||
if err := svc.ReplaceDependencies(ctx, dependent.ID, []string{newPredecessor.ID}); err != nil {
|
||||
t.Fatalf("ReplaceDependencies: %v", err)
|
||||
}
|
||||
|
||||
got, err := svc.GetBlockers(ctx, dependent.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetBlockers: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0] != newPredecessor.ID {
|
||||
t.Fatalf("blockers = %v, want [%s]", got, newPredecessor.ID)
|
||||
}
|
||||
|
||||
updatedIDs := map[string]bool{}
|
||||
for _, event := range eventBus.GetPublishedEvents() {
|
||||
if event.Type != events.TaskUpdated {
|
||||
continue
|
||||
}
|
||||
data, ok := event.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("task.updated data type = %T", event.Data)
|
||||
}
|
||||
if id, ok := data["task_id"].(string); ok {
|
||||
updatedIDs[id] = true
|
||||
}
|
||||
}
|
||||
if len(updatedIDs) != 3 {
|
||||
t.Fatalf("updated task IDs = %v, want dependent and both changed peers", updatedIDs)
|
||||
}
|
||||
for _, id := range []string{dependent.ID, oldPredecessor.ID, newPredecessor.ID} {
|
||||
if !updatedIDs[id] {
|
||||
t.Errorf("task.updated missing for %s", id)
|
||||
}
|
||||
}
|
||||
_ = repo
|
||||
}
|
||||
|
||||
func TestReplaceDependencies_RejectsDuplicateWithoutChangingSet(t *testing.T) {
|
||||
svc, _ := setupOfficeTest(t)
|
||||
blockers := &replacementBlockerRepo{mockBlockerRepo: &mockBlockerRepo{}}
|
||||
svc.SetBlockerRepository(blockers)
|
||||
ctx := context.Background()
|
||||
predecessor := mustSeedTask(t, svc, "Predecessor")
|
||||
dependent := mustSeedTask(t, svc, "Dependent")
|
||||
if err := svc.AddDependency(ctx, dependent.ID, predecessor.ID); err != nil {
|
||||
t.Fatalf("AddDependency: %v", err)
|
||||
}
|
||||
|
||||
err := svc.ReplaceDependencies(ctx, dependent.ID, []string{predecessor.ID, predecessor.ID})
|
||||
if !errors.Is(err, ErrInvalidDependencySet) {
|
||||
t.Fatalf("error = %v, want ErrInvalidDependencySet", err)
|
||||
}
|
||||
got, err := svc.GetBlockers(ctx, dependent.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetBlockers: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0] != predecessor.ID {
|
||||
t.Fatalf("blockers after rejected replacement = %v, want [%s]", got, predecessor.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceDependencies_RejectsCycleWithoutChangingSet(t *testing.T) {
|
||||
svc, _ := setupOfficeTest(t)
|
||||
blockers := &replacementBlockerRepo{mockBlockerRepo: &mockBlockerRepo{}}
|
||||
svc.SetBlockerRepository(blockers)
|
||||
ctx := context.Background()
|
||||
a := mustSeedTask(t, svc, "A")
|
||||
b := mustSeedTask(t, svc, "B")
|
||||
c := mustSeedTask(t, svc, "C")
|
||||
if err := svc.AddDependency(ctx, a.ID, b.ID); err != nil {
|
||||
t.Fatalf("AddDependency(a,b): %v", err)
|
||||
}
|
||||
if err := svc.AddDependency(ctx, b.ID, c.ID); err != nil {
|
||||
t.Fatalf("AddDependency(b,c): %v", err)
|
||||
}
|
||||
|
||||
err := svc.ReplaceDependencies(ctx, c.ID, []string{a.ID})
|
||||
var cycle *CycleError
|
||||
if !errors.As(err, &cycle) {
|
||||
t.Fatalf("error = %v, want CycleError", err)
|
||||
}
|
||||
got, err := svc.GetBlockers(ctx, c.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetBlockers: %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("cycle rejection changed blockers = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceDependencies_ReturnsUnavailableWithoutReplacementCapability(t *testing.T) {
|
||||
svc, _ := setupOfficeTest(t)
|
||||
svc.SetBlockerRepository(&mockBlockerRepo{})
|
||||
dependent := mustSeedTask(t, svc, "Dependent")
|
||||
predecessor := mustSeedTask(t, svc, "Predecessor")
|
||||
|
||||
err := svc.ReplaceDependencies(context.Background(), dependent.ID, []string{predecessor.ID})
|
||||
if !errors.Is(err, ErrDependencyRepositoryUnavailable) {
|
||||
t.Fatalf("error = %v, want ErrDependencyRepositoryUnavailable", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplaceDependencies_PreservesNotFoundAuthorizationContract(t *testing.T) {
|
||||
svc, _, repo := createTestService(t)
|
||||
seedForeignPair(t, repo)
|
||||
svc.SetBlockerRepository(&replacementBlockerRepo{mockBlockerRepo: &mockBlockerRepo{}})
|
||||
|
||||
err := svc.ReplaceDependencies(ctxAs("user-a"), "task-b", []string{"task-b2"})
|
||||
if !errors.Is(err, taskrepo.ErrTaskNotFound) {
|
||||
t.Fatalf("foreign replacement error = %v, want ErrTaskNotFound", err)
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,40 @@ type taskDependencyCleaner interface {
|
||||
DeleteTaskBlockersForTask(ctx context.Context, taskID string) error
|
||||
}
|
||||
|
||||
// taskDependencyReplacer is optional so existing repository test doubles and
|
||||
// integrations that only need one-edge operations keep compiling.
|
||||
type taskDependencyReplacer interface {
|
||||
ReplaceTaskBlockers(ctx context.Context, taskID string, blockerTaskIDs []string) error
|
||||
}
|
||||
|
||||
func changedDependencyIDs(existing []*orchmodels.TaskBlocker, desired []string) []string {
|
||||
existingSet := make(map[string]struct{}, len(existing))
|
||||
for _, blocker := range existing {
|
||||
if blocker != nil {
|
||||
existingSet[blocker.BlockerTaskID] = struct{}{}
|
||||
}
|
||||
}
|
||||
desiredSet := make(map[string]struct{}, len(desired))
|
||||
for _, blockerTaskID := range desired {
|
||||
desiredSet[blockerTaskID] = struct{}{}
|
||||
}
|
||||
changed := make([]string, 0)
|
||||
for _, blocker := range existing {
|
||||
if blocker == nil {
|
||||
continue
|
||||
}
|
||||
if _, keep := desiredSet[blocker.BlockerTaskID]; !keep {
|
||||
changed = append(changed, blocker.BlockerTaskID)
|
||||
}
|
||||
}
|
||||
for _, blockerTaskID := range desired {
|
||||
if _, alreadyExists := existingSet[blockerTaskID]; !alreadyExists {
|
||||
changed = append(changed, blockerTaskID)
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// CommentRepository provides access to task comment persistence.
|
||||
type CommentRepository interface {
|
||||
CreateTaskComment(ctx context.Context, comment *orchmodels.TaskComment) error
|
||||
|
||||
@@ -234,6 +234,34 @@ describe("TaskCreateDependencies", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("selected dependency preservation", () => {
|
||||
it("keeps an omitted selected predecessor titled and individually removable", () => {
|
||||
const selectedID = "task-archived";
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<TaskCreateDependencies
|
||||
value={[selectedID]}
|
||||
onChange={onChange}
|
||||
candidates={[{ id: "task-other", title: "Other task" }]}
|
||||
selectedTitles={{ [selectedID]: "Archived predecessor" }}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId(TRIGGER_TEST_ID));
|
||||
|
||||
const selectedOption = within(
|
||||
screen.getByTestId("task-create-dependencies-popover"),
|
||||
).getByTestId(`task-create-dependency-option-${selectedID}`);
|
||||
expect(selectedOption).toBeTruthy();
|
||||
expect(selectedOption.textContent).toContain("Archived predecessor");
|
||||
|
||||
fireEvent.click(selectedOption);
|
||||
expect(onChange).toHaveBeenCalledWith([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskCreateDependencies change-request search", () => {
|
||||
it("narrows to the PR-linked task when searching by PR number and shows its badge", () => {
|
||||
const prTask = task(ALPHA_ID, ALPHA_TITLE, {
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useAppStore } from "@/components/state-provider";
|
||||
import { useWorkspacePRs } from "@/hooks/domains/github/use-task-pr";
|
||||
import { useWorkspaceMRs } from "@/hooks/domains/gitlab/use-task-mr";
|
||||
import { useTaskCreateDialogPopoverContainer } from "@/hooks/use-task-create-dialog-popover-container";
|
||||
import type { TaskDependencyCandidate } from "@/lib/types/task-dependencies";
|
||||
import type { KanbanState } from "@/lib/state/slices/kanban/types";
|
||||
import type { TaskMR } from "@/lib/types/gitlab";
|
||||
import type { TaskPR } from "@/lib/types/github";
|
||||
@@ -49,6 +50,17 @@ export type TaskCreateDependenciesProps = {
|
||||
value: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
disabled?: boolean;
|
||||
/** Optional server-backed candidates for the edit dialog. */
|
||||
candidates?: TaskDependencyCandidate[];
|
||||
/** Titles for selected tasks that are outside the current search page. */
|
||||
selectedTitles?: Record<string, string>;
|
||||
/** True while the server-backed candidate search is running. */
|
||||
candidatesLoading?: boolean;
|
||||
/** Controlled search value for the server-backed candidate search. */
|
||||
searchValue?: string;
|
||||
onSearchValueChange?: (value: string) => void;
|
||||
/** The edited task is never a valid predecessor of itself. */
|
||||
excludeTaskId?: string;
|
||||
};
|
||||
|
||||
function useBoardTasks(): KanbanState["tasks"] {
|
||||
@@ -114,7 +126,7 @@ function DependencyOption({
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
task: KanbanState["tasks"][number];
|
||||
task: TaskDependencyCandidate;
|
||||
numbers: number[];
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
@@ -152,25 +164,33 @@ function DependencyOption({
|
||||
}
|
||||
|
||||
type DependencyPickerContentProps = {
|
||||
candidates: KanbanState["tasks"];
|
||||
mrsByTaskId: Record<string, TaskMR[]>;
|
||||
prsByTaskId: Record<string, TaskPR[]>;
|
||||
candidates: TaskDependencyCandidate[];
|
||||
numbersByTaskId: ReadonlyMap<string, number[]>;
|
||||
value: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
setOpen: (open: boolean) => void;
|
||||
portalContainer: HTMLElement | null;
|
||||
candidatesLoading: boolean;
|
||||
searchValue?: string;
|
||||
onSearchValueChange?: (value: string) => void;
|
||||
};
|
||||
|
||||
function DependencyPickerContent({
|
||||
candidates,
|
||||
mrsByTaskId,
|
||||
prsByTaskId,
|
||||
numbersByTaskId,
|
||||
value,
|
||||
onChange,
|
||||
setOpen,
|
||||
portalContainer,
|
||||
candidatesLoading,
|
||||
searchValue,
|
||||
onSearchValueChange,
|
||||
}: DependencyPickerContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const commandInputProps =
|
||||
searchValue === undefined
|
||||
? {}
|
||||
: { value: searchValue, onValueChange: onSearchValueChange ?? (() => undefined) };
|
||||
return (
|
||||
<PopoverContent
|
||||
align="start"
|
||||
@@ -182,7 +202,12 @@ function DependencyPickerContent({
|
||||
<Command>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CommandInput placeholder={t("task:searchTasksOrChangeRequest")} />
|
||||
<CommandInput
|
||||
{...commandInputProps}
|
||||
placeholder={t(
|
||||
searchValue === undefined ? "task:searchTasksOrChangeRequest" : "task:searchTasks",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -203,7 +228,9 @@ function DependencyPickerContent({
|
||||
</Tooltip>
|
||||
</div>
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("task:noTasksFound")}</CommandEmpty>
|
||||
<CommandEmpty>
|
||||
{candidatesLoading ? t("tasks:loadingTasks") : t("task:noTasksFound")}
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="__no_dependency__"
|
||||
@@ -227,11 +254,7 @@ function DependencyPickerContent({
|
||||
<DependencyOption
|
||||
key={task.id}
|
||||
task={task}
|
||||
numbers={changeRequestNumbers(
|
||||
task,
|
||||
mrsByTaskId[task.id] ?? NO_MRS,
|
||||
prsByTaskId[task.id] ?? NO_PRS,
|
||||
)}
|
||||
numbers={numbersByTaskId.get(task.id) ?? []}
|
||||
selected={value.includes(task.id)}
|
||||
onSelect={() => {
|
||||
onChange(
|
||||
@@ -249,7 +272,108 @@ function DependencyPickerContent({
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskCreateDependencies({ value, onChange, disabled }: TaskCreateDependenciesProps) {
|
||||
type DependencyPickerDataArgs = {
|
||||
boardTasks: KanbanState["tasks"];
|
||||
providedCandidates?: TaskDependencyCandidate[];
|
||||
selectedTitles?: Record<string, string>;
|
||||
value: string[];
|
||||
excludeTaskId?: string;
|
||||
mrsByTaskId: Record<string, TaskMR[]>;
|
||||
prsByTaskId: Record<string, TaskPR[]>;
|
||||
};
|
||||
|
||||
type DependencyPickerData = {
|
||||
candidates: TaskDependencyCandidate[];
|
||||
numbersByTaskId: ReadonlyMap<string, number[]>;
|
||||
selectedTitle?: string;
|
||||
selectedNumber?: number;
|
||||
};
|
||||
|
||||
function useDependencyPickerData({
|
||||
boardTasks,
|
||||
providedCandidates,
|
||||
selectedTitles,
|
||||
value,
|
||||
excludeTaskId,
|
||||
mrsByTaskId,
|
||||
prsByTaskId,
|
||||
}: DependencyPickerDataArgs): DependencyPickerData {
|
||||
const tasks = useMemo(() => {
|
||||
const byID = new Map<string, TaskDependencyCandidate>();
|
||||
for (const task of providedCandidates ?? boardTasks) byID.set(task.id, task);
|
||||
for (const id of value) {
|
||||
const title = selectedTitles?.[id];
|
||||
if (title !== undefined && !byID.has(id)) {
|
||||
byID.set(id, { id, title, isArchived: true });
|
||||
}
|
||||
}
|
||||
return [...byID.values()];
|
||||
}, [boardTasks, providedCandidates, selectedTitles, value]);
|
||||
|
||||
const boardTasksById = useMemo(
|
||||
() => new Map(boardTasks.map((task) => [task.id, task] as const)),
|
||||
[boardTasks],
|
||||
);
|
||||
const numbersByTaskId = useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
tasks.map((task) => {
|
||||
const boardTask = boardTasksById.get(task.id);
|
||||
return [
|
||||
task.id,
|
||||
boardTask
|
||||
? changeRequestNumbers(
|
||||
boardTask,
|
||||
mrsByTaskId[task.id] ?? NO_MRS,
|
||||
prsByTaskId[task.id] ?? NO_PRS,
|
||||
)
|
||||
: [],
|
||||
] as const;
|
||||
}),
|
||||
),
|
||||
[boardTasksById, mrsByTaskId, prsByTaskId, tasks],
|
||||
);
|
||||
|
||||
const selectedTask = useMemo(() => {
|
||||
if (value.length !== 1) return undefined;
|
||||
return tasks.find((task) => task.id === value[0]);
|
||||
}, [tasks, value]);
|
||||
const selectedTitle = useMemo(() => {
|
||||
if (!selectedTask) return undefined;
|
||||
return selectedTitles?.[selectedTask.id] ?? selectedTask.title;
|
||||
}, [selectedTask, selectedTitles]);
|
||||
const selectedNumber = useMemo(
|
||||
() => (selectedTask ? numbersByTaskId.get(selectedTask.id)?.[0] : undefined),
|
||||
[numbersByTaskId, selectedTask],
|
||||
);
|
||||
const candidates = useMemo(
|
||||
() =>
|
||||
tasks
|
||||
.filter(
|
||||
(task) => task.id !== excludeTaskId && (!task.isArchived || value.includes(task.id)),
|
||||
)
|
||||
.sort((left, right) => {
|
||||
const leftTask = boardTasksById.get(left.id) ?? left;
|
||||
const rightTask = boardTasksById.get(right.id) ?? right;
|
||||
return compareDependencyCandidates(leftTask, rightTask);
|
||||
}),
|
||||
[boardTasksById, excludeTaskId, tasks, value],
|
||||
);
|
||||
|
||||
return { candidates, numbersByTaskId, selectedTitle, selectedNumber };
|
||||
}
|
||||
|
||||
export function TaskCreateDependencies({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
candidates: providedCandidates,
|
||||
selectedTitles,
|
||||
candidatesLoading = false,
|
||||
searchValue,
|
||||
onSearchValueChange,
|
||||
excludeTaskId,
|
||||
}: TaskCreateDependenciesProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const portalContainer = useTaskCreateDialogPopoverContainer();
|
||||
@@ -258,30 +382,22 @@ export function TaskCreateDependencies({ value, onChange, disabled }: TaskCreate
|
||||
// routes, which normally hydrate these association stores.
|
||||
useWorkspacePRs(activeWorkspaceId);
|
||||
useWorkspaceMRs(activeWorkspaceId);
|
||||
const tasks = useBoardTasks();
|
||||
const boardTasks = useBoardTasks();
|
||||
const mrsByTaskId = useMRsByTaskId();
|
||||
const prsByTaskId = usePRsByTaskId();
|
||||
|
||||
const selectedTask = useMemo(() => {
|
||||
if (value.length !== 1) return undefined;
|
||||
return tasks.find((task) => task.id === value[0]);
|
||||
}, [tasks, value]);
|
||||
const selectedNumber = useMemo(() => {
|
||||
if (!selectedTask) return undefined;
|
||||
return changeRequestNumbers(
|
||||
selectedTask,
|
||||
mrsByTaskId[selectedTask.id] ?? NO_MRS,
|
||||
prsByTaskId[selectedTask.id] ?? NO_PRS,
|
||||
)[0];
|
||||
}, [selectedTask, mrsByTaskId, prsByTaskId]);
|
||||
const candidates = useMemo(
|
||||
() => tasks.filter((task) => !task.isArchived).sort(compareDependencyCandidates),
|
||||
[tasks],
|
||||
);
|
||||
const { candidates, numbersByTaskId, selectedTitle, selectedNumber } = useDependencyPickerData({
|
||||
boardTasks,
|
||||
providedCandidates,
|
||||
selectedTitles,
|
||||
value,
|
||||
excludeTaskId,
|
||||
mrsByTaskId,
|
||||
prsByTaskId,
|
||||
});
|
||||
const triggerLabel = (
|
||||
<DependencyTriggerLabel
|
||||
value={value}
|
||||
selectedTitle={selectedTask?.title}
|
||||
selectedTitle={selectedTitle}
|
||||
selectedNumber={selectedNumber}
|
||||
t={t}
|
||||
/>
|
||||
@@ -320,12 +436,14 @@ export function TaskCreateDependencies({ value, onChange, disabled }: TaskCreate
|
||||
</PopoverTrigger>
|
||||
<DependencyPickerContent
|
||||
candidates={candidates}
|
||||
mrsByTaskId={mrsByTaskId}
|
||||
prsByTaskId={prsByTaskId}
|
||||
numbersByTaskId={numbersByTaskId}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
setOpen={setOpen}
|
||||
portalContainer={portalContainer}
|
||||
candidatesLoading={candidatesLoading}
|
||||
searchValue={searchValue}
|
||||
onSearchValueChange={onSearchValueChange}
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
@@ -20,24 +20,27 @@ export function changeRequestNumbers(
|
||||
}
|
||||
|
||||
/** cmdk search key: title, id, and both '#N' and bare 'N' for each change-request number. */
|
||||
export function dependencyOptionValue(task: Task, numbers: number[]): string {
|
||||
export function dependencyOptionValue(task: Pick<Task, "id" | "title">, numbers: number[]): string {
|
||||
const parts = [task.title, task.id, ...numbers.map((n) => `#${n} ${n}`)];
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function timestampMs(task: Task): number | undefined {
|
||||
function timestampMs(task: Pick<Task, "updatedAt" | "createdAt">): number | undefined {
|
||||
const updated = task.updatedAt ? Date.parse(task.updatedAt) : NaN;
|
||||
if (Number.isFinite(updated)) return updated;
|
||||
return createdAtMs(task);
|
||||
}
|
||||
|
||||
function createdAtMs(task: Task): number | undefined {
|
||||
function createdAtMs(task: Pick<Task, "createdAt">): number | undefined {
|
||||
const created = task.createdAt ? Date.parse(task.createdAt) : NaN;
|
||||
return Number.isFinite(created) ? created : undefined;
|
||||
}
|
||||
|
||||
/** Most-recently-updated first, using createdAt and then title as tie-breakers. */
|
||||
export function compareDependencyCandidates(a: Task, b: Task): number {
|
||||
export function compareDependencyCandidates(
|
||||
a: Pick<Task, "title" | "updatedAt" | "createdAt">,
|
||||
b: Pick<Task, "title" | "updatedAt" | "createdAt">,
|
||||
): number {
|
||||
const aTime = timestampMs(a);
|
||||
const bTime = timestampMs(b);
|
||||
if (aTime !== undefined && bTime !== undefined && aTime !== bTime) return bTime - aTime;
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
REASON_AGENT,
|
||||
REASON_DESCRIPTION,
|
||||
REASON_PROMPT,
|
||||
REASON_LOADING_DEPENDENCIES,
|
||||
} from "./task-create-dialog-footer";
|
||||
import { t } from "@/lib/i18n";
|
||||
import type { ButtonKind, TaskCreateDialogFooterProps } from "./task-create-dialog-footer";
|
||||
@@ -60,6 +61,14 @@ describe("isNativeSubmitDisabled", () => {
|
||||
it("allows a complete native submission", () => {
|
||||
expect(isNativeSubmitDisabled(makeProps())).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks edit submission until dependencies are ready", () => {
|
||||
expect(
|
||||
isNativeSubmitDisabled(
|
||||
makeProps({ isCreateMode: false, isEditMode: true, editDependenciesReady: false }),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeDisabledReason (start-task)", () => {
|
||||
@@ -183,6 +192,19 @@ describe("computeDisabledReason (update)", () => {
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("explains why edit update waits for dependencies", () => {
|
||||
expect(
|
||||
computeDisabledReason(
|
||||
makeProps({
|
||||
isCreateMode: false,
|
||||
isEditMode: true,
|
||||
editDependenciesReady: false,
|
||||
}),
|
||||
KIND_UPDATE,
|
||||
),
|
||||
).toBe(REASON_LOADING_DEPENDENCIES);
|
||||
});
|
||||
});
|
||||
|
||||
describe("computeDisabledReason (default)", () => {
|
||||
|
||||
@@ -23,17 +23,23 @@ import { useTranslation } from "react-i18next";
|
||||
type UpdateButtonProps = {
|
||||
isCreatingTask: boolean;
|
||||
hasTitle: boolean;
|
||||
editDependenciesReady?: boolean;
|
||||
onUpdate: () => void;
|
||||
};
|
||||
|
||||
function UpdateButton({ isCreatingTask, hasTitle, onUpdate }: UpdateButtonProps) {
|
||||
function UpdateButton({
|
||||
isCreatingTask,
|
||||
hasTitle,
|
||||
editDependenciesReady,
|
||||
onUpdate,
|
||||
}: UpdateButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
className="w-full h-11 cursor-pointer sm:w-auto sm:h-7 gap-1.5"
|
||||
disabled={isCreatingTask || !hasTitle}
|
||||
disabled={isCreatingTask || !hasTitle || editDependenciesReady === false}
|
||||
onClick={onUpdate}
|
||||
>
|
||||
{isCreatingTask ? (
|
||||
@@ -239,6 +245,8 @@ export type TaskCreateDialogFooterProps = {
|
||||
* of the usual missing-field reason.
|
||||
*/
|
||||
submitBlockedReason?: string | null;
|
||||
/** Edit-mode dependency projection and candidate list have loaded. */
|
||||
editDependenciesReady?: boolean;
|
||||
};
|
||||
|
||||
function isMissingWorkflowCtx(
|
||||
@@ -262,7 +270,8 @@ function computeBaseDisabled(props: TaskCreateDialogFooterProps) {
|
||||
!props.hasRepositorySelection ||
|
||||
!props.hasAllBranches ||
|
||||
missingCtx ||
|
||||
props.noCompatibleAgent
|
||||
props.noCompatibleAgent ||
|
||||
(props.isEditMode && props.editDependenciesReady === false)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -281,6 +290,7 @@ export const REASON_WORKFLOW = "task:reasonSelectWorkflow";
|
||||
export const REASON_AGENT = "task:reasonSelectAgent";
|
||||
export const REASON_DESCRIPTION = "task:reasonAddSessionDescription";
|
||||
export const REASON_NO_COMPATIBLE_AGENT = "task:noCompatibleAgentProfileFor";
|
||||
export const REASON_LOADING_DEPENDENCIES = "task:loadingDependencies";
|
||||
|
||||
/**
|
||||
* Resolve what `computeDisabledReason` returned. Reasons this component owns are
|
||||
@@ -307,6 +317,9 @@ function baseReason(props: TaskCreateDialogFooterProps): string | null {
|
||||
if (props.isCreateMode && !props.workspaceId) return REASON_WORKSPACE;
|
||||
if (props.isCreateMode && !props.effectiveWorkflowId) return REASON_WORKFLOW;
|
||||
if (props.noCompatibleAgent) return REASON_NO_COMPATIBLE_AGENT;
|
||||
if (props.isEditMode && props.editDependenciesReady === false) {
|
||||
return REASON_LOADING_DEPENDENCIES;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -323,7 +336,12 @@ export function computeDisabledReason(
|
||||
): string | null {
|
||||
if (props.isCreatingTask) return null;
|
||||
if (props.submitBlockedReason) return props.submitBlockedReason;
|
||||
if (kind === "update") return props.hasTitle ? null : REASON_TITLE;
|
||||
if (kind === "update") {
|
||||
if (props.isEditMode && props.editDependenciesReady === false) {
|
||||
return REASON_LOADING_DEPENDENCIES;
|
||||
}
|
||||
return props.hasTitle ? null : REASON_TITLE;
|
||||
}
|
||||
if (kind === "default" && props.isSessionMode) return sessionDefaultReason(props);
|
||||
const base = baseReason(props);
|
||||
if (base) return base;
|
||||
@@ -359,7 +377,13 @@ function computeFooterState(props: TaskCreateDialogFooterProps) {
|
||||
|
||||
export function isNativeSubmitDisabled(props: TaskCreateDialogFooterProps): boolean {
|
||||
const { showStartTask, splitDisabled, defaultDisabled } = computeFooterState(props);
|
||||
if (props.isTaskStarted) return props.isCreatingTask || !props.hasTitle;
|
||||
if (props.isTaskStarted) {
|
||||
return (
|
||||
props.isCreatingTask ||
|
||||
!props.hasTitle ||
|
||||
(props.isEditMode && props.editDependenciesReady === false)
|
||||
);
|
||||
}
|
||||
return showStartTask ? splitDisabled : defaultDisabled;
|
||||
}
|
||||
|
||||
@@ -414,6 +438,7 @@ export const TaskCreateDialogFooter = memo(function TaskCreateDialogFooter(
|
||||
<UpdateButton
|
||||
isCreatingTask={isCreatingTask}
|
||||
hasTitle={hasTitle}
|
||||
editDependenciesReady={props.editDependenciesReady}
|
||||
onUpdate={onUpdateWithoutAgent}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -51,10 +51,12 @@ export function buildDialogFormBodyProps(
|
||||
agentProfilesLoading: computed.agentProfilesLoading,
|
||||
executorsLoading: computed.executorsLoading,
|
||||
isCreatingSession: fs.isCreatingSession,
|
||||
isCreatingTask: fs.isCreatingTask,
|
||||
workflows: setup.workflows,
|
||||
snapshots: setup.snapshots,
|
||||
effectiveWorkflowId: computed.effectiveWorkflowId ?? null,
|
||||
fs,
|
||||
editDependencies: setup.editDependencies,
|
||||
handleKeyDown: setup.handleKeyDown,
|
||||
onAgentProfileChange: handlers.handleAgentProfileChange,
|
||||
onExecutorProfileChange: handlers.handleExecutorProfileChange,
|
||||
@@ -120,5 +122,6 @@ export function buildDialogFooterProps(
|
||||
onCreateWithoutAgent: submitHandlers.handleCreateWithoutAgent,
|
||||
onCreateWithPlanMode: submitHandlers.handleCreateWithPlanMode,
|
||||
submitBlockedReason: props.submitBlockedReason ?? pendingAttachmentUploadReason,
|
||||
editDependenciesReady: setup.isEditMode ? setup.editDependencies.ready : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import { useResolvedTaskCreateWorkflowContext } from "@/components/task-create-d
|
||||
import { truncateRemoteTaskTitle } from "@/lib/task-title";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { listRepositoryBranchPolicies } from "@/lib/api";
|
||||
import { useTaskEditDialogDependencies } from "@/hooks/domains/task/use-task-edit-dialog-dependencies";
|
||||
|
||||
// Catalog key: module scope, so it is resolved at the call site.
|
||||
const PROMPT_INSERTED_MESSAGE_KEY = "task:enhancedPromptInserted";
|
||||
@@ -112,6 +113,23 @@ function useLinearImportHandler(
|
||||
);
|
||||
}
|
||||
|
||||
function useEditDialogDependencies(
|
||||
open: boolean,
|
||||
isEditMode: boolean,
|
||||
workspaceId: string | null | undefined,
|
||||
taskId: string | null | undefined,
|
||||
) {
|
||||
return useTaskEditDialogDependencies({
|
||||
open: open && isEditMode,
|
||||
workspaceId,
|
||||
taskId,
|
||||
});
|
||||
}
|
||||
|
||||
function isFreshBranchAvailable(fs: DialogFormState, isLocalExecutor: boolean): boolean {
|
||||
return !fs.useRemote && isLocalExecutor && fs.repositories.length === 1;
|
||||
}
|
||||
|
||||
type SubmitWiringArgs = {
|
||||
props: TaskCreateDialogProps;
|
||||
fs: ReturnType<typeof useDialogFormState>;
|
||||
@@ -121,6 +139,7 @@ type SubmitWiringArgs = {
|
||||
isSessionMode: boolean;
|
||||
isEditMode: boolean;
|
||||
autoTitle: boolean;
|
||||
editDependencies: ReturnType<typeof useTaskEditDialogDependencies>;
|
||||
refreshBranchPolicies: () => Promise<void>;
|
||||
preserveQueuedLastUsedOnClose: () => void;
|
||||
};
|
||||
@@ -134,6 +153,7 @@ function useSubmitHandlersWiring({
|
||||
isSessionMode,
|
||||
isEditMode,
|
||||
autoTitle,
|
||||
editDependencies,
|
||||
refreshBranchPolicies,
|
||||
preserveQueuedLastUsedOnClose,
|
||||
}: SubmitWiringArgs) {
|
||||
@@ -196,6 +216,7 @@ function useSubmitHandlersWiring({
|
||||
noRepository: fs.noRepository,
|
||||
workspacePath: fs.workspacePath,
|
||||
blockedBy: fs.blockedBy,
|
||||
editDependencies,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -309,8 +330,14 @@ export function useTaskCreateDialogSetup(
|
||||
options: { preserveQueuedLastUsedOnClose?: () => void } = {},
|
||||
) {
|
||||
const resolvedProps = useResolvedTaskCreateWorkflowContext(props);
|
||||
const { open, mode = "create", workspaceId, workflowId } = resolvedProps;
|
||||
const { editingTask, initialValues } = resolvedProps;
|
||||
const {
|
||||
open,
|
||||
mode = "create",
|
||||
workspaceId,
|
||||
workflowId,
|
||||
editingTask,
|
||||
initialValues,
|
||||
} = resolvedProps;
|
||||
const isSessionMode = mode === "session";
|
||||
const isEditMode = mode === "edit";
|
||||
const isTaskStarted = computeIsTaskStarted(isEditMode, editingTask);
|
||||
@@ -325,31 +352,25 @@ export function useTaskCreateDialogSetup(
|
||||
initialValues,
|
||||
resolvedProps.lockedFields?.workflow === true,
|
||||
);
|
||||
const editDependencies = useEditDialogDependencies(
|
||||
open,
|
||||
isEditMode,
|
||||
workspaceId,
|
||||
editingTask?.id ?? null,
|
||||
);
|
||||
const sessionRepoName = useSessionRepoName(isSessionMode);
|
||||
const data = useDialogSetupData(resolvedProps, fs);
|
||||
const {
|
||||
workflows,
|
||||
agentProfiles,
|
||||
snapshots,
|
||||
repositories,
|
||||
repositoriesLoading,
|
||||
refreshRepositories,
|
||||
taskCreateLastUsed,
|
||||
userSettingsLoaded,
|
||||
computed,
|
||||
handlers,
|
||||
repositoryLocalPath,
|
||||
refreshBranchPolicies,
|
||||
} = data;
|
||||
const { computed, handlers, repositoryLocalPath, refreshBranchPolicies } = data;
|
||||
const submitHandlers = useSubmitHandlersWiring({
|
||||
props: resolvedProps,
|
||||
fs,
|
||||
computed,
|
||||
workspaceRepositories: repositories,
|
||||
workspaceRepositories: data.repositories,
|
||||
repositoryLocalPath,
|
||||
isSessionMode,
|
||||
isEditMode,
|
||||
autoTitle,
|
||||
editDependencies,
|
||||
refreshBranchPolicies,
|
||||
preserveQueuedLastUsedOnClose: options.preserveQueuedLastUsedOnClose ?? (() => undefined),
|
||||
});
|
||||
@@ -363,18 +384,18 @@ export function useTaskCreateDialogSetup(
|
||||
const enhance = useEnhanceForDialog(fs, resolvedProps.taskId, resolvedProps.open);
|
||||
const handleJiraImport = useJiraImportHandler(fs, data.handlers.handleTaskNameChange);
|
||||
const handleLinearImport = useLinearImportHandler(fs, data.handlers.handleTaskNameChange);
|
||||
const freshBranchAvailable =
|
||||
!fs.useRemote && computed.isLocalExecutor && fs.repositories.length === 1;
|
||||
const freshBranchAvailable = isFreshBranchAvailable(fs, computed.isLocalExecutor);
|
||||
const repositorySets = useRepositorySetsForDialog({
|
||||
workspaceId: resolvedProps.workspaceId ?? null,
|
||||
open: resolvedProps.open,
|
||||
rows: fs.repositories,
|
||||
repositories,
|
||||
repositories: data.repositories,
|
||||
setRepositories: fs.setRepositories,
|
||||
setRepositoriesDirty: fs.setRepositoriesDirty,
|
||||
userSettingsLoaded,
|
||||
userSettingsLoaded: data.userSettingsLoaded,
|
||||
});
|
||||
return {
|
||||
...data,
|
||||
fs,
|
||||
isSessionMode,
|
||||
isEditMode,
|
||||
@@ -382,24 +403,17 @@ export function useTaskCreateDialogSetup(
|
||||
autoTitle,
|
||||
isTaskStarted,
|
||||
sessionRepoName,
|
||||
workflows,
|
||||
agentProfiles,
|
||||
snapshots,
|
||||
repositories,
|
||||
repositoriesLoading,
|
||||
refreshRepositories,
|
||||
computed,
|
||||
handlers,
|
||||
submitHandlers,
|
||||
handleKeyDown,
|
||||
freshBranchAvailable,
|
||||
repositorySets,
|
||||
taskCreateLastUsed,
|
||||
userSettingsLoaded,
|
||||
guardedHandleSubmit,
|
||||
enhance,
|
||||
handleJiraImport,
|
||||
handleLinearImport,
|
||||
editDependencies,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* eslint-disable max-lines -- submit lifecycle regressions share one fixture. */
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { createRef } from "react";
|
||||
@@ -376,6 +378,90 @@ describe("useTaskSubmitHandlers — started task edits", () => {
|
||||
expect(launchSessionMock).not.toHaveBeenCalled();
|
||||
expect(onSuccess).toHaveBeenCalledWith({ id: TASK_ID, title: RENAMED_TITLE }, "edit");
|
||||
});
|
||||
|
||||
it("replaces edited dependencies after task fields and closes on success", async () => {
|
||||
const save = vi.fn(async () => undefined);
|
||||
const onOpenChange = vi.fn();
|
||||
const deps = makeDeps({
|
||||
isEditMode: true,
|
||||
taskName: RENAMED_TITLE,
|
||||
editingTask: {
|
||||
id: TASK_ID,
|
||||
title: ORIGINAL_TITLE,
|
||||
description: ORIGINAL_PROMPT,
|
||||
workflowStepId: "step-1",
|
||||
state: TODO_STATE,
|
||||
},
|
||||
descriptionInputRef: makeRef(UPDATED_PROMPT),
|
||||
onOpenChange,
|
||||
editDependencies: { isDirty: true, ready: true, save },
|
||||
});
|
||||
const { result } = renderHook(() => useTaskSubmitHandlers(deps));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleUpdateWithoutAgent();
|
||||
});
|
||||
|
||||
expect(updateTaskMock).toHaveBeenCalledWith(TASK_ID, {
|
||||
title: RENAMED_TITLE,
|
||||
description: UPDATED_PROMPT,
|
||||
});
|
||||
expect(save).toHaveBeenCalledOnce();
|
||||
expect(save.mock.invocationCallOrder[0]).toBeGreaterThan(
|
||||
updateTaskMock.mock.invocationCallOrder[0]!,
|
||||
);
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("keeps the edit dialog open and restores task fields when dependencies fail", async () => {
|
||||
const setValue = vi.fn();
|
||||
const setTaskName = vi.fn();
|
||||
const setHasDescription = vi.fn();
|
||||
const onOpenChange = vi.fn();
|
||||
const cycleError = new ApiError("would create a dependency cycle", 409, {
|
||||
cycle: [TASK_ID, "task-2", TASK_ID],
|
||||
});
|
||||
const deps = makeDeps({
|
||||
isEditMode: true,
|
||||
taskName: RENAMED_TITLE,
|
||||
editingTask: {
|
||||
id: TASK_ID,
|
||||
title: ORIGINAL_TITLE,
|
||||
description: ORIGINAL_PROMPT,
|
||||
workflowStepId: "step-1",
|
||||
state: TODO_STATE,
|
||||
},
|
||||
descriptionInputRef: {
|
||||
current: { ...makeRef(UPDATED_PROMPT).current!, setValue },
|
||||
},
|
||||
onOpenChange,
|
||||
setTaskName,
|
||||
setHasDescription,
|
||||
editDependencies: {
|
||||
isDirty: true,
|
||||
ready: true,
|
||||
save: vi.fn().mockRejectedValue({ dependencyUpdate: true, cause: cycleError }),
|
||||
},
|
||||
});
|
||||
updateTaskMock.mockResolvedValueOnce({
|
||||
id: TASK_ID,
|
||||
title: RENAMED_TITLE,
|
||||
description: "Server description",
|
||||
});
|
||||
const { result } = renderHook(() => useTaskSubmitHandlers(deps));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleUpdateWithoutAgent();
|
||||
});
|
||||
|
||||
expect(onOpenChange).not.toHaveBeenCalled();
|
||||
expect(setTaskName).toHaveBeenCalledWith(RENAMED_TITLE);
|
||||
expect(setValue).toHaveBeenCalledWith("Server description");
|
||||
expect(setHasDescription).toHaveBeenCalledWith(true);
|
||||
expect(toastMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ description: "Dependency cycle: task-1 -> task-2 -> task-1" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useTaskSubmitHandlers — repository selection edit failures", () => {
|
||||
|
||||
@@ -15,6 +15,8 @@ import { t } from "@/lib/i18n";
|
||||
import { useFreshBranchConsent } from "@/components/task-create-dialog-fresh-branch-consent";
|
||||
import { queueTaskCreateLastUsedFromPayload } from "@/components/task-create-dialog-handlers";
|
||||
import { ApiError } from "@/lib/api/client";
|
||||
import { getTaskDependencyCycle } from "@/lib/api/domains/task-dependencies-api";
|
||||
import { isTaskDependencyUpdateFailure } from "@/hooks/domains/task/use-task-edit-dialog-dependencies";
|
||||
import { recordAgentProfileRecentUseBestEffort } from "@/lib/agent-profile-recent-use";
|
||||
|
||||
const GENERIC_ERROR_KEY = "common:anErrorOccurred";
|
||||
@@ -84,6 +86,11 @@ const REPOSITORY_SELECTION_ERROR_KEYS: Record<string, string> = {
|
||||
};
|
||||
|
||||
export function taskSubmitErrorMessage(error: unknown): string {
|
||||
if (isTaskDependencyUpdateFailure(error)) {
|
||||
const cycle = getTaskDependencyCycle(error.cause);
|
||||
if (cycle?.length) return t("task:dependencyCycleError", { cycle: cycle.join(" -> ") });
|
||||
return t("task:dependencyUpdateFailed");
|
||||
}
|
||||
if (error instanceof ApiError) {
|
||||
const key = REPOSITORY_SELECTION_ERROR_KEYS[error.errorCode ?? ""];
|
||||
if (key) return t(key);
|
||||
@@ -91,6 +98,53 @@ export function taskSubmitErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : t(GENERIC_ERROR_KEY);
|
||||
}
|
||||
|
||||
type EditDependencySaveArgs = {
|
||||
editDependencies?: SubmitHandlersDeps["editDependencies"];
|
||||
updatedTask: Awaited<ReturnType<typeof updateTask>>;
|
||||
isStartedEdit: boolean;
|
||||
descriptionInputRef: SubmitHandlersDeps["descriptionInputRef"];
|
||||
setTaskName: SubmitHandlersDeps["setTaskName"];
|
||||
setHasDescription: SubmitHandlersDeps["setHasDescription"];
|
||||
};
|
||||
|
||||
async function saveEditedTaskDependencies({
|
||||
editDependencies,
|
||||
updatedTask,
|
||||
isStartedEdit,
|
||||
descriptionInputRef,
|
||||
setTaskName,
|
||||
setHasDescription,
|
||||
}: EditDependencySaveArgs): Promise<void> {
|
||||
if (!editDependencies?.isDirty) return;
|
||||
try {
|
||||
await editDependencies.save();
|
||||
} catch (error) {
|
||||
setTaskName(updatedTask.title);
|
||||
if (!isStartedEdit) {
|
||||
const confirmedDescription = updatedTask.description?.trim() ?? "";
|
||||
descriptionInputRef.current?.setValue(confirmedDescription);
|
||||
setHasDescription(confirmedDescription.length > 0);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function areEditDependenciesReady(
|
||||
isEditMode: boolean,
|
||||
editDependencies: SubmitHandlersDeps["editDependencies"],
|
||||
): boolean {
|
||||
return !isEditMode || editDependencies?.ready !== false;
|
||||
}
|
||||
|
||||
async function shouldKeepEditDialogOpen(
|
||||
error: unknown,
|
||||
refreshStaleBranchPolicies: (error: unknown) => Promise<boolean>,
|
||||
): Promise<boolean> {
|
||||
if (isRepositorySelectionError(error)) return true;
|
||||
if (isTaskDependencyUpdateFailure(error)) return true;
|
||||
return refreshStaleBranchPolicies(error);
|
||||
}
|
||||
|
||||
function isRepositorySelectionError(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof ApiError && Boolean(REPOSITORY_SELECTION_ERROR_KEYS[error.errorCode ?? ""])
|
||||
@@ -146,6 +200,7 @@ export function useTaskSubmitHandlers({
|
||||
noRepository,
|
||||
workspacePath,
|
||||
blockedBy,
|
||||
editDependencies,
|
||||
transformDescriptionBeforeSubmit,
|
||||
}: SubmitHandlersDeps) {
|
||||
const router = useRouter();
|
||||
@@ -363,6 +418,7 @@ export function useTaskSubmitHandlers({
|
||||
|
||||
const performTaskUpdate = useCallback(async () => {
|
||||
if (!editingTask) return null;
|
||||
if (!areEditDependenciesReady(isEditMode, editDependencies)) return null;
|
||||
const trimmedTitle = taskName.trim();
|
||||
if (!trimmedTitle) return null;
|
||||
const description = isStartedEdit
|
||||
@@ -379,6 +435,14 @@ export function useTaskSubmitHandlers({
|
||||
};
|
||||
|
||||
const updatedTask = await updateTask(editingTask.id, updatePayload);
|
||||
await saveEditedTaskDependencies({
|
||||
editDependencies,
|
||||
updatedTask,
|
||||
isStartedEdit,
|
||||
descriptionInputRef,
|
||||
setTaskName,
|
||||
setHasDescription,
|
||||
});
|
||||
return { updatedTask, trimmedDescription };
|
||||
}, [
|
||||
editingTask,
|
||||
@@ -387,9 +451,14 @@ export function useTaskSubmitHandlers({
|
||||
getRepositoriesPayload,
|
||||
isStartedEdit,
|
||||
repositoriesDirty,
|
||||
editDependencies,
|
||||
isEditMode,
|
||||
setTaskName,
|
||||
setHasDescription,
|
||||
]);
|
||||
|
||||
const handleEditSubmit = useCallback(async () => {
|
||||
if (!areEditDependenciesReady(isEditMode, editDependencies)) return;
|
||||
if (checkRemoteResolution()) return;
|
||||
setIsCreatingTask(true);
|
||||
let closeDialog = true;
|
||||
@@ -422,9 +491,7 @@ export function useTaskSubmitHandlers({
|
||||
|
||||
onSuccess?.(updatedTask, "edit", { taskSessionId });
|
||||
} catch (error) {
|
||||
closeDialog = !(
|
||||
isRepositorySelectionError(error) || (await refreshStaleBranchPolicies(error))
|
||||
);
|
||||
closeDialog = !(await shouldKeepEditDialogOpen(error, refreshStaleBranchPolicies));
|
||||
toast({
|
||||
title: t("task:failedToUpdateTask"),
|
||||
description: taskSubmitErrorMessage(error),
|
||||
@@ -446,9 +513,12 @@ export function useTaskSubmitHandlers({
|
||||
toast,
|
||||
setIsCreatingTask,
|
||||
applyAgentProfileRecentUse,
|
||||
editDependencies,
|
||||
isEditMode,
|
||||
]);
|
||||
|
||||
const handleUpdateWithoutAgent = useCallback(async () => {
|
||||
if (!areEditDependenciesReady(isEditMode, editDependencies)) return;
|
||||
if (checkRemoteResolution()) return;
|
||||
setIsCreatingTask(true);
|
||||
let closeDialog = true;
|
||||
@@ -457,9 +527,7 @@ export function useTaskSubmitHandlers({
|
||||
if (!result) return;
|
||||
onSuccess?.(result.updatedTask, "edit");
|
||||
} catch (error) {
|
||||
closeDialog = !(
|
||||
isRepositorySelectionError(error) || (await refreshStaleBranchPolicies(error))
|
||||
);
|
||||
closeDialog = !(await shouldKeepEditDialogOpen(error, refreshStaleBranchPolicies));
|
||||
toast({
|
||||
title: t("task:failedToUpdateTask"),
|
||||
description: taskSubmitErrorMessage(error),
|
||||
@@ -477,6 +545,8 @@ export function useTaskSubmitHandlers({
|
||||
refreshStaleBranchPolicies,
|
||||
toast,
|
||||
setIsCreatingTask,
|
||||
editDependencies,
|
||||
isEditMode,
|
||||
]);
|
||||
|
||||
const performCreate = useCallback(
|
||||
@@ -630,6 +700,7 @@ export function useTaskSubmitHandlers({
|
||||
|
||||
const handleCreateWithPlanMode = useCallback(async () => {
|
||||
if (isEditMode) {
|
||||
if (editDependencies && !editDependencies.ready) return;
|
||||
setIsCreatingTask(true);
|
||||
try {
|
||||
await performEditWithPlanMode();
|
||||
@@ -687,6 +758,7 @@ export function useTaskSubmitHandlers({
|
||||
toast,
|
||||
descriptionInputRef,
|
||||
setIsCreatingTask,
|
||||
editDependencies,
|
||||
]);
|
||||
|
||||
const submitCreateTask = useCallback(
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { RepositoryInspection } from "@/lib/plugins/types";
|
||||
import type { UtilityGenerationResult } from "@/hooks/use-utility-agent-generator";
|
||||
import type { AgentProfileOption, WorkspaceState } from "@/lib/state/slices";
|
||||
import type { AgentProfileRecentUseContext } from "@/lib/types/http-agent-profile-recent-use";
|
||||
import type { TaskEditDialogDependenciesState } from "@/hooks/domains/task/use-task-edit-dialog-dependencies";
|
||||
import type {
|
||||
KanbanMultiState,
|
||||
WorkflowSnapshotData,
|
||||
@@ -517,6 +518,8 @@ export type SubmitHandlersDeps = {
|
||||
noRepository: boolean;
|
||||
/** Predecessor task IDs to link at creation time. */
|
||||
blockedBy?: string[];
|
||||
/** Edit-mode dependency draft and persistence state. */
|
||||
editDependencies?: Pick<TaskEditDialogDependenciesState, "isDirty" | "ready" | "save">;
|
||||
/** Optional host folder for repo-less tasks; empty means kandev creates a scratch workspace. */
|
||||
workspacePath: string;
|
||||
/**
|
||||
@@ -559,10 +562,12 @@ export type DialogFormBodyProps = {
|
||||
agentProfilesLoading: boolean;
|
||||
executorsLoading: boolean;
|
||||
isCreatingSession: boolean;
|
||||
isCreatingTask?: boolean;
|
||||
workflows: WorkflowsState["items"];
|
||||
snapshots: KanbanMultiState["snapshots"];
|
||||
effectiveWorkflowId: string | null;
|
||||
fs: DialogFormState;
|
||||
editDependencies: TaskEditDialogDependenciesState;
|
||||
handleKeyDown: ReturnType<typeof useKeyboardShortcutHandler>;
|
||||
onTaskNameChange: (v: string) => void;
|
||||
onRowRepositoryChange: (key: string, value: string) => void;
|
||||
|
||||
@@ -96,6 +96,10 @@ vi.mock("@/components/task-create-dialog-repo-chips", () => ({
|
||||
RepoChipsRow: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("./task-edit-dialog-dependencies", () => ({
|
||||
TaskEditDialogDependencies: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-task-create-dialog-popover-container", () => ({
|
||||
useTaskCreateDialogPopoverContainer: () => null,
|
||||
TaskCreateDialogPopoverContainerProvider: ({ children }: { children: ReactNode }) => (
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from "@/components/task-create-dialog-selectors";
|
||||
import { RepoChipsRow } from "@/components/task-create-dialog-repo-chips";
|
||||
import { TaskCreateAdvancedSettings } from "@/components/task-create-dialog-advanced-settings";
|
||||
import { TaskEditDialogDependencies } from "@/components/task-edit-dialog-dependencies";
|
||||
import type {
|
||||
DialogFormBodyProps,
|
||||
TaskCreateDialogProps,
|
||||
@@ -194,8 +195,14 @@ function DialogFormBody(props: DialogFormBodyProps) {
|
||||
isTaskStarted={isTaskStarted}
|
||||
blockedBy={props.fs.blockedBy}
|
||||
onBlockedByChange={props.fs.setBlockedBy}
|
||||
dependenciesDisabled={props.isCreatingSession}
|
||||
dependenciesDisabled={props.isCreatingSession || props.isCreatingTask}
|
||||
/>
|
||||
{props.isEditMode && (
|
||||
<TaskEditDialogDependencies
|
||||
state={props.editDependencies}
|
||||
disabled={props.isCreatingSession || props.isCreatingTask}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { TooltipProvider } from "@kandev/ui/tooltip";
|
||||
import type { TaskEditDialogDependenciesState } from "@/hooks/domains/task/use-task-edit-dialog-dependencies";
|
||||
import { ApiError } from "@/lib/api/client";
|
||||
import { TaskEditDialogDependencies } from "./task-edit-dialog-dependencies";
|
||||
|
||||
const workspaceHydrationMocks = vi.hoisted(() => ({
|
||||
useWorkspacePRs: vi.fn(),
|
||||
useWorkspaceMRs: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/domains/github/use-task-pr", () => workspaceHydrationMocks);
|
||||
vi.mock("@/hooks/domains/gitlab/use-task-mr", () => workspaceHydrationMocks);
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { count?: number; cycle?: string }) =>
|
||||
({
|
||||
"task:dependsOn": "Depends on",
|
||||
"task:noDependency": "No dependency",
|
||||
"task:searchTasks": "Search tasks...",
|
||||
"task:noTasksFound": "No tasks found.",
|
||||
"tasks:loadingTasks": "Loading tasks...",
|
||||
"task:dependencyInfoLabel": "About task dependencies",
|
||||
"task:dependencyInfo": "This task waits until every selected task completes successfully.",
|
||||
"task:failedToLoadDependencies": "Failed to load dependencies.",
|
||||
"task:failedToLoadDependencyCandidates": "Failed to load dependency candidates.",
|
||||
"task:dependencyUpdateFailed": "Failed to update dependencies.",
|
||||
"task:dependencyCycleError": `Dependency cycle: ${options?.cycle ?? ""}`,
|
||||
"task:retry": "Retry",
|
||||
"task:dependencyCount_one": `${options?.count ?? 0} dependency`,
|
||||
"task:dependencyCount_other": `${options?.count ?? 0} dependencies`,
|
||||
})[key] ?? key,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/state-provider", () => ({
|
||||
useAppStore: (selector: (state: unknown) => unknown) =>
|
||||
selector({
|
||||
kanban: { tasks: [] },
|
||||
kanbanMulti: { snapshots: {} },
|
||||
workspaces: { activeId: null },
|
||||
workspaceContextGeneration: 0,
|
||||
taskMRs: { byWorkspaceId: {} },
|
||||
taskPRs: { byTaskId: {}, workspaceId: null, workspaceContextGeneration: 0 },
|
||||
}),
|
||||
}));
|
||||
|
||||
function state(
|
||||
overrides: Partial<TaskEditDialogDependenciesState> = {},
|
||||
): TaskEditDialogDependenciesState {
|
||||
return {
|
||||
taskId: "task-1",
|
||||
confirmedIds: ["task-2"],
|
||||
draftIds: ["task-2"],
|
||||
setDraftIds: vi.fn(),
|
||||
selectedTitles: { "task-2": "Predecessor" },
|
||||
candidates: [
|
||||
{ id: "task-1", title: "Edited task" },
|
||||
{ id: "task-2", title: "Predecessor" },
|
||||
{ id: "task-3", title: "Another task" },
|
||||
{ id: "task-archived", title: "Archived task", isArchived: true },
|
||||
],
|
||||
candidatesLoading: false,
|
||||
query: "",
|
||||
setQuery: vi.fn(),
|
||||
loading: false,
|
||||
loadError: null,
|
||||
candidateError: null,
|
||||
saveError: null,
|
||||
error: null,
|
||||
submitError: null,
|
||||
ready: true,
|
||||
isDirty: false,
|
||||
save: vi.fn(),
|
||||
retry: vi.fn(),
|
||||
retryCandidates: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderDependencies(value: TaskEditDialogDependenciesState) {
|
||||
return render(
|
||||
<TooltipProvider>
|
||||
<TaskEditDialogDependencies state={value} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("TaskEditDialogDependencies", () => {
|
||||
it("uses the current predecessor set and excludes the edited and archived tasks", () => {
|
||||
renderDependencies(state());
|
||||
|
||||
expect(screen.getByTestId("task-edit-dependencies")).toBeTruthy();
|
||||
expect(screen.getByText("Depends on")).toBeTruthy();
|
||||
expect(screen.getByTestId("task-create-dependencies-trigger").textContent).toContain(
|
||||
"Predecessor",
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("task-create-dependencies-trigger"));
|
||||
const picker = screen.getByTestId("task-create-dependencies-popover");
|
||||
expect(within(picker).getByTestId("task-create-dependency-option-task-2")).toBeTruthy();
|
||||
expect(within(picker).getByTestId("task-create-dependency-option-task-3")).toBeTruthy();
|
||||
expect(within(picker).queryByText("Edited task")).toBeNull();
|
||||
expect(within(picker).queryByText("Archived task")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the editor open and names the cycle when saving fails", () => {
|
||||
const cycleError = new ApiError("would create a dependency cycle", 409, {
|
||||
cycle: ["task-1", "task-2", "task-1"],
|
||||
});
|
||||
renderDependencies(
|
||||
state({
|
||||
saveError: cycleError,
|
||||
submitError: {
|
||||
dependencyUpdate: true,
|
||||
cause: cycleError,
|
||||
},
|
||||
error: cycleError,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("task-edit-dependencies")).toBeTruthy();
|
||||
expect(screen.getByTestId("task-edit-dependencies-error").textContent).toContain(
|
||||
"Dependency cycle: task-1 -> task-2 -> task-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the candidate retry without disabling the editor", () => {
|
||||
const retryCandidates = vi.fn();
|
||||
renderDependencies(
|
||||
state({
|
||||
candidateError: new Error("candidate search failed"),
|
||||
retryCandidates,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("task-create-dependencies-trigger")).toBeTruthy();
|
||||
fireEvent.click(screen.getByTestId("task-edit-dependencies-candidates-retry"));
|
||||
expect(retryCandidates).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TaskCreateDependencies } from "@/components/task-create-dialog-dependencies";
|
||||
import type { TaskEditDialogDependenciesState } from "@/hooks/domains/task/use-task-edit-dialog-dependencies";
|
||||
import { getTaskDependencyCycle } from "@/lib/api/domains/task-dependencies-api";
|
||||
|
||||
type TaskEditDialogDependenciesProps = {
|
||||
state: TaskEditDialogDependenciesState;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
function dependencySaveError(
|
||||
error: unknown,
|
||||
t: (key: string, options?: { cycle?: string }) => string,
|
||||
): string {
|
||||
const cycle = getTaskDependencyCycle(error);
|
||||
if (cycle?.length) return t("task:dependencyCycleError", { cycle: cycle.join(" -> ") });
|
||||
return t("task:dependencyUpdateFailed");
|
||||
}
|
||||
|
||||
export function TaskEditDialogDependencies({
|
||||
state,
|
||||
disabled = false,
|
||||
}: TaskEditDialogDependenciesProps) {
|
||||
const { t } = useTranslation();
|
||||
const { loadError, saveError, candidateError } = state;
|
||||
let dependencyField: ReactNode;
|
||||
if (state.loading) {
|
||||
dependencyField = (
|
||||
<p
|
||||
className="px-1 text-xs text-muted-foreground"
|
||||
data-testid="task-edit-dependencies-loading"
|
||||
>
|
||||
{t("task:loadingDependencies")}
|
||||
</p>
|
||||
);
|
||||
} else if (loadError) {
|
||||
dependencyField = (
|
||||
<div className="flex min-h-12 items-center gap-2 px-1">
|
||||
<p className="min-w-0 flex-1 text-xs text-destructive" role="alert">
|
||||
{t("task:failedToLoadDependencies")}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-11 min-h-11 shrink-0 cursor-pointer px-3 text-xs"
|
||||
onClick={state.retry}
|
||||
data-testid="task-edit-dependencies-retry"
|
||||
>
|
||||
{t("task:retry")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
dependencyField = (
|
||||
<TaskCreateDependencies
|
||||
value={state.draftIds}
|
||||
onChange={state.setDraftIds}
|
||||
disabled={disabled}
|
||||
candidates={state.candidates}
|
||||
selectedTitles={state.selectedTitles}
|
||||
candidatesLoading={state.candidatesLoading}
|
||||
searchValue={state.query}
|
||||
onSearchValueChange={state.setQuery}
|
||||
excludeTaskId={state.taskId ?? undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-w-0 space-y-1" data-testid="task-edit-dependencies">
|
||||
<div className="flex min-h-11 items-center gap-1 px-1 text-[11px] text-muted-foreground/70 md:min-h-6">
|
||||
<span>{t("task:dependsOn")}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-11 min-h-11 w-11 min-w-11 cursor-pointer p-0 text-muted-foreground/70 hover:bg-transparent hover:text-muted-foreground md:h-6 md:min-h-6 md:w-6 md:min-w-6"
|
||||
aria-label={t("task:dependencyInfoLabel")}
|
||||
data-testid="task-edit-dependency-info"
|
||||
>
|
||||
<IconInfoCircle className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="z-[60] max-w-xs">
|
||||
{t("task:dependencyInfo")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{dependencyField}
|
||||
{Boolean(candidateError) && !loadError && (
|
||||
<div className="flex min-h-11 items-center gap-2 px-1">
|
||||
<p className="min-w-0 flex-1 text-xs text-destructive" role="alert">
|
||||
{t("task:failedToLoadDependencyCandidates")}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-11 min-h-11 shrink-0 cursor-pointer px-3 text-xs"
|
||||
onClick={state.retryCandidates}
|
||||
data-testid="task-edit-dependencies-candidates-retry"
|
||||
>
|
||||
{t("task:retry")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{Boolean(saveError) && (
|
||||
<p
|
||||
className="px-1 text-xs text-destructive"
|
||||
role="alert"
|
||||
data-testid="task-edit-dependencies-error"
|
||||
>
|
||||
{dependencySaveError(saveError, t)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2342,6 +2342,23 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
/** Replace the complete predecessor set, including an empty set. */
|
||||
async replaceTaskDependencies(
|
||||
taskId: string,
|
||||
dependsOnTaskIds: string[],
|
||||
): Promise<TaskDependencyProjection> {
|
||||
return this.request("PUT", `/api/v1/tasks/${taskId}/dependencies`, {
|
||||
depends_on_task_ids: dependsOnTaskIds,
|
||||
});
|
||||
}
|
||||
|
||||
/** Raw replacement helper for asserting structured validation failures. */
|
||||
async rawReplaceTaskDependencies(taskId: string, dependsOnTaskIds: string[]): Promise<Response> {
|
||||
return this.rawRequest("PUT", `/api/v1/tasks/${taskId}/dependencies`, {
|
||||
depends_on_task_ids: dependsOnTaskIds,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw add, for asserting the rejection path. A cycle answers 409 with a
|
||||
* `cycle` array; the typed helper above would throw the body away.
|
||||
|
||||
@@ -174,6 +174,90 @@ test.describe("Task-create dependency selector", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("edits a started task's predecessor set and keeps cancel draft-only", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
prCapture,
|
||||
}) => {
|
||||
const taskOptions = {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
};
|
||||
const alpha = await apiClient.createTask(
|
||||
seedData.workspaceId,
|
||||
"Edit dependency Alpha",
|
||||
taskOptions,
|
||||
);
|
||||
const beta = await apiClient.createTask(
|
||||
seedData.workspaceId,
|
||||
"Edit dependency Beta",
|
||||
taskOptions,
|
||||
);
|
||||
const archived = await apiClient.createTask(
|
||||
seedData.workspaceId,
|
||||
"Edit archived dependency",
|
||||
taskOptions,
|
||||
);
|
||||
await apiClient.archiveTask(archived.id);
|
||||
const target = await apiClient.createTask(seedData.workspaceId, "Edit dependency target", {
|
||||
...taskOptions,
|
||||
description: "The started task prompt remains unchanged.",
|
||||
blocked_by: [alpha.id],
|
||||
});
|
||||
await apiClient.updateTaskState(target.id, "IN_PROGRESS");
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
await kanban.openTaskActionsMenu(target.id);
|
||||
await testPage.getByRole("menuitem", { name: "Edit", exact: true }).click();
|
||||
|
||||
const dialog = testPage.getByTestId("create-task-dialog");
|
||||
await expect(dialog.getByTestId("task-edit-dependencies")).toBeVisible();
|
||||
await prCapture.screenshot("desktop-task-dependency-edit-dialog", {
|
||||
caption: "Desktop task edit dialog with predecessor dependencies",
|
||||
});
|
||||
const dependency = dialog.getByTestId(DEPENDENCY_TRIGGER);
|
||||
await expect(dependency).toContainText("Edit dependency Alpha");
|
||||
await dependency.click();
|
||||
const picker = dialog.getByTestId("task-create-dependencies-popover");
|
||||
const search = picker.getByPlaceholder("Search tasks...");
|
||||
await search.fill("Edit dependency Beta");
|
||||
await expect(picker.getByTestId(`${DEPENDENCY_OPTION_PREFIX}${beta.id}`)).toBeVisible();
|
||||
await picker.getByTestId(`${DEPENDENCY_OPTION_PREFIX}${beta.id}`).click();
|
||||
await expect(dependency).toContainText("2 dependencies");
|
||||
|
||||
await testPage.keyboard.press("Escape");
|
||||
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
expect(
|
||||
(await apiClient.getTaskDependencies(target.id)).depends_on?.map((ref) => ref.id),
|
||||
).toEqual([alpha.id]);
|
||||
|
||||
await kanban.openTaskActionsMenu(target.id);
|
||||
await testPage.getByRole("menuitem", { name: "Edit", exact: true }).click();
|
||||
const reopened = testPage.getByTestId("create-task-dialog");
|
||||
await expect(reopened.getByTestId("task-edit-dependencies")).toBeVisible();
|
||||
const reopenedDependency = reopened.getByTestId(DEPENDENCY_TRIGGER);
|
||||
await reopenedDependency.click();
|
||||
const reopenedPicker = reopened.getByTestId("task-create-dependencies-popover");
|
||||
await reopenedPicker.getByPlaceholder("Search tasks...").fill("");
|
||||
await expect(
|
||||
reopenedPicker.getByTestId(`${DEPENDENCY_OPTION_PREFIX}${archived.id}`),
|
||||
).toHaveCount(0);
|
||||
await reopenedPicker.getByPlaceholder("Search tasks...").fill("Edit dependency Beta");
|
||||
await reopenedPicker.getByTestId(`${DEPENDENCY_OPTION_PREFIX}${beta.id}`).click();
|
||||
await testPage.keyboard.press("Escape");
|
||||
await reopened.getByRole("button", { name: "Update", exact: true }).click();
|
||||
await expect(reopened).toHaveCount(0);
|
||||
await expect
|
||||
.poll(async () =>
|
||||
(await apiClient.getTaskDependencies(target.id)).depends_on?.map((ref) => ref.id),
|
||||
)
|
||||
.toEqual(expect.arrayContaining([alpha.id, beta.id]));
|
||||
});
|
||||
|
||||
test("finds a candidate by its linked GitHub PR number and shows the badge", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
|
||||
@@ -111,4 +111,91 @@ test.describe("Create task dependency selector on mobile", () => {
|
||||
await expect(trigger).toContainText("#188 · Mobile dependency predecessor");
|
||||
await assertNoDocumentHorizontalOverflow(testPage, "mobile dependency selection");
|
||||
});
|
||||
|
||||
test("edits predecessors from the same touch-friendly dialog", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
prCapture,
|
||||
}) => {
|
||||
const taskOptions = {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
};
|
||||
const predecessor = await apiClient.createTask(
|
||||
seedData.workspaceId,
|
||||
"Mobile edit dependency Alpha",
|
||||
taskOptions,
|
||||
);
|
||||
const replacement = await apiClient.createTask(
|
||||
seedData.workspaceId,
|
||||
"Mobile edit dependency Beta",
|
||||
taskOptions,
|
||||
);
|
||||
const target = await apiClient.createTask(
|
||||
seedData.workspaceId,
|
||||
"Mobile edit dependency target",
|
||||
{
|
||||
...taskOptions,
|
||||
description: "Mobile edit prompt",
|
||||
blocked_by: [predecessor.id],
|
||||
},
|
||||
);
|
||||
await apiClient.updateTaskState(target.id, "IN_PROGRESS");
|
||||
|
||||
const mobile = new MobileKanbanPage(testPage);
|
||||
await mobile.goto();
|
||||
await mobile.taskCard(target.id).getByRole("button", { name: "More options" }).tap();
|
||||
await testPage
|
||||
.locator('[data-slot="dropdown-menu-content"]:visible')
|
||||
.getByRole("menuitem", { name: "Edit", exact: true })
|
||||
.tap();
|
||||
|
||||
const dialog = testPage.getByTestId("create-task-dialog");
|
||||
const editDependencies = dialog.getByTestId("task-edit-dependencies");
|
||||
await expect(editDependencies).toBeVisible();
|
||||
const trigger = dialog.getByTestId("task-create-dependencies-trigger");
|
||||
await expect(trigger).toBeVisible({ timeout: 30_000 });
|
||||
const triggerBox = await trigger.boundingBox();
|
||||
expect(triggerBox).not.toBeNull();
|
||||
expect(triggerBox!.height).toBeGreaterThanOrEqual(44);
|
||||
await assertLocatorWithinViewportX(trigger, "mobile edit dependency trigger");
|
||||
await assertNoElementHorizontalOverflow(trigger, "mobile edit dependency trigger");
|
||||
await expect(trigger).toContainText("Mobile edit dependency Alpha");
|
||||
|
||||
await trigger.tap();
|
||||
const picker = dialog.getByTestId("task-create-dependencies-popover");
|
||||
await expect(picker).toBeVisible();
|
||||
await assertLocatorWithinViewportX(picker, "mobile edit dependency picker");
|
||||
await assertNoElementHorizontalOverflow(picker, "mobile edit dependency picker");
|
||||
await assertNoDocumentHorizontalOverflow(testPage, "mobile edit dependency picker");
|
||||
const info = picker.getByTestId("task-create-dependency-info");
|
||||
const infoBox = await info.boundingBox();
|
||||
expect(infoBox).not.toBeNull();
|
||||
expect(infoBox!.height).toBeGreaterThanOrEqual(44);
|
||||
expect(infoBox!.width).toBeGreaterThanOrEqual(44);
|
||||
|
||||
const search = picker.getByPlaceholder("Search tasks...");
|
||||
await search.fill("Mobile edit dependency Beta");
|
||||
const option = picker.getByTestId(`task-create-dependency-option-${replacement.id}`);
|
||||
await expect(option).toBeVisible();
|
||||
const optionBox = await option.boundingBox();
|
||||
expect(optionBox).not.toBeNull();
|
||||
expect(optionBox!.height).toBeGreaterThanOrEqual(44);
|
||||
await prCapture.screenshot("mobile-task-dependency-picker", {
|
||||
caption: "Mobile task edit dialog predecessor picker",
|
||||
});
|
||||
await option.tap();
|
||||
await expect(trigger).toContainText("2 dependencies");
|
||||
await testPage.keyboard.press("Escape");
|
||||
await dialog.getByRole("button", { name: "Update", exact: true }).tap();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect
|
||||
.poll(async () =>
|
||||
(await apiClient.getTaskDependencies(target.id)).depends_on?.map((ref) => ref.id),
|
||||
)
|
||||
.toEqual(expect.arrayContaining([predecessor.id, replacement.id]));
|
||||
await assertNoDocumentHorizontalOverflow(testPage, "mobile edit dependency saved");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiError } from "@/lib/api/client";
|
||||
import {
|
||||
getTaskDependencies,
|
||||
replaceTaskDependencies,
|
||||
} from "@/lib/api/domains/task-dependencies-api";
|
||||
import { listTasksByWorkspace } from "@/lib/api/domains/kanban-api";
|
||||
import {
|
||||
isTaskDependencyUpdateFailure,
|
||||
useTaskEditDialogDependencies,
|
||||
} from "./use-task-edit-dialog-dependencies";
|
||||
|
||||
vi.mock("@/lib/api/domains/task-dependencies-api", () => ({
|
||||
getTaskDependencies: vi.fn(),
|
||||
replaceTaskDependencies: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/domains/kanban-api", () => ({
|
||||
listTasksByWorkspace: vi.fn(),
|
||||
}));
|
||||
|
||||
const dependency = (id: string, title: string) => ({ id, title, state: "TODO" as const });
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(getTaskDependencies).mockReset();
|
||||
vi.mocked(replaceTaskDependencies).mockReset();
|
||||
vi.mocked(listTasksByWorkspace).mockReset();
|
||||
vi.mocked(getTaskDependencies).mockResolvedValue({
|
||||
id: "task-1",
|
||||
depends_on: [dependency("task-2", "Predecessor")],
|
||||
});
|
||||
vi.mocked(listTasksByWorkspace).mockResolvedValue({
|
||||
total: 3,
|
||||
tasks: [
|
||||
{ id: "task-1", title: "Edited task", archived_at: null },
|
||||
{ id: "task-2", title: "Predecessor", archived_at: null },
|
||||
{ id: "task-archived", title: "Archived", archived_at: "2026-01-01" },
|
||||
],
|
||||
} as never);
|
||||
vi.mocked(replaceTaskDependencies).mockResolvedValue({ task_id: "task-1" });
|
||||
});
|
||||
|
||||
describe("useTaskEditDialogDependencies", () => {
|
||||
it("loads current predecessors and filters the edited and archived tasks", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTaskEditDialogDependencies({ open: true, workspaceId: "ws-1", taskId: "task-1" }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.confirmedIds).toEqual(["task-2"]);
|
||||
expect(result.current.draftIds).toEqual(["task-2"]);
|
||||
expect(result.current.selectedTitles).toEqual({ "task-2": "Predecessor" });
|
||||
expect(result.current.candidates.map((task) => task.id)).toEqual(["task-2"]);
|
||||
expect(listTasksByWorkspace).toHaveBeenCalledWith(
|
||||
"ws-1",
|
||||
{
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
query: "",
|
||||
},
|
||||
{
|
||||
init: { signal: expect.any(AbortSignal) },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("loads every candidate page", async () => {
|
||||
const firstPage = Array.from({ length: 100 }, (_, index) => ({
|
||||
id: `task-${index + 1}`,
|
||||
title: `Task ${index + 1}`,
|
||||
archived_at: null,
|
||||
}));
|
||||
vi.mocked(listTasksByWorkspace).mockImplementation(async (_workspaceId, params) => {
|
||||
if (params?.page === 1) return { total: 101, tasks: firstPage } as never;
|
||||
return {
|
||||
total: 101,
|
||||
tasks: [{ id: "task-101", title: "Task 101", archived_at: null }],
|
||||
} as never;
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTaskEditDialogDependencies({ open: true, workspaceId: "ws-1", taskId: "task-1" }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.candidates).toHaveLength(100));
|
||||
|
||||
expect(result.current.candidates.map((task) => task.id)).toContain("task-101");
|
||||
expect(listTasksByWorkspace).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"ws-1",
|
||||
{
|
||||
page: 2,
|
||||
pageSize: 100,
|
||||
query: "",
|
||||
},
|
||||
{
|
||||
init: { signal: expect.any(AbortSignal) },
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("candidate loading cancellation", () => {
|
||||
it("does not request later pages when a candidate query is superseded", async () => {
|
||||
const oldFirstPage = deferred<{
|
||||
total: number;
|
||||
tasks: Array<{ id: string; title: string; archived_at: null }>;
|
||||
}>();
|
||||
vi.mocked(listTasksByWorkspace).mockImplementation(async (_workspaceId, params) => {
|
||||
if (params?.query === "old" && params.page === 1) return oldFirstPage.promise as never;
|
||||
if (params?.query === "old") {
|
||||
return {
|
||||
total: 200,
|
||||
tasks: Array.from({ length: 100 }, (_, index) => ({
|
||||
id: `old-page-${index}`,
|
||||
title: `Old page ${index}`,
|
||||
archived_at: null,
|
||||
})),
|
||||
} as never;
|
||||
}
|
||||
if (params?.query === "new") {
|
||||
return {
|
||||
total: 1,
|
||||
tasks: [{ id: "task-new", title: "New result", archived_at: null }],
|
||||
} as never;
|
||||
}
|
||||
return { total: 0, tasks: [] } as never;
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTaskEditDialogDependencies({ open: true, workspaceId: "ws-1", taskId: "task-1" }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
vi.mocked(listTasksByWorkspace).mock.calls.some(([, params]) => params?.query === ""),
|
||||
).toBe(true),
|
||||
);
|
||||
|
||||
act(() => result.current.setQuery("old"));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
vi.mocked(listTasksByWorkspace).mock.calls.some(([, params]) => params?.query === "old"),
|
||||
).toBe(true),
|
||||
);
|
||||
const oldFirstPageCall = vi
|
||||
.mocked(listTasksByWorkspace)
|
||||
.mock.calls.find(([, params]) => params?.query === "old" && params.page === 1);
|
||||
expect(oldFirstPageCall?.[2]?.init?.signal).toBeInstanceOf(AbortSignal);
|
||||
|
||||
act(() => result.current.setQuery("new"));
|
||||
expect(oldFirstPageCall?.[2]?.init?.signal?.aborted).toBe(true);
|
||||
await waitFor(() =>
|
||||
expect(result.current.candidates).toEqual([
|
||||
{ id: "task-new", title: "New result", isArchived: false },
|
||||
]),
|
||||
);
|
||||
|
||||
oldFirstPage.resolve({
|
||||
total: 200,
|
||||
tasks: Array.from({ length: 100 }, (_, index) => ({
|
||||
id: `old-${index}`,
|
||||
title: `Old ${index}`,
|
||||
archived_at: null,
|
||||
})),
|
||||
});
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(
|
||||
vi
|
||||
.mocked(listTasksByWorkspace)
|
||||
.mock.calls.filter(([, params]) => params?.query === "old" && (params.page ?? 0) > 1),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useTaskEditDialogDependencies updates", () => {
|
||||
it("saves only draft changes and updates the confirmed set after success", async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTaskEditDialogDependencies({ open: true, workspaceId: "ws-1", taskId: "task-1" }),
|
||||
);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
act(() => result.current.setDraftIds([]));
|
||||
expect(replaceTaskDependencies).not.toHaveBeenCalled();
|
||||
expect(result.current.isDirty).toBe(true);
|
||||
|
||||
await act(async () => result.current.save());
|
||||
|
||||
expect(replaceTaskDependencies).toHaveBeenCalledWith("task-1", []);
|
||||
expect(result.current.confirmedIds).toEqual([]);
|
||||
expect(result.current.isDirty).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the draft and exposes the original error when replacement fails", async () => {
|
||||
const apiError = new ApiError("would create a dependency cycle", 409, {
|
||||
error: "would create a dependency cycle",
|
||||
cycle: ["task-1", "task-2", "task-1"],
|
||||
});
|
||||
vi.mocked(replaceTaskDependencies).mockRejectedValueOnce(apiError);
|
||||
const { result } = renderHook(() =>
|
||||
useTaskEditDialogDependencies({ open: true, workspaceId: "ws-1", taskId: "task-1" }),
|
||||
);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
act(() => result.current.setDraftIds(["task-2", "task-3"]));
|
||||
let failure: unknown;
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.save();
|
||||
} catch (error: unknown) {
|
||||
failure = error;
|
||||
}
|
||||
});
|
||||
|
||||
expect(isTaskDependencyUpdateFailure(failure)).toBe(true);
|
||||
expect(result.current.confirmedIds).toEqual(["task-2"]);
|
||||
expect(result.current.draftIds).toEqual(["task-2", "task-3"]);
|
||||
expect(result.current.error).toBe(apiError);
|
||||
expect(isTaskDependencyUpdateFailure(result.current.submitError)).toBe(true);
|
||||
});
|
||||
|
||||
it("retains a newly selected title when a later candidate result omits it", async () => {
|
||||
vi.mocked(listTasksByWorkspace).mockImplementation(async (_workspaceId, params) => {
|
||||
if (params?.query === "later") {
|
||||
return {
|
||||
total: 1,
|
||||
tasks: [{ id: "task-b", title: "Task B", archived_at: null }],
|
||||
} as never;
|
||||
}
|
||||
return {
|
||||
total: 1,
|
||||
tasks: [{ id: "task-a", title: "Task A", archived_at: null }],
|
||||
} as never;
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useTaskEditDialogDependencies({ open: true, workspaceId: "ws-1", taskId: "task-1" }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(result.current.candidates).toEqual([
|
||||
{ id: "task-a", title: "Task A", isArchived: false },
|
||||
]),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setDraftIds(["task-a"]);
|
||||
result.current.setQuery("later");
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(result.current.candidates).toEqual([
|
||||
{ id: "task-b", title: "Task B", isArchived: false },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(result.current.draftIds).toEqual(["task-a"]);
|
||||
expect(result.current.selectedTitles).toMatchObject({ "task-a": "Task A" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("useTaskEditDialogDependencies failure handling", () => {
|
||||
it("keeps the editor ready when candidate search fails", async () => {
|
||||
vi.mocked(listTasksByWorkspace).mockRejectedValueOnce(new Error("candidate search failed"));
|
||||
const { result } = renderHook(() =>
|
||||
useTaskEditDialogDependencies({ open: true, workspaceId: "ws-1", taskId: "task-1" }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.candidateError).not.toBeNull());
|
||||
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.ready).toBe(true);
|
||||
});
|
||||
|
||||
it("retries candidates without reloading the projection or losing the draft", async () => {
|
||||
vi.mocked(listTasksByWorkspace).mockRejectedValueOnce(new Error("candidate search failed"));
|
||||
const { result } = renderHook(() =>
|
||||
useTaskEditDialogDependencies({ open: true, workspaceId: "ws-1", taskId: "task-1" }),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.candidateError).not.toBeNull());
|
||||
act(() => result.current.setDraftIds(["task-3"]));
|
||||
vi.mocked(listTasksByWorkspace).mockResolvedValueOnce({
|
||||
total: 1,
|
||||
tasks: [{ id: "task-3", title: "Recovered candidate", archived_at: null }],
|
||||
} as never);
|
||||
|
||||
act(() => result.current.retryCandidates());
|
||||
await waitFor(() =>
|
||||
expect(result.current.candidates).toEqual([
|
||||
{ id: "task-3", title: "Recovered candidate", isArchived: false },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(result.current.draftIds).toEqual(["task-3"]);
|
||||
expect(getTaskDependencies).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("ignores a save result from the previous task after the dialog changes identity", async () => {
|
||||
const saveRequest = deferred<{ task_id: string }>();
|
||||
vi.mocked(replaceTaskDependencies).mockReturnValue(saveRequest.promise as never);
|
||||
const { result, rerender } = renderHook(
|
||||
({ open, workspaceId, taskId }: { open: boolean; workspaceId: string; taskId: string }) =>
|
||||
useTaskEditDialogDependencies({ open, workspaceId, taskId }),
|
||||
{
|
||||
initialProps: { open: true, workspaceId: "ws-1", taskId: "task-1" },
|
||||
},
|
||||
);
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
act(() => result.current.setDraftIds(["task-3"]));
|
||||
let savePromise!: Promise<void>;
|
||||
act(() => {
|
||||
savePromise = result.current.save();
|
||||
});
|
||||
|
||||
vi.mocked(getTaskDependencies).mockResolvedValueOnce({
|
||||
id: "task-2",
|
||||
depends_on: [dependency("task-4", "New predecessor")],
|
||||
});
|
||||
vi.mocked(listTasksByWorkspace).mockResolvedValueOnce({
|
||||
total: 1,
|
||||
tasks: [{ id: "task-4", title: "New predecessor", archived_at: null }],
|
||||
} as never);
|
||||
rerender({ open: true, workspaceId: "ws-2", taskId: "task-2" });
|
||||
await waitFor(() => expect(result.current.draftIds).toEqual(["task-4"]));
|
||||
|
||||
await act(async () => {
|
||||
saveRequest.resolve({ task_id: "task-1" });
|
||||
await savePromise;
|
||||
});
|
||||
|
||||
expect(result.current.confirmedIds).toEqual(["task-4"]);
|
||||
expect(result.current.draftIds).toEqual(["task-4"]);
|
||||
expect(result.current.saveError).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,424 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { listTasksByWorkspace } from "@/lib/api/domains/kanban-api";
|
||||
import {
|
||||
getTaskDependencies,
|
||||
replaceTaskDependencies,
|
||||
type TaskDependencyProjectionResponse,
|
||||
} from "@/lib/api/domains/task-dependencies-api";
|
||||
import type { TaskDependencyCandidate } from "@/lib/types/task-dependencies";
|
||||
|
||||
export type TaskDependencyUpdateFailure = {
|
||||
readonly dependencyUpdate: true;
|
||||
readonly cause: unknown;
|
||||
};
|
||||
|
||||
export function isTaskDependencyUpdateFailure(
|
||||
error: unknown,
|
||||
): error is TaskDependencyUpdateFailure {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"dependencyUpdate" in error &&
|
||||
(error as { dependencyUpdate?: unknown }).dependencyUpdate === true
|
||||
);
|
||||
}
|
||||
|
||||
export type TaskEditDialogDependenciesState = {
|
||||
taskId: string | null;
|
||||
confirmedIds: string[];
|
||||
draftIds: string[];
|
||||
setDraftIds: (ids: string[]) => void;
|
||||
selectedTitles: Record<string, string>;
|
||||
candidates: TaskDependencyCandidate[];
|
||||
candidatesLoading: boolean;
|
||||
query: string;
|
||||
setQuery: (query: string) => void;
|
||||
loading: boolean;
|
||||
loadError: unknown | null;
|
||||
candidateError: unknown | null;
|
||||
saveError: unknown | null;
|
||||
error: unknown | null;
|
||||
submitError: TaskDependencyUpdateFailure | null;
|
||||
ready: boolean;
|
||||
isDirty: boolean;
|
||||
save: () => Promise<void>;
|
||||
retry: () => void;
|
||||
retryCandidates: () => void;
|
||||
};
|
||||
|
||||
type TaskEditDialogDependenciesArgs = {
|
||||
open: boolean;
|
||||
workspaceId: string | null | undefined;
|
||||
taskId: string | null | undefined;
|
||||
};
|
||||
|
||||
type DependencyRef = NonNullable<TaskDependencyProjectionResponse["depends_on"]>[number];
|
||||
|
||||
type DependencyProjectionState = {
|
||||
confirmedIds: string[];
|
||||
setConfirmedIds: (ids: string[]) => void;
|
||||
draftIds: string[];
|
||||
setDraftIds: (ids: string[]) => void;
|
||||
knownTitles: Record<string, string>;
|
||||
loading: boolean;
|
||||
loadError: unknown | null;
|
||||
};
|
||||
|
||||
type DependencyCandidateState = {
|
||||
candidates: TaskDependencyCandidate[];
|
||||
candidatesLoading: boolean;
|
||||
candidateError: unknown | null;
|
||||
};
|
||||
|
||||
type DependencyTitleCacheState = {
|
||||
identity: string;
|
||||
titles: Record<string, string>;
|
||||
};
|
||||
|
||||
type DependencySaveRequest = {
|
||||
identity: string;
|
||||
generation: number;
|
||||
};
|
||||
|
||||
function uniqueIDs(ids: string[]): string[] {
|
||||
return [...new Set(ids)];
|
||||
}
|
||||
|
||||
function sameIDs(left: string[], right: string[]): boolean {
|
||||
return left.length === right.length && left.every((id) => right.includes(id));
|
||||
}
|
||||
|
||||
function dependencyRefs(response: TaskDependencyProjectionResponse): DependencyRef[] {
|
||||
return response.depends_on ?? [];
|
||||
}
|
||||
|
||||
function mapCandidates(
|
||||
tasks: Array<{ id: string; title: string; archived_at?: string | null }>,
|
||||
taskId: string,
|
||||
): TaskDependencyCandidate[] {
|
||||
return tasks
|
||||
.filter((task) => task.id !== taskId && task.archived_at == null)
|
||||
.map((task) => ({ id: task.id, title: task.title, isArchived: false }))
|
||||
.sort((left, right) => left.title.localeCompare(right.title));
|
||||
}
|
||||
|
||||
function titlesFromRefs(refs: DependencyRef[]): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
refs
|
||||
.filter((ref): ref is DependencyRef & { title: string } => typeof ref.title === "string")
|
||||
.map((ref) => [ref.id, ref.title]),
|
||||
);
|
||||
}
|
||||
|
||||
function useDependencySaveGuard(identity: string) {
|
||||
const identityRef = useRef(identity);
|
||||
const generationRef = useRef(0);
|
||||
if (identityRef.current !== identity) {
|
||||
identityRef.current = identity;
|
||||
generationRef.current += 1;
|
||||
}
|
||||
|
||||
const begin = useCallback((): DependencySaveRequest => {
|
||||
const generation = generationRef.current + 1;
|
||||
generationRef.current = generation;
|
||||
return { identity: identityRef.current, generation };
|
||||
}, []);
|
||||
const isCurrent = useCallback((request: DependencySaveRequest) => {
|
||||
return identityRef.current === request.identity && generationRef.current === request.generation;
|
||||
}, []);
|
||||
|
||||
return { begin, isCurrent };
|
||||
}
|
||||
|
||||
function useTaskDependencyProjection(
|
||||
{ open, workspaceId, taskId }: TaskEditDialogDependenciesArgs,
|
||||
reloadToken: number,
|
||||
): DependencyProjectionState {
|
||||
const [confirmedIds, setConfirmedIds] = useState<string[]>([]);
|
||||
const [draftIds, setDraftIds] = useState<string[]>([]);
|
||||
const [knownTitles, setKnownTitles] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadError, setLoadError] = useState<unknown | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!open || !workspaceId || !taskId) {
|
||||
setConfirmedIds([]);
|
||||
setDraftIds([]);
|
||||
setKnownTitles({});
|
||||
setLoading(false);
|
||||
setLoadError(null);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
void getTaskDependencies(taskId)
|
||||
.then((response) => {
|
||||
if (cancelled) return;
|
||||
const refs = dependencyRefs(response);
|
||||
const ids = refs.map((ref) => ref.id);
|
||||
setConfirmedIds(ids);
|
||||
setDraftIds(ids);
|
||||
setKnownTitles(titlesFromRefs(refs));
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!cancelled) setLoadError(error);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, reloadToken, taskId, workspaceId]);
|
||||
|
||||
return {
|
||||
confirmedIds,
|
||||
setConfirmedIds,
|
||||
draftIds,
|
||||
setDraftIds,
|
||||
knownTitles,
|
||||
loading,
|
||||
loadError,
|
||||
};
|
||||
}
|
||||
|
||||
function useTaskDependencyCandidates(
|
||||
{ open, workspaceId, taskId, query }: TaskEditDialogDependenciesArgs & { query: string },
|
||||
reloadToken: number,
|
||||
): DependencyCandidateState {
|
||||
const [candidates, setCandidates] = useState<TaskDependencyCandidate[]>([]);
|
||||
const [candidatesLoading, setCandidatesLoading] = useState(false);
|
||||
const [candidateError, setCandidateError] = useState<unknown | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!open || !workspaceId || !taskId) {
|
||||
setCandidates([]);
|
||||
setCandidatesLoading(false);
|
||||
setCandidateError(null);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
setCandidatesLoading(true);
|
||||
setCandidateError(null);
|
||||
|
||||
const loadCandidates = () => {
|
||||
void listAllDependencyCandidates(workspaceId, query, controller.signal)
|
||||
.then((tasks) => {
|
||||
if (!cancelled && !controller.signal.aborted) {
|
||||
setCandidates(mapCandidates(tasks, taskId));
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!cancelled && !controller.signal.aborted) setCandidateError(error);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled && !controller.signal.aborted) setCandidatesLoading(false);
|
||||
});
|
||||
};
|
||||
const debounceTimer =
|
||||
query.length > 0 ? window.setTimeout(loadCandidates, dependencySearchDebounceMs) : undefined;
|
||||
if (debounceTimer === undefined) loadCandidates();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
if (debounceTimer !== undefined) window.clearTimeout(debounceTimer);
|
||||
};
|
||||
}, [open, query, reloadToken, taskId, workspaceId]);
|
||||
|
||||
return { candidates, candidatesLoading, candidateError };
|
||||
}
|
||||
|
||||
const dependencyCandidatePageSize = 100;
|
||||
const dependencySearchDebounceMs = 200;
|
||||
|
||||
async function listAllDependencyCandidates(
|
||||
workspaceId: string,
|
||||
query: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<Array<{ id: string; title: string; archived_at?: string | null }>> {
|
||||
const tasks: Array<{ id: string; title: string; archived_at?: string | null }> = [];
|
||||
let page = 1;
|
||||
let total = 0;
|
||||
do {
|
||||
if (signal.aborted) return tasks;
|
||||
const response = await listTasksByWorkspace(
|
||||
workspaceId,
|
||||
{
|
||||
page,
|
||||
pageSize: dependencyCandidatePageSize,
|
||||
query,
|
||||
},
|
||||
{
|
||||
init: { signal },
|
||||
},
|
||||
);
|
||||
if (signal.aborted) return tasks;
|
||||
tasks.push(...response.tasks);
|
||||
total = response.total;
|
||||
if (response.tasks.length === 0) break;
|
||||
page += 1;
|
||||
} while (tasks.length < total);
|
||||
return tasks;
|
||||
}
|
||||
|
||||
function useDependencyTitleCache(
|
||||
identity: string,
|
||||
candidates: TaskDependencyCandidate[],
|
||||
draftIds: string[],
|
||||
): Record<string, string> {
|
||||
const [titleCache, setTitleCache] = useState<DependencyTitleCacheState>({
|
||||
identity: "",
|
||||
titles: {},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setTitleCache((current) => {
|
||||
if (current.identity !== identity) {
|
||||
return { identity, titles: {} };
|
||||
}
|
||||
const nextTitles = { ...current.titles };
|
||||
let changed = false;
|
||||
for (const candidate of candidates) {
|
||||
if (!draftIds.includes(candidate.id) || nextTitles[candidate.id] === candidate.title) {
|
||||
continue;
|
||||
}
|
||||
nextTitles[candidate.id] = candidate.title;
|
||||
changed = true;
|
||||
}
|
||||
return changed ? { identity, titles: nextTitles } : current;
|
||||
});
|
||||
}, [candidates, draftIds, identity]);
|
||||
|
||||
return titleCache.identity === identity ? titleCache.titles : {};
|
||||
}
|
||||
|
||||
function mergeSelectedTitles(
|
||||
knownTitles: Record<string, string>,
|
||||
cachedSelectedTitles: Record<string, string>,
|
||||
candidates: TaskDependencyCandidate[],
|
||||
): Record<string, string> {
|
||||
return {
|
||||
...knownTitles,
|
||||
...cachedSelectedTitles,
|
||||
...Object.fromEntries(candidates.map((candidate) => [candidate.id, candidate.title])),
|
||||
};
|
||||
}
|
||||
|
||||
export function useTaskEditDialogDependencies({
|
||||
open,
|
||||
workspaceId,
|
||||
taskId,
|
||||
}: TaskEditDialogDependenciesArgs): TaskEditDialogDependenciesState {
|
||||
const [saveError, setSaveError] = useState<unknown | null>(null);
|
||||
const [submitError, setSubmitError] = useState<TaskDependencyUpdateFailure | null>(null);
|
||||
const [projectionReloadToken, setProjectionReloadToken] = useState(0);
|
||||
const [candidateReloadToken, setCandidateReloadToken] = useState(0);
|
||||
const [query, setQueryState] = useState("");
|
||||
const dependencyDialogIdentity = JSON.stringify([open, workspaceId ?? null, taskId ?? null]);
|
||||
const projection = useTaskDependencyProjection(
|
||||
{ open, workspaceId, taskId },
|
||||
projectionReloadToken,
|
||||
);
|
||||
const candidateState = useTaskDependencyCandidates(
|
||||
{ open, workspaceId, taskId, query },
|
||||
candidateReloadToken,
|
||||
);
|
||||
|
||||
const saveIdentity = JSON.stringify([
|
||||
open,
|
||||
workspaceId ?? null,
|
||||
taskId ?? null,
|
||||
projectionReloadToken,
|
||||
]);
|
||||
const saveGuard = useDependencySaveGuard(saveIdentity);
|
||||
|
||||
useEffect(() => {
|
||||
setSaveError(null);
|
||||
setSubmitError(null);
|
||||
if (open && workspaceId && taskId) setQueryState("");
|
||||
}, [open, taskId, workspaceId]);
|
||||
|
||||
const setDraftIds = useCallback(
|
||||
(ids: string[]) => {
|
||||
projection.setDraftIds(uniqueIDs(ids));
|
||||
setSaveError(null);
|
||||
setSubmitError(null);
|
||||
},
|
||||
[projection],
|
||||
);
|
||||
|
||||
const setQuery = useCallback((value: string) => {
|
||||
setQueryState(value);
|
||||
}, []);
|
||||
|
||||
const { confirmedIds, setConfirmedIds, draftIds, knownTitles, loading, loadError } = projection;
|
||||
const { candidates, candidatesLoading, candidateError } = candidateState;
|
||||
const isDirty = !sameIDs(confirmedIds, draftIds);
|
||||
const cachedSelectedTitles = useDependencyTitleCache(
|
||||
dependencyDialogIdentity,
|
||||
candidates,
|
||||
draftIds,
|
||||
);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
if (!open || !workspaceId || !taskId || loading || !isDirty) return;
|
||||
const request = saveGuard.begin();
|
||||
const ids = [...draftIds];
|
||||
setSaveError(null);
|
||||
setSubmitError(null);
|
||||
try {
|
||||
await replaceTaskDependencies(taskId, ids);
|
||||
if (!saveGuard.isCurrent(request)) return;
|
||||
setConfirmedIds(ids);
|
||||
} catch (cause: unknown) {
|
||||
if (!saveGuard.isCurrent(request)) return;
|
||||
const failure: TaskDependencyUpdateFailure = { dependencyUpdate: true, cause };
|
||||
setSaveError(cause);
|
||||
setSubmitError(failure);
|
||||
throw failure;
|
||||
}
|
||||
}, [draftIds, isDirty, loading, open, saveGuard, taskId, workspaceId]);
|
||||
|
||||
const selectedTitles = useMemo(
|
||||
() => mergeSelectedTitles(knownTitles, cachedSelectedTitles, candidates),
|
||||
[cachedSelectedTitles, candidates, knownTitles],
|
||||
);
|
||||
const retry = useCallback(() => setProjectionReloadToken((value) => value + 1), []);
|
||||
const retryCandidates = useCallback(() => setCandidateReloadToken((value) => value + 1), []);
|
||||
const ready = !loading && !loadError;
|
||||
|
||||
return {
|
||||
taskId: taskId ?? null,
|
||||
confirmedIds,
|
||||
draftIds,
|
||||
setDraftIds,
|
||||
selectedTitles,
|
||||
candidates,
|
||||
candidatesLoading,
|
||||
query,
|
||||
setQuery,
|
||||
loading,
|
||||
loadError,
|
||||
candidateError,
|
||||
saveError,
|
||||
error: saveError ?? loadError ?? candidateError,
|
||||
submitError,
|
||||
ready,
|
||||
isDirty,
|
||||
save,
|
||||
retry,
|
||||
retryCandidates,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiError } from "@/lib/api/client";
|
||||
import { getTaskDependencyCycle, replaceTaskDependencies } from "./task-dependencies-api";
|
||||
|
||||
const fetchSpy = vi.fn<typeof fetch>();
|
||||
const API_BASE_URL = "http://api.test";
|
||||
|
||||
beforeEach(() => {
|
||||
fetchSpy.mockReset();
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
});
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("replaceTaskDependencies", () => {
|
||||
it("replaces the complete predecessor set with a PUT request", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
task_id: "task-1",
|
||||
blocked: true,
|
||||
blocked_reason: "pending",
|
||||
depends_on: [{ id: "task-2", title: "Predecessor" }],
|
||||
blocks: [],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
replaceTaskDependencies("task-1", ["task-2"], { baseUrl: API_BASE_URL }),
|
||||
).resolves.toMatchObject({ task_id: "task-1", blocked: true });
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledOnce();
|
||||
const [url, init] = fetchSpy.mock.calls[0];
|
||||
expect(url).toBe(`${API_BASE_URL}/api/v1/tasks/task-1/dependencies`);
|
||||
expect(init).toMatchObject({
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ depends_on_task_ids: ["task-2"] }),
|
||||
});
|
||||
expect(new Headers(init?.headers).get("Content-Type")).toBe("application/json");
|
||||
});
|
||||
|
||||
it("does not let caller request options override the replacement method or body", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ task_id: "task-1" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
await replaceTaskDependencies("task-1", ["task-2"], {
|
||||
baseUrl: API_BASE_URL,
|
||||
init: { method: "POST", body: "caller-controlled body" },
|
||||
});
|
||||
|
||||
const [, init] = fetchSpy.mock.calls[0];
|
||||
expect(init?.method).toBe("PUT");
|
||||
expect(init?.body).toBe(JSON.stringify({ depends_on_task_ids: ["task-2"] }));
|
||||
});
|
||||
|
||||
it("keeps the structured cycle path available to the edit dialog", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error: "would create a dependency cycle",
|
||||
cycle: ["task-1", "task-2", "task-1"],
|
||||
}),
|
||||
{ status: 409, headers: { "Content-Type": "application/json" } },
|
||||
),
|
||||
);
|
||||
|
||||
let error: unknown;
|
||||
try {
|
||||
await replaceTaskDependencies("task-1", ["task-2"], { baseUrl: API_BASE_URL });
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(ApiError);
|
||||
expect(getTaskDependencyCycle(error)).toEqual(["task-1", "task-2", "task-1"]);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fetchJson, type ApiRequestOptions } from "@/lib/api/client";
|
||||
import { ApiError, fetchJson, type ApiRequestOptions } from "@/lib/api/client";
|
||||
|
||||
const BASE = "/api/v1";
|
||||
|
||||
@@ -9,6 +9,15 @@ export type TaskDependencyRefResponse = {
|
||||
status?: "resolved" | "failed" | "pending";
|
||||
};
|
||||
|
||||
export type TaskDependencyProjectionResponse = {
|
||||
task_id?: string;
|
||||
id?: string;
|
||||
blocked?: boolean;
|
||||
blocked_reason?: string;
|
||||
depends_on?: TaskDependencyRefResponse[];
|
||||
blocks?: TaskDependencyRefResponse[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads one task's dependency projection.
|
||||
*
|
||||
@@ -19,11 +28,29 @@ export type TaskDependencyRefResponse = {
|
||||
* fetch-your-own-data shape `PRStatusChip` already uses via `useTaskPR`.
|
||||
*/
|
||||
export function getTaskDependencies(taskId: string, options?: ApiRequestOptions) {
|
||||
return fetchJson<{
|
||||
id: string;
|
||||
blocked?: boolean;
|
||||
blocked_reason?: string;
|
||||
depends_on?: TaskDependencyRefResponse[];
|
||||
blocks?: TaskDependencyRefResponse[];
|
||||
}>(`${BASE}/tasks/${taskId}`, options);
|
||||
return fetchJson<TaskDependencyProjectionResponse>(`${BASE}/tasks/${taskId}`, options);
|
||||
}
|
||||
|
||||
export function replaceTaskDependencies(
|
||||
taskId: string,
|
||||
dependsOnTaskIDs: string[],
|
||||
options?: ApiRequestOptions,
|
||||
) {
|
||||
return fetchJson<TaskDependencyProjectionResponse>(`${BASE}/tasks/${taskId}/dependencies`, {
|
||||
...options,
|
||||
init: {
|
||||
...options?.init,
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ depends_on_task_ids: dependsOnTaskIDs }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function getTaskDependencyCycle(error: unknown): string[] | null {
|
||||
if (!(error instanceof ApiError) || !error.body || typeof error.body !== "object") return null;
|
||||
const cycle = (error.body as { cycle?: unknown }).cycle;
|
||||
if (!Array.isArray(cycle) || !cycle.every((id): id is string => typeof id === "string")) {
|
||||
return null;
|
||||
}
|
||||
return cycle;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export type TaskDependencyCandidate = {
|
||||
id: string;
|
||||
title: string;
|
||||
isArchived?: boolean;
|
||||
};
|
||||
@@ -350,6 +350,7 @@
|
||||
"dependencyChangeRequestAria": "Linked pull or merge request #{{number}}",
|
||||
"dependencyChangeRequestLabel": "#{{number}}",
|
||||
"dependencyChipLabel": "Task dependencies",
|
||||
"dependencyCycleError": "Dependency cycle: {{cycle}}",
|
||||
"dependencyCountBlockedBy_one": "{{count}} blocker",
|
||||
"dependencyCountBlockedBy_other": "{{count}} blockers",
|
||||
"dependencyCountBlocks_one": "blocks {{count}}",
|
||||
@@ -358,6 +359,7 @@
|
||||
"dependencyCount_other": "{{count}} dependencies",
|
||||
"dependencyInfo": "This task waits until every selected task completes successfully. If you start an agent, it starts automatically when all dependencies are resolved.",
|
||||
"dependencyInfoLabel": "About task dependencies",
|
||||
"dependencyUpdateFailed": "Failed to update dependencies.",
|
||||
"dependsOn": "Depends on",
|
||||
"describeWhatNeedsToChange": "Describe what needs to change",
|
||||
"describeWhatYouWantTheAgent": "Describe what you want the agent to do... (@ to insert a saved prompt)",
|
||||
@@ -486,6 +488,8 @@
|
||||
"failedToLinkGithubPullRequest": "Failed to link GitHub pull request.",
|
||||
"failedToLinkTarget": "Failed to link {{target}}.",
|
||||
"failedToLoadDocuments": "Failed to load documents",
|
||||
"failedToLoadDependencies": "Failed to load dependencies.",
|
||||
"failedToLoadDependencyCandidates": "Failed to load dependency candidates.",
|
||||
"failedToLoadPreview": "Failed to load preview.",
|
||||
"failedToLoadRevision": "Failed to load revision",
|
||||
"failedToLoadRevisionContent": "Failed to load revision content",
|
||||
@@ -714,6 +718,7 @@
|
||||
"loadingCommandOutput": "Loading command output...",
|
||||
"loadingCommit": "Loading commit...",
|
||||
"loadingConversation": "Loading conversation...",
|
||||
"loadingDependencies": "Loading dependencies...",
|
||||
"loadingEditor": "Loading editor...",
|
||||
"loadingFile": "Loading file...",
|
||||
"loadingFiles": "Loading files...",
|
||||
|
||||
@@ -350,6 +350,7 @@
|
||||
"dependencyChangeRequestAria": "Ĺĩńķēď ƥũĺĺ ōŕ ḿēŕĝē ŕēqũēśţ #{{number}}",
|
||||
"dependencyChangeRequestLabel": "#{{number}}",
|
||||
"dependencyChipLabel": "Ţàśķ ďēƥēńďēńćĩēś",
|
||||
"dependencyCycleError": "Ďēƥēńďēńćŷ ćŷćĺē: {{cycle}}",
|
||||
"dependencyCountBlockedBy_one": "{{count}} ƀĺōćķēŕ",
|
||||
"dependencyCountBlockedBy_other": "{{count}} ƀĺōćķēŕś",
|
||||
"dependencyCountBlocks_one": "ƀĺōćķś {{count}}",
|
||||
@@ -358,6 +359,7 @@
|
||||
"dependencyCount_other": "{{count}} ďēƥēńďēńćĩēś",
|
||||
"dependencyInfo": "Ţĥĩś ţàśķ ŵàĩţś ũńţĩĺ ēvēŕŷ śēĺēćţēď ţàśķ ćōḿƥĺēţēś śũććēśśƒũĺĺŷ. Ĩƒ ŷōũ śţàŕţ àń àĝēńţ, ĩţ śţàŕţś àũţōḿàţĩćàĺĺŷ ŵĥēń àĺĺ ďēƥēńďēńćĩēś àŕē ŕēśōĺvēď.",
|
||||
"dependencyInfoLabel": "Àƀōũţ ţàśķ ďēƥēńďēńćĩēś",
|
||||
"dependencyUpdateFailed": "Ƒàĩĺēď ţō ũƥďàţē ďēƥēńďēńćĩēś.",
|
||||
"dependsOn": "Ďēƥēńďś ōń",
|
||||
"describeWhatNeedsToChange": "Ďēśćŕĩƀē ŵĥàţ ńēēďś ţō ćĥàńĝē",
|
||||
"describeWhatYouWantTheAgent": "Ďēśćŕĩƀē ŵĥàţ ŷōũ ŵàńţ ţĥē àĝēńţ ţō ďō... (@ ţō ĩńśēŕţ à śàvēď ƥŕōḿƥţ)",
|
||||
@@ -486,6 +488,8 @@
|
||||
"failedToLinkGithubPullRequest": "Ƒàĩĺēď ţō ĺĩńķ ĜĩţĤũƀ ƥũĺĺ ŕēqũēśţ.",
|
||||
"failedToLinkTarget": "Ƒàĩĺēď ţō ĺĩńķ {{target}}.",
|
||||
"failedToLoadDocuments": "Ƒàĩĺēď ţō ĺōàď ďōćũḿēńţś",
|
||||
"failedToLoadDependencies": "Ƒàĩĺēď ţō ĺōàď ďēƥēńďēńćĩēś.",
|
||||
"failedToLoadDependencyCandidates": "Ƒàĩĺēď ţō ĺōàď ďēƥēńďēńćŷ ćàńďĩďàţēś.",
|
||||
"failedToLoadPreview": "Ƒàĩĺēď ţō ĺōàď ƥŕēvĩēŵ.",
|
||||
"failedToLoadRevision": "Ƒàĩĺēď ţō ĺōàď ŕēvĩśĩōń",
|
||||
"failedToLoadRevisionContent": "Ƒàĩĺēď ţō ĺōàď ŕēvĩśĩōń ćōńţēńţ",
|
||||
@@ -714,6 +718,7 @@
|
||||
"loadingCommandOutput": "Ĺōàďĩńĝ ćōḿḿàńď ōũţƥũţ...",
|
||||
"loadingCommit": "Ĺōàďĩńĝ ćōḿḿĩţ...",
|
||||
"loadingConversation": "Ĺōàďĩńĝ ćōńvēŕśàţĩōń...",
|
||||
"loadingDependencies": "Ĺōàďĩńĝ ďēƥēńďēńćĩēś...",
|
||||
"loadingEditor": "Ĺōàďĩńĝ ēďĩţōŕ...",
|
||||
"loadingFile": "Ĺōàďĩńĝ ƒĩĺē...",
|
||||
"loadingFiles": "Ĺōàďĩńĝ ƒĩĺēś...",
|
||||
|
||||
@@ -469,6 +469,8 @@
|
||||
"failedToLinkGithubPullRequest": "Não foi possível associar o pull request do GitHub.",
|
||||
"failedToLinkTarget": "Não foi possível associar {{target}}.",
|
||||
"failedToLoadDocuments": "Não foi possível carregar os documentos",
|
||||
"failedToLoadDependencies": "Não foi possível carregar as dependências.",
|
||||
"failedToLoadDependencyCandidates": "Não foi possível carregar as tarefas disponíveis como dependências.",
|
||||
"failedToLoadPreview": "Não foi possível carregar a pré-visualização.",
|
||||
"failedToLoadRevision": "Não foi possível carregar a revisão",
|
||||
"failedToLoadRevisionContent": "Não foi possível carregar o conteúdo da revisão",
|
||||
@@ -697,6 +699,7 @@
|
||||
"loadingCommandOutput": "A carregar a saída do comando...",
|
||||
"loadingCommit": "A carregar o commit...",
|
||||
"loadingConversation": "A carregar a conversa...",
|
||||
"loadingDependencies": "A carregar dependências...",
|
||||
"loadingEditor": "A carregar o editor...",
|
||||
"loadingFile": "A carregar o ficheiro...",
|
||||
"loadingFiles": "A carregar os ficheiros...",
|
||||
@@ -1800,6 +1803,7 @@
|
||||
"dependencyChangeRequestAria": "Pull request ou merge request associado #{{number}}",
|
||||
"dependencyChangeRequestLabel": "#{{number}}",
|
||||
"dependencyChipLabel": "Dependências da tarefa",
|
||||
"dependencyCycleError": "Ciclo de dependências: {{cycle}}",
|
||||
"dependencyCount_one": "{{count}} dependência",
|
||||
"dependencyCount_other": "{{count}} dependências",
|
||||
"dependencyCountBlockedBy_one": "{{count}} bloqueio",
|
||||
@@ -1808,6 +1812,7 @@
|
||||
"dependencyCountBlocks_other": "bloqueia {{count}}",
|
||||
"dependencyInfo": "Esta tarefa espera até que todas as tarefas selecionadas terminem com sucesso. Se iniciar um agente, ele arranca automaticamente quando todas as dependências estiverem resolvidas.",
|
||||
"dependencyInfoLabel": "Sobre dependências de tarefas",
|
||||
"dependencyUpdateFailed": "Não foi possível atualizar as dependências.",
|
||||
"dependsOn": "Depende de",
|
||||
"enhancedPromptCopiedToClipboard": "Prompt melhorado copiado para a área de transferência.",
|
||||
"enhancedPromptCouldNotBeCopied": "Não foi possível copiar o prompt melhorado.",
|
||||
|
||||
@@ -469,6 +469,8 @@
|
||||
"failedToLinkGithubPullRequest": "关联 GitHub 拉取请求失败。",
|
||||
"failedToLinkTarget": "关联 {{target}} 失败。",
|
||||
"failedToLoadDocuments": "加载文档失败",
|
||||
"failedToLoadDependencies": "加载依赖关系失败。",
|
||||
"failedToLoadDependencyCandidates": "加载依赖任务失败。",
|
||||
"failedToLoadPreview": "加载预览失败。",
|
||||
"failedToLoadRevision": "加载修订失败",
|
||||
"failedToLoadRevisionContent": "加载修订内容失败",
|
||||
@@ -696,6 +698,7 @@
|
||||
"loadingCommandOutput": "正在加载命令输出…",
|
||||
"loadingCommit": "正在加载提交…",
|
||||
"loadingConversation": "正在加载对话…",
|
||||
"loadingDependencies": "正在加载依赖关系…",
|
||||
"loadingEditor": "正在加载编辑器…",
|
||||
"loadingFile": "正在加载文件…",
|
||||
"loadingFiles": "正在加载文件…",
|
||||
@@ -1800,6 +1803,7 @@
|
||||
"dependencyChangeRequestAria": "关联的拉取请求或合并请求 #{{number}}",
|
||||
"dependencyChangeRequestLabel": "#{{number}}",
|
||||
"dependencyChipLabel": "任务依赖",
|
||||
"dependencyCycleError": "依赖关系循环:{{cycle}}",
|
||||
"dependencyCount_one": "{{count}} 个依赖",
|
||||
"dependencyCount_other": "{{count}} 个依赖",
|
||||
"dependencyCountBlockedBy_one": "{{count}} 个阻塞项",
|
||||
@@ -1808,6 +1812,7 @@
|
||||
"dependencyCountBlocks_other": "阻塞 {{count}} 项",
|
||||
"dependencyInfo": "该任务会一直等待,直到所选的每个任务都成功完成。如果你启动了智能体,它会在所有依赖解决后自动开始。",
|
||||
"dependencyInfoLabel": "关于任务依赖",
|
||||
"dependencyUpdateFailed": "更新依赖关系失败。",
|
||||
"dependsOn": "依赖于",
|
||||
"enhancedPromptCopiedToClipboard": "已将优化后的提示词复制到剪贴板。",
|
||||
"enhancedPromptCouldNotBeCopied": "无法复制优化后的提示词。",
|
||||
|
||||
@@ -469,6 +469,8 @@
|
||||
"failedToLinkGithubPullRequest": "關聯 GitHub 拉取要求失敗。",
|
||||
"failedToLinkTarget": "關聯 {{target}} 失敗。",
|
||||
"failedToLoadDocuments": "載入文檔失敗",
|
||||
"failedToLoadDependencies": "載入依賴關係失敗。",
|
||||
"failedToLoadDependencyCandidates": "載入依賴任務失敗。",
|
||||
"failedToLoadPreview": "載入預覽失敗。",
|
||||
"failedToLoadRevision": "載入修訂失敗",
|
||||
"failedToLoadRevisionContent": "載入修訂內容失敗",
|
||||
@@ -696,6 +698,7 @@
|
||||
"loadingCommandOutput": "正在載入命令輸出…",
|
||||
"loadingCommit": "正在載入提交…",
|
||||
"loadingConversation": "正在載入對話…",
|
||||
"loadingDependencies": "正在載入依賴關係…",
|
||||
"loadingEditor": "正在載入編輯器…",
|
||||
"loadingFile": "正在載入檔案…",
|
||||
"loadingFiles": "正在載入檔案…",
|
||||
@@ -1800,6 +1803,7 @@
|
||||
"dependencyChangeRequestAria": "關聯的拉取要求或合併請求 #{{number}}",
|
||||
"dependencyChangeRequestLabel": "#{{number}}",
|
||||
"dependencyChipLabel": "任務依賴",
|
||||
"dependencyCycleError": "依賴關係循環:{{cycle}}",
|
||||
"dependencyCount_one": "{{count}} 個依賴",
|
||||
"dependencyCount_other": "{{count}} 個依賴",
|
||||
"dependencyCountBlockedBy_one": "{{count}} 個阻塞項",
|
||||
@@ -1808,6 +1812,7 @@
|
||||
"dependencyCountBlocks_other": "阻塞 {{count}} 項",
|
||||
"dependencyInfo": "該任務會一直等待,直到所選的每個任務都成功完成。如果你啓動了代理程式,它會在所有依賴解決後自動開始。",
|
||||
"dependencyInfoLabel": "關於任務依賴",
|
||||
"dependencyUpdateFailed": "更新依賴關係失敗。",
|
||||
"dependsOn": "依賴於",
|
||||
"enhancedPromptCopiedToClipboard": "已將優化後的提示詞複製到剪貼板。",
|
||||
"enhancedPromptCouldNotBeCopied": "無法複製優化後的提示詞。",
|
||||
|
||||
@@ -469,6 +469,8 @@
|
||||
"failedToLinkGithubPullRequest": "關聯 GitHub 提取要求失敗。",
|
||||
"failedToLinkTarget": "關聯 {{target}} 失敗。",
|
||||
"failedToLoadDocuments": "載入檔案失敗",
|
||||
"failedToLoadDependencies": "載入依賴關係失敗。",
|
||||
"failedToLoadDependencyCandidates": "載入依賴任務失敗。",
|
||||
"failedToLoadPreview": "載入預覽失敗。",
|
||||
"failedToLoadRevision": "載入修訂失敗",
|
||||
"failedToLoadRevisionContent": "載入修訂內容失敗",
|
||||
@@ -696,6 +698,7 @@
|
||||
"loadingCommandOutput": "正在載入命令輸出…",
|
||||
"loadingCommit": "正在載入提交…",
|
||||
"loadingConversation": "正在載入對話…",
|
||||
"loadingDependencies": "正在載入依賴關係…",
|
||||
"loadingEditor": "正在載入編輯器…",
|
||||
"loadingFile": "正在載入檔案…",
|
||||
"loadingFiles": "正在載入檔案…",
|
||||
@@ -1800,6 +1803,7 @@
|
||||
"dependencyChangeRequestAria": "關聯的提取要求或合併請求 #{{number}}",
|
||||
"dependencyChangeRequestLabel": "#{{number}}",
|
||||
"dependencyChipLabel": "任務依賴",
|
||||
"dependencyCycleError": "依賴關係迴圈:{{cycle}}",
|
||||
"dependencyCount_one": "{{count}} 個依賴",
|
||||
"dependencyCount_other": "{{count}} 個依賴",
|
||||
"dependencyCountBlockedBy_one": "{{count}} 個阻塞項",
|
||||
@@ -1808,6 +1812,7 @@
|
||||
"dependencyCountBlocks_other": "阻塞 {{count}} 項",
|
||||
"dependencyInfo": "該任務會一直等待,直到所選的每個任務都成功完成。如果你啟動了代理程式,它會在所有依賴解決後自動開始。",
|
||||
"dependencyInfoLabel": "關於任務依賴",
|
||||
"dependencyUpdateFailed": "更新依賴關係失敗。",
|
||||
"dependsOn": "依賴於",
|
||||
"enhancedPromptCopiedToClipboard": "已將最佳化後的提示詞複製到剪貼簿。",
|
||||
"enhancedPromptCouldNotBeCopied": "無法複製最佳化後的提示詞。",
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
---
|
||||
created: 2026-09-03
|
||||
status: complete
|
||||
requirements:
|
||||
- REQ-TASKS-TASK-DEPENDENCY-DETAIL-EDITING-001
|
||||
system_design:
|
||||
- ../../specs/tasks/system-design/task-dependency-detail-editing.md
|
||||
legacy_specs: []
|
||||
---
|
||||
|
||||
# Implementation Plan: Edit task dependencies
|
||||
|
||||
## Overview
|
||||
|
||||
Add dependency selection to the existing Edit task dialog. Keep the task-detail
|
||||
dependency chip read-only. Add an atomic replacement route so Cancel and Update
|
||||
retain their existing meanings.
|
||||
|
||||
The implementation has two sequential work orders. Both use TDD.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- Add an atomic task-scoped dependency replacement route.
|
||||
- Search non-archived tasks in the edited task's workspace.
|
||||
- Show current predecessors as selected in the Edit task dialog.
|
||||
- Keep dependency selection as a draft until Update.
|
||||
- Preserve dependencies when the user cancels.
|
||||
- Add and remove direct predecessors through one replacement request.
|
||||
- Keep the dialog open for cycle and request errors.
|
||||
- Let existing WebSocket events update the Kanban board.
|
||||
- Keep the task-detail dependency chip read-only.
|
||||
- Provide desktop and touch layouts with the same user value.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Database migration, WebSocket, or MCP contract changes.
|
||||
- Multi-task dependency edits.
|
||||
- Cross-workspace edges.
|
||||
- Changes to deferred launch intent.
|
||||
- Changes to the Office task-properties picker.
|
||||
|
||||
## Technical approach
|
||||
|
||||
### Backend replacement contract
|
||||
|
||||
- Add `PUT /api/v1/tasks/:id/dependencies` with a complete predecessor list.
|
||||
- Authorize and validate the complete desired set before mutation.
|
||||
- Hold the existing dependency lock across validation and replacement.
|
||||
- Apply the edge diff in one SQL transaction.
|
||||
- Publish existing `task.updated` events for the edited task and changed peers.
|
||||
- Keep the existing add and remove routes unchanged.
|
||||
|
||||
### Edit task dialog
|
||||
|
||||
- Add the dependency field only in edit mode and outside the create-only
|
||||
advanced-settings disclosure.
|
||||
- Load the confirmed dependency projection when the dialog opens.
|
||||
- Disable Update until the projection load finishes.
|
||||
- Use `listTasksByWorkspace` for server-backed candidate search.
|
||||
- Exclude the edited task and archived tasks.
|
||||
- Keep selection changes in form state until Update.
|
||||
- Add a typed replacement function and structured cycle parsing to the API
|
||||
client.
|
||||
- Keep `TaskDependencyChip` unchanged in both task status rows.
|
||||
|
||||
### Desktop and mobile
|
||||
|
||||
- Reuse the current centered edit dialog on desktop.
|
||||
- Reuse the current full-screen edit dialog on phones.
|
||||
- Keep the task-switcher sheet behind the editor on tablets.
|
||||
- Reuse task-create picker rows for search and draft selection.
|
||||
- Use the form as the dialog's single vertical scroll owner.
|
||||
- Keep touch controls at least 44 CSS pixels in the active dimension.
|
||||
- Keep the document free of horizontal overflow.
|
||||
|
||||
### Localization
|
||||
|
||||
- Add editor labels, loading states, empty states, and errors to the task namespace.
|
||||
- Update `en`, `pt-pt`, `zh-cn`, `zh-hk`, and `zh-tw` catalogs.
|
||||
- Generate the pseudo locale and both Traditional Chinese catalogs with repository scripts.
|
||||
|
||||
## Tests
|
||||
|
||||
| Acceptance criteria | Evidence |
|
||||
| --- | --- |
|
||||
| `AC-TASKS-TASK-DEPENDENCY-DETAIL-EDITING-001.1` through `.3` | Dialog component tests for edit-mode visibility, initialization, and candidate filtering |
|
||||
| `AC-TASKS-TASK-DEPENDENCY-DETAIL-EDITING-001.4` and `.5` | Form-state tests and desktop cancel E2E |
|
||||
| `AC-TASKS-TASK-DEPENDENCY-DETAIL-EDITING-001.6` | Repository, service, and handler tests for atomic full-set replacement |
|
||||
| `AC-TASKS-TASK-DEPENDENCY-DETAIL-EDITING-001.7` | API client, dialog submit, WebSocket, and desktop E2E evidence |
|
||||
| `AC-TASKS-TASK-DEPENDENCY-DETAIL-EDITING-001.8` and `.9` | Backend rollback tests, dialog error tests, and desktop cycle E2E |
|
||||
| `AC-TASKS-TASK-DEPENDENCY-DETAIL-EDITING-001.10` and `.11` | Existing entry-point and dependency-chip tests plus focused regression tests |
|
||||
| `AC-TASKS-TASK-DEPENDENCY-DETAIL-EDITING-001.12` through `.14` | Desktop and mobile Playwright tests |
|
||||
| `AC-TASKS-TASK-DEPENDENCY-DETAIL-EDITING-001.15` | Backend authorization, cycle, and deferred-launch regression tests |
|
||||
|
||||
## E2E tests
|
||||
|
||||
- Extend `apps/web/e2e/tests/task/create-task-dependency-selector.spec.ts` for
|
||||
desktop edit, update, cancel, and archived-task filtering.
|
||||
- Extend `apps/web/e2e/tests/task/mobile-create-task-dependency-selector.spec.ts`
|
||||
for touch edit, update, dialog geometry, and overflow.
|
||||
- Use the `chromium` project for desktop and `mobile-chrome` for mobile.
|
||||
|
||||
## Work orders
|
||||
|
||||
- [x] [Task 01: Add atomic dependency replacement](task-01-enable-detail-editing.md)
|
||||
- [x] [Task 02: Add dependencies to Edit task](task-02-add-edit-dialog-dependencies.md)
|
||||
|
||||
## Verification results
|
||||
|
||||
Implemented and verified.
|
||||
|
||||
- Backend repository, service, and handler tests pass, including transaction
|
||||
rollback, authorization, cycle rejection, and update publication.
|
||||
- Frontend focused tests pass with 68 tests across the edit dialog, submit
|
||||
flow, API client, dependency hook, and footer behavior.
|
||||
- TypeScript typecheck, full frontend lint, i18n completeness, i18n ratchet,
|
||||
and the full backend test and lint suites pass.
|
||||
- The desktop dependency-selector suite passes with 2 tests, and the mobile
|
||||
suite passes with 2 tests through the managed production E2E runner.
|
||||
- Fresh desktop and mobile PR screenshots were captured, inspected, and
|
||||
compressed from the same E2E flows.
|
||||
|
||||
## Risks
|
||||
|
||||
- The task-field update and dependency replacement are separate HTTP requests.
|
||||
A dependency error must keep the dialog open and reload confirmed task fields.
|
||||
- Candidate search can show stale results if a request from an old query wins a race.
|
||||
- The cycle path contains task IDs. The UI must keep the error readable without changing server data.
|
||||
@@ -0,0 +1,114 @@
|
||||
---
|
||||
id: "01-enable-detail-editing"
|
||||
title: "Add atomic dependency replacement"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-TASKS-TASK-DEPENDENCY-DETAIL-EDITING-001
|
||||
acceptance_criteria:
|
||||
- AC-TASKS-TASK-DEPENDENCY-DETAIL-EDITING-001.6
|
||||
- AC-TASKS-TASK-DEPENDENCY-DETAIL-EDITING-001.7
|
||||
- AC-TASKS-TASK-DEPENDENCY-DETAIL-EDITING-001.8
|
||||
- AC-TASKS-TASK-DEPENDENCY-DETAIL-EDITING-001.9
|
||||
- AC-TASKS-TASK-DEPENDENCY-DETAIL-EDITING-001.15
|
||||
system_design:
|
||||
- ../../specs/tasks/system-design/task-dependency-detail-editing.md
|
||||
---
|
||||
|
||||
# Task 01: Add atomic dependency replacement
|
||||
|
||||
## Summary
|
||||
|
||||
Add a task-scoped route that replaces one task's complete predecessor set as an
|
||||
atomic dependency operation.
|
||||
|
||||
## In scope
|
||||
|
||||
- Add the full-set replacement handler and response contract.
|
||||
- Authorize the edited task and every submitted predecessor.
|
||||
- Validate empty, duplicate, self, cross-workspace, and cyclic inputs.
|
||||
- Hold the dependency lock across validation and replacement.
|
||||
- Apply the edge diff in one SQL transaction.
|
||||
- Publish task updates for the edited task and every added or removed peer.
|
||||
- Add repository, service, and handler tests.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Frontend changes.
|
||||
- Multi-task dependency edits.
|
||||
- Changes to the existing one-edge add and remove routes.
|
||||
- Changes to `start_when_unblocked`.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- A successful request replaces the exact predecessor set and returns its
|
||||
dependency projection.
|
||||
- Validation and storage errors leave the prior set unchanged.
|
||||
- Existing authorization, cycle prevention, deferred launch, and event behavior
|
||||
remain in force.
|
||||
|
||||
## TDD sequence
|
||||
|
||||
1. Add failing repository transaction tests.
|
||||
2. Add failing service validation and publication tests.
|
||||
3. Add failing HTTP contract tests.
|
||||
4. Implement the repository, service, and handler changes.
|
||||
5. Run the focused tests and refactor with all tests green.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
make -C apps/backend test
|
||||
make -C apps/backend lint
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/office/repository/sqlite/blockers.go`
|
||||
- `apps/backend/internal/office/repository/sqlite/blockers_test.go`
|
||||
- `apps/backend/internal/task/service/service_office.go`
|
||||
- `apps/backend/internal/task/service/service_dependencies.go`
|
||||
- `apps/backend/internal/task/service/service_dependencies_test.go`
|
||||
- `apps/backend/internal/task/handlers/task_dependency_handlers.go`
|
||||
- `apps/backend/internal/task/handlers/task_dependency_handlers_test.go`
|
||||
- `apps/backend/internal/task/handlers/task_handlers.go`
|
||||
|
||||
## Dependencies
|
||||
|
||||
None. The core dependency service and persistence table already exist.
|
||||
|
||||
## Risks
|
||||
|
||||
- The replacement route must not publish before the storage transaction commits.
|
||||
- Adding a method to `BlockerRepository` requires updates to its test doubles.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Inputs
|
||||
|
||||
- `docs/specs/tasks/requirements/task-dependency-detail-editing.md`
|
||||
- `docs/specs/tasks/system-design/task-dependency-detail-editing.md`
|
||||
- `apps/web/components/task/task-dependency-chip.tsx`
|
||||
- `apps/backend/internal/task/handlers/task_dependency_handlers.go`
|
||||
- `apps/backend/internal/task/service/service_dependencies.go`
|
||||
|
||||
## Results
|
||||
|
||||
Implemented the task-scoped replacement contract while preserving the existing
|
||||
one-edge routes. The service validates the complete predecessor set while
|
||||
holding the dependency lock, the repository applies the edge diff in one SQL
|
||||
transaction, and the handler returns the existing dependency projection. Task
|
||||
updates publish for the edited task and changed peers after a successful commit.
|
||||
|
||||
Validation passed:
|
||||
|
||||
- `go test ./internal/office/repository/sqlite ./internal/task/service`
|
||||
- `go test ./internal/task/handlers`
|
||||
- `env -u KANDEV_INTERNAL_CONFIG_FILE -u KANDEV_INTERNAL_CONFIG_HOME_FILE make -C apps/backend test`
|
||||
- `make -C apps/backend lint`
|
||||
- `git diff --check`
|
||||
@@ -0,0 +1,153 @@
|
||||
---
|
||||
id: "02-add-edit-dialog-dependencies"
|
||||
title: "Add dependencies to Edit task"
|
||||
status: done
|
||||
wave: 2
|
||||
depends_on:
|
||||
- "01-enable-detail-editing"
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-TASKS-TASK-DEPENDENCY-DETAIL-EDITING-001
|
||||
acceptance_criteria:
|
||||
- AC-TASKS-TASK-DEPENDENCY-DETAIL-EDITING-001.1
|
||||
- AC-TASKS-TASK-DEPENDENCY-DETAIL-EDITING-001.2
|
||||
- AC-TASKS-TASK-DEPENDENCY-DETAIL-EDITING-001.3
|
||||
- AC-TASKS-TASK-DEPENDENCY-DETAIL-EDITING-001.4
|
||||
- AC-TASKS-TASK-DEPENDENCY-DETAIL-EDITING-001.5
|
||||
- AC-TASKS-TASK-DEPENDENCY-DETAIL-EDITING-001.7
|
||||
- AC-TASKS-TASK-DEPENDENCY-DETAIL-EDITING-001.8
|
||||
- AC-TASKS-TASK-DEPENDENCY-DETAIL-EDITING-001.9
|
||||
- AC-TASKS-TASK-DEPENDENCY-DETAIL-EDITING-001.10
|
||||
- AC-TASKS-TASK-DEPENDENCY-DETAIL-EDITING-001.11
|
||||
- AC-TASKS-TASK-DEPENDENCY-DETAIL-EDITING-001.12
|
||||
- AC-TASKS-TASK-DEPENDENCY-DETAIL-EDITING-001.13
|
||||
- AC-TASKS-TASK-DEPENDENCY-DETAIL-EDITING-001.14
|
||||
system_design:
|
||||
- ../../specs/tasks/system-design/task-dependency-detail-editing.md
|
||||
---
|
||||
|
||||
# Task 02: Add dependencies to Edit task
|
||||
|
||||
## Summary
|
||||
|
||||
Add a searchable dependency field to the shared Edit task dialog. Stage changes
|
||||
until Update and submit the complete predecessor draft through the replacement
|
||||
route from Task 01.
|
||||
|
||||
## In scope
|
||||
|
||||
- Load the edited task's confirmed dependency projection.
|
||||
- Search non-archived tasks in the edited task's workspace.
|
||||
- Show current predecessors as selected and exclude the edited task.
|
||||
- Keep picker changes in dialog form state until Update.
|
||||
- Keep dependencies unchanged on Cancel.
|
||||
- Keep the dialog open and preserve the draft after cycle or request errors.
|
||||
- Keep the field available when the edited task has started.
|
||||
- Keep the task-detail dependency chip read-only.
|
||||
- Add localized copy in all required catalogs.
|
||||
- Add API client, component, submit, desktop E2E, and mobile E2E coverage.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Changes to the Office task-properties picker.
|
||||
- Inline dependency mutation from the task-detail chip.
|
||||
- Multi-task dependency editing.
|
||||
- Changes to `start_when_unblocked`.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Every existing Edit task entry point opens the same dependency field.
|
||||
- Update submits the desired predecessor set and Cancel sends no replacement.
|
||||
- Cycle and request errors preserve confirmed dependencies and permit a retry.
|
||||
- Desktop, phone, and tablet paths provide the same user outcome.
|
||||
|
||||
## TDD sequence
|
||||
|
||||
1. Add failing API client and dialog state tests.
|
||||
2. Add failing submit tests for update, cancel, and error retention.
|
||||
3. Add failing desktop and mobile Playwright scenarios.
|
||||
4. Implement the smallest shared picker, state, and submit changes.
|
||||
5. Run the focused tests and refactor with all tests green.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm --filter @kandev/web test -- components/task-create-dialog.test.tsx components/task-create-dialog-submit.test.tsx components/task-edit-dialog-dependencies.test.tsx lib/api/domains/task-dependencies-api.test.ts hooks/domains/task/use-task-edit-dialog-dependencies.test.ts components/task-create-dialog-footer.test.ts
|
||||
pnpm --filter @kandev/web run typecheck
|
||||
pnpm run lint
|
||||
cd web
|
||||
pnpm run i18n:check
|
||||
pnpm run i18n:ratchet
|
||||
pnpm e2e:run --host --no-build --project chromium tests/task/create-task-dependency-selector.spec.ts
|
||||
pnpm e2e:run --host --no-build --project mobile-chrome tests/task/mobile-create-task-dependency-selector.spec.ts
|
||||
cd ../..
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/lib/api/domains/task-dependencies-api.ts`
|
||||
- `apps/web/lib/api/domains/task-dependencies-api.test.ts`
|
||||
- `apps/web/components/task-create-dialog.tsx`
|
||||
- `apps/web/components/task-create-dialog-types.ts`
|
||||
- `apps/web/components/task-create-dialog-state.ts`
|
||||
- `apps/web/components/task-create-dialog-setup.ts`
|
||||
- `apps/web/components/task-create-dialog-prop-builders.ts`
|
||||
- `apps/web/components/task-create-dialog-submit.tsx`
|
||||
- `apps/web/components/task-create-dialog-submit.test.tsx`
|
||||
- `apps/web/components/task-create-dialog-dependencies.tsx`
|
||||
- `apps/web/components/task-edit-dialog-dependencies.tsx`
|
||||
- `apps/web/components/task-edit-dialog-dependencies.test.tsx`
|
||||
- `apps/web/src/locales/en/task.json`
|
||||
- `apps/web/src/locales/pt-pt/task.json`
|
||||
- `apps/web/src/locales/zh-cn/task.json`
|
||||
- `apps/web/src/locales/zh-hk/task.json`
|
||||
- `apps/web/src/locales/zh-tw/task.json`
|
||||
- `apps/web/src/locales/pseudo/task.json`
|
||||
- `apps/web/e2e/tests/task/task-dependencies.spec.ts`
|
||||
- `apps/web/e2e/tests/task/mobile-sidebar-task-actions.spec.ts`
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 01 supplies the atomic replacement route.
|
||||
|
||||
## Risks
|
||||
|
||||
- Task-field and dependency updates use separate HTTP requests. A dependency
|
||||
error must reload confirmed task fields while it keeps the dialog open.
|
||||
- Candidate search responses can arrive out of order.
|
||||
- A nested popover must remain inside the full-screen phone dialog.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Inputs
|
||||
|
||||
- `docs/specs/tasks/requirements/task-dependency-detail-editing.md`
|
||||
- `docs/specs/tasks/system-design/task-dependency-detail-editing.md`
|
||||
- `apps/web/components/task-create-dialog.tsx`
|
||||
- `apps/web/components/task-create-dialog-dependencies.tsx`
|
||||
- `apps/web/components/task/task-session-sidebar-edit.tsx`
|
||||
- `apps/web/lib/api/domains/task-dependencies-api.ts`
|
||||
|
||||
## Results
|
||||
|
||||
Implemented the shared Edit task dependency field and the typed replacement
|
||||
flow. The field loads confirmed predecessors, searches non-archived workspace
|
||||
tasks, retains draft selection through Cancel or request errors, and appears in
|
||||
the existing desktop and mobile Edit entry points. Cycle errors keep the dialog
|
||||
open with the draft available for correction. The task-detail dependency chip
|
||||
remains read-only.
|
||||
|
||||
Validation passed:
|
||||
|
||||
- 68 focused web tests passed across 6 files.
|
||||
- TypeScript typecheck, full web lint, i18n completeness, and i18n ratchet passed.
|
||||
- The desktop managed E2E suite passed with 2 tests.
|
||||
- The mobile managed E2E suite passed with 2 tests.
|
||||
- Capture-enabled desktop and mobile E2E runs produced inspected, compressed
|
||||
PR assets with a complete manifest.
|
||||
- `git diff --check` passed.
|
||||
@@ -88,6 +88,7 @@ signals, and task-scoped scheduling contracts.
|
||||
- [Task Create Workflow Memory](requirements/task-create-workflow-memory.md)
|
||||
- [Task-create advanced settings disclosure](requirements/task-dependencies-create-dialog-advanced-settings.md)
|
||||
- [Task-create dependency selector refinement](requirements/task-dependencies-create-dialog-dependency-selector.md)
|
||||
- [Edit task dependencies](requirements/task-dependency-detail-editing.md)
|
||||
- [Task Dependencies and Auto-Start Chains](requirements/task-dependencies.md)
|
||||
- [Task Launch Failure Recovery](requirements/task-launch-failure-recovery.md)
|
||||
- [Task Title Length Limit](requirements/title-length-limit.md)
|
||||
@@ -142,6 +143,7 @@ signals, and task-scoped scheduling contracts.
|
||||
- [Queued Run Scheduling](system-design/run-scheduling.md)
|
||||
- [Session Delete Preserves Task Workspaces](system-design/session-delete-resource-cleanup.md)
|
||||
- [Task Dependencies and Auto-Start Chains](system-design/task-dependencies.md)
|
||||
- [Edit task dependencies](system-design/task-dependency-detail-editing.md)
|
||||
- [Task Launch Failure Recovery](system-design/task-launch-failure-recovery.md)
|
||||
- [WIP Limits and Visible Overflow Queues](system-design/wip-limit-pull-system.md)
|
||||
- [Workflow quorum decision recording](system-design/workflow-quorum-decision-recording.md)
|
||||
|
||||
@@ -56,9 +56,10 @@ place for dependency selection and future less-common options.
|
||||
advanced controls can be appended inside that region without adding another
|
||||
top-level row to the dialog. Do not add placeholder future controls in this
|
||||
change.
|
||||
- Show the disclosure only for an unstarted task in create mode, matching the
|
||||
dependency selector's existing availability. Session mode, edit mode, and
|
||||
started-task forms do not gain a new advanced-settings row.
|
||||
- Show the disclosure only for an unstarted task in create mode. Session mode,
|
||||
edit mode, and started-task forms do not gain this advanced-settings row.
|
||||
Edit-mode dependency behavior is owned by
|
||||
[`task-dependency-detail-editing.md`](task-dependency-detail-editing.md).
|
||||
- Keep all new trigger copy localized through the task translation namespace.
|
||||
|
||||
## Interaction contract
|
||||
@@ -135,7 +136,7 @@ place for dependency selection and future less-common options.
|
||||
workflow's existing behavior is preserved and the advanced dependency
|
||||
control remains available under the same create-mode rules.
|
||||
- **GIVEN** session mode, edit mode, or a started task, **WHEN** the form
|
||||
renders, **THEN** no advanced-settings disclosure is added.
|
||||
renders, **THEN** this create-only advanced-settings disclosure is absent.
|
||||
- **GIVEN** a mobile create dialog, **WHEN** the user taps the disclosure and
|
||||
opens the dependency picker, **THEN** the controls remain reachable, the
|
||||
picker is contained in the viewport, and the document has no horizontal
|
||||
@@ -150,7 +151,7 @@ place for dependency selection and future less-common options.
|
||||
archived-task filtering, or automatic task-start behavior.
|
||||
- Changing the dependency selector's search, task icons, info help, or picker
|
||||
interaction except where needed to mount it inside the disclosure.
|
||||
- Adding dependency editing to task detail, Kanban card menus, or MCP.
|
||||
- Adding dependency editing as part of this advanced-settings refinement.
|
||||
- Adding or designing future advanced controls beyond the extensible content
|
||||
region.
|
||||
- Changing workflow, agent, executor, repository, or branch selector behavior.
|
||||
- Changing workflow, agent, executor, repository, or branch selector behavior.
|
||||
|
||||
+5
-4
@@ -65,8 +65,9 @@ set of tasks.
|
||||
unchanged. Selecting several predecessors keeps the existing AND semantics.
|
||||
- The dependency selector is disabled while the create flow is creating a
|
||||
session, matching the current dependency control behavior.
|
||||
- The selector is available only for an unstarted task in create mode. It does
|
||||
not add an edge editor to task detail or edit mode.
|
||||
- This create-flow selector is available only for an unstarted task in create
|
||||
mode. Edit-mode behavior is owned by
|
||||
[`task-dependency-detail-editing.md`](task-dependency-detail-editing.md).
|
||||
- All new trigger labels, search copy, empty states, count labels, and help
|
||||
copy use the task translation namespace. No new user-facing literal is
|
||||
hardcoded in a component.
|
||||
@@ -133,6 +134,6 @@ set of tasks.
|
||||
- Changing the dependency graph, auto-start semantics, or backend/API payload.
|
||||
- Limiting a task to one predecessor. Multiple predecessors and AND semantics
|
||||
remain supported.
|
||||
- Adding dependency editing to task detail, Kanban card menus, or MCP.
|
||||
- Adding dependency editing as part of this create-dialog refinement.
|
||||
- Adding a new mobile navigation surface or replacing the task-create dialog.
|
||||
- Changing workflow, agent, executor, repository, or branch selector behavior.
|
||||
- Changing workflow, agent, executor, repository, or branch selector behavior.
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
---
|
||||
status: active
|
||||
system: tasks
|
||||
created: 2026-09-03
|
||||
owners:
|
||||
- kandev
|
||||
---
|
||||
|
||||
# Edit task dependencies Requirements
|
||||
|
||||
## Overview
|
||||
|
||||
The task system owns dependency edges and their effect on task execution. Users
|
||||
need to change these edges when work plans change after task creation.
|
||||
|
||||
## Terminology
|
||||
|
||||
- **Predecessor:** A task that must complete successfully before the dependent task is unblocked.
|
||||
- **Dependent task:** The open task that waits for one or more predecessors.
|
||||
|
||||
## Requirements
|
||||
|
||||
### REQ-TASKS-TASK-DEPENDENCY-DETAIL-EDITING-001: Manage dependencies in the Edit task dialog
|
||||
|
||||
**Intent:** Let users correct task order from the existing task editor without task recreation or direct database changes.
|
||||
|
||||
**User story:** As a developer, I want to change task dependencies after creation, so that the task graph matches the current work plan.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-TASKS-TASK-DEPENDENCY-DETAIL-EDITING-001.1:** The existing Edit task dialog shall show a dependency field for every non-archived task, including a task that has started.
|
||||
- **AC-TASKS-TASK-DEPENDENCY-DETAIL-EDITING-001.2:** The field shall show each current predecessor as selected and shall show `No dependency` when the set is empty.
|
||||
- **AC-TASKS-TASK-DEPENDENCY-DETAIL-EDITING-001.3:** The picker shall search non-archived tasks in the edited task's workspace and shall exclude the edited task.
|
||||
- **AC-TASKS-TASK-DEPENDENCY-DETAIL-EDITING-001.4:** Picker changes shall remain draft changes until the user activates Update.
|
||||
- **AC-TASKS-TASK-DEPENDENCY-DETAIL-EDITING-001.5:** When the user cancels the dialog, the system shall not change the dependency set.
|
||||
- **AC-TASKS-TASK-DEPENDENCY-DETAIL-EDITING-001.6:** Update shall replace the direct predecessor set as one atomic dependency operation.
|
||||
- **AC-TASKS-TASK-DEPENDENCY-DETAIL-EDITING-001.7:** After a successful update, the task editor and Kanban board shall show the new blocked state without a reload.
|
||||
- **AC-TASKS-TASK-DEPENDENCY-DETAIL-EDITING-001.8:** When the replacement creates a cycle, the dialog shall stay open, show the cycle error, preserve the confirmed dependency set, and keep the draft available for correction.
|
||||
- **AC-TASKS-TASK-DEPENDENCY-DETAIL-EDITING-001.9:** When another replacement error occurs, the dialog shall stay open, preserve the confirmed dependency set, and permit a retry.
|
||||
- **AC-TASKS-TASK-DEPENDENCY-DETAIL-EDITING-001.10:** Existing Edit actions on Kanban cards, desktop task menus, and touch task menus shall open the same dependency editor.
|
||||
- **AC-TASKS-TASK-DEPENDENCY-DETAIL-EDITING-001.11:** The task-detail dependency chip shall remain read-only. Its reverse `blocks` direction shall remain readable and navigable.
|
||||
- **AC-TASKS-TASK-DEPENDENCY-DETAIL-EDITING-001.12:** Desktop and touch layouts shall provide the same search, selection, save, cancel, and error outcomes.
|
||||
- **AC-TASKS-TASK-DEPENDENCY-DETAIL-EDITING-001.13:** Touch controls and task rows shall have a target of at least 44 CSS pixels in the active dimension.
|
||||
- **AC-TASKS-TASK-DEPENDENCY-DETAIL-EDITING-001.14:** The mobile dialog shall use one form scroll region and shall not add document-level horizontal overflow.
|
||||
- **AC-TASKS-TASK-DEPENDENCY-DETAIL-EDITING-001.15:** Dependency edits shall keep the existing workspace authorization, cycle prevention, and auto-start behavior.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Bulk dependency edits from the Kanban selection toolbar.
|
||||
- Cross-workspace dependencies.
|
||||
- Changes to `start_when_unblocked` after task creation.
|
||||
- Editable attributes on an edge. An edge has only add and remove operations.
|
||||
- Changes to the Office task-properties dependency picker.
|
||||
- Inline mutation controls in the task-detail dependency chip.
|
||||
@@ -69,9 +69,9 @@ running agents unattended.
|
||||
start the dependent manually). Kandev never auto-retries a failed
|
||||
predecessor and never silently drops the edge.
|
||||
- Users declare dependencies in the **task-create dialog** ("Depends on") and
|
||||
manage them afterwards over MCP. The task detail view deliberately has no edge
|
||||
editor: dependencies describe how work was planned, and the surfaces that read
|
||||
them (card badge, chip, graph) stay read-only.
|
||||
manage them afterwards from the **Edit task dialog** or over MCP. The edit
|
||||
dialog behavior is defined in
|
||||
[`task-dependency-detail-editing.md`](task-dependency-detail-editing.md).
|
||||
- An open task SHALL show a **dependency chip** in the status row directly above
|
||||
its chat composer, alongside the PR status chip. The chip reports both
|
||||
directions — the tasks this task is blocked by, and the tasks it blocks — and
|
||||
@@ -571,9 +571,6 @@ MCP:
|
||||
A chain is a set of edges between concrete tasks in v1.
|
||||
- Gantt or timeline rendering, and estimated-duration modelling.
|
||||
- Editing dependencies from the multi-select toolbar or via bulk operations.
|
||||
- An edge editor on the task detail view. Declaring dependencies is a planning
|
||||
act that belongs to task creation (or to MCP for an agent decomposing work);
|
||||
the task view only reports them.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
---
|
||||
status: current
|
||||
system: tasks
|
||||
requirements:
|
||||
- REQ-TASKS-TASK-DEPENDENCY-DETAIL-EDITING-001
|
||||
created: 2026-09-03
|
||||
owners:
|
||||
- kandev
|
||||
---
|
||||
|
||||
# Edit task dependencies System Design
|
||||
|
||||
## Purpose and boundaries
|
||||
|
||||
This design adds dependency selection to the existing Edit task dialog. The
|
||||
task-detail dependency chip stays read-only. The core dependency graph and
|
||||
launch rules remain unchanged.
|
||||
|
||||
The task system owns this design because it owns dependency state, permissions, and lifecycle effects. The UI provides the interaction surface only.
|
||||
|
||||
## Requirement mapping
|
||||
|
||||
| Requirement | Design section |
|
||||
| --- | --- |
|
||||
| `REQ-TASKS-TASK-DEPENDENCY-DETAIL-EDITING-001` | [Components and responsibilities](#components-and-responsibilities), [Control flow](#control-flow), [Failure behavior](#failure-behavior), [Responsive behavior](#responsive-behavior) |
|
||||
|
||||
## Existing contracts
|
||||
|
||||
The implementation keeps the one-edge contracts in
|
||||
[`task-dependencies.md`](task-dependencies.md):
|
||||
|
||||
- `POST /api/v1/tasks/:id/dependencies` adds one predecessor.
|
||||
- `DELETE /api/v1/tasks/:id/dependencies/:depId` removes one predecessor.
|
||||
- Both routes authorize both task IDs and reject cross-workspace access.
|
||||
- The add route rejects cycles with HTTP `409` and a `cycle` array.
|
||||
- A mutation publishes `task.updated` for both ends of the edge.
|
||||
- A remove operation never consumes a deferred launch intent.
|
||||
|
||||
It adds one task-scoped replacement contract:
|
||||
|
||||
- `PUT /api/v1/tasks/:id/dependencies` accepts
|
||||
`{ "depends_on_task_ids": ["task-id"] }`.
|
||||
- The route replaces the complete direct predecessor set for one task.
|
||||
- An empty list removes every direct predecessor.
|
||||
- The response uses the existing dependency mutation projection.
|
||||
|
||||
The replacement is one atomic dependency operation. The service validates the
|
||||
complete desired set before storage changes. Storage applies the edge diff in
|
||||
one transaction. A validation or storage error leaves the prior dependency set
|
||||
unchanged.
|
||||
|
||||
No database migration or WebSocket event change is necessary. The one-edge
|
||||
routes remain available for Office, MCP, and other callers.
|
||||
|
||||
### Replacement rationale
|
||||
|
||||
The Edit task dialog stages several fields and applies them through Update.
|
||||
Immediate one-edge mutations make Cancel misleading. A sequence of one-edge
|
||||
requests can also stop after only part of a multi-selection succeeds. The
|
||||
full-set route keeps the dependency part of the update atomic while it leaves
|
||||
the established one-edge contracts intact.
|
||||
|
||||
## Components and responsibilities
|
||||
|
||||
### API client
|
||||
|
||||
`apps/web/lib/api/domains/task-dependencies-api.ts` owns the typed replacement
|
||||
function. It returns the dependency projection from the mutation response. The
|
||||
module also parses the structured cycle response.
|
||||
|
||||
The picker uses `listTasksByWorkspace` from `kanban-api.ts`. It sends the active
|
||||
search text to the server and requests non-archived tasks only.
|
||||
|
||||
### Dependency service and storage
|
||||
|
||||
`Service.ReplaceDependencies` authorizes the edited task and every desired
|
||||
predecessor. It rejects an empty ID, duplicate ID, self-edge, inaccessible task,
|
||||
cross-workspace edge, and cycle.
|
||||
|
||||
The existing process-wide dependency lock covers validation and replacement.
|
||||
The production blocker repository replaces the edge diff in one SQL
|
||||
transaction. It preserves unchanged edges and their creation times.
|
||||
|
||||
After commit, the service publishes `task.updated` for the edited task and for
|
||||
every predecessor that was added or removed. Publication remains outside the
|
||||
lock.
|
||||
|
||||
### Edit task dialog
|
||||
|
||||
`TaskCreateDialog` remains the shared create, session, and edit shell. A focused
|
||||
dependency field mounts only in edit mode. It stays outside the create-only
|
||||
advanced-settings disclosure.
|
||||
|
||||
When the dialog opens, the field loads the confirmed dependency projection for
|
||||
the edited task. It initializes a draft list from `depends_on`. The Update
|
||||
actions stay unavailable until this load finishes.
|
||||
|
||||
The field owns these functions:
|
||||
|
||||
- show current predecessors as selected
|
||||
- search non-archived tasks in the edited task's workspace
|
||||
- add or remove a predecessor from the draft
|
||||
- show loading, empty, and error states.
|
||||
|
||||
The picker excludes the edited task. The server remains the authority for
|
||||
workspace and cycle validation. The same field remains available for started
|
||||
tasks even when the prompt and workspace-source fields are locked.
|
||||
|
||||
The current task-detail chip stays read-only and keeps its present visibility
|
||||
rule. A task with no dependency edges has no chip. Users enter the editor from
|
||||
the existing Edit actions.
|
||||
|
||||
### Shared picker presentation
|
||||
|
||||
The create and edit flows share the searchable dependency picker presentation.
|
||||
They keep separate state and save behavior. Create mode still writes
|
||||
`blocked_by` during task creation. Edit mode submits the complete draft through
|
||||
the replacement route.
|
||||
|
||||
The picker sends search text to `listTasksByWorkspace`. It ignores stale search
|
||||
responses and never treats a partial page as the complete workspace task set.
|
||||
|
||||
### State convergence
|
||||
|
||||
The successful replacement response updates the dialog's confirmed projection.
|
||||
The existing `task.updated` events then update the Kanban caches for all changed
|
||||
tasks.
|
||||
|
||||
The local response state is temporary. When the store contains the same
|
||||
projection, the component uses the store projection again.
|
||||
|
||||
If the store has no dependency projection, `getTaskDependencies` remains the
|
||||
read fallback. An omitted projection does not mean that the task has no edges.
|
||||
|
||||
## Control flow
|
||||
|
||||
1. The user activates an existing Edit action.
|
||||
2. The dialog loads the task's dependency projection and workspace candidates.
|
||||
3. The user searches and changes the selected predecessor draft.
|
||||
4. Cancel closes the dialog without a dependency request.
|
||||
5. Update first runs the existing task-field update.
|
||||
6. If the dependency draft changed, the client calls the replacement route.
|
||||
7. The replacement response updates the dialog's confirmed projection.
|
||||
8. WebSocket events update all affected task projections in board stores.
|
||||
9. The dialog closes after every required update succeeds.
|
||||
|
||||
The task-field update and dependency replacement remain separate HTTP
|
||||
operations. If the task-field update succeeds and dependency replacement fails,
|
||||
the dialog stays open and reloads the confirmed task fields. The dependency set
|
||||
does not change. This keeps the new dependency operation atomic without
|
||||
expanding the task update transaction and its repository replacement behavior.
|
||||
|
||||
## Failure behavior
|
||||
|
||||
For HTTP `409`, the dialog translates the cycle path and shows it in the normal
|
||||
error surface. The dialog does not close or change the confirmed edge list. It
|
||||
keeps the draft so the user can remove the invalid selection.
|
||||
|
||||
For all other replacement errors, the dialog shows localized error copy. It
|
||||
keeps the prior projection, preserves the draft, and enables Update again.
|
||||
|
||||
An empty or failed candidate search shows a localized state. It does not alter
|
||||
dependency data.
|
||||
|
||||
## Security
|
||||
|
||||
The picker requests tasks for the edited task's workspace. The server authorizes
|
||||
the edited task and every submitted predecessor before replacement. It returns
|
||||
not-found behavior for inaccessible tasks.
|
||||
|
||||
The client does not infer permission from the picker contents. It treats the
|
||||
server response as authoritative.
|
||||
|
||||
## Responsive behavior
|
||||
|
||||
Desktop uses the current centered Edit task dialog with its contained searchable
|
||||
popover. Phones use the existing full-screen Edit task dialog. Tablets keep the
|
||||
task-switcher sheet behind the same editor.
|
||||
|
||||
The nearest mobile exemplars are `TaskCreateDialog` and
|
||||
`session-task-switcher-sheet.tsx`. The phone task-actions menu closes before the
|
||||
editor opens. The dialog form remains the single vertical scroll owner. The
|
||||
dependency command list owns scrolling only while its contained popover is
|
||||
open.
|
||||
|
||||
Touch actions and task rows use a target of at least 44 CSS pixels. Long titles
|
||||
truncate inside the surface. The document does not gain horizontal scrolling.
|
||||
|
||||
The editor shares loading, candidate search, draft selection, submission, and
|
||||
error logic across layouts. Existing dialog breakpoints own the presentation.
|
||||
|
||||
## Test strategy
|
||||
|
||||
Backend tests cover full-set validation, atomic replacement, cycle errors,
|
||||
authorization, rollback, and publication for added and removed predecessors.
|
||||
|
||||
Frontend tests cover edit-mode initialization, filtering, draft changes, cancel,
|
||||
successful replacement, cycle errors, request errors, and started-task access.
|
||||
|
||||
Desktop Playwright coverage proves edit, update, live blocked state, removal,
|
||||
cancel, and cycle feedback. Mobile Playwright coverage proves the same value
|
||||
through the visible task-actions menu. It also checks dialog containment, touch
|
||||
targets, and horizontal overflow.
|
||||
|
||||
## Related designs
|
||||
|
||||
- [Task Dependencies and Auto-Start Chains](task-dependencies.md)
|
||||
- [Task-create dependency selector refinement](../requirements/task-dependencies-create-dialog-dependency-selector.md)
|
||||
Reference in New Issue
Block a user