Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1017,16 +1017,21 @@ func (m *Manager) RecoverAgentPromptStream(ctx context.Context, sessionID string
return fmt.Errorf("agent stream not connected")
}
if execution.Status == v1.AgentStatusFailed && execution.sessionInitialized && execution.ACPSessionID != "" {
status, err := execution.agentctl.GetStatus(ctx)
if err != nil {
return fmt.Errorf("failed to verify agent status after stream recovery: %w", err)
}
if !status.IsAgentRunning() {
return fmt.Errorf("agent process is not running after stream recovery: %s", status.AgentStatus)
}
if err := m.markBootReadyFromFailed(ctx, execution.ID); err != nil {
return err
}
return m.restoreRecoveredFailedExecution(ctx, execution)
}
return nil
}

func (m *Manager) restoreRecoveredFailedExecution(ctx context.Context, execution *AgentExecution) error {
status, err := execution.agentctl.GetStatus(ctx)
if err != nil {
return fmt.Errorf("failed to verify agent status after stream recovery: %w", err)
}
if !status.IsAgentRunning() {
return fmt.Errorf("agent process is not running after stream recovery: %s", status.AgentStatus)
}
if err := m.markBootReadyFromFailed(ctx, execution.ID); err != nil {
return err
}
return nil
}
Expand Down
3 changes: 3 additions & 0 deletions apps/backend/internal/backendapp/e2e_reset.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ func handleE2EReset(
if _, err := repo.DB().ExecContext(ctx, `DELETE FROM runtime_flag_overrides`); err != nil {
log.Warn("e2e reset: runtime flag override cleanup failed", zap.Error(err))
}
if _, err := repo.DB().ExecContext(ctx, `DELETE FROM github_workspace_settings WHERE workspace_id = ?`, workspaceID); err != nil {
log.Warn("e2e reset: GitHub workspace settings cleanup failed", zap.Error(err))
}

// Reset every agent's routing override to the inherit-markers
// shape onboarding writes. Without this, an agent-override test
Expand Down
97 changes: 92 additions & 5 deletions apps/backend/internal/github/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ func (c *Controller) RegisterHTTPRoutes(router *gin.Engine) {
api.GET("/action-presets", c.httpGetActionPresets)
api.PUT("/action-presets", c.httpUpdateActionPresets)
api.POST("/action-presets/reset", c.httpResetActionPresets)
api.GET("/workspace-settings", c.httpGetWorkspaceSettings)
api.PUT("/workspace-settings", c.httpUpdateWorkspaceSettings)

api.GET("/stats", c.httpGetStats)
}
Expand Down Expand Up @@ -616,6 +618,9 @@ func (c *Controller) httpCreateReviewWatch(ctx *gin.Context) {

func (c *Controller) httpUpdateReviewWatch(ctx *gin.Context) {
id := ctx.Param("id")
if !c.requireReviewWatchInWorkspace(ctx, id) {
return
}
var req UpdateReviewWatchRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
Expand All @@ -630,6 +635,9 @@ func (c *Controller) httpUpdateReviewWatch(ctx *gin.Context) {

func (c *Controller) httpDeleteReviewWatch(ctx *gin.Context) {
id := ctx.Param("id")
if !c.requireReviewWatchInWorkspace(ctx, id) {
return
}
if err := c.service.DeleteReviewWatch(ctx.Request.Context(), id); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
Expand Down Expand Up @@ -812,8 +820,17 @@ func (c *Controller) httpGetRepoMergeMethods(ctx *gin.Context) {
func (c *Controller) httpSearchUserPRs(ctx *gin.Context) {
query := ctx.Query("query")
filter := ctx.Query("filter")
workspaceID := ctx.Query("workspace_id")
page, perPage := parsePaginationQuery(ctx)
result, err := c.service.SearchUserPRsPaged(ctx.Request.Context(), filter, query, page, perPage)
var (
result *PRSearchPage
err error
)
if workspaceID == "" {
result, err = c.service.SearchUserPRsPaged(ctx.Request.Context(), filter, query, page, perPage)
} else {
result, err = c.service.SearchUserPRsPagedForWorkspace(ctx.Request.Context(), workspaceID, filter, query, page, perPage)
}
if err != nil {
c.handleSearchError(ctx, err)
return
Expand All @@ -828,8 +845,17 @@ func (c *Controller) httpSearchUserPRs(ctx *gin.Context) {
func (c *Controller) httpSearchUserIssues(ctx *gin.Context) {
query := ctx.Query("query")
filter := ctx.Query("filter")
workspaceID := ctx.Query("workspace_id")
page, perPage := parsePaginationQuery(ctx)
result, err := c.service.SearchUserIssuesPaged(ctx.Request.Context(), filter, query, page, perPage)
var (
result *IssueSearchPage
err error
)
if workspaceID == "" {
result, err = c.service.SearchUserIssuesPaged(ctx.Request.Context(), filter, query, page, perPage)
} else {
result, err = c.service.SearchUserIssuesPagedForWorkspace(ctx.Request.Context(), workspaceID, filter, query, page, perPage)
}
if err != nil {
c.handleSearchError(ctx, err)
return
Expand All @@ -840,6 +866,38 @@ func (c *Controller) httpSearchUserIssues(ctx *gin.Context) {
ctx.JSON(http.StatusOK, result)
}

func (c *Controller) httpGetWorkspaceSettings(ctx *gin.Context) {
workspaceID := ctx.Query("workspace_id")
if strings.TrimSpace(workspaceID) == "" {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id query parameter required"})
return
}
settings, err := c.service.GetWorkspaceSettings(ctx.Request.Context(), workspaceID)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, settings)
}

func (c *Controller) httpUpdateWorkspaceSettings(ctx *gin.Context) {
Comment thread
carlosflorencio marked this conversation as resolved.
var req UpdateWorkspaceSettingsRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
settings, err := c.service.UpdateWorkspaceSettings(ctx.Request.Context(), &req)
if err != nil {
if errors.Is(err, ErrWorkspaceSettingsValidation) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
} else {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
ctx.JSON(http.StatusOK, settings)
}

// parsePaginationQuery reads `page` and `per_page` query params. Missing or
// non-positive values fall back to defaults (page=1, perPage=50). The upper
// bound for perPage (100) is applied later by clampSearchPage in the client
Expand Down Expand Up @@ -945,6 +1003,9 @@ func (c *Controller) httpCreateIssueWatch(ctx *gin.Context) {

func (c *Controller) httpUpdateIssueWatch(ctx *gin.Context) {
id := ctx.Param("id")
if !c.requireIssueWatchInWorkspace(ctx, id) {
return
}
var req UpdateIssueWatchRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
Expand All @@ -964,6 +1025,9 @@ func (c *Controller) httpUpdateIssueWatch(ctx *gin.Context) {

func (c *Controller) httpDeleteIssueWatch(ctx *gin.Context) {
id := ctx.Param("id")
if !c.requireIssueWatchInWorkspace(ctx, id) {
return
}
if err := c.service.DeleteIssueWatch(ctx.Request.Context(), id); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
Expand All @@ -973,12 +1037,17 @@ func (c *Controller) httpDeleteIssueWatch(ctx *gin.Context) {

func (c *Controller) httpTriggerIssueWatch(ctx *gin.Context) {
id := ctx.Param("id")
workspaceID := ctx.Query("workspace_id")
if workspaceID == "" {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id query parameter required"})
return
}
watch, err := c.service.GetIssueWatch(ctx.Request.Context(), id)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if watch == nil {
if watch == nil || watch.WorkspaceID != workspaceID {
ctx.JSON(http.StatusNotFound, gin.H{"error": "issue watch not found"})
return
}
Expand Down Expand Up @@ -1072,7 +1141,16 @@ func (c *Controller) httpResetActionPresets(ctx *gin.Context) {
// watch lived in a workspace they no longer have open. A future
// multi-tenant rollout would add an optional workspace_id query param.
func (c *Controller) httpCleanupReviewTasks(ctx *gin.Context) {
deleted, err := c.service.CleanupAllReviewTasks(ctx.Request.Context())
workspaceID := ctx.Query("workspace_id")
var (
deleted int
err error
)
if workspaceID == "" {
deleted, err = c.service.CleanupAllReviewTasks(ctx.Request.Context())
} else {
deleted, err = c.service.CleanupReviewTasksForWorkspace(ctx.Request.Context(), workspaceID)
}
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
Expand All @@ -1082,7 +1160,16 @@ func (c *Controller) httpCleanupReviewTasks(ctx *gin.Context) {

// httpCleanupIssueTasks mirrors httpCleanupReviewTasks for issue watches.
func (c *Controller) httpCleanupIssueTasks(ctx *gin.Context) {
deleted, err := c.service.CleanupAllIssueTasks(ctx.Request.Context())
workspaceID := ctx.Query("workspace_id")
var (
deleted int
err error
)
if workspaceID == "" {
deleted, err = c.service.CleanupAllIssueTasks(ctx.Request.Context())
} else {
deleted, err = c.service.CleanupIssueTasksForWorkspace(ctx.Request.Context(), workspaceID)
}
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
Expand Down
6 changes: 5 additions & 1 deletion apps/backend/internal/github/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -484,11 +484,15 @@ func wsTriggerIssueWatch(svc *Service, log *logger.Logger) func(ctx context.Cont
if id == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, errMsgIDRequired, nil)
}
workspaceID, _ := payload["workspace_id"].(string)
if workspaceID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "workspace_id is required", nil)
}
watch, err := svc.GetIssueWatch(ctx, id)
if err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil)
}
if watch == nil {
if watch == nil || watch.WorkspaceID != workspaceID {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "issue watch not found", nil)
}
newIssues, err := svc.CheckIssueWatch(ctx, watch)
Expand Down
75 changes: 74 additions & 1 deletion apps/backend/internal/github/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
// review queue management, and CI/check status tracking.
package github

import "time"
import (
"encoding/json"
"time"
)

// TaskCIAutoFixMaxRounds is the server-enforced CI auto-fix loop guard.
const TaskCIAutoFixMaxRounds = 10
Expand Down Expand Up @@ -628,6 +631,76 @@ type UpdateIssueWatchRequest struct {
CleanupPolicy *string `json:"cleanup_policy,omitempty"`
}

// --- Workspace GitHub scope/settings ---

const (
RepoScopeModeAll = "all"
RepoScopeModeOrgs = "orgs"
RepoScopeModeRepos = "repos"
)

// WorkspaceSettings stores per-workspace GitHub operational settings.
// Authentication remains install-wide; these settings scope GitHub UI/search
// surfaces to the workspace's intended repositories.
type WorkspaceSettings struct {
WorkspaceID string `json:"workspace_id" db:"workspace_id"`
RepoScopeMode string `json:"repo_scope_mode" db:"repo_scope_mode"`
RepoScopeOrgs []string `json:"repo_scope_orgs" db:"-"`
RepoScopeRepos []RepoFilter `json:"repo_scope_repos" db:"-"`
RepoScopeOrgsJSON string `json:"-" db:"repo_scope_orgs"`
RepoScopeReposJSON string `json:"-" db:"repo_scope_repos"`
SavedPresets json.RawMessage `json:"saved_presets,omitempty" db:"saved_presets"`
DefaultQueryPresets json.RawMessage `json:"default_query_presets,omitempty" db:"default_query_presets"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}

// UpdateWorkspaceSettingsRequest replaces the workspace GitHub scope and/or
// preference blobs. Nil blobs leave values unchanged; explicit JSON null clears
// default query presets back to built-ins.
type UpdateWorkspaceSettingsRequest struct {
WorkspaceID string `json:"workspace_id"`
RepoScopeMode *string `json:"repo_scope_mode,omitempty"`
RepoScopeOrgs *[]string `json:"repo_scope_orgs,omitempty"`
RepoScopeRepos *[]RepoFilter `json:"repo_scope_repos,omitempty"`
SavedPresets *json.RawMessage `json:"saved_presets,omitempty"`
DefaultQueryPresets *json.RawMessage `json:"default_query_presets,omitempty"`
Comment thread
carlosflorencio marked this conversation as resolved.
SavedPresetsSet bool `json:"-"`
DefaultQueriesSet bool `json:"-"`
}

func (r *UpdateWorkspaceSettingsRequest) UnmarshalJSON(data []byte) error {
type alias UpdateWorkspaceSettingsRequest
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
var decoded alias
if err := json.Unmarshal(data, &decoded); err != nil {
return err
}
*r = UpdateWorkspaceSettingsRequest(decoded)
if value, ok := raw["saved_presets"]; ok {
r.SavedPresetsSet = true
if string(value) == jsonNullLiteral {
r.SavedPresets = nil
} else {
next := cloneRawMessage(value)
r.SavedPresets = &next
}
}
if value, ok := raw["default_query_presets"]; ok {
r.DefaultQueriesSet = true
if string(value) == jsonNullLiteral {
r.DefaultQueryPresets = nil
} else {
next := cloneRawMessage(value)
r.DefaultQueryPresets = &next
}
}
return nil
}

// --- Action presets (quick-launch prompts on the /github page) ---

// ActionPresetKind enumerates the two lists of quick-launch presets.
Expand Down
Loading
Loading