From 7598880e67ca4acaf1a8ff2adb5cb122d614a94c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Tue, 28 Jul 2026 21:25:39 +0100 Subject: [PATCH] feat: add issue-only Improve Kandev workflow (#1994) --- .../skills/spec-driven-development/SKILL.md | 16 ++ AGENTS.md | 12 +- .../config/workflows/improve-kandev.yml | 11 + apps/backend/config/workflows/loader_test.go | 66 +++++- .../config/workflows/report-kandev-issue.yml | 67 ++++++ .../backend/internal/improvekandev/handler.go | 145 ++++++++---- .../integration/improve_kandev_test.go | 84 +++++++ .../task/repository/sqlite/base_migrations.go | 52 +++++ .../repository/sqlite/postgres_schema_test.go | 36 +++ .../sqlite/repository_entity_test.go | 158 +++++++++++++ .../improve-kandev-dialog-create.tsx | 160 +++++++------- .../improve-kandev-dialog-helpers.test.ts | 1 + .../improve-kandev-dialog-model.test.ts | 137 ++++++++++++ .../components/improve-kandev-dialog-model.ts | 109 +++++++++ apps/web/components/improve-kandev-dialog.tsx | 94 ++++++-- .../components/kanban/mobile-menu-sheet.tsx | 100 +++++++-- apps/web/e2e/tests/improve-kandev.spec.ts | 106 +++++++-- .../e2e/tests/mobile-improve-kandev.spec.ts | 65 ++++++ .../web/lib/api/domains/improve-kandev-api.ts | 1 + docs/plans/improve-kandev/plan.md | 208 ++++++++++++++++++ .../task-01-harness-planning-gate.md | 66 ++++++ .../task-02-backend-issue-workflow.md | 82 +++++++ .../task-03-frontend-dialog-and-mobile.md | 106 +++++++++ .../improve-kandev/task-04-e2e-coverage.md | 85 +++++++ .../task-05-verification-and-commit.md | 85 +++++++ docs/specs/INDEX.md | 2 +- docs/specs/improve-kandev/spec.md | 86 +++++++- 27 files changed, 1938 insertions(+), 202 deletions(-) create mode 100644 apps/backend/config/workflows/report-kandev-issue.yml create mode 100644 apps/backend/internal/integration/improve_kandev_test.go create mode 100644 apps/web/components/improve-kandev-dialog-model.test.ts create mode 100644 apps/web/components/improve-kandev-dialog-model.ts create mode 100644 apps/web/e2e/tests/mobile-improve-kandev.spec.ts create mode 100644 docs/plans/improve-kandev/plan.md create mode 100644 docs/plans/improve-kandev/task-01-harness-planning-gate.md create mode 100644 docs/plans/improve-kandev/task-02-backend-issue-workflow.md create mode 100644 docs/plans/improve-kandev/task-03-frontend-dialog-and-mobile.md create mode 100644 docs/plans/improve-kandev/task-04-e2e-coverage.md create mode 100644 docs/plans/improve-kandev/task-05-verification-and-commit.md diff --git a/.agents/skills/spec-driven-development/SKILL.md b/.agents/skills/spec-driven-development/SKILL.md index 0f52fd6496..2ecf71705f 100644 --- a/.agents/skills/spec-driven-development/SKILL.md +++ b/.agents/skills/spec-driven-development/SKILL.md @@ -19,6 +19,22 @@ Intent -> Spec -> Plan + task files -> User model switch -> Sequential TDD imple Do not replace the spec, plan, or task files with chat-only notes. Do not skip from intent to code unless the user explicitly opts out of this workflow. +## Prompt-Precedence Gate + +Treat workflow-generated envelopes such as `[IMPROVE PHASE]`, "implement the +requested change", Red-Green-Refactor checklists, verification commands, and +commit instructions as execution guidance for Phase 5, not as permission to +skip Phases 1–4. Before editing production or permanent test files, verify one +of these conditions: + +1. The request references an approved spec, plan, and current task file; or +2. The user explicitly says to skip spec/plan/task creation. + +If neither is true, create or update the spec, plan, and task files, record any +existing partial implementation as continuation context, and stop at the model +switch checkpoint. A phase label or generic "implement" directive generated by +a Kandev workflow is not an explicit opt-out. + When a user explicitly asks to proceed with feature planning, complete the whole strong-model design package — spec, plan, and task files — before pausing for review or a model switch. Pause after a standalone spec only when the user asks diff --git a/AGENTS.md b/AGENTS.md index 7b30b2f916..73ed0335cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,6 +112,16 @@ session to a lower-cost implementation model. Detailed feature, fix, validation, and delegation routing lives in the relevant skills, especially `planner-orchestration`. +Workflow-generated phase text such as `[IMPROVE PHASE]`, "implement the +requested change", TDD checklists, verification commands, or commit steps does +not opt a feature or behavior-changing fix out of spec-driven development. If +the request does not reference an approved spec, plan, and task file, run +`spec-driven-development` through the plan/task checkpoint and stop before +production or permanent test changes. Continue directly to implementation only +when approved task artifacts already exist, or when the user explicitly says +to skip planning; a generic implementation envelope is not that explicit +opt-out. + ### Kandev Task Creation Use Kandev task/session MCP APIs only when the user explicitly asks to create or @@ -167,4 +177,4 @@ For developing in ephemeral cloud VMs (Cursor Cloud, Codex, GitHub Codespaces, e --- -**Last Updated**: 2026-07-21 +**Last Updated**: 2026-07-27 diff --git a/apps/backend/config/workflows/improve-kandev.yml b/apps/backend/config/workflows/improve-kandev.yml index fb278e037d..a8d0599971 100644 --- a/apps/backend/config/workflows/improve-kandev.yml +++ b/apps/backend/config/workflows/improve-kandev.yml @@ -37,6 +37,17 @@ steps: Re-read the title and description carefully. If anything is unclear or ambiguous, use the ask_user_question_kandev tool before coding. + PLANNING GATE + If this request adds a feature or changes product behavior and does not + reference an approved spec, plan, and current task file, follow the + `spec-driven-development` skill through spec, plan, and task creation. + Record any partial implementation as continuation context, then stop at + the model-switch checkpoint before changing more production or permanent + test files. The [IMPROVE PHASE] label and implementation checklist below + are Phase 5 guidance; they do not opt the task out of planning. Continue + directly only when approved task artifacts already exist or the user + explicitly says to skip planning. + STEP 2: EXPLORE THE CODEBASE - Find similar features and understand how they are implemented. - Identify the architecture and patterns in the relevant area. diff --git a/apps/backend/config/workflows/loader_test.go b/apps/backend/config/workflows/loader_test.go index e1de16e83f..0b0dfec38c 100644 --- a/apps/backend/config/workflows/loader_test.go +++ b/apps/backend/config/workflows/loader_test.go @@ -2,6 +2,7 @@ package workflows import ( "fmt" + "strings" "testing" "github.com/kandev/kandev/internal/workflow/models" @@ -168,14 +169,15 @@ func TestLoadTemplates_ExpectedTemplateIDs(t *testing.T) { } expected := map[string]bool{ - "simple": false, - "standard": false, - "architecture": false, - "pr-review": false, - "feature-dev": false, - "improve-kandev": false, - "office-default": false, - "routine": false, + "simple": false, + "standard": false, + "architecture": false, + "pr-review": false, + "feature-dev": false, + "improve-kandev": false, + "report-kandev-issue": false, + "office-default": false, + "routine": false, } for _, tmpl := range templates { @@ -205,7 +207,7 @@ func TestLoadTemplates_HiddenFlag(t *testing.T) { hiddenByID[tmpl.ID] = tmpl.Hidden } - for _, id := range []string{"improve-kandev", "office-default", "routine"} { + for _, id := range []string{"improve-kandev", "report-kandev-issue", "office-default", "routine"} { if !hiddenByID[id] { t.Errorf("expected template %q to be hidden", id) } @@ -216,3 +218,49 @@ func TestLoadTemplates_HiddenFlag(t *testing.T) { } } } + +func TestLoadTemplates_ReportKandevIssuePromptContract(t *testing.T) { + templates, err := LoadTemplates() + if err != nil { + t.Fatalf("LoadTemplates() returned error: %v", err) + } + + var report *models.WorkflowTemplate + for _, tmpl := range templates { + if tmpl.ID == "report-kandev-issue" { + report = tmpl + break + } + } + if report == nil { + t.Fatal("report-kandev-issue template not found") + } + if !report.Hidden { + t.Error("report-kandev-issue must be hidden") + } + if len(report.Steps) != 1 { + t.Fatalf("report-kandev-issue steps = %d, want 1", len(report.Steps)) + } + + step := report.Steps[0] + if !step.IsStartStep { + t.Error("issue step must be the start step") + } + if len(step.Events.OnEnter) != 1 || + step.Events.OnEnter[0].Type != models.OnEnterAutoStartAgent { + t.Errorf("issue step on_enter = %+v, want auto_start_agent", step.Events.OnEnter) + } + for _, required := range []string{ + "ask_user_question_kandev", + ".github/ISSUE_TEMPLATE", + "gh issue create", + "sensitive", + "duplicate", + "skip STEP 4", + "security-advisory", + } { + if !strings.Contains(step.Prompt, required) { + t.Errorf("issue prompt must contain %q", required) + } + } +} diff --git a/apps/backend/config/workflows/report-kandev-issue.yml b/apps/backend/config/workflows/report-kandev-issue.yml new file mode 100644 index 0000000000..d3d573f64c --- /dev/null +++ b/apps/backend/config/workflows/report-kandev-issue.yml @@ -0,0 +1,67 @@ +# Internal workflow TEMPLATE definition — NOT the portable import/export format. +# Format reference: docs/workflow-import-export.md +id: report-kandev-issue +name: Report Kandev Issue +description: >- + Hidden one-step workflow that gathers the information required by the + kdlbs/kandev issue forms and publishes a GitHub issue without changing code. +hidden: true + +steps: + - id: open-issue + name: Open issue + position: 0 + color: bg-amber-500 + is_start_step: true + show_in_command_panel: false + allow_manual_move: true + prompt: | + [REPORT ISSUE PHASE] + Gather a complete report from the user and publish it as a GitHub issue + in `kdlbs/kandev`. This workflow only reports the problem or request. + Do not implement code, create a branch, commit, push, or open a pull + request. + + {{task_prompt}} + + STEP 1: READ THE REPOSITORY REPORTING RULES + - Read `AGENTS.md`, `.github/ISSUE_TEMPLATE/config.yml`, and the current + `.github/ISSUE_TEMPLATE/bug_report.yml` and `feature_request.yml`. + - Classify the report as a bug or feature request. If it describes a + security vulnerability, do not publish it publicly; direct the user to + the private security-advisory link from the repository configuration. + + STEP 2: GATHER EVERY REQUIRED FIELD + - Compare the task description with every required field in the matching + issue form. + - Use `ask_user_question_kandev` to ask concise follow-up questions for + all missing or ambiguous information. Ask in batches and repeat until + every required field has a concrete answer. + - Never invent environment details, reproduction steps, expected or + actual behavior, acceptance criteria, confirmations, or other missing + facts. + - Remind the user to remove secrets, tokens, private repository URLs, + private prompts, customer data, and other sensitive information from + text, logs, and attachments. + + STEP 3: CHECK FOR DUPLICATES + Search existing open and closed `kdlbs/kandev` issues for the same + behavior. If a likely duplicate exists, show it to the user and use + `ask_user_question_kandev` to confirm whether to add context there or + continue with a new issue. + + STEP 4: PREVIEW AND CONFIRM + If STEP 1 classified this as a security vulnerability, skip STEP 4 and + STEP 5 entirely. Point the user to the private security-advisory link + from the repository configuration and stop. + Draft a title and body whose headings and answers follow the matching + repository issue template. Show the complete public text to the user and + use `ask_user_question_kandev` to obtain explicit publication approval. + + STEP 5: PUBLISH + After approval, write the body to a temporary Markdown file and run + `gh issue create --repo kdlbs/kandev` with the matching title, label, and + body file. Report the resulting issue URL to the user and stop. + events: + on_enter: + - type: auto_start_agent diff --git a/apps/backend/internal/improvekandev/handler.go b/apps/backend/internal/improvekandev/handler.go index 5ac3ca3f7a..7b461f33c2 100644 --- a/apps/backend/internal/improvekandev/handler.go +++ b/apps/backend/internal/improvekandev/handler.go @@ -28,9 +28,13 @@ const ( repoProvider = "github" defaultBranch = "main" - templateID = "improve-kandev" - workflowName = "Improve Kandev" - workflowDesc = "Hidden workflow for filing improvements via the in-app entry point." + improveTemplateID = "improve-kandev" + improveWorkflowName = "Improve Kandev" + improveWorkflowDesc = "Hidden workflow for filing improvements via the in-app entry point." + + issueTemplateID = "report-kandev-issue" + issueWorkflowName = "Report Kandev Issue" + issueWorkflowDesc = "Hidden workflow for publishing a kdlbs/kandev issue without changing code." ) // Cloner is the minimal subset of repoclone.Cloner the bootstrap endpoint uses. @@ -111,15 +115,16 @@ const ( // BootstrapResponse describes the artifacts the dialog needs to submit a task. type BootstrapResponse struct { - RepositoryID string `json:"repository_id"` - WorkflowID string `json:"workflow_id"` - Branch string `json:"branch"` - BundleDir string `json:"bundle_dir"` - BundleFiles map[string]string `json:"bundle_files"` - GitHubLogin string `json:"github_login"` - HasWriteAccess bool `json:"has_write_access"` - ForkStatus ForkStatus `json:"fork_status"` - ForkMessage string `json:"fork_message,omitempty"` + RepositoryID string `json:"repository_id"` + WorkflowID string `json:"workflow_id"` + IssueWorkflowID string `json:"issue_workflow_id"` + Branch string `json:"branch"` + BundleDir string `json:"bundle_dir"` + BundleFiles map[string]string `json:"bundle_files"` + GitHubLogin string `json:"github_login"` + HasWriteAccess bool `json:"has_write_access"` + ForkStatus ForkStatus `json:"fork_status"` + ForkMessage string `json:"fork_message,omitempty"` } func (h *Handler) httpBootstrap(c *gin.Context) { @@ -142,12 +147,38 @@ func (h *Handler) httpBootstrap(c *gin.Context) { return } - workflow, err := h.ensureWorkflow(ctx, workspaceID) + workflows, err := h.taskSvc.ListWorkflows(ctx, workspaceID, true) + if err != nil { + h.log.Error("improve-kandev: workflow list failed", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list improve-kandev workflows"}) + return + } + workflow, err := h.ensureWorkflow( + ctx, + workspaceID, + workflows, + improveTemplateID, + improveWorkflowName, + improveWorkflowDesc, + ) if err != nil { h.log.Error("improve-kandev: workflow upsert failed", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to ensure improve-kandev workflow"}) return } + issueWorkflow, err := h.ensureWorkflow( + ctx, + workspaceID, + workflows, + issueTemplateID, + issueWorkflowName, + issueWorkflowDesc, + ) + if err != nil { + h.log.Error("improve-kandev: issue workflow upsert failed", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to ensure report-kandev-issue workflow"}) + return + } dir, err := createBundleDir() if err != nil { @@ -169,10 +200,11 @@ func (h *Handler) httpBootstrap(c *gin.Context) { access := h.resolveGitHubAccess(ctx) c.JSON(http.StatusOK, BootstrapResponse{ - RepositoryID: repo.ID, - WorkflowID: workflow.ID, - Branch: defaultBranch, - BundleDir: dir, + RepositoryID: repo.ID, + WorkflowID: workflow.ID, + IssueWorkflowID: issueWorkflow.ID, + Branch: defaultBranch, + BundleDir: dir, BundleFiles: map[string]string{ "metadata": filepath.Join(dir, "metadata.json"), "backend_log": filepath.Join(dir, "backend.log"), @@ -345,37 +377,66 @@ func (h *Handler) resolveGitHubAccess(ctx context.Context) githubAccess { return out } -// ensureWorkflow finds or creates the hidden improve-kandev workflow in the -// given workspace. Idempotent: matches by WorkflowTemplateID == templateID. -func (h *Handler) ensureWorkflow(ctx context.Context, workspaceID string) (*taskmodels.Workflow, error) { - existing, err := h.taskSvc.ListWorkflows(ctx, workspaceID, true) - if err != nil { - return nil, err - } - for _, w := range existing { - if w.WorkflowTemplateID != nil && *w.WorkflowTemplateID == templateID { - // Heal records created before the workflow honored Hidden on insert. - // Best-effort: a DB failure here must not block the caller from getting - // their workflow ID, since the workflow itself is already usable. - if !w.Hidden { - if err := h.taskSvc.SetWorkflowHidden(ctx, w.ID, true); err != nil { - h.log.Warn("improve-kandev: failed to heal hidden flag on stale record", - zap.String("workflow_id", w.ID), zap.Error(err)) - } else { - w.Hidden = true - } - } - return w, nil - } +// ensureWorkflow finds or creates a hidden Improve Kandev workflow in the +// given workspace. Idempotent: matches by WorkflowTemplateID. +func (h *Handler) ensureWorkflow( + ctx context.Context, + workspaceID string, + existing []*taskmodels.Workflow, + templateID string, + name string, + description string, +) (*taskmodels.Workflow, error) { + if workflow := h.findAndHealWorkflow(ctx, existing, templateID); workflow != nil { + return workflow, nil } tmplID := templateID - return h.taskSvc.CreateWorkflow(ctx, &taskservice.CreateWorkflowRequest{ + created, err := h.taskSvc.CreateWorkflow(ctx, &taskservice.CreateWorkflowRequest{ WorkspaceID: workspaceID, - Name: workflowName, - Description: workflowDesc, + Name: name, + Description: description, WorkflowTemplateID: &tmplID, Hidden: true, }) + if err == nil { + return created, nil + } + + // A concurrent bootstrap may have inserted the same template after the + // initial snapshot. Re-read after a uniqueness failure and converge on the + // row that won the race instead of surfacing a transient 500. + latest, listErr := h.taskSvc.ListWorkflows(ctx, workspaceID, true) + if listErr == nil { + if workflow := h.findAndHealWorkflow(ctx, latest, templateID); workflow != nil { + return workflow, nil + } + } + return nil, err +} + +func (h *Handler) findAndHealWorkflow( + ctx context.Context, + workflows []*taskmodels.Workflow, + templateID string, +) *taskmodels.Workflow { + for _, workflow := range workflows { + if workflow.WorkflowTemplateID == nil || *workflow.WorkflowTemplateID != templateID { + continue + } + // Heal records created before the workflow honored Hidden on insert. + // Best-effort: a DB failure here must not block the caller from getting + // their workflow ID, since the workflow itself is already usable. + if !workflow.Hidden { + if err := h.taskSvc.SetWorkflowHidden(ctx, workflow.ID, true); err != nil { + h.log.Warn("improve-kandev: failed to heal hidden flag on stale record", + zap.String("workflow_id", workflow.ID), zap.Error(err)) + } else { + workflow.Hidden = true + } + } + return workflow + } + return nil } // FrontendLogRequest is the JSON body for POST /bundle/frontend-log. diff --git a/apps/backend/internal/integration/improve_kandev_test.go b/apps/backend/internal/integration/improve_kandev_test.go new file mode 100644 index 0000000000..9673ff49cc --- /dev/null +++ b/apps/backend/internal/integration/improve_kandev_test.go @@ -0,0 +1,84 @@ +package integration + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os/exec" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" + + "github.com/kandev/kandev/internal/improvekandev" + "github.com/kandev/kandev/internal/task/models" +) + +type improveKandevTestCloner struct { + path string +} + +func (c improveKandevTestCloner) EnsureWorkspaceCloned( + _ context.Context, _, _, _, _, _ string, +) (string, error) { + return c.path, nil +} + +func TestImproveKandevBootstrapCreatesBothHiddenWorkflowsIdempotently(t *testing.T) { + ts := NewOrchestratorTestServer(t) + workspaceID := "123e4567-e89b-12d3-a456-426614174000" + require.NoError(t, ts.TaskRepo.CreateWorkspace(context.Background(), &models.Workspace{ + ID: workspaceID, + Name: "Improve Kandev", + })) + + repoPath := t.TempDir() + require.NoError(t, exec.Command("git", "init", repoPath).Run()) + router := gin.New() + handler := improvekandev.NewHandler(ts.TaskSvc, improveKandevTestCloner{path: repoPath}, "test", ts.Logger) + improvekandev.RegisterRoutes(router, handler) + + bootstrap := func() improvekandev.BootstrapResponse { + t.Helper() + body, err := json.Marshal(improvekandev.BootstrapRequest{WorkspaceID: workspaceID}) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodPost, "/api/v1/system/improve-kandev/bootstrap", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + require.Equal(t, http.StatusOK, res.Code, res.Body.String()) + var response improvekandev.BootstrapResponse + require.NoError(t, json.Unmarshal(res.Body.Bytes(), &response)) + return response + } + + first := bootstrap() + second := bootstrap() + require.NotEmpty(t, first.WorkflowID) + require.NotEmpty(t, first.IssueWorkflowID) + require.NotEqual(t, first.WorkflowID, first.IssueWorkflowID) + require.Equal(t, first.WorkflowID, second.WorkflowID) + require.Equal(t, first.IssueWorkflowID, second.IssueWorkflowID) + + workflows, err := ts.TaskRepo.ListWorkflows(context.Background(), workspaceID, true) + require.NoError(t, err) + byTemplate := make(map[string]*models.Workflow, len(workflows)) + for _, workflow := range workflows { + if workflow.WorkflowTemplateID != nil { + byTemplate[*workflow.WorkflowTemplateID] = workflow + } + } + for _, templateID := range []string{"improve-kandev", "report-kandev-issue"} { + workflow := byTemplate[templateID] + require.NotNil(t, workflow, "workflow template %s", templateID) + require.True(t, workflow.Hidden, "workflow template %s should stay hidden", templateID) + } + + issueSteps, err := ts.WorkflowSvc.ListStepsByWorkflow(context.Background(), first.IssueWorkflowID) + require.NoError(t, err) + require.Len(t, issueSteps, 1) + require.Equal(t, "Open issue", issueSteps[0].Name) + require.True(t, issueSteps[0].IsStartStep) +} diff --git a/apps/backend/internal/task/repository/sqlite/base_migrations.go b/apps/backend/internal/task/repository/sqlite/base_migrations.go index ecadc0ae64..f795ea1b80 100644 --- a/apps/backend/internal/task/repository/sqlite/base_migrations.go +++ b/apps/backend/internal/task/repository/sqlite/base_migrations.go @@ -175,6 +175,9 @@ func (r *Repository) runMigrations() error { // file path it was synced from. r.migrate.Apply("workflows.source", `ALTER TABLE workflows ADD COLUMN source TEXT NOT NULL DEFAULT 'manual'`) r.migrate.Apply("workflows.source_path", `ALTER TABLE workflows ADD COLUMN source_path TEXT NOT NULL DEFAULT ''`) + if err := r.ensureImproveKandevWorkflowTemplateUniqueness(); err != nil { + return err + } // Native code review — indexes for the task_review_* tables. Declared here // rather than in initTaskReviewSchema because schema init is a no-op on an @@ -193,6 +196,55 @@ func (r *Repository) runMigrations() error { return nil } +// ensureImproveKandevWorkflowTemplateUniqueness removes the broad index from +// the initial implementation, reconciles legacy bootstrap duplicates, then +// enforces uniqueness only for the two hidden workflows created by this flow. +func (r *Repository) ensureImproveKandevWorkflowTemplateUniqueness() error { + tx, err := r.db.Beginx() + if err != nil { + return fmt.Errorf("begin improve kandev workflow migration: %w", err) + } + // Keep this template list synchronized with every hidden Improve Kandev + // workflow bootstrapped by internal/improvekandev. Add new IDs to both this + // reconciliation query and the partial-index predicate below. + if _, err := tx.Exec(` + UPDATE workflows + SET workflow_template_id = '' + WHERE id IN ( + SELECT duplicate.id + FROM workflows AS duplicate + WHERE duplicate.workflow_template_id IN ('improve-kandev', 'report-kandev-issue') + AND EXISTS ( + SELECT 1 + FROM workflows AS canonical + WHERE canonical.workspace_id = duplicate.workspace_id + AND canonical.workflow_template_id = duplicate.workflow_template_id + AND canonical.hidden = duplicate.hidden + AND ( + canonical.created_at < duplicate.created_at + OR (canonical.created_at = duplicate.created_at AND canonical.id < duplicate.id) + ) + ) + )`); err != nil { + _ = tx.Rollback() + return fmt.Errorf("reconcile improve kandev workflow duplicates: %w", err) + } + if _, err := tx.Exec(`DROP INDEX IF EXISTS uniq_workflows_workspace_template_hidden`); err != nil { + _ = tx.Rollback() + return fmt.Errorf("drop broad workflow template index: %w", err) + } + if _, err := tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS uniq_improve_kandev_workflows + ON workflows(workspace_id, workflow_template_id, hidden) + WHERE workflow_template_id IN ('improve-kandev', 'report-kandev-issue')`); err != nil { + _ = tx.Rollback() + return fmt.Errorf("create improve kandev workflow index: %w", err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit improve kandev workflow migration: %w", err) + } + return nil +} + // ensureTaskWorkspaceFoldersSchema upgrades databases created before durable // folder attachments existed. CREATE TABLE/INDEX IF NOT EXISTS is replay-safe // on SQLite and Postgres. diff --git a/apps/backend/internal/task/repository/sqlite/postgres_schema_test.go b/apps/backend/internal/task/repository/sqlite/postgres_schema_test.go index 97f012e9c1..1960e52049 100644 --- a/apps/backend/internal/task/repository/sqlite/postgres_schema_test.go +++ b/apps/backend/internal/task/repository/sqlite/postgres_schema_test.go @@ -74,6 +74,42 @@ func TestPostgresExecutorRunningLocalPIDMigration(t *testing.T) { } } +func TestPostgresImproveKandevWorkflowIndexMigration(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("init postgres schema: %v", err) + } + if _, err := db.Exec(`DROP INDEX IF EXISTS uniq_improve_kandev_workflows`); err != nil { + t.Fatalf("drop improve kandev index: %v", err) + } + now := time.Now().UTC() + for _, id := range []string{"wf-pg-improve-first", "wf-pg-improve-second"} { + if _, err := db.Exec(db.Rebind(` + INSERT INTO workflows (id, workspace_id, workflow_template_id, name, hidden, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `), id, "ws-pg-improve-kandev", "improve-kandev", "Improve Kandev", 1, now, now); err != nil { + t.Fatalf("seed legacy workflow %q: %v", id, err) + } + } + if err := repo.runMigrations(); err != nil { + t.Fatalf("migrate legacy postgres workflow duplicates: %v", err) + } + if err := repo.runMigrations(); err != nil { + t.Fatalf("replay postgres improve kandev migration: %v", err) + } + var count int + if err := db.QueryRow(db.Rebind(` + SELECT COUNT(*) FROM workflows + WHERE workspace_id = ? AND workflow_template_id = ? AND hidden = ? + `), "ws-pg-improve-kandev", "improve-kandev", 1).Scan(&count); err != nil { + t.Fatalf("count reconciled workflows: %v", err) + } + if count != 1 { + t.Fatalf("improve-kandev workflow template rows = %d, want 1", count) + } +} + func TestPostgresExecutionProfileMigration(t *testing.T) { db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) repo, err := NewWithDB(db, db, nil) diff --git a/apps/backend/internal/task/repository/sqlite/repository_entity_test.go b/apps/backend/internal/task/repository/sqlite/repository_entity_test.go index 33634edbeb..07931b68da 100644 --- a/apps/backend/internal/task/repository/sqlite/repository_entity_test.go +++ b/apps/backend/internal/task/repository/sqlite/repository_entity_test.go @@ -37,6 +37,164 @@ func seedWorkspace(t *testing.T, repo *Repository, id string) { } } +func strptr(value string) *string { return &value } + +func TestCreateWorkflowRejectsDuplicateHiddenTemplatePerWorkspace(t *testing.T) { + repo := newRepoForEntityTests(t) + ctx := context.Background() + seedWorkspace(t, repo, "ws-template-unique") + + first := &models.Workflow{ + ID: "wf-template-first", + WorkspaceID: "ws-template-unique", + Name: "Improve Kandev", + WorkflowTemplateID: strptr("improve-kandev"), + Hidden: true, + } + if err := repo.CreateWorkflow(ctx, first); err != nil { + t.Fatalf("create first template workflow: %v", err) + } + duplicate := &models.Workflow{ + ID: "wf-template-duplicate", + WorkspaceID: first.WorkspaceID, + Name: first.Name, + WorkflowTemplateID: strptr("improve-kandev"), + Hidden: true, + } + if err := repo.CreateWorkflow(ctx, duplicate); err == nil { + t.Fatal("duplicate hidden template workflow was accepted") + } + for _, id := range []string{"wf-other-template-first", "wf-other-template-second"} { + if err := repo.CreateWorkflow(ctx, &models.Workflow{ + ID: id, + WorkspaceID: first.WorkspaceID, + Name: "Reusable template workflow", + WorkflowTemplateID: strptr("reusable-template"), + Hidden: true, + }); err != nil { + t.Fatalf("create non-Improve-Kandev template workflow %q: %v", id, err) + } + } +} + +func TestImproveKandevWorkflowIndexMigratesFormerBroadIndex(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "improve-kandev-index-replay.db") + openRepo := func() (*Repository, *sqlx.DB) { + t.Helper() + dbConn, err := db.OpenSQLite(dbPath) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + sqlxDB := sqlx.NewDb(dbConn, "sqlite3") + repo, err := NewWithDB(sqlxDB, sqlxDB, nil) + if err != nil { + _ = sqlxDB.Close() + t.Fatalf("new repo: %v", err) + } + return repo, sqlxDB + } + + repo, sqlxDB := openRepo() + seedWorkspace(t, repo, "ws-improve-kandev-index-replay") + if _, err := sqlxDB.Exec(`DROP INDEX IF EXISTS uniq_improve_kandev_workflows`); err != nil { + t.Fatalf("drop scoped index: %v", err) + } + if _, err := sqlxDB.Exec(`DROP INDEX IF EXISTS uniq_workflows_workspace_template_hidden`); err != nil { + t.Fatalf("drop index: %v", err) + } + if _, err := sqlxDB.Exec(`CREATE UNIQUE INDEX uniq_workflows_workspace_template_hidden + ON workflows(workspace_id, workflow_template_id, hidden) + WHERE workflow_template_id <> ''`); err != nil { + t.Fatalf("create former broad index: %v", err) + } + if err := sqlxDB.Close(); err != nil { + t.Fatalf("close first database: %v", err) + } + + repo, sqlxDB = openRepo() + t.Cleanup(func() { _ = sqlxDB.Close() }) + for indexName, want := range map[string]int{ + "uniq_workflows_workspace_template_hidden": 0, + "uniq_improve_kandev_workflows": 1, + } { + var got int + if err := sqlxDB.Get(&got, `SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = ?`, indexName); err != nil { + t.Fatalf("count %s: %v", indexName, err) + } + if got != want { + t.Errorf("%s count = %d, want %d", indexName, got, want) + } + } + ctx := context.Background() + for _, id := range []string{"wf-replay-first", "wf-replay-second"} { + if err := repo.CreateWorkflow(ctx, &models.Workflow{ + ID: id, + WorkspaceID: "ws-improve-kandev-index-replay", + Name: "Reusable template workflow", + WorkflowTemplateID: strptr("reusable-template"), + Hidden: true, + }); err != nil { + t.Fatalf("create non-Improve-Kandev template workflow %q after replay: %v", id, err) + } + } +} + +func TestImproveKandevWorkflowIndexReconcilesLegacyDuplicates(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "improve-kandev-duplicates.db") + openRepo := func() (*Repository, *sqlx.DB) { + t.Helper() + dbConn, err := db.OpenSQLite(dbPath) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + sqlxDB := sqlx.NewDb(dbConn, "sqlite3") + repo, err := NewWithDB(sqlxDB, sqlxDB, nil) + if err != nil { + _ = sqlxDB.Close() + t.Fatalf("new repo: %v", err) + } + return repo, sqlxDB + } + + repo, sqlxDB := openRepo() + workspaceID := "ws-improve-kandev-duplicates" + seedWorkspace(t, repo, workspaceID) + if _, err := sqlxDB.Exec(`DROP INDEX IF EXISTS uniq_improve_kandev_workflows`); err != nil { + t.Fatalf("drop improve kandev index: %v", err) + } + ctx := context.Background() + for _, id := range []string{"wf-legacy-first", "wf-legacy-second"} { + if err := repo.CreateWorkflow(ctx, &models.Workflow{ + ID: id, + WorkspaceID: workspaceID, + Name: "Improve Kandev", + WorkflowTemplateID: strptr("improve-kandev"), + Hidden: true, + }); err != nil { + t.Fatalf("create legacy duplicate %q: %v", id, err) + } + } + if err := sqlxDB.Close(); err != nil { + t.Fatalf("close legacy database: %v", err) + } + + repo, sqlxDB = openRepo() + t.Cleanup(func() { _ = sqlxDB.Close() }) + workflows, err := repo.ListWorkflows(ctx, workspaceID, true) + if err != nil { + t.Fatalf("list reconciled workflows: %v", err) + } + var matchingTemplates int + for _, workflow := range workflows { + if workflow.WorkflowTemplateID != nil && *workflow.WorkflowTemplateID == "improve-kandev" { + matchingTemplates++ + } + } + if matchingTemplates != 1 { + t.Fatalf("improve-kandev workflow template rows = %d, want 1", matchingTemplates) + } +} + func TestDeleteRepositoryIfUnreferenced_PreservesTaskAdoptedRepository(t *testing.T) { repo := newRepoForEntityTests(t) ctx := context.Background() diff --git a/apps/web/components/improve-kandev-dialog-create.tsx b/apps/web/components/improve-kandev-dialog-create.tsx index 00e4f17ce9..ea8432e9f5 100644 --- a/apps/web/components/improve-kandev-dialog-create.tsx +++ b/apps/web/components/improve-kandev-dialog-create.tsx @@ -1,8 +1,6 @@ "use client"; import { Fragment, useState } from "react"; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@kandev/ui/dialog"; -import { Button } from "@kandev/ui/button"; import { Checkbox } from "@kandev/ui/checkbox"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@kandev/ui/collapsible"; import { Tabs, TabsList, TabsTrigger } from "@kandev/ui/tabs"; @@ -15,22 +13,32 @@ import { IconChevronDown, IconBrandGithub, IconGitFork, - IconStethoscope, } from "@tabler/icons-react"; import { TaskCreateDialog } from "@/components/task-create-dialog"; import type { ImproveKandevBootstrapResponse } from "@/lib/api/domains/improve-kandev-api"; import { cn } from "@/lib/utils"; import type { Task, WorkflowStep } from "@/lib/types/http"; +import { + contributorAccessMessage, + getImproveKandevForkBlockedReason, + getImproveKandevStepDescription, + resolveImproveKandevWorkflow, + type ImproveKandevKind, +} from "./improve-kandev-dialog-model"; export type BootstrapState = | { kind: "idle" } | { kind: "loading" } - | { kind: "ready"; data: ImproveKandevBootstrapResponse; steps: WorkflowStep[] } - | { kind: "error"; message: string } - | { kind: "blocked"; message: string }; + | { + kind: "ready"; + data: ImproveKandevBootstrapResponse; + steps: WorkflowStep[]; + issueSteps: WorkflowStep[]; + } + | { kind: "error"; message: string }; -type ImproveKind = "bug" | "feature"; +type ImproveKind = ImproveKandevKind; // Surfaced in the dialog's submit-button tooltip while the bootstrap probe is // in flight, so the disabled state has an explanation the user can act on @@ -39,6 +47,9 @@ function bootstrapBlockedReason(bootstrap: BootstrapState): string | null { if (bootstrap.kind === "loading" || bootstrap.kind === "idle") { return "Preparing kandev repository…"; } + if (bootstrap.kind === "error") { + return "Bootstrap failed — close and reopen to retry."; + } return null; } @@ -46,6 +57,8 @@ const PLACEHOLDERS: Record = { bug: "E.g. When I open the kanban board, the column header text overlaps the task count badge on screens narrower than 1200px...", feature: "E.g. Add a keyboard shortcut to mark a task as Done from the task detail view, and show the shortcut in the task action menu...", + issue: + "Describe the problem or request you want published. The agent will ask for any details required by the matching GitHub issue template...", }; type CreateModeViewProps = { @@ -57,42 +70,32 @@ type CreateModeViewProps = { setCaptureLogs: (v: boolean) => void; transformDescription: (description: string) => Promise; onTaskCreated: (task: Task) => void; + externalBlockedReason?: string | null; }; export function CreateModeView(props: CreateModeViewProps) { const { bootstrap, captureLogs, setCaptureLogs } = props; const [kind, setKind] = useState("bug"); const ready = bootstrap.kind === "ready" ? bootstrap : null; - const startStep = ready ? (ready.steps.find((s) => s.is_start_step) ?? ready.steps[0]) : null; + const workflow = resolveImproveKandevWorkflow(ready, kind); + const forkBlockedReason = ready + ? getImproveKandevForkBlockedReason(kind, ready.data.fork_status, ready.data.fork_message) + : null; const handleKindChange = (next: ImproveKind) => { setKind(next); setCaptureLogs(next === "bug"); }; - // Hard-block the contribution flow if the bootstrap probe detected the - // user can't fork kdlbs/kandev (e.g., EMU enterprise restriction). Showing - // the task form here would only let them fill out a task that fails at - // the PR step. - if (bootstrap.kind === "blocked") { - return ( - - ); - } - return ( ({ id: s.id, title: s.name, events: s.events })) : []} + workflowId={workflow.workflowId} + defaultStepId={workflow.startStep?.id ?? null} + steps={workflow.steps.map((s) => ({ id: s.id, title: s.name, events: s.events }))} initialValues={{ title: "", repositoryId: ready?.data.repository_id ?? "", @@ -100,12 +103,15 @@ export function CreateModeView(props: CreateModeViewProps) { }} onSuccess={props.onTaskCreated} lockedFields={{ repository: true, branch: true, workflow: true }} - submitBlockedReason={bootstrapBlockedReason(bootstrap)} + submitBlockedReason={ + props.externalBlockedReason ?? forkBlockedReason ?? bootstrapBlockedReason(bootstrap) + } descriptionPlaceholder={PLACEHOLDERS[kind]} aboveDescriptionSlot={} extraFormSlot={ @@ -113,8 +119,7 @@ export function CreateModeView(props: CreateModeViewProps) { bottomSlot={ ready && (
- - +
) } @@ -123,12 +128,14 @@ export function CreateModeView(props: CreateModeViewProps) { ); } -const STEP_DESCRIPTIONS: Record = { - improve: - "Agent reads the report, explores the codebase, and implements the change with TDD. Runs make fmt, typecheck, test, lint, then commits.", - test: "Agent boots a secondary kandev instance with make dev and tells you what to verify. You confirm the change works in the running app.", - pr: "Agent runs the pr skill: pushes the branch and opens a pull request to kdlbs/kandev for the maintainers to review.", -}; +function CreateModeBottomSlot({ kind, steps }: { kind: ImproveKind; steps: WorkflowStep[] }) { + return ( + <> + + {kind !== "issue" && } + + ); +} function WorkflowStepsPreview({ steps }: { steps: WorkflowStep[] }) { if (steps.length === 0) return null; @@ -156,7 +163,7 @@ function WorkflowStepsPreview({ steps }: { steps: WorkflowStep[] }) { - {STEP_DESCRIPTIONS[s.name.toLowerCase()] ?? s.name} + {getImproveKandevStepDescription(s)} @@ -244,6 +251,9 @@ function KindTabs({ Feature request + + Open issue + ); @@ -251,16 +261,28 @@ function KindTabs({ function BootstrapStatusSlot({ bootstrap, + kind, captureLogs, setCaptureLogs, }: { bootstrap: BootstrapState; + kind: ImproveKind; captureLogs: boolean; setCaptureLogs: (v: boolean) => void; }) { return (
- + + {kind === "issue" && ( +
+ This workflow will only create a GitHub issue from the repository template. The agent will + ask for missing details and publication approval; it will not implement the change or open + a pull request. +
+ )}