diff --git a/apps/backend/cmd/kandev/helpers.go b/apps/backend/cmd/kandev/helpers.go index dffc8bf0b3..e40be2d994 100644 --- a/apps/backend/cmd/kandev/helpers.go +++ b/apps/backend/cmd/kandev/helpers.go @@ -36,6 +36,7 @@ import ( gateways "github.com/kandev/kandev/internal/gateway/websocket" "github.com/kandev/kandev/internal/github" "github.com/kandev/kandev/internal/health" + "github.com/kandev/kandev/internal/improvekandev" "github.com/kandev/kandev/internal/jira" "github.com/kandev/kandev/internal/linear" mcphandlers "github.com/kandev/kandev/internal/mcp/handlers" @@ -45,6 +46,7 @@ import ( "github.com/kandev/kandev/internal/orchestrator" promptcontroller "github.com/kandev/kandev/internal/prompts/controller" prompthandlers "github.com/kandev/kandev/internal/prompts/handlers" + "github.com/kandev/kandev/internal/repoclone" "github.com/kandev/kandev/internal/secrets" spriteshandlers "github.com/kandev/kandev/internal/sprites" taskhandlers "github.com/kandev/kandev/internal/task/handlers" @@ -339,6 +341,8 @@ type routeParams struct { secretStore secrets.SecretStore mcpConfigSvc *mcpconfig.Service addCleanup func(func() error) + repoCloner *repoclone.Cloner + version string webInternalURL string pprofEnabled bool httpPort int @@ -492,6 +496,15 @@ func registerSecondaryRoutes( registerHealthRoutes(p) + if p.repoCloner != nil { + ikHandler := improvekandev.NewHandler(p.taskSvc, p.repoCloner, p.version, p.log) + improvekandev.RegisterRoutes(p.router, ikHandler) + improvekandev.CleanupStaleBundles(func(path string, err error) { + p.log.Warn("Improve Kandev: failed to clean stale bundle", zap.String("path", path), zap.Error(err)) + }) + p.log.Debug("Registered Improve Kandev handlers (HTTP)") + } + registerMCPAndDebugRoutes(p, workflowCtrl, clarificationStore, planService) registerE2EResetRoutes(p.router, p.taskRepo, p.log) diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index e55425580b..913e495e6d 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -73,6 +73,14 @@ var ( flagVersion = flag.Bool("version", false, "Show version information") ) +// Build-time variables injected via -ldflags "-X main.Version=... -X main.Commit=... -X main.BuildTime=..." +// (see apps/backend/Makefile). Defaults apply when running un-stamped builds (e.g. `go run`). +var ( + Version = "dev" + Commit = "unknown" + BuildTime = "unknown" +) + func init() { flag.Usage = func() { fmt.Fprintf(os.Stderr, "Usage: kandev [options]\n\n") @@ -103,7 +111,7 @@ func realMain() int { } if *flagVersion { - fmt.Println("kandev version 0.1.0") + fmt.Printf("kandev version %s (commit %s, built %s)\n", Version, Commit, BuildTime) return 0 } @@ -352,7 +360,7 @@ func startAgentInfrastructure( } return startGatewayAndServe(ctx, cfg, log, eventBus, repos, services, - agentSettingsController, lifecycleMgr, agentRegistry, orchestratorSvc, msgCreator, addCleanup, runCleanups) + agentSettingsController, lifecycleMgr, agentRegistry, orchestratorSvc, msgCreator, repoCloner, addCleanup, runCleanups) } // startGatewayAndServe sets up the WebSocket gateway, HTTP routes, starts the server, @@ -369,6 +377,7 @@ func startGatewayAndServe( agentRegistry *registry.Registry, orchestratorSvc *orchestrator.Service, msgCreator *messageCreatorAdapter, + repoCloner *repoclone.Cloner, addCleanup func(func() error), runCleanups func(), ) bool { @@ -437,7 +446,7 @@ func startGatewayAndServe( // HTTP SERVER // ============================================ server := buildHTTPServer(cfg, log, gateway, repos, services, agentSettingsController, - lifecycleMgr, eventBus, orchestratorSvc, notificationCtrl, msgCreator, agentRegistry, hostUtilityMgr, addCleanup) + lifecycleMgr, eventBus, orchestratorSvc, notificationCtrl, msgCreator, agentRegistry, hostUtilityMgr, addCleanup, repoCloner) port := cfg.Server.Port if port == 0 { @@ -476,6 +485,7 @@ func buildHTTPServer( agentRegistry *registry.Registry, hostUtilityMgr *hostutility.Manager, addCleanup func(func() error), + repoCloner *repoclone.Cloner, ) *http.Server { gin.SetMode(gin.ReleaseMode) router := gin.New() @@ -512,6 +522,8 @@ func buildHTTPServer( secretStore: repos.Secrets, mcpConfigSvc: mcpconfig.NewService(repos.AgentSettings), addCleanup: addCleanup, + repoCloner: repoCloner, + version: Version, webInternalURL: cfg.Server.WebInternalURL, pprofEnabled: cfg.Debug.PprofEnabled, httpPort: port, diff --git a/apps/backend/cmd/kandev/services.go b/apps/backend/cmd/kandev/services.go index ea89ac6913..1b159a3822 100644 --- a/apps/backend/cmd/kandev/services.go +++ b/apps/backend/cmd/kandev/services.go @@ -329,8 +329,8 @@ type workflowProviderAdapter struct { } // ListWorkflows implements workflowservice.WorkflowProvider. -func (a *workflowProviderAdapter) ListWorkflows(ctx context.Context, workspaceID string) ([]*taskmodels.Workflow, error) { - return a.svc.ListWorkflows(ctx, workspaceID) +func (a *workflowProviderAdapter) ListWorkflows(ctx context.Context, workspaceID string, includeHidden bool) ([]*taskmodels.Workflow, error) { + return a.svc.ListWorkflows(ctx, workspaceID, includeHidden) } // GetWorkflow implements workflowservice.WorkflowProvider. diff --git a/apps/backend/config/workflows/improve-kandev.yml b/apps/backend/config/workflows/improve-kandev.yml new file mode 100644 index 0000000000..16d0410a59 --- /dev/null +++ b/apps/backend/config/workflows/improve-kandev.yml @@ -0,0 +1,138 @@ +id: improve-kandev +name: Improve Kandev +description: >- + Hidden three-step workflow for filing improvements to kandev. + The agent implements the change, the user tests it in a secondary + kandev instance, then the agent opens a pull request to kdlbs/kandev. +hidden: true + +steps: + - id: improve + name: Improve + position: 0 + color: bg-purple-500 + is_start_step: true + show_in_command_panel: false + allow_manual_move: true + prompt: | + [IMPROVE PHASE] + Implement the requested change to kandev using TDD. + + {{task_prompt}} + + If the task description references a context bundle (paths to + metadata.json, backend.log, frontend.log in a temp directory), + read those files for additional debugging context before starting. + + STEP 0: CREATE A TASK LIST + Track the substeps below using your todo tool. Mark each in_progress + when you start and completed when you finish. + + STEP 1: UNDERSTAND THE REPORT + Re-read the title and description carefully. If anything is unclear + or ambiguous, use the ask_user_question_kandev tool before coding. + + STEP 2: EXPLORE THE CODEBASE + - Find similar features and understand how they are implemented. + - Identify the architecture and patterns in the relevant area. + - Read AGENTS.md for repo-specific conventions. + + STEP 3: IMPLEMENT WITH TDD + For each behavior: + 1. Write a failing test that asserts the expected behavior. + 2. Run the test — confirm it fails with the expected assertion. + 3. Write the minimum code to make it pass. + 4. Run the test — confirm it passes. + 5. Refactor while keeping tests green. + Add an E2E test (Playwright) when the change touches a user-facing flow. + + STEP 4: VERIFY + Run `make fmt` first, then `make typecheck test lint` per AGENTS.md. + + STEP 5: COMMIT + Stage and commit your changes with a Conventional Commits message + (e.g. `fix:`, `feat:`). Use explicit paths in `git add`. + + When the implementation is complete and verified, stop and tell the + user. The user will move the task to the next step (Test) when ready. + events: + on_enter: + - type: auto_start_agent + + - id: test + name: Test + position: 1 + color: bg-blue-500 + show_in_command_panel: false + allow_manual_move: true + prompt: | + [TEST PHASE] + Help the user verify the implemented change in a secondary kandev + instance running side-by-side with the primary one. + + STEP 1: PREPARE + Run `make install` to install dependencies if needed. + + STEP 2: START THE SECONDARY INSTANCE + Run `make dev` with auto-port assignment so the new instance does not + collide with the primary one. Capture the assigned URLs (web app, + backend API) from stdout. + + STEP 3: REPORT + Tell the user exactly: + - Which URL to open to test the change. + - What to look for / how to reproduce the bug or exercise the feature. + - Any login or setup steps needed. + + Wait for the user to confirm the change works. If the user reports + issues, take notes and ask the user to move the task back to Improve. + When the user is satisfied, they will move the task to PR. + events: + on_enter: + - type: auto_start_agent + + - id: pr + name: PR + position: 2 + color: bg-green-500 + show_in_command_panel: false + allow_manual_move: true + prompt: | + [PR PHASE] + Open a pull request against `main` in `kdlbs/kandev` for the change + implemented and verified in the previous steps. + + STEP 1: PRE-FLIGHT + - Run `gh auth status` to confirm GitHub CLI is authenticated. + - Confirm your changes are committed (`git status`). + + STEP 2: ENSURE A FORK EXISTS AND WIRE REMOTES + The local clone's `origin` initially points at `kdlbs/kandev`, which + most contributors cannot push to. Run: + + gh repo fork --remote --remote-name=origin + + `gh` will fork `kdlbs/kandev` to your account if a fork does not + already exist, rename the existing `origin` remote to `upstream`, + and point `origin` at `/kandev`. The command is a no-op + on subsequent runs, so it is safe to re-run. + + Verify with `git remote -v`: `origin` must point at your fork and + `upstream` at `kdlbs/kandev`. Skip this step only if you confirmed + via `gh api repos/kdlbs/kandev --jq .permissions.push` that you + already have push access to the upstream repo. + + STEP 3: INVOKE THE PR SKILL + Use the `pr` skill (see `.claude/skills/pr/SKILL.md`): + - Push the current branch to `origin` (your fork, after step 2). + - Open a pull request against `main` in `kdlbs/kandev` with a clear + title and description summarising the change and how it was tested. + `gh pr create` auto-detects the cross-fork setup. + - Reference the original task title in the PR description. + + STEP 4: REPORT + Tell the user the PR URL. Stop and wait — the user takes it from here + (review, merge, follow-ups). + events: + on_enter: + - type: auto_start_agent diff --git a/apps/backend/config/workflows/loader.go b/apps/backend/config/workflows/loader.go index 1d4dc4ebbb..5edc2e83c7 100644 --- a/apps/backend/config/workflows/loader.go +++ b/apps/backend/config/workflows/loader.go @@ -16,6 +16,7 @@ type templateYAML struct { ID string `yaml:"id"` Name string `yaml:"name"` Description string `yaml:"description"` + Hidden bool `yaml:"hidden,omitempty"` Steps []stepDefYAML `yaml:"steps"` } @@ -92,6 +93,7 @@ func loadTemplate(filename string) (*models.WorkflowTemplate, error) { Name: raw.Name, Description: raw.Description, IsSystem: true, + Hidden: raw.Hidden, CreatedAt: now, UpdatedAt: now, } diff --git a/apps/backend/config/workflows/loader_test.go b/apps/backend/config/workflows/loader_test.go index 5968a89c8c..ef17ceb0d3 100644 --- a/apps/backend/config/workflows/loader_test.go +++ b/apps/backend/config/workflows/loader_test.go @@ -114,11 +114,12 @@ func TestLoadTemplates_ExpectedTemplateIDs(t *testing.T) { } expected := map[string]bool{ - "simple": false, - "standard": false, - "architecture": false, - "pr-review": false, - "feature-dev": false, + "simple": false, + "standard": false, + "architecture": false, + "pr-review": false, + "feature-dev": false, + "improve-kandev": false, } for _, tmpl := range templates { @@ -133,3 +134,27 @@ func TestLoadTemplates_ExpectedTemplateIDs(t *testing.T) { } } } + +// TestLoadTemplates_HiddenFlag verifies that the YAML loader propagates +// the `hidden` field into WorkflowTemplate.Hidden. Only improve-kandev +// is expected to be hidden; all user-facing templates must be visible. +func TestLoadTemplates_HiddenFlag(t *testing.T) { + templates, err := LoadTemplates() + if err != nil { + t.Fatalf("LoadTemplates() returned error: %v", err) + } + + hiddenByID := map[string]bool{} + for _, tmpl := range templates { + hiddenByID[tmpl.ID] = tmpl.Hidden + } + + if !hiddenByID["improve-kandev"] { + t.Errorf("expected template %q to be hidden", "improve-kandev") + } + for _, id := range []string{"simple", "standard", "architecture", "pr-review", "feature-dev"} { + if hiddenByID[id] { + t.Errorf("template %q must not be hidden", id) + } + } +} diff --git a/apps/backend/internal/common/logger/buffer/buffer.go b/apps/backend/internal/common/logger/buffer/buffer.go new file mode 100644 index 0000000000..045413258b --- /dev/null +++ b/apps/backend/internal/common/logger/buffer/buffer.go @@ -0,0 +1,151 @@ +// Package buffer provides an in-memory ring buffer of structured log entries. +// Used to capture recent log lines for inclusion in user-submitted reports +// (e.g. Improve Kandev) without unbounded memory growth. +package buffer + +import ( + "sync" + "time" + + "go.uber.org/zap/zapcore" +) + +// DefaultCapacity is the maximum number of entries kept in the default buffer. +const DefaultCapacity = 2000 + +// Entry is a structured log record snapshotted from a zap core. +type Entry struct { + Timestamp time.Time `json:"timestamp"` + Level string `json:"level"` + Logger string `json:"logger,omitempty"` + Caller string `json:"caller,omitempty"` + Message string `json:"message"` + Fields map[string]any `json:"fields,omitempty"` + Stack string `json:"stack,omitempty"` +} + +// RingBuffer is a thread-safe FIFO buffer with a fixed capacity. +type RingBuffer struct { + mu sync.Mutex + entries []Entry + capacity int +} + +// New returns a RingBuffer with the given capacity. A non-positive capacity +// is treated as DefaultCapacity. +func New(capacity int) *RingBuffer { + if capacity <= 0 { + capacity = DefaultCapacity + } + return &RingBuffer{capacity: capacity, entries: make([]Entry, 0, capacity)} +} + +// Push appends e to the buffer, evicting the oldest entry when full. +func (b *RingBuffer) Push(e Entry) { + b.mu.Lock() + defer b.mu.Unlock() + if len(b.entries) >= b.capacity { + copy(b.entries, b.entries[1:]) + b.entries = b.entries[:len(b.entries)-1] + } + b.entries = append(b.entries, e) +} + +// Snapshot returns a copy of the current entries in chronological order. +// Each entry is deep-copied so callers cannot mutate buffered Fields maps. +func (b *RingBuffer) Snapshot() []Entry { + b.mu.Lock() + defer b.mu.Unlock() + out := make([]Entry, len(b.entries)) + for i, e := range b.entries { + out[i] = e + if e.Fields != nil { + fields := make(map[string]any, len(e.Fields)) + for k, v := range e.Fields { + fields[k] = v + } + out[i].Fields = fields + } + } + return out +} + +// Len returns the number of entries currently held. +func (b *RingBuffer) Len() int { + b.mu.Lock() + defer b.mu.Unlock() + return len(b.entries) +} + +// Clear removes all entries. +func (b *RingBuffer) Clear() { + b.mu.Lock() + defer b.mu.Unlock() + b.entries = b.entries[:0] +} + +var ( + defaultOnce sync.Once + defaultBuf *RingBuffer +) + +// Default returns the process-wide ring buffer used by the structured logger. +func Default() *RingBuffer { + defaultOnce.Do(func() { + defaultBuf = New(DefaultCapacity) + }) + return defaultBuf +} + +// Core is a zapcore.Core that pushes every emitted entry into a RingBuffer. +// It performs no encoding or output; it is intended to be teed alongside the +// real output core via zapcore.NewTee. +type Core struct { + zapcore.LevelEnabler + buffer *RingBuffer + fields []zapcore.Field +} + +// NewCore returns a zapcore.Core that writes to buf at the given level. +func NewCore(buf *RingBuffer, level zapcore.LevelEnabler) zapcore.Core { + return &Core{LevelEnabler: level, buffer: buf} +} + +// With returns a child Core with additional fields attached. +func (c *Core) With(fields []zapcore.Field) zapcore.Core { + clone := *c + clone.fields = append(append([]zapcore.Field{}, c.fields...), fields...) + return &clone +} + +// Check adds the core to the checked entry when the level is enabled. +func (c *Core) Check(ent zapcore.Entry, ce *zapcore.CheckedEntry) *zapcore.CheckedEntry { + if c.Enabled(ent.Level) { + return ce.AddCore(ent, c) + } + return ce +} + +// Write captures the entry into the ring buffer. +func (c *Core) Write(ent zapcore.Entry, fields []zapcore.Field) error { + enc := zapcore.NewMapObjectEncoder() + for _, f := range c.fields { + f.AddTo(enc) + } + for _, f := range fields { + f.AddTo(enc) + } + c.buffer.Push(Entry{ + Timestamp: ent.Time, + Level: ent.Level.String(), + Logger: ent.LoggerName, + Caller: ent.Caller.TrimmedPath(), + Message: ent.Message, + Fields: enc.Fields, + Stack: ent.Stack, + }) + return nil +} + +// Sync is a no-op; the ring buffer holds entries in memory only. +func (c *Core) Sync() error { return nil } diff --git a/apps/backend/internal/common/logger/buffer/buffer_test.go b/apps/backend/internal/common/logger/buffer/buffer_test.go new file mode 100644 index 0000000000..c9e2756970 --- /dev/null +++ b/apps/backend/internal/common/logger/buffer/buffer_test.go @@ -0,0 +1,155 @@ +package buffer + +import ( + "sync" + "testing" + "time" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +func TestRingBuffer_PushAndSnapshot(t *testing.T) { + b := New(3) + for i := 0; i < 3; i++ { + b.Push(Entry{Message: string(rune('a' + i))}) + } + snap := b.Snapshot() + if len(snap) != 3 { + t.Fatalf("expected 3 entries, got %d", len(snap)) + } + for i, want := range []string{"a", "b", "c"} { + if snap[i].Message != want { + t.Errorf("entry %d: got %q, want %q", i, snap[i].Message, want) + } + } +} + +func TestRingBuffer_FIFOEvictionAtCapacity(t *testing.T) { + b := New(3) + for i := 0; i < 5; i++ { + b.Push(Entry{Message: string(rune('a' + i))}) + } + snap := b.Snapshot() + if len(snap) != 3 { + t.Fatalf("expected 3 entries after eviction, got %d", len(snap)) + } + for i, want := range []string{"c", "d", "e"} { + if snap[i].Message != want { + t.Errorf("entry %d: got %q, want %q", i, snap[i].Message, want) + } + } +} + +func TestRingBuffer_DefaultCapacity(t *testing.T) { + b := New(0) + if b.capacity != DefaultCapacity { + t.Fatalf("non-positive capacity should default to %d, got %d", DefaultCapacity, b.capacity) + } +} + +func TestRingBuffer_SnapshotIsolation(t *testing.T) { + b := New(5) + b.Push(Entry{Message: "first"}) + snap := b.Snapshot() + snap[0].Message = "mutated" + if got := b.Snapshot()[0].Message; got != "first" { + t.Fatalf("snapshot mutation leaked: got %q", got) + } +} + +func TestRingBuffer_ConcurrentWrites(t *testing.T) { + const writers = 8 + const perWriter = 200 + b := New(writers * perWriter) + var wg sync.WaitGroup + wg.Add(writers) + for w := 0; w < writers; w++ { + go func() { + defer wg.Done() + for i := 0; i < perWriter; i++ { + b.Push(Entry{Timestamp: time.Now(), Message: "x"}) + } + }() + } + wg.Wait() + if got := b.Len(); got != writers*perWriter { + t.Fatalf("expected %d entries, got %d", writers*perWriter, got) + } +} + +func TestRingBuffer_Clear(t *testing.T) { + b := New(3) + b.Push(Entry{Message: "a"}) + b.Push(Entry{Message: "b"}) + b.Clear() + if got := b.Len(); got != 0 { + t.Fatalf("expected 0 entries after clear, got %d", got) + } +} + +func TestDefault_ReturnsSameInstance(t *testing.T) { + first := Default() + second := Default() + if first != second { + t.Fatal("Default() should return the same singleton") + } +} + +func TestCore_CapturesEntriesWithFields(t *testing.T) { + buf := New(10) + core := NewCore(buf, zapcore.DebugLevel) + logger := zap.New(core).With(zap.String("service", "test")) + + logger.Info("hello", zap.Int("count", 42)) + logger.Warn("careful") + + snap := buf.Snapshot() + if len(snap) != 2 { + t.Fatalf("expected 2 entries, got %d", len(snap)) + } + if snap[0].Message != "hello" || snap[0].Level != "info" { + t.Errorf("entry 0: got message=%q level=%q", snap[0].Message, snap[0].Level) + } + if got, _ := snap[0].Fields["service"].(string); got != "test" { + t.Errorf("entry 0 service field: got %v", snap[0].Fields["service"]) + } + if got, _ := snap[0].Fields["count"].(int64); got != 42 { + t.Errorf("entry 0 count field: got %v", snap[0].Fields["count"]) + } + if snap[1].Level != "warn" { + t.Errorf("entry 1 level: got %q", snap[1].Level) + } +} + +func TestRingBuffer_SnapshotDeepCopiesFields(t *testing.T) { + b := New(3) + b.Push(Entry{Message: "m", Fields: map[string]any{"k": "v1"}}) + + snap1 := b.Snapshot() + snap1[0].Fields["k"] = "mutated" + snap1[0].Fields["new"] = "added" + + snap2 := b.Snapshot() + if got := snap2[0].Fields["k"]; got != "v1" { + t.Errorf("buffered field mutated: got %v", got) + } + if _, ok := snap2[0].Fields["new"]; ok { + t.Errorf("buffered field map had extra key added") + } +} + +func TestCore_RespectsLevelEnabler(t *testing.T) { + buf := New(10) + core := NewCore(buf, zapcore.WarnLevel) + logger := zap.New(core) + + logger.Debug("nope") + logger.Info("nope") + logger.Warn("yes") + logger.Error("yes") + + if got := buf.Len(); got != 2 { + t.Fatalf("expected only warn+error to be captured, got %d entries", got) + } +} diff --git a/apps/backend/internal/common/logger/logger.go b/apps/backend/internal/common/logger/logger.go index 83660d7f3d..803461e9fd 100644 --- a/apps/backend/internal/common/logger/logger.go +++ b/apps/backend/internal/common/logger/logger.go @@ -8,6 +8,8 @@ import ( "go.uber.org/zap" "go.uber.org/zap/zapcore" + + "github.com/kandev/kandev/internal/common/logger/buffer" ) // Context keys for extracting values from context. @@ -99,7 +101,9 @@ func NewLogger(cfg LoggingConfig) (*Logger, error) { writeSyncer = zapcore.AddSync(file) } - core := zapcore.NewCore(encoder, writeSyncer, level) + outputCore := zapcore.NewCore(encoder, writeSyncer, level) + bufferCore := buffer.NewCore(buffer.Default(), level) + core := zapcore.NewTee(outputCore, bufferCore) zapLogger := zap.New(core, zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel)) return &Logger{ @@ -213,3 +217,9 @@ func (l *Logger) Zap() *zap.Logger { func (l *Logger) Sugar() *zap.SugaredLogger { return l.sugar } + +// BufferSnapshot returns a copy of the entries currently held in the +// process-wide log ring buffer used by Improve Kandev reports. +func (l *Logger) BufferSnapshot() []buffer.Entry { + return buffer.Default().Snapshot() +} diff --git a/apps/backend/internal/improvekandev/bundle.go b/apps/backend/internal/improvekandev/bundle.go new file mode 100644 index 0000000000..f691c7bd15 --- /dev/null +++ b/apps/backend/internal/improvekandev/bundle.go @@ -0,0 +1,210 @@ +// Package improvekandev exposes the HTTP endpoints that bootstrap a hidden +// improve-kandev workflow and capture recent backend/frontend logs into a +// temporary bundle directory referenced by the resulting task. +package improvekandev + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + "time" + + "github.com/kandev/kandev/internal/common/logger/buffer" +) + +// bundlePrefix is the temp-dir prefix used by os.MkdirTemp. +// It is also the marker validateBundleDir uses to refuse arbitrary writes. +const bundlePrefix = "kandev-improve-" + +// FrontendLogEntry mirrors apps/web/lib/logger/buffer.ts shape. +type FrontendLogEntry struct { + Timestamp string `json:"timestamp"` + Level string `json:"level"` + Message string `json:"message"` + Args []any `json:"args,omitempty"` + Stack string `json:"stack,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +// metadata is the JSON payload written to /metadata.json. +type metadata struct { + Version string `json:"version"` + OS string `json:"os"` + Arch string `json:"arch"` + GoVersion string `json:"go_version"` + CapturedAt time.Time `json:"captured_at"` + Health map[string]any `json:"health,omitempty"` +} + +// createBundleDir creates a fresh temp directory matching bundlePrefix. +func createBundleDir() (string, error) { + return os.MkdirTemp("", bundlePrefix+"*") +} + +// writeMetadata writes metadata.json into the bundle directory. +// health may be nil if no health snapshot is available. +func writeMetadata(dir, version string, health map[string]any) error { + m := metadata{ + Version: version, + OS: runtime.GOOS, + Arch: runtime.GOARCH, + GoVersion: runtime.Version(), + CapturedAt: time.Now().UTC(), + Health: health, + } + data, err := json.MarshalIndent(m, "", " ") + if err != nil { + return fmt.Errorf("marshal metadata: %w", err) + } + return os.WriteFile(filepath.Join(dir, "metadata.json"), data, 0o644) +} + +// writeBackendLog renders the buffer snapshot as plain text and writes it. +func writeBackendLog(dir string, entries []buffer.Entry) error { + var b strings.Builder + for _, e := range entries { + b.WriteString(formatBackendEntry(e)) + b.WriteByte('\n') + } + return os.WriteFile(filepath.Join(dir, "backend.log"), []byte(b.String()), 0o644) +} + +// writeFrontendLog renders frontend entries as plain text and writes them. +func writeFrontendLog(dir string, entries []FrontendLogEntry) error { + var b strings.Builder + for _, e := range entries { + b.WriteString(formatFrontendEntry(e)) + b.WriteByte('\n') + } + return os.WriteFile(filepath.Join(dir, "frontend.log"), []byte(b.String()), 0o644) +} + +// formatBackendEntry renders a single buffer.Entry as a single line of text. +func formatBackendEntry(e buffer.Entry) string { + ts := e.Timestamp.UTC().Format(time.RFC3339Nano) + level := strings.ToUpper(e.Level) + var b strings.Builder + fmt.Fprintf(&b, "%s\t%s\t%s", ts, level, e.Message) + if e.Caller != "" { + fmt.Fprintf(&b, "\t%s", e.Caller) + } + if len(e.Fields) > 0 { + fmt.Fprintf(&b, "\t%s", flattenFields(e.Fields)) + } + if e.Stack != "" { + fmt.Fprintf(&b, "\n%s", e.Stack) + } + return b.String() +} + +// formatFrontendEntry mirrors formatBackendEntry for frontend entries. +func formatFrontendEntry(e FrontendLogEntry) string { + level := strings.ToUpper(e.Level) + var b strings.Builder + fmt.Fprintf(&b, "%s\t%s\t%s", e.Timestamp, level, e.Message) + if len(e.Args) > 0 { + fmt.Fprintf(&b, "\targs=%v", e.Args) + } + if e.Stack != "" { + fmt.Fprintf(&b, "\n%s", e.Stack) + } + return b.String() +} + +// flattenFields renders structured fields as space-separated key=value pairs in +// alphabetical order so log lines are deterministic across runs. +func flattenFields(fields map[string]any) string { + keys := make([]string, 0, len(fields)) + for k := range fields { + keys = append(keys, k) + } + sort.Strings(keys) + var b strings.Builder + for i, k := range keys { + if i > 0 { + b.WriteByte(' ') + } + fmt.Fprintf(&b, "%s=%v", k, fields[k]) + } + return b.String() +} + +// staleBundleAge is the minimum age before a leftover bundle dir is removed +// by CleanupStaleBundles. Recent dirs are preserved in case a task is still +// being created and references them. +const staleBundleAge = 24 * time.Hour + +// CleanupStaleBundles removes kandev-improve-* directories under the OS temp +// dir that are older than staleBundleAge. Errors are logged via onError but +// do not abort the sweep. Safe to call concurrently with active bundles — +// fresh dirs are skipped by the mtime check. +func CleanupStaleBundles(onError func(path string, err error)) { + cleanupStaleBundlesIn(os.TempDir(), staleBundleAge, onError) +} + +func cleanupStaleBundlesIn(tempDir string, maxAge time.Duration, onError func(path string, err error)) { + entries, err := os.ReadDir(tempDir) + if err != nil { + if onError != nil { + onError(tempDir, err) + } + return + } + cutoff := time.Now().Add(-maxAge) + for _, entry := range entries { + if !entry.IsDir() || !strings.HasPrefix(entry.Name(), bundlePrefix) { + continue + } + path := filepath.Join(tempDir, entry.Name()) + info, err := entry.Info() + if err != nil { + if onError != nil { + onError(path, err) + } + continue + } + if info.ModTime().After(cutoff) { + continue + } + if err := os.RemoveAll(path); err != nil && onError != nil { + onError(path, err) + } + } +} + +// validateBundleDir refuses paths that do not match the kandev-improve-* temp +// pattern under the OS temp dir. Returns the cleaned absolute path on success. +func validateBundleDir(dir string) (string, error) { + if dir == "" { + return "", errors.New("bundle_dir is required") + } + clean := filepath.Clean(dir) + abs, err := filepath.Abs(clean) + if err != nil { + return "", fmt.Errorf("invalid bundle_dir: %w", err) + } + tempDir, err := filepath.EvalSymlinks(os.TempDir()) + if err != nil { + tempDir = os.TempDir() + } + resolved, err := filepath.EvalSymlinks(abs) + if err != nil { + resolved = abs + } + if !strings.HasPrefix(resolved, tempDir+string(os.PathSeparator)) { + return "", errors.New("bundle_dir must be inside the OS temp directory") + } + if !strings.HasPrefix(filepath.Base(resolved), bundlePrefix) { + return "", errors.New("bundle_dir name must start with " + bundlePrefix) + } + info, err := os.Stat(resolved) + if err != nil || !info.IsDir() { + return "", errors.New("bundle_dir does not exist or is not a directory") + } + return resolved, nil +} diff --git a/apps/backend/internal/improvekandev/bundle_test.go b/apps/backend/internal/improvekandev/bundle_test.go new file mode 100644 index 0000000000..bbd6cd010f --- /dev/null +++ b/apps/backend/internal/improvekandev/bundle_test.go @@ -0,0 +1,161 @@ +package improvekandev + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/kandev/kandev/internal/common/logger/buffer" +) + +func TestCreateBundleDirAndWriteFiles(t *testing.T) { + dir, err := createBundleDir() + if err != nil { + t.Fatalf("createBundleDir: %v", err) + } + defer func() { _ = os.RemoveAll(dir) }() + + if !strings.HasPrefix(filepath.Base(dir), bundlePrefix) { + t.Errorf("bundle dir base %q must start with %q", filepath.Base(dir), bundlePrefix) + } + + if err := writeMetadata(dir, "test", map[string]any{"github_auth": "ok"}); err != nil { + t.Fatalf("writeMetadata: %v", err) + } + data, err := os.ReadFile(filepath.Join(dir, "metadata.json")) + if err != nil { + t.Fatalf("read metadata.json: %v", err) + } + if !strings.Contains(string(data), "\"version\": \"test\"") { + t.Errorf("metadata.json missing version: %s", data) + } + if !strings.Contains(string(data), "\"github_auth\": \"ok\"") { + t.Errorf("metadata.json missing health: %s", data) + } + + entries := []buffer.Entry{{ + Timestamp: time.Date(2026, 4, 29, 10, 0, 0, 0, time.UTC), + Level: "info", + Message: "hello world", + Fields: map[string]any{"foo": "bar", "n": 42}, + }} + if err := writeBackendLog(dir, entries); err != nil { + t.Fatalf("writeBackendLog: %v", err) + } + beLog, _ := os.ReadFile(filepath.Join(dir, "backend.log")) + got := string(beLog) + if !strings.Contains(got, "INFO") || !strings.Contains(got, "hello world") { + t.Errorf("backend.log missing expected content: %q", got) + } + if !strings.Contains(got, "foo=bar") || !strings.Contains(got, "n=42") { + t.Errorf("backend.log missing fields: %q", got) + } + + feEntries := []FrontendLogEntry{{ + Timestamp: "2026-04-29T10:00:00.000Z", + Level: "error", + Message: "boom", + Stack: "Error\n at foo", + }} + if err := writeFrontendLog(dir, feEntries); err != nil { + t.Fatalf("writeFrontendLog: %v", err) + } + feLog, _ := os.ReadFile(filepath.Join(dir, "frontend.log")) + if !strings.Contains(string(feLog), "ERROR") || !strings.Contains(string(feLog), "boom") { + t.Errorf("frontend.log missing expected content: %q", feLog) + } +} + +func TestValidateBundleDir_AcceptsValid(t *testing.T) { + dir, err := createBundleDir() + if err != nil { + t.Fatalf("createBundleDir: %v", err) + } + defer func() { _ = os.RemoveAll(dir) }() + + resolved, err := validateBundleDir(dir) + if err != nil { + t.Errorf("validateBundleDir(%q): %v", dir, err) + } + if !strings.HasPrefix(filepath.Base(resolved), bundlePrefix) { + t.Errorf("resolved base %q must start with %q", filepath.Base(resolved), bundlePrefix) + } +} + +func TestValidateBundleDir_RejectsBad(t *testing.T) { + cases := []struct { + name string + dir string + }{ + {"empty", ""}, + {"home", "/etc"}, + {"wrong_prefix", filepath.Join(os.TempDir(), "not-kandev")}, + {"missing", filepath.Join(os.TempDir(), "kandev-improve-doesnotexist")}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := validateBundleDir(tc.dir); err == nil { + t.Errorf("expected error for %q", tc.dir) + } + }) + } +} + +func TestCleanupStaleBundles_RemovesOnlyStale(t *testing.T) { + root := t.TempDir() + + stale := filepath.Join(root, bundlePrefix+"old") + if err := os.Mkdir(stale, 0o755); err != nil { + t.Fatalf("mkdir stale: %v", err) + } + old := time.Now().Add(-72 * time.Hour) + if err := os.Chtimes(stale, old, old); err != nil { + t.Fatalf("chtimes stale: %v", err) + } + + fresh := filepath.Join(root, bundlePrefix+"new") + if err := os.Mkdir(fresh, 0o755); err != nil { + t.Fatalf("mkdir fresh: %v", err) + } + + other := filepath.Join(root, "unrelated-dir") + if err := os.Mkdir(other, 0o755); err != nil { + t.Fatalf("mkdir other: %v", err) + } + if err := os.Chtimes(other, old, old); err != nil { + t.Fatalf("chtimes other: %v", err) + } + + cleanupStaleBundlesIn(root, 24*time.Hour, nil) + + if _, err := os.Stat(stale); !os.IsNotExist(err) { + t.Errorf("stale bundle %q should have been removed: %v", stale, err) + } + if _, err := os.Stat(fresh); err != nil { + t.Errorf("fresh bundle %q should have been preserved: %v", fresh, err) + } + if _, err := os.Stat(other); err != nil { + t.Errorf("non-bundle dir %q should have been preserved: %v", other, err) + } +} + +func TestFormatBackendEntry_DeterministicFieldOrder(t *testing.T) { + e := buffer.Entry{ + Timestamp: time.Date(2026, 4, 29, 10, 0, 0, 0, time.UTC), + Level: "warn", + Message: "msg", + Fields: map[string]any{"b": 2, "a": 1, "c": 3}, + } + got := formatBackendEntry(e) + ai := strings.Index(got, "a=1") + bi := strings.Index(got, "b=2") + ci := strings.Index(got, "c=3") + if ai < 0 || bi < 0 || ci < 0 { + t.Fatalf("missing one of a=1/b=2/c=3 in %q", got) + } + if ai >= bi || bi >= ci { + t.Errorf("fields not alphabetically ordered: %q", got) + } +} diff --git a/apps/backend/internal/improvekandev/github.go b/apps/backend/internal/improvekandev/github.go new file mode 100644 index 0000000000..4947666e1a --- /dev/null +++ b/apps/backend/internal/improvekandev/github.go @@ -0,0 +1,94 @@ +package improvekandev + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strings" + "time" +) + +// GitHubInfo exposes the authenticated user's login, write-access status, +// and existing-fork lookup. It is used during bootstrap to tell the +// contributor whether forking kdlbs/kandev will work for them when they +// lack push access on the upstream repo. The EMU-restriction signal is +// derived from the login itself (see isEMULogin) and does not require an +// extra API call. +type GitHubInfo interface { + GetAuthenticatedLogin(ctx context.Context) (string, error) + HasRepoWriteAccess(ctx context.Context, owner, name string) (bool, error) + UserHasFork(ctx context.Context, login, name string) (bool, error) +} + +// defaultGitHubInfo shells out to the gh CLI. The improve-kandev dialog +// gates entry on gh auth being present, so by the time bootstrap runs gh +// is expected to be installed and authenticated. +type defaultGitHubInfo struct{} + +func newDefaultGitHubInfo() GitHubInfo { return &defaultGitHubInfo{} } + +func (d *defaultGitHubInfo) GetAuthenticatedLogin(ctx context.Context) (string, error) { + out, err := runGH(ctx, "api", "user", "--jq", ".login") + if err != nil { + return "", err + } + return strings.TrimSpace(out), nil +} + +func (d *defaultGitHubInfo) HasRepoWriteAccess(ctx context.Context, owner, name string) (bool, error) { + out, err := runGH(ctx, "api", fmt.Sprintf("repos/%s/%s", owner, name), "--jq", ".permissions.push") + if err != nil { + return false, err + } + return strings.TrimSpace(out) == "true", nil +} + +// UserHasFork returns true if a repo named name exists under login and is a +// fork. A 404 from GitHub is treated as a missing fork (false, nil) so callers +// can distinguish "no fork yet" from a real error. +func (d *defaultGitHubInfo) UserHasFork(ctx context.Context, login, name string) (bool, error) { + out, err := runGH(ctx, "api", fmt.Sprintf("repos/%s/%s", login, name), "--jq", ".fork") + if err != nil { + if isGHNotFound(err) { + return false, nil + } + return false, err + } + return strings.TrimSpace(out) == "true", nil +} + +// isEMULogin reports whether login looks like a GitHub Enterprise Managed +// User shortname. EMU account usernames are always of the form +// "{username}_{enterprise-shortcode}"; regular GitHub usernames cannot +// contain underscores, so the presence of an underscore is a definitive +// signal. EMU accounts typically cannot fork repositories outside their +// owning enterprise, so the contribution flow will fail at the PR step. +func isEMULogin(login string) bool { + return strings.Contains(login, "_") +} + +const ghTimeout = 10 * time.Second + +func runGH(ctx context.Context, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, ghTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, "gh", args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return stdout.String(), fmt.Errorf("gh %s: %w: %s", args[0], err, stderr.String()) + } + return stdout.String(), nil +} + +// isGHNotFound reports whether err originates from gh CLI receiving an HTTP +// 404 from the GitHub API. The default error format embeds the stderr output, +// where gh prints e.g. "HTTP 404: Not Found (https://api.github.com/...)". +func isGHNotFound(err error) bool { + if err == nil { + return false + } + return strings.Contains(err.Error(), "HTTP 404") +} diff --git a/apps/backend/internal/improvekandev/handler.go b/apps/backend/internal/improvekandev/handler.go new file mode 100644 index 0000000000..00d390aabd --- /dev/null +++ b/apps/backend/internal/improvekandev/handler.go @@ -0,0 +1,287 @@ +package improvekandev + +import ( + "context" + "net/http" + "path/filepath" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/common/logger/buffer" + taskmodels "github.com/kandev/kandev/internal/task/models" + taskservice "github.com/kandev/kandev/internal/task/service" +) + +// Constants identifying the canonical kandev repository and the workflow +// template loaded from apps/backend/config/workflows/improve-kandev.yml. +const ( + repoCloneURL = "https://github.com/kdlbs/kandev" + repoOwner = "kdlbs" + repoName = "kandev" + repoProvider = "github" + defaultBranch = "main" + + templateID = "improve-kandev" + workflowName = "Improve Kandev" + workflowDesc = "Hidden workflow for filing improvements via the in-app entry point." +) + +// Cloner is the minimal subset of repoclone.Cloner the bootstrap endpoint uses. +// Defined as an interface so tests can substitute a fake without network access. +type Cloner interface { + EnsureCloned(ctx context.Context, cloneURL, owner, name string) (string, error) +} + +// Handler exposes the improve-kandev HTTP endpoints. +type Handler struct { + taskSvc *taskservice.Service + cloner Cloner + log *logger.Logger + version string + // snapshot returns the current backend log buffer; defaults to logger's + // process-wide buffer but can be overridden for tests. + snapshot func() []buffer.Entry + // gh resolves the authenticated user's login and write access. Defaults + // to a gh-CLI shell-out; tests can substitute a fake. + gh GitHubInfo +} + +// NewHandler constructs a Handler. version is embedded into bundle metadata. +func NewHandler(taskSvc *taskservice.Service, cloner Cloner, version string, log *logger.Logger) *Handler { + return &Handler{ + taskSvc: taskSvc, + cloner: cloner, + log: log, + version: version, + snapshot: func() []buffer.Entry { return buffer.Default().Snapshot() }, + gh: newDefaultGitHubInfo(), + } +} + +// RegisterRoutes registers the bootstrap and frontend-log endpoints. +func RegisterRoutes(router *gin.Engine, h *Handler) { + if h == nil { + return + } + api := router.Group("/api/v1/system/improve-kandev") + api.POST("/bootstrap", h.httpBootstrap) + api.POST("/bundle/frontend-log", h.httpFrontendLog) +} + +// BootstrapRequest is the JSON body for POST /bootstrap. +type BootstrapRequest struct { + WorkspaceID string `json:"workspace_id"` +} + +// ForkStatus reports the result of the bootstrap fork-capability probe. The +// frontend uses it to surface a clear error before the user invests time in +// drafting a contribution that the GitHub side will block. +type ForkStatus string + +const ( + // ForkStatusWritable: user has push access on the upstream repo, no fork + // is needed. + ForkStatusWritable ForkStatus = "writable" + // ForkStatusReady: user already has a fork at github.com/{login}/kandev, + // so the PR step can push to it without forking again. + ForkStatusReady ForkStatus = "ready" + // ForkStatusBlockedEMU: the authenticated user looks like an Enterprise + // Managed User. EMU accounts cannot fork repositories outside their + // owning enterprise, so the contribution flow will fail at the PR step. + ForkStatusBlockedEMU ForkStatus = "blocked_emu" + // ForkStatusUnknown: bootstrap could not determine fork eligibility + // (e.g., gh CLI lookup failed). Frontend should proceed and rely on the + // PR step to surface any errors. + ForkStatusUnknown ForkStatus = "unknown" +) + +// 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"` +} + +func (h *Handler) httpBootstrap(c *gin.Context) { + var req BootstrapRequest + if err := c.ShouldBindJSON(&req); err != nil || req.WorkspaceID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id is required"}) + return + } + + ctx := c.Request.Context() + localPath, err := h.cloner.EnsureCloned(ctx, repoCloneURL, repoOwner, repoName) + if err != nil { + h.log.Error("improve-kandev: clone failed", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to clone kandev repo"}) + return + } + + repo, err := h.taskSvc.FindOrCreateRepository(ctx, &taskservice.FindOrCreateRepositoryRequest{ + WorkspaceID: req.WorkspaceID, + Provider: repoProvider, + ProviderOwner: repoOwner, + ProviderName: repoName, + DefaultBranch: defaultBranch, + LocalPath: localPath, + }) + if err != nil { + h.log.Error("improve-kandev: repository upsert failed", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to register kandev repository"}) + return + } + + workflow, err := h.ensureWorkflow(ctx, req.WorkspaceID) + 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 + } + + dir, err := createBundleDir() + if err != nil { + h.log.Error("improve-kandev: bundle dir creation failed", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create bundle dir"}) + return + } + if err := writeMetadata(dir, h.version, nil); err != nil { + h.log.Error("improve-kandev: metadata write failed", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write metadata"}) + return + } + if err := writeBackendLog(dir, h.snapshot()); err != nil { + h.log.Error("improve-kandev: backend log write failed", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write backend log"}) + return + } + + access := h.resolveGitHubAccess(ctx) + + c.JSON(http.StatusOK, BootstrapResponse{ + RepositoryID: repo.ID, + WorkflowID: workflow.ID, + Branch: defaultBranch, + BundleDir: dir, + BundleFiles: map[string]string{ + "metadata": filepath.Join(dir, "metadata.json"), + "backend_log": filepath.Join(dir, "backend.log"), + "frontend_log": filepath.Join(dir, "frontend.log"), + }, + GitHubLogin: access.login, + HasWriteAccess: access.hasWrite, + ForkStatus: access.forkStatus, + ForkMessage: access.forkMessage, + }) +} + +// emuBlockedMessage explains the EMU-restriction case to the contributor in +// terms they can act on. Surfaced in the dialog when ForkStatusBlockedEMU is +// returned. +const emuBlockedMessage = "Your GitHub account appears to be an Enterprise Managed User (EMU) account, " + + "which typically cannot fork repositories outside your owning enterprise. " + + "The PR step would fail when forking kdlbs/kandev. Contact your GitHub admin " + + "if you'd like to enable this, or contribute via another account." + +// githubAccess is the resolved bootstrap GitHub state. +type githubAccess struct { + login string + hasWrite bool + forkStatus ForkStatus + forkMessage string +} + +// resolveGitHubAccess resolves the authenticated user's login, push access, +// and a fork-capability hint. Failures are logged at debug level and return +// safe defaults so bootstrap never fails on gh issues; the frontend treats +// ForkStatusUnknown as "proceed and rely on the PR step". +func (h *Handler) resolveGitHubAccess(ctx context.Context) githubAccess { + out := githubAccess{forkStatus: ForkStatusUnknown} + if h.gh == nil { + return out + } + login, err := h.gh.GetAuthenticatedLogin(ctx) + if err != nil { + h.log.Debug("improve-kandev: gh login lookup failed", zap.Error(err)) + return out + } + out.login = login + hasWrite, err := h.gh.HasRepoWriteAccess(ctx, repoOwner, repoName) + if err != nil { + h.log.Debug("improve-kandev: gh write-access check failed", zap.Error(err)) + return out + } + out.hasWrite = hasWrite + if hasWrite { + out.forkStatus = ForkStatusWritable + return out + } + hasFork, err := h.gh.UserHasFork(ctx, login, repoName) + if err != nil { + h.log.Debug("improve-kandev: gh fork lookup failed", zap.Error(err)) + return out + } + if hasFork { + out.forkStatus = ForkStatusReady + return out + } + if isEMULogin(login) { + out.forkStatus = ForkStatusBlockedEMU + out.forkMessage = emuBlockedMessage + } + 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 { + return w, nil + } + } + tmplID := templateID + return h.taskSvc.CreateWorkflow(ctx, &taskservice.CreateWorkflowRequest{ + WorkspaceID: workspaceID, + Name: workflowName, + Description: workflowDesc, + WorkflowTemplateID: &tmplID, + Hidden: true, + }) +} + +// FrontendLogRequest is the JSON body for POST /bundle/frontend-log. +type FrontendLogRequest struct { + BundleDir string `json:"bundle_dir"` + Entries []FrontendLogEntry `json:"entries"` +} + +func (h *Handler) httpFrontendLog(c *gin.Context) { + var req FrontendLogRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"}) + return + } + dir, err := validateBundleDir(req.BundleDir) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if err := writeFrontendLog(dir, req.Entries); err != nil { + h.log.Error("improve-kandev: frontend log write failed", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write frontend log"}) + return + } + c.JSON(http.StatusOK, gin.H{"path": filepath.Join(dir, "frontend.log")}) +} diff --git a/apps/backend/internal/improvekandev/handler_test.go b/apps/backend/internal/improvekandev/handler_test.go new file mode 100644 index 0000000000..972dd355b0 --- /dev/null +++ b/apps/backend/internal/improvekandev/handler_test.go @@ -0,0 +1,146 @@ +package improvekandev + +import ( + "context" + "errors" + "testing" + + "github.com/kandev/kandev/internal/common/logger" +) + +// fakeGitHubInfo is a configurable GitHubInfo for unit tests. Each method +// returns the value of the corresponding field; if the err counterpart is +// non-nil, the value is ignored and the error is returned instead. +type fakeGitHubInfo struct { + login string + loginErr error + hasWrite bool + hasWriteErr error + hasFork bool + hasForkErr error + calledHasFork bool +} + +func (f *fakeGitHubInfo) GetAuthenticatedLogin(_ context.Context) (string, error) { + return f.login, f.loginErr +} + +func (f *fakeGitHubInfo) HasRepoWriteAccess(_ context.Context, _, _ string) (bool, error) { + return f.hasWrite, f.hasWriteErr +} + +func (f *fakeGitHubInfo) UserHasFork(_ context.Context, _, _ string) (bool, error) { + f.calledHasFork = true + return f.hasFork, f.hasForkErr +} + +func newTestHandler(gh GitHubInfo) *Handler { + return &Handler{gh: gh, log: logger.Default()} +} + +func TestResolveGitHubAccess_Writable(t *testing.T) { + gh := &fakeGitHubInfo{login: "alice", hasWrite: true} + access := newTestHandler(gh).resolveGitHubAccess(context.Background()) + if access.login != "alice" || !access.hasWrite { + t.Fatalf("login/write mismatch: %+v", access) + } + if access.forkStatus != ForkStatusWritable { + t.Errorf("fork status = %q, want %q", access.forkStatus, ForkStatusWritable) + } + if gh.calledHasFork { + t.Errorf("writable users should short-circuit before the fork check") + } +} + +func TestResolveGitHubAccess_ForkAlreadyExists(t *testing.T) { + gh := &fakeGitHubInfo{login: "bob_corp", hasWrite: false, hasFork: true} + access := newTestHandler(gh).resolveGitHubAccess(context.Background()) + if access.forkStatus != ForkStatusReady { + t.Errorf("fork status = %q, want %q", access.forkStatus, ForkStatusReady) + } + if access.forkMessage != "" { + t.Errorf("ready status must not include a fork_message even for EMU-shaped logins: %q", access.forkMessage) + } +} + +func TestResolveGitHubAccess_BlockedEMU(t *testing.T) { + gh := &fakeGitHubInfo{login: "eve_corp", hasWrite: false, hasFork: false} + access := newTestHandler(gh).resolveGitHubAccess(context.Background()) + if access.forkStatus != ForkStatusBlockedEMU { + t.Errorf("fork status = %q, want %q", access.forkStatus, ForkStatusBlockedEMU) + } + if access.forkMessage == "" { + t.Errorf("blocked_emu must include a fork_message for the dialog") + } +} + +func TestResolveGitHubAccess_UnknownOnLoginError(t *testing.T) { + gh := &fakeGitHubInfo{loginErr: errors.New("auth failed")} + access := newTestHandler(gh).resolveGitHubAccess(context.Background()) + if access.login != "" || access.hasWrite { + t.Errorf("login error must yield empty access: %+v", access) + } + if access.forkStatus != ForkStatusUnknown { + t.Errorf("fork status = %q, want %q", access.forkStatus, ForkStatusUnknown) + } +} + +func TestResolveGitHubAccess_UnknownOnForkLookupError(t *testing.T) { + gh := &fakeGitHubInfo{login: "carol_corp", hasWrite: false, hasForkErr: errors.New("network")} + access := newTestHandler(gh).resolveGitHubAccess(context.Background()) + if access.forkStatus != ForkStatusUnknown { + t.Errorf("fork status = %q, want %q", access.forkStatus, ForkStatusUnknown) + } + if access.forkMessage != "" { + t.Errorf("fork lookup failures must not produce an EMU message even for underscore logins") + } +} + +func TestResolveGitHubAccess_NoForkNotEMU(t *testing.T) { + gh := &fakeGitHubInfo{login: "frank", hasWrite: false, hasFork: false} + access := newTestHandler(gh).resolveGitHubAccess(context.Background()) + if access.forkStatus != ForkStatusUnknown { + t.Errorf("fork status = %q, want %q", access.forkStatus, ForkStatusUnknown) + } + if access.forkMessage != "" { + t.Errorf("non-EMU users should not get a fork_message") + } +} + +func TestIsEMULogin(t *testing.T) { + cases := []struct { + login string + want bool + }{ + {"alice", false}, + {"alice-bob", false}, + {"", false}, + {"alice_corp", true}, + {"Carlos-Florencio-ii3_nbcuni", true}, + } + for _, tc := range cases { + if got := isEMULogin(tc.login); got != tc.want { + t.Errorf("isEMULogin(%q) = %v, want %v", tc.login, got, tc.want) + } + } +} + +func TestIsGHNotFound(t *testing.T) { + cases := []struct { + name string + err error + want bool + }{ + {"nil", nil, false}, + {"unrelated", errors.New("network unreachable"), false}, + {"http 404", errors.New("gh api: exit status 1: HTTP 404: Not Found"), true}, + {"http 403", errors.New("gh api: HTTP 403: Forbidden"), false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := isGHNotFound(tc.err); got != tc.want { + t.Errorf("isGHNotFound(%v) = %v, want %v", tc.err, got, tc.want) + } + }) + } +} diff --git a/apps/backend/internal/mcp/handlers/handlers.go b/apps/backend/internal/mcp/handlers/handlers.go index 8601c2333e..ee63d1c8b4 100644 --- a/apps/backend/internal/mcp/handlers/handlers.go +++ b/apps/backend/internal/mcp/handlers/handlers.go @@ -273,7 +273,7 @@ func (h *Handlers) handleDeleteByField( func (h *Handlers) handleListWorkflows(ctx context.Context, msg *ws.Message) (*ws.Message, error) { return h.handleListByField(ctx, msg, "workspace_id", "failed to list workflows", "Failed to list workflows", func(ctx context.Context, workspaceID string) (any, error) { - workflows, err := h.taskSvc.ListWorkflows(ctx, workspaceID) + workflows, err := h.taskSvc.ListWorkflows(ctx, workspaceID, false) if err != nil { return nil, err } @@ -374,7 +374,7 @@ func (h *Handlers) handleCreateTask(ctx context.Context, msg *ws.Message) (*ws.M } if req.WorkflowID == "" && h.taskSvc != nil { - if workflows, wfErr := h.taskSvc.ListWorkflows(ctx, req.WorkspaceID); wfErr != nil { + if workflows, wfErr := h.taskSvc.ListWorkflows(ctx, req.WorkspaceID, false); wfErr != nil { h.logger.Warn("failed to auto-resolve workflow", zap.String("workspace_id", req.WorkspaceID), zap.Error(wfErr)) } else if len(workflows) == 1 { req.WorkflowID = workflows[0].ID diff --git a/apps/backend/internal/mcp/handlers/handlers_test.go b/apps/backend/internal/mcp/handlers/handlers_test.go index 0c3e365b11..042a132810 100644 --- a/apps/backend/internal/mcp/handlers/handlers_test.go +++ b/apps/backend/internal/mcp/handlers/handlers_test.go @@ -367,7 +367,7 @@ func TestHandleCreateTask_AutoResolvesWorkspaceAndWorkflow(t *testing.T) { require.NoError(t, wsErr) require.Len(t, workspaces, 1, "should have exactly 1 default workspace") - workflows, wfErr := svc.ListWorkflows(ctx, workspaces[0].ID) + workflows, wfErr := svc.ListWorkflows(ctx, workspaces[0].ID, false) require.NoError(t, wfErr) require.Len(t, workflows, 1, "should have exactly 1 default workflow") diff --git a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go index 7608a7b63a..6d23ddfcb9 100644 --- a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go @@ -382,7 +382,7 @@ func (m *mockRepository) UpdateWorkflow(ctx context.Context, workflow *models.Wo return nil } func (m *mockRepository) DeleteWorkflow(ctx context.Context, id string) error { return nil } -func (m *mockRepository) ListWorkflows(ctx context.Context, workspaceID string) ([]*models.Workflow, error) { +func (m *mockRepository) ListWorkflows(ctx context.Context, workspaceID string, includeHidden bool) ([]*models.Workflow, error) { return nil, nil } func (m *mockRepository) ReorderWorkflows(ctx context.Context, workspaceID string, workflowIDs []string) error { diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index 51d0f56d68..e201c45af5 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -15,6 +15,7 @@ type WorkflowDTO struct { Description *string `json:"description,omitempty"` AgentProfileID string `json:"agent_profile_id,omitempty"` SortOrder int `json:"sort_order"` + Hidden bool `json:"hidden,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } @@ -369,6 +370,7 @@ func FromWorkflow(workflow *models.Workflow) WorkflowDTO { Description: description, AgentProfileID: workflow.AgentProfileID, SortOrder: workflow.SortOrder, + Hidden: workflow.Hidden, CreatedAt: workflow.CreatedAt, UpdatedAt: workflow.UpdatedAt, } diff --git a/apps/backend/internal/task/handlers/process_handlers_test.go b/apps/backend/internal/task/handlers/process_handlers_test.go index f31057efcc..fd9760879f 100644 --- a/apps/backend/internal/task/handlers/process_handlers_test.go +++ b/apps/backend/internal/task/handlers/process_handlers_test.go @@ -118,7 +118,7 @@ func (m *mockRepository) UpdateWorkflow(ctx context.Context, workflow *models.Wo func (m *mockRepository) DeleteWorkflow(ctx context.Context, id string) error { return nil } -func (m *mockRepository) ListWorkflows(ctx context.Context, workspaceID string) ([]*models.Workflow, error) { +func (m *mockRepository) ListWorkflows(ctx context.Context, workspaceID string, includeHidden bool) ([]*models.Workflow, error) { return nil, nil } func (m *mockRepository) ReorderWorkflows(ctx context.Context, workspaceID string, workflowIDs []string) error { diff --git a/apps/backend/internal/task/handlers/workflow_handlers.go b/apps/backend/internal/task/handlers/workflow_handlers.go index 6ba82acd86..85c7d0e04a 100644 --- a/apps/backend/internal/task/handlers/workflow_handlers.go +++ b/apps/backend/internal/task/handlers/workflow_handlers.go @@ -70,8 +70,8 @@ func (h *WorkflowHandlers) registerWS(dispatcher *ws.Dispatcher) { dispatcher.RegisterFunc(ws.ActionWorkflowReorder, h.wsReorderWorkflows) } -func (h *WorkflowHandlers) listWorkflows(ctx context.Context, workspaceID string) (dto.ListWorkflowsResponse, error) { - workflows, err := h.service.ListWorkflows(ctx, workspaceID) +func (h *WorkflowHandlers) listWorkflows(ctx context.Context, workspaceID string, includeHidden bool) (dto.ListWorkflowsResponse, error) { + workflows, err := h.service.ListWorkflows(ctx, workspaceID, includeHidden) if err != nil { return dto.ListWorkflowsResponse{}, err } @@ -85,11 +85,20 @@ func (h *WorkflowHandlers) listWorkflows(ctx context.Context, workspaceID string return result, nil } +func parseIncludeHidden(s string) bool { + v, err := strconv.ParseBool(s) + if err != nil { + return false + } + return v +} + // HTTP handlers func (h *WorkflowHandlers) httpListWorkflows(c *gin.Context) { workspaceID := c.Query("workspace_id") - resp, err := h.listWorkflows(c.Request.Context(), workspaceID) + includeHidden := parseIncludeHidden(c.Query("include_hidden")) + resp, err := h.listWorkflows(c.Request.Context(), workspaceID, includeHidden) if err != nil { h.logger.Error("failed to list workflows", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list workflows"}) @@ -100,7 +109,8 @@ func (h *WorkflowHandlers) httpListWorkflows(c *gin.Context) { func (h *WorkflowHandlers) httpListWorkflowsByWorkspace(c *gin.Context) { workspaceID := c.Param("id") - resp, err := h.listWorkflows(c.Request.Context(), workspaceID) + includeHidden := parseIncludeHidden(c.Query("include_hidden")) + resp, err := h.listWorkflows(c.Request.Context(), workspaceID, includeHidden) if err != nil { h.logger.Error("failed to list workflows", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list workflows"}) @@ -228,7 +238,7 @@ func (h *WorkflowHandlers) httpGetWorkspaceSnapshot(c *gin.Context) { workflowID := c.Query("workflow_id") if workflowID == "" { - workflows, err := h.service.ListWorkflows(c.Request.Context(), workspaceID) + workflows, err := h.service.ListWorkflows(c.Request.Context(), workspaceID, false) if err != nil { handleNotFound(c, h.logger, err, "workflow not found") return @@ -295,14 +305,15 @@ func applyTaskLimit(c *gin.Context, tasks []*models.Task) []*models.Task { func (h *WorkflowHandlers) wsListWorkflows(ctx context.Context, msg *ws.Message) (*ws.Message, error) { var req struct { - WorkspaceID string `json:"workspace_id,omitempty"` + WorkspaceID string `json:"workspace_id,omitempty"` + IncludeHidden bool `json:"include_hidden,omitempty"` } if msg.Payload != nil { if err := msg.ParsePayload(&req); err != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) } } - workflows, err := h.service.ListWorkflows(ctx, req.WorkspaceID) + workflows, err := h.service.ListWorkflows(ctx, req.WorkspaceID, req.IncludeHidden) if err != nil { h.logger.Error("failed to list workflows", zap.Error(err)) return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list workflows", nil) diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index cbe087eb17..e68257c5b4 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -54,15 +54,18 @@ type Task struct { // Workflow represents a task workflow type Workflow struct { - ID string `json:"id"` - WorkspaceID string `json:"workspace_id"` - Name string `json:"name"` - Description string `json:"description"` - AgentProfileID string `json:"agent_profile_id,omitempty"` - WorkflowTemplateID *string `json:"workflow_template_id,omitempty"` - SortOrder int `json:"sort_order"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + WorkspaceID string `json:"workspace_id"` + Name string `json:"name"` + Description string `json:"description"` + AgentProfileID string `json:"agent_profile_id,omitempty"` + WorkflowTemplateID *string `json:"workflow_template_id,omitempty"` + SortOrder int `json:"sort_order"` + // Hidden workflows are excluded from management and picker UIs by default. + // Used by system-only flows like Improve Kandev. + Hidden bool `json:"hidden,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // Workspace represents a workspace diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index d3d04887ea..d2a5fac960 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -55,7 +55,7 @@ type WorkflowRepository interface { GetWorkflow(ctx context.Context, id string) (*models.Workflow, error) UpdateWorkflow(ctx context.Context, workflow *models.Workflow) error DeleteWorkflow(ctx context.Context, id string) error - ListWorkflows(ctx context.Context, workspaceID string) ([]*models.Workflow, error) + ListWorkflows(ctx context.Context, workspaceID string, includeHidden bool) ([]*models.Workflow, error) ReorderWorkflows(ctx context.Context, workspaceID string, workflowIDs []string) error } diff --git a/apps/backend/internal/task/repository/repository_test.go b/apps/backend/internal/task/repository/repository_test.go index 14e842487a..401bef334e 100644 --- a/apps/backend/internal/task/repository/repository_test.go +++ b/apps/backend/internal/task/repository/repository_test.go @@ -77,7 +77,7 @@ func TestSQLiteRepository_SeedsDefaultWorkspace(t *testing.T) { t.Errorf("expected Default Workspace, got %s", workspaces[0].Name) } - workflows, err := repo.ListWorkflows(ctx, workspaces[0].ID) + workflows, err := repo.ListWorkflows(ctx, workspaces[0].ID, false) if err != nil { t.Fatalf("failed to list workflows: %v", err) } diff --git a/apps/backend/internal/task/repository/sqlite/base.go b/apps/backend/internal/task/repository/sqlite/base.go index 56be451eeb..30640a0ce4 100644 --- a/apps/backend/internal/task/repository/sqlite/base.go +++ b/apps/backend/internal/task/repository/sqlite/base.go @@ -160,6 +160,8 @@ func (r *Repository) runMigrations() error { _, _ = r.db.Exec(`ALTER TABLE workflows ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0`) // Add agent_profile_id column to workflows for per-workflow agent profile override (ignore error if already exists) _, _ = r.db.Exec(`ALTER TABLE workflows ADD COLUMN agent_profile_id TEXT DEFAULT ''`) + // Add hidden flag to workflows for system-only flows excluded from management UI (ignore error if already exists) + _, _ = r.db.Exec(`ALTER TABLE workflows ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0`) return nil } @@ -492,6 +494,7 @@ func (r *Repository) initInfraSchema() error { workflow_template_id TEXT DEFAULT '', name TEXT NOT NULL, description TEXT DEFAULT '', + hidden INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL ); diff --git a/apps/backend/internal/task/repository/sqlite/workflow.go b/apps/backend/internal/task/repository/sqlite/workflow.go index b309410ab1..fa1d9176ce 100644 --- a/apps/backend/internal/task/repository/sqlite/workflow.go +++ b/apps/backend/internal/task/repository/sqlite/workflow.go @@ -51,9 +51,9 @@ func (r *Repository) CreateWorkflow(ctx context.Context, workflow *models.Workfl workflow.SortOrder = maxOrder + 1 _, err = r.db.ExecContext(ctx, r.db.Rebind(` - INSERT INTO workflows (id, workspace_id, name, description, agent_profile_id, workflow_template_id, sort_order, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `), workflow.ID, workflow.WorkspaceID, workflow.Name, workflow.Description, workflow.AgentProfileID, workflow.WorkflowTemplateID, workflow.SortOrder, workflow.CreatedAt, workflow.UpdatedAt) + INSERT INTO workflows (id, workspace_id, name, description, agent_profile_id, workflow_template_id, sort_order, hidden, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `), workflow.ID, workflow.WorkspaceID, workflow.Name, workflow.Description, workflow.AgentProfileID, workflow.WorkflowTemplateID, workflow.SortOrder, workflow.Hidden, workflow.CreatedAt, workflow.UpdatedAt) return err } @@ -64,9 +64,9 @@ func (r *Repository) GetWorkflow(ctx context.Context, id string) (*models.Workfl var workflowTemplateID, agentProfileID sql.NullString err := r.ro.QueryRowContext(ctx, r.ro.Rebind(` - SELECT id, workspace_id, name, description, agent_profile_id, workflow_template_id, sort_order, created_at, updated_at + SELECT id, workspace_id, name, description, agent_profile_id, workflow_template_id, sort_order, hidden, created_at, updated_at FROM workflows WHERE id = ? - `), id).Scan(&workflow.ID, &workflow.WorkspaceID, &workflow.Name, &workflow.Description, &agentProfileID, &workflowTemplateID, &workflow.SortOrder, &workflow.CreatedAt, &workflow.UpdatedAt) + `), id).Scan(&workflow.ID, &workflow.WorkspaceID, &workflow.Name, &workflow.Description, &agentProfileID, &workflowTemplateID, &workflow.SortOrder, &workflow.Hidden, &workflow.CreatedAt, &workflow.UpdatedAt) if err == sql.ErrNoRows { return nil, fmt.Errorf("workflow not found: %s", id) @@ -89,8 +89,8 @@ func (r *Repository) UpdateWorkflow(ctx context.Context, workflow *models.Workfl workflow.UpdatedAt = time.Now().UTC() result, err := r.db.ExecContext(ctx, r.db.Rebind(` - UPDATE workflows SET name = ?, description = ?, agent_profile_id = ?, workflow_template_id = ?, updated_at = ? WHERE id = ? - `), workflow.Name, workflow.Description, workflow.AgentProfileID, workflow.WorkflowTemplateID, workflow.UpdatedAt, workflow.ID) + UPDATE workflows SET name = ?, description = ?, agent_profile_id = ?, workflow_template_id = ?, hidden = ?, updated_at = ? WHERE id = ? + `), workflow.Name, workflow.Description, workflow.AgentProfileID, workflow.WorkflowTemplateID, workflow.Hidden, workflow.UpdatedAt, workflow.ID) if err != nil { return err } @@ -140,16 +140,28 @@ func (r *Repository) DeleteWorkflow(ctx context.Context, id string) error { return nil } -// ListWorkflows returns all workflows -func (r *Repository) ListWorkflows(ctx context.Context, workspaceID string) ([]*models.Workflow, error) { +// ListWorkflows returns workflows for the given workspace, excluding hidden by default. +// Pass includeHidden=true to also return system-only workflows like Improve Kandev. +func (r *Repository) ListWorkflows(ctx context.Context, workspaceID string, includeHidden bool) ([]*models.Workflow, error) { query := ` - SELECT id, workspace_id, name, description, agent_profile_id, workflow_template_id, sort_order, created_at, updated_at FROM workflows + SELECT id, workspace_id, name, description, agent_profile_id, workflow_template_id, sort_order, hidden, created_at, updated_at FROM workflows ` var args []interface{} + var conditions []string if workspaceID != "" { - query += " WHERE workspace_id = ?" + conditions = append(conditions, "workspace_id = ?") args = append(args, workspaceID) } + if !includeHidden { + conditions = append(conditions, "hidden = 0") + } + for i, c := range conditions { + if i == 0 { + query += " WHERE " + c + } else { + query += " AND " + c + } + } query += " ORDER BY sort_order ASC, created_at ASC" rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), args...) @@ -162,7 +174,7 @@ func (r *Repository) ListWorkflows(ctx context.Context, workspaceID string) ([]* for rows.Next() { workflow := &models.Workflow{} var agentProfileID, workflowTemplateID sql.NullString - err := rows.Scan(&workflow.ID, &workflow.WorkspaceID, &workflow.Name, &workflow.Description, &agentProfileID, &workflowTemplateID, &workflow.SortOrder, &workflow.CreatedAt, &workflow.UpdatedAt) + err := rows.Scan(&workflow.ID, &workflow.WorkspaceID, &workflow.Name, &workflow.Description, &agentProfileID, &workflowTemplateID, &workflow.SortOrder, &workflow.Hidden, &workflow.CreatedAt, &workflow.UpdatedAt) if err != nil { return nil, err } diff --git a/apps/backend/internal/task/repository/workflow_repository_test.go b/apps/backend/internal/task/repository/workflow_repository_test.go index ee15654643..964a6e0f19 100644 --- a/apps/backend/internal/task/repository/workflow_repository_test.go +++ b/apps/backend/internal/task/repository/workflow_repository_test.go @@ -93,7 +93,7 @@ func TestSQLiteRepository_ListWorkflows(t *testing.T) { _ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-2", WorkspaceID: "ws-1", Name: "Workflow 2"}) _ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-3", WorkspaceID: "ws-2", Name: "Workflow 3"}) - workflows, err := repo.ListWorkflows(ctx, "ws-1") + workflows, err := repo.ListWorkflows(ctx, "ws-1", false) if err != nil { t.Fatalf("failed to list workflows: %v", err) } @@ -134,7 +134,7 @@ func TestSQLiteRepository_WorkflowSortOrder(t *testing.T) { } // Verify ListWorkflows returns in sort_order - workflows, _ := repo.ListWorkflows(ctx, "ws-1") + workflows, _ := repo.ListWorkflows(ctx, "ws-1", false) if len(workflows) != 3 { t.Fatalf("expected 3 workflows, got %d", len(workflows)) } @@ -160,7 +160,7 @@ func TestSQLiteRepository_ReorderWorkflows(t *testing.T) { t.Fatalf("failed to reorder workflows: %v", err) } - workflows, _ := repo.ListWorkflows(ctx, "ws-1") + workflows, _ := repo.ListWorkflows(ctx, "ws-1", false) if len(workflows) != 3 { t.Fatalf("expected 3 workflows, got %d", len(workflows)) } @@ -266,7 +266,7 @@ func TestSQLiteRepository_WorkflowAgentProfileID(t *testing.T) { Name: "Another Workflow", AgentProfileID: "profile-789", }) - workflows, err := repo.ListWorkflows(ctx, "ws-1") + workflows, err := repo.ListWorkflows(ctx, "ws-1", false) if err != nil { t.Fatalf("failed to list workflows: %v", err) } diff --git a/apps/backend/internal/task/service/service_requests.go b/apps/backend/internal/task/service/service_requests.go index 851cb1f094..340f7ff79c 100644 --- a/apps/backend/internal/task/service/service_requests.go +++ b/apps/backend/internal/task/service/service_requests.go @@ -53,6 +53,8 @@ type CreateWorkflowRequest struct { Name string `json:"name"` Description string `json:"description"` WorkflowTemplateID *string `json:"workflow_template_id,omitempty"` + // Hidden marks the workflow as system-only; excluded from management UI and pickers. + Hidden bool `json:"hidden,omitempty"` } // UpdateWorkflowRequest contains the data for updating a workflow diff --git a/apps/backend/internal/task/service/service_resources.go b/apps/backend/internal/task/service/service_resources.go index 832070d888..70f745ccff 100644 --- a/apps/backend/internal/task/service/service_resources.go +++ b/apps/backend/internal/task/service/service_resources.go @@ -124,6 +124,7 @@ func (s *Service) CreateWorkflow(ctx context.Context, req *CreateWorkflowRequest Name: req.Name, Description: req.Description, WorkflowTemplateID: req.WorkflowTemplateID, + Hidden: req.Hidden, } if err := s.workflows.CreateWorkflow(ctx, workflow); err != nil { @@ -196,9 +197,10 @@ func (s *Service) DeleteWorkflow(ctx context.Context, id string) error { return nil } -// ListWorkflows returns all workflows for a workspace (or all if empty) -func (s *Service) ListWorkflows(ctx context.Context, workspaceID string) ([]*models.Workflow, error) { - return s.workflows.ListWorkflows(ctx, workspaceID) +// ListWorkflows returns workflows for a workspace, excluding hidden ones by default. +// Pass includeHidden=true to include system-only flows like Improve Kandev. +func (s *Service) ListWorkflows(ctx context.Context, workspaceID string, includeHidden bool) ([]*models.Workflow, error) { + return s.workflows.ListWorkflows(ctx, workspaceID, includeHidden) } // ReorderWorkflows updates sort_order for workflows within a workspace. diff --git a/apps/backend/internal/task/service/service_test.go b/apps/backend/internal/task/service/service_test.go index 3c3181fa72..36bb30297e 100644 --- a/apps/backend/internal/task/service/service_test.go +++ b/apps/backend/internal/task/service/service_test.go @@ -465,7 +465,7 @@ func TestService_ListWorkflows(t *testing.T) { _ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "Workflow 1"}) _ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-2", WorkspaceID: "ws-1", Name: "Workflow 2"}) - workflows, err := svc.ListWorkflows(ctx, "ws-1") + workflows, err := svc.ListWorkflows(ctx, "ws-1", false) if err != nil { t.Fatalf("failed to list workflows: %v", err) } diff --git a/apps/backend/internal/workflow/models/models.go b/apps/backend/internal/workflow/models/models.go index fa980b5ed7..be86f213d1 100644 --- a/apps/backend/internal/workflow/models/models.go +++ b/apps/backend/internal/workflow/models/models.go @@ -83,13 +83,17 @@ const ( // WorkflowTemplate represents a pre-defined workflow type that workflows can adopt type WorkflowTemplate struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - IsSystem bool `json:"is_system"` - Steps []StepDefinition `json:"steps"` // JSON stored - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + IsSystem bool `json:"is_system"` + // Hidden marks templates that produce hidden workflow instances + // (excluded from management UI and pickers). Used by system-only flows. + // Not persisted to DB; sourced from the embedded YAML at load time. + Hidden bool `json:"hidden,omitempty"` + Steps []StepDefinition `json:"steps"` // JSON stored + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // StepDefinition represents a step in a workflow template (stored as JSON in WorkflowTemplate) diff --git a/apps/backend/internal/workflow/service/service.go b/apps/backend/internal/workflow/service/service.go index ccab311774..d3efb19fa4 100644 --- a/apps/backend/internal/workflow/service/service.go +++ b/apps/backend/internal/workflow/service/service.go @@ -16,7 +16,7 @@ import ( // WorkflowProvider gives the workflow service access to workflows owned by the task domain. type WorkflowProvider interface { - ListWorkflows(ctx context.Context, workspaceID string) ([]*taskmodels.Workflow, error) + ListWorkflows(ctx context.Context, workspaceID string, includeHidden bool) ([]*taskmodels.Workflow, error) GetWorkflow(ctx context.Context, id string) (*taskmodels.Workflow, error) CreateWorkflow(ctx context.Context, workspaceID, name, description string) (*taskmodels.Workflow, error) UpdateWorkflow(ctx context.Context, workflow *taskmodels.Workflow) error @@ -395,7 +395,7 @@ func (s *Service) ExportWorkflow(ctx context.Context, workflowID string) (*model // ExportWorkflows exports all workflows for a workspace. func (s *Service) ExportWorkflows(ctx context.Context, workspaceID string) (*models.WorkflowExport, error) { - workflows, err := s.workflowProvider.ListWorkflows(ctx, workspaceID) + workflows, err := s.workflowProvider.ListWorkflows(ctx, workspaceID, true) if err != nil { return nil, fmt.Errorf("failed to list workflows: %w", err) } @@ -416,7 +416,7 @@ func (s *Service) ImportWorkflows(ctx context.Context, workspaceID string, expor return nil, fmt.Errorf("invalid export data: %w", err) } - existing, err := s.workflowProvider.ListWorkflows(ctx, workspaceID) + existing, err := s.workflowProvider.ListWorkflows(ctx, workspaceID, true) if err != nil { return nil, fmt.Errorf("failed to list existing workflows: %w", err) } diff --git a/apps/backend/internal/workflow/service/service_test.go b/apps/backend/internal/workflow/service/service_test.go index 43395b01e8..b16566dd8d 100644 --- a/apps/backend/internal/workflow/service/service_test.go +++ b/apps/backend/internal/workflow/service/service_test.go @@ -50,12 +50,16 @@ type mockWorkflowProvider struct { workflows []*taskmodels.Workflow } -func (m *mockWorkflowProvider) ListWorkflows(_ context.Context, workspaceID string) ([]*taskmodels.Workflow, error) { +func (m *mockWorkflowProvider) ListWorkflows(_ context.Context, workspaceID string, includeHidden bool) ([]*taskmodels.Workflow, error) { var result []*taskmodels.Workflow for _, wf := range m.workflows { - if wf.WorkspaceID == workspaceID { - result = append(result, wf) + if wf.WorkspaceID != workspaceID { + continue } + if !includeHidden && wf.Hidden { + continue + } + result = append(result, wf) } return result, nil } diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index eacdf4e9b0..ec68e021e5 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -13,6 +13,7 @@ import { QuickChatProvider } from "@/components/quick-chat/quick-chat-provider"; import { ConfigChatProvider } from "@/components/config-chat/config-chat-provider"; import { SessionFailureToastBridge } from "@/components/session-failure-toast-bridge"; import { SidebarViewsSyncBridge } from "@/components/sidebar-views-sync-bridge"; +import { LogBufferBridge } from "@/components/log-buffer-bridge"; export const metadata: Metadata = { title: "KanDev - AI Kanban", @@ -63,6 +64,7 @@ export default function RootLayout({ + diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 5ad96c5fe1..eab6748863 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -151,14 +151,20 @@ export default async function Page({ searchParams }: PageProps) { listWorkspaceTaskPRs(activeWorkspaceId, { cache: "no-store" }).catch(() => {}); const [workflowList, repositoriesResponse, quickChatResponse] = await Promise.all([ - listWorkflows(activeWorkspaceId, { cache: "no-store" }), + listWorkflows(activeWorkspaceId, { cache: "no-store", includeHidden: true }), listRepositories(activeWorkspaceId, undefined, { cache: "no-store" }).catch(() => ({ repositories: [], })), listQuickChatSessions(activeWorkspaceId, { cache: "no-store" }).catch(() => ({ tasks: [] })), ]); - const workflowId = resolveActiveId(workflowList.workflows, workflowIdParam, settingsWorkflowId); + // Active workflow defaults to the first non-hidden workflow when no preference is set, + // so hidden workflows (e.g., improve-kandev) never auto-select on load. + const workflowId = resolveActiveId( + workflowList.workflows.filter((w) => !w.hidden), + workflowIdParam, + settingsWorkflowId, + ); // Map quick chat tasks to sessions const quickChatSessions = quickChatResponse.tasks @@ -181,6 +187,7 @@ export default async function Page({ searchParams }: PageProps) { id: w.id, workspaceId: w.workspace_id, name: w.name, + hidden: w.hidden, })), activeId: workflowId, }, diff --git a/apps/web/components/github/issue-watch-dialog.tsx b/apps/web/components/github/issue-watch-dialog.tsx index 25c0d3ae1c..7dd8b331fd 100644 --- a/apps/web/components/github/issue-watch-dialog.tsx +++ b/apps/web/components/github/issue-watch-dialog.tsx @@ -125,7 +125,8 @@ function useWatchFormData(workspaceId: string) { useSettingsData(true); useWorkflows(workspaceId, true); - const workflows = useAppStore((state) => state.workflows.items); + const allWorkflows = useAppStore((state) => state.workflows.items); + const workflows = useMemo(() => allWorkflows.filter((w) => !w.hidden), [allWorkflows]); const agentProfiles = useAppStore((state) => state.agentProfiles.items); const executors = useAppStore((state) => state.executors.items); const allExecutorProfiles = useMemo( diff --git a/apps/web/components/github/review-watch-dialog.tsx b/apps/web/components/github/review-watch-dialog.tsx index e7efe694e6..6149a8b00b 100644 --- a/apps/web/components/github/review-watch-dialog.tsx +++ b/apps/web/components/github/review-watch-dialog.tsx @@ -166,7 +166,8 @@ function useWatchFormData(workspaceId: string) { useSettingsData(true); useWorkflows(workspaceId, true); - const workflows = useAppStore((state) => state.workflows.items); + const allWorkflows = useAppStore((state) => state.workflows.items); + const workflows = useMemo(() => allWorkflows.filter((w) => !w.hidden), [allWorkflows]); const agentProfiles = useAppStore((state) => state.agentProfiles.items); const executors = useAppStore((state) => state.executors.items); const allExecutorProfiles = useMemo( diff --git a/apps/web/components/improve-kandev-dialog-create.tsx b/apps/web/components/improve-kandev-dialog-create.tsx new file mode 100644 index 0000000000..e4880bac9c --- /dev/null +++ b/apps/web/components/improve-kandev-dialog-create.tsx @@ -0,0 +1,367 @@ +"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"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip"; +import { + IconLoader2, + IconAlertTriangle, + IconInfoCircle, + IconArrowRight, + 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"; + +export type BootstrapState = + | { kind: "idle" } + | { kind: "loading" } + | { kind: "ready"; data: ImproveKandevBootstrapResponse; steps: WorkflowStep[] } + | { kind: "error"; message: string } + | { kind: "blocked"; message: string }; + +type ImproveKind = "bug" | "feature"; + +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...", +}; + +type CreateModeViewProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + workspaceId: string | null; + bootstrap: BootstrapState; + captureLogs: boolean; + setCaptureLogs: (v: boolean) => void; + transformDescription: (description: string) => Promise; + onTaskCreated: (task: Task) => void; +}; + +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 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 })) : []} + initialValues={{ + title: "", + repositoryId: ready?.data.repository_id ?? "", + branch: ready?.data.branch ?? "", + }} + onSuccess={props.onTaskCreated} + lockedFields={{ repository: true, branch: true, workflow: true }} + descriptionPlaceholder={PLACEHOLDERS[kind]} + aboveDescriptionSlot={} + extraFormSlot={ + + } + bottomSlot={ + ready && ( +
+ + +
+ ) + } + transformDescriptionBeforeSubmit={props.transformDescription} + /> + ); +} + +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 WorkflowStepsPreview({ steps }: { steps: WorkflowStep[] }) { + if (steps.length === 0) return null; + const ordered = [...steps].sort((a, b) => a.position - b.position); + return ( +
+

+ Workflow +

+ +
+ {ordered.map((s, i) => ( + + {i > 0 && } + + + + + {s.name} + + + + {STEP_DESCRIPTIONS[s.name.toLowerCase()] ?? s.name} + + + + ))} +
+
+
+ ); +} + +function UsefulInfoCollapsible() { + const [open, setOpen] = useState(false); + return ( + + + + + +
+

+ Shell command you can run in the secondary instance, plus slash-command skills{" "} + you can ask the agent to run during the workflow. +

+
+

+ Shell +

+ + Boots a secondary kandev dev instance with a clean DB so you can verify the change + without touching your main one. + +
+
+

+ Agent skills (ask the agent to run) +

+ + Stages and commits changes using Conventional Commits. + + Commits and pushes to the current branch. + + Runs format, typecheck, test, and lint across the monorepo. + + Commits, pushes, and opens a pull request. + + Waits for CI and automated reviews, then fixes failures and addresses comments. + +
+
+
+
+ ); +} + +function UsefulInfoItem({ cmd, children }: { cmd: string; children: React.ReactNode }) { + return ( +
+ {cmd} + {children} +
+ ); +} + +function KindTabs({ + kind, + onChange, +}: { + kind: ImproveKind; + onChange: (next: ImproveKind) => void; +}) { + return ( + onChange(v as ImproveKind)}> + + + Bug fix + + + Feature request + + + + ); +} + +function BootstrapStatusSlot({ + bootstrap, + captureLogs, + setCaptureLogs, +}: { + bootstrap: BootstrapState; + captureLogs: boolean; + setCaptureLogs: (v: boolean) => void; +}) { + return ( +
+ +
+ + + + + + + + KanDev keeps a small in-memory ring buffer of the most recent backend logs and browser + console events. When enabled, those logs are written to a temporary folder on your + machine, and the file paths are appended to the task description so the agent can read + them while investigating. + + + +
+
+ ); +} + +function BootstrapBanner({ bootstrap }: { bootstrap: BootstrapState }) { + if (bootstrap.kind === "loading" || bootstrap.kind === "idle") { + return ( +
+ + Preparing kandev repository in background. Fill in the details, submit when ready. +
+ ); + } + // The "blocked" kind is intercepted earlier in CreateModeView and routed to + // BlockedDialog, so the form (and this banner) are not rendered for it. It + // is still listed here to keep the union exhaustive for the type narrower. + if (bootstrap.kind === "error" || bootstrap.kind === "blocked") { + return ( +
+ + {bootstrap.message} +
+ ); + } + return ; +} + +function BlockedDialog({ + open, + onOpenChange, + message, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + message: string; +}) { + return ( + + + + + + Improve Kandev + + +
+
+ + {message} +
+
+ +
+
+
+
+ ); +} + +function ContributorBanner({ data }: { data: ImproveKandevBootstrapResponse }) { + const { github_login: login, has_write_access: hasWrite } = data; + return ( +
+ {hasWrite ? ( + + ) : ( + + )} + + Contributing as{" "} + {login ? ( + @{login} + ) : ( + your GitHub account + )} + .{" "} + {hasWrite + ? "You have write access to kdlbs/kandev, so the agent will push directly to a branch on the upstream repo." + : "The agent will fork kdlbs/kandev to your account during the PR step and open a pull request from your fork."} + +
+ ); +} diff --git a/apps/web/components/improve-kandev-dialog-helpers.test.ts b/apps/web/components/improve-kandev-dialog-helpers.test.ts new file mode 100644 index 0000000000..aaa5a2a3d0 --- /dev/null +++ b/apps/web/components/improve-kandev-dialog-helpers.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { buildImproveKandevDescription } from "./improve-kandev-dialog-helpers"; +import type { ImproveKandevBootstrapResponse } from "@/lib/api/domains/improve-kandev-api"; + +const uploadFrontendLog = vi.fn(); +vi.mock("@/lib/api/domains/improve-kandev-api", async (orig) => { + const actual = await orig(); + return { ...actual, uploadFrontendLog: (...args: unknown[]) => uploadFrontendLog(...args) }; +}); + +vi.mock("@/lib/logger/buffer", () => ({ + snapshotLogs: () => [ + { timestamp: "2026-04-29T10:00:00Z", level: "info", source: "console", message: "hi" }, + ], +})); + +const bootstrap: ImproveKandevBootstrapResponse = { + repository_id: "r1", + workflow_id: "w1", + branch: "main", + bundle_dir: "/tmp/kandev-improve-abc", + bundle_files: { + metadata: "/tmp/kandev-improve-abc/metadata.json", + backend_log: "/tmp/kandev-improve-abc/backend.log", + frontend_log: "/tmp/kandev-improve-abc/frontend.log", + }, + github_login: "octocat", + has_write_access: false, + fork_status: "unknown", +}; + +describe("buildImproveKandevDescription", () => { + beforeEach(() => { + uploadFrontendLog.mockReset(); + uploadFrontendLog.mockResolvedValue({ path: bootstrap.bundle_files.frontend_log }); + }); + + it("returns description unchanged when bootstrap is null", async () => { + const out = await buildImproveKandevDescription("desc", null, true); + expect(out).toBe("desc"); + expect(uploadFrontendLog).not.toHaveBeenCalled(); + }); + + it("returns description unchanged when captureLogs is false", async () => { + const out = await buildImproveKandevDescription("desc", bootstrap, false); + expect(out).toBe("desc"); + expect(uploadFrontendLog).not.toHaveBeenCalled(); + }); + + it("appends bundle file paths and uploads frontend log when captureLogs=true", async () => { + const out = await buildImproveKandevDescription("Original prompt", bootstrap, true); + expect(out).toContain("Original prompt"); + expect(out).toContain("Context bundle for the agent:"); + expect(out).toContain(bootstrap.bundle_files.metadata); + expect(out).toContain(bootstrap.bundle_files.backend_log); + expect(out).toContain(bootstrap.bundle_files.frontend_log); + expect(uploadFrontendLog).toHaveBeenCalledWith( + bootstrap.bundle_dir, + expect.arrayContaining([expect.objectContaining({ message: "hi" })]), + ); + }); + + it("does not abort when frontend log upload fails", async () => { + uploadFrontendLog.mockRejectedValueOnce(new Error("network down")); + await expect(buildImproveKandevDescription("desc", bootstrap, true)).resolves.toContain( + "Context bundle for the agent:", + ); + }); + + it("omits the frontend_log path when upload fails", async () => { + uploadFrontendLog.mockRejectedValueOnce(new Error("network down")); + const out = await buildImproveKandevDescription("desc", bootstrap, true); + expect(out).toContain(bootstrap.bundle_files.metadata); + expect(out).toContain(bootstrap.bundle_files.backend_log); + expect(out).not.toContain(bootstrap.bundle_files.frontend_log); + }); +}); diff --git a/apps/web/components/improve-kandev-dialog-helpers.ts b/apps/web/components/improve-kandev-dialog-helpers.ts new file mode 100644 index 0000000000..af23d1f218 --- /dev/null +++ b/apps/web/components/improve-kandev-dialog-helpers.ts @@ -0,0 +1,49 @@ +"use client"; + +import { snapshotLogs } from "@/lib/logger/buffer"; +import { + uploadFrontendLog, + type ImproveKandevBootstrapResponse, +} from "@/lib/api/domains/improve-kandev-api"; + +/** + * Append the bundle file paths to the user-supplied description as a + * machine-readable footer the agent prompt instructs to read. + * + * Behavior: + * - When `bootstrap` is null, returns the description unchanged. + * - When `captureLogs` is false, returns the description unchanged. + * - When `captureLogs` is true, attempts to upload the current in-memory + * frontend log snapshot to the bundle directory and only includes the + * `frontend_log` entry when the upload succeeded — referencing a file + * that was never written would mislead the agent. + */ +export async function buildImproveKandevDescription( + description: string, + bootstrap: ImproveKandevBootstrapResponse | null, + captureLogs: boolean, +): Promise { + if (!bootstrap) return description; + if (!captureLogs) return description; + + let frontendLogUploaded = false; + try { + await uploadFrontendLog(bootstrap.bundle_dir, snapshotLogs()); + frontendLogUploaded = true; + } catch { + // Frontend log upload is best-effort — keep submitting the task without it. + } + + const lines = [ + description, + "", + "---", + "Context bundle for the agent:", + `- ${bootstrap.bundle_files.metadata}`, + `- ${bootstrap.bundle_files.backend_log}`, + ]; + if (frontendLogUploaded) { + lines.push(`- ${bootstrap.bundle_files.frontend_log}`); + } + return lines.join("\n"); +} diff --git a/apps/web/components/improve-kandev-dialog.tsx b/apps/web/components/improve-kandev-dialog.tsx new file mode 100644 index 0000000000..ddaed2de1d --- /dev/null +++ b/apps/web/components/improve-kandev-dialog.tsx @@ -0,0 +1,301 @@ +"use client"; + +import Link from "next/link"; +import { useCallback, useEffect, useState } from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@kandev/ui/dialog"; +import { Button } from "@kandev/ui/button"; +import { IconAlertTriangle, IconStethoscope, IconCheck } from "@tabler/icons-react"; + +import { useToast } from "@/components/toast-provider"; +import { useAppStore } from "@/components/state-provider"; +import { bootstrapImproveKandev } from "@/lib/api/domains/improve-kandev-api"; +import { listRepositories } from "@/lib/api/domains/workspace-api"; +import { listWorkflowSteps } from "@/lib/api/domains/workflow-api"; +import { fetchSystemHealth } from "@/lib/api/domains/health-api"; +import type { Task } from "@/lib/types/http"; +import { buildImproveKandevDescription } from "./improve-kandev-dialog-helpers"; +import { CreateModeView, type BootstrapState } from "./improve-kandev-dialog-create"; + +type ImproveKandevDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + workspaceId: string | null; + onSuccess?: (task: Task) => void; +}; + +type Mode = "intro" | "create"; + +type AuthState = + | { kind: "checking" } + | { kind: "ok" } + | { kind: "missing"; message: string; fixUrl: string; fixLabel: string }; + +export function ImproveKandevDialog(props: ImproveKandevDialogProps) { + const { open, onOpenChange, workspaceId, onSuccess } = props; + const [mode, setMode] = useState("intro"); + const [auth, setAuth] = useState({ kind: "checking" }); + const [bootstrap, setBootstrap] = useState({ kind: "idle" }); + const [captureLogs, setCaptureLogs] = useState(true); + + // Reset everything on close so a re-open re-runs the auth check. + // The setState-in-effect calls here mirror the documented "subscribe to + // external system" pattern (parent-controlled `open` toggling). + useEffect(() => { + if (open) return; + /* eslint-disable react-hooks/set-state-in-effect */ + setMode("intro"); + setAuth({ kind: "checking" }); + setBootstrap({ kind: "idle" }); + setCaptureLogs(true); + /* eslint-enable react-hooks/set-state-in-effect */ + }, [open]); + + useGitHubAuthCheck(open, workspaceId, setAuth); + useBootstrapKandev(mode, workspaceId, setBootstrap); + + const handleSuccess = useCallback( + (task: Task) => { + onOpenChange(false); + onSuccess?.(task); + }, + [onOpenChange, onSuccess], + ); + + const transformDescription = useCallback( + async (description: string) => { + if (bootstrap.kind !== "ready") return description; + return buildImproveKandevDescription(description, bootstrap.data, captureLogs); + }, + [bootstrap, captureLogs], + ); + + if (mode === "create") { + return ( + + ); + } + + return ( + + + + + + Improve Kandev + + + onOpenChange(false)} + onProceed={() => setMode("create")} + /> + + + ); +} + +function useGitHubAuthCheck( + open: boolean, + workspaceId: string | null, + setAuth: (s: AuthState) => void, +) { + useEffect(() => { + if (!open || !workspaceId) return; + let cancelled = false; + (async () => { + try { + const health = await fetchSystemHealth(); + if (cancelled) return; + const ghIssue = health.issues.find((i) => i.category === "github"); + if (!ghIssue) { + setAuth({ kind: "ok" }); + return; + } + setAuth({ + kind: "missing", + message: ghIssue.message, + fixUrl: ghIssue.fix_url.replace("{workspaceId}", workspaceId), + fixLabel: ghIssue.fix_label || "Configure GitHub", + }); + } catch { + if (!cancelled) setAuth({ kind: "ok" }); // Fail open — bootstrap will surface real errors. + } + })(); + return () => { + cancelled = true; + }; + }, [open, workspaceId, setAuth]); +} + +function useBootstrapKandev( + mode: Mode, + workspaceId: string | null, + setBootstrap: (s: BootstrapState) => void, +) { + const { toast } = useToast(); + const setRepositories = useAppStore((state) => state.setRepositories); + useEffect(() => { + if (mode !== "create" || !workspaceId) return; + let cancelled = false; + setBootstrap({ kind: "loading" }); + (async () => { + try { + const data = await bootstrapImproveKandev(workspaceId); + // Surface the EMU fork-restriction case before any further setup so + // the user sees a clear error and can't submit a contribution that + // would only fail at the PR step. + if (data.fork_status === "blocked_emu") { + if (cancelled) return; + const message = data.fork_message || "Your account cannot fork kdlbs/kandev."; + setBootstrap({ kind: "blocked", message }); + toast({ + title: "Cannot contribute from this account", + description: message, + variant: "error", + }); + return; + } + // Refresh the workspace repository list so the newly-created kandev + // repo is in the store; otherwise the locked repo dropdown can't + // resolve a label for the bootstrapped repository_id. + const [stepsRes, reposRes] = await Promise.all([ + listWorkflowSteps(data.workflow_id), + listRepositories(workspaceId, undefined, { cache: "no-store" }), + ]); + if (cancelled) return; + setRepositories(workspaceId, reposRes.repositories); + setBootstrap({ kind: "ready", data, steps: stepsRes.steps }); + } catch (err) { + if (cancelled) return; + const message = err instanceof Error ? err.message : "Bootstrap failed"; + setBootstrap({ kind: "error", message }); + toast({ + title: "Could not prepare Improve Kandev", + description: message, + variant: "error", + }); + } + })(); + return () => { + cancelled = true; + }; + }, [mode, workspaceId, setBootstrap, setRepositories, toast]); +} + +function IntroBody({ + auth, + onCancel, + onProceed, +}: { + auth: AuthState; + onCancel: () => void; + onProceed: () => void; +}) { + if (auth.kind === "missing") { + return ; + } + return ; +} + +function GhAuthMissing({ + auth, + onCancel, +}: { + auth: Extract; + onCancel: () => void; +}) { + return ( +
+
+ +
+

GitHub CLI not authenticated

+

+ The final step of this workflow opens a pull request, which needs the gh{" "} + CLI to be authenticated. {auth.message} +

+
+
+
+ + +
+
+ ); +} + +function IntroExplanation({ + auth, + onCancel, + onProceed, +}: { + auth: AuthState; + onCancel: () => void; + onProceed: () => void; +}) { + return ( +
+

+ KanDev is open source, and you can help make it better. +

+ +

+ Describe a bug you hit or a feature you'd like, and we'll create a task on your + own agent to implement it in the kandev codebase. +

+ +

+ When it's done, the agent opens a pull request to{" "} + kdlbs/kandev for the maintainers to review, + saving them time and shipping the improvement to everyone. +

+ +
    + Create a task describing your bug or feature request + Your agent implements it in the kandev repository, with tests + You verify and test the change in a second kandev instance + + The agent forks kdlbs/kandev to your GitHub + account and opens a PR from your fork, credited to you + +
+
+ + +
+
+ ); +} + +function IntroBullet({ children }: { children: React.ReactNode }) { + return ( +
  • + + {children} +
  • + ); +} diff --git a/apps/web/components/jira/jira-issue-watch-dialog.tsx b/apps/web/components/jira/jira-issue-watch-dialog.tsx index 4e6a7eaccd..21e9aae47b 100644 --- a/apps/web/components/jira/jira-issue-watch-dialog.tsx +++ b/apps/web/components/jira/jira-issue-watch-dialog.tsx @@ -103,7 +103,8 @@ function useWorkflowSteps(workflowId: string) { function useFormData(workspaceId: string) { useSettingsData(true); useWorkflows(workspaceId, true); - const workflows = useAppStore((s) => s.workflows.items); + const allWorkflows = useAppStore((s) => s.workflows.items); + const workflows = useMemo(() => allWorkflows.filter((w) => !w.hidden), [allWorkflows]); const agentProfiles = useAppStore((s) => s.agentProfiles.items); const executors = useAppStore((s) => s.executors.items); const allExecutorProfiles = useMemo( diff --git a/apps/web/components/kanban/kanban-header.tsx b/apps/web/components/kanban/kanban-header.tsx index a1471615f7..3f10233fef 100644 --- a/apps/web/components/kanban/kanban-header.tsx +++ b/apps/web/components/kanban/kanban-header.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; +import { useState } from "react"; import { Button } from "@kandev/ui/button"; import { ToggleGroup, ToggleGroupItem } from "@kandev/ui/toggle-group"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip"; @@ -16,9 +17,12 @@ import { IconBrandGithub, IconTicket, IconHexagon, + IconStethoscope, } from "@tabler/icons-react"; import { useJiraAvailable } from "@/components/jira/my-jira/use-jira-availability"; import { useLinearAvailable } from "@/components/linear/use-linear-availability"; +import { ImproveKandevDialog } from "@/components/improve-kandev-dialog"; +import { linkToTask } from "@/lib/links"; import { KanbanDisplayDropdown } from "../kanban-display-dropdown"; import { ReleaseNotesButton } from "../release-notes/release-notes-button"; import { ReleaseNotesDialog } from "../release-notes/release-notes-dialog"; @@ -107,6 +111,35 @@ function LinearTopbarButton({ workspaceId }: { workspaceId: string | undefined } ); } +function ImproveKandevTopbarButton({ workspaceId }: { workspaceId: string | undefined }) { + const router = useRouter(); + const [open, setOpen] = useState(false); + return ( + <> + + + + + Improve KanDev + + router.push(linkToTask(task.id))} + /> + + ); +} + function ViewToggleGroup({ toggleValue, onValueChange, @@ -189,6 +222,7 @@ function TabletHeader({ + {onSearchChange && ( @@ -271,13 +305,18 @@ function DesktopHeader({ + + + + + Stats + + - {onSearchChange && ( diff --git a/apps/web/components/log-buffer-bridge.tsx b/apps/web/components/log-buffer-bridge.tsx new file mode 100644 index 0000000000..0c5d906652 --- /dev/null +++ b/apps/web/components/log-buffer-bridge.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { useEffect } from "react"; + +import { installConsoleInterceptor } from "@/lib/logger/intercept"; + +/** + * Installs the console + window-error interceptor on the client so recent + * frontend logs are captured for Improve Kandev reports. No UI. + */ +export function LogBufferBridge() { + useEffect(() => { + installConsoleInterceptor(); + }, []); + return null; +} diff --git a/apps/web/components/task-create-dialog-form-body.tsx b/apps/web/components/task-create-dialog-form-body.tsx index 6b4aee819e..ad31da1839 100644 --- a/apps/web/components/task-create-dialog-form-body.tsx +++ b/apps/web/components/task-create-dialog-form-body.tsx @@ -83,6 +83,7 @@ type CreateEditSelectorsProps = { freshBranchEnabled: boolean; onToggleFreshBranch: (enabled: boolean) => void; currentLocalBranch: string; + branchLocked?: boolean; }; type AgentColumnProps = Pick< @@ -134,6 +135,21 @@ function AgentColumn({ ); } +function computeBranchDisabled(args: { + branchLocked?: boolean; + lockedToCurrentBranch: boolean; + hasRepositorySelection: boolean; + branchesLoading: boolean; + localBranchesLoading: boolean; + optionCount: number; +}): boolean { + if (args.branchLocked) return true; + if (args.lockedToCurrentBranch) return true; + if (!args.hasRepositorySelection) return true; + if (args.branchesLoading || args.localBranchesLoading) return true; + return args.optionCount === 0; +} + export const CreateEditSelectors = memo(function CreateEditSelectors( props: CreateEditSelectorsProps, ) { @@ -160,9 +176,13 @@ export const CreateEditSelectors = memo(function CreateEditSelectors( currentLocalBranch, BranchSelectorComponent, ExecutorProfileSelectorComponent, + branchLocked, } = props; const isLocalWithoutGitHubUrl = isLocalExecutor && !useGitHubUrl; - const lockedToCurrentBranch = isLocalWithoutGitHubUrl && !freshBranchEnabled; + // When `branchLocked` is true the caller pinned a specific branch (e.g. + // Improve Kandev) — suppress the local-executor "current branch" shortcut and + // the FreshBranchToggle so users can't drift away from the pinned value. + const lockedToCurrentBranch = !branchLocked && isLocalWithoutGitHubUrl && !freshBranchEnabled; const branchPlaceholder = computeBranchPlaceholder({ lockedToCurrentBranch, currentLocalBranch, @@ -170,12 +190,14 @@ export const CreateEditSelectors = memo(function CreateEditSelectors( loading: branchesLoading || localBranchesLoading, optionCount: branchOptions.length, }); - const branchDisabled = - lockedToCurrentBranch || - !hasRepositorySelection || - branchesLoading || - localBranchesLoading || - branchOptions.length === 0; + const branchDisabled = computeBranchDisabled({ + branchLocked, + lockedToCurrentBranch, + hasRepositorySelection, + branchesLoading, + localBranchesLoading, + optionCount: branchOptions.length, + }); // When the local executor locks to the current branch, ignore any branch // value the user picked under a different executor — the placeholder // should reflect the actual checked-out branch instead. @@ -200,7 +222,7 @@ export const CreateEditSelectors = memo(function CreateEditSelectors( loading={branchesLoading || localBranchesLoading} /> - {isLocalWithoutGitHubUrl && ( + {isLocalWithoutGitHubUrl && !branchLocked && ( )} @@ -288,7 +310,13 @@ export const SessionSelectors = memo(function SessionSelectors({ type WorkflowSectionProps = { isCreateMode: boolean; isTaskStarted: boolean; - workflows: Array<{ id: string; name: string; agent_profile_id?: string; [key: string]: unknown }>; + workflows: Array<{ + id: string; + name: string; + agent_profile_id?: string; + hidden?: boolean; + [key: string]: unknown; + }>; snapshots: Record; effectiveWorkflowId: string | null; onWorkflowChange: (value: string) => void; @@ -298,7 +326,7 @@ type WorkflowSectionProps = { export const WorkflowSection = memo(function WorkflowSection({ isCreateMode, isTaskStarted, - workflows, + workflows: allWorkflows, snapshots, effectiveWorkflowId, onWorkflowChange, @@ -306,6 +334,10 @@ export const WorkflowSection = memo(function WorkflowSection({ }: WorkflowSectionProps) { const [lastUsedWorkflowId, setLastUsedWorkflowId] = useState(null); + // Hidden workflows (e.g. improve-kandev) are excluded from the picker; they + // remain reachable via their dedicated entry point. + const workflows = allWorkflows.filter((w) => !w.hidden); + const handleWorkflowChange = useCallback( (workflowId: string) => { setLastUsedWorkflowId(workflowId); @@ -378,6 +410,12 @@ export type DialogPromptSectionProps = { workspaceId?: string | null; onJiraImport?: (ticket: JiraTicket) => void; onLinearImport?: (issue: LinearIssue) => void; + /** Extension slot rendered below the description textarea (e.g. log-capture toggle). */ + extraFormSlot?: React.ReactNode; + /** Optional override for the description textarea placeholder. */ + descriptionPlaceholder?: string; + /** Optional slot rendered above the description textarea (e.g. a tab toggle). */ + aboveDescriptionSlot?: React.ReactNode; }; // importBindings collapses the optional Jira/Linear import callbacks into the @@ -406,11 +444,18 @@ export function DialogPromptSection({ workspaceId, onJiraImport, onLinearImport, + extraFormSlot, + descriptionPlaceholder, + aboveDescriptionSlot, }: DialogPromptSectionProps) { const importsEnabled = !isSessionMode && !isTaskStarted; const ws = workspaceId ?? null; + const placeholder = isPassthroughProfile + ? "Passthrough mode — prompt not supported" + : descriptionPlaceholder; return ( <> + {aboveDescriptionSlot} + {extraFormSlot} {isPassthroughProfile && hasDescription && (

    Prompt ignored — passthrough mode active

    )} diff --git a/apps/web/components/task-create-dialog-header.tsx b/apps/web/components/task-create-dialog-header.tsx index b6b6e32fa7..210750e4ec 100644 --- a/apps/web/components/task-create-dialog-header.tsx +++ b/apps/web/components/task-create-dialog-header.tsx @@ -34,22 +34,10 @@ export type DialogHeaderContentProps = { githubUrlError: string | null; onToggleGitHubUrl: () => void; onGitHubUrlChange: (v: string) => void; + repositoryLocked?: boolean; }; -function RepoSourceInput({ - useGitHubUrl, - githubUrl, - githubUrlError, - onGitHubUrlChange, - isTaskStarted, - headerRepositoryOptions, - repositoryId, - discoveredRepoPath, - onRepositoryChange, - workspaceId, - repositoriesLoading, - discoverReposLoading, -}: Pick< +type RepoSourceInputProps = Pick< DialogHeaderContentProps, | "useGitHubUrl" | "githubUrl" @@ -63,50 +51,78 @@ function RepoSourceInput({ | "workspaceId" | "repositoriesLoading" | "discoverReposLoading" + | "repositoryLocked" +>; + +function GhUrlInput({ + githubUrl, + githubUrlError, + onGitHubUrlChange, + isTaskStarted, +}: Pick< + RepoSourceInputProps, + "githubUrl" | "githubUrlError" | "onGitHubUrlChange" | "isTaskStarted" >): React.ReactNode { - if (useGitHubUrl) { - return ( -
    - onGitHubUrlChange(e.target.value)} - placeholder="github.com/owner/repo" - data-testid="github-url-input" - aria-label="GitHub repository URL" - aria-invalid={!!githubUrlError} - aria-describedby={githubUrlError ? "github-url-error" : undefined} - size={Math.max((githubUrl || "").length + 1, "github.com/owner/repo".length)} - className={`bg-transparent border-none outline-none focus:ring-0 text-sm font-medium min-w-0 h-7 rounded-md px-2 hover:bg-muted focus:bg-muted transition-colors placeholder:text-muted-foreground ${githubUrlError ? "text-destructive" : ""}`} - disabled={isTaskStarted} - autoFocus - /> - {githubUrlError && ( - - )} -
    - ); + return ( +
    + onGitHubUrlChange(e.target.value)} + placeholder="github.com/owner/repo" + data-testid="github-url-input" + aria-label="GitHub repository URL" + aria-invalid={!!githubUrlError} + aria-describedby={githubUrlError ? "github-url-error" : undefined} + size={Math.max((githubUrl || "").length + 1, "github.com/owner/repo".length)} + className={`bg-transparent border-none outline-none focus:ring-0 text-sm font-medium min-w-0 h-7 rounded-md px-2 hover:bg-muted focus:bg-muted transition-colors placeholder:text-muted-foreground ${githubUrlError ? "text-destructive" : ""}`} + disabled={isTaskStarted} + autoFocus + /> + {githubUrlError && ( + + )} +
    + ); +} + +function resolveSelectorPlaceholder(p: RepoSourceInputProps): string { + if (p.repositoryLocked && !(p.repositoryId || p.discoveredRepoPath)) { + return "Preparing kandev repository..."; } + return getRepositoryPlaceholder(p.workspaceId, p.repositoriesLoading, p.discoverReposLoading); +} + +function RepoSourceInput(p: RepoSourceInputProps): React.ReactNode { + // When the dialog locks the repository (e.g. Improve Kandev pinning kdlbs/kandev), + // never expose the editable GitHub URL input — even if useGitHubUrl is on. + if (p.useGitHubUrl && !p.repositoryLocked) return ; + const isDisabled = + p.repositoryLocked || + p.isTaskStarted || + !p.workspaceId || + p.repositoriesLoading || + p.discoverReposLoading; + const emptyMessage = + p.repositoriesLoading || p.discoverReposLoading + ? "Loading repositories..." + : "No repositories found."; return ( ); @@ -114,7 +130,7 @@ function RepoSourceInput({ export function DialogHeaderContent(props: DialogHeaderContentProps) { const { isCreateMode, isEditMode, isTaskStarted, sessionRepoName, initialTitle } = props; - const { taskName, onTaskNameChange, useGitHubUrl, onToggleGitHubUrl } = props; + const { taskName, onTaskNameChange, useGitHubUrl, onToggleGitHubUrl, repositoryLocked } = props; if (isCreateMode || isEditMode) { return ( @@ -130,7 +146,7 @@ export function DialogHeaderContent(props: DialogHeaderContentProps) { /> - {!isTaskStarted && ( + {!isTaskStarted && !repositoryLocked && (