fix: stabilize task and workspace creation (#1910)
This commit is contained in:
@@ -83,6 +83,58 @@ func TestTaskCreatorAdapterPersistsOriginByCreationPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfficeWorkspaceCreatorDoesNotBootstrapKanbanWorkflow(t *testing.T) {
|
||||
_, taskSvc := newOfficeTaskAdapterHarness(t)
|
||||
adapter := &taskWorkspaceCreatorAdapter{taskSvc: taskSvc}
|
||||
ctx := context.Background()
|
||||
|
||||
if err := adapter.CreateWorkspace(ctx, "Office workspace", "Created by Office onboarding"); err != nil {
|
||||
t.Fatalf("CreateWorkspace: %v", err)
|
||||
}
|
||||
workspaceID, err := adapter.FindWorkspaceIDByName(ctx, "Office workspace")
|
||||
if err != nil {
|
||||
t.Fatalf("FindWorkspaceIDByName: %v", err)
|
||||
}
|
||||
workflows, err := taskSvc.ListWorkflows(ctx, workspaceID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("ListWorkflows: %v", err)
|
||||
}
|
||||
if len(workflows) != 0 {
|
||||
t.Fatalf("Office workspace workflows = %d, want 0 before Office onboarding materializes them", len(workflows))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateWorkspaceKanbanBootstrapCreatesUsableSteps(t *testing.T) {
|
||||
harness := newBootStateTestHarness(t)
|
||||
ctx := context.Background()
|
||||
|
||||
workspace, err := harness.taskSvc.CreateWorkspace(ctx, &taskservice.CreateWorkspaceRequest{
|
||||
Name: "Kanban workspace",
|
||||
BootstrapKanbanWorkflow: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorkspace: %v", err)
|
||||
}
|
||||
workflows, err := harness.taskSvc.ListWorkflows(ctx, workspace.ID, false)
|
||||
if err != nil {
|
||||
t.Fatalf("ListWorkflows: %v", err)
|
||||
}
|
||||
if len(workflows) != 1 {
|
||||
t.Fatalf("visible workflows = %d, want 1", len(workflows))
|
||||
}
|
||||
workflow := workflows[0]
|
||||
if workflow.Name != "Kanban" || workflow.WorkflowTemplateID == nil || *workflow.WorkflowTemplateID != "simple" {
|
||||
t.Fatalf("workflow = %+v, want visible Kanban workflow from simple template", workflow)
|
||||
}
|
||||
steps, err := harness.workflowSvc.ListStepsByWorkflow(ctx, workflow.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListStepsByWorkflow: %v", err)
|
||||
}
|
||||
if len(steps) == 0 {
|
||||
t.Fatal("Kanban workflow has no usable steps")
|
||||
}
|
||||
}
|
||||
|
||||
func newOfficeTaskAdapterHarness(t *testing.T) (*taskCreatorAdapter, *taskservice.Service) {
|
||||
t.Helper()
|
||||
dbConn, err := db.OpenSQLite(filepath.Join(t.TempDir(), "office-adapter.db"))
|
||||
|
||||
@@ -1642,6 +1642,7 @@ func newBootStateTestHarness(t *testing.T) bootStateTestHarness {
|
||||
taskservice.RepositoryDiscoveryConfig{},
|
||||
)
|
||||
taskSvc.SetWorkflowStepCreator(workflowSvc)
|
||||
taskSvc.SetWorkspaceBootstrapper(taskRepo)
|
||||
taskSvc.SetWorkflowStepGetter(&workflowStepGetterAdapter{svc: workflowSvc})
|
||||
taskSvc.SetStartStepResolver(&startStepResolverAdapter{svc: workflowSvc})
|
||||
workflowSvc.SetWorkflowProvider(&workflowProviderAdapter{svc: taskSvc})
|
||||
|
||||
@@ -87,6 +87,9 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories
|
||||
|
||||
// Wire workflow step creator to task service for board creation
|
||||
taskSvc.SetWorkflowStepCreator(workflowSvc)
|
||||
// Standard Kanban workspace creation is coordinated by the task SQLite
|
||||
// repository so workspace, workflow, and steps share one transaction.
|
||||
taskSvc.SetWorkspaceBootstrapper(repos.Task)
|
||||
|
||||
// Wire workflow step getter to task service for MoveTask
|
||||
taskSvc.SetWorkflowStepGetter(&workflowStepGetterAdapter{svc: workflowSvc})
|
||||
|
||||
@@ -98,6 +98,7 @@ func NewTestServer(t *testing.T) *TestServer {
|
||||
Reviews: taskRepo,
|
||||
}, eventBus, log, taskservice.RepositoryDiscoveryConfig{})
|
||||
taskSvc.SetWorkflowStepCreator(workflowSvc)
|
||||
taskSvc.SetWorkspaceBootstrapper(taskRepo)
|
||||
|
||||
// Create WebSocket gateway
|
||||
gateway := gateways.NewGateway(log)
|
||||
|
||||
@@ -37,7 +37,8 @@ func TestMultipleClients(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
workflowID := workflowPayload["id"].(string)
|
||||
|
||||
// Client 2 can see the workflow (filter by workspace to avoid default workflow)
|
||||
// Client 2 can see both the workspace's default Kanban workflow and the
|
||||
// additional workflow created by client 1.
|
||||
listResp, err := client2.SendRequest("c2-list-1", ws.ActionWorkflowList, map[string]interface{}{
|
||||
"workspace_id": workspaceID,
|
||||
})
|
||||
@@ -49,7 +50,7 @@ func TestMultipleClients(t *testing.T) {
|
||||
|
||||
workflows, ok := listPayload["workflows"].([]interface{})
|
||||
require.True(t, ok)
|
||||
assert.Len(t, workflows, 1)
|
||||
assert.Len(t, workflows, 2)
|
||||
|
||||
// Client 2 lists workflow steps (created automatically with workflow)
|
||||
stepResp, err := client2.SendRequest("c2-step-list-1", ws.ActionWorkflowStepList, map[string]interface{}{
|
||||
|
||||
@@ -301,6 +301,7 @@ func NewOrchestratorTestServer(t *testing.T) *OrchestratorTestServer {
|
||||
Reviews: taskRepo,
|
||||
}, eventBus, log, taskservice.RepositoryDiscoveryConfig{})
|
||||
taskSvc.SetWorkflowStepCreator(workflowSvc)
|
||||
taskSvc.SetWorkspaceBootstrapper(taskRepo)
|
||||
|
||||
// Create simulated agent manager
|
||||
agentManager := NewSimulatedAgentManager(eventBus, log)
|
||||
|
||||
@@ -51,7 +51,7 @@ func TestWorkflowCRUD(t *testing.T) {
|
||||
|
||||
workflows, ok := payload["workflows"].([]interface{})
|
||||
require.True(t, ok)
|
||||
assert.Len(t, workflows, 1)
|
||||
assert.Len(t, workflows, 2)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -101,6 +101,7 @@ func (h *WorkspaceHandlers) httpCreateWorkspace(c *gin.Context) {
|
||||
DefaultEnvironmentID: body.DefaultEnvironmentID,
|
||||
DefaultAgentProfileID: body.DefaultAgentProfileID,
|
||||
DefaultConfigAgentProfileID: body.DefaultConfigAgentProfileID,
|
||||
BootstrapKanbanWorkflow: true,
|
||||
})
|
||||
if err != nil {
|
||||
handleNotFound(c, h.logger, err, "workspace not created")
|
||||
@@ -221,6 +222,7 @@ func (h *WorkspaceHandlers) wsCreateWorkspace(ctx context.Context, msg *ws.Messa
|
||||
DefaultEnvironmentID: req.DefaultEnvironmentID,
|
||||
DefaultAgentProfileID: req.DefaultAgentProfileID,
|
||||
DefaultConfigAgentProfileID: req.DefaultConfigAgentProfileID,
|
||||
BootstrapKanbanWorkflow: true,
|
||||
})
|
||||
if err != nil {
|
||||
h.logger.Error("failed to create workspace", zap.Error(err))
|
||||
|
||||
@@ -30,6 +30,82 @@ type workspaceDeleteRepo struct {
|
||||
getErr error
|
||||
}
|
||||
|
||||
type recordingWorkspaceBootstrapper struct {
|
||||
workspaces []*models.Workspace
|
||||
err error
|
||||
}
|
||||
|
||||
func (b *recordingWorkspaceBootstrapper) CreateWorkspaceWithKanban(_ context.Context, workspace *models.Workspace) (*models.Workflow, error) {
|
||||
b.workspaces = append(b.workspaces, workspace)
|
||||
if b.err != nil {
|
||||
return nil, b.err
|
||||
}
|
||||
templateID := "simple"
|
||||
return &models.Workflow{ID: "kanban", WorkspaceID: workspace.ID, Name: "Kanban", WorkflowTemplateID: &templateID}, nil
|
||||
}
|
||||
|
||||
func newWorkspaceBootstrapService(t *testing.T, repo *mockRepository, bootstrapper service.WorkspaceBootstrapper) (*service.Service, *logger.Logger) {
|
||||
t.Helper()
|
||||
log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json", OutputPath: "stdout"})
|
||||
require.NoError(t, err)
|
||||
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.SetWorkspaceBootstrapper(bootstrapper)
|
||||
return svc, log
|
||||
}
|
||||
|
||||
func TestHTTPCreateWorkspaceCreatesKanbanWorkflow(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
repo := &mockRepository{}
|
||||
bootstrapper := &recordingWorkspaceBootstrapper{}
|
||||
svc, log := newWorkspaceBootstrapService(t, repo, bootstrapper)
|
||||
|
||||
h := NewWorkspaceHandlers(svc, log)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/workspaces", strings.NewReader(`{"name":"Kanban"}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.httpCreateWorkspace(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Len(t, bootstrapper.workspaces, 1)
|
||||
}
|
||||
|
||||
func TestWSCreateWorkspaceCreatesKanbanWorkflow(t *testing.T) {
|
||||
repo := &mockRepository{}
|
||||
bootstrapper := &recordingWorkspaceBootstrapper{}
|
||||
svc, log := newWorkspaceBootstrapService(t, repo, bootstrapper)
|
||||
h := NewWorkspaceHandlers(svc, log)
|
||||
req, err := ws.NewRequest("request-1", ws.ActionWorkspaceCreate, map[string]string{"name": "Kanban"})
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := h.wsCreateWorkspace(context.Background(), req)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ws.MessageTypeResponse, resp.Type)
|
||||
require.Len(t, bootstrapper.workspaces, 1)
|
||||
}
|
||||
|
||||
func TestHTTPCreateWorkspaceFailsWhenKanbanBootstrapFails(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
repo := &mockRepository{}
|
||||
svc, log := newWorkspaceBootstrapService(t, repo, &recordingWorkspaceBootstrapper{err: errors.New("template unavailable")})
|
||||
h := NewWorkspaceHandlers(svc, log)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/workspaces", strings.NewReader(`{"name":"Kanban"}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.httpCreateWorkspace(c)
|
||||
|
||||
require.Equal(t, http.StatusInternalServerError, rec.Code)
|
||||
}
|
||||
|
||||
func (r *workspaceDeleteRepo) GetWorkspace(_ context.Context, id string) (*models.Workspace, error) {
|
||||
r.getCalls++
|
||||
if r.getErr != nil {
|
||||
|
||||
@@ -33,6 +33,11 @@ func (r *Repository) RemoveTaskFromWorkflow(ctx context.Context, taskID, workflo
|
||||
|
||||
// CreateWorkflow creates a new workflow
|
||||
func (r *Repository) CreateWorkflow(ctx context.Context, workflow *models.Workflow) error {
|
||||
r.prepareWorkflow(workflow)
|
||||
return r.insertWorkflow(ctx, r.db, workflow)
|
||||
}
|
||||
|
||||
func (r *Repository) prepareWorkflow(workflow *models.Workflow) {
|
||||
if workflow.ID == "" {
|
||||
workflow.ID = uuid.New().String()
|
||||
}
|
||||
@@ -40,10 +45,13 @@ func (r *Repository) CreateWorkflow(ctx context.Context, workflow *models.Workfl
|
||||
workflow.CreatedAt = now
|
||||
workflow.UpdatedAt = now
|
||||
|
||||
}
|
||||
|
||||
func (r *Repository) insertWorkflow(ctx context.Context, exec sqlx.ExtContext, workflow *models.Workflow) error {
|
||||
// Auto-assign sort_order as max+1 within the workspace.
|
||||
// Use writer connection (r.db) to avoid stale reads under concurrent creation.
|
||||
// The caller's transaction keeps the read and insert coherent.
|
||||
var maxOrder int
|
||||
err := r.db.QueryRowContext(ctx, r.db.Rebind(
|
||||
err := exec.QueryRowxContext(ctx, r.db.Rebind(
|
||||
`SELECT COALESCE(MAX(sort_order), -1) FROM workflows WHERE workspace_id = ?`,
|
||||
), workflow.WorkspaceID).Scan(&maxOrder)
|
||||
if err != nil {
|
||||
@@ -51,7 +59,7 @@ func (r *Repository) CreateWorkflow(ctx context.Context, workflow *models.Workfl
|
||||
}
|
||||
workflow.SortOrder = maxOrder + 1
|
||||
|
||||
_, err = r.db.ExecContext(ctx, r.db.Rebind(`
|
||||
_, err = exec.ExecContext(ctx, r.db.Rebind(`
|
||||
INSERT INTO workflows (id, workspace_id, name, description, agent_profile_id, workflow_template_id, sort_order, hidden, style, source, source_path, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`), workflow.ID, workflow.WorkspaceID, workflow.Name, workflow.Description, workflow.AgentProfileID, workflow.WorkflowTemplateID, workflow.SortOrder, dialect.BoolToInt(workflow.Hidden), normalizeWorkflowStyle(workflow.Style), normalizeWorkflowSource(workflow.Source), workflow.SourcePath, workflow.CreatedAt, workflow.UpdatedAt)
|
||||
|
||||
@@ -16,6 +16,11 @@ import (
|
||||
|
||||
// CreateWorkspace creates a new workspace
|
||||
func (r *Repository) CreateWorkspace(ctx context.Context, workspace *models.Workspace) error {
|
||||
r.prepareWorkspace(workspace)
|
||||
return r.insertWorkspace(ctx, r.db, workspace)
|
||||
}
|
||||
|
||||
func (r *Repository) prepareWorkspace(workspace *models.Workspace) {
|
||||
if workspace.ID == "" {
|
||||
workspace.ID = uuid.New().String()
|
||||
}
|
||||
@@ -25,8 +30,10 @@ func (r *Repository) CreateWorkspace(ctx context.Context, workspace *models.Work
|
||||
if workspace.TaskPrefix == "" {
|
||||
workspace.TaskPrefix = "KAN"
|
||||
}
|
||||
}
|
||||
|
||||
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
||||
func (r *Repository) insertWorkspace(ctx context.Context, exec sqlx.ExtContext, workspace *models.Workspace) error {
|
||||
_, err := exec.ExecContext(ctx, r.db.Rebind(`
|
||||
INSERT INTO workspaces (
|
||||
id,
|
||||
name,
|
||||
@@ -192,6 +199,16 @@ func (r *Repository) deleteWorkspaceCascade(
|
||||
`), id); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, r.db.Rebind(`
|
||||
DELETE FROM workflow_steps
|
||||
WHERE workflow_id IN (
|
||||
SELECT id
|
||||
FROM workflows
|
||||
WHERE workspace_id = ?
|
||||
)
|
||||
`), id); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, r.db.Rebind(`
|
||||
DELETE FROM workflows
|
||||
WHERE workspace_id = ?
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
workflowcfg "github.com/kandev/kandev/config/workflows"
|
||||
"github.com/kandev/kandev/internal/db/dialect"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
wfmodels "github.com/kandev/kandev/internal/workflow/models"
|
||||
)
|
||||
|
||||
const kanbanTemplateID = "simple"
|
||||
|
||||
// CreateWorkspaceWithKanban persists a standard Kanban workspace, its default
|
||||
// workflow, and all template-derived steps in one transaction.
|
||||
func (r *Repository) CreateWorkspaceWithKanban(ctx context.Context, workspace *models.Workspace) (*models.Workflow, error) {
|
||||
template, err := kanbanTemplate()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tx, err := r.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
r.prepareWorkspace(workspace)
|
||||
if err := r.insertWorkspace(ctx, tx, workspace); err != nil {
|
||||
return nil, fmt.Errorf("create workspace: %w", err)
|
||||
}
|
||||
|
||||
workflow := &models.Workflow{
|
||||
WorkspaceID: workspace.ID,
|
||||
Name: "Kanban",
|
||||
WorkflowTemplateID: &template.ID,
|
||||
}
|
||||
r.prepareWorkflow(workflow)
|
||||
if err := r.insertWorkflow(ctx, tx, workflow); err != nil {
|
||||
return nil, fmt.Errorf("create Kanban workflow: %w", err)
|
||||
}
|
||||
if err := r.insertTemplateSteps(ctx, tx, workflow.ID, template); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return workflow, nil
|
||||
}
|
||||
|
||||
func kanbanTemplate() (*wfmodels.WorkflowTemplate, error) {
|
||||
templates, err := workflowcfg.LoadTemplates()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load Kanban template: %w", err)
|
||||
}
|
||||
for _, template := range templates {
|
||||
if template.ID == kanbanTemplateID {
|
||||
return template, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("kanban template %q not found", kanbanTemplateID)
|
||||
}
|
||||
|
||||
func (r *Repository) insertTemplateSteps(ctx context.Context, tx *sqlx.Tx, workflowID string, template *wfmodels.WorkflowTemplate) error {
|
||||
idMap := make(map[string]string, len(template.Steps))
|
||||
for _, stepDef := range template.Steps {
|
||||
idMap[stepDef.ID] = uuid.NewString()
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
for _, stepDef := range template.Steps {
|
||||
events, err := json.Marshal(wfmodels.RemapStepEvents(stepDef.Events, idMap))
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal Kanban step %q: %w", stepDef.Name, err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, r.db.Rebind(`
|
||||
INSERT INTO workflow_steps (
|
||||
id, workflow_id, name, position, color, prompt, events,
|
||||
allow_manual_move, is_start_step, show_in_command_panel,
|
||||
auto_archive_after_hours, agent_profile_id, stage_type,
|
||||
auto_advance_requires_signal, wip_limit, pull_from_step_id,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`), idMap[stepDef.ID], workflowID, stepDef.Name, stepDef.Position, stepDef.Color, stepDef.Prompt, string(events),
|
||||
dialect.BoolToInt(stepDef.AllowManualMove), dialect.BoolToInt(stepDef.IsStartStep), dialect.BoolToInt(stepDef.ShowInCommandPanel),
|
||||
stepDef.AutoArchiveAfterHours, stepDef.AgentProfileID, normalizeBootstrapStageType(stepDef.StageType),
|
||||
dialect.BoolToInt(stepDef.AutoAdvanceRequiresSignal), stepDef.WIPLimit, wfmodels.RemapStepID(stepDef.PullFromStepID, idMap), now, now); err != nil {
|
||||
return fmt.Errorf("create Kanban step %q: %w", stepDef.Name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeBootstrapStageType(stageType wfmodels.StageType) string {
|
||||
switch stageType {
|
||||
case wfmodels.StageTypeWork, wfmodels.StageTypeReview, wfmodels.StageTypeApproval, wfmodels.StageTypeCustom:
|
||||
return string(stageType)
|
||||
default:
|
||||
return string(wfmodels.StageTypeCustom)
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,12 @@ import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
"github.com/kandev/kandev/internal/task/repository/repoerrors"
|
||||
wfmodels "github.com/kandev/kandev/internal/workflow/models"
|
||||
workflowrepo "github.com/kandev/kandev/internal/workflow/repository"
|
||||
)
|
||||
|
||||
func TestDeleteWorkspaceCascadeWithNameDeletesWorkspaceChildren(t *testing.T) {
|
||||
@@ -73,6 +77,45 @@ func TestDeleteWorkspaceCascadeDeletesWorkspaceChildren(t *testing.T) {
|
||||
assertNoWorkspaceCascadeDependents(t, repo)
|
||||
}
|
||||
|
||||
func TestDeleteWorkspaceCascadeDeletesBootstrappedWorkflowSteps(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newRepoForHealTests(t)
|
||||
if _, err := workflowrepo.NewWithDB(repo.db, repo.db, nil); err != nil {
|
||||
t.Fatalf("initialize workflow repository: %v", err)
|
||||
}
|
||||
|
||||
workspace := &models.Workspace{ID: "ws-kanban-delete", Name: "Delete Kanban"}
|
||||
workflow, err := repo.CreateWorkspaceWithKanban(ctx, workspace)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorkspaceWithKanban: %v", err)
|
||||
}
|
||||
var steps int
|
||||
if err := repo.db.QueryRow(`
|
||||
SELECT COUNT(*)
|
||||
FROM workflow_steps
|
||||
WHERE workflow_id = ?
|
||||
`, workflow.ID).Scan(&steps); err != nil {
|
||||
t.Fatalf("count bootstrapped steps: %v", err)
|
||||
}
|
||||
if steps != 4 {
|
||||
t.Fatalf("bootstrapped steps = %d, want 4", steps)
|
||||
}
|
||||
|
||||
if _, _, err := repo.DeleteWorkspaceCascade(ctx, workspace.ID); err != nil {
|
||||
t.Fatalf("DeleteWorkspaceCascade: %v", err)
|
||||
}
|
||||
if err := repo.db.QueryRow(`
|
||||
SELECT COUNT(*)
|
||||
FROM workflow_steps
|
||||
WHERE workflow_id = ?
|
||||
`, workflow.ID).Scan(&steps); err != nil {
|
||||
t.Fatalf("count remaining steps: %v", err)
|
||||
}
|
||||
if steps != 0 {
|
||||
t.Fatalf("remaining workflow steps = %d, want 0", steps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteWorkspaceCascadeWithNameRejectsMismatchedName(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newRepoForHealTests(t)
|
||||
@@ -124,6 +167,295 @@ func TestDeleteWorkspaceCascadeWithNameRollsBackWhenChildDeleteFails(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateWorkspaceWithKanbanRollsBackAllRowsWhenStepInsertFails(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newRepoForHealTests(t)
|
||||
if _, err := workflowrepo.NewWithDB(repo.db, repo.db, nil); err != nil {
|
||||
t.Fatalf("initialize workflow repository: %v", err)
|
||||
}
|
||||
if _, err := repo.db.Exec(`
|
||||
CREATE TRIGGER fail_kanban_step
|
||||
BEFORE INSERT ON workflow_steps
|
||||
WHEN NEW.name = 'In Progress'
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'step insert blocked');
|
||||
END
|
||||
`); err != nil {
|
||||
t.Fatalf("create failure trigger: %v", err)
|
||||
}
|
||||
stepsBefore := countWorkflowSteps(t, repo)
|
||||
|
||||
_, err := repo.CreateWorkspaceWithKanban(ctx, &models.Workspace{ID: "ws-bootstrap", Name: "Bootstrap"})
|
||||
if err == nil {
|
||||
t.Fatal("CreateWorkspaceWithKanban succeeded despite step insert failure")
|
||||
}
|
||||
assertBootstrapRowsAbsent(t, repo, "ws-bootstrap", stepsBefore)
|
||||
}
|
||||
|
||||
func TestCreateWorkspaceWithKanbanRollsBackWhenContextCancelled(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
repo := newRepoForHealTests(t)
|
||||
if _, err := workflowrepo.NewWithDB(repo.db, repo.db, nil); err != nil {
|
||||
t.Fatalf("initialize workflow repository: %v", err)
|
||||
}
|
||||
|
||||
_, err := repo.CreateWorkspaceWithKanban(ctx, &models.Workspace{ID: "ws-cancelled", Name: "Cancelled"})
|
||||
if err == nil {
|
||||
t.Fatal("CreateWorkspaceWithKanban succeeded with a cancelled context")
|
||||
}
|
||||
assertBootstrapRowsAbsent(t, repo, "ws-cancelled", countWorkflowSteps(t, repo))
|
||||
}
|
||||
|
||||
func TestCreateWorkspaceWithKanbanUsesSimpleTemplate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newRepoForHealTests(t)
|
||||
workflowStore := newWorkflowStore(t, repo)
|
||||
|
||||
workflow, err := repo.CreateWorkspaceWithKanban(ctx, &models.Workspace{ID: "ws-kanban", Name: "Kanban"})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorkspaceWithKanban: %v", err)
|
||||
}
|
||||
if workflow.WorkflowTemplateID == nil || *workflow.WorkflowTemplateID != kanbanTemplateID {
|
||||
t.Fatalf("workflow template = %v, want %q", workflow.WorkflowTemplateID, kanbanTemplateID)
|
||||
}
|
||||
steps, err := workflowStore.ListStepsByWorkflow(ctx, workflow.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListStepsByWorkflow: %v", err)
|
||||
}
|
||||
if len(steps) != 4 {
|
||||
t.Fatalf("Kanban steps = %d, want 4", len(steps))
|
||||
}
|
||||
template, err := kanbanTemplate()
|
||||
if err != nil {
|
||||
t.Fatalf("kanbanTemplate: %v", err)
|
||||
}
|
||||
if len(template.Steps) != 4 {
|
||||
t.Fatalf("simple template steps = %d, want 4", len(template.Steps))
|
||||
}
|
||||
byName := workflowStepsByName(t, steps)
|
||||
if len(byName) != len(template.Steps) {
|
||||
t.Fatalf("unique Kanban step names = %d, want %d", len(byName), len(template.Steps))
|
||||
}
|
||||
for _, definition := range template.Steps {
|
||||
assertKanbanStep(t, byName[definition.Name], workflow.ID, definition)
|
||||
}
|
||||
|
||||
reviewID := byName["Review"].ID
|
||||
if got := stepIDFromTurnComplete(t, byName["Backlog"], wfmodels.OnTurnCompleteMoveToStep); got != reviewID {
|
||||
t.Fatalf("Backlog move target = %q, want Review ID %q", got, reviewID)
|
||||
}
|
||||
if got := stepIDFromTurnComplete(t, byName["In Progress"], wfmodels.OnTurnCompleteMoveToStep); got != reviewID {
|
||||
t.Fatalf("In Progress move target = %q, want Review ID %q", got, reviewID)
|
||||
}
|
||||
if got := stepIDFromTurnStart(t, byName["Done"], wfmodels.OnTurnStartMoveToStep); got != byName["In Progress"].ID {
|
||||
t.Fatalf("Done move target = %q, want In Progress ID %q", got, byName["In Progress"].ID)
|
||||
}
|
||||
assertKanbanEventTypes(t, byName)
|
||||
}
|
||||
|
||||
func TestInsertTemplateStepsPreservesOptionalFieldsAndRemapsReferences(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := newRepoForHealTests(t)
|
||||
workflowStore := newWorkflowStore(t, repo)
|
||||
workspace := &models.Workspace{ID: "ws-synthetic", Name: "Synthetic"}
|
||||
if err := repo.CreateWorkspace(ctx, workspace); err != nil {
|
||||
t.Fatalf("CreateWorkspace: %v", err)
|
||||
}
|
||||
workflow := &models.Workflow{ID: "wf-synthetic", WorkspaceID: workspace.ID, Name: "Synthetic"}
|
||||
if err := repo.CreateWorkflow(ctx, workflow); err != nil {
|
||||
t.Fatalf("CreateWorkflow: %v", err)
|
||||
}
|
||||
template := &wfmodels.WorkflowTemplate{Steps: []wfmodels.StepDefinition{
|
||||
{
|
||||
ID: "source",
|
||||
Name: "Source",
|
||||
Position: 4,
|
||||
Color: "bg-purple-500",
|
||||
Prompt: "keep every field",
|
||||
AllowManualMove: false,
|
||||
IsStartStep: true,
|
||||
ShowInCommandPanel: false,
|
||||
AutoArchiveAfterHours: 72,
|
||||
AgentProfileID: "agent-profile",
|
||||
StageType: wfmodels.StageTypeReview,
|
||||
AutoAdvanceRequiresSignal: true,
|
||||
WIPLimit: 3,
|
||||
PullFromStepID: "target",
|
||||
Events: wfmodels.StepEvents{
|
||||
OnEnter: []wfmodels.OnEnterAction{{Type: wfmodels.OnEnterAutoStartAgent}},
|
||||
OnTurnStart: []wfmodels.OnTurnStartAction{{Type: wfmodels.OnTurnStartMoveToStep, Config: map[string]any{"step_id": "target"}}},
|
||||
OnTurnComplete: []wfmodels.OnTurnCompleteAction{{Type: wfmodels.OnTurnCompleteMoveToStep, Config: map[string]any{"step_id": "target"}}},
|
||||
OnComment: []wfmodels.GenericAction{{Type: wfmodels.GenericActionMoveToStep, Config: map[string]any{"step_id": "target"}}},
|
||||
},
|
||||
},
|
||||
{ID: "target", Name: "Target", Position: 5, Color: "bg-green-500"},
|
||||
}}
|
||||
tx, err := repo.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("begin transaction: %v", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
if err := repo.insertTemplateSteps(ctx, tx, workflow.ID, template); err != nil {
|
||||
t.Fatalf("insertTemplateSteps: %v", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatalf("commit template steps: %v", err)
|
||||
}
|
||||
steps, err := workflowStore.ListStepsByWorkflow(ctx, workflow.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListStepsByWorkflow: %v", err)
|
||||
}
|
||||
byName := workflowStepsByName(t, steps)
|
||||
source := byName["Source"]
|
||||
target := byName["Target"]
|
||||
if source.ID == "source" || target.ID == "target" || source.ID == target.ID {
|
||||
t.Fatalf("generated IDs = source:%q target:%q, want distinct non-template IDs", source.ID, target.ID)
|
||||
}
|
||||
for _, step := range []*wfmodels.WorkflowStep{source, target} {
|
||||
if _, err := uuid.Parse(step.ID); err != nil {
|
||||
t.Fatalf("step ID %q is not a UUID: %v", step.ID, err)
|
||||
}
|
||||
}
|
||||
if source.Prompt != "keep every field" || source.Color != "bg-purple-500" || source.Position != 4 ||
|
||||
source.AllowManualMove || !source.IsStartStep || source.ShowInCommandPanel ||
|
||||
source.AutoArchiveAfterHours != 72 || source.AgentProfileID != "agent-profile" ||
|
||||
source.StageType != wfmodels.StageTypeReview || !source.AutoAdvanceRequiresSignal || source.WIPLimit != 3 {
|
||||
t.Fatalf("optional fields were not persisted faithfully: %#v", source)
|
||||
}
|
||||
if source.PullFromStepID != target.ID {
|
||||
t.Fatalf("pull source = %q, want remapped target ID %q", source.PullFromStepID, target.ID)
|
||||
}
|
||||
if got := stepIDFromTurnStart(t, source, wfmodels.OnTurnStartMoveToStep); got != target.ID {
|
||||
t.Fatalf("on_turn_start target = %q, want %q", got, target.ID)
|
||||
}
|
||||
if got := stepIDFromTurnComplete(t, source, wfmodels.OnTurnCompleteMoveToStep); got != target.ID {
|
||||
t.Fatalf("on_turn_complete target = %q, want %q", got, target.ID)
|
||||
}
|
||||
if got := stepIDFromComment(t, source); got != target.ID {
|
||||
t.Fatalf("on_comment target = %q, want %q", got, target.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func newWorkflowStore(t *testing.T, repo *Repository) *workflowrepo.Repository {
|
||||
t.Helper()
|
||||
store, err := workflowrepo.NewWithDB(repo.db, repo.db, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("initialize workflow repository: %v", err)
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
func workflowStepsByName(t *testing.T, steps []*wfmodels.WorkflowStep) map[string]*wfmodels.WorkflowStep {
|
||||
t.Helper()
|
||||
byName := make(map[string]*wfmodels.WorkflowStep, len(steps))
|
||||
for _, step := range steps {
|
||||
byName[step.Name] = step
|
||||
}
|
||||
return byName
|
||||
}
|
||||
|
||||
func assertKanbanStep(t *testing.T, step *wfmodels.WorkflowStep, workflowID string, definition wfmodels.StepDefinition) {
|
||||
t.Helper()
|
||||
if step == nil {
|
||||
t.Fatal("expected Kanban step is missing")
|
||||
}
|
||||
if step.WorkflowID != workflowID || step.Position != definition.Position || step.Color != definition.Color ||
|
||||
step.Prompt != definition.Prompt || step.AllowManualMove != definition.AllowManualMove ||
|
||||
step.IsStartStep != definition.IsStartStep || step.ShowInCommandPanel != definition.ShowInCommandPanel ||
|
||||
step.AutoArchiveAfterHours != definition.AutoArchiveAfterHours || step.AgentProfileID != definition.AgentProfileID ||
|
||||
step.StageType != wfmodels.StageType(normalizeBootstrapStageType(definition.StageType)) ||
|
||||
step.AutoAdvanceRequiresSignal != definition.AutoAdvanceRequiresSignal || step.WIPLimit != definition.WIPLimit ||
|
||||
step.PullFromStepID != definition.PullFromStepID {
|
||||
t.Fatalf("Kanban step %q was not persisted faithfully: %#v", step.Name, step)
|
||||
}
|
||||
if _, err := uuid.Parse(step.ID); err != nil {
|
||||
t.Fatalf("Kanban step %q ID %q is not a UUID: %v", step.Name, step.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertKanbanEventTypes(t *testing.T, steps map[string]*wfmodels.WorkflowStep) {
|
||||
t.Helper()
|
||||
backlog := steps["Backlog"]
|
||||
inProgress := steps["In Progress"]
|
||||
review := steps["Review"]
|
||||
if len(backlog.Events.OnTurnStart) != 1 || backlog.Events.OnTurnStart[0].Type != wfmodels.OnTurnStartMoveToNext {
|
||||
t.Fatalf("Backlog on_turn_start = %#v, want move_to_next", backlog.Events.OnTurnStart)
|
||||
}
|
||||
if len(inProgress.Events.OnEnter) != 1 || inProgress.Events.OnEnter[0].Type != wfmodels.OnEnterAutoStartAgent {
|
||||
t.Fatalf("In Progress on_enter = %#v, want auto_start_agent", inProgress.Events.OnEnter)
|
||||
}
|
||||
if len(review.Events.OnTurnStart) != 1 || review.Events.OnTurnStart[0].Type != wfmodels.OnTurnStartMoveToPrevious {
|
||||
t.Fatalf("Review on_turn_start = %#v, want move_to_previous", review.Events.OnTurnStart)
|
||||
}
|
||||
}
|
||||
|
||||
func stepIDFromTurnStart(t *testing.T, step *wfmodels.WorkflowStep, expected wfmodels.OnTurnStartActionType) string {
|
||||
t.Helper()
|
||||
if len(step.Events.OnTurnStart) != 1 {
|
||||
t.Fatalf("%s on_turn_start actions = %d, want 1", step.Name, len(step.Events.OnTurnStart))
|
||||
}
|
||||
if step.Events.OnTurnStart[0].Type != expected {
|
||||
t.Fatalf("%s on_turn_start type = %q, want %q", step.Name, step.Events.OnTurnStart[0].Type, expected)
|
||||
}
|
||||
return configStepID(t, step.Name, step.Events.OnTurnStart[0].Config)
|
||||
}
|
||||
|
||||
func stepIDFromTurnComplete(t *testing.T, step *wfmodels.WorkflowStep, expected wfmodels.OnTurnCompleteActionType) string {
|
||||
t.Helper()
|
||||
if len(step.Events.OnTurnComplete) != 1 {
|
||||
t.Fatalf("%s on_turn_complete actions = %d, want 1", step.Name, len(step.Events.OnTurnComplete))
|
||||
}
|
||||
if step.Events.OnTurnComplete[0].Type != expected {
|
||||
t.Fatalf("%s on_turn_complete type = %q, want %q", step.Name, step.Events.OnTurnComplete[0].Type, expected)
|
||||
}
|
||||
return configStepID(t, step.Name, step.Events.OnTurnComplete[0].Config)
|
||||
}
|
||||
|
||||
func stepIDFromComment(t *testing.T, step *wfmodels.WorkflowStep) string {
|
||||
t.Helper()
|
||||
if len(step.Events.OnComment) != 1 {
|
||||
t.Fatalf("%s on_comment actions = %d, want 1", step.Name, len(step.Events.OnComment))
|
||||
}
|
||||
if step.Events.OnComment[0].Type != wfmodels.GenericActionMoveToStep {
|
||||
t.Fatalf("%s on_comment type = %q, want move_to_step", step.Name, step.Events.OnComment[0].Type)
|
||||
}
|
||||
return configStepID(t, step.Name, step.Events.OnComment[0].Config)
|
||||
}
|
||||
|
||||
func configStepID(t *testing.T, stepName string, config map[string]interface{}) string {
|
||||
t.Helper()
|
||||
stepID, ok := config["step_id"].(string)
|
||||
if !ok {
|
||||
t.Fatalf("%s step_id config = %#v, want string", stepName, config)
|
||||
}
|
||||
return stepID
|
||||
}
|
||||
|
||||
func assertBootstrapRowsAbsent(t *testing.T, repo *Repository, workspaceID string, stepsBefore int) {
|
||||
t.Helper()
|
||||
var workspaces, workflows int
|
||||
if err := repo.db.QueryRow(`SELECT COUNT(*) FROM workspaces WHERE id = ?`, workspaceID).Scan(&workspaces); err != nil {
|
||||
t.Fatalf("count workspaces: %v", err)
|
||||
}
|
||||
if err := repo.db.QueryRow(`SELECT COUNT(*) FROM workflows WHERE workspace_id = ?`, workspaceID).Scan(&workflows); err != nil {
|
||||
t.Fatalf("count workflows: %v", err)
|
||||
}
|
||||
stepsAfter := countWorkflowSteps(t, repo)
|
||||
if workspaces != 0 || workflows != 0 || stepsAfter != stepsBefore {
|
||||
t.Fatalf("bootstrap rows = workspace:%d workflow:%d total-steps:%d, want workspace/workflow zero and steps unchanged at %d", workspaces, workflows, stepsAfter, stepsBefore)
|
||||
}
|
||||
}
|
||||
|
||||
func countWorkflowSteps(t *testing.T, repo *Repository) int {
|
||||
t.Helper()
|
||||
var steps int
|
||||
if err := repo.db.QueryRow(`SELECT COUNT(*) FROM workflow_steps`).Scan(&steps); err != nil {
|
||||
t.Fatalf("count workflow steps: %v", err)
|
||||
}
|
||||
return steps
|
||||
}
|
||||
|
||||
func seedWorkspaceCascadeRows(t *testing.T, repo *Repository, workspaceID string) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/events/bus"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
"github.com/kandev/kandev/internal/task/repository"
|
||||
wfmodels "github.com/kandev/kandev/internal/workflow/models"
|
||||
"github.com/kandev/kandev/internal/worktree"
|
||||
@@ -114,6 +115,12 @@ type WorkflowStepCreator interface {
|
||||
CreateStepsFromTemplate(ctx context.Context, workflowID, templateID string) error
|
||||
}
|
||||
|
||||
// WorkspaceBootstrapper owns the atomic persistence of a standard Kanban
|
||||
// workspace and its initial workflow state.
|
||||
type WorkspaceBootstrapper interface {
|
||||
CreateWorkspaceWithKanban(ctx context.Context, workspace *models.Workspace) (*models.Workflow, error)
|
||||
}
|
||||
|
||||
// WorkflowStepGetter retrieves workflow step information.
|
||||
type WorkflowStepGetter interface {
|
||||
GetStep(ctx context.Context, stepID string) (*wfmodels.WorkflowStep, error)
|
||||
@@ -203,6 +210,7 @@ type Service struct {
|
||||
providerProber ProviderDefaultBranchProber
|
||||
gitArchiveCapture GitArchiveCapture
|
||||
workflowStepCreator WorkflowStepCreator
|
||||
workspaceBootstrapper WorkspaceBootstrapper
|
||||
workflowStepGetter WorkflowStepGetter
|
||||
startStepResolver StartStepResolver
|
||||
prTaskResolver PRTaskResolver
|
||||
@@ -309,6 +317,10 @@ func (s *Service) SetWorkflowStepCreator(creator WorkflowStepCreator) {
|
||||
s.workflowStepCreator = creator
|
||||
}
|
||||
|
||||
func (s *Service) SetWorkspaceBootstrapper(bootstrapper WorkspaceBootstrapper) {
|
||||
s.workspaceBootstrapper = bootstrapper
|
||||
}
|
||||
|
||||
// SetWorkflowStepGetter wires the workflow step getter for MoveTask.
|
||||
func (s *Service) SetWorkflowStepGetter(getter WorkflowStepGetter) {
|
||||
s.workflowStepGetter = getter
|
||||
|
||||
@@ -101,6 +101,7 @@ type CreateWorkspaceRequest struct {
|
||||
DefaultEnvironmentID *string `json:"default_environment_id,omitempty"`
|
||||
DefaultAgentProfileID *string `json:"default_agent_profile_id,omitempty"`
|
||||
DefaultConfigAgentProfileID *string `json:"default_config_agent_profile_id,omitempty"`
|
||||
BootstrapKanbanWorkflow bool
|
||||
}
|
||||
|
||||
// UpdateWorkspaceRequest contains the data for updating a workspace
|
||||
|
||||
@@ -78,13 +78,30 @@ func (s *Service) CreateWorkspace(ctx context.Context, req *CreateWorkspaceReque
|
||||
DefaultConfigAgentProfileID: normalizeOptionalID(req.DefaultConfigAgentProfileID),
|
||||
}
|
||||
|
||||
if err := s.workspaces.CreateWorkspace(ctx, workspace); err != nil {
|
||||
var kanbanWorkflow *models.Workflow
|
||||
if req.BootstrapKanbanWorkflow {
|
||||
if s.workspaceBootstrapper == nil {
|
||||
err := errors.New("workspace bootstrapper is not configured")
|
||||
s.logger.Error("failed to create workspace with Kanban bootstrap", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
workflow, err := s.workspaceBootstrapper.CreateWorkspaceWithKanban(ctx, workspace)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to create workspace with Kanban bootstrap", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
kanbanWorkflow = workflow
|
||||
} else if err := s.workspaces.CreateWorkspace(ctx, workspace); err != nil {
|
||||
s.logger.Error("failed to create workspace", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.publishWorkspaceEvent(ctx, events.WorkspaceCreated, workspace)
|
||||
s.logger.Info("workspace created", zap.String("workspace_id", workspace.ID), zap.String("name", workspace.Name))
|
||||
if kanbanWorkflow != nil {
|
||||
s.publishWorkflowEvent(ctx, events.WorkflowCreated, kanbanWorkflow)
|
||||
s.logger.Info("workflow created", zap.String("workflow_id", kanbanWorkflow.ID), zap.String("name", kanbanWorkflow.Name))
|
||||
}
|
||||
return workspace, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -12,12 +12,87 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
commonlogger "github.com/kandev/kandev/internal/common/logger"
|
||||
"github.com/kandev/kandev/internal/events"
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
"github.com/kandev/kandev/internal/task/repository"
|
||||
"github.com/kandev/kandev/internal/worktree"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
)
|
||||
|
||||
type failingWorkspaceBootstrapper struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (b *failingWorkspaceBootstrapper) CreateWorkspaceWithKanban(
|
||||
context.Context,
|
||||
*models.Workspace,
|
||||
) (*models.Workflow, error) {
|
||||
return nil, b.err
|
||||
}
|
||||
|
||||
func TestService_CreateWorkspaceKanbanBootstrapPublishesParentBeforeWorkflow(t *testing.T) {
|
||||
svc, eventBus, _ := createTestService(t)
|
||||
|
||||
_, err := svc.CreateWorkspace(context.Background(), &CreateWorkspaceRequest{
|
||||
Name: "Kanban",
|
||||
BootstrapKanbanWorkflow: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorkspace: %v", err)
|
||||
}
|
||||
published := eventBus.GetPublishedEvents()
|
||||
if len(published) != 2 {
|
||||
t.Fatalf("published events = %d, want 2", len(published))
|
||||
}
|
||||
if published[0].Type != events.WorkspaceCreated || published[1].Type != events.WorkflowCreated {
|
||||
t.Fatalf("event order = %q, %q; want workspace.created, workflow.created", published[0].Type, published[1].Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_CreateWorkspaceLogsKanbanBootstrapFailures(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
bootstrapper WorkspaceBootstrapper
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing bootstrapper",
|
||||
wantErr: "workspace bootstrapper is not configured",
|
||||
},
|
||||
{
|
||||
name: "bootstrap persistence failure",
|
||||
bootstrapper: &failingWorkspaceBootstrapper{err: errors.New("insert failed")},
|
||||
wantErr: "insert failed",
|
||||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
svc, _, _ := createTestService(t)
|
||||
svc.SetWorkspaceBootstrapper(tc.bootstrapper)
|
||||
core, logs := observer.New(zapcore.ErrorLevel)
|
||||
log, err := commonlogger.NewFromZap(zap.New(core))
|
||||
if err != nil {
|
||||
t.Fatalf("create logger: %v", err)
|
||||
}
|
||||
svc.logger = log
|
||||
|
||||
_, err = svc.CreateWorkspace(context.Background(), &CreateWorkspaceRequest{
|
||||
Name: "Kanban",
|
||||
BootstrapKanbanWorkflow: true,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("CreateWorkspace error = %v, want %q", err, tc.wantErr)
|
||||
}
|
||||
if logs.FilterMessage("failed to create workspace with Kanban bootstrap").Len() != 1 {
|
||||
t.Fatalf("bootstrap failure log count = %d, want 1", logs.Len())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_CreateRepositoryCanonicalizesExplicitLocalPath(t *testing.T) {
|
||||
svc, _, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/kandev/kandev/internal/task/models"
|
||||
"github.com/kandev/kandev/internal/task/repository"
|
||||
sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite"
|
||||
workflowrepo "github.com/kandev/kandev/internal/workflow/repository"
|
||||
"github.com/kandev/kandev/internal/worktree"
|
||||
v1 "github.com/kandev/kandev/pkg/api/v1"
|
||||
)
|
||||
@@ -116,6 +117,9 @@ func createTestServiceWithSessionsRepo(
|
||||
if _, err := officesqlite.NewWithDB(sqlxDB, sqlxDB, nil); err != nil {
|
||||
t.Fatalf("failed to apply office migrations: %v", err)
|
||||
}
|
||||
if _, err := workflowrepo.NewWithDB(sqlxDB, sqlxDB, nil); err != nil {
|
||||
t.Fatalf("failed to initialize workflow repository: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := sqlxDB.Close(); err != nil {
|
||||
t.Errorf("failed to close sqlite db: %v", err)
|
||||
@@ -142,6 +146,7 @@ func createTestServiceWithSessionsRepo(
|
||||
Reviews: repo,
|
||||
ResourceCleanups: repo,
|
||||
}, eventBus, log, RepositoryDiscoveryConfig{})
|
||||
svc.SetWorkspaceBootstrapper(repo)
|
||||
if err := svc.StartTaskResourceCleanupWorker(context.Background()); err != nil {
|
||||
t.Fatalf("failed to start task resource cleanup worker: %v", err)
|
||||
}
|
||||
|
||||
@@ -3,8 +3,11 @@ import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-li
|
||||
import type { ReactNode } from "react";
|
||||
import type { Branch } from "@/lib/types/http";
|
||||
|
||||
const TOOLTIP_ROOT_TEST_ID = "tooltip-root";
|
||||
|
||||
vi.mock("@kandev/ui/tooltip", async () => {
|
||||
const React = await import("react");
|
||||
const TooltipContext = React.createContext<((open: boolean) => void) | undefined>(undefined);
|
||||
return {
|
||||
Tooltip: ({
|
||||
open,
|
||||
@@ -15,18 +18,64 @@ vi.mock("@kandev/ui/tooltip", async () => {
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
children: ReactNode;
|
||||
}) => {
|
||||
React.useEffect(() => {
|
||||
onOpenChange?.(true);
|
||||
}, [onOpenChange]);
|
||||
return (
|
||||
<div data-testid="tooltip-root" data-open={String(open)}>
|
||||
{children}
|
||||
</div>
|
||||
<TooltipContext.Provider value={onOpenChange}>
|
||||
<div data-testid={TOOLTIP_ROOT_TEST_ID} data-open={String(open)}>
|
||||
{children}
|
||||
</div>
|
||||
</TooltipContext.Provider>
|
||||
);
|
||||
},
|
||||
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
TooltipContent: ({ children }: { children: ReactNode }) => (
|
||||
<div data-testid="tooltip-content">{children}</div>
|
||||
TooltipTrigger: ({
|
||||
children,
|
||||
onPointerEnter: triggerPointerEnter,
|
||||
onPointerLeave: triggerPointerLeave,
|
||||
onFocus: triggerFocus,
|
||||
onBlur: triggerBlur,
|
||||
...triggerProps
|
||||
}: {
|
||||
children: ReactNode;
|
||||
onPointerEnter?: React.PointerEventHandler;
|
||||
onPointerLeave?: React.PointerEventHandler;
|
||||
onFocus?: React.FocusEventHandler;
|
||||
onBlur?: React.FocusEventHandler;
|
||||
[key: string]: unknown;
|
||||
}) => {
|
||||
const onOpenChange = React.useContext(TooltipContext);
|
||||
const child = React.Children.only(children) as React.ReactElement<{
|
||||
onPointerEnter?: React.PointerEventHandler;
|
||||
onPointerLeave?: React.PointerEventHandler;
|
||||
onFocus?: React.FocusEventHandler;
|
||||
onBlur?: React.FocusEventHandler;
|
||||
}>;
|
||||
return React.cloneElement(child, {
|
||||
...triggerProps,
|
||||
onPointerEnter: (event) => {
|
||||
triggerPointerEnter?.(event);
|
||||
child.props.onPointerEnter?.(event);
|
||||
onOpenChange?.(true);
|
||||
},
|
||||
onPointerLeave: (event) => {
|
||||
triggerPointerLeave?.(event);
|
||||
child.props.onPointerLeave?.(event);
|
||||
onOpenChange?.(false);
|
||||
},
|
||||
onFocus: (event) => {
|
||||
triggerFocus?.(event);
|
||||
child.props.onFocus?.(event);
|
||||
onOpenChange?.(true);
|
||||
},
|
||||
onBlur: (event) => {
|
||||
triggerBlur?.(event);
|
||||
child.props.onBlur?.(event);
|
||||
onOpenChange?.(false);
|
||||
},
|
||||
});
|
||||
},
|
||||
TooltipContent: ({ children, className }: { children: ReactNode; className?: string }) => (
|
||||
<div data-testid="tooltip-content" className={className}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
@@ -36,12 +85,14 @@ import {
|
||||
branchToOption,
|
||||
computeBranchPlaceholder,
|
||||
Pill,
|
||||
type PillOption,
|
||||
} from "./task-create-dialog-pill";
|
||||
|
||||
const CREATE_REPOSITORY = "Create new repository";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
@@ -53,6 +104,32 @@ function remoteBranch(name: string, remote = "origin"): Branch {
|
||||
return { name, type: "remote", remote } as Branch;
|
||||
}
|
||||
|
||||
const TOOLTIP_PILL_VALUE = "kandev";
|
||||
const REPOSITORY_TOOLTIP = "Repository · ~/kandev";
|
||||
|
||||
function renderTooltipPill({
|
||||
value = TOOLTIP_PILL_VALUE,
|
||||
options = [],
|
||||
tooltip = REPOSITORY_TOOLTIP,
|
||||
}: {
|
||||
value?: string;
|
||||
options?: PillOption[];
|
||||
tooltip?: string;
|
||||
} = {}) {
|
||||
render(
|
||||
<Pill
|
||||
icon={<span aria-hidden="true" />}
|
||||
value={value}
|
||||
placeholder="repository"
|
||||
options={options}
|
||||
onSelect={vi.fn()}
|
||||
searchPlaceholder="Search repositories..."
|
||||
emptyMessage="No repositories"
|
||||
tooltip={tooltip}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("sortBranches", () => {
|
||||
it("lifts main before master before develop before other branches", () => {
|
||||
const sorted = sortBranches([
|
||||
@@ -136,21 +213,10 @@ describe("Pill tooltip", () => {
|
||||
);
|
||||
vi.stubGlobal("cancelAnimationFrame", vi.fn());
|
||||
|
||||
render(
|
||||
<Pill
|
||||
icon={<span aria-hidden="true" />}
|
||||
value="kandev"
|
||||
placeholder="repository"
|
||||
options={[]}
|
||||
onSelect={vi.fn()}
|
||||
searchPlaceholder="Search repositories..."
|
||||
emptyMessage="No repositories"
|
||||
tooltip="Repository · ~/kandev"
|
||||
/>,
|
||||
);
|
||||
renderTooltipPill();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("tooltip-root").getAttribute("data-open")).toBe("false");
|
||||
expect(screen.getByTestId(TOOLTIP_ROOT_TEST_ID).getAttribute("data-open")).toBe("false");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -173,6 +239,83 @@ describe("Pill tooltip", () => {
|
||||
expect(tooltipTrigger.getAttribute("tabindex")).toBe("0");
|
||||
expect(tooltipTrigger.querySelector("[aria-hidden='true'] button")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("allows the first later mouse hover when the pointer left for the picker", async () => {
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
});
|
||||
vi.stubGlobal("cancelAnimationFrame", vi.fn());
|
||||
|
||||
renderTooltipPill({ options: [{ value: "repo-1", label: "repo-one" }] });
|
||||
|
||||
const trigger = screen.getByRole("button", { name: new RegExp(TOOLTIP_PILL_VALUE, "i") });
|
||||
fireEvent.click(trigger);
|
||||
fireEvent.pointerLeave(trigger);
|
||||
fireEvent.click(await screen.findByRole("option", { name: "repo-one" }));
|
||||
fireEvent.pointerEnter(trigger);
|
||||
|
||||
expect(screen.getByTestId(TOOLTIP_ROOT_TEST_ID).getAttribute("data-open")).toBe("true");
|
||||
});
|
||||
|
||||
it("keeps touch selection tooltip suppression through focus restoration", async () => {
|
||||
const frameCallbacks: FrameRequestCallback[] = [];
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
|
||||
frameCallbacks.push(callback);
|
||||
return frameCallbacks.length;
|
||||
});
|
||||
vi.stubGlobal("cancelAnimationFrame", vi.fn());
|
||||
|
||||
renderTooltipPill({ options: [{ value: "repo-1", label: "repo-one" }] });
|
||||
frameCallbacks.shift()?.(0);
|
||||
|
||||
const trigger = screen.getByRole("button", { name: new RegExp(TOOLTIP_PILL_VALUE, "i") });
|
||||
fireEvent.pointerEnter(trigger, { pointerType: "touch" });
|
||||
fireEvent.click(trigger);
|
||||
const option = await screen.findByRole("option", { name: "repo-one" });
|
||||
fireEvent.pointerDown(option, { pointerType: "touch" });
|
||||
fireEvent.click(option);
|
||||
fireEvent.pointerLeave(trigger, { pointerType: "touch" });
|
||||
frameCallbacks.shift()?.(0);
|
||||
frameCallbacks.shift()?.(0);
|
||||
fireEvent.blur(trigger);
|
||||
fireEvent.focus(trigger);
|
||||
|
||||
expect(screen.getByTestId(TOOLTIP_ROOT_TEST_ID).getAttribute("data-open")).toBe("false");
|
||||
|
||||
fireEvent.pointerEnter(trigger, { pointerType: "mouse" });
|
||||
|
||||
expect(screen.getByTestId(TOOLTIP_ROOT_TEST_ID).getAttribute("data-open")).toBe("true");
|
||||
});
|
||||
|
||||
it("wraps long repository paths inside a viewport-safe tooltip", () => {
|
||||
renderTooltipPill({
|
||||
value: "repository",
|
||||
tooltip: `Repository · C:\\${"unbroken-path-segment".repeat(20)}`,
|
||||
});
|
||||
|
||||
const classes = screen.getByTestId("tooltip-content").classList;
|
||||
expect(classes.contains("max-w-[calc(100vw-2rem)]")).toBe(true);
|
||||
expect(classes.contains("break-all")).toBe(true);
|
||||
});
|
||||
|
||||
it("releases picker tooltip suppression when the keyboard leaves the trigger", async () => {
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
});
|
||||
vi.stubGlobal("cancelAnimationFrame", vi.fn());
|
||||
|
||||
renderTooltipPill({ options: [{ value: "repo-1", label: "repo-one" }] });
|
||||
|
||||
const trigger = screen.getByRole("button", { name: new RegExp(TOOLTIP_PILL_VALUE, "i") });
|
||||
fireEvent.click(trigger);
|
||||
fireEvent.click(await screen.findByRole("option", { name: "repo-one" }));
|
||||
fireEvent.blur(trigger);
|
||||
fireEvent.focus(trigger);
|
||||
|
||||
expect(screen.getByTestId(TOOLTIP_ROOT_TEST_ID).getAttribute("data-open")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Pill popover", () => {
|
||||
|
||||
@@ -87,11 +87,13 @@ function pillActiveClass(flat: boolean): string {
|
||||
function PillCommandList({
|
||||
options,
|
||||
onSelect,
|
||||
onPointerSelect,
|
||||
setOpen,
|
||||
emptyMessage,
|
||||
}: {
|
||||
options: PillOption[];
|
||||
onSelect: (value: string) => void;
|
||||
onPointerSelect: (pointerType: string) => void;
|
||||
setOpen: (open: boolean) => void;
|
||||
emptyMessage: string;
|
||||
}) {
|
||||
@@ -104,6 +106,7 @@ function PillCommandList({
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
keywords={[option.label, ...(option.keywords ?? [])]}
|
||||
onPointerDown={(event) => onPointerSelect(event.pointerType)}
|
||||
onSelect={() => {
|
||||
onSelect(option.value);
|
||||
setOpen(false);
|
||||
@@ -130,6 +133,88 @@ function pillTriggerClass(disabled: boolean, flat: boolean, hasValue: boolean):
|
||||
);
|
||||
}
|
||||
|
||||
function usePillTooltipSuppression(open: boolean) {
|
||||
const [suppressTooltip, setSuppressTooltip] = useState(false);
|
||||
const suppressTooltipRef = useRef(false);
|
||||
const pointerInsideTriggerRef = useRef(false);
|
||||
const suppressionReleaseOnExitRef = useRef(true);
|
||||
const suppressionReleaseArmedRef = useRef(false);
|
||||
const suppressionReleaseFrameRef = useRef<number | null>(null);
|
||||
const clearTooltipSuppression = useCallback(() => {
|
||||
if (suppressionReleaseFrameRef.current !== null) {
|
||||
cancelAnimationFrame(suppressionReleaseFrameRef.current);
|
||||
suppressionReleaseFrameRef.current = null;
|
||||
}
|
||||
suppressionReleaseArmedRef.current = false;
|
||||
suppressionReleaseOnExitRef.current = true;
|
||||
suppressTooltipRef.current = false;
|
||||
setSuppressTooltip(false);
|
||||
}, []);
|
||||
const suppressTooltipUntilLeave = useCallback(
|
||||
(releaseOnExit = true) => {
|
||||
if (suppressionReleaseFrameRef.current !== null) {
|
||||
cancelAnimationFrame(suppressionReleaseFrameRef.current);
|
||||
}
|
||||
suppressionReleaseOnExitRef.current = releaseOnExit;
|
||||
suppressionReleaseArmedRef.current = false;
|
||||
suppressTooltipRef.current = true;
|
||||
setSuppressTooltip(true);
|
||||
suppressionReleaseFrameRef.current = requestAnimationFrame(() => {
|
||||
suppressionReleaseFrameRef.current = requestAnimationFrame(() => {
|
||||
suppressionReleaseFrameRef.current = null;
|
||||
if (!pointerInsideTriggerRef.current && suppressionReleaseOnExitRef.current) {
|
||||
clearTooltipSuppression();
|
||||
return;
|
||||
}
|
||||
suppressionReleaseArmedRef.current = true;
|
||||
});
|
||||
});
|
||||
},
|
||||
[clearTooltipSuppression],
|
||||
);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (suppressionReleaseFrameRef.current !== null) {
|
||||
cancelAnimationFrame(suppressionReleaseFrameRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
const handlePointerEnter = useCallback(
|
||||
(event: React.PointerEvent) => {
|
||||
pointerInsideTriggerRef.current = true;
|
||||
if (
|
||||
event.pointerType !== "touch" &&
|
||||
!suppressionReleaseOnExitRef.current &&
|
||||
suppressTooltipRef.current
|
||||
) {
|
||||
clearTooltipSuppression();
|
||||
}
|
||||
},
|
||||
[clearTooltipSuppression],
|
||||
);
|
||||
const handlePointerLeave = useCallback(() => {
|
||||
pointerInsideTriggerRef.current = false;
|
||||
if (!open && suppressionReleaseOnExitRef.current && suppressionReleaseArmedRef.current) {
|
||||
clearTooltipSuppression();
|
||||
}
|
||||
}, [clearTooltipSuppression, open]);
|
||||
const handleBlur = useCallback(() => {
|
||||
if (!open && suppressionReleaseOnExitRef.current && suppressionReleaseArmedRef.current) {
|
||||
clearTooltipSuppression();
|
||||
}
|
||||
}, [clearTooltipSuppression, open]);
|
||||
|
||||
return {
|
||||
suppressTooltip,
|
||||
suppressTooltipRef,
|
||||
suppressTooltipUntilLeave,
|
||||
handlePointerEnter,
|
||||
handlePointerLeave,
|
||||
handleBlur,
|
||||
};
|
||||
}
|
||||
|
||||
function DisabledPillTooltip({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -163,6 +248,7 @@ function PillPopoverContent({
|
||||
refreshLabel,
|
||||
options,
|
||||
onSelect,
|
||||
onPointerSelect,
|
||||
setOpen,
|
||||
emptyMessage,
|
||||
portalContainer,
|
||||
@@ -175,6 +261,7 @@ function PillPopoverContent({
|
||||
refreshLabel?: string;
|
||||
options: PillOption[];
|
||||
onSelect: (value: string) => void;
|
||||
onPointerSelect: (pointerType: string) => void;
|
||||
setOpen: (open: boolean) => void;
|
||||
emptyMessage: string;
|
||||
portalContainer: HTMLElement | null;
|
||||
@@ -223,6 +310,7 @@ function PillPopoverContent({
|
||||
<PillCommandList
|
||||
options={options}
|
||||
onSelect={onSelect}
|
||||
onPointerSelect={onPointerSelect}
|
||||
setOpen={setOpen}
|
||||
emptyMessage={emptyMessage}
|
||||
/>
|
||||
@@ -231,6 +319,58 @@ function PillPopoverContent({
|
||||
);
|
||||
}
|
||||
|
||||
function PillPopover({
|
||||
open,
|
||||
setOpen,
|
||||
triggerButton,
|
||||
filter,
|
||||
searchPlaceholder,
|
||||
onRefresh,
|
||||
refreshing,
|
||||
refreshLabel,
|
||||
options,
|
||||
onSelect,
|
||||
onPointerSelect,
|
||||
emptyMessage,
|
||||
portalContainer,
|
||||
action,
|
||||
}: {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
triggerButton: React.ReactElement;
|
||||
filter?: PillProps["filter"];
|
||||
searchPlaceholder: string;
|
||||
onRefresh?: () => void;
|
||||
refreshing?: boolean;
|
||||
refreshLabel?: string;
|
||||
options: PillOption[];
|
||||
onSelect: (value: string) => void;
|
||||
onPointerSelect: (pointerType: string) => void;
|
||||
emptyMessage: string;
|
||||
portalContainer: HTMLElement | null;
|
||||
action?: PillAction;
|
||||
}) {
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>{triggerButton}</PopoverTrigger>
|
||||
<PillPopoverContent
|
||||
filter={filter}
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
onRefresh={onRefresh}
|
||||
refreshing={refreshing}
|
||||
refreshLabel={refreshLabel}
|
||||
options={options}
|
||||
onSelect={onSelect}
|
||||
onPointerSelect={onPointerSelect}
|
||||
setOpen={setOpen}
|
||||
emptyMessage={emptyMessage}
|
||||
portalContainer={portalContainer}
|
||||
action={action}
|
||||
/>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function renderPillTriggerButton({
|
||||
icon,
|
||||
value,
|
||||
@@ -240,8 +380,14 @@ function renderPillTriggerButton({
|
||||
hasValue,
|
||||
testId,
|
||||
prefix,
|
||||
onPointerEnter,
|
||||
onPointerLeave,
|
||||
onBlur,
|
||||
}: Pick<PillProps, "icon" | "value" | "placeholder" | "disabled" | "flat" | "testId" | "prefix"> & {
|
||||
hasValue: boolean;
|
||||
onPointerEnter?: React.PointerEventHandler<HTMLButtonElement>;
|
||||
onPointerLeave?: React.PointerEventHandler<HTMLButtonElement>;
|
||||
onBlur?: React.FocusEventHandler<HTMLButtonElement>;
|
||||
}): React.ReactElement {
|
||||
const showPrefix = !!prefix && hasValue;
|
||||
return (
|
||||
@@ -250,6 +396,9 @@ function renderPillTriggerButton({
|
||||
disabled={disabled}
|
||||
data-testid={testId}
|
||||
className={pillTriggerClass(Boolean(disabled), Boolean(flat), hasValue)}
|
||||
onPointerEnter={onPointerEnter}
|
||||
onPointerLeave={onPointerLeave}
|
||||
onBlur={onBlur}
|
||||
>
|
||||
{icon}
|
||||
<span className="truncate max-w-[240px]">
|
||||
@@ -260,6 +409,33 @@ function renderPillTriggerButton({
|
||||
);
|
||||
}
|
||||
|
||||
function usePillOpenHandlers(
|
||||
setOpenState: React.Dispatch<React.SetStateAction<boolean>>,
|
||||
closeTooltip: () => void,
|
||||
suppressTooltipUntilLeave: (releaseOnExit?: boolean) => void,
|
||||
) {
|
||||
const selectionPointerTypeRef = useRef("");
|
||||
const suppressForSelection = useCallback(() => {
|
||||
suppressTooltipUntilLeave(selectionPointerTypeRef.current !== "touch");
|
||||
}, [suppressTooltipUntilLeave]);
|
||||
const setOpen = useCallback(
|
||||
(next: boolean) => {
|
||||
if (next) {
|
||||
closeTooltip();
|
||||
selectionPointerTypeRef.current = "";
|
||||
} else {
|
||||
suppressForSelection();
|
||||
}
|
||||
setOpenState(next);
|
||||
},
|
||||
[closeTooltip, setOpenState, suppressForSelection],
|
||||
);
|
||||
const recordPointerSelection = useCallback((pointerType: string) => {
|
||||
selectionPointerTypeRef.current = pointerType;
|
||||
}, []);
|
||||
return { setOpen, suppressForSelection, recordPointerSelection };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact pill trigger that opens a popover with a search list. Auto-widths
|
||||
* to its content (no `w-full`, no chevron) so multiple pills can sit on one
|
||||
@@ -288,33 +464,25 @@ export function Pill({
|
||||
const [open, setOpenState] = useState(false);
|
||||
const { tooltipOpenState, handleTooltipOpenChange, closeTooltip } = useTooltipMountGate();
|
||||
const portalContainer = useTaskCreateDialogPopoverContainer();
|
||||
const [suppressTooltip, setSuppressTooltip] = useState(false);
|
||||
const suppressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const setOpen = useCallback(
|
||||
(next: boolean) => {
|
||||
if (next) closeTooltip();
|
||||
setOpenState((prev) => {
|
||||
if (prev && !next) {
|
||||
if (suppressTimerRef.current) clearTimeout(suppressTimerRef.current);
|
||||
setSuppressTooltip(true);
|
||||
suppressTimerRef.current = setTimeout(() => {
|
||||
setSuppressTooltip(false);
|
||||
suppressTimerRef.current = null;
|
||||
}, 200);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[closeTooltip],
|
||||
const {
|
||||
suppressTooltip,
|
||||
suppressTooltipRef,
|
||||
suppressTooltipUntilLeave,
|
||||
handlePointerEnter,
|
||||
handlePointerLeave,
|
||||
handleBlur,
|
||||
} = usePillTooltipSuppression(open);
|
||||
const { setOpen, suppressForSelection, recordPointerSelection } = usePillOpenHandlers(
|
||||
setOpenState,
|
||||
closeTooltip,
|
||||
suppressTooltipUntilLeave,
|
||||
);
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (suppressTimerRef.current) {
|
||||
clearTimeout(suppressTimerRef.current);
|
||||
suppressTimerRef.current = null;
|
||||
}
|
||||
const handlePillTooltipOpenChange = useCallback(
|
||||
(next: boolean) => {
|
||||
if (next && suppressTooltipRef.current) return;
|
||||
handleTooltipOpenChange(next);
|
||||
},
|
||||
[],
|
||||
[handleTooltipOpenChange],
|
||||
);
|
||||
const triggerButton = renderPillTriggerButton({
|
||||
icon,
|
||||
@@ -325,6 +493,9 @@ export function Pill({
|
||||
hasValue: Boolean(value),
|
||||
testId,
|
||||
prefix,
|
||||
onPointerEnter: tooltip ? handlePointerEnter : undefined,
|
||||
onPointerLeave: tooltip ? handlePointerLeave : undefined,
|
||||
onBlur: tooltip ? handleBlur : undefined,
|
||||
});
|
||||
|
||||
// Disabled buttons swallow pointer/focus events, so the wrapper owns tooltip
|
||||
@@ -341,35 +512,39 @@ export function Pill({
|
||||
}
|
||||
|
||||
const popover = (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
{tooltip ? <TooltipTrigger asChild>{triggerButton}</TooltipTrigger> : triggerButton}
|
||||
</PopoverTrigger>
|
||||
<PillPopoverContent
|
||||
filter={filter}
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
onRefresh={onRefresh}
|
||||
refreshing={refreshing}
|
||||
refreshLabel={refreshLabel}
|
||||
options={options}
|
||||
onSelect={onSelect}
|
||||
setOpen={setOpen}
|
||||
emptyMessage={emptyMessage}
|
||||
portalContainer={portalContainer}
|
||||
action={action}
|
||||
/>
|
||||
</Popover>
|
||||
<PillPopover
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
triggerButton={
|
||||
tooltip ? <TooltipTrigger asChild>{triggerButton}</TooltipTrigger> : triggerButton
|
||||
}
|
||||
filter={filter}
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
onRefresh={onRefresh}
|
||||
refreshing={refreshing}
|
||||
refreshLabel={refreshLabel}
|
||||
options={options}
|
||||
onPointerSelect={recordPointerSelection}
|
||||
onSelect={(selectedValue) => {
|
||||
if (tooltip) suppressForSelection();
|
||||
onSelect(selectedValue);
|
||||
}}
|
||||
emptyMessage={emptyMessage}
|
||||
portalContainer={portalContainer}
|
||||
action={action}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!tooltip) return popover;
|
||||
|
||||
// Suppress the hover tooltip while the popover is open, then briefly after
|
||||
// close so lingering hover cannot immediately reopen it.
|
||||
const tooltipOpen = open || suppressTooltip ? false : tooltipOpenState;
|
||||
// Suppress the hover tooltip while the popover is open and until the pointer
|
||||
// leaves after close, so a selection cannot disclose a tooltip underneath it.
|
||||
const tooltipOpen =
|
||||
open || suppressTooltip || suppressTooltipRef.current ? false : tooltipOpenState;
|
||||
return (
|
||||
<Tooltip open={tooltipOpen} onOpenChange={handleTooltipOpenChange}>
|
||||
<Tooltip open={tooltipOpen} onOpenChange={handlePillTooltipOpenChange}>
|
||||
{popover}
|
||||
<TooltipContent>{tooltip}</TooltipContent>
|
||||
<TooltipContent className="max-w-[calc(100vw-2rem)] break-all">{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,94 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { OfficeApiClient } from "../../helpers/office-api-client";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
import { WorkflowSettingsPage } from "../../pages/workflow-settings-page";
|
||||
|
||||
test.describe("Workspace settings", () => {
|
||||
test("creates a Kanban workflow with template steps through workspace settings", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
prCapture,
|
||||
}) => {
|
||||
const workspaceName = `Settings Kanban ${Date.now().toString(36)}`;
|
||||
const taskTitle = `Kanban Bootstrap Task ${Date.now().toString(36)}`;
|
||||
|
||||
await testPage.goto("/settings/workspace");
|
||||
await testPage.getByRole("button", { name: "Add Workspace" }).click();
|
||||
await testPage.getByLabel("Workspace Name").fill(workspaceName);
|
||||
await testPage.locator("form").getByRole("button", { name: "Add Workspace" }).click();
|
||||
|
||||
// Follow the newly rendered workspace link rather than seeding or creating
|
||||
// it through the API: this covers the standard Settings creation flow.
|
||||
const workspaceLink = testPage.getByRole("link").filter({
|
||||
has: testPage.getByRole("heading", { name: workspaceName, exact: true }),
|
||||
});
|
||||
await expect(workspaceLink).toBeVisible();
|
||||
const workspaceHref = await workspaceLink.getAttribute("href");
|
||||
const workspaceId = new URL(workspaceHref ?? "", "http://kandev.test").pathname.split("/")[3];
|
||||
expect(workspaceId).toBeTruthy();
|
||||
let createdTaskId: string | undefined;
|
||||
|
||||
try {
|
||||
await workspaceLink.click();
|
||||
|
||||
await expect(
|
||||
testPage.getByRole("heading", { name: workspaceName, exact: true }),
|
||||
).toBeVisible();
|
||||
await testPage
|
||||
.getByTestId("settings-scroll-container")
|
||||
.getByRole("link", { name: "Workflows", exact: true })
|
||||
.click();
|
||||
|
||||
const workflows = new WorkflowSettingsPage(testPage);
|
||||
const kanbanCard = await workflows.findWorkflowCard("Kanban");
|
||||
await expect(kanbanCard).toBeVisible();
|
||||
for (const stepName of ["Backlog", "In Progress", "Review", "Done"]) {
|
||||
await expect(workflows.stepNodeByName(kanbanCard, stepName)).toBeVisible();
|
||||
}
|
||||
await prCapture.screenshot("desktop-kanban-workspace-bootstrap", {
|
||||
caption:
|
||||
"A workspace created from Settings includes the Kanban workflow and its four ready-to-use steps.",
|
||||
});
|
||||
|
||||
// Select the new workspace from the user-facing picker, then verify the
|
||||
// bootstrap immediately makes its workflow usable by the standard New
|
||||
// Task flow. Scratch mode avoids introducing a repository as test setup.
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
await testPage.getByTestId("sidebar-workspace-trigger").click();
|
||||
await testPage.getByTestId(`sidebar-workspace-item-${workspaceId}`).click();
|
||||
await expect(testPage).toHaveURL(
|
||||
(url) => url.pathname === "/" && url.searchParams.get("workspaceId") === workspaceId,
|
||||
);
|
||||
await expect(kanban.board).toBeVisible();
|
||||
|
||||
await kanban.createTaskButton.first().click();
|
||||
const dialog = testPage.getByTestId("create-task-dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
await dialog.getByTestId("source-mode-scratch").click();
|
||||
await dialog.getByTestId("task-title-input").fill(taskTitle);
|
||||
await dialog.getByTestId("task-description-input").fill("Created without starting an agent");
|
||||
await expect(dialog.getByTestId("submit-start-agent-chevron")).toBeEnabled();
|
||||
|
||||
const created = testPage.waitForResponse(
|
||||
(response) =>
|
||||
response.url().endsWith("/api/v1/tasks") && response.request().method() === "POST",
|
||||
);
|
||||
await dialog.getByTestId("submit-start-agent-chevron").click();
|
||||
await testPage.getByTestId("submit-create-without-agent").click();
|
||||
const createdTask = (await created).json() as Promise<{ id?: string }>;
|
||||
createdTaskId = (await createdTask).id;
|
||||
|
||||
await expect(dialog).not.toBeVisible();
|
||||
await expect(testPage).toHaveURL(/\/t\//);
|
||||
await expect(testPage.getByRole("link", { name: taskTitle, exact: true })).toBeVisible();
|
||||
} finally {
|
||||
if (createdTaskId) await apiClient.deleteTask(createdTaskId);
|
||||
if (workspaceId) await apiClient.deleteWorkspace(workspaceId, workspaceName);
|
||||
}
|
||||
});
|
||||
|
||||
test.describe("Workspace settings deletion", () => {
|
||||
test("deletes a workspace from the settings edit page", async ({ testPage, apiClient }) => {
|
||||
const suffix = Date.now().toString(36);
|
||||
const workspaceName = `Settings Delete ${suffix}`;
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "../../fixtures/test-base";
|
||||
import { makeGitEnv } from "../../helpers/git-helper";
|
||||
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
|
||||
import { useRegularMode } from "../../helpers/regular-mode";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
|
||||
useRegularMode();
|
||||
|
||||
test.describe("Create task repository tooltip", () => {
|
||||
test("contains a long path and waits for a deliberate re-hover after picker selection", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
prCapture,
|
||||
}) => {
|
||||
const longRepoDir = path.join(backend.tmpDir, "repos", "x".repeat(180));
|
||||
fs.mkdirSync(longRepoDir, { recursive: true });
|
||||
const gitEnv = makeGitEnv(backend.tmpDir);
|
||||
execSync("git init -b main", { cwd: longRepoDir, env: gitEnv });
|
||||
execSync('git commit --allow-empty -m "init"', { cwd: longRepoDir, env: gitEnv });
|
||||
const repository = await apiClient.createRepository(seedData.workspaceId, longRepoDir, "main", {
|
||||
name: "Long path repository",
|
||||
});
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
await kanban.createTaskButton.first().click();
|
||||
|
||||
const dialog = testPage.getByTestId("create-task-dialog");
|
||||
const trigger = dialog.getByTestId("repo-chip-trigger").first();
|
||||
await trigger.hover();
|
||||
await trigger.click();
|
||||
await testPage.getByPlaceholder("Search repositories...").fill("Long path repository");
|
||||
await trigger.hover();
|
||||
await testPage.keyboard.press("ArrowDown");
|
||||
await testPage.keyboard.press("Enter");
|
||||
await expect(dialog.getByTestId("repo-chip").first()).toHaveAttribute(
|
||||
"data-repository-id",
|
||||
repository.id,
|
||||
);
|
||||
|
||||
const tooltip = testPage.getByRole("tooltip").filter({ hasText: longRepoDir });
|
||||
expect(await trigger.evaluate((element) => element.matches(":hover"))).toBe(true);
|
||||
await testPage.waitForTimeout(300);
|
||||
await expect(tooltip).toBeHidden();
|
||||
|
||||
await testPage.mouse.move(0, 0);
|
||||
await trigger.hover();
|
||||
await expect(tooltip).toBeVisible();
|
||||
|
||||
const [tooltipBox, viewport] = await Promise.all([
|
||||
tooltip.boundingBox(),
|
||||
testPage.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight })),
|
||||
]);
|
||||
expect(tooltipBox).not.toBeNull();
|
||||
expect(tooltipBox!.x).toBeGreaterThanOrEqual(0);
|
||||
expect(tooltipBox!.y).toBeGreaterThanOrEqual(0);
|
||||
expect(tooltipBox!.x + tooltipBox!.width).toBeLessThanOrEqual(viewport.width);
|
||||
expect(tooltipBox!.y + tooltipBox!.height).toBeLessThanOrEqual(viewport.height);
|
||||
await assertNoDocumentHorizontalOverflow(testPage, "long repository tooltip");
|
||||
await prCapture.screenshot("desktop-long-repository-tooltip", {
|
||||
caption:
|
||||
"New Task keeps a deliberately long repository path wrapped inside the desktop viewport.",
|
||||
});
|
||||
});
|
||||
|
||||
test("opens on the first later mouse hover after picker selection", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
}) => {
|
||||
const repositoryDir = path.join(backend.tmpDir, "repos", "mouse-tooltip-repository");
|
||||
fs.mkdirSync(repositoryDir, { recursive: true });
|
||||
const gitEnv = makeGitEnv(backend.tmpDir);
|
||||
execSync("git init -b main", { cwd: repositoryDir, env: gitEnv });
|
||||
execSync('git commit --allow-empty -m "init"', { cwd: repositoryDir, env: gitEnv });
|
||||
const repository = await apiClient.createRepository(
|
||||
seedData.workspaceId,
|
||||
repositoryDir,
|
||||
"main",
|
||||
{
|
||||
name: "Mouse tooltip repository",
|
||||
},
|
||||
);
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
await kanban.createTaskButton.first().click();
|
||||
|
||||
const dialog = testPage.getByTestId("create-task-dialog");
|
||||
const trigger = dialog.getByTestId("repo-chip-trigger").first();
|
||||
await trigger.hover();
|
||||
await trigger.click();
|
||||
await testPage.getByPlaceholder("Search repositories...").fill("Mouse tooltip repository");
|
||||
await testPage.getByRole("option", { name: "Mouse tooltip repository" }).click();
|
||||
await expect(dialog.getByTestId("repo-chip").first()).toHaveAttribute(
|
||||
"data-repository-id",
|
||||
repository.id,
|
||||
);
|
||||
|
||||
await testPage.waitForTimeout(300);
|
||||
await testPage.mouse.move(0, 0);
|
||||
await trigger.hover();
|
||||
await expect(testPage.getByRole("tooltip").filter({ hasText: repositoryDir })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,15 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
|
||||
import { useRegularMode } from "../../helpers/regular-mode";
|
||||
import { MobileKanbanPage } from "../../pages/mobile-kanban-page";
|
||||
|
||||
useRegularMode();
|
||||
|
||||
test.describe("Create task workspace repository picker on mobile", () => {
|
||||
test("marks another row's repository while keeping it selectable", async ({ testPage }) => {
|
||||
test("marks another row's repository while keeping it selectable", async ({
|
||||
testPage,
|
||||
prCapture,
|
||||
}) => {
|
||||
const mobile = new MobileKanbanPage(testPage);
|
||||
await mobile.goto();
|
||||
await mobile.mobileFab.click();
|
||||
@@ -17,12 +21,19 @@ test.describe("Create task workspace repository picker on mobile", () => {
|
||||
|
||||
await dialog.getByTestId("add-repository").click();
|
||||
await expect(repositoryChips).toHaveCount(2);
|
||||
await repositoryChips.nth(1).click();
|
||||
await repositoryChips.nth(1).tap();
|
||||
|
||||
const selectedElsewhere = testPage.getByRole("option", { name: /^E2E Repo/ });
|
||||
await expect(selectedElsewhere).toBeVisible();
|
||||
await expect(selectedElsewhere.getByTestId("already-added-repository-marker")).toBeVisible();
|
||||
await selectedElsewhere.click();
|
||||
await selectedElsewhere.tap();
|
||||
await expect(repositoryChips.nth(1)).toContainText("E2E Repo");
|
||||
await testPage.waitForTimeout(300);
|
||||
await expect(testPage.getByRole("tooltip")).toBeHidden();
|
||||
await assertNoDocumentHorizontalOverflow(testPage, "repository picker selection");
|
||||
await prCapture.screenshot("mobile-repository-chip-selection", {
|
||||
caption:
|
||||
"After mobile repository selection, the chip stays contained and no tooltip is shown.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
spec: docs/specs/workspaces/creation.md
|
||||
created: 2026-07-23
|
||||
status: implemented
|
||||
---
|
||||
|
||||
# Implementation Plan: Creation flow reliability
|
||||
|
||||
## Overview
|
||||
|
||||
Fix the task-create repository tooltip regression independently from the Kanban workspace bootstrap
|
||||
behavior, then verify both through their existing user-facing creation flows. The backend change
|
||||
keeps Office onboarding separate while the frontend change preserves the current desktop and mobile
|
||||
dialog composition.
|
||||
|
||||
Related requirement: `docs/specs/tasks/multi-branch/spec.md` task-creation scenarios.
|
||||
|
||||
## Backend
|
||||
|
||||
### Kanban workspace bootstrap
|
||||
|
||||
- Extend `service.CreateWorkspaceRequest` with an internal creation-mode signal used only by the
|
||||
standard HTTP and WebSocket Kanban handlers.
|
||||
- When that signal is set, persist the workspace, a `Kanban` workflow from template `simple`, and
|
||||
its template-derived steps in one repository transaction. Return an error without publishing
|
||||
either resource if any part of the transaction fails.
|
||||
- Set the signal in `internal/task/handlers/workspace_handlers.go`; leave
|
||||
`internal/backendapp/adapters_office.go` unchanged so Office onboarding does not opt in.
|
||||
- Preserve the existing workspace response shape and publish the existing workspace/workflow events.
|
||||
|
||||
## Frontend
|
||||
|
||||
### Repository chip tooltip
|
||||
|
||||
- In `components/task-create-dialog-pill.tsx`, replace the fixed post-popover suppression timer with
|
||||
suppression that lasts until the pointer leaves the trigger.
|
||||
- Make repository-path tooltip content wrap unbroken paths and cap its width to the viewport.
|
||||
- Keep the existing dialog, searchable popover, keyboard focus, and touch behavior unchanged.
|
||||
|
||||
### Mobile design contract
|
||||
|
||||
- Desktop outcome and mobile entry point remain the existing New Task dialog and repository chips.
|
||||
- Nearest shipped mobile exemplar:
|
||||
`e2e/tests/task/mobile-create-task-repository-selection.spec.ts`; reuse its same dialog/pill flow.
|
||||
- Presentation remains the existing dialog and touch-usable picker popover because this fix changes
|
||||
disclosure timing and path containment, not navigation, hierarchy, scrolling, or primary action.
|
||||
- Shared `Pill` state owns the behavior on all viewports. No mobile-only state or persisted fallback
|
||||
is introduced.
|
||||
- The dialog remains the scroll owner; tooltip width is viewport-safe and no document horizontal
|
||||
overflow is introduced.
|
||||
|
||||
## Tests
|
||||
|
||||
- **Post-selection suppression:** update `components/task-create-dialog-pill.test.tsx` to prove a
|
||||
closed picker ignores tooltip-open requests until pointer leave, then allows a later deliberate
|
||||
hover.
|
||||
- **Long path containment:** assert the repository tooltip receives wrapping and viewport-width
|
||||
classes.
|
||||
- **Kanban workspace bootstrap:** add a focused backend handler/integration regression proving a
|
||||
standard workspace creation produces one `Kanban` workflow with template-derived steps.
|
||||
- **Office exclusion:** add or extend an adapter/onboarding test proving the direct Office creation
|
||||
path does not opt into Kanban bootstrap.
|
||||
|
||||
## E2E Tests
|
||||
|
||||
- Add a desktop fine-pointer regression that renders the real Radix tooltip for a long unbroken
|
||||
repository path, proves post-selection suppression through a deliberate leave/re-hover, and checks
|
||||
its bounding box against the viewport.
|
||||
- Extend `apps/web/e2e/tests/task/mobile-create-task-repository-selection.spec.ts` with the
|
||||
post-selection no-tooltip and viewport containment assertions.
|
||||
- Add or extend the workspace settings creation flow to create a workspace through the UI, then
|
||||
verify its workflow is visible and the task-create flow is immediately usable.
|
||||
|
||||
## Implementation Waves
|
||||
|
||||
Wave 1 (parallel):
|
||||
|
||||
- [x] [task-01-repository-tooltip](task-01-repository-tooltip.md) — implementer, balanced tier
|
||||
- [x] [task-02-kanban-workspace-bootstrap](task-02-kanban-workspace-bootstrap.md) — implementer,
|
||||
balanced tier
|
||||
|
||||
Wave 2:
|
||||
|
||||
- Integrate, run focused E2E, simplify, commit, and run delegated change-aware verification.
|
||||
|
||||
## Verification
|
||||
|
||||
- `cd apps && pnpm --filter @kandev/web test -- --run components/task-create-dialog-pill.test.tsx`
|
||||
- `cd apps/backend && go test -run 'Test.*CreateWorkspace.*Kanban|Test.*Office.*Workspace' ./internal/task/handlers ./internal/backendapp`
|
||||
- `make build-backend build-web`
|
||||
- `cd apps/web && pnpm e2e --project=chromium tests/task/create-task-repository-tooltip.spec.ts`
|
||||
- `cd apps/web && pnpm e2e -- tests/task/mobile-create-task-repository-selection.spec.ts`
|
||||
- Focused workspace-creation Playwright spec selected during implementation from the nearest existing
|
||||
settings coverage.
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
id: "01-repository-tooltip"
|
||||
title: "Repository tooltip containment and disclosure"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/tasks/multi-branch/spec.md"
|
||||
---
|
||||
|
||||
# Task 01: Repository tooltip containment and disclosure
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Long unbroken repository paths wrap inside a viewport-safe tooltip without covering neighboring
|
||||
repository controls.
|
||||
- Closing a repository picker suppresses its tooltip until the pointer leaves; a later deliberate
|
||||
hover can open it.
|
||||
- Existing keyboard-focus, popover selection, and mobile repository-selection behavior remain
|
||||
usable.
|
||||
|
||||
## Verification
|
||||
|
||||
- `cd apps && pnpm --filter @kandev/web test -- --run components/task-create-dialog-pill.test.tsx`
|
||||
- `make build-web`
|
||||
- `cd apps/web && pnpm e2e -- tests/task/mobile-create-task-repository-selection.spec.ts`
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/components/task-create-dialog-pill.tsx`
|
||||
- `apps/web/components/task-create-dialog-pill.test.tsx`
|
||||
- `apps/web/components/task-create-dialog-workspace-repo-chips.tsx`
|
||||
- `apps/web/e2e/tests/task/mobile-create-task-repository-selection.spec.ts`
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Inputs
|
||||
|
||||
- `docs/specs/tasks/multi-branch/spec.md` task-creation scenarios.
|
||||
- `docs/plans/creation-flow-reliability/plan.md` frontend and mobile design sections.
|
||||
- Existing `useTooltipMountGate` behavior and Radix tooltip guidance in `apps/web/AGENTS.md`.
|
||||
|
||||
## Output contract
|
||||
|
||||
Return a compact handoff capsule with intent/acceptance, base/head SHA, changed files and entry
|
||||
points, risk tags, exact commands/results, uncertainties, and this task status set to `done`.
|
||||
|
||||
## Completion
|
||||
|
||||
- Tooltip content wraps long unbroken paths inside a viewport-width cap.
|
||||
- Post-picker suppression is armed after the close settles, so transient focus/pointer events cannot
|
||||
reveal the tooltip; a later pointer leave or keyboard blur releases it.
|
||||
- Unit, mobile touch, and real desktop Radix coverage exercise the disclosure and containment paths.
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
id: "02-kanban-workspace-bootstrap"
|
||||
title: "Kanban workspace workflow bootstrap"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/workspaces/creation.md"
|
||||
---
|
||||
|
||||
# Task 02: Kanban workspace workflow bootstrap
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Standard HTTP and WebSocket workspace creation produce one visible `Kanban` workflow from the
|
||||
built-in `simple` template with usable steps.
|
||||
- Standard creation persists the workspace, Kanban workflow, and template-derived steps in one
|
||||
transaction; cancellation or any persistence failure leaves none of those rows visible.
|
||||
- A workflow bootstrap failure makes the standard creation request fail.
|
||||
- Office onboarding's direct workspace creation path does not implicitly add a Kanban workflow.
|
||||
|
||||
## Verification
|
||||
|
||||
- `cd apps/backend && go test -run 'Test.*CreateWorkspace.*Kanban|Test.*Office.*Workspace' ./internal/task/handlers ./internal/backendapp`
|
||||
- Run any directly affected backend integration package tests identified while updating fixtures.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/backend/internal/task/service/service_requests.go`
|
||||
- `apps/backend/internal/task/service/service_resources.go`
|
||||
- `apps/backend/internal/task/handlers/workspace_handlers.go`
|
||||
- Focused `*_test.go` files beside the changed handler/service or in backend integration tests.
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Inputs
|
||||
|
||||
- `docs/specs/workspaces/creation.md`.
|
||||
- `docs/plans/creation-flow-reliability/plan.md` backend section.
|
||||
- Built-in template `apps/backend/config/workflows/kanban.yml`.
|
||||
- Office adapter `apps/backend/internal/backendapp/adapters_office.go`.
|
||||
|
||||
## Output contract
|
||||
|
||||
Return a compact handoff capsule with intent/acceptance, base/head SHA, changed files and entry
|
||||
points, risk tags, exact commands/results, uncertainties, and this task status set to `done`.
|
||||
|
||||
## Completion
|
||||
|
||||
- Standard HTTP and WebSocket creation opt into the built-in `simple` Kanban workflow bootstrap.
|
||||
- Bootstrap is strict and atomic: workflow or template-step creation errors, including request
|
||||
cancellation, roll back the workspace, workflow, and any partial steps together. No compensating
|
||||
cleanup path is used because no bootstrap row becomes visible before commit.
|
||||
- Successful creation publishes the workspace event before the child workflow event.
|
||||
- Office's direct service adapter remains opt-out and is covered by regression tests.
|
||||
@@ -23,15 +23,18 @@ Workflow position and runtime state are different. Moving a card changes its wor
|
||||
|
||||
## Prepare a workspace
|
||||
|
||||
A new workspace created by a user does not automatically receive a workflow.
|
||||
A new workspace created from **Settings → Workspaces** automatically receives a **Kanban** workflow
|
||||
with the built-in Kanban steps, so it can accept tasks immediately.
|
||||
|
||||
1. Open **Settings → Workspaces** and select **Add Workspace**.
|
||||
2. Enter the required workspace name.
|
||||
3. Open the workspace's **Repositories** page and add existing local repositories the workspace needs. You can also initialize a new empty repository while creating a task. Remote URLs are not registered on this page; enter them through **New Task → Remote**.
|
||||
4. Open its **Workflows** page and create, import, or synchronize a workflow.
|
||||
4. Open its **Workflows** page to review the default **Kanban** workflow. Create, import, or synchronize another workflow when the workspace needs a different process.
|
||||
5. On **Workspace Settings**, optionally choose a **Default Executor** and **Default Agent Profile**. Both default to **No default** unless configured.
|
||||
|
||||
The initial database bootstrap can include a **Default Workspace** and a simple development workflow. Do not assume a later workspace inherits them.
|
||||
The initial database bootstrap can include a **Default Workspace** and a **Development** workflow.
|
||||
Later user-created workspaces receive **Kanban** instead; they do not inherit other workflows or
|
||||
settings from the default workspace.
|
||||
|
||||
## Create a task
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@ Per-workspace credentials and triage triggers for external services.
|
||||
|
||||
| Spec | Status |
|
||||
|---|---|
|
||||
| [creation](workspaces/creation.md) | building |
|
||||
| [deletion](workspaces/deletion.md) | shipped |
|
||||
| [local-repositories](workspaces/local-repositories.md) | shipped |
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ Workarounds (sibling tasks, manually managing two worktrees) lost shared context
|
||||
- Repo chips in chat-message renderers now key on `(repository_id, checkout_branch)` so multi-branch tasks render distinct chips instead of collapsing.
|
||||
- In the task-creation dialog, every workspace, discovered-on-disk, and remote provider repository picker shows an accent-colored check (with an accessible `Already added` label) when another repository row already selects that repository. The current row does not mark its own selection.
|
||||
- Marked options remain selectable so users can intentionally create a multi-branch task from one repository. Removing or changing the other row removes the marker immediately.
|
||||
- Repository-chip tooltips keep long local paths inside a viewport-safe, wrapping surface. Closing a repository picker does not reveal its tooltip until the pointer leaves and deliberately hovers the chip again.
|
||||
- Review surfaces expose one linked pull request at a time when a task has multiple PRs. A task-scoped selector defaults to the primary (oldest) PR, remembers an in-session override, and falls back to the primary PR when that override disappears.
|
||||
- Selecting a PR changes the remote PR diff contribution while preserving the existing source precedence: uncommitted worktree changes, then cumulative committed changes, then the selected PR. PR-only views and PR timeline rows resolve the exact PR rather than the task primary.
|
||||
- The selector is available on desktop, phone, and coarse-pointer tablet. Phone uses a touch-sized bottom-menu treatment inside the existing Review surface; switching keeps Review open and exposes selected-PR loading, empty, and retry states.
|
||||
@@ -62,6 +63,8 @@ Workarounds (sibling tasks, manually managing two worktrees) lost shared context
|
||||
- **GIVEN** a task-creation Remote row selects a provider-backed repository, **WHEN** the user opens another Remote repository selector, **THEN** the same provider repository remains selectable and is visibly marked by the same compact accent-colored check.
|
||||
- **GIVEN** a repository is marked because another row selects it, **WHEN** the user changes or removes that other row, **THEN** the marker disappears from the open or next-opened selector.
|
||||
- **GIVEN** a row already selects a repository and no other row selects it, **WHEN** the user reopens that row's selector, **THEN** its current repository is not marked as a duplicate.
|
||||
- **GIVEN** a selected repository has a long unbroken local path, **WHEN** its tooltip opens, **THEN** the tooltip wraps the path and remains within the viewport instead of covering adjacent repository controls.
|
||||
- **GIVEN** a user selects a repository from a repository-chip picker, **WHEN** the picker closes while the pointer remains over the chip, **THEN** no repository tooltip opens until the pointer leaves and deliberately hovers the chip again.
|
||||
|
||||
## Non-goals
|
||||
|
||||
@@ -86,7 +89,7 @@ Workarounds (sibling tasks, manually managing two worktrees) lost shared context
|
||||
- `TestAddBranchToTask_HappyPath` — second branch appended after the fact lands as a new row.
|
||||
- `TestAddBranchToTask_RejectsDuplicate` — re-adding the same `(repo, branch)` errors.
|
||||
- `TestLaunchPreparedSession_MultiBranch_ReusesWorktreeIDsByBranchSlug` — a follow-on session for the same task reuses each existing branch worktree instead of preparing a new task directory.
|
||||
- Task-creation component and mobile E2E coverage prove the accessible, compact selected-repository marker for workspace/on-disk and Remote provider selectors while preserving option selection.
|
||||
- Task-creation component and mobile E2E coverage prove the accessible, compact selected-repository marker for workspace/on-disk and Remote provider selectors while preserving option selection, viewport-safe long-path tooltips, and post-selection tooltip suppression.
|
||||
- Web unit tests prove selected-PR default, override, task isolation, and removed-PR fallback behavior.
|
||||
- Desktop and mobile Playwright tests prove a two-PR task can switch Review from the primary PR to a sibling PR without stale files, overflow, or closing the surface.
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
status: building
|
||||
created: 2026-07-23
|
||||
owner: Kandev
|
||||
---
|
||||
|
||||
# Kanban workspace creation
|
||||
|
||||
## Why
|
||||
|
||||
A newly created Kanban workspace can currently contain no workflows, which leaves the task-create
|
||||
flow without a destination workflow or start step. Users should be able to create a task immediately
|
||||
after creating a Kanban workspace.
|
||||
|
||||
## What
|
||||
|
||||
- Creating a Kanban workspace also creates one visible workflow named `Kanban`.
|
||||
- The workflow uses the built-in Kanban template and includes its configured steps and automation.
|
||||
- The workspace response remains the existing workspace response; clients discover the created
|
||||
workflow through the existing workflow list and real-time update surfaces.
|
||||
- Office onboarding remains a separate creation path and continues to materialize its Office
|
||||
workflows without receiving an implicit Kanban workflow from this behavior.
|
||||
|
||||
## Data model
|
||||
|
||||
The feature reuses the existing `workspaces`, `workflows`, and `workflow_steps` entities. The
|
||||
created workflow belongs to the new workspace through `workflows.workspace_id`, references the
|
||||
built-in `simple` workflow template, and owns the template-derived workflow steps.
|
||||
|
||||
## API surface
|
||||
|
||||
The existing Kanban workspace creation contracts retain their request and workspace response shapes:
|
||||
|
||||
- `POST /api/v1/workspaces`
|
||||
- WebSocket `workspace.create`
|
||||
|
||||
Successful creation makes the new workflow available through the existing workflow list contracts.
|
||||
Office onboarding uses its existing service-level workspace creation path and is excluded.
|
||||
|
||||
## Failure modes
|
||||
|
||||
- If the workspace row cannot be created, creation fails as it does today.
|
||||
- The workspace, default Kanban workflow, and template-derived steps are committed in one
|
||||
transaction. If any insert fails or the request is canceled, the transaction rolls back and none
|
||||
of those rows become visible.
|
||||
|
||||
## Persistence guarantees
|
||||
|
||||
The default workflow and its steps are stored with the workspace and survive process restarts.
|
||||
This is creation-time behavior only; existing empty workspaces are not backfilled.
|
||||
|
||||
## Scenarios
|
||||
|
||||
- **GIVEN** a user creates a Kanban workspace, **WHEN** creation succeeds, **THEN** listing workflows
|
||||
for that workspace returns one visible `Kanban` workflow created from the built-in Kanban template.
|
||||
- **GIVEN** the newly created Kanban workflow, **WHEN** its steps are listed, **THEN** the
|
||||
template-derived start step and remaining Kanban steps are available so a task can be created
|
||||
immediately.
|
||||
- **GIVEN** Office onboarding creates an Office workspace, **WHEN** onboarding materializes its
|
||||
workflows, **THEN** no extra implicit Kanban workflow is added by Kanban workspace creation.
|
||||
- **GIVEN** Kanban bootstrap fails or the request is canceled before commit, **WHEN** persistence
|
||||
rolls back, **THEN** no workspace, workflow, or partial step from that attempt is visible.
|
||||
- **GIVEN** an existing empty workspace from an earlier version, **WHEN** Kandev starts after this
|
||||
change, **THEN** the workspace remains unchanged.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Backfilling workflows into existing empty workspaces.
|
||||
- Letting users choose a different default template during workspace creation.
|
||||
- Changing Office onboarding or Office workflow defaults.
|
||||
Reference in New Issue
Block a user