feat: add improve kandev in-app contribution flow (#740)

This commit is contained in:
Carlos Florêncio
2026-05-01 15:18:18 +01:00
committed by GitHub
parent 9d275d3009
commit 1eae869054
65 changed files with 4029 additions and 477 deletions
+13
View File
@@ -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)
+15 -3
View File
@@ -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,
+2 -2
View File
@@ -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.
@@ -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 `<your-login>/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
+2
View File
@@ -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,
}
+30 -5
View File
@@ -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)
}
}
}
@@ -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 }
@@ -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)
}
}
+11 -1
View File
@@ -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()
}
@@ -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 <bundle>/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
}
@@ -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)
}
}
@@ -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")
}
@@ -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")})
}
@@ -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)
}
})
}
}
@@ -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
@@ -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")
@@ -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 {
+2
View File
@@ -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,
}
@@ -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 {
@@ -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)
+12 -9
View File
@@ -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
@@ -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
}
@@ -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)
}
@@ -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
);
@@ -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
}
@@ -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)
}
@@ -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
@@ -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.
@@ -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)
}
@@ -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)
@@ -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)
}
@@ -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
}
+2
View File
@@ -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({
<ToastProvider>
<SessionFailureToastBridge />
<SidebarViewsSyncBridge />
<LogBufferBridge />
<CommandRegistryProvider>
<WebSocketConnector />
<GlobalCommands />
+9 -2
View File
@@ -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,
},
@@ -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(
@@ -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(
@@ -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<ImproveKind, string> = {
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<string>;
onTaskCreated: (task: Task) => void;
};
export function CreateModeView(props: CreateModeViewProps) {
const { bootstrap, captureLogs, setCaptureLogs } = props;
const [kind, setKind] = useState<ImproveKind>("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 (
<BlockedDialog
open={props.open}
onOpenChange={props.onOpenChange}
message={bootstrap.message}
/>
);
}
return (
<TaskCreateDialog
open={props.open}
onOpenChange={props.onOpenChange}
mode="create"
workspaceId={props.workspaceId}
workflowId={ready?.data.workflow_id ?? null}
defaultStepId={startStep?.id ?? null}
steps={ready ? ready.steps.map((s) => ({ 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={<KindTabs kind={kind} onChange={handleKindChange} />}
extraFormSlot={
<BootstrapStatusSlot
bootstrap={bootstrap}
captureLogs={captureLogs}
setCaptureLogs={setCaptureLogs}
/>
}
bottomSlot={
ready && (
<div className="space-y-2">
<WorkflowStepsPreview steps={ready.steps} />
<UsefulInfoCollapsible />
</div>
)
}
transformDescriptionBeforeSubmit={props.transformDescription}
/>
);
}
const STEP_DESCRIPTIONS: Record<string, string> = {
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 (
<div className="rounded-md border border-border bg-muted/30 px-3 py-2">
<p className="mb-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground/80">
Workflow
</p>
<TooltipProvider delayDuration={150}>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs">
{ordered.map((s, i) => (
<Fragment key={s.id}>
{i > 0 && <IconArrowRight className="h-3 w-3 shrink-0 text-muted-foreground/50" />}
<Tooltip>
<TooltipTrigger asChild>
<span className="flex cursor-help items-center gap-1.5">
<span
className={cn(
"h-1.5 w-1.5 rounded-full shrink-0",
s.color || "bg-muted-foreground",
)}
/>
<span className="text-foreground">{s.name}</span>
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs text-xs leading-relaxed">
{STEP_DESCRIPTIONS[s.name.toLowerCase()] ?? s.name}
</TooltipContent>
</Tooltip>
</Fragment>
))}
</div>
</TooltipProvider>
</div>
);
}
function UsefulInfoCollapsible() {
const [open, setOpen] = useState(false);
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="group flex w-full cursor-pointer items-center justify-between rounded-md border border-border bg-muted/30 px-3 py-2 text-left text-xs hover:bg-muted/50"
>
<span className="font-medium text-muted-foreground">
Useful commands &amp; agent skills
</span>
<IconChevronDown className="h-3.5 w-3.5 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-2 space-y-3 rounded-md border border-border bg-muted/20 px-3 py-2 text-xs leading-relaxed text-muted-foreground">
<p>
Shell command you can run in the secondary instance, plus slash-command <em>skills</em>{" "}
you can ask the agent to run during the workflow.
</p>
<div className="space-y-2">
<p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground/70">
Shell
</p>
<UsefulInfoItem cmd="make install && make dev">
Boots a secondary kandev dev instance with a clean DB so you can verify the change
without touching your main one.
</UsefulInfoItem>
</div>
<div className="space-y-2">
<p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground/70">
Agent skills (ask the agent to run)
</p>
<UsefulInfoItem cmd="/commit">
Stages and commits changes using Conventional Commits.
</UsefulInfoItem>
<UsefulInfoItem cmd="/push">Commits and pushes to the current branch.</UsefulInfoItem>
<UsefulInfoItem cmd="/verify">
Runs format, typecheck, test, and lint across the monorepo.
</UsefulInfoItem>
<UsefulInfoItem cmd="/pr">Commits, pushes, and opens a pull request.</UsefulInfoItem>
<UsefulInfoItem cmd="/pr-fixup">
Waits for CI and automated reviews, then fixes failures and addresses comments.
</UsefulInfoItem>
</div>
</div>
</CollapsibleContent>
</Collapsible>
);
}
function UsefulInfoItem({ cmd, children }: { cmd: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-0.5">
<code className="font-mono text-[11px] text-foreground">{cmd}</code>
<span>{children}</span>
</div>
);
}
function KindTabs({
kind,
onChange,
}: {
kind: ImproveKind;
onChange: (next: ImproveKind) => void;
}) {
return (
<Tabs value={kind} onValueChange={(v) => onChange(v as ImproveKind)}>
<TabsList>
<TabsTrigger value="bug" className="cursor-pointer">
Bug fix
</TabsTrigger>
<TabsTrigger value="feature" className="cursor-pointer">
Feature request
</TabsTrigger>
</TabsList>
</Tabs>
);
}
function BootstrapStatusSlot({
bootstrap,
captureLogs,
setCaptureLogs,
}: {
bootstrap: BootstrapState;
captureLogs: boolean;
setCaptureLogs: (v: boolean) => void;
}) {
return (
<div className="space-y-2">
<BootstrapBanner bootstrap={bootstrap} />
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<label className="flex cursor-pointer items-center gap-2">
<Checkbox checked={captureLogs} onCheckedChange={(v) => setCaptureLogs(v === true)} />
Include recent backend &amp; browser logs as context for the agent
</label>
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="How log context works"
className="cursor-help text-muted-foreground/70 hover:text-muted-foreground"
>
<IconInfoCircle className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs text-xs leading-relaxed">
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.
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
);
}
function BootstrapBanner({ bootstrap }: { bootstrap: BootstrapState }) {
if (bootstrap.kind === "loading" || bootstrap.kind === "idle") {
return (
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
<IconLoader2 className="h-3.5 w-3.5 animate-spin" />
Preparing kandev repository in background. Fill in the details, submit when ready.
</div>
);
}
// 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 (
<div className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive">
<IconAlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<span>{bootstrap.message}</span>
</div>
);
}
return <ContributorBanner data={bootstrap.data} />;
}
function BlockedDialog({
open,
onOpenChange,
message,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
message: string;
}) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<IconStethoscope className="h-5 w-5" />
Improve Kandev
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<IconAlertTriangle className="h-4 w-4 mt-0.5 shrink-0" />
<span>{message}</span>
</div>
<div className="flex justify-end">
<Button
data-testid="improve-kandev-blocked-close"
variant="outline"
onClick={() => onOpenChange(false)}
className="cursor-pointer"
>
Close
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
function ContributorBanner({ data }: { data: ImproveKandevBootstrapResponse }) {
const { github_login: login, has_write_access: hasWrite } = data;
return (
<div className="flex items-start gap-2 rounded-md border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
{hasWrite ? (
<IconBrandGithub className="h-3.5 w-3.5 mt-0.5 shrink-0" />
) : (
<IconGitFork className="h-3.5 w-3.5 mt-0.5 shrink-0" />
)}
<span>
Contributing as{" "}
{login ? (
<code className="font-mono text-foreground">@{login}</code>
) : (
<span>your GitHub account</span>
)}
.{" "}
{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."}
</span>
</div>
);
}
@@ -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<typeof import("@/lib/api/domains/improve-kandev-api")>();
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);
});
});
@@ -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<string> {
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");
}
@@ -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<Mode>("intro");
const [auth, setAuth] = useState<AuthState>({ kind: "checking" });
const [bootstrap, setBootstrap] = useState<BootstrapState>({ 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 (
<CreateModeView
open={open}
onOpenChange={onOpenChange}
workspaceId={workspaceId}
bootstrap={bootstrap}
captureLogs={captureLogs}
setCaptureLogs={setCaptureLogs}
transformDescription={transformDescription}
onTaskCreated={handleSuccess}
/>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<IconStethoscope className="h-5 w-5" />
Improve Kandev
</DialogTitle>
</DialogHeader>
<IntroBody
auth={auth}
onCancel={() => onOpenChange(false)}
onProceed={() => setMode("create")}
/>
</DialogContent>
</Dialog>
);
}
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 <GhAuthMissing auth={auth} onCancel={onCancel} />;
}
return <IntroExplanation auth={auth} onCancel={onCancel} onProceed={onProceed} />;
}
function GhAuthMissing({
auth,
onCancel,
}: {
auth: Extract<AuthState, { kind: "missing" }>;
onCancel: () => void;
}) {
return (
<div className="space-y-4 py-2">
<div className="flex items-start gap-3 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
<IconAlertTriangle className="h-4 w-4 shrink-0 text-amber-500" />
<div>
<p className="font-medium text-foreground">GitHub CLI not authenticated</p>
<p className="mt-1 text-muted-foreground">
The final step of this workflow opens a pull request, which needs the <code>gh</code>{" "}
CLI to be authenticated. {auth.message}
</p>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={onCancel} className="cursor-pointer">
Cancel
</Button>
<Button asChild className="cursor-pointer">
<Link href={auth.fixUrl} onClick={onCancel}>
{auth.fixLabel}
</Link>
</Button>
</div>
</div>
);
}
function IntroExplanation({
auth,
onCancel,
onProceed,
}: {
auth: AuthState;
onCancel: () => void;
onProceed: () => void;
}) {
return (
<div className="space-y-5 py-2">
<p className="text-sm leading-relaxed text-muted-foreground">
KanDev is open source, and you can help make it better.
</p>
<p className="text-sm leading-relaxed text-muted-foreground">
Describe a bug you hit or a feature you&apos;d like, and we&apos;ll create a task on your
own agent to implement it in the kandev codebase.
</p>
<p className="text-sm leading-relaxed text-muted-foreground">
When it&apos;s done, the agent opens a pull request to{" "}
<code className="font-mono text-xs">kdlbs/kandev</code> for the maintainers to review,
saving them time and shipping the improvement to everyone.
</p>
<ul className="space-y-2 text-sm text-muted-foreground">
<IntroBullet>Create a task describing your bug or feature request</IntroBullet>
<IntroBullet>Your agent implements it in the kandev repository, with tests</IntroBullet>
<IntroBullet>You verify and test the change in a second kandev instance</IntroBullet>
<IntroBullet>
The agent forks <code className="font-mono text-xs">kdlbs/kandev</code> to your GitHub
account and opens a PR from your fork, credited to you
</IntroBullet>
</ul>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={onCancel} className="cursor-pointer">
Cancel
</Button>
<Button
onClick={onProceed}
disabled={auth.kind === "checking"}
className="cursor-pointer"
data-testid="improve-kandev-proceed"
>
Contribute
</Button>
</div>
</div>
);
}
function IntroBullet({ children }: { children: React.ReactNode }) {
return (
<li className="flex items-start gap-2">
<IconCheck className="h-4 w-4 mt-0.5 shrink-0 text-emerald-500" />
<span>{children}</span>
</li>
);
}
@@ -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(
+45 -6
View File
@@ -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 (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => setOpen(true)}
className="cursor-pointer"
data-testid="improve-kandev-button"
>
<IconStethoscope className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Improve KanDev</TooltipContent>
</Tooltip>
<ImproveKandevDialog
open={open}
onOpenChange={setOpen}
workspaceId={workspaceId ?? null}
onSuccess={(task) => router.push(linkToTask(task.id))}
/>
</>
);
}
function ViewToggleGroup({
toggleValue,
onValueChange,
@@ -189,6 +222,7 @@ function TabletHeader({
<GitHubTopbarButton />
<JiraTopbarButton workspaceId={workspaceId} />
<LinearTopbarButton workspaceId={workspaceId} />
<ImproveKandevTopbarButton workspaceId={workspaceId} />
</TooltipProvider>
</div>
{onSearchChange && (
@@ -271,13 +305,18 @@ function DesktopHeader({
<GitHubTopbarButton />
<JiraTopbarButton workspaceId={workspaceId} />
<LinearTopbarButton workspaceId={workspaceId} />
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="icon" asChild className="cursor-pointer">
<Link href="/stats">
<IconChartBar className="h-4 w-4" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>Stats</TooltipContent>
</Tooltip>
<ImproveKandevTopbarButton workspaceId={workspaceId} />
</TooltipProvider>
<Button variant="outline" asChild className="cursor-pointer gap-2">
<Link href="/stats">
<IconChartBar className="h-4 w-4" />
<span>Stats</span>
</Link>
</Button>
</div>
</div>
{onSearchChange && (
+16
View File
@@ -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;
}
@@ -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}
/>
</div>
{isLocalWithoutGitHubUrl && (
{isLocalWithoutGitHubUrl && !branchLocked && (
<FreshBranchToggle enabled={freshBranchEnabled} onToggle={onToggleFreshBranch} />
)}
</div>
@@ -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<string, WorkflowSnapshotData>;
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<string | null>(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}
<TaskFormInputs
key={fs.openCycle}
isSessionMode={isSessionMode}
@@ -420,13 +465,14 @@ export function DialogPromptSection({
onKeyDown={handleKeyDown}
descriptionValueRef={fs.descriptionInputRef}
disabled={isTaskStarted || isPassthroughProfile}
placeholder={isPassthroughProfile ? "Passthrough mode — prompt not supported" : undefined}
placeholder={placeholder}
onEnhancePrompt={enhance?.onEnhance}
isEnhancingPrompt={enhance?.isLoading}
isUtilityConfigured={enhance?.isConfigured}
jiraImport={importBindings(importsEnabled, ws, isPassthroughProfile, onJiraImport)}
linearImport={importBindings(importsEnabled, ws, isPassthroughProfile, onLinearImport)}
/>
{extraFormSlot}
{isPassthroughProfile && hasDescription && (
<p className="text-xs text-amber-500">Prompt ignored — passthrough mode active</p>
)}
@@ -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 (
<div className="relative">
<input
type="text"
value={githubUrl}
onChange={(e) => 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 && (
<div
id="github-url-error"
role="alert"
className="absolute left-0 top-full mt-1 z-50 rounded-md border bg-popover px-2 py-1 text-[11px] text-destructive shadow-md whitespace-nowrap"
data-testid="github-url-error"
>
{githubUrlError}
</div>
)}
</div>
);
return (
<div className="relative">
<input
type="text"
value={githubUrl}
onChange={(e) => 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 && (
<div
id="github-url-error"
role="alert"
className="absolute left-0 top-full mt-1 z-50 rounded-md border bg-popover px-2 py-1 text-[11px] text-destructive shadow-md whitespace-nowrap"
data-testid="github-url-error"
>
{githubUrlError}
</div>
)}
</div>
);
}
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 <GhUrlInput {...p} />;
const isDisabled =
p.repositoryLocked ||
p.isTaskStarted ||
!p.workspaceId ||
p.repositoriesLoading ||
p.discoverReposLoading;
const emptyMessage =
p.repositoriesLoading || p.discoverReposLoading
? "Loading repositories..."
: "No repositories found.";
return (
<RepositorySelector
options={headerRepositoryOptions}
value={repositoryId || discoveredRepoPath}
onValueChange={onRepositoryChange}
placeholder={getRepositoryPlaceholder(workspaceId, repositoriesLoading, discoverReposLoading)}
options={p.headerRepositoryOptions}
value={p.repositoryId || p.discoveredRepoPath}
onValueChange={p.onRepositoryChange}
placeholder={resolveSelectorPlaceholder(p)}
searchPlaceholder="Search repositories..."
emptyMessage={
repositoriesLoading || discoverReposLoading
? "Loading repositories..."
: "No repositories found."
}
disabled={isTaskStarted || !workspaceId || repositoriesLoading || discoverReposLoading}
emptyMessage={emptyMessage}
disabled={isDisabled}
triggerClassName="w-auto text-sm"
/>
);
@@ -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) {
/>
</div>
</DialogTitle>
{!isTaskStarted && (
{!isTaskStarted && !repositoryLocked && (
<div className="flex items-center gap-2 pl-2">
<button
type="button"
@@ -0,0 +1,288 @@
"use client";
import { FormEvent, useCallback } from "react";
import type { JiraTicket } from "@/lib/types/jira";
import type { LinearIssue } from "@/lib/types/linear";
import type { Task } from "@/lib/types/http";
import { SHORTCUTS } from "@/lib/keyboard/constants";
import { useIsUtilityConfigured } from "@/hooks/use-is-utility-configured";
import { useKeyboardShortcutHandler } from "@/hooks/use-keyboard-shortcut";
import { useUtilityAgentGenerator } from "@/hooks/use-utility-agent-generator";
import { useTaskSubmitHandlers } from "@/components/task-create-dialog-submit";
import { useToast } from "@/components/toast-provider";
import {
useDialogFormState,
useTaskCreateDialogEffects,
useDialogHandlers,
useLockedFieldSync,
useSessionRepoName,
useTaskCreateDialogData,
computeIsTaskStarted,
type DialogFormState,
type TaskCreateDialogInitialValues,
} from "@/components/task-create-dialog-state";
export interface TaskCreateDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
mode?: "create" | "edit" | "session";
workspaceId: string | null;
workflowId: string | null;
defaultStepId: string | null;
steps: Array<{
id: string;
title: string;
events?: {
on_enter?: Array<{ type: string; config?: Record<string, unknown> }>;
on_turn_complete?: Array<{ type: string; config?: Record<string, unknown> }>;
};
}>;
editingTask?: {
id: string;
title: string;
description?: string;
workflowStepId: string;
state?: Task["state"];
repositoryId?: string;
} | null;
onSuccess?: (
task: Task,
mode: "create" | "edit",
meta?: { taskSessionId?: string | null },
) => void;
onCreateSession?: (data: { prompt: string; agentProfileId: string; executorId: string }) => void;
initialValues?: TaskCreateDialogInitialValues;
taskId?: string | null;
parentTaskId?: string;
/**
* Optional UI rendered below the description textarea. Used by feature wrappers
* such as Improve Kandev to add capture toggles and pre-flight status without
* forking the dialog.
*/
extraFormSlot?: React.ReactNode;
/**
* Optional async transform applied to the task description before the API
* payload is built. Awaited; receives the trimmed description and returns
* the value to send. Used to append context-bundle file paths.
*/
transformDescriptionBeforeSubmit?: (description: string) => Promise<string> | string;
/**
* Optional locks for individual fields. When a flag is true, the
* corresponding selector is disabled (or hidden, for workflow). Used by
* feature wrappers such as Improve Kandev to enforce a fixed
* repository / branch / workflow selection.
*/
lockedFields?: { repository?: boolean; branch?: boolean; workflow?: boolean };
/**
* Optional override for the task description textarea placeholder. Used by
* feature wrappers (e.g. Improve Kandev) to suggest the kind of input
* expected.
*/
descriptionPlaceholder?: string;
/**
* Optional slot rendered above the description textarea (e.g. a bug/feature
* tab toggle for Improve Kandev).
*/
aboveDescriptionSlot?: React.ReactNode;
/**
* Optional slot rendered below the branch/agent/executor selectors (e.g.
* a workflow preview or contextual help for Improve Kandev).
*/
bottomSlot?: React.ReactNode;
}
function useEnhanceForDialog(fs: DialogFormState) {
const isConfigured = useIsUtilityConfigured();
const { enhancePrompt, isEnhancingPrompt } = useUtilityAgentGenerator({
sessionId: null,
taskTitle: fs.taskName,
});
const onEnhance = useCallback(() => {
const current = fs.descriptionInputRef.current?.getValue()?.trim();
if (!current) return;
enhancePrompt(current, (enhanced) => {
fs.descriptionInputRef.current?.setValue(enhanced);
fs.setHasDescription(true);
});
}, [enhancePrompt, fs]);
return { onEnhance, isLoading: isEnhancingPrompt, isConfigured };
}
function useJiraImportHandler(fs: DialogFormState) {
return useCallback(
(ticket: JiraTicket) => {
const title = `[${ticket.key}] ${ticket.summary}`;
fs.setTaskName(title);
fs.setHasTitle(true);
const description = ticket.description?.trim()
? `${ticket.description}\n\n---\nJira: ${ticket.url}`
: `Jira: ${ticket.url}`;
fs.descriptionInputRef.current?.setValue(description);
fs.setHasDescription(true);
},
[fs],
);
}
function useLinearImportHandler(fs: DialogFormState) {
return useCallback(
(issue: LinearIssue) => {
const title = `[${issue.identifier}] ${issue.title}`;
fs.setTaskName(title);
fs.setHasTitle(true);
const description = issue.description?.trim()
? `${issue.description}\n\n---\nLinear: ${issue.url}`
: `Linear: ${issue.url}`;
fs.descriptionInputRef.current?.setValue(description);
fs.setHasDescription(true);
},
[fs],
);
}
type SubmitWiringArgs = {
props: TaskCreateDialogProps;
fs: ReturnType<typeof useDialogFormState>;
computed: ReturnType<typeof useTaskCreateDialogData>["computed"];
repositoryLocalPath: string;
isSessionMode: boolean;
isEditMode: boolean;
};
function useSubmitHandlersWiring({
props,
fs,
computed,
repositoryLocalPath,
isSessionMode,
isEditMode,
}: SubmitWiringArgs) {
const { workspaceId, workflowId, editingTask, onSuccess, onCreateSession, onOpenChange } = props;
const { parentTaskId } = props;
const taskId = props.taskId ?? null;
return useTaskSubmitHandlers({
isSessionMode,
isEditMode,
isPassthroughProfile: computed.isPassthroughProfile,
taskName: fs.taskName,
workspaceId,
workflowId,
effectiveWorkflowId: computed.effectiveWorkflowId,
effectiveDefaultStepId: computed.effectiveDefaultStepId,
repositoryId: fs.repositoryId,
selectedLocalRepo: fs.selectedLocalRepo,
useGitHubUrl: fs.useGitHubUrl,
githubUrl: fs.githubUrl,
githubPrHeadBranch: fs.githubPrHeadBranch,
branch: fs.branch,
agentProfileId: computed.effectiveAgentProfileId,
executorId: fs.executorId,
executorProfileId: fs.executorProfileId,
editingTask,
onSuccess,
onCreateSession,
onOpenChange,
taskId,
parentTaskId,
descriptionInputRef: fs.descriptionInputRef,
setIsCreatingSession: fs.setIsCreatingSession,
setIsCreatingTask: fs.setIsCreatingTask,
setHasTitle: fs.setHasTitle,
setHasDescription: fs.setHasDescription,
setTaskName: fs.setTaskName,
setRepositoryId: fs.setRepositoryId,
setBranch: fs.setBranch,
setAgentProfileId: fs.setAgentProfileId,
setExecutorId: fs.setExecutorId,
setSelectedWorkflowId: fs.setSelectedWorkflowId,
setFetchedSteps: fs.setFetchedSteps,
clearDraft: fs.clearDraft,
freshBranchEnabled: fs.freshBranchEnabled,
isLocalExecutor: computed.isLocalExecutor,
repositoryLocalPath,
transformDescriptionBeforeSubmit: props.transformDescriptionBeforeSubmit,
});
}
export function useTaskCreateDialogSetup(props: TaskCreateDialogProps) {
const { open, mode = "create", workspaceId, workflowId, defaultStepId } = props;
const { editingTask, initialValues } = props;
const isSessionMode = mode === "session";
const isEditMode = mode === "edit";
const isCreateMode = mode === "create";
const isTaskStarted = computeIsTaskStarted(isEditMode, editingTask);
const fs = useDialogFormState(open, workspaceId, workflowId, initialValues);
const { toast } = useToast();
const sessionRepoName = useSessionRepoName(isSessionMode);
const {
workflows,
agentProfiles,
executors,
snapshots,
repositories,
repositoriesLoading,
branches,
branchesLoading,
refreshBranches,
branchesFetchedAt,
branchesFetchError,
computed,
} = useTaskCreateDialogData(open, workspaceId, workflowId, defaultStepId, fs);
const repositoryLocalPath = (() => {
if (fs.selectedLocalRepo) return fs.selectedLocalRepo.path;
if (fs.repositoryId) {
const repo = repositories.find((r) => r.id === fs.repositoryId);
return repo?.local_path ?? "";
}
return "";
})();
useTaskCreateDialogEffects(fs, {
open,
workspaceId,
workflowId,
repositories,
repositoriesLoading,
branches,
agentProfiles,
executors,
workspaceDefaults: computed.workspaceDefaults,
toast,
workflows,
});
useLockedFieldSync(open, workflowId, initialValues, fs);
const handlers = useDialogHandlers(fs, repositories);
const submitHandlers = useSubmitHandlersWiring({
props,
fs,
computed,
repositoryLocalPath,
isSessionMode,
isEditMode,
});
const handleKeyDown = useKeyboardShortcutHandler(SHORTCUTS.SUBMIT, (event) => {
submitHandlers.handleSubmit(event as unknown as FormEvent);
});
return {
fs,
isSessionMode,
isEditMode,
isCreateMode,
isTaskStarted,
sessionRepoName,
workflows,
agentProfiles,
snapshots,
repositoriesLoading,
branchesLoading,
refreshBranches,
branchesFetchedAt,
branchesFetchError,
computed,
handlers,
submitHandlers,
handleKeyDown,
enhance: useEnhanceForDialog(fs),
handleJiraImport: useJiraImportHandler(fs),
handleLinearImport: useLinearImportHandler(fs),
};
}
@@ -405,6 +405,32 @@ export function useDialogFormState(
return { ...form, ...discovery, ...ghUrl, ...wfAgent, ...freshBranch, clearDraft };
}
// Pushes late-arriving locked field values (workflow id, repository id, branch)
// into form state when they change after the dialog is already open. Used by
// feature wrappers like Improve Kandev that resolve these values asynchronously.
export function useLockedFieldSync(
open: boolean,
workflowId: string | null,
initialValues: TaskCreateDialogInitialValues | undefined,
fs: ReturnType<typeof useDialogFormState>,
) {
const repoId = initialValues?.repositoryId;
const branch = initialValues?.branch;
useEffect(() => {
if (!open) return;
if (workflowId && workflowId !== fs.selectedWorkflowId) {
fs.setSelectedWorkflowId(workflowId);
}
if (repoId && repoId !== fs.repositoryId) {
fs.setRepositoryId(repoId);
}
if (branch && branch !== fs.branch) {
fs.setBranch(branch);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, workflowId, repoId, branch]);
}
export type { DialogFormState } from "@/components/task-create-dialog-types";
export {
computePassthroughProfile,
@@ -62,6 +62,7 @@ export function useTaskSubmitHandlers({
freshBranchEnabled,
isLocalExecutor,
repositoryLocalPath,
transformDescriptionBeforeSubmit,
}: SubmitHandlersDeps) {
const router = useRouter();
const { toast } = useToast();
@@ -447,9 +448,12 @@ export function useTaskSubmitHandlers({
setIsCreatingTask(true);
try {
if (trimmedDescription || isPassthroughProfile) {
const finalDescription = transformDescriptionBeforeSubmit
? await transformDescriptionBeforeSubmit(trimmedDescription)
: trimmedDescription;
await performCreate({
trimmedTitle,
trimmedDescription,
trimmedDescription: finalDescription,
consented: consent,
withAgent: true,
attachments,
@@ -473,6 +477,7 @@ export function useTaskSubmitHandlers({
ensureFreshBranchConsent,
performCreate,
handleCreatePlanMode,
transformDescriptionBeforeSubmit,
toast,
descriptionInputRef,
setIsCreatingTask,
@@ -226,4 +226,10 @@ export type SubmitHandlersDeps = {
isLocalExecutor: boolean;
/** Resolved on-disk path for the selected repository (workspace or discovered). Empty if not local. */
repositoryLocalPath: string;
/**
* Optional async transform applied to the trimmed description before the
* API payload is built. Used by feature wrappers (e.g. Improve Kandev) to
* append generated context like bundle file paths.
*/
transformDescriptionBeforeSubmit?: (description: string) => Promise<string> | string;
};
+150 -323
View File
@@ -1,15 +1,10 @@
"use client";
import { FormEvent, useCallback } from "react";
import type { JiraTicket } from "@/lib/types/jira";
import type { LinearIssue } from "@/lib/types/linear";
import { Dialog, DialogContent, DialogHeader, DialogFooter } from "@kandev/ui/dialog";
import type { Task } from "@/lib/types/http";
import type { AgentProfileOption } from "@/lib/state/slices";
import { SHORTCUTS } from "@/lib/keyboard/constants";
import { useIsUtilityConfigured } from "@/hooks/use-is-utility-configured";
import { useKeyboardShortcutHandler } from "@/hooks/use-keyboard-shortcut";
import { useUtilityAgentGenerator } from "@/hooks/use-utility-agent-generator";
import type { useKeyboardShortcutHandler } from "@/hooks/use-keyboard-shortcut";
import { TaskCreateDialogFooter } from "@/components/task-create-dialog-footer";
import { DiscardLocalChangesDialog } from "@/components/discard-local-changes-dialog";
import { DialogHeaderContent } from "@/components/task-create-dialog-header";
@@ -26,51 +21,11 @@ import {
ExecutorProfileSelector,
} from "@/components/task-create-dialog-selectors";
import { useTaskSubmitHandlers } from "@/components/task-create-dialog-submit";
import { useToast } from "@/components/toast-provider";
import { type DialogFormState } from "@/components/task-create-dialog-state";
import {
useDialogFormState,
useTaskCreateDialogEffects,
useDialogHandlers,
useSessionRepoName,
useTaskCreateDialogData,
computeIsTaskStarted,
type DialogFormState,
type TaskCreateDialogInitialValues,
} from "@/components/task-create-dialog-state";
interface TaskCreateDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
mode?: "create" | "edit" | "session";
workspaceId: string | null;
workflowId: string | null;
defaultStepId: string | null;
steps: Array<{
id: string;
title: string;
events?: {
on_enter?: Array<{ type: string; config?: Record<string, unknown> }>;
on_turn_complete?: Array<{ type: string; config?: Record<string, unknown> }>;
};
}>;
editingTask?: {
id: string;
title: string;
description?: string;
workflowStepId: string;
state?: Task["state"];
repositoryId?: string;
} | null;
onSuccess?: (
task: Task,
mode: "create" | "edit",
meta?: { taskSessionId?: string | null },
) => void;
onCreateSession?: (data: { prompt: string; agentProfileId: string; executorId: string }) => void;
initialValues?: TaskCreateDialogInitialValues;
taskId?: string | null;
parentTaskId?: string;
}
useTaskCreateDialogSetup,
type TaskCreateDialogProps,
} from "@/components/task-create-dialog-setup";
type DialogFormBodyProps = {
isSessionMode: boolean;
@@ -111,60 +66,63 @@ type DialogFormBodyProps = {
enhance?: { onEnhance: () => void; isLoading: boolean; isConfigured: boolean };
workflowAgentLocked: boolean;
onToggleFreshBranch: (enabled: boolean) => void;
extraFormSlot?: React.ReactNode;
lockedFields?: TaskCreateDialogProps["lockedFields"];
descriptionPlaceholder?: string;
aboveDescriptionSlot?: React.ReactNode;
bottomSlot?: React.ReactNode;
};
// eslint-disable-next-line max-lines-per-function -- thin pass-through; each section already factored into its own component
function DialogFormBody({
isSessionMode,
isCreateMode,
isTaskStarted,
isPassthroughProfile,
initialDescription,
hasDescription,
workspaceId,
onJiraImport,
onLinearImport,
branchOptions,
branchesLoading,
onRefreshBranches,
branchesFetchedAt,
branchesFetchError,
agentProfileOptions,
executorProfileOptions,
agentProfiles,
agentProfilesLoading,
executorsLoading,
isCreatingSession,
workflows,
snapshots,
effectiveWorkflowId,
fs,
handleKeyDown,
onBranchChange,
onAgentProfileChange,
onExecutorProfileChange,
onWorkflowChange,
hasRepositorySelection,
isLocalExecutor,
enhance,
workflowAgentLocked,
onToggleFreshBranch,
}: DialogFormBodyProps) {
function DialogFormBody(p: DialogFormBodyProps) {
const {
isSessionMode,
isCreateMode,
isTaskStarted,
branchOptions,
branchesLoading,
onRefreshBranches,
branchesFetchedAt,
branchesFetchError,
agentProfileOptions,
executorProfileOptions,
agentProfiles,
agentProfilesLoading,
executorsLoading,
isCreatingSession,
workflows,
snapshots,
effectiveWorkflowId,
fs,
onBranchChange,
onAgentProfileChange,
onExecutorProfileChange,
onWorkflowChange,
hasRepositorySelection,
isLocalExecutor,
workflowAgentLocked,
onToggleFreshBranch,
lockedFields,
} = p;
return (
<div className="flex-1 space-y-4 overflow-y-auto pr-1">
<DialogPromptSection
isSessionMode={isSessionMode}
isTaskStarted={isTaskStarted}
isPassthroughProfile={isPassthroughProfile}
initialDescription={initialDescription}
hasDescription={hasDescription}
isPassthroughProfile={p.isPassthroughProfile}
initialDescription={p.initialDescription}
hasDescription={p.hasDescription}
fs={fs}
handleKeyDown={handleKeyDown}
enhance={enhance}
workspaceId={workspaceId}
onJiraImport={onJiraImport}
onLinearImport={onLinearImport}
handleKeyDown={p.handleKeyDown}
enhance={p.enhance}
workspaceId={p.workspaceId}
descriptionPlaceholder={p.descriptionPlaceholder}
onJiraImport={p.onJiraImport}
onLinearImport={p.onLinearImport}
extraFormSlot={p.extraFormSlot}
aboveDescriptionSlot={p.aboveDescriptionSlot}
/>
{!isSessionMode &&
renderCreateEditSelectors({
isTaskStarted,
@@ -187,16 +145,19 @@ function DialogFormBody({
onAgentProfileChange,
onExecutorProfileChange,
onToggleFreshBranch,
branchLocked: !!lockedFields?.branch,
})}
<WorkflowSection
isCreateMode={isCreateMode}
isTaskStarted={isTaskStarted}
workflows={workflows as Parameters<typeof WorkflowSection>[0]["workflows"]}
snapshots={snapshots as Parameters<typeof WorkflowSection>[0]["snapshots"]}
effectiveWorkflowId={effectiveWorkflowId}
onWorkflowChange={onWorkflowChange}
agentProfiles={agentProfiles}
/>
{!lockedFields?.workflow && (
<WorkflowSection
isCreateMode={isCreateMode}
isTaskStarted={isTaskStarted}
workflows={workflows as Parameters<typeof WorkflowSection>[0]["workflows"]}
snapshots={snapshots as Parameters<typeof WorkflowSection>[0]["snapshots"]}
effectiveWorkflowId={effectiveWorkflowId}
onWorkflowChange={onWorkflowChange}
agentProfiles={agentProfiles}
/>
)}
{isSessionMode && (
<SessionSelectors
agentProfileOptions={agentProfileOptions}
@@ -212,6 +173,7 @@ function DialogFormBody({
ExecutorProfileSelectorComponent={ExecutorProfileSelector}
/>
)}
{p.bottomSlot}
</div>
);
}
@@ -238,7 +200,7 @@ type CreateEditSelectorsRenderArgs = Pick<
| "onAgentProfileChange"
| "onExecutorProfileChange"
| "onToggleFreshBranch"
>;
> & { branchLocked?: boolean };
function renderCreateEditSelectors(args: CreateEditSelectorsRenderArgs) {
const { fs } = args;
@@ -270,6 +232,7 @@ function renderCreateEditSelectors(args: CreateEditSelectorsRenderArgs) {
freshBranchEnabled={fs.freshBranchEnabled}
onToggleFreshBranch={args.onToggleFreshBranch}
currentLocalBranch={fs.currentLocalBranch}
branchLocked={args.branchLocked}
BranchSelectorComponent={BranchSelector}
AgentSelectorComponent={AgentSelector}
ExecutorProfileSelectorComponent={ExecutorProfileSelector}
@@ -277,206 +240,25 @@ function renderCreateEditSelectors(args: CreateEditSelectorsRenderArgs) {
);
}
function useEnhanceForDialog(fs: DialogFormState) {
const isConfigured = useIsUtilityConfigured();
const { enhancePrompt, isEnhancingPrompt } = useUtilityAgentGenerator({
sessionId: null,
taskTitle: fs.taskName,
});
const onEnhance = useCallback(() => {
const current = fs.descriptionInputRef.current?.getValue()?.trim();
if (!current) return;
enhancePrompt(current, (enhanced) => {
fs.descriptionInputRef.current?.setValue(enhanced);
fs.setHasDescription(true);
});
}, [enhancePrompt, fs]);
return { onEnhance, isLoading: isEnhancingPrompt, isConfigured };
}
function useJiraImportHandler(fs: DialogFormState) {
return useCallback(
(ticket: JiraTicket) => {
const title = `[${ticket.key}] ${ticket.summary}`;
fs.setTaskName(title);
fs.setHasTitle(true);
const description = ticket.description?.trim()
? `${ticket.description}\n\n---\nJira: ${ticket.url}`
: `Jira: ${ticket.url}`;
fs.descriptionInputRef.current?.setValue(description);
fs.setHasDescription(true);
},
[fs],
);
}
function useLinearImportHandler(fs: DialogFormState) {
return useCallback(
(issue: LinearIssue) => {
const title = `[${issue.identifier}] ${issue.title}`;
fs.setTaskName(title);
fs.setHasTitle(true);
const description = issue.description?.trim()
? `${issue.description}\n\n---\nLinear: ${issue.url}`
: `Linear: ${issue.url}`;
fs.descriptionInputRef.current?.setValue(description);
fs.setHasDescription(true);
},
[fs],
);
}
type SubmitWiringArgs = {
props: TaskCreateDialogProps;
fs: ReturnType<typeof useDialogFormState>;
computed: ReturnType<typeof useTaskCreateDialogData>["computed"];
repositoryLocalPath: string;
isSessionMode: boolean;
isEditMode: boolean;
};
function useSubmitHandlersWiring({
props,
fs,
computed,
repositoryLocalPath,
isSessionMode,
isEditMode,
}: SubmitWiringArgs) {
const { workspaceId, workflowId, editingTask, onSuccess, onCreateSession, onOpenChange } = props;
const { parentTaskId } = props;
const taskId = props.taskId ?? null;
return useTaskSubmitHandlers({
isSessionMode,
isEditMode,
isPassthroughProfile: computed.isPassthroughProfile,
taskName: fs.taskName,
workspaceId,
workflowId,
effectiveWorkflowId: computed.effectiveWorkflowId,
effectiveDefaultStepId: computed.effectiveDefaultStepId,
repositoryId: fs.repositoryId,
selectedLocalRepo: fs.selectedLocalRepo,
useGitHubUrl: fs.useGitHubUrl,
githubUrl: fs.githubUrl,
githubPrHeadBranch: fs.githubPrHeadBranch,
branch: fs.branch,
agentProfileId: computed.effectiveAgentProfileId,
executorId: fs.executorId,
executorProfileId: fs.executorProfileId,
editingTask,
onSuccess,
onCreateSession,
onOpenChange,
taskId,
parentTaskId,
descriptionInputRef: fs.descriptionInputRef,
setIsCreatingSession: fs.setIsCreatingSession,
setIsCreatingTask: fs.setIsCreatingTask,
setHasTitle: fs.setHasTitle,
setHasDescription: fs.setHasDescription,
setTaskName: fs.setTaskName,
setRepositoryId: fs.setRepositoryId,
setBranch: fs.setBranch,
setAgentProfileId: fs.setAgentProfileId,
setExecutorId: fs.setExecutorId,
setSelectedWorkflowId: fs.setSelectedWorkflowId,
setFetchedSteps: fs.setFetchedSteps,
clearDraft: fs.clearDraft,
freshBranchEnabled: fs.freshBranchEnabled,
isLocalExecutor: computed.isLocalExecutor,
repositoryLocalPath,
});
}
function useTaskCreateDialogSetup(props: TaskCreateDialogProps) {
const { open, mode = "create", workspaceId, workflowId, defaultStepId } = props;
const { editingTask, initialValues } = props;
const isSessionMode = mode === "session";
const isEditMode = mode === "edit";
const isCreateMode = mode === "create";
const isTaskStarted = computeIsTaskStarted(isEditMode, editingTask);
const fs = useDialogFormState(open, workspaceId, workflowId, initialValues);
const { toast } = useToast();
const sessionRepoName = useSessionRepoName(isSessionMode);
const {
workflows,
agentProfiles,
executors,
snapshots,
repositories,
repositoriesLoading,
branches,
branchesLoading,
refreshBranches,
branchesFetchedAt,
branchesFetchError,
computed,
} = useTaskCreateDialogData(open, workspaceId, workflowId, defaultStepId, fs);
const repositoryLocalPath = (() => {
if (fs.selectedLocalRepo) return fs.selectedLocalRepo.path;
if (fs.repositoryId) {
const repo = repositories.find((r) => r.id === fs.repositoryId);
return repo?.local_path ?? "";
}
return "";
})();
useTaskCreateDialogEffects(fs, {
open,
workspaceId,
workflowId,
repositories,
repositoriesLoading,
branches,
agentProfiles,
executors,
workspaceDefaults: computed.workspaceDefaults,
toast,
workflows,
});
const handlers = useDialogHandlers(fs, repositories);
const submitHandlers = useSubmitHandlersWiring({
props,
fs,
computed,
repositoryLocalPath,
isSessionMode,
isEditMode,
});
const handleKeyDown = useKeyboardShortcutHandler(SHORTCUTS.SUBMIT, (event) => {
submitHandlers.handleSubmit(event as unknown as FormEvent);
});
return {
fs,
isSessionMode,
isEditMode,
isCreateMode,
isTaskStarted,
sessionRepoName,
workflows,
agentProfiles,
snapshots,
repositoriesLoading,
branchesLoading,
refreshBranches,
branchesFetchedAt,
branchesFetchError,
computed,
handlers,
submitHandlers,
handleKeyDown,
enhance: useEnhanceForDialog(fs),
handleJiraImport: useJiraImportHandler(fs),
handleLinearImport: useLinearImportHandler(fs),
};
}
type DialogFormProps = {
setup: ReturnType<typeof useTaskCreateDialogSetup>;
workspaceId: string | null;
extraFormSlot?: React.ReactNode;
lockedFields?: TaskCreateDialogProps["lockedFields"];
descriptionPlaceholder?: string;
aboveDescriptionSlot?: React.ReactNode;
bottomSlot?: React.ReactNode;
};
function DialogForm({ setup, workspaceId }: DialogFormProps) {
function DialogForm({
setup,
workspaceId,
extraFormSlot,
lockedFields,
descriptionPlaceholder,
aboveDescriptionSlot,
bottomSlot,
}: DialogFormProps) {
const { fs, isSessionMode, isCreateMode, isTaskStarted, workflows, agentProfiles } = setup;
const { snapshots, branchesLoading, computed, handlers, handleKeyDown } = setup;
const { handleJiraImport, handleLinearImport, refreshBranches, branchesFetchedAt } = setup;
@@ -520,6 +302,11 @@ function DialogForm({ setup, workspaceId }: DialogFormProps) {
enhance={setup.enhance}
workflowAgentLocked={computed.workflowAgentLocked}
onToggleFreshBranch={handlers.handleToggleFreshBranch}
extraFormSlot={extraFormSlot}
lockedFields={lockedFields}
descriptionPlaceholder={descriptionPlaceholder}
aboveDescriptionSlot={aboveDescriptionSlot}
bottomSlot={bottomSlot}
/>
<DialogFooter className="border-t border-border pt-3 flex-col gap-3 sm:flex-row sm:gap-2">
<TaskCreateDialogFooter
@@ -549,46 +336,86 @@ function DialogForm({ setup, workspaceId }: DialogFormProps) {
}
export function TaskCreateDialog(props: TaskCreateDialogProps) {
const { open, onOpenChange, initialValues, workspaceId } = props;
const setup = useTaskCreateDialogSetup(props);
const { fs, isCreateMode, isEditMode, isTaskStarted } = setup;
const { repositoriesLoading, computed, handlers } = setup;
const { fs, computed, handlers } = setup;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent
data-testid="create-task-dialog"
className="w-full h-full max-w-full max-h-full rounded-none sm:w-[900px] sm:h-auto sm:max-w-none sm:max-h-[85vh] sm:rounded-lg flex flex-col"
>
<DialogHeader>
<DialogHeaderContent
isCreateMode={isCreateMode}
isEditMode={isEditMode}
isTaskStarted={isTaskStarted}
<RenderHeader
initialTitle={props.initialValues?.title}
workspaceId={props.workspaceId}
isCreateMode={setup.isCreateMode}
isEditMode={setup.isEditMode}
isTaskStarted={setup.isTaskStarted}
sessionRepoName={setup.sessionRepoName}
initialTitle={initialValues?.title}
taskName={fs.taskName}
repositoryId={fs.repositoryId}
discoveredRepoPath={fs.discoveredRepoPath}
workspaceId={workspaceId}
repositoriesLoading={repositoriesLoading}
discoverReposLoading={fs.discoverReposLoading}
headerRepositoryOptions={computed.headerRepositoryOptions}
onRepositoryChange={handlers.handleRepositoryChange}
onTaskNameChange={handlers.handleTaskNameChange}
useGitHubUrl={fs.useGitHubUrl}
githubUrl={fs.githubUrl}
githubUrlError={fs.githubUrlError}
onToggleGitHubUrl={handlers.handleToggleGitHubUrl}
onGitHubUrlChange={handlers.handleGitHubUrlChange}
fs={fs}
repositoriesLoading={setup.repositoriesLoading}
computed={computed}
handlers={handlers}
repositoryLocked={!!props.lockedFields?.repository}
/>
</DialogHeader>
<DialogForm setup={setup} workspaceId={workspaceId} />
<DialogForm
setup={setup}
workspaceId={props.workspaceId}
extraFormSlot={props.extraFormSlot}
lockedFields={props.lockedFields}
descriptionPlaceholder={props.descriptionPlaceholder}
aboveDescriptionSlot={props.aboveDescriptionSlot}
bottomSlot={props.bottomSlot}
/>
<PendingDiscardModal pending={setup.submitHandlers.pendingDiscard} />
</DialogContent>
</Dialog>
);
}
type RenderHeaderProps = {
initialTitle: string | undefined;
workspaceId: string | null | undefined;
isCreateMode: boolean;
isEditMode: boolean;
isTaskStarted: boolean;
sessionRepoName: string | null | undefined;
fs: ReturnType<typeof useTaskCreateDialogSetup>["fs"];
repositoriesLoading: boolean;
computed: ReturnType<typeof useTaskCreateDialogSetup>["computed"];
handlers: ReturnType<typeof useTaskCreateDialogSetup>["handlers"];
repositoryLocked?: boolean;
};
function RenderHeader(props: RenderHeaderProps) {
const { fs, computed, handlers } = props;
return (
<DialogHeaderContent
isCreateMode={props.isCreateMode}
isEditMode={props.isEditMode}
isTaskStarted={props.isTaskStarted}
sessionRepoName={props.sessionRepoName ?? undefined}
initialTitle={props.initialTitle}
taskName={fs.taskName}
repositoryId={fs.repositoryId}
discoveredRepoPath={fs.discoveredRepoPath}
workspaceId={props.workspaceId ?? null}
repositoriesLoading={props.repositoriesLoading}
discoverReposLoading={fs.discoverReposLoading}
headerRepositoryOptions={computed.headerRepositoryOptions}
onRepositoryChange={handlers.handleRepositoryChange}
onTaskNameChange={handlers.handleTaskNameChange}
useGitHubUrl={fs.useGitHubUrl}
githubUrl={fs.githubUrl}
githubUrlError={fs.githubUrlError}
onToggleGitHubUrl={handlers.handleToggleGitHubUrl}
onGitHubUrlChange={handlers.handleGitHubUrlChange}
repositoryLocked={props.repositoryLocked}
/>
);
}
function PendingDiscardModal({
pending,
}: {
@@ -171,9 +171,12 @@ async function switchWorkspace(newWorkspaceId: string, opts: SheetNavOptions) {
const { store, loadTaskSessionsForTask, setActiveSession, setActiveTask, onOpenChange } = opts;
store.setState((state) => ({ ...state, kanban: { ...state.kanban, isLoading: true } }));
try {
const workflowsResponse = await listWorkflows(newWorkspaceId, { cache: "no-store" });
const workflowsResponse = await listWorkflows(newWorkspaceId, {
cache: "no-store",
includeHidden: true,
});
const newWorkspaceWorkflows = workflowsResponse.workflows ?? [];
const firstWorkflow = newWorkspaceWorkflows[0];
const firstWorkflow = newWorkspaceWorkflows.find((w) => !w.hidden);
if (!firstWorkflow) {
store.setState((state) => ({ ...state, kanban: { ...state.kanban, isLoading: false } }));
return;
@@ -191,6 +194,7 @@ async function switchWorkspace(newWorkspaceId: string, opts: SheetNavOptions) {
id: w.id,
workspaceId: w.workspace_id,
name: w.name,
hidden: w.hidden,
})),
],
activeId: firstWorkflow.id,
+189
View File
@@ -0,0 +1,189 @@
import type { Page } from "@playwright/test";
import { test, expect } from "../fixtures/test-base";
/**
* E2E tests for the Improve Kandev dialog. The bootstrap endpoint is mocked
* because it would otherwise clone https://github.com/kdlbs/kandev and shell
* out to the gh CLI. The system/health endpoint is mocked to keep the intro
* screen out of the GhAuthMissing branch regardless of backend state.
*/
const BOOTSTRAP_URL = "**/api/v1/system/improve-kandev/bootstrap";
const FRONTEND_LOG_URL = "**/api/v1/system/improve-kandev/bundle/frontend-log";
const HEALTH_URL = "**/api/v1/system/health";
type ForkStatus = "writable" | "ready" | "blocked_emu" | "unknown";
type BootstrapOverrides = {
github_login?: string;
has_write_access?: boolean;
fork_status?: ForkStatus;
fork_message?: string;
};
async function mockImproveKandevApis(
page: Page,
seed: { repositoryId: string; workflowId: string },
overrides: BootstrapOverrides = {},
): Promise<void> {
const bundleDir = "/tmp/kandev-improve-e2e";
const hasWrite = overrides.has_write_access ?? false;
// Default fork_status mirrors how the backend would respond for the given
// write-access value: writable when the user has push access, otherwise
// unknown (the safe fall-through that lets the dialog proceed normally).
const forkStatus: ForkStatus = overrides.fork_status ?? (hasWrite ? "writable" : "unknown");
await page.route(HEALTH_URL, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ healthy: true, issues: [] }),
}),
);
await page.route(BOOTSTRAP_URL, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
repository_id: seed.repositoryId,
workflow_id: seed.workflowId,
branch: "main",
bundle_dir: bundleDir,
bundle_files: {
metadata: `${bundleDir}/metadata.json`,
backend_log: `${bundleDir}/backend.log`,
frontend_log: `${bundleDir}/frontend.log`,
},
github_login: overrides.github_login ?? "octocat",
has_write_access: hasWrite,
fork_status: forkStatus,
...(overrides.fork_message ? { fork_message: overrides.fork_message } : {}),
}),
}),
);
await page.route(FRONTEND_LOG_URL, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ path: `${bundleDir}/frontend.log` }),
}),
);
}
test.describe("Improve Kandev dialog", () => {
test("intro → create flow shows workflow preview, useful info, and fork banner", async ({
testPage,
seedData,
}) => {
await mockImproveKandevApis(testPage, seedData, {
github_login: "octocat",
has_write_access: false,
});
await testPage.goto("/");
await testPage.getByTestId("improve-kandev-button").first().click();
// Intro screen
const introDialog = testPage.getByRole("dialog", { name: "Improve Kandev" });
await expect(introDialog).toBeVisible();
await expect(introDialog.getByText(/KanDev is open source/)).toBeVisible();
await expect(introDialog.getByText(/forks .* to your GitHub account/)).toBeVisible();
const contribute = testPage.getByTestId("improve-kandev-proceed");
await expect(contribute).toBeEnabled({ timeout: 10_000 });
await contribute.click();
// Create dialog mounts after Contribute is clicked
const createDialog = testPage.getByTestId("create-task-dialog");
await expect(createDialog).toBeVisible({ timeout: 10_000 });
// Bug fix / Feature request kind tabs
await expect(createDialog.getByRole("tab", { name: "Bug fix" })).toBeVisible();
await expect(createDialog.getByRole("tab", { name: "Feature request" })).toBeVisible();
// Contributor banner (fork mode)
await expect(createDialog.getByText("@octocat")).toBeVisible();
await expect(
createDialog.getByText(/agent will fork kdlbs\/kandev to your account/i),
).toBeVisible();
// Workflow preview header
await expect(createDialog.getByText("Workflow", { exact: true })).toBeVisible();
// Useful info collapsible expands and lists shell + skill commands
const usefulInfoTrigger = createDialog.getByRole("button", { name: /useful commands/i });
await expect(usefulInfoTrigger).toBeVisible();
await usefulInfoTrigger.click();
await expect(createDialog.getByText("make install && make dev")).toBeVisible();
await expect(createDialog.getByText("/commit", { exact: true })).toBeVisible();
await expect(createDialog.getByText("/pr-fixup", { exact: true })).toBeVisible();
// GitHub URL toggle is hidden because the repository is locked to kandev
await expect(createDialog.getByTestId("toggle-github-url")).toHaveCount(0);
});
test("contributor banner shows direct-push copy when user has write access", async ({
testPage,
seedData,
}) => {
await mockImproveKandevApis(testPage, seedData, {
github_login: "kandev-maint",
has_write_access: true,
});
await testPage.goto("/");
await testPage.getByTestId("improve-kandev-button").first().click();
const contribute = testPage.getByTestId("improve-kandev-proceed");
await expect(contribute).toBeEnabled({ timeout: 10_000 });
await contribute.click();
const createDialog = testPage.getByTestId("create-task-dialog");
await expect(createDialog).toBeVisible({ timeout: 10_000 });
await expect(createDialog.getByText("@kandev-maint")).toBeVisible();
await expect(
createDialog.getByText(/push directly to a branch on the upstream repo/i),
).toBeVisible();
});
test("blocks contribution when account is detected as Enterprise Managed User", async ({
testPage,
seedData,
}) => {
const blockedMessage =
"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.";
await mockImproveKandevApis(testPage, seedData, {
github_login: "alice_corp",
has_write_access: false,
fork_status: "blocked_emu",
fork_message: blockedMessage,
});
await testPage.goto("/");
await testPage.getByTestId("improve-kandev-button").first().click();
const contribute = testPage.getByTestId("improve-kandev-proceed");
await expect(contribute).toBeEnabled({ timeout: 10_000 });
await contribute.click();
// Blocked dialog mounts in place of the task-create form. The message
// also fires as a toast, so scope the assertion to the dialog body.
const blockedDialog = testPage.getByRole("dialog", { name: "Improve Kandev" });
await expect(blockedDialog).toBeVisible({ timeout: 10_000 });
await expect(blockedDialog.getByText(blockedMessage)).toBeVisible();
// The task-create form must NOT mount: there is nothing to submit and
// no fork should be attempted.
await expect(testPage.getByTestId("create-task-dialog")).toHaveCount(0);
// Close button is the only action; clicking it dismisses the dialog.
await testPage.getByTestId("improve-kandev-blocked-close").click();
await expect(blockedDialog).toBeHidden();
});
});
+2 -1
View File
@@ -8,7 +8,7 @@ export function useWorkflows(workspaceId: string | null, enabled = true) {
useEffect(() => {
if (!enabled || !workspaceId) return;
listWorkflows(workspaceId, { cache: "no-store" })
listWorkflows(workspaceId, { cache: "no-store", includeHidden: true })
.then((response) => {
const mapped = response.workflows.map((workflow) => ({
id: workflow.id,
@@ -17,6 +17,7 @@ export function useWorkflows(workspaceId: string | null, enabled = true) {
description: workflow.description,
sortOrder: workflow.sort_order ?? 0,
agent_profile_id: workflow.agent_profile_id,
hidden: workflow.hidden,
}));
setWorkflows(mapped);
})
@@ -0,0 +1,71 @@
import { fetchJson, type ApiRequestOptions } from "../client";
import type { LogEntry } from "@/lib/logger/buffer";
// Result of the bootstrap fork-capability probe; mirrors backend ForkStatus.
// "writable": user has push access to upstream, no fork needed.
// "ready": a fork already exists at github.com/{login}/kandev.
// "blocked_emu": user looks like an Enterprise Managed User and likely
// cannot fork repositories outside their owning enterprise.
// "unknown": bootstrap could not determine fork eligibility; proceed and
// rely on the PR step to surface any errors.
export type ForkStatus = "writable" | "ready" | "blocked_emu" | "unknown";
export type ImproveKandevBootstrapResponse = {
repository_id: string;
workflow_id: string;
branch: string;
bundle_dir: string;
bundle_files: {
metadata: string;
backend_log: string;
frontend_log: string;
};
github_login: string;
has_write_access: boolean;
fork_status: ForkStatus;
fork_message?: string;
};
export async function bootstrapImproveKandev(
workspaceId: string,
options?: ApiRequestOptions,
): Promise<ImproveKandevBootstrapResponse> {
return fetchJson<ImproveKandevBootstrapResponse>("/api/v1/system/improve-kandev/bootstrap", {
...options,
init: {
method: "POST",
body: JSON.stringify({ workspace_id: workspaceId }),
...(options?.init ?? {}),
},
});
}
export type FrontendLogPayloadEntry = {
timestamp: string;
level: string;
message: string;
args?: unknown[];
stack?: string;
};
export async function uploadFrontendLog(
bundleDir: string,
entries: LogEntry[],
options?: ApiRequestOptions,
): Promise<{ path: string }> {
const payload: FrontendLogPayloadEntry[] = entries.map((e) => ({
timestamp: e.timestamp,
level: e.level,
message: e.message,
args: e.args,
stack: e.stack,
}));
return fetchJson<{ path: string }>("/api/v1/system/improve-kandev/bundle/frontend-log", {
...options,
init: {
method: "POST",
body: JSON.stringify({ bundle_dir: bundleDir, entries: payload }),
...(options?.init ?? {}),
},
});
}
+10 -3
View File
@@ -10,11 +10,18 @@ import type {
} from "@/lib/types/http";
// Workflow operations
export async function listWorkflows(workspaceId: string, options?: ApiRequestOptions) {
const baseUrl = options?.baseUrl ?? getBackendConfig().apiBaseUrl;
export async function listWorkflows(
workspaceId: string,
options?: ApiRequestOptions & { includeHidden?: boolean },
) {
const { includeHidden, ...requestOptions } = options ?? {};
const baseUrl = requestOptions.baseUrl ?? getBackendConfig().apiBaseUrl;
const url = new URL(`${baseUrl}/api/v1/workflows`);
url.searchParams.set("workspace_id", workspaceId);
return fetchJson<ListWorkflowsResponse>(url.toString(), options);
if (includeHidden) {
url.searchParams.set("include_hidden", "true");
}
return fetchJson<ListWorkflowsResponse>(url.toString(), requestOptions);
}
export async function fetchWorkflowSnapshot(workflowId: string, options?: ApiRequestOptions) {
+62
View File
@@ -0,0 +1,62 @@
import { describe, it, expect, beforeEach } from "vitest";
import {
DEFAULT_CAPACITY,
_resetForTesting,
clearLogs,
getLogBuffer,
snapshotLogs,
} from "./buffer";
describe("frontend log buffer", () => {
beforeEach(() => {
_resetForTesting();
});
it("appends entries in order", () => {
const buf = getLogBuffer();
buf.push({ timestamp: "t1", level: "info", source: "console", message: "a" });
buf.push({ timestamp: "t2", level: "warn", source: "console", message: "b" });
expect(snapshotLogs().map((e) => e.message)).toEqual(["a", "b"]);
});
it("evicts oldest when capacity is exceeded", () => {
const buf = getLogBuffer();
for (let i = 0; i < DEFAULT_CAPACITY + 5; i++) {
buf.push({ timestamp: String(i), level: "info", source: "console", message: `m${i}` });
}
const snap = snapshotLogs();
expect(snap).toHaveLength(DEFAULT_CAPACITY);
expect(snap[0].message).toBe(`m5`);
expect(snap[snap.length - 1].message).toBe(`m${DEFAULT_CAPACITY + 4}`);
});
it("snapshot is isolated from the live buffer", () => {
const buf = getLogBuffer();
buf.push({ timestamp: "t", level: "info", source: "console", message: "x" });
const snap = snapshotLogs();
snap[0].message = "mutated";
expect(snapshotLogs()[0].message).toBe("x");
});
it("snapshot deep-copies the args array", () => {
const buf = getLogBuffer();
buf.push({
timestamp: "t",
level: "info",
source: "console",
message: "x",
args: ["a", "b"],
});
const snap = snapshotLogs();
snap[0].args!.push("mutated");
expect(snapshotLogs()[0].args).toEqual(["a", "b"]);
});
it("clearLogs empties the buffer", () => {
const buf = getLogBuffer();
buf.push({ timestamp: "t", level: "info", source: "console", message: "x" });
clearLogs();
expect(snapshotLogs()).toHaveLength(0);
});
});
+75
View File
@@ -0,0 +1,75 @@
/**
* In-memory ring buffer of recent frontend log entries.
*
* Used to capture browser-side `console.*` calls and unhandled errors so they
* can be attached to user-submitted Improve Kandev reports without unbounded
* memory growth.
*/
export type LogLevel = "debug" | "info" | "warn" | "error";
export interface LogEntry {
timestamp: string; // ISO8601
level: LogLevel;
source: string; // "console" | "window.onerror" | "unhandledrejection"
message: string;
args?: unknown[]; // serialized best-effort
stack?: string; // populated when the entry originated from an Error
}
export const DEFAULT_CAPACITY = 500;
class RingBuffer {
private entries: LogEntry[] = [];
private capacity: number;
constructor(capacity: number = DEFAULT_CAPACITY) {
this.capacity = Math.max(1, capacity);
}
push(entry: LogEntry): void {
if (this.entries.length >= this.capacity) {
this.entries.shift();
}
this.entries.push(entry);
}
snapshot(): LogEntry[] {
// Shallow-copy each entry and the args slice so callers cannot mutate
// buffered state (e.g. when the snapshot is later JSON-serialized in place).
return this.entries.map((e) => ({
...e,
args: e.args ? [...e.args] : undefined,
}));
}
clear(): void {
this.entries = [];
}
size(): number {
return this.entries.length;
}
}
let defaultBuffer: RingBuffer | null = null;
export function getLogBuffer(): RingBuffer {
if (!defaultBuffer) {
defaultBuffer = new RingBuffer();
}
return defaultBuffer;
}
export function snapshotLogs(): LogEntry[] {
return getLogBuffer().snapshot();
}
export function clearLogs(): void {
getLogBuffer().clear();
}
/** Exposed for tests. */
export function _resetForTesting(): void {
defaultBuffer = new RingBuffer();
}
+104
View File
@@ -0,0 +1,104 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { _resetForTesting, snapshotLogs } from "./buffer";
import { _resetInstalledForTesting, installConsoleInterceptor } from "./intercept";
describe("console interceptor", () => {
// Hold originals so each test starts with a clean console.
const originals = {
debug: console.debug,
info: console.info,
warn: console.warn,
error: console.error,
};
let infoSpy: ReturnType<typeof vi.fn>;
let warnSpy: ReturnType<typeof vi.fn>;
let errorSpy: ReturnType<typeof vi.fn>;
beforeEach(() => {
_resetForTesting();
_resetInstalledForTesting();
infoSpy = vi.fn();
warnSpy = vi.fn();
errorSpy = vi.fn();
console.debug = vi.fn();
console.info = infoSpy;
console.warn = warnSpy;
console.error = errorSpy;
});
afterEach(() => {
Object.assign(console, originals);
});
it("captures console.* into the ring buffer and still calls the original", () => {
installConsoleInterceptor();
console.info("hello", { user: "x" });
console.warn("careful");
console.error(new Error("boom"));
const snap = snapshotLogs();
expect(snap).toHaveLength(3);
expect(snap[0].level).toBe("info");
expect(snap[0].message).toBe("hello");
expect(snap[0].source).toBe("console");
expect(snap[1].level).toBe("warn");
expect(snap[2].level).toBe("error");
expect(snap[2].message).toBe("boom");
// Error stack must survive on the entry so the bundle preserves it.
expect(typeof snap[2].stack).toBe("string");
expect(snap[2].stack).toContain("Error");
expect(infoSpy).toHaveBeenCalledWith("hello", { user: "x" });
expect(warnSpy).toHaveBeenCalledWith("careful");
expect(errorSpy).toHaveBeenCalled();
});
it("is idempotent — second install does not double-wrap", () => {
installConsoleInterceptor();
installConsoleInterceptor();
console.info("once");
expect(snapshotLogs()).toHaveLength(1);
});
it("captures window error events", () => {
installConsoleInterceptor();
const err = new Error("explode");
window.dispatchEvent(
new ErrorEvent("error", {
message: err.message,
error: err,
filename: "x.ts",
lineno: 1,
colno: 2,
}),
);
const snap = snapshotLogs();
expect(snap.some((e) => e.source === "window.onerror" && e.message === "explode")).toBe(true);
});
it("captures unhandled promise rejections", () => {
installConsoleInterceptor();
const reason = new Error("rejected");
const event = new Event("unhandledrejection") as Event & {
reason: unknown;
promise: Promise<unknown>;
};
Object.defineProperty(event, "reason", { value: reason });
Object.defineProperty(event, "promise", {
value: Promise.reject(reason).catch(() => undefined),
});
window.dispatchEvent(event);
const snap = snapshotLogs();
expect(snap.some((e) => e.source === "unhandledrejection" && e.message === "rejected")).toBe(
true,
);
});
});
+116
View File
@@ -0,0 +1,116 @@
/**
* Console + window-error interceptor that mirrors entries into the in-memory
* log ring buffer (see `./buffer.ts`). Idempotent — safe to call multiple
* times. Designed to be installed once from the top-level client boot.
*/
import { getLogBuffer, type LogEntry, type LogLevel } from "./buffer";
let installed = false;
const LEVELS: LogLevel[] = ["debug", "info", "warn", "error"];
function safeStringify(arg: unknown): unknown {
if (
typeof arg === "string" ||
typeof arg === "number" ||
typeof arg === "boolean" ||
arg === null
) {
return arg;
}
if (arg instanceof Error) {
return { name: arg.name, message: arg.message, stack: arg.stack };
}
try {
JSON.stringify(arg);
return arg;
} catch {
return String(arg);
}
}
function extractMessage(first: unknown): string {
if (typeof first === "string") return first;
if (first instanceof Error) return first.message;
return String(first ?? "");
}
function buildEntry(level: LogLevel, source: string, args: unknown[]): LogEntry {
const message = extractMessage(args[0]);
// Preserve the originating Error's stack on the entry so the bundle keeps it
// even though we serialize args by value below.
const stack = args[0] instanceof Error ? args[0].stack : undefined;
return {
timestamp: new Date().toISOString(),
level,
source,
message,
args: args.length > 1 ? args.slice(1).map(safeStringify) : undefined,
stack,
};
}
export function installConsoleInterceptor(): void {
if (installed) return;
if (typeof window === "undefined") return;
installed = true;
const buffer = getLogBuffer();
for (const level of LEVELS) {
const original = console[level]?.bind(console);
if (!original) continue;
console[level] = (...args: unknown[]) => {
try {
buffer.push(buildEntry(level, "console", args));
} catch {
// never let interceptor break the host console call
}
original(...args);
};
}
window.addEventListener("error", (event) => {
buffer.push({
timestamp: new Date().toISOString(),
level: "error",
source: "window.onerror",
message: event.message ?? "Uncaught error",
args: [
{
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
error:
event.error instanceof Error
? { name: event.error.name, stack: event.error.stack }
: undefined,
},
],
stack: event.error instanceof Error ? event.error.stack : undefined,
});
});
window.addEventListener("unhandledrejection", (event) => {
const reason = event.reason;
const message =
reason instanceof Error ? reason.message : String(reason ?? "Unhandled rejection");
buffer.push({
timestamp: new Date().toISOString(),
level: "error",
source: "unhandledrejection",
message,
args:
reason instanceof Error
? [{ name: reason.name, stack: reason.stack }]
: [safeStringify(reason)],
stack: reason instanceof Error ? reason.stack : undefined,
});
});
}
/** Exposed for tests. */
export function _resetInstalledForTesting(): void {
installed = false;
}
+12 -3
View File
@@ -105,7 +105,12 @@ function buildResourceState(p: BuildSessionPageStateParams) {
activeId: task.workspace_id,
},
workflows: {
items: workflows.map((w) => ({ id: w.id, workspaceId: w.workspace_id, name: w.name })),
items: workflows.map((w) => ({
id: w.id,
workspaceId: w.workspace_id,
name: w.name,
hidden: w.hidden,
})),
activeId: task.workflow_id,
},
repositories: {
@@ -273,7 +278,9 @@ async function fetchTaskDataOnly(
listAgents({ cache: "no-store" }),
listRepositories(task.workspace_id, { includeScripts: true }, { cache: "no-store" }),
listWorkspaces({ cache: "no-store" }).catch(() => ({ workspaces: [] })),
listWorkflows(task.workspace_id, { cache: "no-store" }).catch(() => ({ workflows: [] })),
listWorkflows(task.workspace_id, { cache: "no-store", includeHidden: true }).catch(() => ({
workflows: [],
})),
fetchUserSettings({ cache: "no-store" }).catch(() => null),
]);
@@ -321,7 +328,9 @@ async function fetchSessionDataFromTask(
listAgents({ cache: "no-store" }),
listRepositories(task.workspace_id, { includeScripts: true }, { cache: "no-store" }),
listWorkspaces({ cache: "no-store" }).catch(() => ({ workspaces: [] })),
listWorkflows(task.workspace_id, { cache: "no-store" }).catch(() => ({ workflows: [] })),
listWorkflows(task.workspace_id, { cache: "no-store", includeHidden: true }).catch(() => ({
workflows: [],
})),
listSessionTurns(sessionId, { cache: "no-store" }).catch(() => ({ turns: [], total: 0 })),
fetchUserSettings({ cache: "no-store" }).catch(() => null),
fetchTerminals(sessionId).catch(() => []),
@@ -68,6 +68,7 @@ export type WorkflowsState = {
description?: string | null;
sortOrder?: number;
agent_profile_id?: string;
hidden?: boolean;
}>;
activeId: string | null;
};
+1
View File
@@ -143,6 +143,7 @@ export type Workflow = {
workflow_template_id?: string | null;
agent_profile_id?: string;
sort_order?: number;
hidden?: boolean;
created_at: string;
updated_at: string;
};
+1
View File
@@ -7,3 +7,4 @@ See `.agents/skills/spec/SKILL.md` for the workflow.
| Slug | Status | Created | PR |
|------|--------|---------|----|
| [external-mcp](external-mcp/spec.md) | draft | 2026-04-28 | — |
| [improve-kandev](./improve-kandev/spec.md) | draft | 2026-04-29 | — |
+302
View File
@@ -0,0 +1,302 @@
# Plan — Improve Kandev
> Implementation plan for the `improve-kandev` spec.
> Workflow: TDD (Red → Green → Refactor) per `.claude/skills/tdd/SKILL.md`.
## 1. Overview
Deliver an in-app entry point for filing bug reports and feature suggestions
that produces a real kandev task running on the user's own agent. The task
follows a hidden three-step workflow (improve → test → pr) and optionally
attaches a logs bundle written to a temp folder.
Goals:
- Capture recent backend and frontend logs in bounded ring buffers.
- Add a hidden workflow `improve-kandev` with three manually-advanced steps.
- Add an Improve Kandev settings entry that opens a tailored task-create
dialog with the kandev repo, `main` branch, and a starter description
pre-filled.
- Filter hidden workflows out of management UI and the workflow picker.
Out of scope (deferred):
- Automatic step transitions, rate limiting, log redaction, fork management.
- A generic feedback inbox.
## 2. Prerequisites
- No external services. Uses existing `repoclone.Cloner`,
`worktree.Manager`, `task.Service`, `orchestrator.Service`, and the
workflow YAML loader.
- New temp directory layout: `os.MkdirTemp("", "kandev-improve-*")`.
- Workflows YAML loader at `apps/backend/config/workflows/loader.go` already
supports loading additional templates from `apps/backend/config/workflows/`.
- The `Workflow` model gains a `hidden` boolean column. SQLite migration required.
## 3. Implementation Steps
### Step 1 — Backend log ring buffer (RED → GREEN)
- **File (new)**: `apps/backend/internal/common/logger/buffer/buffer.go`
- `RingBuffer` struct: thread-safe, FIFO, capped at 2000 entries.
- `Entry` struct: `{Timestamp, Level, Logger, Message, Fields, Caller}`.
- `Snapshot()` returns a copy of current entries.
- `WriteSyncer` adapter (implements `zapcore.WriteSyncer`) that decodes the
line emitted by the configured zap encoder back into an `Entry`. Prefer
using a small custom `zapcore.Core` that hooks into entry creation rather
than parsing strings — `zap.Hooks` returns a wrapped core.
- **File (modify)**: `apps/backend/internal/common/logger/logger.go`
- In `NewLogger`, wrap the core with `zap.Hooks(...)` (or a small custom
`zapcore.Core` tee) that pushes each entry into the ring buffer.
- Expose `(l *Logger) BufferSnapshot() []buffer.Entry`.
- **File (new)**: `apps/backend/internal/common/logger/buffer/buffer_test.go`
- FIFO eviction at capacity.
- Concurrent writes are serialized.
- `Snapshot()` returns an isolated copy.
### Step 2 — Frontend log ring buffer + console interceptor (RED → GREEN)
- **File (new)**: `apps/web/lib/logger/buffer.ts`
- In-memory ring buffer (cap 500). `Entry` shape mirrors backend
(`timestamp`, `level`, `source`, `message`, `args`).
- `snapshot()`, `clear()` exports.
- **File (new)**: `apps/web/lib/logger/intercept.ts`
- `installConsoleInterceptor()` wraps `console.debug/info/warn/error` so the
original behavior is preserved and each call also pushes to the ring.
- Hooks `window.onerror` and `window.addEventListener("unhandledrejection")`.
- Idempotent (no-op on repeat calls).
- **File (modify)**: `apps/web/app/layout.tsx` (or the closest top-level
client boot) — call `installConsoleInterceptor()` once on the client.
- **Files (new tests)**: `apps/web/lib/logger/buffer.test.ts`,
`apps/web/lib/logger/intercept.test.ts`.
### Step 3 — `hidden` flag on Workflow (RED → GREEN)
- **File (modify)**: `apps/backend/internal/task/models/models.go` — add
`Hidden bool` to `Workflow`.
- **File (new)**: `apps/backend/internal/task/repository/migrations/NNNN_workflow_hidden.sql`
(use the existing migrations pattern; add `ALTER TABLE workflows ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0`).
- **Files (modify)**: workflow repository read/write paths — include `hidden`
in `SELECT` / `INSERT` / `UPDATE`.
- **File (modify)**: workflow service `ListWorkflows` — accept an
`IncludeHidden bool` option (default false). Existing callers pass false.
- **File (modify)**: workflow API DTOs and the OpenAPI/types generation if
applicable; ensure the `hidden` field flows to the frontend.
- **Files (modify, frontend)**:
- `apps/web/lib/state/slices/settings/...` (or wherever workflows hydrate)
— preserve `hidden` field on the type.
- `apps/web/app/settings/workspace/workspace-workflows-client.tsx` — filter
`workflow.hidden !== true` before rendering `WorkflowList`.
- `apps/web/components/workflow-selector-row.tsx` — same filter on the
picker list.
- **Tests**: repository test for `hidden` round-trip; one frontend store test
ensuring hidden workflows are excluded from the picker selector.
### Step 4 — `improve-kandev` workflow YAML (GREEN)
- **File (new)**: `apps/backend/config/workflows/improve-kandev.yml`
- Three steps with `is_start_step` on improve, `allow_manual_move: true` on
all, `on_enter: auto_start_agent`, and per-step `prompt` text matching
the spec. Top-level YAML does not yet support `hidden`; add support:
- **File (modify)**: `apps/backend/config/workflows/loader.go` — extend
`templateYAML` with `Hidden bool` and propagate when materializing the
template. When the loader instantiates a workflow from a template marked
hidden, the resulting workflow is created with `Hidden: true`.
- **File (modify)**: workflow template materialization path (the place that
creates a `Workflow` row from a `WorkflowTemplate`) — copy `IsSystem` /
`Hidden` into the resulting workflow.
- **Test**: a loader test that parses `improve-kandev.yml` and asserts the
`hidden` flag is propagated.
### Step 5 — Bootstrap endpoint (RED → GREEN)
- **File (new)**: `apps/backend/internal/system/handlers/improve_kandev.go`
- `POST /api/v1/system/improve-kandev/bootstrap`
- Ensures kandev repo is cloned via
`repoclone.Cloner.EnsureCloned("https://github.com/kdlbs/kandev", "kdlbs", "kandev")`.
- Ensures a `Repository` row exists in the user's active workspace pointing
at the local path; creates one on first call, reuses thereafter (idempotent).
- Ensures the hidden `improve-kandev` workflow exists in the workspace
(creates it from the template on first call).
- Creates a temp directory via `os.MkdirTemp("", "kandev-improve-*")` and
writes `metadata.json` (version, OS/arch, health snapshot, captured_at)
and `backend.log` (zap ring buffer snapshot, formatted as plain text,
one entry per line).
- Response: `{repository_id, workflow_id, branch: "main",
bundle_dir, bundle_files: {metadata, backend_log, frontend_log_target}}`.
- `POST /api/v1/system/improve-kandev/bundle/frontend-log`
- Body: `{bundle_dir, entries: FrontendLogEntry[]}`.
- Writes plain-text `frontend.log` into the bundle dir.
- Validates `bundle_dir` matches the temp pattern to refuse arbitrary writes.
- **File (modify)**: `cmd/kandev/helpers.go` — register the new routes.
- **File (new tests)**: `improve_kandev_test.go`
- First call clones + creates repo row + workflow row; second call is
idempotent.
- Bundle dir validation rejects paths outside the temp pattern.
### Step 6 — TaskCreateDialog extension props (RED → GREEN)
- **File (modify)**: `apps/web/components/task-create-dialog.tsx`
- Add two optional props to `TaskCreateDialogProps`:
- `extraFormSlot?: React.ReactNode` — rendered inside the form, directly
below the description textarea.
- `transformDescriptionBeforeSubmit?: (desc: string) => Promise<string> | string`
— invoked once with the current description text just before the
create-task API call; the returned string is used as the description.
- Submit handler awaits the transform, swaps the description, then proceeds.
- **File (new test)**: `task-create-dialog-extra-slot.test.tsx`
- Rendering with a slot mounts the node.
- The transform is awaited and its result is sent in the API payload.
### Step 7 — `ImproveKandevDialog` wrapper (GREEN)
- **File (new)**: `apps/web/components/improve-kandev-dialog.tsx`
- Props: `{open, onOpenChange, onSuccess?}`.
- On mount: calls bootstrap endpoint to obtain `repository_id`,
`workflow_id`, `bundle_dir`, `bundle_files`.
- Owns local state: `includeLogs: boolean` (default true),
`logsPreviewOpen: boolean`.
- Renders `<TaskCreateDialog>` with:
- `workspaceId` = active workspace id from store.
- `workflowId` = bootstrap response.
- `initialValues = { title: "", description: STARTER_TEMPLATE,
repositoryId, branch: "main" }`.
- `extraFormSlot` = a `Card` containing the dialog's explanatory header
copy ("Help improve kandev — flow: Improve → Test → PR — contribute…")
plus the "Include recent logs" checkbox with an expandable preview.
- `transformDescriptionBeforeSubmit` = when `includeLogs` is true, sends
the current ring-buffer snapshot to the frontend-log endpoint to write
`frontend.log`, then appends:
```
---
Context bundle for the agent:
- {metadata path}
- {backend.log path}
- {frontend.log path}
```
- **File (new test)**: `improve-kandev-dialog.test.tsx`
- Bootstrap is called once on open.
- With logs on, the description sent to create-task contains all three
bundle paths.
- With logs off, no bundle reference is appended; the frontend-log endpoint
is not called.
### Step 8 — Top-bar entry point (GREEN)
- **File (modify)**: `apps/web/components/kanban/kanban-header.tsx`
- Add a new `ImproveKandevTopbarButton` component next to
`GitHubTopbarButton` and `JiraTopbarButton` (inside the same
`TooltipProvider`), following the same icon-only `Tooltip` +
`Button variant="outline" size="icon"` pattern.
- Icon: `IconBug` (or similar, e.g. `IconSparkles`) from `@tabler/icons-react`.
- Tooltip text: "Improve kandev".
- Click handler toggles a `useState` boolean that mounts
`<ImproveKandevDialog open={…} onOpenChange={…} onSuccess={…} />`.
- On `onSuccess`, navigate to `/t/{taskId}` so the user lands on the
running task.
- **File (modify)**: `apps/web/components/kanban/kanban-header-mobile.tsx`
- Add the same button to the mobile header for parity (icon-only,
visible alongside the existing icon controls).
- **File (new test)**: `apps/web/components/kanban/kanban-header.test.tsx`
(or extend an existing test) — assert the button renders with its
tooltip and that clicking it opens the dialog.
### Step 9 — Pre-flight `gh auth` check (GREEN)
- **File (modify)**: `improve-kandev-dialog.tsx`
- On mount, call `/api/v1/system/health`; if the GitHub-auth checker reports
failure, render a blocking banner inside the dialog (replacing the form)
explaining the issue and link to the relevant settings.
- Disable submit while the banner is visible.
- **File (new test)**: extends the dialog test to assert that an unhealthy
`gh auth` result blocks submission.
### Step 10 — Verify
- From `apps/`:
- `pnpm --filter @kandev/web test -- logger`
- `pnpm --filter @kandev/web test -- task-create-dialog-extra-slot`
- `pnpm --filter @kandev/web test -- improve-kandev-dialog`
- From `apps/backend`:
- `make test`
- From repo root:
- `make fmt` then `make typecheck test lint` per AGENTS.md.
## 4. File Changes Summary
Created (backend):
- `internal/common/logger/buffer/buffer.go` (+ test).
- `internal/system/handlers/improve_kandev.go` (+ test).
- `config/workflows/improve-kandev.yml`.
- Migration file for `workflows.hidden` column.
Modified (backend):
- `internal/common/logger/logger.go` — wire ring buffer.
- `internal/task/models/models.go` — `Hidden` field on `Workflow`.
- `internal/task/repository/...` — read/write `hidden`.
- `internal/workflow/service/...` — filter hidden in list calls.
- `config/workflows/loader.go` — propagate `hidden` from YAML.
- `cmd/kandev/helpers.go` — register new routes.
Created (frontend):
- `lib/logger/buffer.ts` + `intercept.ts` (+ tests).
- `lib/api/domains/improve-kandev-api.ts`.
- `components/improve-kandev-dialog.tsx` (+ test).
Modified (frontend):
- `app/layout.tsx` — install console interceptor.
- `components/task-create-dialog.tsx` — `extraFormSlot` +
`transformDescriptionBeforeSubmit` props.
- `components/workflow-selector-row.tsx` — filter hidden workflows.
- `app/settings/workspace/workspace-workflows-client.tsx` — filter hidden.
- `components/kanban/kanban-header.tsx` — `ImproveKandevTopbarButton`
with tooltip; mounts `<ImproveKandevDialog>` and routes to `/t/{taskId}`
on success.
- `components/kanban/kanban-header-mobile.tsx` — same button for mobile.
- workflow store types/hydration to keep the `hidden` field.
Deleted: none.
## 5. Testing Strategy
Backend (Go, `*_test.go`):
- `buffer_test.go`: FIFO eviction, concurrency, snapshot isolation.
- Workflow loader test: `improve-kandev.yml` parses with `hidden: true`.
- Workflow repository test: `hidden` round-trips through SQLite.
- Workflow service test: `ListWorkflows` filters hidden by default.
- Improve-kandev handler test: bootstrap is idempotent; bundle-dir
validation rejects paths outside the temp pattern.
Frontend (vitest):
- Logger buffer + interceptor tests.
- TaskCreateDialog extra-slot + transform-before-submit tests.
- ImproveKandevDialog dialog tests (bootstrap, logs on/off, gh auth gating).
Manual smoke:
1. Start kandev. Open Settings → General → Improve kandev.
2. Submit with logs on; verify the new task contains a bundle reference.
3. Watch the agent run on Improve; move to Test; verify URLs appear.
4. Move to PR; verify a PR is opened against `main`.
## 6. Rollback Plan
- Frontend changes are additive (a new page, a new dialog, two optional
dialog props). Reverting the merge restores the previous behavior.
- Backend `workflows.hidden` column is `DEFAULT 0`; existing rows are
unaffected. The migration is reversible by `ALTER TABLE … DROP COLUMN`.
- The `improve-kandev` workflow YAML is only instantiated on first use of
the feature; reverting before any user invokes it leaves no rows behind.
## 7. Estimated Effort
- Complexity: **medium-large** (cross-cutting: backend log infra, schema
change, workflow loader change, dialog extension, new settings page).
- Time: ~2 days including TDD cycles, manual smoke, and CI fixups.
+86
View File
@@ -0,0 +1,86 @@
---
status: draft
created: 2026-04-29
owner: Carlos Florencio
---
# Improve Kandev
## Why
Users who hit a bug or have a feature idea today have no in-app way to report it,
and even when they do, the report sits as text someone else has to act on. Make
filing an improvement a one-click action that produces a real, actionable task
the user's own agent picks up immediately — turning every report into a contribution.
## What
- An icon-only button in the kanban top bar (next to the GitHub and Stats
controls) opens a task-creation dialog that is pre-configured for the
kandev codebase: repository locked to `https://github.com/kdlbs/kandev`,
base branch `main`, workflow set to a hidden `improve-kandev` workflow,
description seeded with a starter template. The button has a tooltip
("Improve kandev") explaining its purpose.
- The dialog reuses the existing task-create UI, including prompt enhancement,
image paste, and file attachments.
- The dialog explains the flow up front: the agent will implement the change,
the user will test it, then the agent opens a PR. Brief copy positions this
as the user contributing to kandev's future.
- An "Include recent logs" toggle (default on) attaches a context bundle to the
task: recent backend logs, frontend logs, and a metadata snapshot. The bundle
lives in a temporary folder and is referenced by file path in the task
description so the agent can read it on demand.
- Submitting the dialog creates the task in the user's active workspace, clones
the kandev repo if needed, and starts the agent on the first step.
- The `improve-kandev` workflow has three manually-advanced steps:
- **Improve** — agent implements the change with TDD; adds E2E tests when the
change touches user-facing flows.
- **Test** — agent runs `make install` then `make dev` (auto ports), reports
the URLs so the user can verify the change in a second kandev instance.
- **PR** — agent invokes the `pr` skill to commit, push, and open a pull
request against `main` in `kdlbs/kandev`.
- The `improve-kandev` workflow is hidden from the workflow management page in
workspace settings and from the workflow picker in the standard task-create
dialog. It is reachable only through the Improve Kandev entry point.
- A pre-flight check surfaces `gh auth` status from `/api/v1/system/health` and
prevents submission with a clear error when GitHub auth is missing.
## Scenarios
- **GIVEN** the user opens the Improve Kandev dialog with the logs checkbox on,
**WHEN** they submit a title and description, **THEN** a task is created in
their active workspace, the description references three files in a temp
folder (`metadata.json`, `backend.log`, `frontend.log`), and the agent starts
on the **Improve** step.
- **GIVEN** the agent reports the implementation is complete on the **Improve**
step, **WHEN** the user moves the task to **Test**, **THEN** the agent
auto-starts with the test step prompt, runs `make install` and `make dev`,
and reports the assigned URLs back to the user.
- **GIVEN** the user has verified the change works, **WHEN** they move the task
to **PR**, **THEN** the agent invokes the `pr` skill and opens a pull request
against `main` in `kdlbs/kandev`.
- **GIVEN** the standard task-create dialog or the workspace workflows settings
page is open, **WHEN** the page lists workflows, **THEN** `improve-kandev`
does not appear.
- **GIVEN** the user has not configured `gh auth`, **WHEN** they open the
Improve Kandev dialog, **THEN** the dialog shows a blocking error referencing
the health-check result and disables the submit button.
## Out of scope
- Automatic transitions between workflow steps (user moves manually).
- Rate limiting, quotas, or one-task-at-a-time guards.
- Log redaction or sensitive-value scrubbing.
- Manual upstream-URL configuration. The user is expected to have `gh`
authenticated; during the PR step, the agent automatically forks
`kdlbs/kandev` to the user's account when they lack write access on the
upstream repo, and pushes directly otherwise. Manual fork/remote setup
remains an optional advanced workflow but is not part of this feature.
- A generic feedback inbox or report archive; this feature produces tasks,
not stored reports.
- Cleanup of the temporary log bundle directory; left to OS/temp policy.
- Windows-specific considerations for `make install` / `make dev`.