feat: add improve kandev in-app contribution flow (#740)
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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 & 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 & 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'd like, and we'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'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(
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 | — |
|
||||
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
Reference in New Issue
Block a user