Files
multica/server/internal/handler/issue_table_facets.go
Naiyuan Qing 13b06f038e fix(issues): count agents working in the surface, not the workspace (MUL-5525) (#6191)
* fix(issues): count agents working in the surface, not the workspace (MUL-5525)

The "N agents working" chip ran its own workspace-wide
`/api/working-agents` read while the list it filters came from the
surface's own compiled query. Two definitions of the same question, so on
a project page the chip could advertise agents working nowhere near that
project and then open an empty list. Every other narrowing the list knows
about — status, priority, assignee, creator, label, custom property,
date, sub-issue display, the /issues Members/Agents tabs — was invisible
to the count for the same reason. Only /my-issues (relation) and the
issue-detail sub-issue chip (parent) were narrowed, because those were
the two cases the endpoint had grown parameters for.

Rather than add a `project_id` parameter and leave the next dimension to
be discovered the same way, the count now comes from a `working_agents`
facet on the existing issue-table facets endpoint: same scope, same
filters, same compiled WHERE clause the rows come from, joined to running
issue tasks and grouped by agent. Correct-by-construction instead of
correct-by-keeping-two-lists-in-sync.

- Facet is disjunctive like every other one: it drops `working_issue_ids`
  / `working_only`, so the answer is identical whether the filter is on or
  off and the number does not move when you click the chip.
- Facet keys are agent ids, so they pass the same visibility gate as the
  other workspace-wide agent aggregations — a private or non-allow-listed
  agent is not disclosed by id, count, or presence.
- Gantt keeps a client-side count: its canvas projection (scheduled +
  dated + showCompleted) cannot be expressed in the Table query spec, so
  it counts the agents holding canvas rows instead.
- The chip is now presentational; `undefined` renders the existing
  indeterminate label rather than a zero it cannot stand behind.
- Removes the MUL-4884 `workingScopeIssues` plumbing, dead since the count
  moved to the endpoint in MUL-5200, keeping only the Gantt branch that
  still has a real consumer.

Also fixes the empty state that bug dropped you into: a filtered-empty
surface claimed "No issues linked — create one" while 41 issues sat behind
the filter. Shared filtered-empty state now precedes each surface's own
copy and offers to clear exactly the filters it blames.

Verified: pnpm typecheck, pnpm test (469 files), pnpm lint (0 errors),
go test ./internal/handler (new facet tests cover project scope, status
and sub-issue narrowing, filter-independence, and the access gate).

Co-authored-by: multica-agent <github@multica.ai>

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(issues): keep the working-agents unknown state unknown (MUL-5525)

The chip correctly refused to print a number for an unresolved projection,
then handed the hover card `agents ?? []` — so hovering an indeterminate
chip read "No agents working right now". That is the same unearned claim
this issue is about, made by the one surface with room to spell it out:
the label said "—" while the body next to it asserted zero.

- `WorkingAgentsHoverContent` takes `readonly WorkingAgentSummary[] |
  undefined` and distinguishes all three states: `undefined` renders new
  `agent_activity.unknown_hover` copy, `[]` keeps the empty sentence, a
  non-empty list keeps the roster. The chip passes its projection through
  untouched.
- The colour tier had the same collapse: unknown wore the neutral tier
  WITH muted text, which is exactly the "nothing is happening here" tier a
  known zero wears. `chipAppearance` now takes a `ChipActivity`
  ("unknown" | "none" | "some") instead of a boolean, so the three cases
  cannot be written as two, and unknown stays neutral but undimmed.
- The sub-issues chip is unaffected: it passes a resolved array and
  renders nothing at zero, so it never claimed anything either way.

Regression tests cover the hover path specifically — reverting either
downgrade fails "does not let the hover body downgrade an unresolved
projection to zero", "does not dim the chip while the projection is
unresolved", and the chipAppearance unknown case (verified by reverting).
`WorkingAgentsHoverContent` also gets direct unknown / empty / roster
tests, and `chipActivity` one for the three-way split.

Verified: pnpm typecheck, pnpm test (469 files), pnpm lint (0 errors).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-31 13:04:42 +08:00

435 lines
16 KiB
Go

package handler
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"sort"
"strings"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"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"
)
// The workspace property catalog is capped at 20 active definitions. Six
// built-in dimensions plus that catalog fit under this guard with headroom,
// while preventing a single request from scheduling hundreds of sequential
// aggregation scans inside one snapshot transaction.
const issueTableMaxFacets = 32
type issueTableFacetValueResponse struct {
Key string `json:"key"`
Count int64 `json:"count"`
}
type issueTableFacetResponse struct {
Kind string `json:"kind"`
PropertyID string `json:"property_id,omitempty"`
Values []issueTableFacetValueResponse `json:"values"`
}
type issueTableFacetsResponse struct {
QueryFingerprint string `json:"query_fingerprint"`
Total int64 `json:"total"`
Facets []issueTableFacetResponse `json:"facets"`
}
func issueTableQueryWithoutFacet(input issueTableQuerySpec, facet issueTableFacetSpec) issueTableQuerySpec {
output := input
output.Filters = input.Filters
if input.Filters.Properties != nil {
output.Filters.Properties = make(map[string][]string, len(input.Filters.Properties))
for propertyID, values := range input.Filters.Properties {
output.Filters.Properties[propertyID] = append([]string(nil), values...)
}
}
switch facet.Kind {
case "status":
output.Filters.Statuses = nil
case "priority":
output.Filters.Priorities = nil
case "assignee":
output.Filters.Assignees = nil
output.Filters.IncludeNoAssignee = false
case "creator":
output.Filters.Creators = nil
case "project":
output.Filters.ProjectIDs = nil
output.Filters.IncludeNoProject = false
case "label":
output.Filters.LabelIDs = nil
case "property":
delete(output.Filters.Properties, facet.PropertyID)
case "working_agents":
// Disjunctive like every other facet: drop this facet's own dimension so
// the answer is "who would you see if you turned the agents-working
// filter ON", which is also the answer while it is already on. That
// makes the header chip's count stable across the toggle.
output.Filters.WorkingIssueIDs = nil
output.Filters.WorkingOnly = false
}
return output
}
func issueTableFacetIdentity(facet issueTableFacetSpec) string {
if facet.Kind == "property" {
return "property:" + facet.PropertyID
}
return facet.Kind
}
func issueTableBaseFacetExpression(query issueTableQuerySpec, facet issueTableFacetSpec) (string, bool) {
switch facet.Kind {
case "status":
return "i.status", len(query.Filters.Statuses) == 0
case "priority":
return "i.priority", len(query.Filters.Priorities) == 0
case "assignee":
return "CASE WHEN i.assignee_type IS NULL OR i.assignee_id IS NULL THEN '__none__' ELSE i.assignee_type || ':' || i.assignee_id::text END", len(query.Filters.Assignees) == 0 && !query.Filters.IncludeNoAssignee
case "creator":
return "i.creator_type || ':' || i.creator_id::text", len(query.Filters.Creators) == 0
case "project":
return "COALESCE(i.project_id::text, '__none__')", len(query.Filters.ProjectIDs) == 0 && !query.Filters.IncludeNoProject
default:
return "", false
}
}
func (h *Handler) issueTableBaseFacetQuery(
w http.ResponseWriter,
r *http.Request,
base issueTableSQL,
requestQuery issueTableQuerySpec,
facets []issueTableFacetSpec,
includeTotal bool,
) (map[string]issueTableFacetResponse, int64, bool) {
markerCases := make([]string, 0, len(facets))
valueCases := make([]string, 0, len(facets))
groupingSets := make([]string, 0, len(facets)+1)
responses := make(map[string]issueTableFacetResponse, len(facets))
for _, facet := range facets {
expression, ok := issueTableBaseFacetExpression(requestQuery, facet)
if !ok {
writeIssueTableQueryFailure(w, r, "failed to batch table facets")
return nil, 0, false
}
identity := issueTableFacetIdentity(facet)
markerCases = append(markerCases, fmt.Sprintf("WHEN GROUPING(%s) = 0 THEN '%s'", expression, identity))
valueCases = append(valueCases, fmt.Sprintf("WHEN GROUPING(%s) = 0 THEN (%s)::text", expression, expression))
groupingSets = append(groupingSets, "("+expression+")")
responses[identity] = issueTableFacetResponse{
Kind: facet.Kind,
PropertyID: facet.PropertyID,
Values: []issueTableFacetValueResponse{},
}
}
if includeTotal {
groupingSets = append(groupingSets, "()")
}
query := fmt.Sprintf(`SELECT CASE %s ELSE '__total__' END,
CASE %s ELSE '' END,
COUNT(*)::bigint
FROM issue i
WHERE %s
GROUP BY GROUPING SETS (%s)`, strings.Join(markerCases, " "), strings.Join(valueCases, " "), base.where, strings.Join(groupingSets, ", "))
rows, err := h.DB.Query(r.Context(), query, base.args...)
if err != nil {
slog.Warn("ListIssueTableFacets batch query failed", append(logger.RequestAttrs(r), "error", err)...)
writeIssueTableQueryFailure(w, r, "failed to list table facets")
return nil, 0, false
}
defer rows.Close()
var total int64
for rows.Next() {
var identity string
var value string
var count int64
if err := rows.Scan(&identity, &value, &count); err != nil {
writeIssueTableQueryFailure(w, r, "failed to list table facets")
return nil, 0, false
}
if identity == "__total__" {
total = count
continue
}
response, ok := responses[identity]
if !ok {
writeIssueTableQueryFailure(w, r, "failed to list table facets")
return nil, 0, false
}
response.Values = append(response.Values, issueTableFacetValueResponse{Key: value, Count: count})
responses[identity] = response
}
if err := rows.Err(); err != nil {
writeIssueTableQueryFailure(w, r, "failed to list table facets")
return nil, 0, false
}
for identity, response := range responses {
sort.Slice(response.Values, func(i, j int) bool {
return strings.Compare(response.Values[i].Key, response.Values[j].Key) < 0
})
responses[identity] = response
}
return responses, total, true
}
func (h *Handler) issueTableFacetQuery(w http.ResponseWriter, r *http.Request, requestQuery issueTableQuerySpec, facet issueTableFacetSpec) (issueTableFacetResponse, bool) {
response := issueTableFacetResponse{Kind: facet.Kind, PropertyID: facet.PropertyID, Values: []issueTableFacetValueResponse{}}
compiled, ok := h.compileIssueTableQuery(w, r, issueTableQueryWithoutFacet(requestQuery, facet))
if !ok {
return response, false
}
query := ""
switch facet.Kind {
case "status":
query = fmt.Sprintf(`SELECT i.status, COUNT(*)::bigint FROM issue i WHERE %s GROUP BY i.status`, compiled.where)
case "priority":
query = fmt.Sprintf(`SELECT i.priority, COUNT(*)::bigint FROM issue i WHERE %s GROUP BY i.priority`, compiled.where)
case "assignee":
query = fmt.Sprintf(`SELECT CASE WHEN i.assignee_type IS NULL OR i.assignee_id IS NULL THEN '__none__' ELSE i.assignee_type || ':' || i.assignee_id::text END, COUNT(*)::bigint FROM issue i WHERE %s GROUP BY 1`, compiled.where)
case "creator":
query = fmt.Sprintf(`SELECT i.creator_type || ':' || i.creator_id::text, COUNT(*)::bigint FROM issue i WHERE %s GROUP BY 1`, compiled.where)
case "project":
query = fmt.Sprintf(`SELECT COALESCE(i.project_id::text, '__none__'), COUNT(*)::bigint FROM issue i WHERE %s GROUP BY 1`, compiled.where)
case "label":
query = fmt.Sprintf(`SELECT itl.label_id::text, COUNT(DISTINCT i.id)::bigint FROM issue i JOIN issue_to_label itl ON itl.issue_id = i.id WHERE %s GROUP BY itl.label_id`, compiled.where)
case "working_agents":
// One row per agent currently running issue work inside THIS surface,
// with its running-task count. Same predicate as
// ListWorkspaceWorkingAgents with type=issue (chat > autopilot > issue
// precedence, user-authored non-archived agents only), but the issue set
// comes from the surface's own compiled scope + filters instead of a
// second, independent workspace-wide definition. That is what keeps the
// header chip's count equal to the rows clicking it leaves (MUL-5525).
query = fmt.Sprintf(`SELECT a.id::text, COUNT(*)::bigint
FROM issue i
JOIN agent_task_queue atq ON atq.issue_id = i.id
JOIN agent a ON a.id = atq.agent_id AND a.workspace_id = i.workspace_id
WHERE %s
AND a.kind = 'user'
AND a.archived_at IS NULL
AND atq.status = 'running'
AND atq.chat_session_id IS NULL
AND atq.autopilot_run_id IS NULL
GROUP BY a.id`, compiled.where)
case "property":
propertyID, err := util.ParseUUID(facet.PropertyID)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid facets.property_id")
return response, false
}
property, err := h.Queries.GetIssueProperty(r.Context(), db.GetIssuePropertyParams{
ID: propertyID,
WorkspaceID: compiled.workspaceID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeIssueTableUnsupportedGroup(w, "property_not_found", "The faceted property no longer exists.")
return response, false
}
slog.Warn("resolve table facet property failed", append(logger.RequestAttrs(r), "error", err)...)
writeIssueTableQueryFailure(w, r, "failed to resolve table facet")
return response, false
}
if property.ArchivedAt.Valid {
writeIssueTableUnsupportedGroup(w, "property_archived", "The faceted property is archived.")
return response, false
}
propertyKey := "'" + util.UUIDToString(property.ID) + "'"
switch property.Type {
case "select":
query = fmt.Sprintf(`SELECT i.properties ->> %s, COUNT(*)::bigint FROM issue i WHERE %s AND jsonb_typeof(i.properties -> %s) = 'string' GROUP BY 1`, propertyKey, compiled.where, propertyKey)
case "multi_select":
query = fmt.Sprintf(`SELECT property_value.value, COUNT(DISTINCT i.id)::bigint FROM issue i JOIN LATERAL jsonb_array_elements_text(CASE WHEN jsonb_typeof(i.properties -> %s) = 'array' THEN i.properties -> %s ELSE '[]'::jsonb END) AS property_value(value) ON TRUE WHERE %s GROUP BY property_value.value`, propertyKey, propertyKey, compiled.where)
case "checkbox":
query = fmt.Sprintf(`SELECT i.properties ->> %s, COUNT(*)::bigint FROM issue i WHERE %s AND jsonb_typeof(i.properties -> %s) = 'boolean' GROUP BY 1`, propertyKey, compiled.where, propertyKey)
default:
writeIssueTableUnsupportedGroup(w, "property_type_unsupported", "This property type cannot be used as a filter facet.")
return response, false
}
default:
writeError(w, http.StatusBadRequest, "invalid facets.kind")
return response, false
}
rows, err := h.DB.Query(r.Context(), query, compiled.args...)
if err != nil {
slog.Warn("ListIssueTableFacets query failed", append(logger.RequestAttrs(r), "facet", issueTableFacetIdentity(facet), "error", err)...)
writeIssueTableQueryFailure(w, r, "failed to list table facets")
return response, false
}
defer rows.Close()
for rows.Next() {
var value string
var count int64
if err := rows.Scan(&value, &count); err != nil {
writeIssueTableQueryFailure(w, r, "failed to list table facets")
return response, false
}
response.Values = append(response.Values, issueTableFacetValueResponse{Key: value, Count: count})
}
if err := rows.Err(); err != nil {
writeIssueTableQueryFailure(w, r, "failed to list table facets")
return response, false
}
if facet.Kind == "working_agents" {
values, ok := h.filterAccessibleAgentFacetValues(w, r, compiled.workspaceID, response.Values)
if !ok {
return response, false
}
response.Values = values
}
sort.Slice(response.Values, func(i, j int) bool {
return strings.Compare(response.Values[i].Key, response.Values[j].Key) < 0
})
return response, true
}
// The working-agents facet keys are agent ids, so it discloses agent identity
// and must pass the same visibility gate as every other workspace-wide agent
// aggregation: a private or non-allow-listed agent is never exposed by its id,
// its count, or even its presence.
func (h *Handler) filterAccessibleAgentFacetValues(
w http.ResponseWriter,
r *http.Request,
workspaceUUID pgtype.UUID,
values []issueTableFacetValueResponse,
) ([]issueTableFacetValueResponse, bool) {
if len(values) == 0 {
return values, true
}
workspaceID := util.UUIDToString(workspaceUUID)
member, ok := h.workspaceMember(w, r, workspaceID)
if !ok {
return nil, false
}
actorType, actorID := h.resolveActor(r, requestUserID(r), workspaceID)
allowed, ok := h.accessibleAgentIDs(r.Context(), workspaceID, actorType, actorID, member.Role)
if !ok {
writeIssueTableQueryFailure(w, r, "failed to resolve agent access")
return nil, false
}
filtered := make([]issueTableFacetValueResponse, 0, len(values))
for _, value := range values {
if _, permitted := allowed[value.Key]; permitted {
filtered = append(filtered, value)
}
}
return filtered, true
}
func (h *Handler) ListIssueTableFacets(w http.ResponseWriter, r *http.Request) {
if h.DB == nil {
writeError(w, http.StatusInternalServerError, "database is unavailable")
return
}
var request issueTableFacetsRequest
if !decodeIssueTableJSON(w, r, &request) {
return
}
if len(request.Facets) == 0 || len(request.Facets) > issueTableMaxFacets {
writeError(w, http.StatusBadRequest, fmt.Sprintf("facets must contain between 1 and %d entries", issueTableMaxFacets))
return
}
r, cancel := withIssueTableQueryTimeout(r)
defer cancel()
snapshot, tx, err := h.beginIssueTableSnapshot(r.Context())
if err != nil {
slog.Warn("ListIssueTableFacets snapshot failed", append(logger.RequestAttrs(r), "error", err)...)
writeIssueTableQueryFailure(w, r, "failed to start table query")
return
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(context.Background())
}
}()
h = snapshot
base, ok := h.compileIssueTableQuery(w, r, request.Query)
if !ok {
return
}
seen := make(map[string]struct{}, len(request.Facets))
normalizedFacets := make([]issueTableFacetSpec, len(request.Facets))
for index, facet := range request.Facets {
facet.Kind = strings.TrimSpace(facet.Kind)
facet.PropertyID = strings.TrimSpace(facet.PropertyID)
identity := issueTableFacetIdentity(facet)
if _, exists := seen[identity]; exists {
writeError(w, http.StatusBadRequest, "duplicate table facet")
return
}
seen[identity] = struct{}{}
normalizedFacets[index] = facet
}
includeTotal := request.IncludeTotal == nil || *request.IncludeTotal
batchFacets := make([]issueTableFacetSpec, 0, len(normalizedFacets))
individualIndexes := make([]int, 0, len(normalizedFacets))
for index, facet := range normalizedFacets {
if _, batchable := issueTableBaseFacetExpression(request.Query, facet); batchable {
batchFacets = append(batchFacets, facet)
} else {
individualIndexes = append(individualIndexes, index)
}
}
responses := make([]issueTableFacetResponse, len(normalizedFacets))
var total int64
totalResolved := false
if len(batchFacets) > 0 {
batched, batchTotal, ok := h.issueTableBaseFacetQuery(w, r, base, request.Query, batchFacets, includeTotal)
if !ok {
return
}
for index, facet := range normalizedFacets {
if response, exists := batched[issueTableFacetIdentity(facet)]; exists {
responses[index] = response
}
}
if includeTotal {
total = batchTotal
totalResolved = true
}
}
if includeTotal && !totalResolved {
if err := h.DB.QueryRow(r.Context(), fmt.Sprintf("SELECT COUNT(*)::bigint FROM issue i WHERE %s", base.where), base.args...).Scan(&total); err != nil {
slog.Warn("ListIssueTableFacets total failed", append(logger.RequestAttrs(r), "error", err)...)
writeIssueTableQueryFailure(w, r, "failed to count table facets")
return
}
}
for _, index := range individualIndexes {
resolved, ok := h.issueTableFacetQuery(w, r, request.Query, normalizedFacets[index])
if !ok {
return
}
responses[index] = resolved
}
response := issueTableFacetsResponse{
QueryFingerprint: base.fingerprint,
Total: total,
Facets: responses,
}
if err := tx.Commit(r.Context()); err != nil {
slog.Warn("ListIssueTableFacets snapshot commit failed", append(logger.RequestAttrs(r), "error", err)...)
writeIssueTableQueryFailure(w, r, "failed to finish table query")
return
}
committed = true
writeJSON(w, http.StatusOK, response)
}