feat: process runners (#71)

This commit is contained in:
Carlos Florêncio
2026-01-22 16:39:13 +00:00
committed by GitHub
parent b1433a8d45
commit 752e6b4655
63 changed files with 4914 additions and 362 deletions
+32
View File
@@ -2,10 +2,13 @@ package main
import (
"context"
"time"
agentctlclient "github.com/kandev/kandev/internal/agentctl/client"
"github.com/kandev/kandev/internal/agentctl/client/launcher"
"github.com/kandev/kandev/internal/common/config"
"github.com/kandev/kandev/internal/common/logger"
"go.uber.org/zap"
)
func provideAgentctlLauncher(ctx context.Context, cfg *config.Config, log *logger.Logger) (func() error, error) {
@@ -21,3 +24,32 @@ func provideAgentctlLauncher(ctx context.Context, cfg *config.Config, log *logge
}
return cleanup, nil
}
func waitForAgentctlControlHealthy(ctx context.Context, cfg *config.Config, log *logger.Logger) {
if cfg.Agent.Runtime != "standalone" {
return
}
client := agentctlclient.NewControlClient(cfg.Agent.StandaloneHost, cfg.Agent.StandalonePort, log)
healthCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
ticker := time.NewTicker(250 * time.Millisecond)
defer ticker.Stop()
var lastErr error
for {
attemptCtx, attemptCancel := context.WithTimeout(healthCtx, 1*time.Second)
err := client.Health(attemptCtx)
attemptCancel()
if err == nil {
log.Info("agentctl control server is healthy")
return
}
lastErr = err
if healthCtx.Err() != nil {
log.Warn("agentctl control server not ready; skipping resume wait", zap.Error(lastErr))
return
}
<-ticker.C
}
}
+3 -111
View File
@@ -30,7 +30,6 @@ import (
// Agent Manager packages
"github.com/kandev/kandev/internal/agent/docker"
"github.com/kandev/kandev/internal/agent/lifecycle"
agentsettingshandlers "github.com/kandev/kandev/internal/agent/settings/handlers"
editorcontroller "github.com/kandev/kandev/internal/editors/controller"
@@ -291,6 +290,7 @@ func main() {
go gateway.Hub.Run(ctx)
gateways.RegisterTaskNotifications(ctx, eventBus, gateway.Hub, log)
gateways.RegisterUserNotifications(ctx, eventBus, gateway.Hub, log)
gateways.RegisterSessionStreamNotifications(ctx, eventBus, gateway.Hub, log)
notificationSvc = notificationservice.NewService(notificationRepo, taskRepo, gateway.Hub, log)
notificationCtrl = notificationcontroller.NewController(notificationSvc)
@@ -359,6 +359,7 @@ func main() {
})
log.Info("Session data provider configured for session subscriptions (git status)")
waitForAgentctlControlHealthy(ctx, cfg, log)
if err := orchestratorSvc.Start(ctx); err != nil {
log.Fatal("Failed to start orchestrator", zap.Error(err))
}
@@ -468,116 +469,6 @@ func main() {
log.Info("Subscribed to task_session.state_changed events for WebSocket broadcasting")
}
// Subscribe to git status events and broadcast to WebSocket subscribers
_, err = eventBus.Subscribe(events.BuildGitStatusWildcardSubject(), func(ctx context.Context, event *bus.Event) error {
// Extract session_id from the event data
var taskSessionID string
switch data := event.Data.(type) {
case lifecycle.GitStatusEventPayload:
taskSessionID = data.SessionID
case *lifecycle.GitStatusEventPayload:
taskSessionID = data.SessionID
case map[string]interface{}:
taskSessionID, _ = data["session_id"].(string)
}
if taskSessionID == "" {
return nil
}
// Broadcast git status update to session subscribers
notification, _ := ws.NewNotification("session.git.status", event.Data)
gateway.Hub.BroadcastToSession(taskSessionID, notification)
return nil
})
if err != nil {
log.Error("Failed to subscribe to git status events", zap.Error(err))
} else {
log.Info("Subscribed to git status events for WebSocket broadcasting")
}
// Subscribe to file change events and broadcast to WebSocket subscribers
_, err = eventBus.Subscribe(events.BuildFileChangeWildcardSubject(), func(ctx context.Context, event *bus.Event) error {
// Extract session_id from the event data
var taskSessionID string
switch data := event.Data.(type) {
case lifecycle.FileChangeEventPayload:
taskSessionID = data.SessionID
case *lifecycle.FileChangeEventPayload:
taskSessionID = data.SessionID
case map[string]interface{}:
taskSessionID, _ = data["session_id"].(string)
}
if taskSessionID == "" {
return nil
}
// Broadcast file change notification to session subscribers
notification, _ := ws.NewNotification(ws.ActionWorkspaceFileChanges, event.Data)
gateway.Hub.BroadcastToSession(taskSessionID, notification)
return nil
})
if err != nil {
log.Error("Failed to subscribe to file change events", zap.Error(err))
} else {
log.Info("Subscribed to file change events for WebSocket broadcasting")
}
// Subscribe to shell output events and broadcast to WebSocket subscribers
_, err = eventBus.Subscribe(events.BuildShellOutputWildcardSubject(), func(ctx context.Context, event *bus.Event) error {
// Extract session_id from the event data
var taskSessionID string
switch data := event.Data.(type) {
case lifecycle.ShellOutputEventPayload:
taskSessionID = data.SessionID
case *lifecycle.ShellOutputEventPayload:
taskSessionID = data.SessionID
case map[string]interface{}:
taskSessionID, _ = data["session_id"].(string)
}
if taskSessionID == "" {
return nil
}
// Broadcast shell output to session subscribers
notification, _ := ws.NewNotification(ws.ActionSessionShellOutput, event.Data)
gateway.Hub.BroadcastToSession(taskSessionID, notification)
return nil
})
if err != nil {
log.Error("Failed to subscribe to shell output events", zap.Error(err))
} else {
log.Info("Subscribed to shell output events for WebSocket broadcasting")
}
// Subscribe to shell exit events and broadcast to WebSocket subscribers
// Note: Frontend expects shell exit via session.shell.output with type: "exit"
_, err = eventBus.Subscribe(events.BuildShellExitWildcardSubject(), func(ctx context.Context, event *bus.Event) error {
// Extract session_id from the event data
var taskSessionID string
switch data := event.Data.(type) {
case lifecycle.ShellExitEventPayload:
taskSessionID = data.SessionID
case *lifecycle.ShellExitEventPayload:
taskSessionID = data.SessionID
case map[string]interface{}:
taskSessionID, _ = data["session_id"].(string)
}
if taskSessionID == "" {
return nil
}
// Broadcast shell exit to session subscribers via session.shell.output action
// Frontend handles exit events via type: "exit" in the same handler
notification, _ := ws.NewNotification(ws.ActionSessionShellOutput, event.Data)
gateway.Hub.BroadcastToSession(taskSessionID, notification)
return nil
})
if err != nil {
log.Error("Failed to subscribe to shell exit events", zap.Error(err))
} else {
log.Info("Subscribed to shell exit events for WebSocket broadcasting")
}
// ============================================
// HTTP SERVER (WebSocket + HTTP endpoints)
// ============================================
@@ -615,6 +506,7 @@ func main() {
&orchestratorAdapter{svc: orchestratorSvc},
log,
)
taskhandlers.RegisterProcessRoutes(router, taskSvc, lifecycleMgr, log)
log.Info("Registered Task Service handlers (HTTP + WebSocket)")
agentsettingshandlers.RegisterRoutes(router, agentSettingsController, gateway.Hub, log)
@@ -83,6 +83,11 @@ type GitStatusEventPayload struct {
Timestamp string `json:"timestamp"`
}
// GetSessionID returns the session ID for this event (used by event routing).
func (p GitStatusEventPayload) GetSessionID() string {
return p.SessionID
}
// FileChangeEventPayload is the payload for file change notifications.
type FileChangeEventPayload struct {
TaskID string `json:"task_id"`
@@ -93,6 +98,11 @@ type FileChangeEventPayload struct {
Timestamp string `json:"timestamp"`
}
// GetSessionID returns the session ID for this event (used by event routing).
func (p FileChangeEventPayload) GetSessionID() string {
return p.SessionID
}
// PermissionOption represents a single permission option in a permission request.
type PermissionOption struct {
OptionID string `json:"option_id"`
@@ -145,6 +155,40 @@ func (p ShellExitEventPayload) GetSessionID() string {
return p.SessionID
}
// ProcessOutputEventPayload is the payload for process output events.
type ProcessOutputEventPayload struct {
TaskID string `json:"task_id"`
SessionID string `json:"session_id"`
ProcessID string `json:"process_id"`
Kind string `json:"kind"`
Stream string `json:"stream"` // stdout|stderr
Data string `json:"data"`
Timestamp string `json:"timestamp"`
}
// GetSessionID returns the session ID for this event (used by event routing).
func (p ProcessOutputEventPayload) GetSessionID() string {
return p.SessionID
}
// ProcessStatusEventPayload is the payload for process status events.
type ProcessStatusEventPayload struct {
SessionID string `json:"session_id"`
ProcessID string `json:"process_id"`
Kind string `json:"kind"`
ScriptName string `json:"script_name,omitempty"`
Status string `json:"status"`
Command string `json:"command,omitempty"`
WorkingDir string `json:"working_dir,omitempty"`
ExitCode *int `json:"exit_code,omitempty"`
Timestamp string `json:"timestamp"`
}
// GetSessionID returns the session ID for this event (used by event routing).
func (p ProcessStatusEventPayload) GetSessionID() string {
return p.SessionID
}
// ContextWindowEventPayload is the payload for context window update events.
type ContextWindowEventPayload struct {
TaskID string `json:"task_id"`
@@ -343,6 +343,62 @@ func (p *EventPublisher) PublishShellExit(execution *AgentExecution, exitCode in
}
}
// PublishProcessOutput publishes a process output event to the event bus.
func (p *EventPublisher) PublishProcessOutput(execution *AgentExecution, output *agentctl.ProcessOutput) {
if p.eventBus == nil || output == nil {
return
}
payload := ProcessOutputEventPayload{
TaskID: execution.TaskID,
SessionID: output.SessionID,
ProcessID: output.ProcessID,
Kind: string(output.Kind),
Stream: output.Stream,
Data: output.Data,
Timestamp: time.Now().UTC().Format(time.RFC3339Nano),
}
event := bus.NewEvent(events.ProcessOutput, "agent-manager", payload)
subject := events.BuildProcessOutputSubject(output.SessionID)
if err := p.eventBus.Publish(context.Background(), subject, event); err != nil {
p.logger.Error("failed to publish process output event",
zap.String("instance_id", execution.ID),
zap.String("task_id", execution.TaskID),
zap.String("session_id", output.SessionID),
zap.Error(err))
}
}
// PublishProcessStatus publishes a process status event to the event bus.
func (p *EventPublisher) PublishProcessStatus(execution *AgentExecution, status *agentctl.ProcessStatusUpdate) {
if p.eventBus == nil || status == nil {
return
}
payload := ProcessStatusEventPayload{
SessionID: status.SessionID,
ProcessID: status.ProcessID,
Kind: string(status.Kind),
ScriptName: status.ScriptName,
Status: string(status.Status),
Command: status.Command,
WorkingDir: status.WorkingDir,
ExitCode: status.ExitCode,
Timestamp: time.Now().UTC().Format(time.RFC3339Nano),
}
event := bus.NewEvent(events.ProcessStatus, "agent-manager", payload)
subject := events.BuildProcessStatusSubject(status.SessionID)
if err := p.eventBus.Publish(context.Background(), subject, event); err != nil {
p.logger.Error("failed to publish process status event",
zap.String("session_id", status.SessionID),
zap.Error(err))
}
}
// PublishContextWindow publishes a context window update event to the event bus.
func (p *EventPublisher) PublishContextWindow(execution *AgentExecution, size, used, remaining int64, efficiency float64) {
if p.eventBus == nil {
@@ -103,11 +103,13 @@ func NewManager(
// Initialize stream manager with callbacks that delegate to manager methods
mgr.streamManager = NewStreamManager(log, StreamCallbacks{
OnAgentEvent: mgr.handleAgentEvent,
OnGitStatus: mgr.handleGitStatusUpdate,
OnFileChange: mgr.handleFileChangeNotification,
OnShellOutput: mgr.handleShellOutput,
OnShellExit: mgr.handleShellExit,
OnAgentEvent: mgr.handleAgentEvent,
OnGitStatus: mgr.handleGitStatusUpdate,
OnFileChange: mgr.handleFileChangeNotification,
OnShellOutput: mgr.handleShellOutput,
OnShellExit: mgr.handleShellExit,
OnProcessOutput: mgr.handleProcessOutput,
OnProcessStatus: mgr.handleProcessStatus,
})
// Set session manager dependencies for full orchestration
@@ -130,6 +132,117 @@ func (m *Manager) SetWorkspaceInfoProvider(provider WorkspaceInfoProvider) {
m.workspaceInfoProvider = provider
}
// EnsureWorkspaceExecutionForSession ensures an agentctl execution exists for a specific task session.
// This is used when the frontend provides a session ID (e.g., from URL path /task/[id]/[sessionId]).
// If an execution already exists for the session, it returns it. Otherwise, it creates a new execution
// using the session's workspace configuration from the database.
func (m *Manager) EnsureWorkspaceExecutionForSession(ctx context.Context, taskID, sessionID string) (*AgentExecution, error) {
// Check if execution already exists
if execution, exists := m.executionStore.GetBySessionID(sessionID); exists {
return execution, nil
}
// Need to create a new execution - get workspace info for the specific session
if m.workspaceInfoProvider == nil {
return nil, fmt.Errorf("workspace info provider not configured")
}
info, err := m.workspaceInfoProvider.GetWorkspaceInfoForSession(ctx, taskID, sessionID)
if err != nil {
return nil, fmt.Errorf("failed to get workspace info for session %s: %w", sessionID, err)
}
if info.WorkspacePath == "" {
return nil, fmt.Errorf("no workspace path available for session %s", sessionID)
}
m.logger.Info("creating execution for task session",
zap.String("task_id", taskID),
zap.String("session_id", sessionID),
zap.String("workspace_path", info.WorkspacePath),
zap.String("acp_session_id", info.ACPSessionID))
execution, err := m.createExecution(ctx, taskID, info)
if err != nil {
return nil, err
}
// For workspace-only executions (no agent), wait for agentctl to be ready
// then connect the workspace stream so process output can be received
go func() {
// Wait for agentctl to be ready
waitCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
if err := execution.agentctl.WaitForReady(waitCtx, 60*time.Second); err != nil {
m.logger.Error("agentctl not ready for workspace stream connection",
zap.String("execution_id", execution.ID),
zap.Error(err))
return
}
// Connect workspace stream for process output (agent stream not needed for workspace-only)
if m.streamManager != nil {
m.logger.Info("connecting workspace stream for workspace-only execution",
zap.String("execution_id", execution.ID))
go m.streamManager.connectWorkspaceStream(execution)
}
}()
return execution, nil
}
// createExecution creates an agentctl execution.
// The agent subprocess is NOT started - call ConfigureAgent + Start explicitly.
func (m *Manager) createExecution(ctx context.Context, taskID string, info *WorkspaceInfo) (*AgentExecution, error) {
if m.runtime == nil {
return nil, fmt.Errorf("no runtime configured")
}
if info.AgentID == "" {
return nil, fmt.Errorf("agent ID is required in WorkspaceInfo")
}
executionID := uuid.New().String()
agentConfig, err := m.registry.Get(info.AgentID)
if err != nil {
return nil, fmt.Errorf("agent type %q not found in registry: %w", info.AgentID, err)
}
req := &RuntimeCreateRequest{
InstanceID: executionID,
TaskID: taskID,
SessionID: info.SessionID,
AgentProfileID: info.AgentProfileID,
WorkspacePath: info.WorkspacePath,
Protocol: string(agentConfig.Protocol),
}
runtimeInstance, err := m.runtime.CreateInstance(ctx, req)
if err != nil {
return nil, fmt.Errorf("failed to create execution: %w", err)
}
execution := runtimeInstance.ToAgentExecution(req)
// Set the ACP session ID for session resumption
if info.ACPSessionID != "" {
execution.ACPSessionID = info.ACPSessionID
}
m.executionStore.Add(execution)
go m.waitForAgentctlReady(execution)
m.logger.Info("execution created",
zap.String("execution_id", executionID),
zap.String("task_id", taskID),
zap.String("workspace_path", info.WorkspacePath),
zap.String("runtime", string(m.runtime.Name())))
return execution, nil
}
// Start starts the lifecycle manager background tasks
func (m *Manager) Start(ctx context.Context) error {
runtimeName := "none"
@@ -933,6 +1046,34 @@ func (m *Manager) handleShellOutput(execution *AgentExecution, data string) {
m.eventPublisher.PublishShellOutput(execution, data)
}
// handleProcessOutput processes script process output from the workspace stream
func (m *Manager) handleProcessOutput(execution *AgentExecution, output *agentctl.ProcessOutput) {
if output == nil {
return
}
m.logger.Debug("lifecycle received process output",
zap.String("session_id", output.SessionID),
zap.String("process_id", output.ProcessID),
zap.String("kind", string(output.Kind)),
zap.String("stream", output.Stream),
zap.Int("bytes", len(output.Data)),
)
m.eventPublisher.PublishProcessOutput(execution, output)
}
// handleProcessStatus processes script process status updates from the workspace stream
func (m *Manager) handleProcessStatus(execution *AgentExecution, status *agentctl.ProcessStatusUpdate) {
if status == nil {
return
}
m.logger.Debug("lifecycle received process status",
zap.String("session_id", status.SessionID),
zap.String("process_id", status.ProcessID),
zap.String("status", string(status.Status)),
)
m.eventPublisher.PublishProcessStatus(execution, status)
}
// handleShellExit processes shell exit events from the workspace stream
func (m *Manager) handleShellExit(execution *AgentExecution, code int) {
m.eventPublisher.PublishShellExit(execution, code)
@@ -0,0 +1,147 @@
package lifecycle
import (
"context"
"errors"
"fmt"
"time"
agentctl "github.com/kandev/kandev/internal/agentctl/client"
)
type StartProcessRequest struct {
SessionID string
Kind string
ScriptName string
Command string
WorkingDir string
Env map[string]string
}
func (m *Manager) StartProcess(ctx context.Context, req StartProcessRequest) (*agentctl.ProcessInfo, error) {
if req.SessionID == "" {
return nil, fmt.Errorf("session_id is required")
}
execution, ok := m.executionStore.GetBySessionID(req.SessionID)
if !ok {
return nil, fmt.Errorf("no execution found for session %s", req.SessionID)
}
client := execution.GetAgentCtlClient()
if client == nil {
return nil, fmt.Errorf("agentctl client not available for session %s", req.SessionID)
}
return client.StartProcess(ctx, agentctl.StartProcessRequest{
SessionID: req.SessionID,
Kind: agentctl.ProcessKind(req.Kind),
ScriptName: req.ScriptName,
Command: req.Command,
WorkingDir: req.WorkingDir,
Env: req.Env,
})
}
// WaitForAgentctlReadyForSession waits for agentctl to be ready for a session.
func (m *Manager) WaitForAgentctlReadyForSession(ctx context.Context, sessionID string) error {
if sessionID == "" {
return fmt.Errorf("session_id is required")
}
execution, ok := m.executionStore.GetBySessionID(sessionID)
if !ok {
return fmt.Errorf("no execution found for session %s", sessionID)
}
client := execution.GetAgentCtlClient()
if client == nil {
return fmt.Errorf("agentctl client not available for session %s", sessionID)
}
return client.WaitForReady(ctx, 10*time.Second)
}
func (m *Manager) StopProcess(ctx context.Context, processID string) error {
if processID == "" {
return fmt.Errorf("process_id is required")
}
for _, exec := range m.executionStore.List() {
client := exec.GetAgentCtlClient()
if client == nil {
continue
}
if err := client.StopProcess(ctx, processID); err == nil {
return nil
}
}
return fmt.Errorf("process not found: %s", processID)
}
// StopProcessForSession stops a running process by ID within a specific session.
func (m *Manager) StopProcessForSession(ctx context.Context, sessionID, processID string) error {
if sessionID == "" {
return fmt.Errorf("session_id is required")
}
if processID == "" {
return fmt.Errorf("process_id is required")
}
execution, ok := m.executionStore.GetBySessionID(sessionID)
if !ok {
return fmt.Errorf("no execution found for session %s", sessionID)
}
client := execution.GetAgentCtlClient()
if client == nil {
return fmt.Errorf("agentctl client not available for session %s", sessionID)
}
return client.StopProcess(ctx, processID)
}
func (m *Manager) ListProcesses(ctx context.Context, sessionID string) ([]agentctl.ProcessInfo, error) {
if sessionID == "" {
return nil, fmt.Errorf("session_id is required")
}
execution, ok := m.executionStore.GetBySessionID(sessionID)
if !ok {
return nil, fmt.Errorf("no execution found for session %s", sessionID)
}
client := execution.GetAgentCtlClient()
if client == nil {
return nil, fmt.Errorf("agentctl client not available for session %s", sessionID)
}
return client.ListProcesses(ctx, sessionID)
}
func (m *Manager) GetProcess(ctx context.Context, processID string, includeOutput bool) (*agentctl.ProcessInfo, error) {
if processID == "" {
return nil, fmt.Errorf("process_id is required")
}
for _, exec := range m.executionStore.List() {
if exec.GetAgentCtlClient() == nil {
continue
}
proc, err := exec.GetAgentCtlClient().GetProcess(ctx, processID, includeOutput)
if err == nil {
return proc, nil
}
}
return nil, fmt.Errorf("process not found: %s", processID)
}
// StopAllProcesses attempts to stop all running processes across all executions.
func (m *Manager) StopAllProcesses(ctx context.Context) error {
executions := m.executionStore.List()
var errs []error
for _, exec := range executions {
client := exec.GetAgentCtlClient()
if client == nil {
continue
}
procs, err := client.ListProcesses(ctx, exec.SessionID)
if err != nil {
errs = append(errs, err)
continue
}
for _, proc := range procs {
if err := client.StopProcess(ctx, proc.ID); err != nil {
errs = append(errs, err)
}
}
}
return errors.Join(errs...)
}
@@ -0,0 +1,55 @@
package lifecycle
import (
"context"
"testing"
)
func TestProcessRunnerStartErrors(t *testing.T) {
manager := &Manager{executionStore: NewExecutionStore()}
if _, err := manager.StartProcess(context.Background(), StartProcessRequest{
SessionID: "missing",
Kind: "dev",
Command: "echo hi",
}); err == nil {
t.Fatal("expected error when no execution exists for session")
}
exec := &AgentExecution{ID: "exec-1", SessionID: "session-1"}
manager.executionStore.Add(exec)
if _, err := manager.StartProcess(context.Background(), StartProcessRequest{
SessionID: "session-1",
Kind: "dev",
Command: "echo hi",
}); err == nil {
t.Fatal("expected error when agentctl client is missing")
}
}
func TestProcessRunnerListErrors(t *testing.T) {
manager := &Manager{executionStore: NewExecutionStore()}
if _, err := manager.ListProcesses(context.Background(), "missing"); err == nil {
t.Fatal("expected error when no execution exists for session")
}
exec := &AgentExecution{ID: "exec-1", SessionID: "session-1"}
manager.executionStore.Add(exec)
if _, err := manager.ListProcesses(context.Background(), "session-1"); err == nil {
t.Fatal("expected error when agentctl client is missing")
}
}
func TestProcessRunnerStopErrors(t *testing.T) {
manager := &Manager{executionStore: NewExecutionStore()}
if err := manager.StopProcess(context.Background(), "process-1"); err == nil {
t.Fatal("expected error when process not found")
}
}
func TestProcessRunnerGetErrors(t *testing.T) {
manager := &Manager{executionStore: NewExecutionStore()}
if _, err := manager.GetProcess(context.Background(), "process-1", false); err == nil {
t.Fatal("expected error when process not found")
}
}
@@ -12,11 +12,13 @@ import (
// StreamCallbacks defines callbacks for stream events
type StreamCallbacks struct {
OnAgentEvent func(execution *AgentExecution, event agentctl.AgentEvent)
OnGitStatus func(execution *AgentExecution, update *agentctl.GitStatusUpdate)
OnFileChange func(execution *AgentExecution, notification *agentctl.FileChangeNotification)
OnShellOutput func(execution *AgentExecution, data string)
OnShellExit func(execution *AgentExecution, code int)
OnAgentEvent func(execution *AgentExecution, event agentctl.AgentEvent)
OnGitStatus func(execution *AgentExecution, update *agentctl.GitStatusUpdate)
OnFileChange func(execution *AgentExecution, notification *agentctl.FileChangeNotification)
OnShellOutput func(execution *AgentExecution, data string)
OnShellExit func(execution *AgentExecution, code int)
OnProcessOutput func(execution *AgentExecution, output *agentctl.ProcessOutput)
OnProcessStatus func(execution *AgentExecution, status *agentctl.ProcessStatusUpdate)
}
// StreamManager manages WebSocket streams to agent executions
@@ -123,6 +125,16 @@ func (sm *StreamManager) connectWorkspaceStream(execution *AgentExecution) {
sm.callbacks.OnFileChange(execution, notification)
}
},
OnProcessOutput: func(output *agentctl.ProcessOutput) {
if sm.callbacks.OnProcessOutput != nil {
sm.callbacks.OnProcessOutput(execution, output)
}
},
OnProcessStatus: func(status *agentctl.ProcessStatusUpdate) {
if sm.callbacks.OnProcessStatus != nil {
sm.callbacks.OnProcessStatus(execution, status)
}
},
OnConnected: func() {
sm.logger.Debug("workspace stream connected",
zap.String("instance_id", execution.ID))
+188 -7
View File
@@ -264,8 +264,52 @@ type (
ShellMessage = types.ShellMessage
ShellStatusResponse = types.ShellStatusResponse
ShellBufferResponse = types.ShellBufferResponse
ProcessKind = types.ProcessKind
ProcessStatus = types.ProcessStatus
ProcessOutput = types.ProcessOutput
ProcessStatusUpdate = types.ProcessStatusUpdate
)
type StartProcessRequest struct {
SessionID string `json:"session_id"`
Kind ProcessKind `json:"kind"`
ScriptName string `json:"script_name,omitempty"`
Command string `json:"command"`
WorkingDir string `json:"working_dir"`
Env map[string]string `json:"env,omitempty"`
BufferMaxBytes int64 `json:"buffer_max_bytes,omitempty"`
}
type ProcessOutputChunk struct {
Stream string `json:"stream"`
Data string `json:"data"`
Timestamp time.Time `json:"timestamp"`
}
type ProcessInfo struct {
ID string `json:"id"`
SessionID string `json:"session_id"`
Kind ProcessKind `json:"kind"`
ScriptName string `json:"script_name,omitempty"`
Command string `json:"command"`
WorkingDir string `json:"working_dir"`
Status ProcessStatus `json:"status"`
ExitCode *int `json:"exit_code,omitempty"`
StartedAt time.Time `json:"started_at"`
UpdatedAt time.Time `json:"updated_at"`
Output []ProcessOutputChunk `json:"output,omitempty"`
}
type startProcessResponse struct {
Process *ProcessInfo `json:"process,omitempty"`
Error string `json:"error,omitempty"`
}
type stopProcessResponse struct {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
// RequestFileTree requests a file tree via HTTP GET
func (c *Client) RequestFileTree(ctx context.Context, path string, depth int) (*FileTreeResponse, error) {
url := fmt.Sprintf("%s/api/v1/workspace/tree?path=%s&depth=%d", c.baseURL, path, depth)
@@ -387,15 +431,144 @@ func (c *Client) ShellBuffer(ctx context.Context) (string, error) {
return result.Data, nil
}
func (c *Client) StartProcess(ctx context.Context, req StartProcessRequest) (*ProcessInfo, error) {
body, err := json.Marshal(req)
if err != nil {
return nil, err
}
httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/processes/start", bytes.NewReader(body))
if err != nil {
return nil, err
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
respBody, err := readResponseBody(resp)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("start process failed with status %d: %s", resp.StatusCode, string(respBody))
}
var result startProcessResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, fmt.Errorf("failed to parse start process response: %w", err)
}
if result.Process == nil {
if result.Error != "" {
return nil, fmt.Errorf("start process failed: %s", result.Error)
}
return nil, fmt.Errorf("start process failed: no process returned")
}
return result.Process, nil
}
func (c *Client) StopProcess(ctx context.Context, processID string) error {
body, err := json.Marshal(map[string]string{"process_id": processID})
if err != nil {
return err
}
httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL+"/api/v1/processes/stop", bytes.NewReader(body))
if err != nil {
return err
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
respBody, err := readResponseBody(resp)
if err != nil {
return fmt.Errorf("failed to read response body: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("stop process failed with status %d: %s", resp.StatusCode, string(respBody))
}
var result stopProcessResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return fmt.Errorf("failed to parse stop process response: %w", err)
}
if !result.Success {
return fmt.Errorf("stop process failed: %s", result.Error)
}
return nil
}
func (c *Client) ListProcesses(ctx context.Context, sessionID string) ([]ProcessInfo, error) {
url := c.baseURL + "/api/v1/processes"
if sessionID != "" {
url = url + "?session_id=" + sessionID
}
httpReq, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("list processes failed with status %d", resp.StatusCode)
}
var result []ProcessInfo
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result, nil
}
func (c *Client) GetProcess(ctx context.Context, id string, includeOutput bool) (*ProcessInfo, error) {
url := c.baseURL + "/api/v1/processes/" + id
if includeOutput {
url = url + "?include_output=true"
}
httpReq, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("get process failed with status %d", resp.StatusCode)
}
var result ProcessInfo
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return &result, nil
}
// WorkspaceStreamCallbacks defines callbacks for workspace stream events
type WorkspaceStreamCallbacks struct {
OnShellOutput func(data string)
OnShellExit func(code int)
OnGitStatus func(update *GitStatusUpdate)
OnFileChange func(notification *FileChangeNotification)
OnFileList func(update *FileListUpdate)
OnConnected func()
OnError func(err string)
OnShellOutput func(data string)
OnShellExit func(code int)
OnGitStatus func(update *GitStatusUpdate)
OnFileChange func(notification *FileChangeNotification)
OnFileList func(update *FileListUpdate)
OnProcessOutput func(output *types.ProcessOutput)
OnProcessStatus func(status *types.ProcessStatusUpdate)
OnConnected func()
OnError func(err string)
}
// WorkspaceStream represents an active workspace stream connection
@@ -474,6 +647,14 @@ func (c *Client) StreamWorkspace(ctx context.Context, callbacks WorkspaceStreamC
if callbacks.OnFileList != nil && msg.FileList != nil {
callbacks.OnFileList(msg.FileList)
}
case types.WorkspaceMessageTypeProcessOutput:
if callbacks.OnProcessOutput != nil && msg.ProcessOutput != nil {
callbacks.OnProcessOutput(msg.ProcessOutput)
}
case types.WorkspaceMessageTypeProcessStatus:
if callbacks.OnProcessStatus != nil && msg.ProcessStatus != nil {
callbacks.OnProcessStatus(msg.ProcessStatus)
}
case types.WorkspaceMessageTypeConnected:
if callbacks.OnConnected != nil {
callbacks.OnConnected()
@@ -0,0 +1,81 @@
package api
import (
"context"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/kandev/kandev/internal/agentctl/server/process"
"go.uber.org/zap"
)
type startProcessResponse struct {
Process *process.ProcessInfo `json:"process,omitempty"`
Error string `json:"error,omitempty"`
}
type stopProcessResponse struct {
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
func (s *Server) handleStartProcess(c *gin.Context) {
var req process.StartProcessRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, startProcessResponse{Error: "invalid request body"})
return
}
proc, err := s.procMgr.StartProcess(context.Background(), req)
if err != nil {
s.logger.Error("failed to start process", zap.Error(err))
c.JSON(http.StatusBadRequest, startProcessResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, startProcessResponse{Process: proc})
}
func (s *Server) handleStopProcess(c *gin.Context) {
var req process.StopProcessRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, stopProcessResponse{Success: false, Error: "invalid request body"})
return
}
if req.ProcessID == "" {
c.JSON(http.StatusBadRequest, stopProcessResponse{Success: false, Error: "process_id is required"})
return
}
if err := s.procMgr.StopProcess(c.Request.Context(), req); err != nil {
if strings.Contains(err.Error(), "process not found") {
c.JSON(http.StatusOK, stopProcessResponse{Success: true})
return
}
s.logger.Error("failed to stop process", zap.Error(err))
c.JSON(http.StatusInternalServerError, stopProcessResponse{Success: false, Error: err.Error()})
return
}
c.JSON(http.StatusOK, stopProcessResponse{Success: true})
}
func (s *Server) handleListProcesses(c *gin.Context) {
sessionID := c.Query("session_id")
processes := s.procMgr.ListProcesses(sessionID)
c.JSON(http.StatusOK, processes)
}
func (s *Server) handleGetProcess(c *gin.Context) {
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "process id is required"})
return
}
includeOutput := c.Query("include_output") == "true"
proc, ok := s.procMgr.GetProcess(id, includeOutput)
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "process not found"})
return
}
c.JSON(http.StatusOK, proc)
}
@@ -89,6 +89,12 @@ func (s *Server) setupRoutes() {
api.GET("/shell/status", s.handleShellStatus)
api.GET("/shell/buffer", s.handleShellBuffer)
// Process runner
api.POST("/processes/start", s.handleStartProcess)
api.POST("/processes/stop", s.handleStopProcess)
api.GET("/processes", s.handleListProcesses)
api.GET("/processes/:id", s.handleGetProcess)
// Git operations
api.POST("/git/pull", s.handleGitPull)
api.POST("/git/push", s.handleGitPush)
@@ -67,6 +67,9 @@ type InstanceDefaults struct {
// HealthCheckInterval is the interval in seconds for health checks
HealthCheckInterval int
// ProcessBufferMaxBytes is the max bytes per process output buffer (default 2MB)
ProcessBufferMaxBytes int64
}
// McpServerConfig holds configuration for an MCP server.
@@ -130,6 +133,9 @@ type InstanceConfig struct {
// McpServers is a list of MCP servers to configure for the agent
McpServers []McpServerConfig
// ProcessBufferMaxBytes caps per-process output buffer size
ProcessBufferMaxBytes int64
}
// Load loads the configuration from environment variables.
@@ -147,6 +153,7 @@ func Load() *Config {
AutoStart: getEnvBool("AGENTCTL_AUTO_START", false),
AutoApprovePermissions: getEnvBool("AGENTCTL_AUTO_APPROVE_PERMISSIONS", false),
HealthCheckInterval: getEnvInt("AGENTCTL_HEALTH_CHECK_INTERVAL", 5),
ProcessBufferMaxBytes: getEnvInt64("AGENTCTL_PROCESS_BUFFER_MAX_BYTES", 2*1024*1024),
},
ShellEnabled: getEnvBool("AGENTCTL_SHELL_ENABLED", true),
LogLevel: getEnv("AGENTCTL_LOG_LEVEL", "info"),
@@ -167,6 +174,7 @@ func (c *Config) NewInstanceConfig(port int, overrides *InstanceOverrides) *Inst
ShellEnabled: c.ShellEnabled,
LogLevel: c.LogLevel,
LogFormat: c.LogFormat,
ProcessBufferMaxBytes: c.Defaults.ProcessBufferMaxBytes,
}
// Apply overrides if provided
@@ -269,10 +277,18 @@ func getEnvInt(key string, defaultValue int) int {
return defaultValue
}
func getEnvInt64(key string, defaultValue int64) int64 {
if value := os.Getenv(key); value != "" {
if i, err := strconv.ParseInt(value, 10, 64); err == nil {
return i
}
}
return defaultValue
}
func getEnvBool(key string, defaultValue bool) bool {
if value := os.Getenv(key); value != "" {
return strings.ToLower(value) == "true" || value == "1"
}
return defaultValue
}
@@ -3,10 +3,13 @@ package instance
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"strings"
"sync"
"syscall"
"time"
"github.com/google/uuid"
@@ -64,10 +67,36 @@ func (m *Manager) CreateInstance(ctx context.Context, req *CreateRequest) (*Crea
return nil, fmt.Errorf("instance with ID %s already exists", id)
}
// Allocate a port
port, err := m.portAlloc.Allocate(id)
if err != nil {
return nil, fmt.Errorf("failed to allocate port: %w", err)
// Allocate a port and bind a listener to avoid races with address reuse.
var (
port int
listener net.Listener
)
maxAttempts := m.config.Ports.Max - m.config.Ports.Base + 1
for attempt := 0; attempt < maxAttempts; attempt++ {
allocated, err := m.portAlloc.Allocate(id)
if err != nil {
return nil, fmt.Errorf("failed to allocate port: %w", err)
}
addr := fmt.Sprintf(":%d", allocated)
ln, err := net.Listen("tcp", addr)
if err != nil {
if errors.Is(err, syscall.EADDRINUSE) || strings.Contains(err.Error(), "address already in use") {
m.portAlloc.MarkUnavailable(allocated)
m.logger.Warn("port already in use; retrying",
zap.String("instance_id", id),
zap.Int("port", allocated))
continue
}
m.portAlloc.Release(allocated)
return nil, fmt.Errorf("failed to bind instance port %d: %w", allocated, err)
}
port = allocated
listener = ln
break
}
if listener == nil {
return nil, fmt.Errorf("failed to allocate an available port for instance %s", id)
}
// Determine agent command, adding workspace flag if needed
@@ -111,6 +140,10 @@ func (m *Manager) CreateInstance(ctx context.Context, req *CreateRequest) (*Crea
// Create process manager
procMgr := process.NewManager(instanceCfg, m.logger)
// Start workspace tracker immediately so process output can be streamed
// even without an agent running (for dev server, etc.)
procMgr.GetWorkspaceTracker().Start(context.Background())
// Create HTTP handler using factory
var handler http.Handler
if m.serverFactory != nil {
@@ -133,7 +166,7 @@ func (m *Manager) CreateInstance(ctx context.Context, req *CreateRequest) (*Crea
// Start HTTP server in background
go func() {
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
if err := httpServer.Serve(listener); err != nil && err != http.ErrServerClosed {
m.logger.Error("instance server error", zap.String("instance_id", id), zap.Error(err))
}
}()
@@ -11,19 +11,21 @@ import (
// It tracks which ports are in use and provides thread-safe allocation
// and release of ports within a configured range.
type PortAllocator struct {
basePort int
maxPort int
allocated map[int]string // maps port to instance ID
mu sync.Mutex
basePort int
maxPort int
allocated map[int]string // maps port to instance ID
unavailable map[int]struct{}
mu sync.Mutex
}
// NewPortAllocator creates a new PortAllocator that manages ports
// in the range [basePort, maxPort].
func NewPortAllocator(basePort, maxPort int) *PortAllocator {
return &PortAllocator{
basePort: basePort,
maxPort: maxPort,
allocated: make(map[int]string),
basePort: basePort,
maxPort: maxPort,
allocated: make(map[int]string),
unavailable: make(map[int]struct{}),
}
}
@@ -35,6 +37,9 @@ func (p *PortAllocator) Allocate(instanceID string) (int, error) {
defer p.mu.Unlock()
for port := p.basePort; port <= p.maxPort; port++ {
if _, blocked := p.unavailable[port]; blocked {
continue
}
if _, exists := p.allocated[port]; !exists {
p.allocated[port] = instanceID
return port, nil
@@ -53,3 +58,11 @@ func (p *PortAllocator) Release(port int) {
delete(p.allocated, port)
}
// MarkUnavailable prevents a port from being allocated again in this process.
func (p *PortAllocator) MarkUnavailable(port int) {
p.mu.Lock()
defer p.mu.Unlock()
p.unavailable[port] = struct{}{}
delete(p.allocated, port)
}
@@ -75,6 +75,9 @@ type Manager struct {
// Workspace tracker for git status and file changes
workspaceTracker *WorkspaceTracker
// Script/process runner (dev server, setup, cleanup, custom)
processRunner *ProcessRunner
// Embedded shell session (auto-created when agent starts)
shell *shell.Session
@@ -94,11 +97,11 @@ type Manager struct {
gitOperatorMu sync.Mutex
// Synchronization
mu sync.RWMutex
wg sync.WaitGroup
stopCh chan struct{}
doneCh chan struct{}
startMu sync.Mutex
mu sync.RWMutex
wg sync.WaitGroup
stopCh chan struct{}
doneCh chan struct{}
startMu sync.Mutex
}
// NewManager creates a new process manager
@@ -110,6 +113,7 @@ func NewManager(cfg *config.InstanceConfig, log *logger.Logger) *Manager {
updatesCh: make(chan adapter.AgentEvent, 100),
pendingPermissions: make(map[string]*PendingPermission),
}
m.processRunner = NewProcessRunner(m.workspaceTracker, log, cfg.ProcessBufferMaxBytes)
m.status.Store(StatusStopped)
m.exitCode.Store(-1)
return m
@@ -140,6 +144,38 @@ func (m *Manager) GetWorkspaceTracker() *WorkspaceTracker {
return m.workspaceTracker
}
// StartProcess runs a script/process with isolated stdout/stderr.
func (m *Manager) StartProcess(ctx context.Context, req StartProcessRequest) (*ProcessInfo, error) {
if m.processRunner == nil {
return nil, fmt.Errorf("process runner not available")
}
return m.processRunner.Start(ctx, req)
}
// StopProcess stops a running process by ID.
func (m *Manager) StopProcess(ctx context.Context, req StopProcessRequest) error {
if m.processRunner == nil {
return fmt.Errorf("process runner not available")
}
return m.processRunner.Stop(ctx, req)
}
// GetProcess returns a process by ID.
func (m *Manager) GetProcess(id string, includeOutput bool) (*ProcessInfo, bool) {
if m.processRunner == nil {
return nil, false
}
return m.processRunner.Get(id, includeOutput)
}
// ListProcesses returns processes for a session (or all if sessionID empty).
func (m *Manager) ListProcesses(sessionID string) []ProcessInfo {
if m.processRunner == nil {
return nil
}
return m.processRunner.List(sessionID)
}
// GitOperator returns the git operator for git operations.
// The operator is lazy-initialized on first call.
func (m *Manager) GitOperator() *GitOperator {
@@ -429,6 +465,13 @@ func (m *Manager) Stop(ctx context.Context) error {
m.shell = nil
}
// Stop all running workspace processes (dev server, setup, cleanup, custom).
if m.processRunner != nil {
if err := m.processRunner.StopAll(ctx); err != nil {
m.logger.Debug("failed to stop workspace processes", zap.Error(err))
}
}
// Stop workspace tracker
if m.workspaceTracker != nil {
m.workspaceTracker.Stop()
@@ -681,8 +724,6 @@ func (m *Manager) RespondToPermission(pendingID string, optionID string, cancell
}
}
// Shell returns the embedded shell session, or nil if not available
func (m *Manager) Shell() *shell.Session {
m.mu.RLock()
@@ -0,0 +1,437 @@
package process
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
"sync"
"syscall"
"time"
"github.com/google/uuid"
"github.com/kandev/kandev/internal/agentctl/types"
"github.com/kandev/kandev/internal/common/logger"
"go.uber.org/zap"
)
type StartProcessRequest struct {
SessionID string `json:"session_id"`
Kind types.ProcessKind `json:"kind"`
ScriptName string `json:"script_name,omitempty"`
Command string `json:"command"`
WorkingDir string `json:"working_dir"`
Env map[string]string `json:"env,omitempty"`
BufferMaxBytes int64 `json:"buffer_max_bytes,omitempty"`
}
type StopProcessRequest struct {
ProcessID string `json:"process_id"`
}
type ProcessInfo struct {
ID string `json:"id"`
SessionID string `json:"session_id"`
Kind types.ProcessKind `json:"kind"`
ScriptName string `json:"script_name,omitempty"`
Command string `json:"command"`
WorkingDir string `json:"working_dir"`
Status types.ProcessStatus `json:"status"`
ExitCode *int `json:"exit_code,omitempty"`
StartedAt time.Time `json:"started_at"`
UpdatedAt time.Time `json:"updated_at"`
Output []ProcessOutputChunk `json:"output,omitempty"`
}
type ProcessOutputChunk struct {
Stream string `json:"stream"`
Data string `json:"data"`
Timestamp time.Time `json:"timestamp"`
}
type ringBuffer struct {
mu sync.Mutex
maxBytes int64
size int64
chunks []ProcessOutputChunk
}
func newRingBuffer(maxBytes int64) *ringBuffer {
if maxBytes <= 0 {
maxBytes = 2 * 1024 * 1024
}
return &ringBuffer{maxBytes: maxBytes}
}
func (b *ringBuffer) append(chunk ProcessOutputChunk) {
b.mu.Lock()
defer b.mu.Unlock()
b.chunks = append(b.chunks, chunk)
b.size += int64(len(chunk.Data))
for b.size > b.maxBytes && len(b.chunks) > 0 {
removed := b.chunks[0]
b.size -= int64(len(removed.Data))
b.chunks = b.chunks[1:]
}
}
func (b *ringBuffer) snapshot() []ProcessOutputChunk {
b.mu.Lock()
defer b.mu.Unlock()
out := make([]ProcessOutputChunk, len(b.chunks))
copy(out, b.chunks)
return out
}
type commandProcess struct {
info ProcessInfo
cmd *exec.Cmd
buffer *ringBuffer
stopOnce sync.Once
stopSignal chan struct{}
mu sync.Mutex
}
type ProcessRunner struct {
logger *logger.Logger
workspaceTracker *WorkspaceTracker
bufferMaxBytes int64
mu sync.RWMutex
processes map[string]*commandProcess
}
func NewProcessRunner(workspaceTracker *WorkspaceTracker, log *logger.Logger, bufferMaxBytes int64) *ProcessRunner {
return &ProcessRunner{
logger: log.WithFields(zap.String("component", "process-runner")),
workspaceTracker: workspaceTracker,
bufferMaxBytes: bufferMaxBytes,
processes: make(map[string]*commandProcess),
}
}
func (r *ProcessRunner) Start(ctx context.Context, req StartProcessRequest) (*ProcessInfo, error) {
if req.SessionID == "" {
return nil, fmt.Errorf("session_id is required")
}
if req.Command == "" {
return nil, fmt.Errorf("command is required")
}
id := uuid.New().String()
now := time.Now().UTC()
cmd := exec.CommandContext(ctx, "sh", "-lc", req.Command)
if req.WorkingDir != "" {
cmd.Dir = req.WorkingDir
}
cmd.Env = mergeEnv(req.Env)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("failed to attach stdout: %w", err)
}
stderr, err := cmd.StderrPipe()
if err != nil {
return nil, fmt.Errorf("failed to attach stderr: %w", err)
}
bufferMaxBytes := req.BufferMaxBytes
if bufferMaxBytes <= 0 {
bufferMaxBytes = r.bufferMaxBytes
}
proc := &commandProcess{
info: ProcessInfo{
ID: id,
SessionID: req.SessionID,
Kind: req.Kind,
ScriptName: req.ScriptName,
Command: req.Command,
WorkingDir: req.WorkingDir,
Status: types.ProcessStatusStarting,
StartedAt: now,
UpdatedAt: now,
},
cmd: cmd,
buffer: newRingBuffer(bufferMaxBytes),
stopSignal: make(chan struct{}),
}
r.mu.Lock()
r.processes[id] = proc
r.mu.Unlock()
r.logger.Debug("process start requested",
zap.String("process_id", id),
zap.String("session_id", req.SessionID),
zap.String("kind", string(req.Kind)),
zap.String("script_name", req.ScriptName),
zap.String("working_dir", req.WorkingDir),
)
r.publishStatus(proc)
if err := cmd.Start(); err != nil {
proc.mu.Lock()
proc.info.Status = types.ProcessStatusFailed
proc.info.UpdatedAt = time.Now().UTC()
proc.mu.Unlock()
r.publishStatus(proc)
r.mu.Lock()
delete(r.processes, id)
r.mu.Unlock()
return nil, fmt.Errorf("failed to start process: %w", err)
}
// Update status to running after successful start so callers can subscribe immediately.
proc.mu.Lock()
proc.info.Status = types.ProcessStatusRunning
proc.info.UpdatedAt = time.Now().UTC()
proc.mu.Unlock()
r.publishStatus(proc)
// Stream output and wait in the background; wait() is responsible for final status + cleanup.
go r.readOutput(proc, stdout, "stdout")
go r.readOutput(proc, stderr, "stderr")
go r.wait(proc)
info := proc.snapshot(false)
return &info, nil
}
func (r *ProcessRunner) Stop(ctx context.Context, req StopProcessRequest) error {
proc, ok := r.get(req.ProcessID)
if !ok {
return fmt.Errorf("process not found: %s", req.ProcessID)
}
// Signal output readers to exit before attempting to terminate the process.
proc.stopOnce.Do(func() {
close(proc.stopSignal)
})
// Attempt graceful shutdown, then escalate if the context expires.
if proc.cmd != nil && proc.cmd.Process != nil {
pgid, err := syscall.Getpgid(proc.cmd.Process.Pid)
if err == nil {
_ = syscall.Kill(-pgid, syscall.SIGTERM)
} else {
_ = proc.cmd.Process.Signal(syscall.SIGTERM)
}
select {
case <-ctx.Done():
if err == nil {
_ = syscall.Kill(-pgid, syscall.SIGKILL)
} else {
_ = proc.cmd.Process.Kill()
}
case <-time.After(2 * time.Second):
if err == nil {
_ = syscall.Kill(-pgid, syscall.SIGKILL)
} else {
_ = proc.cmd.Process.Kill()
}
}
}
return nil
}
// StopAll stops all running processes managed by this runner.
func (r *ProcessRunner) StopAll(ctx context.Context) error {
r.mu.RLock()
ids := make([]string, 0, len(r.processes))
for id := range r.processes {
ids = append(ids, id)
}
r.mu.RUnlock()
var errs []error
for _, id := range ids {
if err := r.Stop(ctx, StopProcessRequest{ProcessID: id}); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
func (r *ProcessRunner) Get(id string, includeOutput bool) (*ProcessInfo, bool) {
proc, ok := r.get(id)
if !ok {
return nil, false
}
info := proc.snapshot(includeOutput)
return &info, true
}
func (r *ProcessRunner) List(sessionID string) []ProcessInfo {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]ProcessInfo, 0)
for _, proc := range r.processes {
if sessionID != "" && proc.info.SessionID != sessionID {
continue
}
result = append(result, proc.snapshot(false))
}
return result
}
func (r *ProcessRunner) get(id string) (*commandProcess, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
proc, ok := r.processes[id]
return proc, ok
}
func (r *ProcessRunner) readOutput(proc *commandProcess, reader io.ReadCloser, stream string) {
defer func() { _ = reader.Close() }()
buf := bufio.NewReader(reader)
for {
select {
case <-proc.stopSignal:
return
default:
}
data := make([]byte, 4096)
n, err := buf.Read(data)
if n > 0 {
chunk := ProcessOutputChunk{
Stream: stream,
Data: string(data[:n]),
Timestamp: time.Now().UTC(),
}
proc.buffer.append(chunk)
r.publishOutput(proc, chunk)
}
if err != nil {
if err != io.EOF {
r.logger.Debug("process output read error", zap.Error(err))
}
return
}
}
}
func (r *ProcessRunner) wait(proc *commandProcess) {
err := proc.cmd.Wait()
exitCode := 0
status := types.ProcessStatusExited
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
if waitStatus, ok := exitErr.Sys().(syscall.WaitStatus); ok {
exitCode = waitStatus.ExitStatus()
} else {
exitCode = 1
}
} else {
exitCode = 1
}
status = types.ProcessStatusFailed
}
// Final process status is computed here and published once.
r.logger.Debug("process exited",
zap.String("process_id", proc.info.ID),
zap.String("session_id", proc.info.SessionID),
zap.String("status", string(status)),
zap.Int("exit_code", exitCode),
zap.Error(err),
)
proc.mu.Lock()
proc.info.Status = status
proc.info.ExitCode = &exitCode
proc.info.UpdatedAt = time.Now().UTC()
proc.mu.Unlock()
r.publishStatus(proc)
// Remove from the runner map after completion so future lookups don't see stale processes.
// Output is still preserved in the ring buffer returned by GetProcess(includeOutput=true)
// as long as the caller already has the process ID.
r.mu.Lock()
delete(r.processes, proc.info.ID)
r.mu.Unlock()
}
func (r *ProcessRunner) publishOutput(proc *commandProcess, chunk ProcessOutputChunk) {
if r.workspaceTracker == nil {
return
}
proc.mu.Lock()
info := proc.info
proc.mu.Unlock()
output := &types.ProcessOutput{
SessionID: info.SessionID,
ProcessID: info.ID,
Kind: info.Kind,
Stream: chunk.Stream,
Data: chunk.Data,
Timestamp: chunk.Timestamp,
}
r.workspaceTracker.notifyWorkspaceStreamProcessOutput(output)
}
func (r *ProcessRunner) publishStatus(proc *commandProcess) {
if r.workspaceTracker == nil {
return
}
proc.mu.Lock()
info := proc.info
proc.mu.Unlock()
update := &types.ProcessStatusUpdate{
SessionID: info.SessionID,
ProcessID: info.ID,
Kind: info.Kind,
ScriptName: info.ScriptName,
Command: info.Command,
WorkingDir: info.WorkingDir,
Status: info.Status,
ExitCode: info.ExitCode,
Timestamp: time.Now().UTC(),
}
r.logger.Debug("process status update",
zap.String("process_id", info.ID),
zap.String("session_id", info.SessionID),
zap.String("status", string(info.Status)),
)
r.workspaceTracker.notifyWorkspaceStreamProcessStatus(update)
}
func (p *commandProcess) snapshot(includeOutput bool) ProcessInfo {
p.mu.Lock()
defer p.mu.Unlock()
info := p.info
if includeOutput && p.buffer != nil {
info.Output = p.buffer.snapshot()
}
return info
}
func mergeEnv(env map[string]string) []string {
base := make(map[string]string, len(os.Environ())+len(env))
for _, entry := range os.Environ() {
if eq := strings.IndexByte(entry, '='); eq >= 0 {
base[entry[:eq]] = entry[eq+1:]
}
}
for k, v := range env {
base[k] = v
}
merged := make([]string, 0, len(base))
for k, v := range base {
merged = append(merged, fmt.Sprintf("%s=%s", k, v))
}
return merged
}
@@ -0,0 +1,78 @@
package process
import (
"context"
"strings"
"testing"
"time"
"github.com/kandev/kandev/internal/common/logger"
)
func newTestLogger(t *testing.T) *logger.Logger {
log, err := logger.NewLogger(logger.LoggingConfig{
Level: "error",
Format: "json",
})
if err != nil {
t.Fatalf("failed to create logger: %v", err)
}
return log
}
func TestRingBufferTrimsOldest(t *testing.T) {
buffer := newRingBuffer(10)
buffer.append(ProcessOutputChunk{Stream: "stdout", Data: "hello", Timestamp: time.Now()}) // 5
buffer.append(ProcessOutputChunk{Stream: "stdout", Data: "world", Timestamp: time.Now()}) // 5 (total 10)
buffer.append(ProcessOutputChunk{Stream: "stderr", Data: "!!!", Timestamp: time.Now()}) // +3 -> trim
snapshot := buffer.snapshot()
if len(snapshot) == 0 {
t.Fatal("expected buffered output")
}
combined := ""
for _, chunk := range snapshot {
combined += chunk.Data
}
if strings.Contains(combined, "hello") {
t.Fatalf("expected oldest chunk to be trimmed, got %q", combined)
}
if !strings.Contains(combined, "world") {
t.Fatalf("expected newer chunk to remain, got %q", combined)
}
}
func TestProcessRunnerCapturesOutput(t *testing.T) {
log := newTestLogger(t)
runner := NewProcessRunner(nil, log, 2*1024*1024)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
info, err := runner.Start(ctx, StartProcessRequest{
SessionID: "session-1",
Kind: "dev",
Command: "printf 'hello'; sleep 2",
WorkingDir: "",
})
if err != nil {
t.Fatalf("failed to start process: %v", err)
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
proc, ok := runner.Get(info.ID, true)
if !ok {
time.Sleep(25 * time.Millisecond)
continue
}
combined := ""
for _, chunk := range proc.Output {
combined += chunk.Data
}
if strings.Contains(combined, "hello") {
return
}
time.Sleep(25 * time.Millisecond)
}
t.Fatal("process output not captured in time")
}
@@ -40,8 +40,9 @@ type WorkspaceTracker struct {
fsChangeTrigger chan struct{}
// Control
stopCh chan struct{}
wg sync.WaitGroup
stopCh chan struct{}
wg sync.WaitGroup
started bool
}
// NewWorkspaceTracker creates a new workspace tracker
@@ -64,6 +65,15 @@ func NewWorkspaceTracker(workDir string, log *logger.Logger) *WorkspaceTracker {
// Start begins monitoring the workspace
func (wt *WorkspaceTracker) Start(ctx context.Context) {
wt.mu.Lock()
if wt.started {
wt.mu.Unlock()
wt.logger.Debug("workspace tracker already started, skipping")
return
}
wt.started = true
wt.mu.Unlock()
wt.wg.Add(1)
go wt.monitorLoop(ctx)
@@ -230,7 +240,7 @@ func (wt *WorkspaceTracker) getGitStatus(ctx context.Context) (types.GitStatusUp
continue
}
indexStatus := line[0] // Staged status (X)
indexStatus := line[0] // Staged status (X)
workTreeStatus := line[1] // Unstaged status (Y)
filePath := strings.TrimSpace(line[3:])
@@ -479,8 +489,6 @@ func (wt *WorkspaceTracker) getFileList(ctx context.Context) (types.FileListUpda
return update, nil
}
// Unified workspace stream subscriber management methods
// SubscribeWorkspaceStream creates a new unified workspace stream subscriber
@@ -490,7 +498,9 @@ func (wt *WorkspaceTracker) SubscribeWorkspaceStream() types.WorkspaceStreamSubs
wt.workspaceSubMu.Lock()
wt.workspaceStreamSubscribers[sub] = struct{}{}
count := len(wt.workspaceStreamSubscribers)
wt.workspaceSubMu.Unlock()
wt.logger.Info("workspace stream subscriber added", zap.Int("subscribers", count))
// Send current git status immediately
wt.mu.RLock()
@@ -524,8 +534,10 @@ func (wt *WorkspaceTracker) SubscribeWorkspaceStream() types.WorkspaceStreamSubs
func (wt *WorkspaceTracker) UnsubscribeWorkspaceStream(sub types.WorkspaceStreamSubscriber) {
wt.workspaceSubMu.Lock()
delete(wt.workspaceStreamSubscribers, sub)
count := len(wt.workspaceStreamSubscribers)
wt.workspaceSubMu.Unlock()
close(sub)
wt.logger.Info("workspace stream subscriber removed", zap.Int("subscribers", count))
}
// Notification methods for unified workspace stream
@@ -575,6 +587,48 @@ func (wt *WorkspaceTracker) notifyWorkspaceStreamFileList(update types.FileListU
}
}
// notifyWorkspaceStreamProcessOutput sends process output to all workspace stream subscribers
func (wt *WorkspaceTracker) notifyWorkspaceStreamProcessOutput(output *types.ProcessOutput) {
wt.workspaceSubMu.RLock()
defer wt.workspaceSubMu.RUnlock()
msg := types.NewWorkspaceProcessOutput(output)
wt.logger.Debug("broadcast process output",
zap.String("session_id", output.SessionID),
zap.String("process_id", output.ProcessID),
zap.String("kind", string(output.Kind)),
zap.Int("subscribers", len(wt.workspaceStreamSubscribers)),
)
for sub := range wt.workspaceStreamSubscribers {
select {
case sub <- msg:
default:
// Subscriber is slow, skip
}
}
}
// notifyWorkspaceStreamProcessStatus sends process status updates to all workspace stream subscribers
func (wt *WorkspaceTracker) notifyWorkspaceStreamProcessStatus(status *types.ProcessStatusUpdate) {
wt.workspaceSubMu.RLock()
defer wt.workspaceSubMu.RUnlock()
msg := types.NewWorkspaceProcessStatus(status)
wt.logger.Debug("broadcast process status",
zap.String("session_id", status.SessionID),
zap.String("process_id", status.ProcessID),
zap.String("status", string(status.Status)),
zap.Int("subscribers", len(wt.workspaceStreamSubscribers)),
)
for sub := range wt.workspaceStreamSubscribers {
select {
case sub <- msg:
default:
// Subscriber is slow, skip
}
}
}
// watchFilesystem watches for filesystem changes and triggers debounced updates
func (wt *WorkspaceTracker) watchFilesystem(ctx context.Context) {
defer wt.wg.Done()
@@ -755,5 +809,3 @@ func (wt *WorkspaceTracker) GetFileContent(reqPath string) (string, int64, error
return string(content), info.Size(), nil
}
@@ -0,0 +1,47 @@
package streams
import "time"
// ProcessKind identifies which repository script kind is running.
type ProcessKind string
const (
ProcessKindSetup ProcessKind = "setup"
ProcessKindCleanup ProcessKind = "cleanup"
ProcessKindDev ProcessKind = "dev"
ProcessKindCustom ProcessKind = "custom"
)
// ProcessStatus represents the lifecycle status of a process.
type ProcessStatus string
const (
ProcessStatusStarting ProcessStatus = "starting"
ProcessStatusRunning ProcessStatus = "running"
ProcessStatusExited ProcessStatus = "exited"
ProcessStatusFailed ProcessStatus = "failed"
ProcessStatusStopped ProcessStatus = "stopped"
)
// ProcessOutput carries stdout/stderr output from a process.
type ProcessOutput struct {
SessionID string `json:"session_id"`
ProcessID string `json:"process_id"`
Kind ProcessKind `json:"kind"`
Stream string `json:"stream"` // stdout|stderr
Data string `json:"data"`
Timestamp time.Time `json:"timestamp"`
}
// ProcessStatusUpdate carries status updates for a process.
type ProcessStatusUpdate struct {
SessionID string `json:"session_id"`
ProcessID string `json:"process_id"`
Kind ProcessKind `json:"kind"`
ScriptName string `json:"script_name,omitempty"`
Command string `json:"command"`
WorkingDir string `json:"working_dir"`
Status ProcessStatus `json:"status"`
ExitCode *int `json:"exit_code,omitempty"`
Timestamp time.Time `json:"timestamp"`
}
+48 -11
View File
@@ -42,6 +42,12 @@ type (
ShellMessage = streams.ShellMessage
ShellStatusResponse = streams.ShellStatusResponse
ShellBufferResponse = streams.ShellBufferResponse
// Process stream types
ProcessKind = streams.ProcessKind
ProcessStatus = streams.ProcessStatus
ProcessOutput = streams.ProcessOutput
ProcessStatusUpdate = streams.ProcessStatusUpdate
)
// Re-export stream constants for convenience.
@@ -77,6 +83,13 @@ const (
ShellMsgTypePing = streams.ShellMsgTypePing
ShellMsgTypePong = streams.ShellMsgTypePong
ShellMsgTypeExit = streams.ShellMsgTypeExit
// Process statuses
ProcessStatusStarting = streams.ProcessStatusStarting
ProcessStatusRunning = streams.ProcessStatusRunning
ProcessStatusExited = streams.ProcessStatusExited
ProcessStatusFailed = streams.ProcessStatusFailed
ProcessStatusStopped = streams.ProcessStatusStopped
)
// PermissionRequest represents a permission request from the agent.
@@ -127,17 +140,19 @@ type McpServer struct {
const (
// Workspace stream message types
WorkspaceMessageTypeShellOutput WorkspaceMessageType = "shell_output"
WorkspaceMessageTypeShellInput WorkspaceMessageType = "shell_input"
WorkspaceMessageTypeShellExit WorkspaceMessageType = "shell_exit"
WorkspaceMessageTypePing WorkspaceMessageType = "ping"
WorkspaceMessageTypePong WorkspaceMessageType = "pong"
WorkspaceMessageTypeGitStatus WorkspaceMessageType = "git_status"
WorkspaceMessageTypeFileChange WorkspaceMessageType = "file_change"
WorkspaceMessageTypeFileList WorkspaceMessageType = "file_list"
WorkspaceMessageTypeError WorkspaceMessageType = "error"
WorkspaceMessageTypeConnected WorkspaceMessageType = "connected"
WorkspaceMessageTypeShellResize WorkspaceMessageType = "shell_resize"
WorkspaceMessageTypeShellOutput WorkspaceMessageType = "shell_output"
WorkspaceMessageTypeShellInput WorkspaceMessageType = "shell_input"
WorkspaceMessageTypeShellExit WorkspaceMessageType = "shell_exit"
WorkspaceMessageTypePing WorkspaceMessageType = "ping"
WorkspaceMessageTypePong WorkspaceMessageType = "pong"
WorkspaceMessageTypeGitStatus WorkspaceMessageType = "git_status"
WorkspaceMessageTypeFileChange WorkspaceMessageType = "file_change"
WorkspaceMessageTypeFileList WorkspaceMessageType = "file_list"
WorkspaceMessageTypeError WorkspaceMessageType = "error"
WorkspaceMessageTypeConnected WorkspaceMessageType = "connected"
WorkspaceMessageTypeShellResize WorkspaceMessageType = "shell_resize"
WorkspaceMessageTypeProcessOutput WorkspaceMessageType = "process_output"
WorkspaceMessageTypeProcessStatus WorkspaceMessageType = "process_status"
)
// WorkspaceStreamMessage is the unified message format for the workspace stream.
@@ -164,6 +179,10 @@ type WorkspaceStreamMessage struct {
// File list fields (for file_list)
FileList *FileListUpdate `json:"file_list,omitempty"`
// Process fields (for process_output, process_status)
ProcessOutput *ProcessOutput `json:"process_output,omitempty"`
ProcessStatus *ProcessStatusUpdate `json:"process_status,omitempty"`
// Error fields (for error)
Error string `json:"error,omitempty"`
}
@@ -213,6 +232,24 @@ func NewWorkspaceFileList(update *FileListUpdate) WorkspaceStreamMessage {
}
}
// NewWorkspaceProcessOutput creates a process output message
func NewWorkspaceProcessOutput(output *ProcessOutput) WorkspaceStreamMessage {
return WorkspaceStreamMessage{
Type: WorkspaceMessageTypeProcessOutput,
Timestamp: timeNowUnixMilli(),
ProcessOutput: output,
}
}
// NewWorkspaceProcessStatus creates a process status message
func NewWorkspaceProcessStatus(status *ProcessStatusUpdate) WorkspaceStreamMessage {
return WorkspaceStreamMessage{
Type: WorkspaceMessageTypeProcessStatus,
Timestamp: timeNowUnixMilli(),
ProcessStatus: status,
}
}
// NewWorkspacePong creates a pong message
func NewWorkspacePong() WorkspaceStreamMessage {
return WorkspaceStreamMessage{
+26
View File
@@ -116,6 +116,12 @@ const (
ShellExit = "shell.exit" // Shell process exited
)
// Event types for dev server I/O
const (
ProcessOutput = "process.output" // Process output data
ProcessStatus = "process.status" // Process status updates
)
// Event types for context window
const (
ContextWindowUpdated = "context_window.updated" // Context window usage updated
@@ -141,6 +147,26 @@ func BuildShellExitWildcardSubject() string {
return ShellExit + ".*"
}
// BuildProcessOutputSubject creates a process output subject for a specific session
func BuildProcessOutputSubject(sessionID string) string {
return ProcessOutput + "." + sessionID
}
// BuildProcessOutputWildcardSubject creates a wildcard subject for all process output events
func BuildProcessOutputWildcardSubject() string {
return ProcessOutput + ".*"
}
// BuildProcessStatusSubject creates a process status subject for a specific session
func BuildProcessStatusSubject(sessionID string) string {
return ProcessStatus + "." + sessionID
}
// BuildProcessStatusWildcardSubject creates a wildcard subject for all process status events
func BuildProcessStatusWildcardSubject() string {
return ProcessStatus + ".*"
}
// BuildAgentStreamSubject creates an agent stream subject for a specific session
func BuildAgentStreamSubject(sessionID string) string {
return AgentStream + "." + sessionID
@@ -0,0 +1,86 @@
package websocket
import (
"context"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
ws "github.com/kandev/kandev/pkg/websocket"
"go.uber.org/zap"
)
type SessionStreamBroadcaster struct {
hub *Hub
subscriptions []bus.Subscription
logger *logger.Logger
}
func RegisterSessionStreamNotifications(ctx context.Context, eventBus bus.EventBus, hub *Hub, log *logger.Logger) *SessionStreamBroadcaster {
b := &SessionStreamBroadcaster{
hub: hub,
logger: log.WithFields(zap.String("component", "ws-session-stream-broadcaster")),
}
if eventBus == nil {
return b
}
b.subscribe(eventBus, events.BuildGitStatusWildcardSubject(), ws.ActionSessionGitStatus)
b.subscribe(eventBus, events.BuildFileChangeWildcardSubject(), ws.ActionWorkspaceFileChanges)
b.subscribe(eventBus, events.BuildShellOutputWildcardSubject(), ws.ActionSessionShellOutput)
b.subscribe(eventBus, events.BuildShellExitWildcardSubject(), ws.ActionSessionShellOutput)
b.subscribe(eventBus, events.BuildProcessOutputWildcardSubject(), ws.ActionSessionProcessOutput)
b.subscribe(eventBus, events.BuildProcessStatusWildcardSubject(), ws.ActionSessionProcessStatus)
go func() {
<-ctx.Done()
b.Close()
}()
return b
}
func (b *SessionStreamBroadcaster) Close() {
for _, sub := range b.subscriptions {
if sub != nil && sub.IsValid() {
_ = sub.Unsubscribe()
}
}
b.subscriptions = nil
}
func (b *SessionStreamBroadcaster) subscribe(eventBus bus.EventBus, subject, action string) {
sub, err := eventBus.Subscribe(subject, func(ctx context.Context, event *bus.Event) error {
sessionID := extractSessionID(event.Data)
if sessionID == "" {
return nil
}
msg, err := ws.NewNotification(action, event.Data)
if err != nil {
b.logger.Error("failed to build websocket notification", zap.String("action", action), zap.Error(err))
return nil
}
b.hub.BroadcastToSession(sessionID, msg)
return nil
})
if err != nil {
b.logger.Error("failed to subscribe to events", zap.String("subject", subject), zap.Error(err))
return
}
b.subscriptions = append(b.subscriptions, sub)
}
func extractSessionID(data interface{}) string {
if data == nil {
return ""
}
if typed, ok := data.(interface{ GetSessionID() string }); ok {
return typed.GetSessionID()
}
if m, ok := data.(map[string]interface{}); ok {
if sessionID, ok := m["session_id"].(string); ok {
return sessionID
}
}
return ""
}
+27 -38
View File
@@ -76,7 +76,7 @@ type Service struct {
queue *queue.TaskQueue
executor *executor.Executor
scheduler *scheduler.Scheduler
watcher *watcher.Watcher
watcher *watcher.Watcher
// Message creator for saving agent responses
messageCreator MessageCreator
@@ -131,15 +131,15 @@ func NewService(
// Create the service (watcher will be created after we have handlers)
s := &Service{
config: cfg,
logger: svcLogger,
eventBus: eventBus,
taskRepo: taskRepo,
repo: repo,
agentManager: agentManager,
queue: taskQueue,
executor: exec,
scheduler: sched,
config: cfg,
logger: svcLogger,
eventBus: eventBus,
taskRepo: taskRepo,
repo: repo,
agentManager: agentManager,
queue: taskQueue,
executor: exec,
scheduler: sched,
}
// Create the watcher with event handlers that wire everything together
@@ -355,26 +355,13 @@ func (s *Service) resumeExecutorsOnStartup(ctx context.Context) {
continue
}
// For sessions that were STARTING, RUNNING, or WAITING_FOR_INPUT:
// We need a resume token to recover them
if running.ResumeToken == "" {
s.logger.Warn("no resume token available; marking session as failed",
startAgent := running.ResumeToken != "" && running.Resumable
if !startAgent {
s.logger.Warn("resuming workspace without agent session",
zap.String("session_id", sessionID),
zap.String("previous_state", string(previousState)))
_ = s.repo.UpdateTaskSessionState(ctx, sessionID, models.TaskSessionStateFailed,
"backend restarted and session cannot be resumed (no resume token)")
_ = s.repo.DeleteExecutorRunningBySessionID(ctx, sessionID)
continue
}
// Check if executor supports resume
if !running.Resumable {
s.logger.Warn("executor not resumable; marking session as failed",
zap.String("session_id", sessionID))
_ = s.repo.UpdateTaskSessionState(ctx, sessionID, models.TaskSessionStateFailed,
"executor does not support session resume")
_ = s.repo.DeleteExecutorRunningBySessionID(ctx, sessionID)
continue
zap.String("previous_state", string(previousState)),
zap.Bool("has_resume_token", running.ResumeToken != ""),
zap.Bool("resumable", running.Resumable))
}
// Validate worktree exists before resuming
@@ -391,7 +378,8 @@ func (s *Service) resumeExecutorsOnStartup(ctx context.Context) {
zap.String("session_id", sessionID),
zap.String("task_id", running.TaskID),
zap.String("previous_state", string(previousState)),
zap.String("resume_token", running.ResumeToken))
zap.String("resume_token", running.ResumeToken),
zap.Bool("start_agent", startAgent))
// Resume the session - this will:
// 1. Create a new agentctl instance
@@ -404,7 +392,7 @@ func (s *Service) resumeExecutorsOnStartup(ctx context.Context) {
// - Session resume just loads conversation history, doesn't process a prompt
// - Agents don't send a "complete" event after session load (only after prompt completion)
// - The agent is ready for the next user prompt immediately after session loads
if _, err := s.executor.ResumeSession(ctx, session, true); err != nil {
if _, err := s.executor.ResumeSession(ctx, session, startAgent); err != nil {
s.logger.Warn("failed to resume executor on startup",
zap.String("session_id", sessionID),
zap.Error(err))
@@ -413,16 +401,19 @@ func (s *Service) resumeExecutorsOnStartup(ctx context.Context) {
continue
}
// Set state to WAITING_FOR_INPUT - agent is ready for the next prompt
if err := s.repo.UpdateTaskSessionState(ctx, sessionID, models.TaskSessionStateWaitingForInput, ""); err != nil {
s.logger.Warn("failed to set session state to WAITING_FOR_INPUT after resume",
zap.String("session_id", sessionID),
zap.Error(err))
if startAgent {
// Set state to WAITING_FOR_INPUT - agent is ready for the next prompt
if err := s.repo.UpdateTaskSessionState(ctx, sessionID, models.TaskSessionStateWaitingForInput, ""); err != nil {
s.logger.Warn("failed to set session state to WAITING_FOR_INPUT after resume",
zap.String("session_id", sessionID),
zap.Error(err))
}
}
s.logger.Info("session resumed successfully",
zap.String("session_id", sessionID),
zap.String("task_id", running.TaskID),
zap.Bool("start_agent", startAgent),
zap.String("state", string(models.TaskSessionStateWaitingForInput)))
}
}
@@ -458,5 +449,3 @@ func (s *Service) GetStatus() *Status {
LastHeartbeat: time.Now(),
}
}
@@ -53,6 +53,7 @@ func (c *RepositoryController) CreateRepository(ctx context.Context, req dto.Cre
WorktreeBranchPrefix: req.WorktreeBranchPrefix,
SetupScript: req.SetupScript,
CleanupScript: req.CleanupScript,
DevScript: req.DevScript,
})
if err != nil {
return dto.RepositoryDTO{}, err
@@ -73,6 +74,7 @@ func (c *RepositoryController) UpdateRepository(ctx context.Context, req dto.Upd
WorktreeBranchPrefix: req.WorktreeBranchPrefix,
SetupScript: req.SetupScript,
CleanupScript: req.CleanupScript,
DevScript: req.DevScript,
})
if err != nil {
return dto.RepositoryDTO{}, err
+2
View File
@@ -54,6 +54,7 @@ type RepositoryDTO struct {
WorktreeBranchPrefix string `json:"worktree_branch_prefix"`
SetupScript string `json:"setup_script"`
CleanupScript string `json:"cleanup_script"`
DevScript string `json:"dev_script"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
@@ -307,6 +308,7 @@ func FromRepository(repository *models.Repository) RepositoryDTO {
WorktreeBranchPrefix: repository.WorktreeBranchPrefix,
SetupScript: repository.SetupScript,
CleanupScript: repository.CleanupScript,
DevScript: repository.DevScript,
CreatedAt: repository.CreatedAt,
UpdatedAt: repository.UpdatedAt,
}
@@ -102,6 +102,7 @@ type CreateRepositoryRequest struct {
WorktreeBranchPrefix string
SetupScript string
CleanupScript string
DevScript string
}
type UpdateRepositoryRequest struct {
@@ -117,6 +118,7 @@ type UpdateRepositoryRequest struct {
WorktreeBranchPrefix *string
SetupScript *string
CleanupScript *string
DevScript *string
}
type DeleteRepositoryRequest struct {
@@ -0,0 +1,456 @@
package handlers
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/agent/lifecycle"
agentctlclient "github.com/kandev/kandev/internal/agentctl/client"
agentctltypes "github.com/kandev/kandev/internal/agentctl/types"
"github.com/kandev/kandev/internal/agentctl/types/streams"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/service"
)
type ProcessHandlers struct {
service *service.Service
lifecycleMgr *lifecycle.Manager
logger *logger.Logger
}
func RegisterProcessRoutes(
router *gin.Engine,
svc *service.Service,
lifecycleMgr *lifecycle.Manager,
log *logger.Logger,
) {
handlers := &ProcessHandlers{
service: svc,
lifecycleMgr: lifecycleMgr,
logger: log.WithFields(zap.String("component", "task-process-handlers")),
}
api := router.Group("/api/v1")
processes := api.Group("/task-sessions/:id/processes")
processes.Use(func(c *gin.Context) {
start := time.Now()
c.Next()
handlers.logger.Debug("process route",
zap.String("method", c.Request.Method),
zap.String("path", c.Request.URL.Path),
zap.String("route", c.FullPath()),
zap.Int("status", c.Writer.Status()),
zap.Duration("duration", time.Since(start)),
)
})
processes.POST("/start", handlers.httpStartProcess)
processes.POST("/:processId/stop", handlers.httpStopProcessByID)
processes.GET("", handlers.httpListProcesses)
processes.GET("/:processId", handlers.httpGetProcess)
}
type httpStartProcessRequest struct {
Kind string `json:"kind"`
ScriptName string `json:"script_name,omitempty"`
RepositoryID string `json:"repo_id,omitempty"`
}
func (h *ProcessHandlers) httpStartProcess(c *gin.Context) {
sessionID := c.Param("id")
if sessionID == "" {
h.logger.Warn("start process missing session id")
c.JSON(http.StatusBadRequest, gin.H{"error": "session_id is required"})
return
}
var body httpStartProcessRequest
if err := c.ShouldBindJSON(&body); err != nil {
h.logger.Warn("start process invalid request body", zap.String("session_id", sessionID), zap.Error(err))
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
h.logger.Debug("start process request",
zap.String("session_id", sessionID),
zap.String("kind", body.Kind),
zap.String("script_name", body.ScriptName),
zap.String("repo_id", body.RepositoryID),
)
session, err := h.service.GetTaskSession(c.Request.Context(), sessionID)
if err != nil {
h.logger.Warn("start process session not found", zap.String("session_id", sessionID), zap.Error(err))
handleNotFound(c, h.logger, err, "task session not found")
return
}
executorID := session.ExecutorID
// TODO: fix session, should always have executorID
if executorID == "" {
if task, err := h.service.GetTask(c.Request.Context(), session.TaskID); err == nil {
if workspace, err := h.service.GetWorkspace(c.Request.Context(), task.WorkspaceID); err == nil {
if workspace.DefaultExecutorID != nil {
executorID = *workspace.DefaultExecutorID
}
}
}
if executorID == "" {
executorID = models.ExecutorIDLocalPC
}
}
executor, err := h.service.GetExecutor(c.Request.Context(), executorID)
if err != nil {
h.logger.Warn("start process executor not found",
zap.String("session_id", sessionID),
zap.String("executor_id", executorID),
zap.Error(err),
)
handleNotFound(c, h.logger, err, "executor not found")
return
}
repoID := body.RepositoryID
if repoID == "" {
repoID = session.RepositoryID
}
if repoID == "" {
if task, err := h.service.GetTask(c.Request.Context(), session.TaskID); err == nil {
if len(task.Repositories) > 0 {
repoID = task.Repositories[0].RepositoryID
}
}
}
if repoID == "" {
h.logger.Warn("start process missing repository",
zap.String("session_id", sessionID),
zap.String("task_id", session.TaskID),
)
c.JSON(http.StatusBadRequest, gin.H{"error": "session has no repository"})
return
}
repo, err := h.service.GetRepository(c.Request.Context(), repoID)
if err != nil {
h.logger.Warn("start process repository not found",
zap.String("session_id", sessionID),
zap.String("repo_id", repoID),
zap.Error(err),
)
handleNotFound(c, h.logger, err, "repository not found")
return
}
command, kind, scriptName, err := resolveScriptCommand(c.Request.Context(), h.service, repo, body.Kind, body.ScriptName)
if err != nil {
h.logger.Warn("start process script resolution failed",
zap.String("session_id", sessionID),
zap.String("repo_id", repoID),
zap.String("kind", body.Kind),
zap.String("script_name", body.ScriptName),
zap.Error(err),
)
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
workingDir := repo.LocalPath
if len(session.Worktrees) > 0 && session.Worktrees[0].WorktreePath != "" {
workingDir = session.Worktrees[0].WorktreePath
}
h.logger.Info("start process resolved command",
zap.String("session_id", sessionID),
zap.String("repo_id", repoID),
zap.String("kind", kind),
zap.String("script_name", scriptName),
zap.String("working_dir", workingDir),
zap.String("command", command),
)
if _, err := h.lifecycleMgr.EnsureWorkspaceExecutionForSession(
c.Request.Context(),
session.TaskID,
session.ID,
); err != nil {
h.logger.Error("failed to ensure workspace execution for process", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
readyCtx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second)
defer cancel()
if err := h.lifecycleMgr.WaitForAgentctlReadyForSession(readyCtx, session.ID); err != nil {
h.logger.Warn("agentctl not ready for process start",
zap.String("session_id", sessionID),
zap.Error(err),
)
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "agentctl not ready"})
return
}
switch executor.Type {
case models.ExecutorTypeLocalPC, models.ExecutorTypeLocalDocker:
if streams.ProcessKind(kind) == streams.ProcessKindDev {
if existing, err := h.lifecycleMgr.ListProcesses(c.Request.Context(), sessionID); err == nil {
for _, proc := range existing {
if proc.Kind != streams.ProcessKindDev {
continue
}
if proc.Status == agentctltypes.ProcessStatusRunning || proc.Status == agentctltypes.ProcessStatusStarting {
c.JSON(http.StatusOK, gin.H{"process": proc})
return
}
}
} else {
h.logger.Warn("failed to list processes for dev check",
zap.String("session_id", sessionID),
zap.Error(err),
)
}
}
proc, err := h.lifecycleMgr.StartProcess(c.Request.Context(), lifecycle.StartProcessRequest{
SessionID: sessionID,
Kind: kind,
ScriptName: scriptName,
Command: command,
WorkingDir: workingDir,
})
if err != nil {
h.logger.Error("failed to start process",
zap.String("session_id", sessionID),
zap.String("repo_id", repoID),
zap.String("kind", kind),
zap.String("script_name", scriptName),
zap.Error(err),
)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
h.logger.Info("process started",
zap.String("session_id", sessionID),
zap.String("process_id", proc.ID),
zap.String("kind", kind),
zap.String("script_name", scriptName),
)
c.JSON(http.StatusOK, gin.H{"process": proc})
default:
h.logger.Warn("start process unsupported executor type",
zap.String("session_id", sessionID),
zap.String("executor_id", executorID),
zap.String("executor_type", string(executor.Type)),
)
c.JSON(http.StatusBadRequest, gin.H{"error": "executor type not supported for process runner"})
}
}
func (h *ProcessHandlers) httpStopProcessByID(c *gin.Context) {
sessionID := c.Param("id")
processID := c.Param("processId")
if sessionID == "" || processID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "session_id and process_id are required"})
return
}
h.logger.Debug("stop process request (path)",
zap.String("session_id", sessionID),
zap.String("process_id", processID),
)
session, err := h.service.GetTaskSession(c.Request.Context(), sessionID)
if err != nil {
handleNotFound(c, h.logger, err, "task session not found")
return
}
executorID := session.ExecutorID
if executorID == "" {
if task, err := h.service.GetTask(c.Request.Context(), session.TaskID); err == nil {
if workspace, err := h.service.GetWorkspace(c.Request.Context(), task.WorkspaceID); err == nil {
if workspace.DefaultExecutorID != nil {
executorID = *workspace.DefaultExecutorID
}
}
}
if executorID == "" {
executorID = models.ExecutorIDLocalPC
}
}
executor, err := h.service.GetExecutor(c.Request.Context(), executorID)
if err != nil {
handleNotFound(c, h.logger, err, "executor not found")
return
}
switch executor.Type {
case models.ExecutorTypeLocalPC, models.ExecutorTypeLocalDocker:
proc, err := h.lifecycleMgr.GetProcess(c.Request.Context(), processID, false)
if err != nil {
handleNotFound(c, h.logger, err, "process not found")
return
}
if proc.SessionID != sessionID {
handleNotFound(c, h.logger, fmt.Errorf("process not found"), "process not found")
return
}
if err := h.lifecycleMgr.StopProcessForSession(c.Request.Context(), sessionID, processID); err != nil {
h.logger.Warn("failed to stop process (path)",
zap.String("session_id", sessionID),
zap.String("process_id", processID),
zap.Error(err),
)
}
c.Status(http.StatusNoContent)
default:
c.Status(http.StatusNoContent)
}
}
func (h *ProcessHandlers) httpListProcesses(c *gin.Context) {
sessionID := c.Param("id")
if sessionID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "session_id is required"})
return
}
session, err := h.service.GetTaskSession(c.Request.Context(), sessionID)
if err != nil {
handleNotFound(c, h.logger, err, "task session not found")
return
}
executorID := session.ExecutorID
// TODO: fix session, should always have executorID
if executorID == "" {
if task, err := h.service.GetTask(c.Request.Context(), session.TaskID); err == nil {
if workspace, err := h.service.GetWorkspace(c.Request.Context(), task.WorkspaceID); err == nil {
if workspace.DefaultExecutorID != nil {
executorID = *workspace.DefaultExecutorID
}
}
}
if executorID == "" {
executorID = models.ExecutorIDLocalPC
}
}
executor, err := h.service.GetExecutor(c.Request.Context(), executorID)
if err != nil {
handleNotFound(c, h.logger, err, "executor not found")
return
}
switch executor.Type {
case models.ExecutorTypeLocalPC, models.ExecutorTypeLocalDocker:
listCtx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
procs, err := h.lifecycleMgr.ListProcesses(listCtx, sessionID)
if err != nil {
var netErr net.Error
if errors.Is(err, context.DeadlineExceeded) || errors.As(err, &netErr) || strings.Contains(err.Error(), "connection refused") {
h.logger.Warn("process list unavailable", zap.String("session_id", sessionID), zap.Error(err))
c.JSON(http.StatusOK, []agentctlclient.ProcessInfo{})
return
}
h.logger.Error("failed to list processes", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, procs)
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "executor type not supported for process runner"})
}
}
func (h *ProcessHandlers) httpGetProcess(c *gin.Context) {
sessionID := c.Param("id")
processID := c.Param("processId")
if sessionID == "" || processID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "session_id and process_id are required"})
return
}
session, err := h.service.GetTaskSession(c.Request.Context(), sessionID)
if err != nil {
handleNotFound(c, h.logger, err, "task session not found")
return
}
executorID := session.ExecutorID
// TODO: fix session, should always have executorID
if executorID == "" {
if task, err := h.service.GetTask(c.Request.Context(), session.TaskID); err == nil {
if workspace, err := h.service.GetWorkspace(c.Request.Context(), task.WorkspaceID); err == nil {
if workspace.DefaultExecutorID != nil {
executorID = *workspace.DefaultExecutorID
}
}
}
if executorID == "" {
executorID = models.ExecutorIDLocalPC
}
}
executor, err := h.service.GetExecutor(c.Request.Context(), executorID)
if err != nil {
handleNotFound(c, h.logger, err, "executor not found")
return
}
includeOutput := c.Query("include_output") == "true"
switch executor.Type {
case models.ExecutorTypeLocalPC, models.ExecutorTypeLocalDocker:
proc, err := h.lifecycleMgr.GetProcess(c.Request.Context(), processID, includeOutput)
if err != nil {
handleNotFound(c, h.logger, err, "process not found")
return
}
if proc.SessionID != sessionID {
handleNotFound(c, h.logger, fmt.Errorf("process not found"), "process not found")
return
}
c.JSON(http.StatusOK, proc)
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "executor type not supported for process runner"})
}
}
func resolveScriptCommand(
ctx context.Context,
svc *service.Service,
repo *models.Repository,
kind string,
scriptName string,
) (string, string, string, error) {
switch strings.ToLower(kind) {
case "setup":
if strings.TrimSpace(repo.SetupScript) == "" {
return "", "", "", fmt.Errorf("setup script not configured")
}
return repo.SetupScript, "setup", "", nil
case "cleanup":
if strings.TrimSpace(repo.CleanupScript) == "" {
return "", "", "", fmt.Errorf("cleanup script not configured")
}
return repo.CleanupScript, "cleanup", "", nil
case "dev":
if strings.TrimSpace(repo.DevScript) == "" {
return "", "", "", fmt.Errorf("dev script not configured")
}
return repo.DevScript, "dev", "", nil
case "custom":
if strings.TrimSpace(scriptName) == "" {
return "", "", "", fmt.Errorf("script_name is required for custom scripts")
}
scripts, err := svc.ListRepositoryScripts(ctx, repo.ID)
if err != nil {
return "", "", "", err
}
for _, script := range scripts {
if script.Name == scriptName {
if strings.TrimSpace(script.Command) == "" {
return "", "", "", fmt.Errorf("script command is empty")
}
return script.Command, "custom", script.Name, nil
}
}
return "", "", "", fmt.Errorf("custom script not found")
default:
return "", "", "", fmt.Errorf("invalid script kind")
}
}
@@ -0,0 +1,558 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"strconv"
"strings"
"sync/atomic"
"testing"
"unsafe"
"github.com/gin-gonic/gin"
"github.com/kandev/kandev/internal/agent/lifecycle"
"github.com/kandev/kandev/internal/agent/registry"
agentctlclient "github.com/kandev/kandev/internal/agentctl/client"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/repository"
"github.com/kandev/kandev/internal/task/service"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
type mockRepository struct {
scriptsByRepo map[string][]*models.RepositoryScript
sessions map[string]*models.TaskSession
executors map[string]*models.Executor
}
func (m *mockRepository) CreateWorkspace(ctx context.Context, workspace *models.Workspace) error {
return nil
}
func (m *mockRepository) GetWorkspace(ctx context.Context, id string) (*models.Workspace, error) {
return nil, nil
}
func (m *mockRepository) UpdateWorkspace(ctx context.Context, workspace *models.Workspace) error {
return nil
}
func (m *mockRepository) DeleteWorkspace(ctx context.Context, id string) error {
return nil
}
func (m *mockRepository) ListWorkspaces(ctx context.Context) ([]*models.Workspace, error) {
return nil, nil
}
func (m *mockRepository) CreateTask(ctx context.Context, task *models.Task) error {
return nil
}
func (m *mockRepository) GetTask(ctx context.Context, id string) (*models.Task, error) {
return nil, nil
}
func (m *mockRepository) UpdateTask(ctx context.Context, task *models.Task) error {
return nil
}
func (m *mockRepository) DeleteTask(ctx context.Context, id string) error {
return nil
}
func (m *mockRepository) ListTasks(ctx context.Context, boardID string) ([]*models.Task, error) {
return nil, nil
}
func (m *mockRepository) ListTasksByColumn(ctx context.Context, columnID string) ([]*models.Task, error) {
return nil, nil
}
func (m *mockRepository) UpdateTaskState(ctx context.Context, id string, state v1.TaskState) error {
return nil
}
func (m *mockRepository) AddTaskToBoard(ctx context.Context, taskID, boardID, columnID string, position int) error {
return nil
}
func (m *mockRepository) RemoveTaskFromBoard(ctx context.Context, taskID, boardID string) error {
return nil
}
func (m *mockRepository) CreateTaskRepository(ctx context.Context, taskRepo *models.TaskRepository) error {
return nil
}
func (m *mockRepository) GetTaskRepository(ctx context.Context, id string) (*models.TaskRepository, error) {
return nil, nil
}
func (m *mockRepository) ListTaskRepositories(ctx context.Context, taskID string) ([]*models.TaskRepository, error) {
return nil, nil
}
func (m *mockRepository) UpdateTaskRepository(ctx context.Context, taskRepo *models.TaskRepository) error {
return nil
}
func (m *mockRepository) DeleteTaskRepository(ctx context.Context, id string) error {
return nil
}
func (m *mockRepository) DeleteTaskRepositoriesByTask(ctx context.Context, taskID string) error {
return nil
}
func (m *mockRepository) GetPrimaryTaskRepository(ctx context.Context, taskID string) (*models.TaskRepository, error) {
return nil, nil
}
func (m *mockRepository) CreateBoard(ctx context.Context, board *models.Board) error {
return nil
}
func (m *mockRepository) GetBoard(ctx context.Context, id string) (*models.Board, error) {
return nil, nil
}
func (m *mockRepository) UpdateBoard(ctx context.Context, board *models.Board) error {
return nil
}
func (m *mockRepository) DeleteBoard(ctx context.Context, id string) error {
return nil
}
func (m *mockRepository) ListBoards(ctx context.Context, workspaceID string) ([]*models.Board, error) {
return nil, nil
}
func (m *mockRepository) CreateColumn(ctx context.Context, column *models.Column) error {
return nil
}
func (m *mockRepository) GetColumn(ctx context.Context, id string) (*models.Column, error) {
return nil, nil
}
func (m *mockRepository) GetColumnByState(ctx context.Context, boardID string, state v1.TaskState) (*models.Column, error) {
return nil, nil
}
func (m *mockRepository) UpdateColumn(ctx context.Context, column *models.Column) error {
return nil
}
func (m *mockRepository) DeleteColumn(ctx context.Context, id string) error {
return nil
}
func (m *mockRepository) ListColumns(ctx context.Context, boardID string) ([]*models.Column, error) {
return nil, nil
}
func (m *mockRepository) CreateMessage(ctx context.Context, message *models.Message) error {
return nil
}
func (m *mockRepository) GetMessage(ctx context.Context, id string) (*models.Message, error) {
return nil, nil
}
func (m *mockRepository) GetMessageByToolCallID(ctx context.Context, sessionID, toolCallID string) (*models.Message, error) {
return nil, nil
}
func (m *mockRepository) GetMessageByPendingID(ctx context.Context, sessionID, pendingID string) (*models.Message, error) {
return nil, nil
}
func (m *mockRepository) UpdateMessage(ctx context.Context, message *models.Message) error {
return nil
}
func (m *mockRepository) ListMessages(ctx context.Context, sessionID string) ([]*models.Message, error) {
return nil, nil
}
func (m *mockRepository) ListMessagesPaginated(ctx context.Context, sessionID string, opts repository.ListMessagesOptions) ([]*models.Message, bool, error) {
return nil, false, nil
}
func (m *mockRepository) DeleteMessage(ctx context.Context, id string) error {
return nil
}
func (m *mockRepository) CreateTurn(ctx context.Context, turn *models.Turn) error {
return nil
}
func (m *mockRepository) GetTurn(ctx context.Context, id string) (*models.Turn, error) {
return nil, nil
}
func (m *mockRepository) GetActiveTurnBySessionID(ctx context.Context, sessionID string) (*models.Turn, error) {
return nil, nil
}
func (m *mockRepository) UpdateTurn(ctx context.Context, turn *models.Turn) error {
return nil
}
func (m *mockRepository) CompleteTurn(ctx context.Context, id string) error {
return nil
}
func (m *mockRepository) ListTurnsBySession(ctx context.Context, sessionID string) ([]*models.Turn, error) {
return nil, nil
}
func (m *mockRepository) CreateTaskSession(ctx context.Context, session *models.TaskSession) error {
return nil
}
func (m *mockRepository) GetTaskSession(ctx context.Context, id string) (*models.TaskSession, error) {
if m.sessions != nil {
if session, ok := m.sessions[id]; ok {
return session, nil
}
}
return nil, fmt.Errorf("task session not found: %s", id)
}
func (m *mockRepository) GetTaskSessionByTaskID(ctx context.Context, taskID string) (*models.TaskSession, error) {
return nil, nil
}
func (m *mockRepository) GetActiveTaskSessionByTaskID(ctx context.Context, taskID string) (*models.TaskSession, error) {
return nil, nil
}
func (m *mockRepository) UpdateTaskSession(ctx context.Context, session *models.TaskSession) error {
return nil
}
func (m *mockRepository) UpdateTaskSessionState(ctx context.Context, id string, state models.TaskSessionState, errorMessage string) error {
return nil
}
func (m *mockRepository) ListTaskSessions(ctx context.Context, taskID string) ([]*models.TaskSession, error) {
return nil, nil
}
func (m *mockRepository) ListActiveTaskSessions(ctx context.Context) ([]*models.TaskSession, error) {
return nil, nil
}
func (m *mockRepository) ListActiveTaskSessionsByTaskID(ctx context.Context, taskID string) ([]*models.TaskSession, error) {
return nil, nil
}
func (m *mockRepository) HasActiveTaskSessionsByAgentProfile(ctx context.Context, agentProfileID string) (bool, error) {
return false, nil
}
func (m *mockRepository) HasActiveTaskSessionsByExecutor(ctx context.Context, executorID string) (bool, error) {
return false, nil
}
func (m *mockRepository) HasActiveTaskSessionsByEnvironment(ctx context.Context, environmentID string) (bool, error) {
return false, nil
}
func (m *mockRepository) HasActiveTaskSessionsByRepository(ctx context.Context, repositoryID string) (bool, error) {
return false, nil
}
func (m *mockRepository) DeleteTaskSession(ctx context.Context, id string) error {
return nil
}
func (m *mockRepository) CreateTaskSessionWorktree(ctx context.Context, sessionWorktree *models.TaskSessionWorktree) error {
return nil
}
func (m *mockRepository) ListTaskSessionWorktrees(ctx context.Context, sessionID string) ([]*models.TaskSessionWorktree, error) {
return nil, nil
}
func (m *mockRepository) DeleteTaskSessionWorktree(ctx context.Context, id string) error {
return nil
}
func (m *mockRepository) DeleteTaskSessionWorktreesBySession(ctx context.Context, sessionID string) error {
return nil
}
func (m *mockRepository) CreateRepository(ctx context.Context, repository *models.Repository) error {
return nil
}
func (m *mockRepository) GetRepository(ctx context.Context, id string) (*models.Repository, error) {
return nil, nil
}
func (m *mockRepository) UpdateRepository(ctx context.Context, repository *models.Repository) error {
return nil
}
func (m *mockRepository) DeleteRepository(ctx context.Context, id string) error {
return nil
}
func (m *mockRepository) ListRepositories(ctx context.Context, workspaceID string) ([]*models.Repository, error) {
return nil, nil
}
func (m *mockRepository) CreateRepositoryScript(ctx context.Context, script *models.RepositoryScript) error {
return nil
}
func (m *mockRepository) GetRepositoryScript(ctx context.Context, id string) (*models.RepositoryScript, error) {
return nil, nil
}
func (m *mockRepository) UpdateRepositoryScript(ctx context.Context, script *models.RepositoryScript) error {
return nil
}
func (m *mockRepository) DeleteRepositoryScript(ctx context.Context, id string) error {
return nil
}
func (m *mockRepository) ListRepositoryScripts(ctx context.Context, repositoryID string) ([]*models.RepositoryScript, error) {
if m.scriptsByRepo == nil {
return nil, nil
}
return m.scriptsByRepo[repositoryID], nil
}
func (m *mockRepository) CreateExecutor(ctx context.Context, executor *models.Executor) error {
return nil
}
func (m *mockRepository) GetExecutor(ctx context.Context, id string) (*models.Executor, error) {
if m.executors != nil {
if executor, ok := m.executors[id]; ok {
return executor, nil
}
}
return nil, fmt.Errorf("executor not found: %s", id)
}
func (m *mockRepository) UpdateExecutor(ctx context.Context, executor *models.Executor) error {
return nil
}
func (m *mockRepository) DeleteExecutor(ctx context.Context, id string) error {
return nil
}
func (m *mockRepository) ListExecutors(ctx context.Context) ([]*models.Executor, error) {
return nil, nil
}
func (m *mockRepository) ListExecutorsRunning(ctx context.Context) ([]*models.ExecutorRunning, error) {
return nil, nil
}
func (m *mockRepository) UpsertExecutorRunning(ctx context.Context, running *models.ExecutorRunning) error {
return nil
}
func (m *mockRepository) GetExecutorRunningBySessionID(ctx context.Context, sessionID string) (*models.ExecutorRunning, error) {
return nil, nil
}
func (m *mockRepository) DeleteExecutorRunningBySessionID(ctx context.Context, sessionID string) error {
return nil
}
func (m *mockRepository) CreateEnvironment(ctx context.Context, environment *models.Environment) error {
return nil
}
func (m *mockRepository) GetEnvironment(ctx context.Context, id string) (*models.Environment, error) {
return nil, nil
}
func (m *mockRepository) UpdateEnvironment(ctx context.Context, environment *models.Environment) error {
return nil
}
func (m *mockRepository) DeleteEnvironment(ctx context.Context, id string) error {
return nil
}
func (m *mockRepository) ListEnvironments(ctx context.Context) ([]*models.Environment, error) {
return nil, nil
}
func (m *mockRepository) Close() error {
return nil
}
func newTestService(t *testing.T, scripts map[string][]*models.RepositoryScript) *service.Service {
log, err := logger.NewLogger(logger.LoggingConfig{
Level: "error",
Format: "json",
})
if err != nil {
t.Fatalf("failed to create logger: %v", err)
}
repo := &mockRepository{scriptsByRepo: scripts}
return service.NewService(repo, nil, log, service.RepositoryDiscoveryConfig{})
}
func addExecution(t *testing.T, mgr *lifecycle.Manager, exec *lifecycle.AgentExecution) {
t.Helper()
store := getExecutionStore(t, mgr)
store.Add(exec)
}
func getExecutionStore(t *testing.T, mgr *lifecycle.Manager) *lifecycle.ExecutionStore {
t.Helper()
value := reflect.ValueOf(mgr).Elem().FieldByName("executionStore")
if !value.IsValid() {
t.Fatal("executionStore field not found on lifecycle manager")
}
value = reflect.NewAt(value.Type(), unsafe.Pointer(value.UnsafeAddr())).Elem()
store, ok := value.Interface().(*lifecycle.ExecutionStore)
if !ok {
t.Fatal("executionStore is not *lifecycle.ExecutionStore")
}
return store
}
func newLifecycleManager(t *testing.T, log *logger.Logger) *lifecycle.Manager {
t.Helper()
reg := registry.NewRegistry(log)
return lifecycle.NewManager(reg, nil, nil, nil, nil, nil, nil, log)
}
func newAgentctlClient(t *testing.T, serverURL string, log *logger.Logger) *agentctlclient.Client {
t.Helper()
parsed, err := url.Parse(serverURL)
if err != nil {
t.Fatalf("failed to parse server url: %v", err)
}
port, err := strconv.Atoi(parsed.Port())
if err != nil {
t.Fatalf("failed to parse server port: %v", err)
}
return agentctlclient.NewClient(parsed.Hostname(), port, log)
}
func newProcessStopRouter(
t *testing.T,
svc *service.Service,
lifecycleMgr *lifecycle.Manager,
log *logger.Logger,
) *gin.Engine {
t.Helper()
gin.SetMode(gin.TestMode)
router := gin.New()
RegisterProcessRoutes(router, svc, lifecycleMgr, log)
return router
}
func TestResolveScriptCommandBuiltins(t *testing.T) {
svc := newTestService(t, nil)
repo := &models.Repository{ID: "repo-1", SetupScript: "echo setup", CleanupScript: "echo cleanup", DevScript: "echo dev"}
cmd, kind, scriptName, err := resolveScriptCommand(context.Background(), svc, repo, "setup", "")
if err != nil || cmd != "echo setup" || kind != "setup" || scriptName != "" {
t.Fatalf("unexpected setup result: cmd=%q kind=%q script=%q err=%v", cmd, kind, scriptName, err)
}
cmd, kind, scriptName, err = resolveScriptCommand(context.Background(), svc, repo, "cleanup", "")
if err != nil || cmd != "echo cleanup" || kind != "cleanup" || scriptName != "" {
t.Fatalf("unexpected cleanup result: cmd=%q kind=%q script=%q err=%v", cmd, kind, scriptName, err)
}
cmd, kind, scriptName, err = resolveScriptCommand(context.Background(), svc, repo, "dev", "")
if err != nil || cmd != "echo dev" || kind != "dev" || scriptName != "" {
t.Fatalf("unexpected dev result: cmd=%q kind=%q script=%q err=%v", cmd, kind, scriptName, err)
}
}
func TestResolveScriptCommandCustom(t *testing.T) {
scripts := map[string][]*models.RepositoryScript{
"repo-1": {
{ID: "s1", RepositoryID: "repo-1", Name: "build", Command: "make build"},
},
}
svc := newTestService(t, scripts)
repo := &models.Repository{ID: "repo-1"}
cmd, kind, scriptName, err := resolveScriptCommand(context.Background(), svc, repo, "custom", "build")
if err != nil || cmd != "make build" || kind != "custom" || scriptName != "build" {
t.Fatalf("unexpected custom result: cmd=%q kind=%q script=%q err=%v", cmd, kind, scriptName, err)
}
}
func TestResolveScriptCommandErrors(t *testing.T) {
svc := newTestService(t, nil)
repo := &models.Repository{ID: "repo-1"}
if _, _, _, err := resolveScriptCommand(context.Background(), svc, repo, "setup", ""); err == nil {
t.Fatal("expected error for missing setup script")
}
if _, _, _, err := resolveScriptCommand(context.Background(), svc, repo, "cleanup", ""); err == nil {
t.Fatal("expected error for missing cleanup script")
}
if _, _, _, err := resolveScriptCommand(context.Background(), svc, repo, "dev", ""); err == nil {
t.Fatal("expected error for missing dev script")
}
if _, _, _, err := resolveScriptCommand(context.Background(), svc, repo, "custom", ""); err == nil {
t.Fatal("expected error for missing custom script name")
}
if _, _, _, err := resolveScriptCommand(context.Background(), svc, repo, "custom", "unknown"); err == nil {
t.Fatal("expected error for unknown custom script")
}
}
func TestStopProcessRejectsDifferentSession(t *testing.T) {
log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
if err != nil {
t.Fatalf("failed to create logger: %v", err)
}
stopCalled := atomic.Bool{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/processes/stop":
stopCalled.Store(true)
_, _ = w.Write([]byte(`{"success":true}`))
return
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/processes/"):
resp := agentctlclient.ProcessInfo{
ID: "proc-1",
SessionID: "session-other",
Kind: agentctlclient.ProcessKind("dev"),
Status: agentctlclient.ProcessStatus("running"),
}
data, _ := json.Marshal(resp)
_, _ = w.Write(data)
return
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
repo := &mockRepository{
sessions: map[string]*models.TaskSession{
"session-a": {ID: "session-a", TaskID: "task-1", ExecutorID: "exec-1"},
},
executors: map[string]*models.Executor{
"exec-1": {ID: "exec-1", Type: models.ExecutorTypeLocalPC},
},
}
svc := service.NewService(repo, nil, log, service.RepositoryDiscoveryConfig{})
lifecycleMgr := newLifecycleManager(t, log)
client := newAgentctlClient(t, server.URL, log)
exec := (&lifecycle.RuntimeInstance{
InstanceID: "exec-1",
TaskID: "task-1",
SessionID: "session-a",
Client: client,
}).ToAgentExecution(&lifecycle.RuntimeCreateRequest{
InstanceID: "exec-1",
TaskID: "task-1",
SessionID: "session-a",
AgentProfileID: "profile-1",
WorkspacePath: "/tmp",
})
addExecution(t, lifecycleMgr, exec)
router := newProcessStopRouter(t, svc, lifecycleMgr, log)
req := httptest.NewRequest(http.MethodPost, "/api/v1/task-sessions/session-a/processes/proc-1/stop", nil)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if resp.Code != http.StatusNotFound {
t.Fatalf("expected 404 for mismatched session, got %d", resp.Code)
}
if stopCalled.Load() {
t.Fatal("expected stop endpoint not to be called on session mismatch")
}
}
func TestStopProcessAgentctlUnavailable(t *testing.T) {
log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
if err != nil {
t.Fatalf("failed to create logger: %v", err)
}
stopCalled := atomic.Bool{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/processes/stop":
stopCalled.Store(true)
_, _ = w.Write([]byte(`{"success":true}`))
return
case r.Method == http.MethodGet && strings.HasPrefix(r.URL.Path, "/api/v1/processes/"):
w.WriteHeader(http.StatusInternalServerError)
return
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
repo := &mockRepository{
sessions: map[string]*models.TaskSession{
"session-a": {ID: "session-a", TaskID: "task-1", ExecutorID: "exec-1"},
},
executors: map[string]*models.Executor{
"exec-1": {ID: "exec-1", Type: models.ExecutorTypeLocalPC},
},
}
svc := service.NewService(repo, nil, log, service.RepositoryDiscoveryConfig{})
lifecycleMgr := newLifecycleManager(t, log)
client := newAgentctlClient(t, server.URL, log)
exec := (&lifecycle.RuntimeInstance{
InstanceID: "exec-1",
TaskID: "task-1",
SessionID: "session-a",
Client: client,
}).ToAgentExecution(&lifecycle.RuntimeCreateRequest{
InstanceID: "exec-1",
TaskID: "task-1",
SessionID: "session-a",
AgentProfileID: "profile-1",
WorkspacePath: "/tmp",
})
addExecution(t, lifecycleMgr, exec)
router := newProcessStopRouter(t, svc, lifecycleMgr, log)
req := httptest.NewRequest(http.MethodPost, "/api/v1/task-sessions/session-a/processes/proc-1/stop", nil)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if resp.Code != http.StatusNotFound {
t.Fatalf("expected 404 when agentctl is unavailable, got %d", resp.Code)
}
if stopCalled.Load() {
t.Fatal("expected stop endpoint not to be called when get process fails")
}
}
@@ -146,6 +146,7 @@ type httpCreateRepositoryRequest struct {
WorktreeBranchPrefix string `json:"worktree_branch_prefix"`
SetupScript string `json:"setup_script"`
CleanupScript string `json:"cleanup_script"`
DevScript string `json:"dev_script"`
}
func (h *RepositoryHandlers) httpCreateRepository(c *gin.Context) {
@@ -171,6 +172,7 @@ func (h *RepositoryHandlers) httpCreateRepository(c *gin.Context) {
WorktreeBranchPrefix: body.WorktreeBranchPrefix,
SetupScript: body.SetupScript,
CleanupScript: body.CleanupScript,
DevScript: body.DevScript,
})
if err != nil {
if errors.Is(err, service.ErrInvalidRepositorySettings) {
@@ -219,6 +221,7 @@ type httpUpdateRepositoryRequest struct {
WorktreeBranchPrefix *string `json:"worktree_branch_prefix"`
SetupScript *string `json:"setup_script"`
CleanupScript *string `json:"cleanup_script"`
DevScript *string `json:"dev_script"`
}
func (h *RepositoryHandlers) httpUpdateRepository(c *gin.Context) {
@@ -240,6 +243,7 @@ func (h *RepositoryHandlers) httpUpdateRepository(c *gin.Context) {
WorktreeBranchPrefix: body.WorktreeBranchPrefix,
SetupScript: body.SetupScript,
CleanupScript: body.CleanupScript,
DevScript: body.DevScript,
})
if err != nil {
if errors.Is(err, service.ErrInvalidRepositorySettings) {
@@ -376,6 +380,7 @@ type wsCreateRepositoryRequest struct {
WorktreeBranchPrefix string `json:"worktree_branch_prefix"`
SetupScript string `json:"setup_script"`
CleanupScript string `json:"cleanup_script"`
DevScript string `json:"dev_script"`
}
func (h *RepositoryHandlers) wsCreateRepository(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
@@ -399,6 +404,7 @@ func (h *RepositoryHandlers) wsCreateRepository(ctx context.Context, msg *ws.Mes
WorktreeBranchPrefix: req.WorktreeBranchPrefix,
SetupScript: req.SetupScript,
CleanupScript: req.CleanupScript,
DevScript: req.DevScript,
})
if err != nil {
if errors.Is(err, service.ErrInvalidRepositorySettings) {
@@ -442,6 +448,7 @@ type wsUpdateRepositoryRequest struct {
WorktreeBranchPrefix *string `json:"worktree_branch_prefix,omitempty"`
SetupScript *string `json:"setup_script,omitempty"`
CleanupScript *string `json:"cleanup_script,omitempty"`
DevScript *string `json:"dev_script,omitempty"`
}
func (h *RepositoryHandlers) wsUpdateRepository(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
@@ -465,6 +472,7 @@ func (h *RepositoryHandlers) wsUpdateRepository(ctx context.Context, msg *ws.Mes
WorktreeBranchPrefix: req.WorktreeBranchPrefix,
SetupScript: req.SetupScript,
CleanupScript: req.CleanupScript,
DevScript: req.DevScript,
})
if err != nil {
if errors.Is(err, service.ErrInvalidRepositorySettings) {
@@ -3,6 +3,7 @@ package handlers
import (
"context"
"net/http"
"time"
"github.com/gin-gonic/gin"
@@ -163,7 +164,8 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) {
})
}
resp, err := h.controller.CreateTask(c.Request.Context(), dto.CreateTaskRequest{
reqCtx := c.Request.Context()
resp, err := h.controller.CreateTask(reqCtx, dto.CreateTaskRequest{
WorkspaceID: body.WorkspaceID,
BoardID: body.BoardID,
ColumnID: body.ColumnID,
@@ -182,8 +184,10 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) {
response := createTaskResponse{TaskDTO: resp}
if body.StartAgent && body.AgentProfileID != "" && h.orchestrator != nil {
startCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
// Use task description as the initial prompt
execution, err := h.orchestrator.StartTask(c.Request.Context(), resp.ID, body.AgentProfileID, body.Priority, resp.Description)
execution, err := h.orchestrator.StartTask(startCtx, resp.ID, body.AgentProfileID, body.Priority, resp.Description)
if err != nil {
h.logger.Error("failed to start agent for task", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to start agent for task"})
@@ -274,7 +278,9 @@ func (h *TaskHandlers) httpMoveTask(c *gin.Context) {
}
func (h *TaskHandlers) httpDeleteTask(c *gin.Context) {
resp, err := h.controller.DeleteTask(c.Request.Context(), dto.DeleteTaskRequest{ID: c.Param("id")})
deleteCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
resp, err := h.controller.DeleteTask(deleteCtx, dto.DeleteTaskRequest{ID: c.Param("id")})
if err != nil {
handleNotFound(c, h.logger, err, "task not deleted")
return
@@ -277,6 +277,7 @@ type Repository struct {
WorktreeBranchPrefix string `json:"worktree_branch_prefix"`
SetupScript string `json:"setup_script"`
CleanupScript string `json:"cleanup_script"`
DevScript string `json:"dev_script"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
@@ -171,6 +171,7 @@ func (r *sqliteRepository) initSchema() error {
worktree_branch_prefix TEXT DEFAULT 'feature/',
setup_script TEXT DEFAULT '',
cleanup_script TEXT DEFAULT '',
dev_script TEXT DEFAULT '',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
deleted_at DATETIME,
@@ -315,6 +316,9 @@ func (r *sqliteRepository) initSchema() error {
if err := r.ensureColumn("repositories", "worktree_branch_prefix", "TEXT DEFAULT 'feature/'"); err != nil {
return err
}
if err := r.ensureColumn("repositories", "dev_script", "TEXT DEFAULT ''"); err != nil {
return err
}
if err := r.ensureColumn("executors", "deleted_at", "DATETIME"); err != nil {
return err
}
@@ -1763,11 +1767,11 @@ func (r *sqliteRepository) CreateRepository(ctx context.Context, repository *mod
_, err := r.db.ExecContext(ctx, `
INSERT INTO repositories (
id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_owner,
provider_name, default_branch, worktree_branch_prefix, setup_script, cleanup_script, created_at, updated_at, deleted_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
provider_name, default_branch, worktree_branch_prefix, setup_script, cleanup_script, dev_script, created_at, updated_at, deleted_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, repository.ID, repository.WorkspaceID, repository.Name, repository.SourceType, repository.LocalPath, repository.Provider,
repository.ProviderRepoID, repository.ProviderOwner, repository.ProviderName, repository.DefaultBranch, repository.WorktreeBranchPrefix,
repository.SetupScript, repository.CleanupScript, repository.CreatedAt, repository.UpdatedAt, repository.DeletedAt)
repository.SetupScript, repository.CleanupScript, repository.DevScript, repository.CreatedAt, repository.UpdatedAt, repository.DeletedAt)
return err
}
@@ -1778,12 +1782,12 @@ func (r *sqliteRepository) GetRepository(ctx context.Context, id string) (*model
err := r.db.QueryRowContext(ctx, `
SELECT id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_owner,
provider_name, default_branch, worktree_branch_prefix, setup_script, cleanup_script, created_at, updated_at, deleted_at
provider_name, default_branch, worktree_branch_prefix, setup_script, cleanup_script, dev_script, created_at, updated_at, deleted_at
FROM repositories WHERE id = ? AND deleted_at IS NULL
`, id).Scan(
&repository.ID, &repository.WorkspaceID, &repository.Name, &repository.SourceType, &repository.LocalPath,
&repository.Provider, &repository.ProviderRepoID, &repository.ProviderOwner, &repository.ProviderName,
&repository.DefaultBranch, &repository.WorktreeBranchPrefix, &repository.SetupScript, &repository.CleanupScript, &repository.CreatedAt, &repository.UpdatedAt, &repository.DeletedAt,
&repository.DefaultBranch, &repository.WorktreeBranchPrefix, &repository.SetupScript, &repository.CleanupScript, &repository.DevScript, &repository.CreatedAt, &repository.UpdatedAt, &repository.DeletedAt,
)
if err == sql.ErrNoRows {
@@ -1799,11 +1803,11 @@ func (r *sqliteRepository) UpdateRepository(ctx context.Context, repository *mod
result, err := r.db.ExecContext(ctx, `
UPDATE repositories SET
name = ?, source_type = ?, local_path = ?, provider = ?, provider_repo_id = ?, provider_owner = ?,
provider_name = ?, default_branch = ?, worktree_branch_prefix = ?, setup_script = ?, cleanup_script = ?, updated_at = ?
provider_name = ?, default_branch = ?, worktree_branch_prefix = ?, setup_script = ?, cleanup_script = ?, dev_script = ?, updated_at = ?
WHERE id = ? AND deleted_at IS NULL
`, repository.Name, repository.SourceType, repository.LocalPath, repository.Provider, repository.ProviderRepoID,
repository.ProviderOwner, repository.ProviderName, repository.DefaultBranch, repository.WorktreeBranchPrefix, repository.SetupScript,
repository.CleanupScript, repository.UpdatedAt, repository.ID)
repository.CleanupScript, repository.DevScript, repository.UpdatedAt, repository.ID)
if err != nil {
return err
}
@@ -1835,7 +1839,7 @@ func (r *sqliteRepository) DeleteRepository(ctx context.Context, id string) erro
func (r *sqliteRepository) ListRepositories(ctx context.Context, workspaceID string) ([]*models.Repository, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_owner,
provider_name, default_branch, worktree_branch_prefix, setup_script, cleanup_script, created_at, updated_at, deleted_at
provider_name, default_branch, worktree_branch_prefix, setup_script, cleanup_script, dev_script, created_at, updated_at, deleted_at
FROM repositories WHERE workspace_id = ? AND deleted_at IS NULL ORDER BY created_at DESC
`, workspaceID)
if err != nil {
@@ -1849,7 +1853,7 @@ func (r *sqliteRepository) ListRepositories(ctx context.Context, workspaceID str
err := rows.Scan(
&repository.ID, &repository.WorkspaceID, &repository.Name, &repository.SourceType, &repository.LocalPath,
&repository.Provider, &repository.ProviderRepoID, &repository.ProviderOwner, &repository.ProviderName,
&repository.DefaultBranch, &repository.WorktreeBranchPrefix, &repository.SetupScript, &repository.CleanupScript, &repository.CreatedAt, &repository.UpdatedAt, &repository.DeletedAt,
&repository.DefaultBranch, &repository.WorktreeBranchPrefix, &repository.SetupScript, &repository.CleanupScript, &repository.DevScript, &repository.CreatedAt, &repository.UpdatedAt, &repository.DeletedAt,
)
if err != nil {
return nil, err
+68 -36
View File
@@ -185,6 +185,7 @@ type CreateRepositoryRequest struct {
WorktreeBranchPrefix string `json:"worktree_branch_prefix"`
SetupScript string `json:"setup_script"`
CleanupScript string `json:"cleanup_script"`
DevScript string `json:"dev_script"`
}
// UpdateRepositoryRequest contains the data for updating a repository
@@ -200,6 +201,7 @@ type UpdateRepositoryRequest struct {
WorktreeBranchPrefix *string `json:"worktree_branch_prefix,omitempty"`
SetupScript *string `json:"setup_script,omitempty"`
CleanupScript *string `json:"cleanup_script,omitempty"`
DevScript *string `json:"dev_script,omitempty"`
}
// CreateExecutorRequest contains the data for creating an executor
@@ -470,55 +472,35 @@ func (s *Service) UpdateTask(ctx context.Context, id string, req *UpdateTaskRequ
// DeleteTask deletes a task and publishes a task.deleted event
func (s *Service) DeleteTask(ctx context.Context, id string) error {
start := time.Now()
task, err := s.repo.GetTask(ctx, id)
if err != nil {
return err
}
if s.executionStopper != nil {
if err := s.executionStopper.StopTask(ctx, id, "task deleted", true); err != nil {
s.logger.Warn("failed to stop task execution on delete",
zap.String("task_id", id),
zap.Error(err))
}
}
// Clean up worktree BEFORE deleting task (CASCADE would delete worktree DB records)
// This must happen first so we can find the worktree paths to remove from disk
if s.worktreeCleanup != nil {
if err := s.worktreeCleanup.OnTaskDeleted(ctx, id); err != nil {
s.logger.Warn("failed to cleanup worktree on task deletion",
zap.String("task_id", id),
zap.Error(err))
}
}
sessions, err := s.repo.ListTaskSessions(ctx, id)
if err != nil {
s.logger.Warn("failed to list task sessions for delete",
zap.String("task_id", id),
zap.Error(err))
} else {
for _, session := range sessions {
if session == nil || session.ID == "" {
continue
}
if err := s.repo.DeleteExecutorRunningBySessionID(ctx, session.ID); err != nil {
s.logger.Debug("failed to delete executor runtime for session",
}
var worktrees []*worktree.Worktree
if s.worktreeCleanup != nil {
if provider, ok := s.worktreeCleanup.(interface {
GetAllByTaskID(context.Context, string) ([]*worktree.Worktree, error)
}); ok {
worktrees, err = provider.GetAllByTaskID(ctx, id)
if err != nil {
s.logger.Warn("failed to list worktrees for delete",
zap.String("task_id", id),
zap.String("session_id", session.ID),
zap.Error(err))
}
if err := s.repo.DeleteTaskSessionWorktreesBySession(ctx, session.ID); err != nil {
s.logger.Warn("failed to delete session worktrees",
} else {
// Fallback for legacy implementations: cleanup before delete.
if err := s.worktreeCleanup.OnTaskDeleted(ctx, id); err != nil {
s.logger.Warn("failed to cleanup worktree on task deletion",
zap.String("task_id", id),
zap.String("session_id", session.ID),
zap.Error(err))
}
if err := s.repo.DeleteTaskSession(ctx, session.ID); err != nil {
s.logger.Warn("failed to delete task session",
zap.String("task_id", id),
zap.String("session_id", session.ID),
zap.Error(err))
}
}
@@ -530,7 +512,53 @@ func (s *Service) DeleteTask(ctx context.Context, id string) error {
}
s.publishTaskEvent(ctx, events.TaskDeleted, task, nil)
s.logger.Info("task deleted", zap.String("task_id", id))
s.logger.Info("task deleted",
zap.String("task_id", id),
zap.Duration("duration", time.Since(start)))
if s.executionStopper != nil || s.worktreeCleanup != nil || len(sessions) > 0 {
go func(taskID string, sessions []*models.TaskSession, worktrees []*worktree.Worktree) {
cleanupStart := time.Now()
cleanupCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if s.executionStopper != nil {
if err := s.executionStopper.StopTask(cleanupCtx, taskID, "task deleted", true); err != nil {
s.logger.Warn("failed to stop task execution on delete",
zap.String("task_id", taskID),
zap.Error(err))
}
}
if len(worktrees) > 0 {
if cleaner, ok := s.worktreeCleanup.(interface {
CleanupWorktrees(context.Context, []*worktree.Worktree) error
}); ok {
if err := cleaner.CleanupWorktrees(cleanupCtx, worktrees); err != nil {
s.logger.Warn("failed to cleanup worktrees after delete",
zap.String("task_id", taskID),
zap.Error(err))
}
}
}
for _, session := range sessions {
if session == nil || session.ID == "" {
continue
}
if err := s.repo.DeleteExecutorRunningBySessionID(cleanupCtx, session.ID); err != nil {
s.logger.Debug("failed to delete executor runtime for session",
zap.String("task_id", taskID),
zap.String("session_id", session.ID),
zap.Error(err))
}
}
s.logger.Info("task delete cleanup finished",
zap.String("task_id", taskID),
zap.Duration("duration", time.Since(cleanupStart)))
}(id, sessions, worktrees)
}
return nil
}
@@ -975,6 +1003,7 @@ func (s *Service) CreateRepository(ctx context.Context, req *CreateRepositoryReq
WorktreeBranchPrefix: prefix,
SetupScript: req.SetupScript,
CleanupScript: req.CleanupScript,
DevScript: req.DevScript,
}
if err := s.repo.CreateRepository(ctx, repository); err != nil {
@@ -1033,6 +1062,9 @@ func (s *Service) UpdateRepository(ctx context.Context, id string, req *UpdateRe
if req.CleanupScript != nil {
repository.CleanupScript = *req.CleanupScript
}
if req.DevScript != nil {
repository.DevScript = *req.DevScript
}
repository.UpdatedAt = time.Now().UTC()
if err := s.repo.UpdateRepository(ctx, repository); err != nil {
+33 -22
View File
@@ -417,6 +417,38 @@ func (m *Manager) removeWorktree(ctx context.Context, wt *Worktree, removeBranch
return nil
}
// CleanupWorktrees removes provided worktrees without re-fetching from the store.
func (m *Manager) CleanupWorktrees(ctx context.Context, worktrees []*Worktree) error {
if len(worktrees) == 0 {
return nil
}
var lastErr error
for _, wt := range worktrees {
if wt == nil {
continue
}
if err := m.removeWorktree(ctx, wt, true); err != nil {
m.logger.Warn("failed to remove worktree on task deletion",
zap.String("task_id", wt.TaskID),
zap.String("worktree_id", wt.ID),
zap.Error(err))
lastErr = err
}
}
m.mu.Lock()
for _, wt := range worktrees {
if wt == nil {
continue
}
delete(m.worktrees, wt.TaskID)
}
m.mu.Unlock()
return lastErr
}
// OnTaskDeleted cleans up all worktrees for a task when it is deleted.
func (m *Manager) OnTaskDeleted(ctx context.Context, taskID string) error {
// Get all worktrees for this task
@@ -425,28 +457,7 @@ func (m *Manager) OnTaskDeleted(ctx context.Context, taskID string) error {
return err
}
if len(worktrees) == 0 {
return nil
}
// Remove each worktree and its branch
var lastErr error
for _, wt := range worktrees {
if err := m.removeWorktree(ctx, wt, true); err != nil {
m.logger.Warn("failed to remove worktree on task deletion",
zap.String("task_id", taskID),
zap.String("worktree_id", wt.ID),
zap.Error(err))
lastErr = err
}
}
// Clear from cache
m.mu.Lock()
delete(m.worktrees, taskID)
m.mu.Unlock()
return lastErr
return m.CleanupWorktrees(ctx, worktrees)
}
// Reconcile syncs worktree state with active tasks on startup.
+7
View File
@@ -166,6 +166,13 @@ const (
ActionShellInput = "shell.input" // Send input to shell
ActionSessionShellOutput = "session.shell.output" // Shell output notification (also used for exit with type: "exit")
// Session git status
ActionSessionGitStatus = "session.git.status"
// Process runner actions
ActionSessionProcessOutput = "session.process.output"
ActionSessionProcessStatus = "session.process.status"
// Git worktree actions
ActionWorktreePull = "worktree.pull" // Pull from remote
ActionWorktreePush = "worktree.push" // Push to remote
+1 -1
View File
@@ -30,7 +30,7 @@
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react-day-picker": "^9.13.0",
"react-resizable-panels": "^2.1.9",
"react-resizable-panels": "^4.4.1",
"recharts": "2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^2.6.0",
+7 -7
View File
@@ -8,12 +8,12 @@ import { cn } from "./lib/utils"
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
}: React.ComponentProps<typeof ResizablePrimitive.Group>) {
return (
<ResizablePrimitive.PanelGroup
<ResizablePrimitive.Group
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col data-[orientation=vertical]:flex-col",
className
)}
{...props}
@@ -31,14 +31,14 @@ function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
}: React.ComponentProps<typeof ResizablePrimitive.Separator> & {
withHandle?: boolean
}) {
return (
<ResizablePrimitive.PanelResizeHandle
<ResizablePrimitive.Separator
data-slot="resizable-handle"
className={cn(
"focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
"focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[orientation=vertical]:h-px data-[orientation=vertical]:w-full data-[orientation=vertical]:after:left-0 data-[orientation=vertical]:after:h-1 data-[orientation=vertical]:after:w-full data-[orientation=vertical]:after:translate-x-0 data-[orientation=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90 [&[data-orientation=vertical]>div]:rotate-90",
className
)}
{...props}
@@ -46,7 +46,7 @@ function ResizableHandle({
{withHandle && (
<div className="bg-border h-6 w-1 rounded-lg z-10 flex shrink-0" />
)}
</ResizablePrimitive.PanelResizeHandle>
</ResizablePrimitive.Separator>
)
}
+11 -11
View File
@@ -96,7 +96,7 @@ importers:
version: 9.39.2(jiti@2.6.1)
eslint-config-next:
specifier: 16.1.1
version: 16.1.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
version: 16.1.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
tailwindcss:
specifier: ^4.0.0
version: 4.1.18
@@ -197,8 +197,8 @@ importers:
specifier: ^19.0.0
version: 19.2.3(react@19.2.3)
react-resizable-panels:
specifier: ^2.1.9
version: 2.1.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
specifier: ^4.4.1
version: 4.4.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
recharts:
specifier: 2.15.4
version: 2.15.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -357,8 +357,8 @@ importers:
specifier: ^10.1.0
version: 10.1.0(@types/react@19.2.8)(react@19.2.3)
react-resizable-panels:
specifier: ^2.1.9
version: 2.1.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
specifier: ^4.4.1
version: 4.4.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
recharts:
specifier: 2.15.4
version: 2.15.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -404,7 +404,7 @@ importers:
version: 9.39.2(jiti@2.6.1)
eslint-config-next:
specifier: 16.1.1
version: 16.1.1(@typescript-eslint/parser@8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
version: 16.1.1(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
happy-dom:
specifier: ^20.1.0
version: 20.1.0
@@ -4845,11 +4845,11 @@ packages:
'@types/react':
optional: true
react-resizable-panels@2.1.9:
resolution: {integrity: sha512-z77+X08YDIrgAes4jl8xhnUu1LNIRp4+E7cv4xHmLOxxUPO/ML7PSrE813b90vj7xvQ1lcf7g2uA9GeMZonjhQ==}
react-resizable-panels@4.4.1:
resolution: {integrity: sha512-dpM9oI6rGlAq7VYDeafSRA1JmkJv8aNuKySR+tZLQQLfaeqTnQLSM52EcoI/QdowzsjVUCk6jViKS0xHWITVRQ==}
peerDependencies:
react: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
react-smooth@4.0.4:
resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==}
@@ -10748,7 +10748,7 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.8
react-resizable-panels@2.1.9(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
react-resizable-panels@4.4.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
+2
View File
@@ -186,6 +186,7 @@ export async function createRepositoryAction(payload: {
worktree_branch_prefix: string;
setup_script: string;
cleanup_script: string;
dev_script: string;
}) {
return fetchJson<Repository>(`${apiBaseUrl}/api/v1/workspaces/${payload.workspace_id}/repositories`, {
method: 'POST',
@@ -201,6 +202,7 @@ export async function createRepositoryAction(payload: {
worktree_branch_prefix: payload.worktree_branch_prefix,
setup_script: payload.setup_script,
cleanup_script: payload.cleanup_script,
dev_script: payload.dev_script,
}),
});
}
+3
View File
@@ -1,4 +1,5 @@
import { StateHydrator } from '@/components/state-hydrator';
import { readLayoutDefaults } from '@/lib/layout/read-layout-defaults';
import {
fetchBoardSnapshot,
fetchTaskSession,
@@ -21,6 +22,7 @@ export default async function SessionPage({
let initialState: ReturnType<typeof taskToState> | null = null;
let task: Task | null = null;
let sessionId: string | null = null;
const defaultLayouts = await readLayoutDefaults();
try {
const { sessionId: paramSessionId } = await params;
@@ -155,6 +157,7 @@ export default async function SessionPage({
task={task}
sessionId={sessionId}
initialRepositories={initialState?.repositories?.itemsByWorkspaceId?.[task?.workspace_id ?? ''] ?? []}
defaultLayouts={defaultLayouts}
/>
</>
);
@@ -98,7 +98,8 @@ export function WorkspaceRepositoriesClient({
repo.default_branch !== saved.default_branch ||
repo.worktree_branch_prefix !== saved.worktree_branch_prefix ||
repo.setup_script !== saved.setup_script ||
repo.cleanup_script !== saved.cleanup_script
repo.cleanup_script !== saved.cleanup_script ||
repo.dev_script !== saved.dev_script
);
};
@@ -200,6 +201,7 @@ export function WorkspaceRepositoriesClient({
worktree_branch_prefix: 'feature/',
setup_script: '',
cleanup_script: '',
dev_script: '',
created_at: '',
updated_at: '',
scripts: [],
@@ -272,6 +274,7 @@ export function WorkspaceRepositoriesClient({
worktree_branch_prefix: repo.worktree_branch_prefix,
setup_script: repo.setup_script,
cleanup_script: repo.cleanup_script,
dev_script: repo.dev_script,
});
const scripts = await Promise.all(
repo.scripts.map((script, index) =>
@@ -301,6 +304,7 @@ export function WorkspaceRepositoriesClient({
worktree_branch_prefix: repo.worktree_branch_prefix,
setup_script: repo.setup_script,
cleanup_script: repo.cleanup_script,
dev_script: repo.dev_script,
});
const saved = savedRepositoriesById.get(repoId);
+27 -1
View File
@@ -15,17 +15,20 @@ import { useSessionAgentctl } from '@/hooks/use-session-agentctl';
import { useAppStore } from '@/components/state-provider';
import { fetchTask } from '@/lib/http';
import { useTasks } from '@/hooks/use-tasks';
import type { Layout } from 'react-resizable-panels';
type TaskPageClientProps = {
task: Task | null;
sessionId?: string | null;
initialRepositories?: Repository[];
defaultLayouts?: Record<string, Layout>;
};
export default function TaskPage({
task: initialTask,
sessionId = null,
initialRepositories = [],
defaultLayouts = {},
}: TaskPageClientProps) {
const [taskDetails, setTaskDetails] = useState<Task | null>(initialTask);
const [isMounted, setIsMounted] = useState(false);
@@ -72,6 +75,21 @@ export default function TaskPage({
effectiveSessionId ? state.taskSessions.items[effectiveSessionId]?.state ?? null : null
);
const agentctlStatus = useSessionAgentctl(effectiveSessionId);
const previewOpen = useAppStore((state) =>
effectiveSessionId ? state.previewPanel.openBySessionId[effectiveSessionId] ?? false : false
);
const previewStage = useAppStore((state) =>
effectiveSessionId ? state.previewPanel.stageBySessionId[effectiveSessionId] ?? 'closed' : 'closed'
);
const previewUrl = useAppStore((state) =>
effectiveSessionId ? state.previewPanel.urlBySessionId[effectiveSessionId] ?? '' : ''
);
const devProcessId = useAppStore((state) =>
effectiveSessionId ? state.processes.devProcessBySessionId[effectiveSessionId] : undefined
);
const devProcessStatus = useAppStore((state) =>
devProcessId ? state.processes.processesById[devProcessId]?.status ?? null : null
);
// Session resumption hook - handles auto-resume on page reload
const {
@@ -150,6 +168,11 @@ export default function TaskPage({
agentctl_ready: agentctlStatus.isReady,
agentctl_error: agentctlStatus.errorMessage ?? null,
agentctl_execution_id: agentctlStatus.agentExecutionId ?? null,
preview_open: previewOpen,
preview_stage: previewStage,
preview_url: previewUrl || null,
dev_process_id: devProcessId ?? null,
dev_process_status: devProcessStatus ?? null,
}}
/>
)}
@@ -167,12 +190,15 @@ export default function TaskPage({
worktreeBranch={worktreeBranch}
repositoryPath={repository?.local_path ?? null}
repositoryName={repository?.name ?? null}
hasDevScript={Boolean(repository?.dev_script?.trim())}
/>
<TaskLayout
workspaceId={task?.workspace_id ?? null}
boardId={task?.board_id ?? null}
sessionId={effectiveSessionId}
sessionId={effectiveSessionId ?? null}
repository={repository ?? null}
defaultLayouts={defaultLayouts}
/>
</div>
</TooltipProvider>
+29 -10
View File
@@ -1,6 +1,6 @@
'use client';
import { useEffect, useMemo, useState, useSyncExternalStore } from 'react';
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react';
import { Column } from './kanban-column';
import { Task } from './kanban-card';
import { TaskCreateDialog } from './task-create-dialog';
@@ -44,14 +44,11 @@ export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {}
useBoards(workspaceState.activeId, true);
useBoardSnapshot(boardsState.activeId);
const {
settings: userSettings,
commitSettings,
selectedRepositoryIds,
} = useUserDisplaySettings({
workspaceId: workspaceState.activeId,
boardId: boardsState.activeId,
onWorkspaceChange: (nextWorkspaceId) => {
const handleWorkspaceChange = useCallback(
(nextWorkspaceId: string | null) => {
if (nextWorkspaceId === workspaceState.activeId) {
return;
}
setActiveWorkspace(nextWorkspaceId);
if (nextWorkspaceId) {
router.push(`/?workspaceId=${nextWorkspaceId}`);
@@ -59,7 +56,14 @@ export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {}
router.push('/');
}
},
onBoardChange: (nextBoardId) => {
[router, setActiveWorkspace, workspaceState.activeId]
);
const handleBoardChange = useCallback(
(nextBoardId: string | null) => {
if (nextBoardId === boardsState.activeId) {
return;
}
setActiveBoard(nextBoardId);
if (nextBoardId) {
const workspaceId = boardsState.items.find((board) => board.id === nextBoardId)?.workspaceId;
@@ -67,6 +71,21 @@ export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {}
router.push(`/?boardId=${nextBoardId}${workspaceParam}`);
}
},
[boardsState.activeId, boardsState.items, router, setActiveBoard]
);
const stableWorkspaceId = workspaceState.activeId;
const stableBoardId = boardsState.activeId;
const {
settings: userSettings,
commitSettings,
selectedRepositoryIds,
} = useUserDisplaySettings({
workspaceId: stableWorkspaceId,
boardId: stableBoardId,
onWorkspaceChange: handleWorkspaceChange,
onBoardChange: handleBoardChange,
});
const isMounted = useSyncExternalStore(
@@ -59,11 +59,13 @@ export function RepositoryCard({
const worktreeBranchPrefix = repository.worktree_branch_prefix ?? '';
const setupScript = repository.setup_script ?? '';
const cleanupScript = repository.cleanup_script ?? '';
const devScript = repository.dev_script ?? '';
const isDirty = isRepositoryDirty || areScriptsDirty;
const scriptsCount = repository.scripts.length;
const hasSetupScript = Boolean(setupScript.trim());
const hasCleanupScript = Boolean(cleanupScript.trim());
const showScriptsSummary = scriptsCount > 0 || hasSetupScript || hasCleanupScript;
const hasDevScript = Boolean(devScript.trim());
const showScriptsSummary = scriptsCount > 0 || hasSetupScript || hasCleanupScript || hasDevScript;
const scriptsLabel = scriptsCount === 0
? 'No custom scripts'
: `${scriptsCount} custom script${scriptsCount === 1 ? '' : 's'}`;
@@ -196,6 +198,20 @@ export function RepositoryCard({
</div>
</div>
<div className="space-y-2">
<Label>Dev Script</Label>
<Textarea
value={devScript}
onChange={(e) => onUpdate(repository.id, { dev_script: e.target.value })}
placeholder="#!/bin/bash&#10;npm run dev"
rows={3}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
Used to start the preview dev server for this repository.
</p>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<Label className="flex items-center gap-2">
@@ -296,6 +312,7 @@ export function RepositoryCard({
{scriptsCount > 0 && <span>{scriptsLabel}</span>}
{hasSetupScript && <span>build script</span>}
{hasCleanupScript && <span>cleanup script</span>}
{hasDevScript && <span>dev script</span>}
</div>
) : null}
</div>
@@ -0,0 +1,13 @@
'use client';
import { usePreviewPanel } from '@/hooks/use-preview-panel';
type PreviewControllerProps = {
sessionId: string | null;
hasDevScript?: boolean;
};
export function PreviewController({ sessionId, hasDevScript }: PreviewControllerProps) {
usePreviewPanel({ sessionId, hasDevScript });
return null;
}
@@ -0,0 +1,194 @@
'use client';
import { useMemo, useState } from 'react';
import {
IconDeviceDesktop,
IconLayoutSidebarLeftCollapse,
IconLayoutSidebarRightCollapse,
} from '@tabler/icons-react';
import { Button } from '@kandev/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip';
import { startProcess } from '@/lib/http';
import { useAppStore } from '@/components/state-provider';
import { useLayoutStore } from '@/lib/state/layout-store';
type PreviewControlsProps = {
activeSessionId: string | null;
hasDevScript: boolean;
};
export function PreviewControls({ activeSessionId, hasDevScript }: PreviewControlsProps) {
const previewOpen = useAppStore((state) =>
activeSessionId ? state.previewPanel.openBySessionId[activeSessionId] ?? false : false
);
const closeLayoutPreview = useLayoutStore((state) => state.closePreview);
const applyPreset = useLayoutStore((state) => state.applyPreset);
const showColumn = useLayoutStore((state) => state.showColumn);
const toggleColumn = useLayoutStore((state) => state.toggleColumn);
const toggleRightPanel = useLayoutStore((state) => state.toggleRightPanel);
const layoutBySession = useLayoutStore((state) => state.columnsBySessionId);
const layoutState = useMemo(() => {
if (!activeSessionId) return null;
return layoutBySession[activeSessionId] ?? {
left: true,
chat: true,
right: true,
preview: false,
};
}, [layoutBySession, activeSessionId]);
const leftHidden = Boolean(layoutState && !layoutState.left);
const rightHidden = Boolean(layoutState && !layoutState.right);
const setPreviewOpen = useAppStore((state) => state.setPreviewOpen);
const setPreviewStage = useAppStore((state) => state.setPreviewStage);
const setPreviewView = useAppStore((state) => state.setPreviewView);
const devProcessId = useAppStore((state) =>
activeSessionId ? state.processes.devProcessBySessionId[activeSessionId] : undefined
);
const devProcess = useAppStore((state) =>
devProcessId ? state.processes.processesById[devProcessId] : undefined
);
const isDevRunning = devProcess?.status === 'running';
const upsertProcessStatus = useAppStore((state) => state.upsertProcessStatus);
const setActiveProcess = useAppStore((state) => state.setActiveProcess);
const [isStartingPreview, setIsStartingPreview] = useState(false);
const handleTogglePreview = () => {
if (!activeSessionId) return;
const nextOpen = !previewOpen;
if (nextOpen) {
setPreviewOpen(activeSessionId, true);
setPreviewView(activeSessionId, 'output');
setPreviewStage(activeSessionId, 'logs');
applyPreset(activeSessionId, 'preview');
if (hasDevScript && !isStartingPreview) {
const running = devProcess?.status === 'running' || devProcess?.status === 'starting';
if (!running) {
setIsStartingPreview(true);
startProcess(activeSessionId, { kind: 'dev' })
.then((resp) => {
if (resp?.process) {
const status = {
processId: resp.process.id,
sessionId: resp.process.session_id,
kind: resp.process.kind,
scriptName: resp.process.script_name,
status: resp.process.status,
command: resp.process.command,
workingDir: resp.process.working_dir,
exitCode: resp.process.exit_code ?? null,
startedAt: resp.process.started_at,
updatedAt: resp.process.updated_at,
};
upsertProcessStatus(status);
setActiveProcess(status.sessionId, status.processId);
}
})
.finally(() => {
setIsStartingPreview(false);
});
}
}
} else {
setPreviewOpen(activeSessionId, false);
setPreviewStage(activeSessionId, 'closed');
closeLayoutPreview(activeSessionId);
}
};
if (!hasDevScript || !activeSessionId) {
return null;
}
return (
<div className="flex items-center gap-2">
{previewOpen ? (
<div className="inline-flex items-center rounded-md border border-border/70 bg-background">
{leftHidden ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon-sm"
variant="ghost"
className="cursor-pointer rounded-none border-r border-border/70"
onClick={() => {
showColumn(activeSessionId, 'left');
}}
>
<IconLayoutSidebarLeftCollapse className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Show sidebar</TooltipContent>
</Tooltip>
) : (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon-sm"
variant="ghost"
className="cursor-pointer rounded-none border-r border-border/70"
onClick={() => {
toggleColumn(activeSessionId, 'left');
}}
>
<IconLayoutSidebarLeftCollapse className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Hide sidebar</TooltipContent>
</Tooltip>
)}
{rightHidden ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon-sm"
variant="ghost"
className="cursor-pointer rounded-none"
onClick={() => {
toggleRightPanel(activeSessionId);
}}
>
<IconLayoutSidebarRightCollapse className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Show right panel</TooltipContent>
</Tooltip>
) : (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon-sm"
variant="ghost"
className="cursor-pointer rounded-none"
onClick={() => {
toggleRightPanel(activeSessionId);
}}
>
<IconLayoutSidebarRightCollapse className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>Hide right panel</TooltipContent>
</Tooltip>
)}
</div>
) : null}
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon-sm"
variant="outline"
className="relative cursor-pointer border-border/70 bg-muted/30 hover:bg-muted/50"
onClick={handleTogglePreview}
>
<IconDeviceDesktop className="h-3 w-3" />
{isDevRunning ? (
<span className="absolute -right-0.5 -top-0.5 h-2 w-2 rounded-full bg-emerald-500 ring-2 ring-background" />
) : null}
</Button>
</TooltipTrigger>
<TooltipContent>
{previewOpen ? 'Hide Preview' : 'Show Preview'}
</TooltipContent>
</Tooltip>
</div>
);
}
@@ -0,0 +1,176 @@
'use client';
import { useState, useEffect } from 'react';
import { IconRefresh } from '@tabler/icons-react';
import { Button } from '@kandev/ui/button';
import { Input } from '@kandev/ui/input';
import { usePreviewPanel } from '@/hooks/use-preview-panel';
import { useAppStore, useAppStoreApi } from '@/components/state-provider';
import { useLayoutStore } from '@/lib/state/layout-store';
import { ProcessOutputTerminal } from '@/components/task/process-output-terminal';
import { getLocalStorage } from '@/lib/local-storage';
type PreviewPanelProps = {
sessionId: string | null;
hasDevScript: boolean;
};
export function PreviewPanel({ sessionId, hasDevScript }: PreviewPanelProps) {
const {
previewUrl,
previewUrlDraft,
setPreviewUrl,
setPreviewUrlDraft,
isStopping,
handleStop,
} = usePreviewPanel({ sessionId, hasDevScript });
const closeLayoutPreview = useLayoutStore((state) => state.closePreview);
const setPreviewOpen = useAppStore((state) => state.setPreviewOpen);
const setPreviewStage = useAppStore((state) => state.setPreviewStage);
const setPreviewView = useAppStore((state) => state.setPreviewView);
const clearProcessOutput = useAppStore((state) => state.clearProcessOutput);
const appStoreApi = useAppStoreApi();
const previewView = useAppStore((state) => {
if (!sessionId) return 'preview';
const stored = state.previewPanel.viewBySessionId[sessionId];
if (stored) return stored;
// Try to get from localStorage on first render
const key = `preview-view:${sessionId}`;
const localStored = getLocalStorage(key, null as string | null);
if (localStored === 'output' || localStored === 'preview') {
return localStored;
}
return 'preview';
});
const devProcessId = useAppStore((state) =>
sessionId ? state.processes.devProcessBySessionId[sessionId] : undefined
);
const devProcess = useAppStore((state) =>
devProcessId ? state.processes.processesById[devProcessId] : undefined
);
const devOutput = useAppStore ((state) =>
devProcessId ? state.processes.outputsByProcessId[devProcessId] ?? '' : ''
);
const [refreshKey, setRefreshKey] = useState(0);
const isProcessFailed = devProcess?.status === 'failed';
const isProcessExited = devProcess?.status === 'exited';
// Sync the view from localStorage to store if needed
useEffect(() => {
if (!sessionId) return;
const storeView = appStoreApi.getState().previewPanel.viewBySessionId[sessionId];
if (storeView) return; // Already set in store
const key = `preview-view:${sessionId}`;
const stored = getLocalStorage(key, null as string | null);
if (stored === 'output' || stored === 'preview') {
setPreviewView(sessionId, stored);
}
}, [sessionId, appStoreApi, setPreviewView]);
if (!sessionId) {
return (
<div className="h-full w-full flex items-center justify-center text-muted-foreground mr-[5px]">
Select a session to enable preview.
</div>
);
}
if (!hasDevScript) {
return (
<div className="h-full w-full flex items-center justify-center text-muted-foreground mr-[5px]">
Configure a dev script to use preview.
</div>
);
}
const handleStopClick = async () => {
if (!sessionId) return;
setPreviewOpen(sessionId, false);
setPreviewStage(sessionId, 'closed');
setPreviewView(sessionId, 'preview');
setPreviewUrl(sessionId, '');
setPreviewUrlDraft(sessionId, '');
closeLayoutPreview(sessionId);
if (devProcessId) {
clearProcessOutput(devProcessId);
}
await handleStop();
};
return (
<div className="h-full min-h-0 flex flex-col rounded-lg border border-border/70 bg-card p-3 mr-[5px]">
<div className="flex flex-wrap items-center gap-2">
<Input
value={previewUrlDraft}
onChange={(event) => setPreviewUrlDraft(sessionId, event.target.value)}
onBlur={() => setPreviewUrl(sessionId, previewUrlDraft)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
setPreviewUrl(sessionId, previewUrlDraft);
}
}}
placeholder="http://localhost:3000"
className="h-8 flex-1 min-w-[240px]"
/>
<Button
size="sm"
variant="outline"
onClick={() => setRefreshKey((value) => value + 1)}
disabled={!previewUrl}
className="cursor-pointer"
>
<IconRefresh className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="outline"
onClick={handleStopClick}
disabled={isStopping}
className="cursor-pointer"
>
{isStopping ? 'Stopping…' : (isProcessFailed || isProcessExited) ? 'Close' : 'Stop'}
</Button>
<Button
size="sm"
variant={previewView === 'output' ? 'default' : 'outline'}
className="cursor-pointer"
onClick={() =>
setPreviewView(sessionId, previewView === 'output' ? 'preview' : 'output')
}
>
{previewView === 'output' ? 'Preview' : 'Logs'}
</Button>
</div>
<div className="flex-1 min-h-0 mt-3">
{previewView === 'output' ? (
<div className="h-full w-full">
<ProcessOutputTerminal output={devOutput} processId={devProcessId ?? null} />
</div>
) : previewUrl ? (
<div className="h-full w-full flex items-center justify-center overflow-hidden">
<div className="h-full w-full bg-background border border-border rounded-md overflow-hidden">
<iframe
key={refreshKey}
src={previewUrl}
title="Preview"
className="h-full w-full border-0"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-modals"
referrerPolicy="no-referrer"
/>
</div>
</div>
) : (
<div className="h-full w-full flex items-center justify-center text-muted-foreground">
Waiting for a preview URL…
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,140 @@
'use client';
import { useEffect, useRef } from 'react';
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import '@xterm/xterm/css/xterm.css';
const DEFAULT_FONT = 'Menlo, Monaco, "Courier New", monospace';
type ProcessOutputTerminalProps = {
output: string;
processId?: string | null;
isStopping?: boolean;
};
export function ProcessOutputTerminal({ output, processId, isStopping = false }: ProcessOutputTerminalProps) {
const terminalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<Terminal | null>(null);
const fitAddonRef = useRef<FitAddon | null>(null);
const lastOutputLengthRef = useRef(0);
const processIdRef = useRef<string | null>(null);
const outputRef = useRef(output);
useEffect(() => {
outputRef.current = output;
}, [output]);
useEffect(() => {
if (!terminalRef.current || xtermRef.current) return;
const terminal = new Terminal({
cursorBlink: false,
disableStdin: true,
convertEol: true,
fontSize: 12,
fontFamily: DEFAULT_FONT,
theme: {
background: '#1e1e1e',
foreground: '#d4d4d4',
cursor: '#d4d4d4',
cursorAccent: '#1e1e1e',
selectionBackground: '#264f78',
black: '#1e1e1e',
red: '#f44747',
green: '#6a9955',
yellow: '#dcdcaa',
blue: '#569cd6',
magenta: '#c586c0',
cyan: '#4ec9b0',
white: '#d4d4d4',
brightBlack: '#808080',
brightRed: '#f44747',
brightGreen: '#6a9955',
brightYellow: '#dcdcaa',
brightBlue: '#569cd6',
brightMagenta: '#c586c0',
brightCyan: '#4ec9b0',
brightWhite: '#ffffff',
},
});
const fitAddon = new FitAddon();
terminal.loadAddon(fitAddon);
terminal.open(terminalRef.current);
fitAddon.fit();
xtermRef.current = terminal;
fitAddonRef.current = fitAddon;
if (outputRef.current) {
terminal.write(outputRef.current);
lastOutputLengthRef.current = outputRef.current.length;
}
const initialFitTimeout = setTimeout(() => {
fitAddon.fit();
}, 100);
const resizeObserver = new ResizeObserver(() => {
fitAddon.fit();
});
resizeObserver.observe(terminalRef.current);
const intersectionObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && fitAddonRef.current) {
requestAnimationFrame(() => {
fitAddonRef.current?.fit();
});
}
});
},
{ threshold: 0.1 }
);
intersectionObserver.observe(terminalRef.current);
return () => {
clearTimeout(initialFitTimeout);
resizeObserver.disconnect();
intersectionObserver.disconnect();
terminal.dispose();
xtermRef.current = null;
fitAddonRef.current = null;
};
}, []);
useEffect(() => {
if (!xtermRef.current) return;
if (processIdRef.current !== processId) {
processIdRef.current = processId ?? null;
lastOutputLengthRef.current = 0;
xtermRef.current.clear();
if (outputRef.current) {
xtermRef.current.write(outputRef.current);
lastOutputLengthRef.current = outputRef.current.length;
}
}
}, [processId]);
useEffect(() => {
if (!xtermRef.current) return;
const newData = output.slice(lastOutputLengthRef.current);
if (newData) {
xtermRef.current.write(newData);
lastOutputLengthRef.current = output.length;
}
}, [output]);
return (
<div className="h-full w-full rounded-md bg-[#1e1e1e] relative">
<div ref={terminalRef} className="absolute inset-0" />
{isStopping ? (
<div className="absolute right-3 top-2 text-xs text-muted-foreground">
Stopping…
</div>
) : null}
</div>
);
}
+121 -34
View File
@@ -1,32 +1,56 @@
'use client';
import { memo, useState } from 'react';
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@kandev/ui/resizable';
import { getLocalStorage, setLocalStorage } from '@/lib/local-storage';
import { memo, useMemo, useState } from 'react';
import { Group, Panel, Separator, type Layout } from 'react-resizable-panels';
import { useDefaultLayout } from '@/lib/layout/use-default-layout';
import { TaskCenterPanel } from './task-center-panel';
import { TaskRightPanel } from './task-right-panel';
import { TaskFilesPanel } from './task-files-panel';
import { TaskSessionSidebar } from './task-session-sidebar';
import { PreviewPanel } from '@/components/task/preview/preview-panel';
import { PreviewController } from '@/components/task/preview/preview-controller';
import { useLayoutStore } from '@/lib/state/layout-store';
import type { OpenFileTab } from '@/lib/types/backend';
import type { Repository } from '@/lib/types/http';
import { useAppStore } from '@/components/state-provider';
const DEFAULT_HORIZONTAL_LAYOUT: [number, number, number] = [18, 57, 25];
const DEFAULT_HORIZONTAL_LAYOUT: Record<string, number> = {
left: 18,
chat: 57,
preview: 57,
right: 25,
};
const DEFAULT_PREVIEW_LAYOUT: Record<string, number> = {
chat: 60,
preview: 40,
};
const DEFAULT_LAYOUT_STATE = { left: true, chat: true, right: true, preview: false };
type TaskLayoutProps = {
workspaceId: string | null;
boardId: string | null;
sessionId?: string | null;
repository?: Repository | null;
defaultLayouts?: Record<string, Layout>;
};
export const TaskLayout = memo(function TaskLayout({
workspaceId,
boardId,
sessionId = null,
repository = null,
defaultLayouts = {},
}: TaskLayoutProps) {
const [horizontalLayout, setHorizontalLayout] = useState<[number, number, number]>(
getLocalStorage('task-layout-horizontal-v2', DEFAULT_HORIZONTAL_LAYOUT)
);
const [selectedDiffPath, setSelectedDiffPath] = useState<string | null>(null);
const [openFileRequest, setOpenFileRequest] = useState<OpenFileTab | null>(null);
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
const layoutBySession = useLayoutStore((state) => state.columnsBySessionId);
const sessionKey = activeSessionId ?? sessionId ?? '';
const layoutState = useMemo(
() => layoutBySession[sessionKey] ?? DEFAULT_LAYOUT_STATE,
[layoutBySession, sessionKey]
);
const hasDevScript = Boolean(repository?.dev_script?.trim());
const handleSelectDiffPath = (path: string) => {
setSelectedDiffPath(path);
@@ -43,37 +67,100 @@ export const TaskLayout = memo(function TaskLayout({
/>
);
const sessionForPreview = activeSessionId ?? sessionId ?? null;
const horizontalPanelIds = useMemo(() => {
const ids: string[] = [];
if (layoutState.left) ids.push('left');
ids.push(layoutState.preview ? 'preview' : 'chat');
if (layoutState.right) ids.push('right');
return ids;
}, [layoutState.left, layoutState.preview, layoutState.right]);
const horizontalLayoutKey = `task-layout-horizontal-v3:${horizontalPanelIds.join('|')}`;
const { defaultLayout: defaultHorizontalLayout, onLayoutChanged: onHorizontalLayoutChange } =
useDefaultLayout({
id: horizontalLayoutKey,
panelIds: horizontalPanelIds,
baseLayout: DEFAULT_HORIZONTAL_LAYOUT,
serverDefaultLayout: defaultLayouts[horizontalLayoutKey],
});
const previewPanelIds = ['chat', 'preview'];
const previewLayoutKey = `task-layout-preview-v2:${sessionKey}`;
const { defaultLayout: defaultPreviewLayout, onLayoutChanged: onPreviewLayoutChange } =
useDefaultLayout({
id: previewLayoutKey,
panelIds: previewPanelIds,
baseLayout: DEFAULT_PREVIEW_LAYOUT,
serverDefaultLayout: defaultLayouts[previewLayoutKey],
});
return (
<div className="flex-1 min-h-0 px-4 pb-4">
<ResizablePanelGroup
direction="horizontal"
<PreviewController sessionId={sessionForPreview} hasDevScript={hasDevScript} />
<Group
orientation="horizontal"
className="h-full min-h-0 min-w-0"
onLayout={(sizes) => {
setHorizontalLayout(sizes as [number, number, number]);
setLocalStorage('task-layout-horizontal-v2', sizes);
}}
id={horizontalLayoutKey}
key={horizontalLayoutKey}
defaultLayout={defaultHorizontalLayout}
onLayoutChanged={onHorizontalLayoutChange}
>
<ResizablePanel defaultSize={horizontalLayout[0]} minSize={12} className="min-h-0 min-w-0">
<TaskSessionSidebar
workspaceId={workspaceId}
boardId={boardId}
/>
</ResizablePanel>
<ResizableHandle className="w-px" />
<ResizablePanel defaultSize={horizontalLayout[1]} minSize={45} className="min-h-0 min-w-0">
<TaskCenterPanel
selectedDiffPath={selectedDiffPath}
openFileRequest={openFileRequest}
onDiffPathHandled={() => setSelectedDiffPath(null)}
onFileOpenHandled={() => setOpenFileRequest(null)}
sessionId={sessionId}
/>
</ResizablePanel>
<ResizableHandle className="w-px" />
<ResizablePanel defaultSize={horizontalLayout[2]} minSize={20} className="min-h-0 min-w-0">
<TaskRightPanel topPanel={topFilesPanel} />
</ResizablePanel>
</ResizablePanelGroup>
{layoutState.left ? (
<>
<Panel id="left" minSize={12} className="min-h-0 min-w-0">
<TaskSessionSidebar workspaceId={workspaceId} boardId={boardId} />
</Panel>
<Separator className="w-px" />
</>
) : null}
{layoutState.preview ? (
<Panel id="preview" minSize={40} className="min-h-0 min-w-0">
<Group
orientation="horizontal"
className="h-full min-h-0 min-w-0"
id={previewLayoutKey}
key={previewLayoutKey}
defaultLayout={defaultPreviewLayout}
onLayoutChanged={onPreviewLayoutChange}
>
<Panel id="chat" minSize={20} className="min-h-0 min-w-0">
<TaskCenterPanel
selectedDiffPath={selectedDiffPath}
openFileRequest={openFileRequest}
onDiffPathHandled={() => setSelectedDiffPath(null)}
onFileOpenHandled={() => setOpenFileRequest(null)}
/>
</Panel>
<Separator className="w-px" />
<Panel id="preview" minSize={20} className="min-h-0 min-w-0">
<PreviewPanel sessionId={sessionForPreview} hasDevScript={hasDevScript} />
</Panel>
</Group>
</Panel>
) : (
<Panel id="chat" minSize={45} className="min-h-0 min-w-0">
<TaskCenterPanel
selectedDiffPath={selectedDiffPath}
openFileRequest={openFileRequest}
onDiffPathHandled={() => setSelectedDiffPath(null)}
onFileOpenHandled={() => setOpenFileRequest(null)}
sessionId={sessionId}
/>
</Panel>
)}
{layoutState.right ? (
<>
<Separator className="w-px" />
<Panel id="right" minSize={20} className="min-h-0 min-w-0">
<TaskRightPanel topPanel={topFilesPanel} sessionId={sessionForPreview} />
</Panel>
</>
) : null}
</Group>
</div>
);
});
+142 -12
View File
@@ -1,7 +1,7 @@
'use client';
import type { ReactNode } from 'react';
import { memo, useState } from 'react';
import type { ReactNode, MouseEvent } from 'react';
import { memo, useEffect, useState } from 'react';
import { IconChevronDown, IconChevronUp, IconX } from '@tabler/icons-react';
import { Badge } from '@kandev/ui/badge';
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@kandev/ui/resizable';
@@ -9,25 +9,52 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@kandev/ui/tabs';
import { getLocalStorage, setLocalStorage } from '@/lib/local-storage';
import { COMMANDS } from '@/components/task/task-data';
import { ShellTerminal } from '@/components/task/shell-terminal';
import { ProcessOutputTerminal } from '@/components/task/process-output-terminal';
import { useAppStore } from '@/components/state-provider';
import { useLayoutStore } from '@/lib/state/layout-store';
import { stopProcess } from '@/lib/http';
import type { Layout } from 'react-resizable-panels';
type TaskRightPanelProps = {
topPanel: ReactNode;
sessionId?: string | null;
};
const DEFAULT_RIGHT_LAYOUT: [number, number] = [55, 45];
const DEFAULT_RIGHT_LAYOUT: Layout = { top: 55, bottom: 45 };
const TaskRightPanel = memo(function TaskRightPanel({ topPanel }: TaskRightPanelProps) {
const [rightLayout, setRightLayout] = useState<[number, number]>(() =>
const TaskRightPanel = memo(function TaskRightPanel({ topPanel, sessionId = null }: TaskRightPanelProps) {
const [rightLayout, setRightLayout] = useState<Layout>(() =>
getLocalStorage('task-layout-right', DEFAULT_RIGHT_LAYOUT)
);
const [activeTerminalId, setActiveTerminalId] = useState(1);
const [terminalIds, setTerminalIds] = useState([1]);
const [isBottomCollapsed, setIsBottomCollapsed] = useState(false);
const [isStoppingDev, setIsStoppingDev] = useState(false);
const activeTab = useAppStore((state) =>
sessionId ? state.rightPanel.activeTabBySessionId[sessionId] : undefined
);
const setRightPanelActiveTab = useAppStore((state) => state.setRightPanelActiveTab);
const closeLayoutPreview = useLayoutStore((state) => state.closePreview);
const setPreviewOpen = useAppStore((state) => state.setPreviewOpen);
const setPreviewStage = useAppStore((state) => state.setPreviewStage);
const devProcessId = useAppStore((state) =>
sessionId ? state.processes.devProcessBySessionId[sessionId] : undefined
);
const devOutput = useAppStore((state) =>
devProcessId ? state.processes.outputsByProcessId[devProcessId] ?? '' : ''
);
const previewOpen = useAppStore((state) =>
sessionId ? state.previewPanel.openBySessionId[sessionId] ?? false : false
);
const showDevTab = Boolean(sessionId && previewOpen);
const addTerminal = () => {
setTerminalIds((ids) => {
const nextId = Math.max(0, ...ids) + 1;
setActiveTerminalId(nextId);
if (sessionId) {
setRightPanelActiveTab(sessionId, `terminal-${nextId}`);
}
return [...ids, nextId];
});
};
@@ -38,12 +65,64 @@ const TaskRightPanel = memo(function TaskRightPanel({ topPanel }: TaskRightPanel
if (activeTerminalId === id) {
const fallback = nextIds[0] ?? 1;
setActiveTerminalId(fallback);
if (sessionId) {
setRightPanelActiveTab(sessionId, `terminal-${fallback}`);
}
}
return nextIds.length ? nextIds : [1];
});
};
const terminalTabValue = activeTerminalId === 0 ? 'commands' : `terminal-${activeTerminalId}`;
const terminalTabValue =
activeTab ?? (activeTerminalId === 0 ? 'commands' : `terminal-${activeTerminalId}`);
useEffect(() => {
if (!sessionId) return;
if (activeTab && activeTab.startsWith('terminal-')) {
const parsed = Number(activeTab.replace('terminal-', ''));
if (!Number.isNaN(parsed) && parsed !== activeTerminalId) {
setActiveTerminalId(parsed);
}
}
}, [activeTab, activeTerminalId, sessionId]);
useEffect(() => {
if (!sessionId || !activeTab) return;
if (activeTab.startsWith('terminal-')) {
const parsed = Number(activeTab.replace('terminal-', ''));
if (Number.isNaN(parsed) || !terminalIds.includes(parsed)) {
const fallback = terminalIds[0] ?? 1;
setActiveTerminalId(fallback);
setRightPanelActiveTab(sessionId, `terminal-${fallback}`);
}
}
}, [activeTab, sessionId, terminalIds, setRightPanelActiveTab]);
useEffect(() => {
if (!sessionId) return;
if (!showDevTab && terminalTabValue === 'dev-server') {
const fallback = `terminal-${terminalIds[0] ?? 1}`;
setRightPanelActiveTab(sessionId, fallback);
}
}, [showDevTab, sessionId, terminalTabValue, terminalIds, setRightPanelActiveTab]);
const handleCloseDevTab = async (event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
if (!sessionId) return;
if (devProcessId) {
setIsStoppingDev(true);
try {
await stopProcess(sessionId, { process_id: devProcessId });
} finally {
setIsStoppingDev(false);
}
}
setRightPanelActiveTab(sessionId, `terminal-${terminalIds[0] ?? 1}`);
setPreviewOpen(sessionId, false);
setPreviewStage(sessionId, 'closed');
closeLayoutPreview(sessionId);
};
if (isBottomCollapsed) {
return (
@@ -55,11 +134,17 @@ const TaskRightPanel = memo(function TaskRightPanel({ topPanel }: TaskRightPanel
onValueChange={(value) => {
if (value === 'commands') {
setActiveTerminalId(0);
if (sessionId) setRightPanelActiveTab(sessionId, value);
return;
}
if (value === 'dev-server') {
if (sessionId) setRightPanelActiveTab(sessionId, value);
return;
}
const parsed = Number(value.replace('terminal-', ''));
if (!Number.isNaN(parsed)) {
setActiveTerminalId(parsed);
if (sessionId) setRightPanelActiveTab(sessionId, value);
}
}}
className="flex-1 min-h-0"
@@ -68,6 +153,19 @@ const TaskRightPanel = memo(function TaskRightPanel({ topPanel }: TaskRightPanel
<TabsTrigger value="commands" className="cursor-pointer">
Commands
</TabsTrigger>
{showDevTab ? (
<TabsTrigger value="dev-server" className="group flex items-center gap-1 pr-1 cursor-pointer">
Dev Server
<span
role="button"
tabIndex={-1}
className="text-muted-foreground opacity-0 group-hover:opacity-100 hover:text-foreground"
onClick={handleCloseDevTab}
>
<IconX className="h-3.5 w-3.5" />
</span>
</TabsTrigger>
) : null}
{terminalIds.map((id) => (
<TabsTrigger key={id} value={`terminal-${id}`} className="cursor-pointer">
{id === 1 ? 'Terminal' : `Terminal ${id}`}
@@ -91,30 +189,38 @@ const TaskRightPanel = memo(function TaskRightPanel({ topPanel }: TaskRightPanel
}
return (
<ResizablePanelGroup
direction="vertical"
<ResizablePanelGroup
orientation="vertical"
className="h-full"
onLayout={(sizes) => {
setRightLayout(sizes as [number, number]);
id="task-right-panel"
defaultLayout={rightLayout}
onLayoutChanged={(sizes) => {
setRightLayout(sizes);
setLocalStorage('task-layout-right', sizes);
}}
>
<ResizablePanel defaultSize={rightLayout[0]} minSize={30}>
<ResizablePanel id="top" defaultSize={rightLayout.top} minSize={30}>
{topPanel}
</ResizablePanel>
<ResizableHandle className="h-px" />
<ResizablePanel defaultSize={rightLayout[1]} minSize={20}>
<ResizablePanel id="bottom" defaultSize={rightLayout.bottom} minSize={20}>
<div className="h-full min-h-0 bg-card p-4 flex flex-col rounded-lg border border-border/70 border-l-0 mt-[5px]">
<Tabs
value={terminalTabValue}
onValueChange={(value) => {
if (value === 'commands') {
setActiveTerminalId(0);
if (sessionId) setRightPanelActiveTab(sessionId, value);
return;
}
if (value === 'dev-server') {
if (sessionId) setRightPanelActiveTab(sessionId, value);
return;
}
const parsed = Number(value.replace('terminal-', ''));
if (!Number.isNaN(parsed)) {
setActiveTerminalId(parsed);
if (sessionId) setRightPanelActiveTab(sessionId, value);
}
}}
className="flex-1 min-h-0"
@@ -124,6 +230,19 @@ const TaskRightPanel = memo(function TaskRightPanel({ topPanel }: TaskRightPanel
<TabsTrigger value="commands" className="cursor-pointer">
Commands
</TabsTrigger>
{showDevTab ? (
<TabsTrigger value="dev-server" className="group flex items-center gap-1 pr-1 cursor-pointer">
Dev Server
<span
role="button"
tabIndex={-1}
className="text-muted-foreground opacity-0 group-hover:opacity-100 hover:text-foreground"
onClick={handleCloseDevTab}
>
<IconX className="h-3.5 w-3.5" />
</span>
</TabsTrigger>
) : null}
{terminalIds.map((id) => (
<TabsTrigger
key={id}
@@ -175,6 +294,17 @@ const TaskRightPanel = memo(function TaskRightPanel({ topPanel }: TaskRightPanel
</div>
</div>
</TabsContent>
{showDevTab ? (
<TabsContent value="dev-server" className="flex-1 min-h-0">
<div className="flex-1 min-h-0 h-full p-1">
<ProcessOutputTerminal
output={devOutput}
processId={devProcessId ?? null}
isStopping={isStoppingDev}
/>
</div>
</TabsContent>
) : null}
{terminalIds.map((id) => (
<TabsContent key={id} value={`terminal-${id}`} className="flex-1 min-h-0">
<div className="flex-1 min-h-0 h-full p-1">
@@ -45,6 +45,7 @@ import { useGitOperations } from '@/hooks/use-git-operations';
import { formatUserHomePath } from '@/lib/utils';
import { EditorsMenu } from '@/components/task/editors-menu';
import { useToast } from '@/components/toast-provider';
import { PreviewControls } from '@/components/task/preview/preview-controls';
type TaskTopBarProps = {
taskId?: string | null;
@@ -60,6 +61,7 @@ type TaskTopBarProps = {
worktreeBranch?: string | null;
repositoryPath?: string | null;
repositoryName?: string | null;
hasDevScript?: boolean;
};
const TaskTopBar = memo(function TaskTopBar({
@@ -70,6 +72,7 @@ const TaskTopBar = memo(function TaskTopBar({
worktreeBranch,
repositoryPath,
repositoryName,
hasDevScript = false,
}: TaskTopBarProps) {
const [copiedBranch, setCopiedBranch] = useState(false);
const [popoverOpen, setPopoverOpen] = useState(false);
@@ -211,6 +214,8 @@ const TaskTopBar = memo(function TaskTopBar({
}
};
// Preview controls are handled by a dedicated component.
const handleCopyRepo = (e: React.MouseEvent) => {
e.stopPropagation();
if (repositoryPath) {
@@ -366,6 +371,7 @@ const TaskTopBar = memo(function TaskTopBar({
</div>
<div className="flex items-center gap-2">
<PreviewControls activeSessionId={activeSessionId ?? null} hasDevScript={hasDevScript} />
<EditorsMenu activeSessionId={activeSessionId ?? null} />
{/* Commit Split Button */}
+277
View File
@@ -0,0 +1,277 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { getSessionProcess, listSessionProcesses, startProcess, stopProcess } from '@/lib/http';
import { useAppStore } from '@/components/state-provider';
import { useLayoutStore } from '@/lib/state/layout-store';
import type { ProcessInfo } from '@/lib/types/http';
import type { ProcessStatusEntry } from '@/lib/state/store';
import { getLocalStorage } from '@/lib/local-storage';
type UsePreviewPanelParams = {
sessionId: string | null;
hasDevScript?: boolean;
};
function toProcessStatusEntry(process: ProcessInfo): ProcessStatusEntry {
return {
processId: process.id,
sessionId: process.session_id,
kind: process.kind,
scriptName: process.script_name,
status: process.status,
command: process.command,
workingDir: process.working_dir,
exitCode: process.exit_code ?? null,
startedAt: process.started_at,
updatedAt: process.updated_at,
};
}
export function usePreviewPanel({ sessionId, hasDevScript = false }: UsePreviewPanelParams) {
const [isStopping, setIsStopping] = useState(false);
const [hasInitialized, setHasInitialized] = useState(false);
const [hasRestoredState, setHasRestoredState] = useState(false);
const [isStartingOnRestore, setIsStartingOnRestore] = useState(false);
const processState = useAppStore((state) => state.processes);
const previewOpen = useAppStore((state) =>
sessionId ? state.previewPanel.openBySessionId[sessionId] ?? false : false
);
const previewStage = useAppStore((state) =>
sessionId ? state.previewPanel.stageBySessionId[sessionId] ?? 'closed' : 'closed'
);
const previewUrl = useAppStore((state) =>
sessionId ? state.previewPanel.urlBySessionId[sessionId] ?? '' : ''
);
const previewUrlDraft = useAppStore((state) =>
sessionId ? state.previewPanel.urlDraftBySessionId[sessionId] ?? '' : ''
);
const upsertProcessStatus = useAppStore((state) => state.upsertProcessStatus);
const appendProcessOutput = useAppStore((state) => state.appendProcessOutput);
const clearProcessOutput = useAppStore((state) => state.clearProcessOutput);
const setActiveProcess = useAppStore((state) => state.setActiveProcess);
const setPreviewOpen = useAppStore((state) => state.setPreviewOpen);
const setPreviewStage = useAppStore((state) => state.setPreviewStage);
const setPreviewView = useAppStore((state) => state.setPreviewView);
const applyLayoutPreset = useLayoutStore((state) => state.applyPreset);
const layoutState = useLayoutStore((state) =>
sessionId ? state.columnsBySessionId[sessionId] : null
);
const setPreviewUrl = useAppStore((state) => state.setPreviewUrl);
const setPreviewUrlDraft = useAppStore((state) => state.setPreviewUrlDraft);
const devProcessId = useMemo(
() => (sessionId ? processState.devProcessBySessionId[sessionId] : undefined),
[processState.devProcessBySessionId, sessionId]
);
const devProcess = devProcessId ? processState.processesById[devProcessId] ?? null : null;
const devOutput = devProcessId ? processState.outputsByProcessId[devProcessId] ?? '' : '';
const detectedUrl = useMemo(() => {
if (!devOutput) return null;
const matches = devOutput.match(/https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?[^\s]*/g);
if (!matches || matches.length === 0) return null;
return matches[matches.length - 1];
}, [devOutput]);
useEffect(() => {
if (!sessionId) return;
if (!previewOpen || previewStage !== 'logs') return;
if (!detectedUrl) return;
setPreviewUrl(sessionId, detectedUrl);
setPreviewUrlDraft(sessionId, detectedUrl);
setPreviewView(sessionId, 'preview');
setPreviewStage(sessionId, 'preview');
applyLayoutPreset(sessionId, 'preview');
}, [
sessionId,
previewOpen,
previewStage,
detectedUrl,
setPreviewUrl,
setPreviewUrlDraft,
setPreviewView,
setPreviewStage,
applyLayoutPreset,
]);
useEffect(() => {
if (!sessionId) return;
let cancelled = false;
const loadProcesses = async () => {
try {
const processes = await listSessionProcesses(sessionId);
if (cancelled) return;
const sorted = processes.slice().sort((a, b) => {
if (a.kind === 'dev' && b.kind !== 'dev') return -1;
if (a.kind !== 'dev' && b.kind === 'dev') return 1;
return 0;
});
sorted.forEach((proc) => {
upsertProcessStatus(toProcessStatusEntry(proc));
});
setHasInitialized(true);
} catch {
// ignore
setHasInitialized(true);
}
};
loadProcesses();
return () => {
cancelled = true;
};
}, [sessionId, upsertProcessStatus]);
useEffect(() => {
if (!sessionId || !devProcessId) return;
let cancelled = false;
const loadOutput = async () => {
try {
const proc = await getSessionProcess(sessionId, devProcessId, true);
if (cancelled) return;
clearProcessOutput(devProcessId);
proc.output?.forEach((chunk) => {
appendProcessOutput(devProcessId, chunk.data);
});
} catch {
// ignore
}
};
loadOutput();
return () => {
cancelled = true;
};
}, [sessionId, devProcessId, clearProcessOutput, appendProcessOutput]);
// Restore preview state on mount if layout shows preview and dev process is running
useEffect(() => {
if (!sessionId || !hasInitialized || hasRestoredState || isStartingOnRestore) return;
const isPreviewLayout = layoutState?.preview === true;
const hasDevProcess = devProcessId !== undefined;
const isDevRunning = devProcess?.status === 'running' || devProcess?.status === 'starting';
// Restore the preview view mode from localStorage
const key = `preview-view:${sessionId}`;
const stored = getLocalStorage(key, null as string | null);
const restoredView: 'output' | 'preview' =
stored === 'output' || stored === 'preview' ? stored : 'output';
if (isPreviewLayout) {
if (hasDevProcess && isDevRunning) {
// Layout shows preview and dev is running - restore everything
if (!previewOpen) {
setPreviewOpen(sessionId, true);
}
setPreviewView(sessionId, restoredView);
if (previewStage === 'closed') {
setPreviewStage(sessionId, 'preview');
}
setHasRestoredState(true);
} else if (!hasDevProcess && hasDevScript) {
// Layout shows preview but no process - start one
setIsStartingOnRestore(true);
setPreviewOpen(sessionId, true);
setPreviewView(sessionId, restoredView);
setPreviewStage(sessionId, 'preview');
startProcess(sessionId, { kind: 'dev' })
.then((resp) => {
if (resp?.process) {
const status = {
processId: resp.process.id,
sessionId: resp.process.session_id,
kind: resp.process.kind,
scriptName: resp.process.script_name,
status: resp.process.status,
command: resp.process.command,
workingDir: resp.process.working_dir,
exitCode: resp.process.exit_code ?? null,
startedAt: resp.process.started_at,
updatedAt: resp.process.updated_at,
};
upsertProcessStatus(status);
setActiveProcess(status.sessionId, status.processId);
}
})
.finally(() => {
setIsStartingOnRestore(false);
setHasRestoredState(true);
});
} else {
// Layout shows preview but process exists and isn't running - still restore view
setPreviewView(sessionId, restoredView);
setHasRestoredState(true);
}
} else {
// No preview in layout - mark as restored so we don't keep checking
setHasRestoredState(true);
}
}, [
sessionId,
hasInitialized,
hasRestoredState,
isStartingOnRestore,
previewOpen,
layoutState,
devProcessId,
devProcess,
previewStage,
hasDevScript,
setPreviewOpen,
setPreviewView,
setPreviewStage,
upsertProcessStatus,
setActiveProcess,
]);
useEffect(() => {
if (!sessionId) return;
if (!detectedUrl) return;
if (!previewOpen && previewStage === 'closed') {
return;
}
if (!previewOpen) {
setPreviewOpen(sessionId, true);
}
if (!previewUrl) {
setPreviewUrl(sessionId, detectedUrl);
setPreviewUrlDraft(sessionId, detectedUrl);
}
}, [
sessionId,
previewOpen,
detectedUrl,
previewUrl,
previewStage,
setPreviewOpen,
setPreviewUrl,
setPreviewUrlDraft,
]);
const handleStop = async () => {
if (!sessionId || !devProcess) return;
setIsStopping(true);
try {
await stopProcess(sessionId, { process_id: devProcess.processId });
} finally {
setIsStopping(false);
}
};
const isRunning = devProcess?.status === 'running' || devProcess?.status === 'starting';
return {
previewStage,
previewUrl,
previewUrlDraft,
setPreviewUrl,
setPreviewUrlDraft,
detectedUrl,
isRunning,
isStopping,
handleStop,
};
}
+19 -1
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { getWebSocketClient } from '@/lib/ws/connection';
import { fetchUserSettings, updateUserSettings } from '@/lib/http';
import { mapSelectedRepositoryIds } from '@/lib/kanban/filters';
@@ -40,6 +40,8 @@ export function useUserDisplaySettings({
const userSettings = useAppStore((state) => state.userSettings);
const setUserSettings = useAppStore((state) => state.setUserSettings);
const { repositories, isLoading: repositoriesLoading } = useRepositories(workspaceId, true);
const lastAppliedWorkspaceIdRef = useRef<string | null>(null);
const lastAppliedBoardIdRef = useRef<string | null>(null);
const commitSettings = useCallback(
(next: CommitPayload) => {
@@ -113,7 +115,15 @@ export function useUserDisplaySettings({
useEffect(() => {
if (!userSettings.loaded) return;
if (userSettings.workspaceId && userSettings.workspaceId !== workspaceId) {
if (lastAppliedWorkspaceIdRef.current === userSettings.workspaceId) {
return;
}
lastAppliedWorkspaceIdRef.current = userSettings.workspaceId;
onWorkspaceChange?.(userSettings.workspaceId);
return;
}
if (userSettings.workspaceId === workspaceId) {
lastAppliedWorkspaceIdRef.current = null;
}
}, [onWorkspaceChange, userSettings.loaded, userSettings.workspaceId, workspaceId]);
@@ -133,7 +143,15 @@ export function useUserDisplaySettings({
useEffect(() => {
if (!userSettings.loaded) return;
if (userSettings.boardId && userSettings.boardId !== boardId) {
if (lastAppliedBoardIdRef.current === userSettings.boardId) {
return;
}
lastAppliedBoardIdRef.current = userSettings.boardId;
onBoardChange?.(userSettings.boardId);
return;
}
if (userSettings.boardId === boardId) {
lastAppliedBoardIdRef.current = null;
}
}, [boardId, onBoardChange, userSettings.boardId, userSettings.loaded]);
+46
View File
@@ -12,6 +12,7 @@ import type {
ListWorkspacesResponse,
ListMessagesResponse,
RepositoryBranchesResponse,
ListRepositoryScriptsResponse,
NotificationProvidersResponse,
NotificationProvider,
EditorsResponse,
@@ -23,6 +24,7 @@ import type {
TaskSessionsResponse,
TaskSessionResponse,
UserSettingsResponse,
ProcessInfo,
Workspace,
} from '@/lib/types/http';
@@ -96,6 +98,50 @@ export async function listRepositoryBranches(repositoryId: string, options?: Api
return fetchJson<RepositoryBranchesResponse>(`/api/v1/repositories/${repositoryId}/branches`, options);
}
export async function listRepositoryScripts(repositoryId: string, options?: ApiRequestOptions) {
return fetchJson<ListRepositoryScriptsResponse>(`/api/v1/repositories/${repositoryId}/scripts`, options);
}
export async function startProcess(
sessionId: string,
payload: { kind: string; script_name?: string; repo_id?: string },
options?: ApiRequestOptions
) {
return fetchJson<{ process: ProcessInfo }>(`/api/v1/task-sessions/${sessionId}/processes/start`, {
...options,
init: { method: 'POST', body: JSON.stringify(payload), ...(options?.init ?? {}) },
});
}
export async function stopProcess(
sessionId: string,
payload: { process_id: string },
options?: ApiRequestOptions
) {
return fetchJson<null>(`/api/v1/task-sessions/${sessionId}/processes/${payload.process_id}/stop`, {
...options,
init: { method: 'POST', ...(options?.init ?? {}) },
});
}
export async function listSessionProcesses(sessionId: string, options?: ApiRequestOptions) {
return fetchJson<ProcessInfo[]>(`/api/v1/task-sessions/${sessionId}/processes`, options);
}
export async function getSessionProcess(
sessionId: string,
processId: string,
includeOutput = false,
options?: ApiRequestOptions
) {
const baseUrl = options?.baseUrl ?? getBackendConfig().apiBaseUrl;
const url = new URL(`${baseUrl}/api/v1/task-sessions/${sessionId}/processes/${processId}`);
if (includeOutput) {
url.searchParams.set('include_output', 'true');
}
return fetchJson<ProcessInfo>(url.toString(), options);
}
export async function createTask(
payload: {
workspace_id: string;
@@ -0,0 +1,37 @@
import { cookies } from 'next/headers';
import type { Layout } from 'react-resizable-panels';
export const readLayoutDefaults = async () => {
const cookieStore = await cookies();
const defaults: Record<string, Layout> = {};
const cookieList =
typeof (cookieStore as { getAll?: () => { name: string; value: string }[] }).getAll === 'function'
? (cookieStore as { getAll: () => { name: string; value: string }[] }).getAll()
: (() => {
const raw = cookieStore.toString?.() ?? '';
if (!raw) return [];
return raw
.split(';')
.map((entry) => entry.trim())
.filter(Boolean)
.map((entry) => {
const [name, ...rest] = entry.split('=');
return { name, value: rest.join('=') };
});
})();
for (const cookie of cookieList) {
if (!cookie.name.startsWith('layout:')) continue;
const id = decodeURIComponent(cookie.name.slice('layout:'.length));
try {
const parsed = JSON.parse(decodeURIComponent(cookie.value));
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
defaults[id] = parsed as Layout;
}
} catch {
// Ignore malformed layout cookies.
}
}
return defaults;
};
+66
View File
@@ -0,0 +1,66 @@
'use client';
import { useCallback, useMemo } from 'react';
import { getLocalStorage, setLocalStorage } from '@/lib/local-storage';
import type { Layout } from 'react-resizable-panels';
const buildDefaultLayout = (ids: string[], base: Record<string, number>): Layout => {
const raw = ids.map((id) => base[id] ?? 0);
const total = raw.reduce((sum, value) => sum + value, 0) || 1;
const normalized = raw.map((value) => (value / total) * 100);
const result: Layout = {};
ids.forEach((id, index) => {
result[id] = normalized[index];
});
return result;
};
const cookieKeyForId = (id: string) => `layout:${encodeURIComponent(id)}`;
type UseDefaultLayoutParams = {
id: string;
panelIds?: string[];
baseLayout?: Record<string, number>;
serverDefaultLayout?: Layout;
};
export function useDefaultLayout({
id,
panelIds,
baseLayout,
serverDefaultLayout,
}: UseDefaultLayoutParams) {
const defaultLayout = useMemo(() => {
const stored = getLocalStorage(id, null as unknown as Layout | null);
if (stored && typeof stored === 'object' && !Array.isArray(stored)) {
if (!panelIds || panelIds.every((panelId) => typeof stored[panelId] === 'number')) {
return stored;
}
}
if (serverDefaultLayout && typeof serverDefaultLayout === 'object') {
if (!panelIds || panelIds.every((panelId) => typeof serverDefaultLayout[panelId] === 'number')) {
return serverDefaultLayout;
}
}
if (panelIds && baseLayout) {
return buildDefaultLayout(panelIds, baseLayout);
}
return undefined;
}, [id, panelIds, baseLayout, serverDefaultLayout]);
const onLayoutChanged = useCallback(
(layout: Layout) => {
if (panelIds && !panelIds.every((panelId) => typeof layout[panelId] === 'number')) return;
setLocalStorage(id, layout);
if (typeof document !== 'undefined') {
document.cookie = `${cookieKeyForId(id)}=${encodeURIComponent(
JSON.stringify(layout)
)}; path=/;`;
}
},
[id, panelIds]
);
return { defaultLayout, onLayoutChanged };
}
+248
View File
@@ -0,0 +1,248 @@
import { create } from 'zustand';
import { getLocalStorage, setLocalStorage } from '@/lib/local-storage';
export type ColumnId = 'left' | 'chat' | 'right' | 'preview';
export type LayoutPreset =
| 'default'
| 'preview'
| 'preview-with-right'
| 'chat-only'
| 'custom';
export type LayoutState = {
left: boolean;
chat: boolean;
right: boolean;
preview: boolean;
};
type LayoutStateBySession = {
columnsBySessionId: Record<string, LayoutState>;
currentPresetBySessionId: Record<string, LayoutPreset>;
previousStateBySessionId: Record<string, LayoutState | null>;
};
type LayoutStore = LayoutStateBySession & {
applyPreset: (sessionId: string, preset: LayoutPreset) => void;
openPreview: (sessionId: string) => void;
closePreview: (sessionId: string) => void;
toggleColumn: (sessionId: string, column: ColumnId) => void;
showColumn: (sessionId: string, column: ColumnId) => void;
hideColumn: (sessionId: string, column: ColumnId) => void;
toggleRightPanel: (sessionId: string) => void;
setColumns: (sessionId: string, columns: Partial<LayoutState>) => void;
isVisible: (sessionId: string, column: ColumnId) => boolean;
reset: (sessionId: string) => void;
};
const PRESETS: Record<LayoutPreset, Partial<LayoutState>> = {
default: { left: true, chat: true, right: true, preview: false },
preview: { left: false, chat: true, right: false, preview: true },
'preview-with-right': { left: false, chat: true, right: true, preview: true },
'chat-only': { left: false, chat: true, right: false, preview: false },
custom: {},
};
const DEFAULT_STATE: LayoutState = {
left: true,
chat: true,
right: true,
preview: false,
};
const detectPreset = (state: LayoutState): LayoutPreset => {
for (const [preset, config] of Object.entries(PRESETS)) {
if (preset === 'custom') continue;
const matches = Object.entries(config).every(
([key, value]) => state[key as ColumnId] === value
);
if (matches) return preset as LayoutPreset;
}
return 'custom';
};
const STORAGE_KEY = 'layout-columns-by-session';
const loadPersistedState = (): Record<string, LayoutState> => {
const stored = getLocalStorage(STORAGE_KEY, {} as Record<string, LayoutState>);
return stored || {};
};
const persistState = (columnsBySessionId: Record<string, LayoutState>) => {
setLocalStorage(STORAGE_KEY, columnsBySessionId);
};
export const useLayoutStore = create<LayoutStore>((set, get) => ({
columnsBySessionId: loadPersistedState(),
currentPresetBySessionId: {},
previousStateBySessionId: {},
applyPreset: (sessionId, preset) => {
if (preset === 'custom') return;
set((state) => {
const current = state.columnsBySessionId[sessionId] ?? DEFAULT_STATE;
const newColumnsBySessionId = {
...state.columnsBySessionId,
[sessionId]: { ...current, ...PRESETS[preset] } as LayoutState,
};
persistState(newColumnsBySessionId);
return {
previousStateBySessionId: {
...state.previousStateBySessionId,
[sessionId]: {
left: current.left,
chat: current.chat,
right: current.right,
preview: current.preview,
},
},
columnsBySessionId: newColumnsBySessionId,
currentPresetBySessionId: {
...state.currentPresetBySessionId,
[sessionId]: preset,
},
};
});
},
openPreview: (sessionId) => {
get().applyPreset(sessionId, 'preview-with-right');
},
closePreview: (sessionId) => {
set((state) => {
const previous = state.previousStateBySessionId[sessionId];
if (previous && !previous.preview) {
const newColumnsBySessionId = { ...state.columnsBySessionId, [sessionId]: previous };
persistState(newColumnsBySessionId);
return {
columnsBySessionId: newColumnsBySessionId,
currentPresetBySessionId: {
...state.currentPresetBySessionId,
[sessionId]: detectPreset(previous),
},
previousStateBySessionId: { ...state.previousStateBySessionId, [sessionId]: null },
};
}
const newColumnsBySessionId = { ...state.columnsBySessionId, [sessionId]: DEFAULT_STATE };
persistState(newColumnsBySessionId);
return {
columnsBySessionId: newColumnsBySessionId,
currentPresetBySessionId: {
...state.currentPresetBySessionId,
[sessionId]: 'default',
},
previousStateBySessionId: { ...state.previousStateBySessionId, [sessionId]: null },
};
});
},
toggleColumn: (sessionId, column) => {
set((state) => {
const current = state.columnsBySessionId[sessionId] ?? DEFAULT_STATE;
const next = { ...current, [column]: !current[column] } as LayoutState;
const newColumnsBySessionId = { ...state.columnsBySessionId, [sessionId]: next };
persistState(newColumnsBySessionId);
return {
columnsBySessionId: newColumnsBySessionId,
currentPresetBySessionId: {
...state.currentPresetBySessionId,
[sessionId]: detectPreset(next),
},
};
});
},
showColumn: (sessionId, column) => {
set((state) => {
const current = state.columnsBySessionId[sessionId] ?? DEFAULT_STATE;
if (current[column]) return state;
const next = { ...current, [column]: true } as LayoutState;
const newColumnsBySessionId = { ...state.columnsBySessionId, [sessionId]: next };
persistState(newColumnsBySessionId);
return {
columnsBySessionId: newColumnsBySessionId,
currentPresetBySessionId: {
...state.currentPresetBySessionId,
[sessionId]: detectPreset(next),
},
};
});
},
hideColumn: (sessionId, column) => {
set((state) => {
const current = state.columnsBySessionId[sessionId] ?? DEFAULT_STATE;
if (!current[column]) return state;
const next = { ...current, [column]: false } as LayoutState;
const newColumnsBySessionId = { ...state.columnsBySessionId, [sessionId]: next };
persistState(newColumnsBySessionId);
return {
columnsBySessionId: newColumnsBySessionId,
currentPresetBySessionId: {
...state.currentPresetBySessionId,
[sessionId]: detectPreset(next),
},
};
});
},
toggleRightPanel: (sessionId) => {
set((state) => {
const current = state.columnsBySessionId[sessionId] ?? DEFAULT_STATE;
const next = { ...current, right: !current.right } as LayoutState;
const preset = current.preview || next.preview
? next.right
? 'preview-with-right'
: 'preview'
: detectPreset(next);
const newColumnsBySessionId = { ...state.columnsBySessionId, [sessionId]: next };
persistState(newColumnsBySessionId);
return {
columnsBySessionId: newColumnsBySessionId,
currentPresetBySessionId: {
...state.currentPresetBySessionId,
[sessionId]: preset,
},
};
});
},
setColumns: (sessionId, columns) => {
set((state) => {
const current = state.columnsBySessionId[sessionId] ?? DEFAULT_STATE;
const next = { ...current, ...columns } as LayoutState;
const newColumnsBySessionId = { ...state.columnsBySessionId, [sessionId]: next };
persistState(newColumnsBySessionId);
return {
columnsBySessionId: newColumnsBySessionId,
currentPresetBySessionId: {
...state.currentPresetBySessionId,
[sessionId]: detectPreset(next),
},
};
});
},
isVisible: (sessionId, column) => {
const state = get().columnsBySessionId[sessionId];
if (!state) return column !== 'preview';
return state[column];
},
reset: (sessionId) => {
set((state) => {
const newColumnsBySessionId = { ...state.columnsBySessionId, [sessionId]: DEFAULT_STATE };
persistState(newColumnsBySessionId);
return {
columnsBySessionId: newColumnsBySessionId,
currentPresetBySessionId: {
...state.currentPresetBySessionId,
[sessionId]: 'default',
},
previousStateBySessionId: { ...state.previousStateBySessionId, [sessionId]: null },
};
});
},
}));
+210 -7
View File
@@ -15,9 +15,18 @@ import type {
TaskSession,
Turn,
} from '@/lib/types/http';
import type { FileInfo } from '@/lib/types/backend';
import { createStore } from 'zustand/vanilla';
import { immer } from 'zustand/middleware/immer';
import { setLocalStorage } from '@/lib/local-storage';
const maxProcessOutputBytes = 2 * 1024 * 1024;
function trimProcessOutput(value: string) {
if (value.length <= maxProcessOutputBytes) {
return value;
}
return value.slice(value.length - maxProcessOutputBytes);
}
export type KanbanState = {
boardId: string | null;
@@ -163,7 +172,54 @@ export type ShellState = {
>;
};
export type { FileInfo };
export type ProcessStatusEntry = {
processId: string;
sessionId: string;
kind: string;
scriptName?: string;
status: string;
command?: string;
workingDir?: string;
exitCode?: number | null;
startedAt?: string;
updatedAt?: string;
};
export type ProcessState = {
outputsByProcessId: Record<string, string>;
processesById: Record<string, ProcessStatusEntry>;
processIdsBySessionId: Record<string, string[]>;
activeProcessBySessionId: Record<string, string>;
devProcessBySessionId: Record<string, string>;
};
export type PreviewStage = 'closed' | 'logs' | 'preview';
export type PreviewViewMode = 'preview' | 'output';
export type PreviewDevicePreset = 'desktop' | 'tablet' | 'mobile';
export type PreviewPanelState = {
openBySessionId: Record<string, boolean>;
viewBySessionId: Record<string, PreviewViewMode>;
deviceBySessionId: Record<string, PreviewDevicePreset>;
stageBySessionId: Record<string, PreviewStage>;
urlBySessionId: Record<string, string>;
urlDraftBySessionId: Record<string, string>;
};
export type RightPanelState = {
activeTabBySessionId: Record<string, string>;
};
export type FileInfo = {
path: string;
status: 'modified' | 'added' | 'deleted' | 'untracked' | 'renamed';
staged: boolean;
additions?: number;
deletions?: number;
old_path?: string;
diff?: string;
};
export type GitStatusEntry = {
branch: string | null;
@@ -287,6 +343,9 @@ export type AppState = {
userSettings: UserSettingsState;
terminal: TerminalState;
shell: ShellState;
processes: ProcessState;
previewPanel: PreviewPanelState;
rightPanel: RightPanelState;
diffs: DiffState;
gitStatus: GitStatusState;
contextWindow: ContextWindowState;
@@ -331,6 +390,18 @@ export type AppState = {
status: { available: boolean; running?: boolean; shell?: string; cwd?: string }
) => void;
clearShellOutput: (sessionId: string) => void;
appendProcessOutput: (processId: string, data: string) => void;
upsertProcessStatus: (status: ProcessStatusEntry) => void;
clearProcessOutput: (processId: string) => void;
setActiveProcess: (sessionId: string, processId: string) => void;
setPreviewOpen: (sessionId: string, open: boolean) => void;
togglePreviewOpen: (sessionId: string) => void;
setPreviewView: (sessionId: string, view: PreviewViewMode) => void;
setPreviewDevice: (sessionId: string, device: PreviewDevicePreset) => void;
setPreviewStage: (sessionId: string, stage: PreviewStage) => void;
setPreviewUrl: (sessionId: string, url: string) => void;
setPreviewUrlDraft: (sessionId: string, url: string) => void;
setRightPanelActiveTab: (sessionId: string, tab: string) => void;
setConnectionStatus: (status: ConnectionState['status'], error?: string | null) => void;
setMessages: (
sessionId: string,
@@ -408,6 +479,22 @@ const defaultState: AppState = {
},
terminal: { terminals: [] },
shell: { outputs: {}, statuses: {} },
processes: {
outputsByProcessId: {},
processesById: {},
processIdsBySessionId: {},
activeProcessBySessionId: {},
devProcessBySessionId: {},
},
previewPanel: {
openBySessionId: {},
viewBySessionId: {},
deviceBySessionId: {},
stageBySessionId: {},
urlBySessionId: {},
urlDraftBySessionId: {},
},
rightPanel: { activeTabBySessionId: {} },
diffs: { files: [] },
gitStatus: { bySessionId: {} },
contextWindow: { bySessionId: {} },
@@ -452,6 +539,18 @@ const defaultState: AppState = {
appendShellOutput: () => undefined,
setShellStatus: () => undefined,
clearShellOutput: () => undefined,
appendProcessOutput: () => undefined,
upsertProcessStatus: () => undefined,
clearProcessOutput: () => undefined,
setActiveProcess: () => undefined,
setPreviewOpen: () => undefined,
togglePreviewOpen: () => undefined,
setPreviewView: () => undefined,
setPreviewDevice: () => undefined,
setPreviewStage: () => undefined,
setPreviewUrl: () => undefined,
setPreviewUrlDraft: () => undefined,
setRightPanelActiveTab: () => undefined,
setConnectionStatus: () => undefined,
setMessages: () => undefined,
addMessage: () => undefined,
@@ -512,6 +611,18 @@ function mergeInitialState(
| 'appendShellOutput'
| 'setShellStatus'
| 'clearShellOutput'
| 'appendProcessOutput'
| 'upsertProcessStatus'
| 'clearProcessOutput'
| 'setActiveProcess'
| 'setPreviewOpen'
| 'togglePreviewOpen'
| 'setPreviewView'
| 'setPreviewDevice'
| 'setPreviewStage'
| 'setPreviewUrl'
| 'setPreviewUrlDraft'
| 'setRightPanelActiveTab'
| 'setConnectionStatus'
| 'setMessages'
| 'addMessage'
@@ -564,6 +675,9 @@ function mergeInitialState(
userSettings: { ...defaultState.userSettings, ...initialState.userSettings },
terminal: { ...defaultState.terminal, ...initialState.terminal },
shell: { ...defaultState.shell, ...initialState.shell },
processes: { ...defaultState.processes, ...initialState.processes },
previewPanel: { ...defaultState.previewPanel, ...initialState.previewPanel },
rightPanel: { ...defaultState.rightPanel, ...initialState.rightPanel },
diffs: { ...defaultState.diffs, ...initialState.diffs },
gitStatus: { ...defaultState.gitStatus, ...initialState.gitStatus },
contextWindow: { ...defaultState.contextWindow, ...initialState.contextWindow },
@@ -586,7 +700,7 @@ function mergeInitialState(
export function createAppStore(initialState?: Partial<AppState>) {
const merged = mergeInitialState(initialState);
return createStore<AppState>()(
immer((set) => ({
immer((set, get) => ({
...merged,
hydrate: (state) =>
set((draft) => {
@@ -613,6 +727,9 @@ export function createAppStore(initialState?: Partial<AppState>) {
if (state.userSettings) Object.assign(draft.userSettings, state.userSettings);
if (state.terminal) Object.assign(draft.terminal, state.terminal);
if (state.shell) Object.assign(draft.shell, state.shell);
if (state.processes) Object.assign(draft.processes, state.processes);
if (state.previewPanel) Object.assign(draft.previewPanel, state.previewPanel);
if (state.rightPanel) Object.assign(draft.rightPanel, state.rightPanel);
if (state.diffs) Object.assign(draft.diffs, state.diffs);
if (state.gitStatus) Object.assign(draft.gitStatus, state.gitStatus);
if (state.connection) Object.assign(draft.connection, state.connection);
@@ -630,10 +747,14 @@ export function createAppStore(initialState?: Partial<AppState>) {
Object.assign(draft.sessionWorktreesBySessionId, state.sessionWorktreesBySessionId);
}
}),
setActiveWorkspace: (workspaceId) =>
setActiveWorkspace: (workspaceId) => {
if (get().workspaces.activeId === workspaceId) {
return;
}
set((draft) => {
draft.workspaces.activeId = workspaceId;
}),
});
},
setWorkspaces: (workspaces) =>
set((draft) => {
draft.workspaces.items = workspaces;
@@ -648,10 +769,14 @@ export function createAppStore(initialState?: Partial<AppState>) {
draft.boards.activeId = boards[0].id;
}
}),
setActiveBoard: (boardId) =>
setActiveBoard: (boardId) => {
if (get().boards.activeId === boardId) {
return;
}
set((draft) => {
draft.boards.activeId = boardId;
}),
});
},
setExecutors: (executors) =>
set((draft) => {
draft.executors.items = executors;
@@ -761,6 +886,84 @@ export function createAppStore(initialState?: Partial<AppState>) {
set((draft) => {
draft.shell.outputs[sessionId] = '';
}),
appendProcessOutput: (processId, data) =>
set((draft) => {
const next =
(draft.processes.outputsByProcessId[processId] || '') + data;
draft.processes.outputsByProcessId[processId] = trimProcessOutput(next);
}),
upsertProcessStatus: (status) =>
set((draft) => {
draft.processes.processesById[status.processId] = status;
const list = draft.processes.processIdsBySessionId[status.sessionId] || [];
if (!list.includes(status.processId)) {
list.push(status.processId);
draft.processes.processIdsBySessionId[status.sessionId] = list;
}
if (!draft.processes.activeProcessBySessionId[status.sessionId]) {
draft.processes.activeProcessBySessionId[status.sessionId] = status.processId;
}
if (status.kind === 'dev') {
draft.processes.devProcessBySessionId[status.sessionId] = status.processId;
}
}),
clearProcessOutput: (processId) =>
set((draft) => {
draft.processes.outputsByProcessId[processId] = '';
}),
setActiveProcess: (sessionId, processId) =>
set((draft) => {
draft.processes.activeProcessBySessionId[sessionId] = processId;
}),
setPreviewOpen: (sessionId, open) =>
set((draft) => {
draft.previewPanel.openBySessionId[sessionId] = open;
if (!draft.previewPanel.viewBySessionId[sessionId]) {
draft.previewPanel.viewBySessionId[sessionId] = 'preview';
}
if (!draft.previewPanel.deviceBySessionId[sessionId]) {
draft.previewPanel.deviceBySessionId[sessionId] = 'desktop';
}
}),
togglePreviewOpen: (sessionId) =>
set((draft) => {
const current = draft.previewPanel.openBySessionId[sessionId] ?? false;
draft.previewPanel.openBySessionId[sessionId] = !current;
if (!draft.previewPanel.viewBySessionId[sessionId]) {
draft.previewPanel.viewBySessionId[sessionId] = 'preview';
}
if (!draft.previewPanel.deviceBySessionId[sessionId]) {
draft.previewPanel.deviceBySessionId[sessionId] = 'desktop';
}
}),
setPreviewView: (sessionId, view) =>
set((draft) => {
draft.previewPanel.viewBySessionId[sessionId] = view;
// Persist to localStorage
const key = `preview-view:${sessionId}`;
setLocalStorage(key, view);
}),
setPreviewDevice: (sessionId, device) =>
set((draft) => {
draft.previewPanel.deviceBySessionId[sessionId] = device;
}),
setPreviewStage: (sessionId, stage) =>
set((draft) => {
draft.previewPanel.stageBySessionId[sessionId] = stage;
// layout changes handled by layout-store
}),
setPreviewUrl: (sessionId, url) =>
set((draft) => {
draft.previewPanel.urlBySessionId[sessionId] = url;
}),
setPreviewUrlDraft: (sessionId, url) =>
set((draft) => {
draft.previewPanel.urlDraftBySessionId[sessionId] = url;
}),
setRightPanelActiveTab: (sessionId, tab) =>
set((draft) => {
draft.rightPanel.activeTabBySessionId[sessionId] = tab;
}),
setConnectionStatus: (status, error = null) =>
set((draft) => {
draft.connection.status = status;
+26 -1
View File
@@ -39,7 +39,9 @@ export type BackendMessageType =
| 'agent.profile.updated'
| 'user.settings.updated'
| 'session.workspace.file.changes'
| 'session.shell.output';
| 'session.shell.output'
| 'session.process.output'
| 'session.process.status';
export type BackendMessage<T extends BackendMessageType, P> = {
id?: string;
@@ -201,6 +203,27 @@ export type GitStatusPayload = {
timestamp: string;
};
export type ProcessOutputPayload = {
session_id: string;
process_id: string;
kind: string;
stream: 'stdout' | 'stderr';
data: string;
timestamp?: string;
};
export type ProcessStatusPayload = {
session_id: string;
process_id: string;
kind: string;
script_name?: string;
status: string;
command?: string;
working_dir?: string;
exit_code?: number | null;
timestamp?: string;
};
export type ExecutorPayload = {
id: string;
name: string;
@@ -319,6 +342,8 @@ export type BackendMessageMap = {
'user.settings.updated': BackendMessage<'user.settings.updated', UserSettingsUpdatedPayload>;
'session.workspace.file.changes': BackendMessage<'session.workspace.file.changes', FileChangeNotificationPayload>;
'session.shell.output': BackendMessage<'session.shell.output', ShellOutputPayload>;
'session.process.output': BackendMessage<'session.process.output', ProcessOutputPayload>;
'session.process.status': BackendMessage<'session.process.status', ProcessStatusPayload>;
};
// Workspace file types
+21
View File
@@ -65,6 +65,7 @@ export type Repository = {
worktree_branch_prefix: string;
setup_script: string;
cleanup_script: string;
dev_script: string;
created_at: string;
updated_at: string;
};
@@ -79,6 +80,26 @@ export type RepositoryScript = {
updated_at: string;
};
export type ProcessOutputChunk = {
stream: 'stdout' | 'stderr';
data: string;
timestamp: string;
};
export type ProcessInfo = {
id: string;
session_id: string;
kind: string;
script_name?: string;
command: string;
working_dir: string;
status: string;
exit_code?: number | null;
started_at: string;
updated_at: string;
output?: ProcessOutputChunk[];
};
export type TaskRepository = {
id: string;
task_id: string;
+37
View File
@@ -19,5 +19,42 @@ export function registerTerminalsHandlers(store: StoreApi<AppState>): WsHandlers
store.getState().setShellStatus(session_id, { available: false });
}
},
'session.process.output': (message) => {
const { process_id, data } = message.payload;
if (!process_id || !data) {
return;
}
store.getState().appendProcessOutput(process_id, data);
},
'session.process.status': (message) => {
const {
session_id,
process_id,
kind,
status,
script_name,
command,
working_dir,
exit_code,
timestamp,
} = message.payload;
if (!session_id || !process_id || !status) {
return;
}
store.getState().upsertProcessStatus({
processId: process_id,
sessionId: session_id,
kind,
scriptName: script_name,
status,
command,
workingDir: working_dir,
exitCode: exit_code,
updatedAt: timestamp,
});
if (status === 'starting') {
store.getState().clearProcessOutput(process_id);
}
},
};
}
+1 -1
View File
@@ -54,7 +54,7 @@
"react-day-picker": "^9.13.0",
"react-dom": "19.2.3",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^2.1.9",
"react-resizable-panels": "^4.4.1",
"recharts": "2.15.4",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
+166
View File
@@ -0,0 +1,166 @@
# Preview Panel + Dev Process (Updated Plan)
Date: 2026-01-21
## Goals
- When user clicks **Preview** in the session top bar:
- **Left column hidden immediately**.
- **Right column stays visible initially** to show a new **Dev Server** terminal tab with live logs.
- Dev script logs start streaming; the **Dev Server** tab is auto‑focused.
- As soon as a localhost URL is detected in the logs, switch to **Preview layout** and **hide right column** (left already hidden).
- Preview layout includes an **address bar** (editable) and **Stop** button.
- Remove device buttons (desktop/tablet/mobile) from preview bar.
## Architecture Diagram (High‑Level)
```
[User Action]
| click Preview button (Topbar)
v
[UI State]
preview.stage = "logs"
preview.open = true
rightPanel.activeTab = "dev-server"
start dev process (if not running)
|
v
[Backend]
StartProcess (kind=dev, session_id)
-> ProcessRunner (agentctl)
-> Workspace stream process.output
-> Backend WS session.process.output
|
v
[Frontend]
WS handler -> store.processes.outputsByProcessId
|
v
[Preview Hook]
detect URL in dev output
if found:
preview.url = detected
preview.stage = "preview"
(left+right columns hidden)
|
v
[Preview Panel]
iframe src = preview.url
address bar editable
stop button -> StopProcess
```
## UI States
We’ll model preview as **staged**:
- `closed`: normal 3‑column layout.
- `logs`: left hidden, right visible; **Dev Server** tab focused; logs streaming; preview not yet loaded.
- `preview`: left + right hidden; center + preview panel only; address bar + stop button visible.
## Store / Types
### New/Updated Store Types (apps/web/lib/state/store.ts)
```
export type PreviewStage = 'closed' | 'logs' | 'preview';
export type PreviewPanelState = {
openBySessionId: Record<string, boolean>; // existing
viewBySessionId: Record<string, 'preview' | 'output'>; // existing, keep for log view
deviceBySessionId: Record<string, 'desktop' | 'tablet' | 'mobile'>; // keep for now but unused
stageBySessionId: Record<string, PreviewStage>;
urlBySessionId: Record<string, string>;
urlDraftBySessionId: Record<string, string>; // for address bar editing
};
export type RightPanelState = {
activeTabBySessionId: Record<string, string>; // e.g. 'commands', 'terminal-1', 'dev-server'
};
```
### New Store Actions
```
setPreviewStage(sessionId, stage)
setPreviewUrl(sessionId, url)
setPreviewUrlDraft(sessionId, url)
setRightPanelActiveTab(sessionId, tab)
```
## Data Flow (Low‑Level)
### 1) Preview Button Click
1. Topbar preview toggle is clicked.
2. Actions:
- `setPreviewOpen(sessionId, true)`
- `setPreviewStage(sessionId, 'logs')`
- `setRightPanelActiveTab(sessionId, 'dev-server')`
- `startProcess(kind='dev')` **if no dev process running**
### 2) Dev Output → URL Detection
1. WS handler receives `session.process.output` for dev process.
2. `appendProcessOutput(processId, chunk.data)` updates store.
3. `usePreviewPanel` listens to dev output changes; regex scans for `http://localhost` or `127.0.0.1`.
4. On first URL:
- `setPreviewUrl(sessionId, url)`
- `setPreviewUrlDraft(sessionId, url)`
- `setPreviewStage(sessionId, 'preview')`
### 3) Layout Switch
- `TaskLayout` reads `preview.stageBySessionId`:
- `logs`: hide left column; show center + right panel (with dev logs tab focused).
- `preview`: hide both left and right columns; show center + preview panel only.
### 4) Address Bar
- Preview panel shows input bound to `urlDraftBySessionId`.
- On submit/blur, commit to `urlBySessionId`.
- `iframe.src = urlBySessionId`.
### 5) Stop Button
- `stopProcess(processId)` (dev process)
- Optional: set stage back to `logs` (keep logs) or `closed` (if desired).
## UI Components / File‑Level Plan
### `apps/web/components/task/task-layout.tsx`
- Add `previewStage` from store.
- If `previewOpen`:
- Stage `logs`: render **center + right panel** (left hidden).
- Stage `preview`: render **center + preview panel** (left+right hidden).
### `apps/web/components/task/task-right-panel.tsx`
- Add a **Dev Server** tab (value `dev-server`).
- Add new panel content (use new `ProcessTerminal` or output component bound to dev process output).
- Allow external control of active tab via prop or store (right panel state).
### `apps/web/components/task/preview/preview-panel.tsx`
- Remove device buttons.
- Add address bar (input) + Stop button.
- Show “Logs”/“Preview” toggle only if still needed; otherwise keep **Preview** and **Logs** views.
### `apps/web/hooks/use-preview-panel.ts`
- Extend to:
- auto‑start dev process on preview open (if not running)
- update stage to `logs`
- detect URL from dev output and switch to `preview`
- expose `previewUrl`, `previewUrlDraft`, `setPreviewUrlDraft`, `setPreviewUrl`
### `apps/web/lib/state/store.ts`
- Add preview stage + url fields
- Add right panel active tab state + actions
### Terminal for Dev Logs
Options:
1) **New ProcessTerminal** (preferred): uses `outputsByProcessId` from store and renders in xterm.
2) **Simple log view** (fallback): preformatted text with scroll.
## Edge Cases
- Dev script missing: keep current “Configure dev script” message.
- Dev process already running: don’t start a second; focus logs tab and detect URL from existing output.
- URL changes mid‑run: keep last detected URL, allow manual edit.
- Stop button: if stop fails, keep stage; allow retry.
## Minimal Backend Changes
- None for UI flow (existing process output stream already used).
- Ensure dev process outputs stream to WS (already implemented).
## Acceptance Checklist
- Clicking Preview hides left column, focuses Dev Server tab, logs stream.
- URL detected → preview layout shown (left + right hidden).
- Address bar editable + Stop button functional.
- Device buttons removed.
```