// Package handlers provides WebSocket and HTTP handlers for agent operations. package handlers import ( "context" "errors" "fmt" "strings" "time" "github.com/kandev/kandev/internal/agent/runtime/agentctl" "github.com/kandev/kandev/internal/agent/runtime/lifecycle" "github.com/kandev/kandev/internal/common/logger" ws "github.com/kandev/kandev/pkg/websocket" "go.uber.org/zap" ) // PRCreatedCallback is called after a PR is successfully created. `repo` is // the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. // The callback uses it to scope the resulting TaskPR / PRWatch rows to the // owning repository so the second repo's PR doesn't overwrite the first. // Parameters: ctx, sessionID, taskID, prURL, branch. type PRCreatedCallback func(ctx context.Context, sessionID, taskID, prURL, branch, repo string) // GitOperationFailedCallback is called when a git operation fails. // Parameters: ctx, sessionID, taskID, operation name, error output. type GitOperationFailedCallback func(ctx context.Context, sessionID, taskID, operation, errorOutput string) // ExecutionLookup provides access to running agent executions by session ID. type ExecutionLookup interface { GetExecutionBySessionID(sessionID string) (*lifecycle.AgentExecution, bool) // GetOrEnsureExecution returns an existing execution or creates one on-demand. // Use this for workspace-oriented operations that should survive backend restarts. GetOrEnsureExecution(ctx context.Context, sessionID string) (*lifecycle.AgentExecution, error) } // SessionReader is a minimal interface for reading session metadata. // This is needed because git operations need to know the session's base commit SHA // to filter commits to only those made during the session. type SessionReader interface { // GetSessionBaseCommit returns the base commit SHA for a session. // Returns empty string if not set or on error. GetSessionBaseCommit(ctx context.Context, sessionID string) string // GetSessionBaseBranch returns the target branch for a session (e.g., "origin/main"). // Used for computing merge-base to filter commits accurately after rebases. // Returns empty string if not set or on error. GetSessionBaseBranch(ctx context.Context, sessionID string) string } // GitHandlers provides WebSocket handlers for git worktree operations. // Operations are executed via agentctl which runs in the worktree context. type GitHandlers struct { lifecycleMgr ExecutionLookup sessionReader SessionReader logger *logger.Logger onPRCreated PRCreatedCallback onGitOperationFailed GitOperationFailedCallback } // NewGitHandlers creates a new GitHandlers instance. // sessionReader is required to look up session metadata (e.g., base commit SHA). func NewGitHandlers(lifecycleMgr ExecutionLookup, sessionReader SessionReader, log *logger.Logger) *GitHandlers { return &GitHandlers{ lifecycleMgr: lifecycleMgr, sessionReader: sessionReader, logger: log.WithFields(zap.String("component", "git_handlers")), } } // SetOnPRCreated sets a callback invoked after a PR is successfully created. func (h *GitHandlers) SetOnPRCreated(cb PRCreatedCallback) { h.onPRCreated = cb } // SetOnGitOperationFailed sets a callback invoked when a git operation fails. func (h *GitHandlers) SetOnGitOperationFailed(cb GitOperationFailedCallback) { h.onGitOperationFailed = cb } // notifyGitOperationFailed fires the failure callback asynchronously if the result indicates failure. func (h *GitHandlers) notifyGitOperationFailed(sessionID, operation string, result *client.GitOperationResult) { if result == nil || result.Success || h.onGitOperationFailed == nil { return } execution, ok := h.lifecycleMgr.GetExecutionBySessionID(sessionID) if !ok || execution.TaskID == "" { return } taskID := execution.TaskID errorOutput := result.Error if errorOutput == "" { errorOutput = result.Output } go func() { callbackCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() h.onGitOperationFailed(callbackCtx, sessionID, taskID, operation, errorOutput) }() } // RegisterHandlers registers git handlers with the WebSocket dispatcher func (h *GitHandlers) RegisterHandlers(d *ws.Dispatcher) { d.RegisterFunc(ws.ActionWorktreePull, h.wsPull) d.RegisterFunc(ws.ActionWorktreePush, h.wsPush) d.RegisterFunc(ws.ActionWorktreeRebase, h.wsRebase) d.RegisterFunc(ws.ActionWorktreeMerge, h.wsMerge) d.RegisterFunc(ws.ActionWorktreeAbort, h.wsAbort) d.RegisterFunc(ws.ActionWorktreeCommit, h.wsCommit) d.RegisterFunc(ws.ActionWorktreeStage, h.wsStage) d.RegisterFunc(ws.ActionWorktreeUnstage, h.wsUnstage) d.RegisterFunc(ws.ActionWorktreeDiscard, h.wsDiscard) d.RegisterFunc(ws.ActionWorktreeCreatePR, h.wsCreatePR) d.RegisterFunc(ws.ActionWorktreeRevertCommit, h.wsRevertCommit) d.RegisterFunc(ws.ActionWorktreeRenameBranch, h.wsRenameBranch) d.RegisterFunc(ws.ActionWorktreeReset, h.wsReset) d.RegisterFunc(ws.ActionSessionCommitDiff, h.wsCommitDiff) d.RegisterFunc(ws.ActionSessionGitCommits, h.wsGitCommits) d.RegisterFunc(ws.ActionSessionCumulativeDiff, h.wsCumulativeDiff) } // GitPullRequest for worktree.pull action. // Repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. type GitPullRequest struct { SessionID string `json:"session_id"` Rebase bool `json:"rebase"` Repo string `json:"repo,omitempty"` } // GitPushRequest for worktree.push action. // Repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. type GitPushRequest struct { SessionID string `json:"session_id"` Force bool `json:"force"` SetUpstream bool `json:"set_upstream"` Repo string `json:"repo,omitempty"` } // GitRebaseRequest for worktree.rebase action. // Repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. type GitRebaseRequest struct { SessionID string `json:"session_id"` BaseBranch string `json:"base_branch"` Repo string `json:"repo,omitempty"` } // GitMergeRequest for worktree.merge action. // Repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. type GitMergeRequest struct { SessionID string `json:"session_id"` BaseBranch string `json:"base_branch"` Repo string `json:"repo,omitempty"` } // GitAbortRequest for worktree.abort action. // Repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo workspaces. type GitAbortRequest struct { SessionID string `json:"session_id"` Operation string `json:"operation"` // "merge" or "rebase" Repo string `json:"repo,omitempty"` } // GitCommitRequest for worktree.commit action type GitCommitRequest struct { SessionID string `json:"session_id"` Message string `json:"message"` StageAll bool `json:"stage_all"` Amend bool `json:"amend"` // Multi-repo: subpath of the repo to commit in (e.g. "kandev"). Empty for // single-repo workspaces. Required for multi-repo — committing at the task // root fails because it isn't itself a git repo. Repo string `json:"repo,omitempty"` } // GitRenameBranchRequest for worktree.rename_branch action. // Repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo. type GitRenameBranchRequest struct { SessionID string `json:"session_id"` NewName string `json:"new_name"` Repo string `json:"repo,omitempty"` } // GitStageRequest for worktree.stage action type GitStageRequest struct { SessionID string `json:"session_id"` Paths []string `json:"paths"` // Empty = stage all Repo string `json:"repo,omitempty"` // Multi-repo subpath; empty for single-repo } // GitUnstageRequest for worktree.unstage action type GitUnstageRequest struct { SessionID string `json:"session_id"` Paths []string `json:"paths"` // Empty = unstage all Repo string `json:"repo,omitempty"` // Multi-repo subpath; empty for single-repo } // GitDiscardRequest for worktree.discard action type GitDiscardRequest struct { SessionID string `json:"session_id"` Paths []string `json:"paths"` // Required - files to discard Repo string `json:"repo,omitempty"` // Multi-repo subpath; empty for single-repo } // GitCreatePRRequest for worktree.create_pr action. // Repo is the multi-repo subpath (e.g. "kandev"); empty for single-repo // workspaces. Without it, agentctl falls back to the workspace root which // for multi-repo task workspaces isn't a git repo, so PR creation fails. type GitCreatePRRequest struct { SessionID string `json:"session_id"` Title string `json:"title"` Body string `json:"body"` BaseBranch string `json:"base_branch"` Draft bool `json:"draft"` Repo string `json:"repo,omitempty"` } // GitRevertCommitRequest for worktree.revert_commit action type GitRevertCommitRequest struct { SessionID string `json:"session_id"` CommitSHA string `json:"commit_sha"` Repo string `json:"repo,omitempty"` // Multi-repo subpath; empty for single-repo } // GitResetRequest for worktree.reset action type GitResetRequest struct { SessionID string `json:"session_id"` CommitSHA string `json:"commit_sha"` Mode string `json:"mode"` // "soft", "mixed", or "hard" Repo string `json:"repo,omitempty"` // Multi-repo subpath; empty for single-repo } // GitShowCommitRequest for session.commit_diff action type GitShowCommitRequest struct { SessionID string `json:"session_id"` CommitSHA string `json:"commit_sha"` Repo string `json:"repo,omitempty"` // Multi-repo subpath; empty for single-repo } // wsPull handles worktree.pull action func (h *GitHandlers) wsPull(ctx context.Context, msg *ws.Message) (*ws.Message, error) { var req GitPullRequest if err := msg.ParsePayload(&req); err != nil { return nil, fmt.Errorf("invalid payload: %w", err) } if req.SessionID == "" { return nil, fmt.Errorf("session_id is required") } client, err := h.getAgentCtlClient(ctx, req.SessionID) if err != nil { return nil, err } result, err := client.GitPull(ctx, req.Rebase, req.Repo) if err != nil { return nil, fmt.Errorf("pull failed: %w", err) } h.notifyGitOperationFailed(req.SessionID, "pull", result) return ws.NewResponse(msg.ID, msg.Action, result) } // wsPush handles worktree.push action func (h *GitHandlers) wsPush(ctx context.Context, msg *ws.Message) (*ws.Message, error) { var req GitPushRequest if err := msg.ParsePayload(&req); err != nil { return nil, fmt.Errorf("invalid payload: %w", err) } if req.SessionID == "" { return nil, fmt.Errorf("session_id is required") } client, err := h.getAgentCtlClient(ctx, req.SessionID) if err != nil { return nil, err } result, err := client.GitPush(ctx, req.Force, req.SetUpstream, req.Repo) if err != nil { return nil, fmt.Errorf("push failed: %w", err) } h.notifyGitOperationFailed(req.SessionID, "push", result) return ws.NewResponse(msg.ID, msg.Action, result) } // wsRebase handles worktree.rebase action func (h *GitHandlers) wsRebase(ctx context.Context, msg *ws.Message) (*ws.Message, error) { var req GitRebaseRequest if err := msg.ParsePayload(&req); err != nil { return nil, fmt.Errorf("invalid payload: %w", err) } if req.SessionID == "" { return nil, fmt.Errorf("session_id is required") } if req.BaseBranch == "" { return nil, fmt.Errorf("base_branch is required") } client, err := h.getAgentCtlClient(ctx, req.SessionID) if err != nil { return nil, err } result, err := client.GitRebase(ctx, req.BaseBranch, req.Repo) if err != nil { return nil, fmt.Errorf("rebase failed: %w", err) } h.notifyGitOperationFailed(req.SessionID, "rebase", result) return ws.NewResponse(msg.ID, msg.Action, result) } // wsMerge handles worktree.merge action func (h *GitHandlers) wsMerge(ctx context.Context, msg *ws.Message) (*ws.Message, error) { var req GitMergeRequest if err := msg.ParsePayload(&req); err != nil { return nil, fmt.Errorf("invalid payload: %w", err) } if req.SessionID == "" { return nil, fmt.Errorf("session_id is required") } if req.BaseBranch == "" { return nil, fmt.Errorf("base_branch is required") } client, err := h.getAgentCtlClient(ctx, req.SessionID) if err != nil { return nil, err } result, err := client.GitMerge(ctx, req.BaseBranch, req.Repo) if err != nil { return nil, fmt.Errorf("merge failed: %w", err) } h.notifyGitOperationFailed(req.SessionID, "merge", result) return ws.NewResponse(msg.ID, msg.Action, result) } // wsAbort handles worktree.abort action func (h *GitHandlers) wsAbort(ctx context.Context, msg *ws.Message) (*ws.Message, error) { var req GitAbortRequest if err := msg.ParsePayload(&req); err != nil { return nil, fmt.Errorf("invalid payload: %w", err) } if req.SessionID == "" { return nil, fmt.Errorf("session_id is required") } if req.Operation != "merge" && req.Operation != "rebase" { return nil, fmt.Errorf("operation must be 'merge' or 'rebase'") } client, err := h.getAgentCtlClient(ctx, req.SessionID) if err != nil { return nil, err } result, err := client.GitAbort(ctx, req.Operation, req.Repo) if err != nil { return nil, fmt.Errorf("abort failed: %w", err) } return ws.NewResponse(msg.ID, msg.Action, result) } // wsCommit handles worktree.commit action func (h *GitHandlers) wsCommit(ctx context.Context, msg *ws.Message) (*ws.Message, error) { var req GitCommitRequest if err := msg.ParsePayload(&req); err != nil { return nil, fmt.Errorf("invalid payload: %w", err) } if req.SessionID == "" { return nil, fmt.Errorf("session_id is required") } if req.Message == "" { return nil, fmt.Errorf("message is required") } client, err := h.getAgentCtlClient(ctx, req.SessionID) if err != nil { return nil, err } result, err := client.GitCommit(ctx, req.Message, req.StageAll, req.Amend, req.Repo) if err != nil { return nil, fmt.Errorf("commit failed: %w", err) } h.notifyGitOperationFailed(req.SessionID, "commit", result) return ws.NewResponse(msg.ID, msg.Action, result) } // wsRenameBranch handles worktree.rename_branch action func (h *GitHandlers) wsRenameBranch(ctx context.Context, msg *ws.Message) (*ws.Message, error) { var req GitRenameBranchRequest if err := msg.ParsePayload(&req); err != nil { return nil, fmt.Errorf("invalid payload: %w", err) } if req.SessionID == "" { return nil, fmt.Errorf("session_id is required") } if req.NewName == "" { return nil, fmt.Errorf("new_name is required") } client, err := h.getAgentCtlClient(ctx, req.SessionID) if err != nil { return nil, err } result, err := client.GitRenameBranch(ctx, req.NewName, req.Repo) if err != nil { return nil, fmt.Errorf("rename branch failed: %w", err) } return ws.NewResponse(msg.ID, msg.Action, result) } // wsReset handles worktree.reset action func (h *GitHandlers) wsReset(ctx context.Context, msg *ws.Message) (*ws.Message, error) { var req GitResetRequest if err := msg.ParsePayload(&req); err != nil { return nil, fmt.Errorf("invalid payload: %w", err) } if req.SessionID == "" { return nil, fmt.Errorf("session_id is required") } if req.CommitSHA == "" { return nil, fmt.Errorf("commit_sha is required") } if req.Mode == "" { req.Mode = "mixed" } validModes := map[string]bool{"soft": true, "mixed": true, "hard": true} if !validModes[req.Mode] { return nil, fmt.Errorf("invalid reset mode: %s (must be soft, mixed, or hard)", req.Mode) } client, err := h.getAgentCtlClient(ctx, req.SessionID) if err != nil { return nil, err } result, err := client.GitReset(ctx, req.CommitSHA, req.Mode, req.Repo) if err != nil { return nil, fmt.Errorf("reset failed: %w", err) } return ws.NewResponse(msg.ID, msg.Action, result) } // wsStage handles worktree.stage action func (h *GitHandlers) wsStage(ctx context.Context, msg *ws.Message) (*ws.Message, error) { var req GitStageRequest if err := msg.ParsePayload(&req); err != nil { return nil, fmt.Errorf("invalid payload: %w", err) } if req.SessionID == "" { return nil, fmt.Errorf("session_id is required") } client, err := h.getAgentCtlClient(ctx, req.SessionID) if err != nil { return nil, err } result, err := client.GitStage(ctx, req.Paths, req.Repo) if err != nil { return nil, fmt.Errorf("stage failed: %w", err) } return ws.NewResponse(msg.ID, msg.Action, result) } // wsUnstage handles worktree.unstage action func (h *GitHandlers) wsUnstage(ctx context.Context, msg *ws.Message) (*ws.Message, error) { var req GitUnstageRequest if err := msg.ParsePayload(&req); err != nil { return nil, fmt.Errorf("invalid payload: %w", err) } if req.SessionID == "" { return nil, fmt.Errorf("session_id is required") } client, err := h.getAgentCtlClient(ctx, req.SessionID) if err != nil { return nil, err } result, err := client.GitUnstage(ctx, req.Paths, req.Repo) if err != nil { return nil, fmt.Errorf("unstage failed: %w", err) } return ws.NewResponse(msg.ID, msg.Action, result) } // wsDiscard handles worktree.discard action func (h *GitHandlers) wsDiscard(ctx context.Context, msg *ws.Message) (*ws.Message, error) { var req GitDiscardRequest if err := msg.ParsePayload(&req); err != nil { return nil, fmt.Errorf("invalid payload: %w", err) } if req.SessionID == "" { return nil, fmt.Errorf("session_id is required") } if len(req.Paths) == 0 { return nil, fmt.Errorf("paths are required") } client, err := h.getAgentCtlClient(ctx, req.SessionID) if err != nil { return nil, err } result, err := client.GitDiscard(ctx, req.Paths, req.Repo) if err != nil { return nil, fmt.Errorf("discard failed: %w", err) } return ws.NewResponse(msg.ID, msg.Action, result) } // wsCreatePR handles worktree.create_pr action func (h *GitHandlers) wsCreatePR(ctx context.Context, msg *ws.Message) (*ws.Message, error) { var req GitCreatePRRequest if err := msg.ParsePayload(&req); err != nil { return nil, fmt.Errorf("invalid payload: %w", err) } if req.SessionID == "" { return nil, fmt.Errorf("session_id is required") } if req.Title == "" { return nil, fmt.Errorf("title is required") } client, err := h.getAgentCtlClient(ctx, req.SessionID) if err != nil { return nil, err } result, err := client.GitCreatePR(ctx, req.Title, req.Body, req.BaseBranch, req.Draft, req.Repo) if err != nil { return nil, fmt.Errorf("create PR failed: %w", err) } // On success, notify callback to associate PR with task. The repo subpath // flows through so the orchestrator can scope the resulting TaskPR / // PRWatch rows to the per-task repository_id. // Use a timeout-bound context so a stuck callback doesn't leak the goroutine. if result.Success && result.PRURL != "" && h.onPRCreated != nil { execution, ok := h.lifecycleMgr.GetExecutionBySessionID(req.SessionID) if ok && execution.TaskID != "" { sessionID := req.SessionID taskID := execution.TaskID prURL := result.PRURL repo := req.Repo go func() { callbackCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() h.onPRCreated(callbackCtx, sessionID, taskID, prURL, "", repo) }() } } return ws.NewResponse(msg.ID, msg.Action, result) } // wsRevertCommit handles worktree.revert_commit action func (h *GitHandlers) wsRevertCommit(ctx context.Context, msg *ws.Message) (*ws.Message, error) { var req GitRevertCommitRequest if err := msg.ParsePayload(&req); err != nil { return nil, fmt.Errorf("invalid payload: %w", err) } if req.SessionID == "" { return nil, fmt.Errorf("session_id is required") } if req.CommitSHA == "" { return nil, fmt.Errorf("commit_sha is required") } client, err := h.getAgentCtlClient(ctx, req.SessionID) if err != nil { return nil, err } result, err := client.GitRevertCommit(ctx, req.CommitSHA, req.Repo) if err != nil { return nil, fmt.Errorf("revert commit failed: %w", err) } h.notifyGitOperationFailed(req.SessionID, "revert", result) return ws.NewResponse(msg.ID, msg.Action, result) } // wsCommitDiff handles session.commit_diff action func (h *GitHandlers) wsCommitDiff(ctx context.Context, msg *ws.Message) (*ws.Message, error) { var req GitShowCommitRequest if err := msg.ParsePayload(&req); err != nil { return nil, fmt.Errorf("invalid payload: %w", err) } if req.SessionID == "" { return nil, fmt.Errorf("session_id is required") } if req.CommitSHA == "" { return nil, fmt.Errorf("commit_sha is required") } client, err := h.getAgentCtlClient(ctx, req.SessionID) if err != nil { if isSessionNotReadyError(err) { return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ "success": false, "ready": false, "reason": "agent_starting", "retry_after_ms": 500, }) } return nil, err } result, err := client.GitShowCommit(ctx, req.CommitSHA, req.Repo) if err != nil { return nil, fmt.Errorf("show commit failed: %w", err) } return ws.NewResponse(msg.ID, msg.Action, result) } func isSessionNotReadyError(err error) bool { if err == nil { return false } msg := err.Error() return strings.Contains(msg, "no agent running for session") || strings.Contains(msg, "agent client not available for session") } // getAgentCtlClient gets the agentctl client for a session. // Uses GetOrEnsureExecution so git operations survive backend restarts — // they're workspace-oriented and don't require a running agent process. func (h *GitHandlers) getAgentCtlClient(ctx context.Context, sessionID string) (*client.Client, error) { execution, err := h.lifecycleMgr.GetOrEnsureExecution(ctx, sessionID) if err != nil { return nil, fmt.Errorf("no agent running for session %s: %w", sessionID, err) } c := execution.GetAgentCtlClient() if c == nil { return nil, fmt.Errorf("agent client not available for session %s", sessionID) } return c, nil } // GitCommitsRequest for session.git.commits action type GitCommitsRequest struct { SessionID string `json:"session_id"` Limit int `json:"limit"` // Max commits to return } // wsGitCommits handles session.git.commits action // The base commit SHA is always looked up from the session metadata in the database. // This ensures commits are filtered to only those made during the session. // When a target branch is available, we use dynamic merge-base calculation for accuracy. func (h *GitHandlers) wsGitCommits(ctx context.Context, msg *ws.Message) (*ws.Message, error) { var req GitCommitsRequest if err := msg.ParsePayload(&req); err != nil { return nil, fmt.Errorf("invalid payload: %w", err) } if req.SessionID == "" { return nil, fmt.Errorf("session_id is required") } // Use GetOrEnsureExecution to recover workspace after backend restarts. // This is a workspace-oriented operation that doesn't require a running agent process. execution, err := h.lifecycleMgr.GetOrEnsureExecution(ctx, req.SessionID) if err != nil { // Check for specific "not ready" errors that indicate the session workspace // is still being prepared. For these, return ready:false so the client can retry. if errors.Is(err, lifecycle.ErrSessionWorkspaceNotReady) || isSessionNotReadyError(err) { return ws.NewResponse(msg.ID, msg.Action, map[string]any{ "commits": []any{}, "ready": false, }) } // For unexpected errors (database failures, etc.), return the error // so the client can display an appropriate error message. return nil, fmt.Errorf("failed to get execution for session %s: %w", req.SessionID, err) } agentClient := execution.GetAgentCtlClient() if agentClient == nil { return ws.NewResponse(msg.ID, msg.Action, map[string]any{ "commits": []any{}, "ready": false, }) } // Look up base commit SHA and target branch from the session metadata var baseCommit, targetBranch string if h.sessionReader != nil { baseCommit = h.sessionReader.GetSessionBaseCommit(ctx, req.SessionID) targetBranch = h.sessionReader.GetSessionBaseBranch(ctx, req.SessionID) } // Fallback: if base_commit_sha is not stored in session, use git merge-base // from git status. This happens for sessions created before the base commit // capture feature or if the capture failed. if baseCommit == "" { status, statusErr := agentClient.GetGitStatus(ctx) if statusErr == nil && status != nil && status.BaseCommit != "" { baseCommit = status.BaseCommit h.logger.Debug("using git status base commit as fallback", zap.String("session_id", req.SessionID), zap.String("base_commit", baseCommit)) } } // Use target branch for dynamic merge-base calculation. // This ensures accurate commit filtering even after rebases. result, err := agentClient.GitLog(ctx, baseCommit, req.Limit, targetBranch, "") if err != nil { return nil, fmt.Errorf("git log failed: %w", err) } return ws.NewResponse(msg.ID, msg.Action, result) } // CumulativeDiffRequest for session.cumulative_diff action type CumulativeDiffRequest struct { SessionID string `json:"session_id"` } // wsCumulativeDiff handles session.cumulative_diff action // The base commit SHA is always looked up from the session metadata in the database. func (h *GitHandlers) wsCumulativeDiff(ctx context.Context, msg *ws.Message) (*ws.Message, error) { var req CumulativeDiffRequest if err := msg.ParsePayload(&req); err != nil { return nil, fmt.Errorf("invalid payload: %w", err) } if req.SessionID == "" { return nil, fmt.Errorf("session_id is required") } // Use GetOrEnsureExecution to recover workspace after backend restarts. // This is a workspace-oriented operation that doesn't require a running agent process. execution, err := h.lifecycleMgr.GetOrEnsureExecution(ctx, req.SessionID) if err != nil { if errors.Is(err, lifecycle.ErrSessionWorkspaceNotReady) || isSessionNotReadyError(err) { return ws.NewResponse(msg.ID, msg.Action, map[string]any{ "cumulative_diff": nil, "ready": false, }) } return nil, fmt.Errorf("failed to get execution for session %s: %w", req.SessionID, err) } agentClient := execution.GetAgentCtlClient() if agentClient == nil { return ws.NewResponse(msg.ID, msg.Action, map[string]any{ "cumulative_diff": nil, "ready": false, }) } // Look up base commit SHA and target branch from the session metadata. // targetBranch lets agentctl recompute the base via merge-base against // origin/ for live divergence — same anchoring as the COMMITS // panel, so the file diff doesn't include changes that came in via merges // from main after the session was started. var baseCommit, targetBranch string if h.sessionReader != nil { baseCommit = h.sessionReader.GetSessionBaseCommit(ctx, req.SessionID) targetBranch = h.sessionReader.GetSessionBaseBranch(ctx, req.SessionID) } // Fallback: if base_commit_sha is not stored, use git merge-base from status if baseCommit == "" { status, statusErr := agentClient.GetGitStatus(ctx) if statusErr == nil && status != nil && status.BaseCommit != "" { baseCommit = status.BaseCommit h.logger.Debug("using git status base commit as fallback for cumulative diff", zap.String("session_id", req.SessionID), zap.String("base_commit", baseCommit)) } else { // Repo-less tasks (no git workspace) have no base commit — return an // empty diff instead of erroring so the frontend's polling doesn't // spam the logs. return ws.NewResponse(msg.ID, msg.Action, map[string]any{ "cumulative_diff": nil, }) } } result, err := agentClient.GetCumulativeDiff(ctx, baseCommit, targetBranch) if err != nil { return nil, fmt.Errorf("cumulative diff failed: %w", err) } // Wrap in cumulative_diff key as expected by frontend return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ "cumulative_diff": result, }) }