Step order comparator
apps/backend/internal/task/models/step_order.go ↗Defines the total order every surface and promotion uses, with queued_at fallback to created_at.
Comparator
func StepOrderLess(left, right *Task) bool {
if left.Position != right.Position {
return left.Position < right.Position
}
if lp, rp := stepOrderPriorityRank(left.Priority), stepOrderPriorityRank(right.Priority); lp != rp {
return lp < rp
}
leftQueuedAt, rightQueuedAt := stepOrderEffectiveQueuedAt(left), stepOrderEffectiveQueuedAt(right)
if !leftQueuedAt.Equal(rightQueuedAt) {
return leftQueuedAt.Before(rightQueuedAt)
}
if !left.CreatedAt.Equal(right.CreatedAt) {
return left.CreatedAt.Before(right.CreatedAt)
}
return left.ID < right.ID
}
func stepOrderEffectiveQueuedAt(task *Task) time.Time {
if task.QueuedAt != nil {
return *task.QueuedAt
}
return task.CreatedAt
}
Reorder repository with band validation
apps/backend/internal/task/repository/sqlite/task_reorder.go ↗Validates band and membership, renumbers the whole step densely, and bumps order_revision inside a step lock.
Reorder
func (r *Repository) ReorderStepTasks(ctx context.Context, stepID, band string, orderedTaskIDs []string) ([]*models.Task, int64, error) {
if band != ReorderBandAdmitted && band != ReorderBandQueued {
return nil, 0, repoerrors.ErrInvalidReorder
}
if err := validateReorderIDList(orderedTaskIDs); err != nil {
return nil, 0, err
}
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return nil, 0, err
}
defer func() { _ = tx.Rollback() }()
if err := lockWorkflowStepForWrite(ctx, tx, r.db.DriverName(), r.db.Rebind, stepID); err != nil {
return nil, 0, err
}
tasks, err := r.listStepTasksInTx(ctx, tx, stepID)
if err != nil {
return nil, 0, err
}
admitted, queued := partitionReorderBands(tasks, stepID)
named := admitted
other := queued
if band == ReorderBandQueued {
named, other = queued, admitted
}
orderedNamed, unresolvedIDs := resolveReorderStepMembership(tasks, orderedTaskIDs)
if len(unresolvedIDs) > 0 {
return r.reorderUnresolvedIDsResult(ctx, tx, stepID, tasks, unresolvedIDs)
}
if !sameTaskSet(orderedNamed, named) {
revision, revErr := r.stepOrderRevisionInTx(ctx, tx, stepID)
if revErr != nil {
return nil, 0, revErr
}
return sortStepOrder(tasks), revision, repoerrors.ErrStepChanged
}
renumbered := renumberReorderedStep(band, orderedNamed, other)
for _, task := range renumbered {
if _, err := tx.ExecContext(ctx, r.db.Rebind(`UPDATE tasks SET position = ?, updated_at = ? WHERE id = ?`), task.Position, r.nowUTC(), task.ID); err != nil {
return nil, 0, err
}
}
revision, err := r.bumpStepOrderRevisionInTx(ctx, tx, stepID)
if err != nil {
return nil, 0, err
}
if err := tx.Commit(); err != nil {
return nil, 0, err
}
return renumbered, revision, nil
}
Reorder service and event publish
apps/backend/internal/task/service/service_reorder.go ↗Authorizes by workflow, delegates to the repository, and publishes task.reordered with the whole-step positions.
Service
func (s *Service) ReorderStepTasks(ctx context.Context, stepID, band string, orderedTaskIDs []string) (*ReorderStepTasksResult, error) {
step, err := s.workflowStepGetter.GetStep(ctx, stepID)
if err != nil {
return nil, err
}
if err := s.authorizeWorkflowScope(ctx, step.WorkflowID, authz.ScopeTaskWrite); err != nil {
return nil, err
}
reorderer, ok := s.tasks.(reorderRepository)
if !ok {
return nil, fmt.Errorf("task repository does not support reordering")
}
tasks, revision, err := reorderer.ReorderStepTasks(ctx, stepID, band, orderedTaskIDs)
if err != nil {
if errors.Is(err, repoerrors.ErrStepChanged) {
return &ReorderStepTasksResult{WorkflowStepID: stepID, Revision: revision, Tasks: tasks}, err
}
return nil, err
}
result := &ReorderStepTasksResult{WorkflowStepID: stepID, Revision: revision, Tasks: tasks}
s.publishTaskReordered(ctx, stepID, band, revision, tasks)
return result, nil
}
Publish
func (s *Service) publishTaskReordered(ctx context.Context, stepID, band string, revision int64, tasks []*models.Task) {
if s.eventBus == nil || len(tasks) == 0 {
return
}
taskEntries := make([]map[string]interface{}, len(tasks))
for i, task := range tasks {
taskEntries[i] = map[string]interface{}{"id": task.ID, reorderPayloadPositionKey: task.Position}
}
data := map[string]interface{}{
reorderPayloadWorkspaceIDKey: tasks[0].WorkspaceID,
"workflow_step_id": stepID,
"band": band,
"revision": revision,
"tasks": taskEntries,
}
event := bus.NewEvent(events.TaskReordered, "task-service", data)
_ = s.eventBus.Publish(ctx, events.TaskReordered, event)
}
Visible-to-full band merge
apps/web/lib/kanban/reorder-merge.ts ↗Keeps filtered-out tasks in place and moves only the dragged card to its new visible neighbor.
Merge
export function mergeVisibleReorderIntoBand(
fullBandOrder: string[],
visibleOrderAfterMove: string[],
draggedId: string,
): string[] {
const withoutDragged = fullBandOrder.filter((id) => id !== draggedId);
const draggedIndex = visibleOrderAfterMove.indexOf(draggedId);
if (draggedIndex === -1) return fullBandOrder;
const nextVisibleId = visibleOrderAfterMove[draggedIndex + 1];
if (nextVisibleId !== undefined) {
const insertAt = withoutDragged.indexOf(nextVisibleId);
if (insertAt === -1) return fullBandOrder;
const result = [...withoutDragged];
result.splice(insertAt, 0, draggedId);
return result;
}
if (visibleOrderAfterMove[draggedIndex - 1] !== undefined) {
return [...withoutDragged, draggedId];
}
return fullBandOrder;
}
Optimistic reorder with revision reconciliation
apps/web/hooks/domains/kanban/use-step-reorder.ts ↗Applies the new order immediately, calls the reorder API, and reconciles by revision while holding the pending band.
Reorder hook
const nextBandOrder = mergeVisibleReorderIntoBand(currentBandOrder, visibleOrderAfterMove, draggedId);
if (arraysEqual(nextBandOrder, currentBandOrder)) return;
const admittedCount = partitionWipTasks(stepTasks, stepId).admitted.length;
state.setWorkflowSnapshot(workflowId, {
...snapshot,
tasks: applyBandPositions(snapshot.tasks, stepId, band, nextBandOrder, admittedCount),
});
state.setBandReorderPending(stepId, band, true);
try {
const response = await reorderStepTasks(stepId, { band, ordered_task_ids: nextBandOrder });
reconcileAndApplyReorderResponse({ store, workflowId, stepId, band, candidate: response });
} catch (error) {
const conflictBody = error instanceof ApiError && error.status === 409 ? (error.body as ReorderStepTasksResponse) : null;
if (conflictBody) {
reconcileAndApplyReorderResponse({ store, workflowId, stepId, band, candidate: conflictBody });
} else {
restoreBandOnFailure({ store, workflowId, stepId, band, originalTasks, current });
toast({ title: t("task:failedToReorderTasks"), variant: "error" });
}
} finally {
state.setBandReorderPending(stepId, band, false);
}
WS task.reordered handler
apps/web/lib/ws/handlers/kanban.ts ↗Applies positions only on a strictly greater revision and holds the pending band's slice for later reconciliation.
Handler
return (message) => {
const { workflow_step_id: stepId, revision, tasks } = message.payload;
store.setState((state) => {
const currentRevision = state.kanbanMulti.orderRevisionByStepId[stepId] ?? -1;
if (revision <= currentRevision) return state;
const highestHeldRevision = highestHeldReorderRevision(state.kanbanMulti.withheldReorderByBandKey, stepId);
if (revision <= highestHeldRevision) return state;
const pendingBands = REORDER_BANDS.filter((band) => state.kanbanMulti.pendingReorderBandKeys[`${stepId}:${band}`]);
const bandByTaskId = pendingBands.length ? classifyTasksByBand(tasks, stepId, [state.kanban.tasks, ...Object.values(state.kanbanMulti.snapshots).map((s) => s.tasks)]) : new Map();
const held: Record<ReorderBand, ReorderedTaskPosition[]> = { admitted: [], queued: [] };
const applyNow: ReorderedTaskPosition[] = [];
for (const task of tasks) {
const band = bandByTaskId.get(task.id);
if (band && pendingBands.includes(band)) held[band].push(task);
else applyNow.push(task);
}
const nextState = holdPendingReorderTasks(state, stepId, pendingBands, held, revision);
if (applyNow.length === 0) return nextState;
const positionById = new Map(applyNow.map((task) => [task.id, task.position]));
return {
...nextState,
kanban: { ...nextState.kanban, tasks: applyPositionsToTasks(nextState.kanban.tasks, positionById) },
kanbanMulti: { ...nextState.kanbanMulti, orderRevisionByStepId: { ...nextState.kanbanMulti.orderRevisionByStepId, [stepId]: revision } },
};
});
};