diff --git a/server/cmd/migrate/main.go b/server/cmd/migrate/main.go index 80726e5037..6edae57686 100644 --- a/server/cmd/migrate/main.go +++ b/server/cmd/migrate/main.go @@ -274,6 +274,7 @@ var concurrentIndexCleanups = map[string]string{ "438_agent_runtime_online_last_seen_index": "idx_agent_runtime_online_last_seen", "439_agent_runtime_offline_last_seen_index": "idx_agent_runtime_offline_last_seen", "440_github_pr_head_sha_index": "idx_github_pull_request_head_sha", + "443_issue_project_status_index": "idx_issue_project_status", } // concurrentDownIndexCleanups covers every migration whose down direction diff --git a/server/internal/handler/inbox.go b/server/internal/handler/inbox.go index f527b2f5f7..00cb910e95 100644 --- a/server/internal/handler/inbox.go +++ b/server/internal/handler/inbox.go @@ -450,9 +450,15 @@ func (h *Handler) ArchiveCompletedInbox(w http.ResponseWriter, r *http.Request) return } + terminalStatusKeys, err := h.terminalIssueStatusKeys(r.Context(), wsUUID) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to resolve status categories") + return + } count, err := h.Queries.ArchiveCompletedInbox(r.Context(), db.ArchiveCompletedInboxParams{ - WorkspaceID: wsUUID, - RecipientID: parseUUID(userID), + WorkspaceID: wsUUID, + RecipientID: parseUUID(userID), + TerminalStatusKeys: terminalStatusKeys, }) if err != nil { writeError(w, http.StatusInternalServerError, "failed to archive completed inbox") diff --git a/server/internal/handler/inbox_test.go b/server/internal/handler/inbox_test.go index 41d9c7f9b0..c9dcb1e3de 100644 --- a/server/internal/handler/inbox_test.go +++ b/server/internal/handler/inbox_test.go @@ -163,3 +163,50 @@ func TestArchiveAllReadInboxUsesNewestIssueRow(t *testing.T) { t.Fatalf("archived rows in unread issue = %d, want the whole group untouched", got) } } + +func TestArchiveCompletedInboxExpandsCustomTerminalStatuses(t *testing.T) { + workspaceID := dbfx.Workspace(t, "Archive custom completed", "archive-custom-completed-"+uuid.NewString()) + dbfx.Member(t, workspaceID, testUserID, "owner") + dbfx.Insert(t, "issue_status", testutil.Cols{ + "workspace_id": workspaceID, + "key": "verified_complete", + "name": "Verified complete", + "category": "done", + "color": "#22c55e", + "is_system": false, + "position": 1, + }) + completedIssueID := dbfx.Issue(t, "Custom completed issue", testutil.Cols{ + "workspace_id": workspaceID, + "status": "verified_complete", + }) + openIssueID := dbfx.Issue(t, "Open issue", testutil.Cols{ + "workspace_id": workspaceID, + "status": "todo", + }) + for _, issueID := range []string{completedIssueID, openIssueID} { + dbfx.Insert(t, "inbox_item", testutil.Cols{ + "workspace_id": workspaceID, + "recipient_type": "member", + "recipient_id": testUserID, + "type": "status_changed", + "severity": "info", + "issue_id": issueID, + "title": "Status changed", + "archived": false, + }) + } + + testutil.Call(t, inboxWorkspaceHandler(testHandler.ArchiveCompletedInbox), + inboxRequest(http.MethodPost, "/api/inbox/archive-completed", workspaceID)). + Want(http.StatusOK) + + if got := dbfx.Count(t, + "SELECT count(*) FROM inbox_item WHERE issue_id = $1 AND archived = true", completedIssueID); got != 1 { + t.Fatalf("archived rows for custom completed issue = %d, want 1", got) + } + if got := dbfx.Count(t, + "SELECT count(*) FROM inbox_item WHERE issue_id = $1 AND archived = true", openIssueID); got != 0 { + t.Fatalf("archived rows for open issue = %d, want 0", got) + } +} diff --git a/server/internal/handler/issue.go b/server/internal/handler/issue.go index bd615656cf..164f630034 100644 --- a/server/internal/handler/issue.go +++ b/server/internal/handler/issue.go @@ -619,7 +619,7 @@ type searchResult struct { // It uses LOWER(column) LIKE for case-insensitive matching compatible with pg_bigm 1.2 GIN indexes. // Search patterns are lowercased in Go to avoid redundant LOWER() on the pattern side in SQL. // LIKE patterns are pre-built in Go (e.g. "%html%") so pg_bigm can extract bigrams from a single parameter value. -func buildSearchQuery(phrase string, terms []string, queryNum int, hasNum bool, includeClosed bool) (string, []any) { +func buildSearchQuery(phrase string, terms []string, queryNum int, hasNum bool, includeClosed bool, terminalStatusKeys []string) (string, []any) { // Lowercase in Go so SQL only needs LOWER() on the column side. phrase = strings.ToLower(phrase) for i, t := range terms { @@ -701,7 +701,10 @@ func buildSearchQuery(phrase string, terms []string, queryNum int, hasNum bool, whereClause := "(" + strings.Join(whereParts, " OR ") + ")" if !includeClosed { - whereClause += " AND issue_effective_status(i.workspace_id, i.status) NOT IN ('done', 'cancelled')" + // Negate only known terminal keys so an unknown legacy key remains + // searchable instead of disappearing from the default result set. + terminalStatusesParam := nextArg(terminalStatusKeys) + whereClause += fmt.Sprintf(" AND NOT (i.status = ANY(%s::text[]))", terminalStatusesParam) } // --- ORDER BY clause --- @@ -912,8 +915,18 @@ func (h *Handler) SearchIssues(w http.ResponseWriter, r *http.Request) { } terms := splitSearchTerms(q) queryNum, hasNum := parseQueryNumber(q) + var terminalStatusKeys []string + if !includeClosed { + resolvedKeys, err := h.terminalIssueStatusKeys(ctx, wsUUID) + if err != nil { + slog.Warn("expand terminal status categories failed", append(logger.RequestAttrs(r), "error", err)...) + writeError(w, http.StatusInternalServerError, "failed to resolve status categories") + return + } + terminalStatusKeys = resolvedKeys + } - sqlQuery, args := buildSearchQuery(q, terms, queryNum, hasNum, includeClosed) + sqlQuery, args := buildSearchQuery(q, terms, queryNum, hasNum, includeClosed, terminalStatusKeys) // Fill placeholder args: $4 = workspace_id, last two = limit, offset args[3] = wsUUID args[len(args)-2] = limit @@ -1127,16 +1140,22 @@ func (h *Handler) ListIssues(w http.ResponseWriter, r *http.Request) { } openPropertiesFilter = marshaled } + terminalStatusKeys, err := h.terminalIssueStatusKeys(ctx, wsUUID) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to resolve status categories") + return + } issues, err := h.Queries.ListOpenIssues(ctx, db.ListOpenIssuesParams{ - WorkspaceID: wsUUID, - Priority: priorityFilter, - AssigneeID: assigneeFilter, - AssigneeIds: assigneeIdsFilter, - CreatorID: creatorFilter, - ProjectID: projectFilter, - InvolvesUserID: involvesUserFilter, - MetadataFilter: metadataFilter, - PropertiesFilter: openPropertiesFilter, + WorkspaceID: wsUUID, + TerminalStatusKeys: terminalStatusKeys, + Priority: priorityFilter, + AssigneeID: assigneeFilter, + AssigneeIds: assigneeIdsFilter, + CreatorID: creatorFilter, + ProjectID: projectFilter, + InvolvesUserID: involvesUserFilter, + MetadataFilter: metadataFilter, + PropertiesFilter: openPropertiesFilter, }) if err != nil { writeError(w, http.StatusInternalServerError, "failed to list issues") @@ -2371,7 +2390,15 @@ func (h *Handler) ChildIssueProgress(w http.ResponseWriter, r *http.Request) { return } - rows, err := h.Queries.ChildIssueProgress(r.Context(), wsUUID) + terminalStatusKeys, err := h.terminalIssueStatusKeys(r.Context(), wsUUID) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to resolve status categories") + return + } + rows, err := h.Queries.ChildIssueProgress(r.Context(), db.ChildIssueProgressParams{ + WorkspaceID: wsUUID, + TerminalStatusKeys: terminalStatusKeys, + }) if err != nil { writeError(w, http.StatusInternalServerError, "failed to get child issue progress") return diff --git a/server/internal/handler/issue_status.go b/server/internal/handler/issue_status.go index 39d9e209ee..0be1d1d9a8 100644 --- a/server/internal/handler/issue_status.go +++ b/server/internal/handler/issue_status.go @@ -40,6 +40,16 @@ type IssueStatusResponse struct { UpdatedAt string `json:"updated_at"` } +// terminalIssueStatusKeys resolves the concrete status keys whose categories +// carry terminal behavior. Callers pass the result into indexed status +// predicates instead of resolving the category once per issue row. +func (h *Handler) terminalIssueStatusKeys(ctx context.Context, workspaceID pgtype.UUID) ([]string, error) { + return issuestatus.ExpandCategories(ctx, h.Queries, workspaceID, []string{ + issuestatus.Done, + issuestatus.Cancelled, + }) +} + func issueStatusToResponse(s db.IssueStatus) IssueStatusResponse { return IssueStatusResponse{ ID: uuidToString(s.ID), diff --git a/server/internal/handler/issue_status_test.go b/server/internal/handler/issue_status_test.go index d51c1a8611..424b96a541 100644 --- a/server/internal/handler/issue_status_test.go +++ b/server/internal/handler/issue_status_test.go @@ -413,13 +413,31 @@ func TestIssueWriteStoresCanonicalStatusKey(t *testing.T) { } // TestCustomTerminalStatusCountsAsTerminalInSQL covers the SQL-side consumers -// the Go resolver cannot reach. Before issue_effective_status existed, each of -// these read the status literal, so a custom status in the `done` category -// still counted as open for the duplicate guard and was missed by sub-issue and -// project completion counts. +// the Go resolver cannot reach. Terminal categories are expanded once into +// concrete keys so custom done statuses preserve their behavior without a +// per-row issue_effective_status call. func TestCustomTerminalStatusCountsAsTerminalInSQL(t *testing.T) { ctx := context.Background() createTestCustomStatus(t, "gate_approved_s", issuestatus.Done) + createTestCustomStatus(t, "gate_archived_s", issuestatus.Done) + if _, err := testPool.Exec(ctx, ` + UPDATE issue_status + SET archived_at = now() + WHERE workspace_id = $1 AND key = 'gate_archived_s'`, parseUUID(testWorkspaceID)); err != nil { + t.Fatalf("archive custom terminal status: %v", err) + } + terminalStatusKeys, err := issuestatus.ExpandCategories( + ctx, + testHandler.Queries, + parseUUID(testWorkspaceID), + []string{issuestatus.Done, issuestatus.Cancelled}, + ) + if err != nil { + t.Fatalf("expand terminal status categories: %v", err) + } + if !slices.Contains(terminalStatusKeys, "gate_archived_s") { + t.Fatalf("expanded terminal keys %v do not include archived custom status", terminalStatusKeys) + } mkIssue := func(title, status string) pgtype.UUID { t.Helper() @@ -443,13 +461,38 @@ func TestCustomTerminalStatusCountsAsTerminalInSQL(t *testing.T) { title := "sql terminal duplicate probe" mkIssue(title, "gate_approved_s") if _, err := testHandler.Queries.FindActiveDuplicateIssue(ctx, db.FindActiveDuplicateIssueParams{ - WorkspaceID: parseUUID(testWorkspaceID), - NormalizedTitle: title, + WorkspaceID: parseUUID(testWorkspaceID), + TerminalStatusKeys: terminalStatusKeys, + NormalizedTitle: title, }); err == nil { t.Error("an issue on a custom done status must not count as an active duplicate") } }) + t.Run("open issue listing excludes it", func(t *testing.T) { + customDoneID := mkIssue("sql open-list custom done", "gate_approved_s") + openID := mkIssue("sql open-list active", "todo") + unknownID := parseUUID(dbfx.Issue(t, "sql open-list unknown legacy status", testutil.Cols{ + "status": "legacy_unknown", + })) + rows, err := testHandler.Queries.ListOpenIssues(ctx, db.ListOpenIssuesParams{ + WorkspaceID: parseUUID(testWorkspaceID), + TerminalStatusKeys: terminalStatusKeys, + }) + if err != nil { + t.Fatalf("ListOpenIssues: %v", err) + } + var sawCustomDone, sawOpen, sawUnknown bool + for _, row := range rows { + sawCustomDone = sawCustomDone || row.ID == customDoneID + sawOpen = sawOpen || row.ID == openID + sawUnknown = sawUnknown || row.ID == unknownID + } + if sawCustomDone || !sawOpen || !sawUnknown { + t.Fatalf("open listing customDone/open/unknown = %v/%v/%v, want false/true/true", sawCustomDone, sawOpen, sawUnknown) + } + }) + t.Run("project completion counts it as done", func(t *testing.T) { var projectID pgtype.UUID if err := testPool.QueryRow(ctx, @@ -467,8 +510,18 @@ func TestCustomTerminalStatusCountsAsTerminalInSQL(t *testing.T) { t.Fatalf("attach to project: %v", err) } } + foreignWorkspaceID := dbfx.Workspace(t, "Foreign project stats", fmt.Sprintf("foreign-project-stats-%d", time.Now().UnixNano())) + dbfx.Issue(t, "foreign workspace issue with mismatched project", testutil.Cols{ + "workspace_id": foreignWorkspaceID, + "project_id": uuidToString(projectID), + "status": "todo", + }) - stats, err := testHandler.Queries.GetProjectIssueStats(ctx, []pgtype.UUID{projectID}) + stats, err := testHandler.Queries.GetProjectIssueStats(ctx, db.GetProjectIssueStatsParams{ + WorkspaceID: parseUUID(testWorkspaceID), + ProjectIds: []pgtype.UUID{projectID}, + TerminalStatusKeys: terminalStatusKeys, + }) if err != nil { t.Fatalf("GetProjectIssueStats: %v", err) } @@ -492,7 +545,10 @@ func TestCustomTerminalStatusCountsAsTerminalInSQL(t *testing.T) { } } - rows, err := testHandler.Queries.ChildIssueProgress(ctx, parseUUID(testWorkspaceID)) + rows, err := testHandler.Queries.ChildIssueProgress(ctx, db.ChildIssueProgressParams{ + WorkspaceID: parseUUID(testWorkspaceID), + TerminalStatusKeys: terminalStatusKeys, + }) if err != nil { t.Fatalf("ChildIssueProgress: %v", err) } diff --git a/server/internal/handler/project.go b/server/internal/handler/project.go index 46fad304f1..bd05fca65c 100644 --- a/server/internal/handler/project.go +++ b/server/internal/handler/project.go @@ -14,6 +14,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" + "github.com/multica-ai/multica/server/internal/issuestatus" "github.com/multica-ai/multica/server/internal/logger" "github.com/multica-ai/multica/server/internal/util" db "github.com/multica-ai/multica/server/pkg/db/generated" @@ -63,14 +64,32 @@ func projectToResponse(p db.Project) ProjectResponse { } } -func (h *Handler) loadProjectIssueStats(ctx context.Context, projectID pgtype.UUID) (int64, int64) { - stats, err := h.Queries.GetProjectIssueStats(ctx, []pgtype.UUID{projectID}) +func (h *Handler) loadProjectIssueStats(ctx context.Context, workspaceID, projectID pgtype.UUID) (int64, int64) { + terminalStatusKeys := h.projectTerminalIssueStatusKeys(ctx, workspaceID) + stats, err := h.Queries.GetProjectIssueStats(ctx, db.GetProjectIssueStatsParams{ + WorkspaceID: workspaceID, + ProjectIds: []pgtype.UUID{projectID}, + TerminalStatusKeys: terminalStatusKeys, + }) if err != nil || len(stats) == 0 { return 0, 0 } return stats[0].TotalCount, stats[0].DoneCount } +// projectTerminalIssueStatusKeys keeps project responses useful if the custom +// status catalog cannot be read. Canonical terminal keys are less complete +// than the workspace catalog, but they avoid rendering every project as 0/0. +func (h *Handler) projectTerminalIssueStatusKeys(ctx context.Context, workspaceID pgtype.UUID) []string { + keys, err := h.terminalIssueStatusKeys(ctx, workspaceID) + if err == nil { + return keys + } + slog.Warn("expand project terminal status categories failed; using canonical keys", + "workspace_id", uuidToString(workspaceID), "error", err) + return []string{issuestatus.Done, issuestatus.Cancelled} +} + func (h *Handler) loadProjectResourceCount(ctx context.Context, projectID pgtype.UUID) int64 { rows, err := h.Queries.GetProjectResourceCounts(ctx, []pgtype.UUID{projectID}) if err != nil || len(rows) == 0 { @@ -146,8 +165,13 @@ func (h *Handler) ListProjects(w http.ResponseWriter, r *http.Request) { for i, p := range projects { projectIDs[i] = p.ID } - stats, err := h.Queries.GetProjectIssueStats(r.Context(), projectIDs) - if err == nil { + terminalStatusKeys := h.projectTerminalIssueStatusKeys(r.Context(), wsUUID) + stats, statsErr := h.Queries.GetProjectIssueStats(r.Context(), db.GetProjectIssueStatsParams{ + WorkspaceID: wsUUID, + ProjectIds: projectIDs, + TerminalStatusKeys: terminalStatusKeys, + }) + if statsErr == nil { for _, s := range stats { statsMap[uuidToString(s.ProjectID)] = s } @@ -191,7 +215,7 @@ func (h *Handler) GetProject(w http.ResponseWriter, r *http.Request) { return } resp := projectToResponse(project) - resp.IssueCount, resp.DoneCount = h.loadProjectIssueStats(r.Context(), project.ID) + resp.IssueCount, resp.DoneCount = h.loadProjectIssueStats(r.Context(), wsUUID, project.ID) resp.ResourceCount = h.loadProjectResourceCount(r.Context(), project.ID) writeJSON(w, http.StatusOK, resp) } @@ -567,7 +591,7 @@ func (h *Handler) UpdateProject(w http.ResponseWriter, r *http.Request) { return } resp := projectToResponse(project) - resp.IssueCount, resp.DoneCount = h.loadProjectIssueStats(r.Context(), project.ID) + resp.IssueCount, resp.DoneCount = h.loadProjectIssueStats(r.Context(), wsUUID, project.ID) resp.ResourceCount = h.loadProjectResourceCount(r.Context(), project.ID) h.publish(protocol.EventProjectUpdated, workspaceID, "member", userID, map[string]any{"project": resp}) writeJSON(w, http.StatusOK, resp) @@ -896,8 +920,13 @@ func (h *Handler) SearchProjects(w http.ResponseWriter, r *http.Request) { for i, r := range results { projectIDs[i] = r.project.ID } - stats, err := h.Queries.GetProjectIssueStats(ctx, projectIDs) - if err == nil { + terminalStatusKeys := h.projectTerminalIssueStatusKeys(ctx, wsUUID) + stats, statsErr := h.Queries.GetProjectIssueStats(ctx, db.GetProjectIssueStatsParams{ + WorkspaceID: wsUUID, + ProjectIds: projectIDs, + TerminalStatusKeys: terminalStatusKeys, + }) + if statsErr == nil { for _, s := range stats { statsMap[uuidToString(s.ProjectID)] = s } diff --git a/server/internal/handler/project_issue_stats_test.go b/server/internal/handler/project_issue_stats_test.go new file mode 100644 index 0000000000..79a656c0d2 --- /dev/null +++ b/server/internal/handler/project_issue_stats_test.go @@ -0,0 +1,26 @@ +package handler + +import ( + "context" + "errors" + "slices" + "testing" + + "github.com/multica-ai/multica/server/internal/issuestatus" + db "github.com/multica-ai/multica/server/pkg/db/generated" +) + +func TestProjectTerminalIssueStatusKeysFallsBackToCanonicalKeys(t *testing.T) { + h := *testHandler + h.Queries = db.New(failQueryDBTX{ + DBTX: testPool, + failOn: "SELECT key FROM issue_status", + err: errors.New("status catalog unavailable"), + }) + + got := h.projectTerminalIssueStatusKeys(context.Background(), parseUUID(testWorkspaceID)) + want := []string{issuestatus.Done, issuestatus.Cancelled} + if !slices.Equal(got, want) { + t.Fatalf("project terminal keys = %v, want canonical fallback %v", got, want) + } +} diff --git a/server/internal/handler/search_test.go b/server/internal/handler/search_test.go index a3947d2736..ebc93db665 100644 --- a/server/internal/handler/search_test.go +++ b/server/internal/handler/search_test.go @@ -6,7 +6,7 @@ import ( ) func TestBuildSearchQuery_SingleTerm(t *testing.T) { - query, args := buildSearchQuery("Hello", []string{"Hello"}, 0, false, false) + query, args := buildSearchQuery("Hello", []string{"Hello"}, 0, false, false, []string{"done", "cancelled"}) // Pattern should be lowercased in Go. if args[0] != "hello" { @@ -36,13 +36,41 @@ func TestBuildSearchQuery_SingleTerm(t *testing.T) { } // Should exclude closed issues by default. - if !strings.Contains(query, "NOT IN ('done', 'cancelled')") { - t.Error("query should exclude done/cancelled when includeClosed=false") + if !strings.Contains(query, "NOT (i.status = ANY(") { + t.Error("query should exclude the expanded terminal status keys when includeClosed=false") + } + if strings.Contains(query, "issue_effective_status") { + t.Error("query should not resolve status categories once per issue row") + } +} + +func TestBuildSearchQuery_CustomTerminalStatuses(t *testing.T) { + terminalStatusKeys := []string{"done", "cancelled", "verified"} + query, args := buildSearchQuery( + "Hello", + []string{"Hello"}, + 0, + false, + false, + terminalStatusKeys, + ) + + if !strings.Contains(query, "NOT (i.status = ANY($5::text[]))") { + t.Fatalf("query does not filter through the expanded terminal keys:\n%s", query) + } + got, ok := args[4].([]string) + if !ok || len(got) != len(terminalStatusKeys) { + t.Fatalf("terminal status argument = %#v, want %#v", args[4], terminalStatusKeys) + } + for i := range terminalStatusKeys { + if got[i] != terminalStatusKeys[i] { + t.Fatalf("terminal status argument = %#v, want %#v", got, terminalStatusKeys) + } } } func TestBuildSearchQuery_MultiTerm(t *testing.T) { - query, args := buildSearchQuery("Foo Bar", []string{"Foo", "Bar"}, 0, false, false) + query, args := buildSearchQuery("Foo Bar", []string{"Foo", "Bar"}, 0, false, false, []string{"done", "cancelled"}) // Both phrase and terms should be lowercased. if args[0] != "foo bar" { @@ -63,7 +91,7 @@ func TestBuildSearchQuery_MultiTerm(t *testing.T) { } func TestBuildSearchQuery_WithNumber(t *testing.T) { - query, args := buildSearchQuery("MUL-42", []string{"MUL-42"}, 42, true, false) + query, args := buildSearchQuery("MUL-42", []string{"MUL-42"}, 42, true, false, []string{"done", "cancelled"}) _ = args // Number match should be in WHERE. @@ -77,15 +105,15 @@ func TestBuildSearchQuery_WithNumber(t *testing.T) { } func TestBuildSearchQuery_IncludeClosed(t *testing.T) { - query, _ := buildSearchQuery("test", []string{"test"}, 0, false, true) + query, _ := buildSearchQuery("test", []string{"test"}, 0, false, true, nil) - if strings.Contains(query, "NOT IN ('done', 'cancelled')") { + if strings.Contains(query, "i.status = ANY(") { t.Error("query should not exclude done/cancelled when includeClosed=true") } } func TestBuildSearchQuery_SpecialChars(t *testing.T) { - query, args := buildSearchQuery("100%", []string{"100%"}, 0, false, false) + query, args := buildSearchQuery("100%", []string{"100%"}, 0, false, false, []string{"done", "cancelled"}) _ = query // % should be escaped in the phrase arg. @@ -204,7 +232,7 @@ func TestExtractSnippet_CJKContent(t *testing.T) { // --- Ranking regression tests --- func TestBuildSearchQuery_CommentRankTiers(t *testing.T) { - query, _ := buildSearchQuery("test phrase", []string{"test", "phrase"}, 0, false, false) + query, _ := buildSearchQuery("test phrase", []string{"test", "phrase"}, 0, false, false, []string{"done", "cancelled"}) // Comment phrase match should be tier 7 if !strings.Contains(query, "THEN 7") { @@ -221,7 +249,7 @@ func TestBuildSearchQuery_CommentRankTiers(t *testing.T) { } func TestBuildSearchQuery_DescriptionRankTiers(t *testing.T) { - query, _ := buildSearchQuery("foo bar", []string{"foo", "bar"}, 0, false, false) + query, _ := buildSearchQuery("foo bar", []string{"foo", "bar"}, 0, false, false, []string{"done", "cancelled"}) // Description phrase match should be tier 5 if !strings.Contains(query, "THEN 5") { @@ -234,7 +262,7 @@ func TestBuildSearchQuery_DescriptionRankTiers(t *testing.T) { } func TestBuildSearchQuery_SingleTermNoAllTermTiers(t *testing.T) { - query, _ := buildSearchQuery("html", []string{"html"}, 0, false, false) + query, _ := buildSearchQuery("html", []string{"html"}, 0, false, false, []string{"done", "cancelled"}) // Extract the rank CASE expression (ends with "ELSE 9 END") to avoid // false matches against statusRank which also contains THEN 4/6. @@ -268,7 +296,7 @@ func TestBuildSearchQuery_SingleTermNoAllTermTiers(t *testing.T) { // $4 is buildSearchQuery's canonical workspace_id placeholder (the // caller writes wsUUID into args[3] before executing). func TestBuildSearchQuery_CommentSubqueryWorkspaceScope(t *testing.T) { - singleQuery, _ := buildSearchQuery("html", []string{"html"}, 0, false, false) + singleQuery, _ := buildSearchQuery("html", []string{"html"}, 0, false, false, []string{"done", "cancelled"}) // Every occurrence of `FROM comment c` must be followed by the // c.workspace_id = $4 constraint. Counting is safer than a single @@ -286,7 +314,7 @@ func TestBuildSearchQuery_CommentSubqueryWorkspaceScope(t *testing.T) { // Multi-term uses one extra comment subquery in the WHERE and one in // the rank CASE for the all-terms match — same invariant applies. - multiQuery, _ := buildSearchQuery("foo bar", []string{"foo", "bar"}, 0, false, false) + multiQuery, _ := buildSearchQuery("foo bar", []string{"foo", "bar"}, 0, false, false, []string{"done", "cancelled"}) fromCountMulti := strings.Count(multiQuery, "FROM comment c") scopedCountMulti := strings.Count(multiQuery, "c.workspace_id = $4") if scopedCountMulti < fromCountMulti { @@ -398,6 +426,6 @@ func TestBuildProjectSearchQuery_CancelledDemotedAheadOfRelevance(t *testing.T) // keeps each test's literals independent. func buildSearchQueryForTest(t *testing.T, phrase string, terms []string, num int, hasNum bool, includeClosed bool) string { t.Helper() - query, _ := buildSearchQuery(phrase, append([]string(nil), terms...), num, hasNum, includeClosed) + query, _ := buildSearchQuery(phrase, append([]string(nil), terms...), num, hasNum, includeClosed, []string{"done", "cancelled"}) return query } diff --git a/server/internal/issueguard/duplicate.go b/server/internal/issueguard/duplicate.go index 23085c129e..a4b4a12865 100644 --- a/server/internal/issueguard/duplicate.go +++ b/server/internal/issueguard/duplicate.go @@ -9,6 +9,7 @@ import ( "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" + "github.com/multica-ai/multica/server/internal/issuestatus" "github.com/multica-ai/multica/server/internal/util" db "github.com/multica-ai/multica/server/pkg/db/generated" ) @@ -60,12 +61,20 @@ func LockAndFindActiveDuplicate( if allowDuplicate { return db.Issue{}, false, nil } + terminalStatusKeys, err := issuestatus.ExpandCategories(ctx, q, workspaceID, []string{ + issuestatus.Done, + issuestatus.Cancelled, + }) + if err != nil { + return db.Issue{}, false, err + } duplicate, err := q.FindActiveDuplicateIssue(ctx, db.FindActiveDuplicateIssueParams{ - WorkspaceID: workspaceID, - ProjectID: projectID, - ParentIssueID: parentIssueID, - NormalizedTitle: normalizedTitle, + WorkspaceID: workspaceID, + TerminalStatusKeys: terminalStatusKeys, + ProjectID: projectID, + ParentIssueID: parentIssueID, + NormalizedTitle: normalizedTitle, }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -92,13 +101,21 @@ func LockAndFindRecentAutopilotDuplicate( if err := q.LockIssueDuplicateKey(ctx, recentAutopilotLockKey(workspaceID, autopilotID, projectID, normalizedTitle)); err != nil { return db.Issue{}, false, err } + terminalStatusKeys, err := issuestatus.ExpandCategories(ctx, q, workspaceID, []string{ + issuestatus.Done, + issuestatus.Cancelled, + }) + if err != nil { + return db.Issue{}, false, err + } duplicate, err := q.FindRecentAutopilotDuplicateIssue(ctx, db.FindRecentAutopilotDuplicateIssueParams{ - WorkspaceID: workspaceID, - OriginID: autopilotID, - ProjectID: projectID, - NormalizedTitle: normalizedTitle, - CreatedAfter: pgtype.Timestamptz{Time: time.Now().UTC().Add(-window), Valid: true}, + WorkspaceID: workspaceID, + TerminalStatusKeys: terminalStatusKeys, + OriginID: autopilotID, + ProjectID: projectID, + NormalizedTitle: normalizedTitle, + CreatedAfter: pgtype.Timestamptz{Time: time.Now().UTC().Add(-window), Valid: true}, }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { diff --git a/server/internal/issueguard/duplicate_test.go b/server/internal/issueguard/duplicate_test.go new file mode 100644 index 0000000000..a59d90eb33 --- /dev/null +++ b/server/internal/issueguard/duplicate_test.go @@ -0,0 +1,65 @@ +package issueguard + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgtype" + db "github.com/multica-ai/multica/server/pkg/db/generated" +) + +type catalogFailureDB struct { + err error +} + +func (f catalogFailureDB) Exec(context.Context, string, ...any) (pgconn.CommandTag, error) { + return pgconn.NewCommandTag("SELECT 1"), nil +} + +func (f catalogFailureDB) Query(context.Context, string, ...any) (pgx.Rows, error) { + return nil, f.err +} + +func (f catalogFailureDB) QueryRow(context.Context, string, ...any) pgx.Row { + return catalogFailureRow{err: f.err} +} + +type catalogFailureRow struct { + err error +} + +func (r catalogFailureRow) Scan(...any) error { return r.err } + +func TestLockAndFindActiveDuplicatePropagatesStatusCatalogFailure(t *testing.T) { + catalogErr := errors.New("status catalog unavailable") + q := db.New(catalogFailureDB{err: catalogErr}) + + _, found, err := LockAndFindActiveDuplicate( + context.Background(), q, testUUID(1), pgtype.UUID{}, pgtype.UUID{}, "duplicate title", false, + ) + if !errors.Is(err, catalogErr) || found { + t.Fatalf("LockAndFindActiveDuplicate = found %v, err %v; want false, catalog error", found, err) + } +} + +func TestLockAndFindRecentAutopilotDuplicatePropagatesStatusCatalogFailure(t *testing.T) { + catalogErr := errors.New("status catalog unavailable") + q := db.New(catalogFailureDB{err: catalogErr}) + + _, found, err := LockAndFindRecentAutopilotDuplicate( + context.Background(), q, testUUID(1), testUUID(2), pgtype.UUID{}, "duplicate title", time.Hour, + ) + if !errors.Is(err, catalogErr) || found { + t.Fatalf("LockAndFindRecentAutopilotDuplicate = found %v, err %v; want false, catalog error", found, err) + } +} + +func testUUID(lastByte byte) pgtype.UUID { + var value [16]byte + value[len(value)-1] = lastByte + return pgtype.UUID{Bytes: value, Valid: true} +} diff --git a/server/internal/service/delegated_failure_recovery_test.go b/server/internal/service/delegated_failure_recovery_test.go index 6475991525..9642f839b1 100644 --- a/server/internal/service/delegated_failure_recovery_test.go +++ b/server/internal/service/delegated_failure_recovery_test.go @@ -262,6 +262,46 @@ func TestPendingDelegatedFailureSweepRepairsCommittedCommentWithoutTask(t *testi } } +func TestPendingDelegatedFailureSweepSkipsCustomTerminalSourceIssue(t *testing.T) { + f, svc := seedDelegatedFailureFixture(t) + ctx := context.Background() + failedID := f.insertWorkerTask(t, "failed", "comment", 1, 2) + if _, err := f.pool.Exec(ctx, ` + UPDATE agent_task_queue + SET failure_reason = 'agent_error.process_failure', error = 'worker exited', completed_at = now() + WHERE id = $1`, failedID); err != nil { + t.Fatalf("stamp failed task: %v", err) + } + if target, created, err := svc.ensureDelegatedFailureRecoveryComment(ctx, failedID); err != nil || target == nil || !created { + t.Fatalf("ensure recovery comment = target %v created %v err %v", target != nil, created, err) + } + + const customDone = "recovery_verified" + if _, err := f.pool.Exec(ctx, ` + INSERT INTO issue_status ( + workspace_id, key, name, description, category, color, is_system, position + ) VALUES ($1, $2, 'Recovery verified', '', 'done', '#22c55e', false, 1)`, + f.workspaceID, customDone); err != nil { + t.Fatalf("insert custom done status: %v", err) + } + if _, err := f.pool.Exec(ctx, `UPDATE issue SET status = $2 WHERE id = $1`, f.issueID, customDone); err != nil { + t.Fatalf("move source issue to custom done status: %v", err) + } + + if result, err := svc.RecoverPendingDelegatedFailures(ctx, 100); err != nil || result != (DelegatedFailureRecoverySweepResult{}) { + t.Fatalf("terminal-source recovery sweep = %+v, %v; want zero result, nil", result, err) + } + var recoveryTasks int + if err := f.pool.QueryRow(ctx, ` + SELECT count(*) FROM agent_task_queue + WHERE trigger_evidence_kind = 'delegated_failure' AND trigger_evidence_ref_id = $1`, failedID).Scan(&recoveryTasks); err != nil { + t.Fatalf("count recovery tasks: %v", err) + } + if recoveryTasks != 0 { + t.Fatalf("recovery tasks = %d, want none for a custom terminal source issue", recoveryTasks) + } +} + func TestPendingDelegatedFailureSweepRequeuesTerminalUndeliveredTask(t *testing.T) { for _, terminalStatus := range []string{"failed", "cancelled"} { t.Run(terminalStatus, func(t *testing.T) { diff --git a/server/migrations/443_issue_project_status_index.down.sql b/server/migrations/443_issue_project_status_index.down.sql new file mode 100644 index 0000000000..649eab47de --- /dev/null +++ b/server/migrations/443_issue_project_status_index.down.sql @@ -0,0 +1 @@ +DROP INDEX CONCURRENTLY IF EXISTS idx_issue_project_status; diff --git a/server/migrations/443_issue_project_status_index.up.sql b/server/migrations/443_issue_project_status_index.up.sql new file mode 100644 index 0000000000..931aaf6c33 --- /dev/null +++ b/server/migrations/443_issue_project_status_index.up.sql @@ -0,0 +1,2 @@ +CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_issue_project_status + ON issue (project_id, workspace_id, status); diff --git a/server/pkg/db/generated/agent.sql.go b/server/pkg/db/generated/agent.sql.go index bb7aada94f..907f3af531 100644 --- a/server/pkg/db/generated/agent.sql.go +++ b/server/pkg/db/generated/agent.sql.go @@ -5695,6 +5695,9 @@ JOIN agent_task_queue failed ON failed.id = recovery.source_task_id JOIN agent_task_queue source ON source.id = failed.delegated_from_task_id JOIN issue source_issue ON source_issue.id = source.issue_id JOIN agent source_agent ON source_agent.id = source.agent_id +LEFT JOIN issue_status source_status + ON source_status.workspace_id = source_issue.workspace_id + AND source_status.key = source_issue.status WHERE recovery.author_type = 'system' AND recovery.type = 'progress_update' AND recovery.source_task_id IS NOT NULL @@ -5707,7 +5710,7 @@ WHERE recovery.author_type = 'system' AND source.autopilot_run_id IS NULL AND source.issue_id IS NOT NULL AND source.agent_id <> failed.agent_id - AND issue_effective_status(source_issue.workspace_id, source_issue.status) NOT IN ('done', 'cancelled', 'backlog') + AND COALESCE(source_status.category, source_issue.status) NOT IN ('done', 'cancelled', 'backlog') AND source_agent.archived_at IS NULL AND source_agent.runtime_id IS NOT NULL AND source_agent.workspace_id = source_issue.workspace_id diff --git a/server/pkg/db/generated/inbox.sql.go b/server/pkg/db/generated/inbox.sql.go index 00669ccca5..789b048dcc 100644 --- a/server/pkg/db/generated/inbox.sql.go +++ b/server/pkg/db/generated/inbox.sql.go @@ -78,17 +78,18 @@ WHERE i.workspace_id = $1 AND i.recipient_type = 'member' AND i.recipient_id = $ AND i.issue_id IN ( SELECT id FROM issue WHERE workspace_id = $1 - AND issue_effective_status(workspace_id, status) IN ('done', 'cancelled') + AND status = ANY($3::text[]) ) ` type ArchiveCompletedInboxParams struct { - WorkspaceID pgtype.UUID `json:"workspace_id"` - RecipientID pgtype.UUID `json:"recipient_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + RecipientID pgtype.UUID `json:"recipient_id"` + TerminalStatusKeys []string `json:"terminal_status_keys"` } func (q *Queries) ArchiveCompletedInbox(ctx context.Context, arg ArchiveCompletedInboxParams) (int64, error) { - result, err := q.db.Exec(ctx, archiveCompletedInbox, arg.WorkspaceID, arg.RecipientID) + result, err := q.db.Exec(ctx, archiveCompletedInbox, arg.WorkspaceID, arg.RecipientID, arg.TerminalStatusKeys) if err != nil { return 0, err } diff --git a/server/pkg/db/generated/issue.sql.go b/server/pkg/db/generated/issue.sql.go index d8ae97145e..ff539416e7 100644 --- a/server/pkg/db/generated/issue.sql.go +++ b/server/pkg/db/generated/issue.sql.go @@ -14,21 +14,26 @@ import ( const childIssueProgress = `-- name: ChildIssueProgress :many SELECT parent_issue_id, COUNT(*)::bigint AS total, - COUNT(*) FILTER (WHERE issue_effective_status(workspace_id, status) IN ('done', 'cancelled'))::bigint AS done + COUNT(*) FILTER (WHERE status = ANY($2::text[]))::bigint AS done FROM issue WHERE workspace_id = $1 AND parent_issue_id IS NOT NULL GROUP BY parent_issue_id ` +type ChildIssueProgressParams struct { + WorkspaceID pgtype.UUID `json:"workspace_id"` + TerminalStatusKeys []string `json:"terminal_status_keys"` +} + type ChildIssueProgressRow struct { ParentIssueID pgtype.UUID `json:"parent_issue_id"` Total int64 `json:"total"` Done int64 `json:"done"` } -func (q *Queries) ChildIssueProgress(ctx context.Context, workspaceID pgtype.UUID) ([]ChildIssueProgressRow, error) { - rows, err := q.db.Query(ctx, childIssueProgress, workspaceID) +func (q *Queries) ChildIssueProgress(ctx context.Context, arg ChildIssueProgressParams) ([]ChildIssueProgressRow, error) { + rows, err := q.db.Query(ctx, childIssueProgress, arg.WorkspaceID, arg.TerminalStatusKeys) if err != nil { return nil, err } @@ -536,24 +541,27 @@ func (q *Queries) DetachDirectChildIssues(ctx context.Context, arg DetachDirectC const findActiveDuplicateIssue = `-- name: FindActiveDuplicateIssue :one SELECT id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, start_date, metadata, stage, properties, revision, last_activity_at FROM issue WHERE workspace_id = $1 - AND issue_effective_status(workspace_id, status) NOT IN ('done', 'cancelled') - AND project_id IS NOT DISTINCT FROM $2::uuid - AND parent_issue_id IS NOT DISTINCT FROM $3::uuid - AND lower(btrim(regexp_replace(title, '[[:space:]]+', ' ', 'g'))) = $4 + -- Negate only known terminal keys so an unknown legacy key remains active. + AND NOT (status = ANY($2::text[])) + AND project_id IS NOT DISTINCT FROM $3::uuid + AND parent_issue_id IS NOT DISTINCT FROM $4::uuid + AND lower(btrim(regexp_replace(title, '[[:space:]]+', ' ', 'g'))) = $5 ORDER BY created_at ASC LIMIT 1 ` type FindActiveDuplicateIssueParams struct { - WorkspaceID pgtype.UUID `json:"workspace_id"` - ProjectID pgtype.UUID `json:"project_id"` - ParentIssueID pgtype.UUID `json:"parent_issue_id"` - NormalizedTitle string `json:"normalized_title"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + TerminalStatusKeys []string `json:"terminal_status_keys"` + ProjectID pgtype.UUID `json:"project_id"` + ParentIssueID pgtype.UUID `json:"parent_issue_id"` + NormalizedTitle string `json:"normalized_title"` } func (q *Queries) FindActiveDuplicateIssue(ctx context.Context, arg FindActiveDuplicateIssueParams) (Issue, error) { row := q.db.QueryRow(ctx, findActiveDuplicateIssue, arg.WorkspaceID, + arg.TerminalStatusKeys, arg.ProjectID, arg.ParentIssueID, arg.NormalizedTitle, @@ -595,12 +603,13 @@ func (q *Queries) FindActiveDuplicateIssue(ctx context.Context, arg FindActiveDu const findRecentAutopilotDuplicateIssue = `-- name: FindRecentAutopilotDuplicateIssue :one SELECT i.id, i.workspace_id, i.title, i.description, i.status, i.priority, i.assignee_type, i.assignee_id, i.creator_type, i.creator_id, i.parent_issue_id, i.acceptance_criteria, i.context_refs, i.position, i.due_date, i.created_at, i.updated_at, i.number, i.project_id, i.origin_type, i.origin_id, i.first_executed_at, i.start_date, i.metadata, i.stage, i.properties, i.revision, i.last_activity_at FROM issue i WHERE i.workspace_id = $1 - AND issue_effective_status(i.workspace_id, i.status) NOT IN ('done', 'cancelled') + -- Negate only known terminal keys so an unknown legacy key remains active. + AND NOT (i.status = ANY($3::text[])) AND i.origin_type = 'autopilot' AND i.origin_id = $2 - AND i.project_id IS NOT DISTINCT FROM $3::uuid - AND lower(btrim(regexp_replace(i.title, '[[:space:]]+', ' ', 'g'))) = $4 - AND i.created_at >= $5::timestamptz + AND i.project_id IS NOT DISTINCT FROM $4::uuid + AND lower(btrim(regexp_replace(i.title, '[[:space:]]+', ' ', 'g'))) = $5 + AND i.created_at >= $6::timestamptz AND EXISTS ( SELECT 1 FROM autopilot_run r @@ -613,17 +622,19 @@ LIMIT 1 ` type FindRecentAutopilotDuplicateIssueParams struct { - WorkspaceID pgtype.UUID `json:"workspace_id"` - OriginID pgtype.UUID `json:"origin_id"` - ProjectID pgtype.UUID `json:"project_id"` - NormalizedTitle string `json:"normalized_title"` - CreatedAfter pgtype.Timestamptz `json:"created_after"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + OriginID pgtype.UUID `json:"origin_id"` + TerminalStatusKeys []string `json:"terminal_status_keys"` + ProjectID pgtype.UUID `json:"project_id"` + NormalizedTitle string `json:"normalized_title"` + CreatedAfter pgtype.Timestamptz `json:"created_after"` } func (q *Queries) FindRecentAutopilotDuplicateIssue(ctx context.Context, arg FindRecentAutopilotDuplicateIssueParams) (Issue, error) { row := q.db.QueryRow(ctx, findRecentAutopilotDuplicateIssue, arg.WorkspaceID, arg.OriginID, + arg.TerminalStatusKeys, arg.ProjectID, arg.NormalizedTitle, arg.CreatedAfter, @@ -1206,25 +1217,28 @@ SELECT i.id, i.workspace_id, i.title, i.description, i.status, i.priority, i.revision FROM issue i WHERE i.workspace_id = $1 - AND issue_effective_status(i.workspace_id, i.status) NOT IN ('done', 'cancelled') - AND ($2::text IS NULL OR i.priority = $2) - AND ($3::uuid IS NULL OR i.assignee_id = $3) - AND ($4::uuid[] IS NULL OR i.assignee_id = ANY($4::uuid[])) - AND ($5::uuid IS NULL OR i.creator_id = $5) - AND ($6::uuid IS NULL OR i.project_id = $6) - AND ($7::jsonb IS NULL OR i.metadata @> $7::jsonb) + -- Negate only known terminal keys so an unknown legacy key remains visible. + AND NOT (i.status = ANY($2::text[])) + AND ($3::text IS NULL OR i.priority = $3) + AND ($4::uuid IS NULL OR i.assignee_id = $4) + AND ($5::uuid[] IS NULL OR i.assignee_id = ANY($5::uuid[])) + AND ($6::uuid IS NULL OR i.creator_id = $6) + AND ($7::uuid IS NULL OR i.project_id = $7) + AND ($8::jsonb IS NULL OR i.metadata @> $8::jsonb) -- properties_filter is a jsonb array of groups, each group an array of -- containment patterns (built by parsePropertiesFilterParam): the issue -- must match at least one pattern from EVERY group (AND of ORs). A pattern -- of the shape {"__none__": ""} is the "no value" marker and -- matches when the issue's properties are missing that key. The correlated - -- form skips the GIN index, which is fine here: open_only is an - -- unpaginated workspace scan already narrowed by status. + -- form skips the GIN index, which is fine here: open_only is already an + -- unpaginated workspace scan. The terminal-status predicate intentionally + -- remains a filter rather than a positive index narrowing so unknown legacy + -- status keys stay visible. AND ( - $8::jsonb IS NULL + $9::jsonb IS NULL OR NOT EXISTS ( SELECT 1 - FROM jsonb_array_elements($8::jsonb) AS pf(alternatives) + FROM jsonb_array_elements($9::jsonb) AS pf(alternatives) WHERE NOT EXISTS ( SELECT 1 FROM jsonb_array_elements(pf.alternatives) AS alt(pattern) @@ -1234,11 +1248,11 @@ WHERE i.workspace_id = $1 ) ) AND ( - $9::uuid IS NULL + $10::uuid IS NULL OR (i.assignee_type = 'agent' AND i.assignee_id IN ( SELECT a.id FROM agent a WHERE a.workspace_id = $1 - AND a.owner_id = $9::uuid + AND a.owner_id = $10::uuid )) OR (i.assignee_type = 'squad' AND i.assignee_id IN ( SELECT sm.squad_id @@ -1246,14 +1260,14 @@ WHERE i.workspace_id = $1 JOIN squad s ON s.id = sm.squad_id WHERE s.workspace_id = $1 AND sm.member_type = 'member' - AND sm.member_id = $9::uuid + AND sm.member_id = $10::uuid UNION SELECT s.id FROM squad s JOIN agent a ON a.id = s.leader_id WHERE s.workspace_id = $1 AND a.workspace_id = $1 - AND a.owner_id = $9::uuid + AND a.owner_id = $10::uuid UNION SELECT sm.squad_id FROM squad_member sm @@ -1262,22 +1276,23 @@ WHERE i.workspace_id = $1 WHERE s.workspace_id = $1 AND sm.member_type = 'agent' AND a.workspace_id = $1 - AND a.owner_id = $9::uuid + AND a.owner_id = $10::uuid )) ) ORDER BY i.position ASC, i.created_at DESC ` type ListOpenIssuesParams struct { - WorkspaceID pgtype.UUID `json:"workspace_id"` - Priority pgtype.Text `json:"priority"` - AssigneeID pgtype.UUID `json:"assignee_id"` - AssigneeIds []pgtype.UUID `json:"assignee_ids"` - CreatorID pgtype.UUID `json:"creator_id"` - ProjectID pgtype.UUID `json:"project_id"` - MetadataFilter []byte `json:"metadata_filter"` - PropertiesFilter []byte `json:"properties_filter"` - InvolvesUserID pgtype.UUID `json:"involves_user_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + TerminalStatusKeys []string `json:"terminal_status_keys"` + Priority pgtype.Text `json:"priority"` + AssigneeID pgtype.UUID `json:"assignee_id"` + AssigneeIds []pgtype.UUID `json:"assignee_ids"` + CreatorID pgtype.UUID `json:"creator_id"` + ProjectID pgtype.UUID `json:"project_id"` + MetadataFilter []byte `json:"metadata_filter"` + PropertiesFilter []byte `json:"properties_filter"` + InvolvesUserID pgtype.UUID `json:"involves_user_id"` } type ListOpenIssuesRow struct { @@ -1311,6 +1326,7 @@ type ListOpenIssuesRow struct { func (q *Queries) ListOpenIssues(ctx context.Context, arg ListOpenIssuesParams) ([]ListOpenIssuesRow, error) { rows, err := q.db.Query(ctx, listOpenIssues, arg.WorkspaceID, + arg.TerminalStatusKeys, arg.Priority, arg.AssigneeID, arg.AssigneeIds, diff --git a/server/pkg/db/generated/project.sql.go b/server/pkg/db/generated/project.sql.go index 08e81281a0..48dc681017 100644 --- a/server/pkg/db/generated/project.sql.go +++ b/server/pkg/db/generated/project.sql.go @@ -126,20 +126,27 @@ func (q *Queries) GetProjectInWorkspace(ctx context.Context, arg GetProjectInWor const getProjectIssueStats = `-- name: GetProjectIssueStats :many SELECT project_id, count(*)::bigint AS total_count, - count(*) FILTER (WHERE issue_effective_status(workspace_id, status) IN ('done', 'cancelled'))::bigint AS done_count + count(*) FILTER (WHERE status = ANY($1::text[]))::bigint AS done_count FROM issue -WHERE project_id = ANY($1::uuid[]) +WHERE workspace_id = $2::uuid + AND project_id = ANY($3::uuid[]) GROUP BY project_id ` +type GetProjectIssueStatsParams struct { + TerminalStatusKeys []string `json:"terminal_status_keys"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + ProjectIds []pgtype.UUID `json:"project_ids"` +} + type GetProjectIssueStatsRow struct { ProjectID pgtype.UUID `json:"project_id"` TotalCount int64 `json:"total_count"` DoneCount int64 `json:"done_count"` } -func (q *Queries) GetProjectIssueStats(ctx context.Context, projectIds []pgtype.UUID) ([]GetProjectIssueStatsRow, error) { - rows, err := q.db.Query(ctx, getProjectIssueStats, projectIds) +func (q *Queries) GetProjectIssueStats(ctx context.Context, arg GetProjectIssueStatsParams) ([]GetProjectIssueStatsRow, error) { + rows, err := q.db.Query(ctx, getProjectIssueStats, arg.TerminalStatusKeys, arg.WorkspaceID, arg.ProjectIds) if err != nil { return nil, err } diff --git a/server/pkg/db/queries/agent.sql b/server/pkg/db/queries/agent.sql index d4e80a55f7..4cc14c7d3f 100644 --- a/server/pkg/db/queries/agent.sql +++ b/server/pkg/db/queries/agent.sql @@ -2050,6 +2050,9 @@ JOIN agent_task_queue failed ON failed.id = recovery.source_task_id JOIN agent_task_queue source ON source.id = failed.delegated_from_task_id JOIN issue source_issue ON source_issue.id = source.issue_id JOIN agent source_agent ON source_agent.id = source.agent_id +LEFT JOIN issue_status source_status + ON source_status.workspace_id = source_issue.workspace_id + AND source_status.key = source_issue.status WHERE recovery.author_type = 'system' AND recovery.type = 'progress_update' AND recovery.source_task_id IS NOT NULL @@ -2062,7 +2065,7 @@ WHERE recovery.author_type = 'system' AND source.autopilot_run_id IS NULL AND source.issue_id IS NOT NULL AND source.agent_id <> failed.agent_id - AND issue_effective_status(source_issue.workspace_id, source_issue.status) NOT IN ('done', 'cancelled', 'backlog') + AND COALESCE(source_status.category, source_issue.status) NOT IN ('done', 'cancelled', 'backlog') AND source_agent.archived_at IS NULL AND source_agent.runtime_id IS NOT NULL AND source_agent.workspace_id = source_issue.workspace_id diff --git a/server/pkg/db/queries/inbox.sql b/server/pkg/db/queries/inbox.sql index 2fd0fca700..aad0404fb8 100644 --- a/server/pkg/db/queries/inbox.sql +++ b/server/pkg/db/queries/inbox.sql @@ -214,5 +214,5 @@ WHERE i.workspace_id = $1 AND i.recipient_type = 'member' AND i.recipient_id = $ AND i.issue_id IN ( SELECT id FROM issue WHERE workspace_id = $1 - AND issue_effective_status(workspace_id, status) IN ('done', 'cancelled') + AND status = ANY(sqlc.arg('terminal_status_keys')::text[]) ); diff --git a/server/pkg/db/queries/issue.sql b/server/pkg/db/queries/issue.sql index 247a50d58b..8975519bef 100644 --- a/server/pkg/db/queries/issue.sql +++ b/server/pkg/db/queries/issue.sql @@ -291,7 +291,8 @@ SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0)); -- name: FindActiveDuplicateIssue :one SELECT * FROM issue WHERE workspace_id = $1 - AND issue_effective_status(workspace_id, status) NOT IN ('done', 'cancelled') + -- Negate only known terminal keys so an unknown legacy key remains active. + AND NOT (status = ANY(sqlc.arg('terminal_status_keys')::text[])) AND project_id IS NOT DISTINCT FROM sqlc.arg('project_id')::uuid AND parent_issue_id IS NOT DISTINCT FROM sqlc.arg('parent_issue_id')::uuid AND lower(btrim(regexp_replace(title, '[[:space:]]+', ' ', 'g'))) = sqlc.arg('normalized_title') @@ -301,7 +302,8 @@ LIMIT 1; -- name: FindRecentAutopilotDuplicateIssue :one SELECT i.* FROM issue i WHERE i.workspace_id = $1 - AND issue_effective_status(i.workspace_id, i.status) NOT IN ('done', 'cancelled') + -- Negate only known terminal keys so an unknown legacy key remains active. + AND NOT (i.status = ANY(sqlc.arg('terminal_status_keys')::text[])) AND i.origin_type = 'autopilot' AND i.origin_id = $2 AND i.project_id IS NOT DISTINCT FROM sqlc.arg('project_id')::uuid @@ -351,7 +353,8 @@ SELECT i.id, i.workspace_id, i.title, i.description, i.status, i.priority, i.revision FROM issue i WHERE i.workspace_id = $1 - AND issue_effective_status(i.workspace_id, i.status) NOT IN ('done', 'cancelled') + -- Negate only known terminal keys so an unknown legacy key remains visible. + AND NOT (i.status = ANY(sqlc.arg('terminal_status_keys')::text[])) AND (sqlc.narg('priority')::text IS NULL OR i.priority = sqlc.narg('priority')) AND (sqlc.narg('assignee_id')::uuid IS NULL OR i.assignee_id = sqlc.narg('assignee_id')) AND (sqlc.narg('assignee_ids')::uuid[] IS NULL OR i.assignee_id = ANY(sqlc.narg('assignee_ids')::uuid[])) @@ -363,8 +366,10 @@ WHERE i.workspace_id = $1 -- must match at least one pattern from EVERY group (AND of ORs). A pattern -- of the shape {"__none__": ""} is the "no value" marker and -- matches when the issue's properties are missing that key. The correlated - -- form skips the GIN index, which is fine here: open_only is an - -- unpaginated workspace scan already narrowed by status. + -- form skips the GIN index, which is fine here: open_only is already an + -- unpaginated workspace scan. The terminal-status predicate intentionally + -- remains a filter rather than a positive index narrowing so unknown legacy + -- status keys stay visible. AND ( sqlc.narg('properties_filter')::jsonb IS NULL OR NOT EXISTS ( @@ -510,7 +515,7 @@ GROUP BY assignee_type, assignee_id; -- name: ChildIssueProgress :many SELECT parent_issue_id, COUNT(*)::bigint AS total, - COUNT(*) FILTER (WHERE issue_effective_status(workspace_id, status) IN ('done', 'cancelled'))::bigint AS done + COUNT(*) FILTER (WHERE status = ANY(sqlc.arg('terminal_status_keys')::text[]))::bigint AS done FROM issue WHERE workspace_id = $1 AND parent_issue_id IS NOT NULL diff --git a/server/pkg/db/queries/project.sql b/server/pkg/db/queries/project.sql index 7f8d8c0bae..faca704451 100644 --- a/server/pkg/db/queries/project.sql +++ b/server/pkg/db/queries/project.sql @@ -57,7 +57,8 @@ WHERE project_id = $1; -- name: GetProjectIssueStats :many SELECT project_id, count(*)::bigint AS total_count, - count(*) FILTER (WHERE issue_effective_status(workspace_id, status) IN ('done', 'cancelled'))::bigint AS done_count + count(*) FILTER (WHERE status = ANY(sqlc.arg('terminal_status_keys')::text[]))::bigint AS done_count FROM issue -WHERE project_id = ANY(sqlc.arg('project_ids')::uuid[]) +WHERE workspace_id = sqlc.arg('workspace_id')::uuid + AND project_id = ANY(sqlc.arg('project_ids')::uuid[]) GROUP BY project_id;