From 1f4fabf30a561e3203e4ac48187036a7a4978e12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Fri, 24 Jul 2026 09:55:18 +0100 Subject: [PATCH] fix: stabilize task and workspace creation (#1910) --- .../backendapp/adapters_office_test.go | 52 +++ .../internal/backendapp/helpers_test.go | 1 + apps/backend/internal/backendapp/services.go | 3 + .../internal/integration/integration_test.go | 1 + .../multi_client_integration_test.go | 5 +- .../internal/integration/test_server_test.go | 1 + .../integration/workflow_integration_test.go | 2 +- .../task/handlers/workspace_handlers.go | 2 + .../task/handlers/workspace_handlers_test.go | 76 ++++ .../task/repository/sqlite/workflow.go | 14 +- .../task/repository/sqlite/workspace.go | 19 +- .../repository/sqlite/workspace_bootstrap.go | 106 ++++++ .../task/repository/sqlite/workspace_test.go | 332 ++++++++++++++++++ apps/backend/internal/task/service/service.go | 12 + .../internal/task/service/service_requests.go | 1 + .../task/service/service_resources.go | 19 +- .../task/service/service_resources_test.go | 75 ++++ .../internal/task/service/service_test.go | 5 + .../task-create-dialog-pill.test.tsx | 187 ++++++++-- .../components/task-create-dialog-pill.tsx | 271 +++++++++++--- .../tests/settings/workspace-delete.spec.ts | 89 ++++- .../create-task-repository-tooltip.spec.ts | 111 ++++++ ...e-create-task-repository-selection.spec.ts | 17 +- docs/plans/creation-flow-reliability/plan.md | 94 +++++ .../task-01-repository-tooltip.md | 55 +++ .../task-02-kanban-workspace-bootstrap.md | 57 +++ docs/public/tasks-and-workflows.md | 9 +- docs/specs/INDEX.md | 1 + docs/specs/tasks/multi-branch/spec.md | 5 +- docs/specs/workspaces/creation.md | 70 ++++ 30 files changed, 1606 insertions(+), 86 deletions(-) create mode 100644 apps/backend/internal/task/repository/sqlite/workspace_bootstrap.go create mode 100644 apps/web/e2e/tests/task/create-task-repository-tooltip.spec.ts create mode 100644 docs/plans/creation-flow-reliability/plan.md create mode 100644 docs/plans/creation-flow-reliability/task-01-repository-tooltip.md create mode 100644 docs/plans/creation-flow-reliability/task-02-kanban-workspace-bootstrap.md create mode 100644 docs/specs/workspaces/creation.md diff --git a/apps/backend/internal/backendapp/adapters_office_test.go b/apps/backend/internal/backendapp/adapters_office_test.go index bebb830793..73a74c3f51 100644 --- a/apps/backend/internal/backendapp/adapters_office_test.go +++ b/apps/backend/internal/backendapp/adapters_office_test.go @@ -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")) diff --git a/apps/backend/internal/backendapp/helpers_test.go b/apps/backend/internal/backendapp/helpers_test.go index 6af8669aed..8fe30a243a 100644 --- a/apps/backend/internal/backendapp/helpers_test.go +++ b/apps/backend/internal/backendapp/helpers_test.go @@ -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}) diff --git a/apps/backend/internal/backendapp/services.go b/apps/backend/internal/backendapp/services.go index 9d764765ed..f46cf0879f 100644 --- a/apps/backend/internal/backendapp/services.go +++ b/apps/backend/internal/backendapp/services.go @@ -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}) diff --git a/apps/backend/internal/integration/integration_test.go b/apps/backend/internal/integration/integration_test.go index 6accdde9b6..0403fc8fdb 100644 --- a/apps/backend/internal/integration/integration_test.go +++ b/apps/backend/internal/integration/integration_test.go @@ -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) diff --git a/apps/backend/internal/integration/multi_client_integration_test.go b/apps/backend/internal/integration/multi_client_integration_test.go index f0acdc8fc2..38065e5988 100644 --- a/apps/backend/internal/integration/multi_client_integration_test.go +++ b/apps/backend/internal/integration/multi_client_integration_test.go @@ -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{}{ diff --git a/apps/backend/internal/integration/test_server_test.go b/apps/backend/internal/integration/test_server_test.go index 9fcf97cfb0..260346c94b 100644 --- a/apps/backend/internal/integration/test_server_test.go +++ b/apps/backend/internal/integration/test_server_test.go @@ -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) diff --git a/apps/backend/internal/integration/workflow_integration_test.go b/apps/backend/internal/integration/workflow_integration_test.go index 59d21e0e59..35e24b51e4 100644 --- a/apps/backend/internal/integration/workflow_integration_test.go +++ b/apps/backend/internal/integration/workflow_integration_test.go @@ -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) }) } diff --git a/apps/backend/internal/task/handlers/workspace_handlers.go b/apps/backend/internal/task/handlers/workspace_handlers.go index 0033df5d35..1ee725cad0 100644 --- a/apps/backend/internal/task/handlers/workspace_handlers.go +++ b/apps/backend/internal/task/handlers/workspace_handlers.go @@ -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)) diff --git a/apps/backend/internal/task/handlers/workspace_handlers_test.go b/apps/backend/internal/task/handlers/workspace_handlers_test.go index 2f1f5b3fb8..e374f442e3 100644 --- a/apps/backend/internal/task/handlers/workspace_handlers_test.go +++ b/apps/backend/internal/task/handlers/workspace_handlers_test.go @@ -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 { diff --git a/apps/backend/internal/task/repository/sqlite/workflow.go b/apps/backend/internal/task/repository/sqlite/workflow.go index a6a6fbd9a2..7a18199f7b 100644 --- a/apps/backend/internal/task/repository/sqlite/workflow.go +++ b/apps/backend/internal/task/repository/sqlite/workflow.go @@ -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) diff --git a/apps/backend/internal/task/repository/sqlite/workspace.go b/apps/backend/internal/task/repository/sqlite/workspace.go index 5ab32b32f9..ecf4290379 100644 --- a/apps/backend/internal/task/repository/sqlite/workspace.go +++ b/apps/backend/internal/task/repository/sqlite/workspace.go @@ -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 = ? diff --git a/apps/backend/internal/task/repository/sqlite/workspace_bootstrap.go b/apps/backend/internal/task/repository/sqlite/workspace_bootstrap.go new file mode 100644 index 0000000000..06bf554afd --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/workspace_bootstrap.go @@ -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) + } +} diff --git a/apps/backend/internal/task/repository/sqlite/workspace_test.go b/apps/backend/internal/task/repository/sqlite/workspace_test.go index f8970cd105..0a0483e8e4 100644 --- a/apps/backend/internal/task/repository/sqlite/workspace_test.go +++ b/apps/backend/internal/task/repository/sqlite/workspace_test.go @@ -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() diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index c9595f47df..dbf2309506 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -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 diff --git a/apps/backend/internal/task/service/service_requests.go b/apps/backend/internal/task/service/service_requests.go index 69a5638dff..c1546ea539 100644 --- a/apps/backend/internal/task/service/service_requests.go +++ b/apps/backend/internal/task/service/service_requests.go @@ -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 diff --git a/apps/backend/internal/task/service/service_resources.go b/apps/backend/internal/task/service/service_resources.go index 03a67cf8b9..92d907d709 100644 --- a/apps/backend/internal/task/service/service_resources.go +++ b/apps/backend/internal/task/service/service_resources.go @@ -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 } diff --git a/apps/backend/internal/task/service/service_resources_test.go b/apps/backend/internal/task/service/service_resources_test.go index b99fe31759..577c1cb337 100644 --- a/apps/backend/internal/task/service/service_resources_test.go +++ b/apps/backend/internal/task/service/service_resources_test.go @@ -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() diff --git a/apps/backend/internal/task/service/service_test.go b/apps/backend/internal/task/service/service_test.go index 0c4ca906bb..5effe6fb2d 100644 --- a/apps/backend/internal/task/service/service_test.go +++ b/apps/backend/internal/task/service/service_test.go @@ -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) } diff --git a/apps/web/components/task-create-dialog-pill.test.tsx b/apps/web/components/task-create-dialog-pill.test.tsx index 3d43e4730e..7169cbedb1 100644 --- a/apps/web/components/task-create-dialog-pill.test.tsx +++ b/apps/web/components/task-create-dialog-pill.test.tsx @@ -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 ( -
- {children} -
+ +
+ {children} +
+
); }, - TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}, - TooltipContent: ({ children }: { children: ReactNode }) => ( -
{children}
+ 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 }) => ( +
+ {children} +
), }; }); @@ -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( +