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(
+ }
+ 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(
- }
- 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", () => {
diff --git a/apps/web/components/task-create-dialog-pill.tsx b/apps/web/components/task-create-dialog-pill.tsx
index 1395a135e9..5467ddcfe4 100644
--- a/apps/web/components/task-create-dialog-pill.tsx
+++ b/apps/web/components/task-create-dialog-pill.tsx
@@ -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(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({
@@ -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 (
+
+ {triggerButton}
+
+
+ );
+}
+
function renderPillTriggerButton({
icon,
value,
@@ -240,8 +380,14 @@ function renderPillTriggerButton({
hasValue,
testId,
prefix,
+ onPointerEnter,
+ onPointerLeave,
+ onBlur,
}: Pick & {
hasValue: boolean;
+ onPointerEnter?: React.PointerEventHandler;
+ onPointerLeave?: React.PointerEventHandler;
+ onBlur?: React.FocusEventHandler;
}): 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}
@@ -260,6 +409,33 @@ function renderPillTriggerButton({
);
}
+function usePillOpenHandlers(
+ setOpenState: React.Dispatch>,
+ 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 | 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 = (
-
-
- {tooltip ? {triggerButton} : triggerButton}
-
-
-
+ {triggerButton} : 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 (
-
+
{popover}
- {tooltip}
+ {tooltip}
);
}
diff --git a/apps/web/e2e/tests/settings/workspace-delete.spec.ts b/apps/web/e2e/tests/settings/workspace-delete.spec.ts
index cb844705c5..e7dbb7cb59 100644
--- a/apps/web/e2e/tests/settings/workspace-delete.spec.ts
+++ b/apps/web/e2e/tests/settings/workspace-delete.spec.ts
@@ -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}`;
diff --git a/apps/web/e2e/tests/task/create-task-repository-tooltip.spec.ts b/apps/web/e2e/tests/task/create-task-repository-tooltip.spec.ts
new file mode 100644
index 0000000000..a8a25455c3
--- /dev/null
+++ b/apps/web/e2e/tests/task/create-task-repository-tooltip.spec.ts
@@ -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();
+ });
+});
diff --git a/apps/web/e2e/tests/task/mobile-create-task-repository-selection.spec.ts b/apps/web/e2e/tests/task/mobile-create-task-repository-selection.spec.ts
index ca4abb8238..883e418993 100644
--- a/apps/web/e2e/tests/task/mobile-create-task-repository-selection.spec.ts
+++ b/apps/web/e2e/tests/task/mobile-create-task-repository-selection.spec.ts
@@ -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.",
+ });
});
});
diff --git a/docs/plans/creation-flow-reliability/plan.md b/docs/plans/creation-flow-reliability/plan.md
new file mode 100644
index 0000000000..e6a72e2b66
--- /dev/null
+++ b/docs/plans/creation-flow-reliability/plan.md
@@ -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.
diff --git a/docs/plans/creation-flow-reliability/task-01-repository-tooltip.md b/docs/plans/creation-flow-reliability/task-01-repository-tooltip.md
new file mode 100644
index 0000000000..a0cdd793e2
--- /dev/null
+++ b/docs/plans/creation-flow-reliability/task-01-repository-tooltip.md
@@ -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.
diff --git a/docs/plans/creation-flow-reliability/task-02-kanban-workspace-bootstrap.md b/docs/plans/creation-flow-reliability/task-02-kanban-workspace-bootstrap.md
new file mode 100644
index 0000000000..2a8a715fc6
--- /dev/null
+++ b/docs/plans/creation-flow-reliability/task-02-kanban-workspace-bootstrap.md
@@ -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.
diff --git a/docs/public/tasks-and-workflows.md b/docs/public/tasks-and-workflows.md
index 74d67c0bbe..9822148bc6 100644
--- a/docs/public/tasks-and-workflows.md
+++ b/docs/public/tasks-and-workflows.md
@@ -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
diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md
index 15167976ab..346b23cbd2 100644
--- a/docs/specs/INDEX.md
+++ b/docs/specs/INDEX.md
@@ -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 |
diff --git a/docs/specs/tasks/multi-branch/spec.md b/docs/specs/tasks/multi-branch/spec.md
index f2579a18e9..c68d220159 100644
--- a/docs/specs/tasks/multi-branch/spec.md
+++ b/docs/specs/tasks/multi-branch/spec.md
@@ -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.
diff --git a/docs/specs/workspaces/creation.md b/docs/specs/workspaces/creation.md
new file mode 100644
index 0000000000..e28d673075
--- /dev/null
+++ b/docs/specs/workspaces/creation.md
@@ -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.