feat: process runners (#71)
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Generated
+11
-11
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.
|
||||
```
|
||||
Reference in New Issue
Block a user