mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 03:42:06 +00:00
3c3016abd9
* feat(server): focused neighborhood mode on workspace graph endpoint (TASK-1781)
Add ?focus=REF&depth=N to GET /workspaces/{ws}/graph. When focus is set,
BFS-traverse typed edges (undirected) out from the ref up to depth hops
(default 2, clamped to [1,5]) and return only that neighborhood's nodes +
edges. Without focus the whole-workspace behavior is unchanged.
- The focused item is always included, even when terminal (you asked to
view it); neighbors honor the existing include_terminal filter.
- Neighborhood is intersected with the visibility-filtered item set, so a
guest can't infer hidden items from dangling edges.
- Node-count cap (maxFocusNodes=200) stops BFS expansion early and sets a
new GraphResponse.Truncated flag (omitempty — whole-workspace payload
shape unchanged) so the client can offer expand-on-click.
- An unknown/invisible focus ref returns 404.
Tests: depth bounds + clamping, both-direction traversal, terminal focus
node inclusion, terminal-neighbor filtering, cross-collection typed edges,
unknown ref 404, and truncation.
Parent: PLAN-1780.
* fix(server): preserve true child_count in focus mode per Codex review (round 1)
In focus mode child_count was derived from the depth/cap-filtered edge
set, so a boundary parent whose children fell outside the neighborhood
reported child_count=0. The web UI gates hub-label and children-pill
visibility on child_count > 0, so those would wrongly hide.
Count children over the full visible item set instead (terminal filter
on the child preserved), independent of the focus subgraph. This also
reproduces the whole-workspace semantics exactly. Added a regression
test (focused parent with a child beyond depth still reports count=1).
Parent: PLAN-1780.
302 lines
10 KiB
Go
302 lines
10 KiB
Go
package server
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// Focus-mode bounds for the per-item neighborhood view (PLAN-1780 /
|
|
// TASK-1781). defaultFocusDepth is the BFS hop count when ?depth is
|
|
// omitted; maxFocusDepth caps it so a deep chain can't expand into the
|
|
// whole workspace. maxFocusNodes caps the neighborhood size — when the
|
|
// BFS would exceed it, expansion stops (closest nodes first) and the
|
|
// response is flagged truncated so the client can offer expand-on-click.
|
|
const (
|
|
defaultFocusDepth = 2
|
|
maxFocusDepth = 5
|
|
)
|
|
|
|
// maxFocusNodes caps the focus-mode neighborhood size — when the BFS
|
|
// would exceed it, expansion stops (closest nodes first) and the
|
|
// response is flagged truncated so the client can offer expand-on-click.
|
|
// A var, not a const, so tests can lower it without standing up hundreds
|
|
// of items (which would trip the per-IP create rate limiter).
|
|
var maxFocusNodes = 200
|
|
|
|
// GraphNode is one item in the workspace graph
|
|
// (GET /workspaces/{ws}/graph — PLAN-1730 / TASK-1731). Nodes are
|
|
// keyed by ref (TASK-5) rather than UUID: refs are what the web UI's
|
|
// force-graph uses for display, deep links, and edge endpoints.
|
|
type GraphNode struct {
|
|
// ID is the item UUID. The graph view needs it to correlate SSE
|
|
// item events (which carry item_id, not ref) with rendered nodes
|
|
// for the live glow/pulse layer (TASK-1736).
|
|
ID string `json:"id"`
|
|
Ref string `json:"ref"`
|
|
Title string `json:"title"`
|
|
Collection string `json:"collection"`
|
|
Status string `json:"status,omitempty"`
|
|
IsTerminal bool `json:"is_terminal"`
|
|
ChildCount int `json:"child_count"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
// Role is the assigned agent-role slug, when set. Feeds the graph
|
|
// view's role filter (TASK-1735).
|
|
Role string `json:"role,omitempty"`
|
|
}
|
|
|
|
// GraphEdge is one typed relationship between two graph nodes. Source
|
|
// and target are refs. Type is one of 'parent' | 'blocks' |
|
|
// 'implements' | 'related' | 'split-from' | 'supersedes' |
|
|
// 'wiki-link' (the canonical item_links vocabulary, hyphenated — see
|
|
// store.graphEdgeType — plus synthesized wiki-link edges from the
|
|
// PLAN-1593 reverse index). For 'parent', source is the child and
|
|
// target is the parent (matching item_links semantics); for 'blocks',
|
|
// source blocks target.
|
|
type GraphEdge struct {
|
|
Source string `json:"source"`
|
|
Target string `json:"target"`
|
|
Type string `json:"type"`
|
|
}
|
|
|
|
// GraphResponse is the payload of GET /workspaces/{ws}/graph — the
|
|
// whole workspace as {nodes, edges} in one call, feeding the 3D graph
|
|
// view (PLAN-1730) and the per-item neighborhood view (PLAN-1780).
|
|
type GraphResponse struct {
|
|
Nodes []GraphNode `json:"nodes"`
|
|
Edges []GraphEdge `json:"edges"`
|
|
// Truncated is set in focus mode when the neighborhood hit
|
|
// maxFocusNodes and BFS expansion stopped early — the client should
|
|
// offer expand-on-click rather than treat the graph as complete.
|
|
// Omitted (false) for the unbounded whole-workspace view so its
|
|
// payload shape is unchanged.
|
|
Truncated bool `json:"truncated,omitempty"`
|
|
}
|
|
|
|
// handleGetWorkspaceGraph serves GET /api/v1/workspaces/{ws}/graph.
|
|
//
|
|
// Query params:
|
|
// - include_terminal=true — include items in terminal status
|
|
// (done/fixed/archived/...). Default false: the graph view opens
|
|
// showing active work only; large workspaces would otherwise be
|
|
// hairball soup. The focused item itself is always included even
|
|
// when terminal — you asked to look at it.
|
|
// - focus=REF — render only the neighborhood around this item
|
|
// (PLAN-1780). BFS-traverses typed edges out from the ref,
|
|
// undirected, returning nodes reachable within `depth` hops plus the
|
|
// edges among them. An unknown/invisible ref returns 404.
|
|
// - depth=N — focus-mode hop count (default 2, clamped to
|
|
// [1, maxFocusDepth]). Ignored without focus.
|
|
//
|
|
// Visibility follows the dashboard model exactly: collection-level
|
|
// visibility via visibleCollectionIDs, item-level guest grants via
|
|
// guestResourceFilter, both pushed into ListItems. Edges are then
|
|
// filtered to endpoints present in the visible node set, so a guest
|
|
// can't infer the existence of items they can't see from dangling
|
|
// edge endpoints.
|
|
func (s *Server) handleGetWorkspaceGraph(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID, ok := s.getWorkspaceID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
includeTerminal := r.URL.Query().Get("include_terminal") == "true"
|
|
focus := r.URL.Query().Get("focus")
|
|
depth := defaultFocusDepth
|
|
if d := r.URL.Query().Get("depth"); d != "" {
|
|
if n, err := strconv.Atoi(d); err == nil {
|
|
depth = n
|
|
}
|
|
}
|
|
if depth < 1 {
|
|
depth = 1
|
|
}
|
|
if depth > maxFocusDepth {
|
|
depth = maxFocusDepth
|
|
}
|
|
|
|
visibleIDs, err := s.visibleCollectionIDs(r, workspaceID)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
fullCollIDs, grantedItemIDs, err := s.guestResourceFilter(r, workspaceID)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
collIDs := visibleIDs
|
|
var itemIDs []string
|
|
if len(grantedItemIDs) > 0 {
|
|
collIDs = fullCollIDs
|
|
itemIDs = grantedItemIDs
|
|
}
|
|
|
|
items, err := s.store.ListItems(workspaceID, models.ItemListParams{CollectionIDs: collIDs, ItemIDs: itemIDs})
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
|
|
// Done-context map from ALL workspace collections (not the
|
|
// visibility-filtered set) so item-level grants from hidden
|
|
// collections still evaluate against their own done rules — same
|
|
// reasoning as buildDashboardResponse.
|
|
allCollections, err := s.store.ListCollectionsMinimal(workspaceID)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
ctxMap := buildDoneContextMap(allCollections)
|
|
|
|
links, err := s.store.ListWorkspaceGraphLinks(workspaceID)
|
|
if err != nil {
|
|
writeInternalError(w, err)
|
|
return
|
|
}
|
|
|
|
resp := GraphResponse{Nodes: []GraphNode{}, Edges: []GraphEdge{}}
|
|
|
|
// Per-item terminal/status data for every visible item, keyed by ID.
|
|
// Computed once; both the whole-workspace path and focus BFS read it.
|
|
type itemMeta struct {
|
|
terminal bool
|
|
status string
|
|
}
|
|
metaByID := make(map[string]itemMeta, len(items))
|
|
focusID := ""
|
|
for _, item := range items {
|
|
if item.Ref == "" {
|
|
continue
|
|
}
|
|
terminal := isItemDone(item.Fields, item.CollectionID, ctxMap)
|
|
var fields map[string]any
|
|
_ = json.Unmarshal([]byte(item.Fields), &fields)
|
|
status, _ := fields["status"].(string)
|
|
metaByID[item.ID] = itemMeta{terminal: terminal, status: status}
|
|
if focus != "" && item.Ref == focus {
|
|
focusID = item.ID
|
|
}
|
|
}
|
|
|
|
// Decide which item IDs are in the rendered node set.
|
|
included := make(map[string]bool, len(items))
|
|
if focus == "" {
|
|
// Whole-workspace view: every visible item, terminal filtered.
|
|
for id, m := range metaByID {
|
|
if m.terminal && !includeTerminal {
|
|
continue
|
|
}
|
|
included[id] = true
|
|
}
|
|
} else {
|
|
// Focus view: BFS the neighborhood around focusID. An unknown or
|
|
// invisible ref is a 404 — the caller can't tell those apart, by
|
|
// design (no leaking existence of items the user can't see).
|
|
if focusID == "" {
|
|
writeError(w, http.StatusNotFound, "not_found", "focus item not found")
|
|
return
|
|
}
|
|
// Undirected adjacency among visible items only, so the BFS can't
|
|
// hop through (or to) items outside the visible set.
|
|
adj := make(map[string][]string)
|
|
for _, l := range links {
|
|
_, srcOK := metaByID[l.SourceID]
|
|
_, tgtOK := metaByID[l.TargetID]
|
|
if !srcOK || !tgtOK {
|
|
continue
|
|
}
|
|
adj[l.SourceID] = append(adj[l.SourceID], l.TargetID)
|
|
adj[l.TargetID] = append(adj[l.TargetID], l.SourceID)
|
|
}
|
|
// The focus node is always included, even if terminal — you asked
|
|
// to view it. Neighbors honor the terminal filter.
|
|
included[focusID] = true
|
|
frontier := []string{focusID}
|
|
for hop := 0; hop < depth && len(frontier) > 0 && !resp.Truncated; hop++ {
|
|
var next []string
|
|
for _, id := range frontier {
|
|
for _, nb := range adj[id] {
|
|
if included[nb] {
|
|
continue
|
|
}
|
|
if metaByID[nb].terminal && !includeTerminal {
|
|
continue
|
|
}
|
|
if len(included) >= maxFocusNodes {
|
|
resp.Truncated = true
|
|
break
|
|
}
|
|
included[nb] = true
|
|
next = append(next, nb)
|
|
}
|
|
if resp.Truncated {
|
|
break
|
|
}
|
|
}
|
|
frontier = next
|
|
}
|
|
}
|
|
|
|
// Child counts: 'parent' and 'implements' edges both render as
|
|
// children in the existing UI surfaces (see wiki_links_test.go's
|
|
// lineage note). Count over the FULL visible item set — NOT the
|
|
// focus-filtered subgraph — so a boundary node whose children fell
|
|
// outside depth/maxFocusNodes still reports its true visible child
|
|
// count. The web UI gates hub-label and children-pill visibility on
|
|
// child_count > 0, so a filtered count would hide those (TASK-1781
|
|
// review). The child (source) honors the terminal filter, matching
|
|
// the whole-workspace semantics this replaces: only children that
|
|
// would themselves be visible are counted.
|
|
childCount := make(map[string]int) // parent item ID → visible child count
|
|
for _, l := range links {
|
|
if l.Type != "parent" && l.Type != "implements" {
|
|
continue
|
|
}
|
|
childMeta, srcOK := metaByID[l.SourceID]
|
|
_, tgtOK := metaByID[l.TargetID]
|
|
if !srcOK || !tgtOK {
|
|
continue
|
|
}
|
|
if childMeta.terminal && !includeTerminal {
|
|
continue
|
|
}
|
|
childCount[l.TargetID]++
|
|
}
|
|
|
|
// Build nodes in items order (stable) for the included set; edges are
|
|
// filtered against it so guests never see dangling endpoints.
|
|
refByID := make(map[string]string, len(included))
|
|
for _, item := range items {
|
|
if item.Ref == "" || !included[item.ID] {
|
|
continue
|
|
}
|
|
m := metaByID[item.ID]
|
|
refByID[item.ID] = item.Ref
|
|
resp.Nodes = append(resp.Nodes, GraphNode{
|
|
ID: item.ID,
|
|
Ref: item.Ref,
|
|
Title: item.Title,
|
|
Collection: item.CollectionSlug,
|
|
Status: m.status,
|
|
IsTerminal: m.terminal,
|
|
ChildCount: childCount[item.ID],
|
|
UpdatedAt: item.UpdatedAt,
|
|
Role: item.AgentRoleSlug,
|
|
})
|
|
}
|
|
|
|
for _, l := range links {
|
|
srcRef, srcOK := refByID[l.SourceID]
|
|
tgtRef, tgtOK := refByID[l.TargetID]
|
|
if !srcOK || !tgtOK {
|
|
continue
|
|
}
|
|
resp.Edges = append(resp.Edges, GraphEdge{Source: srcRef, Target: tgtRef, Type: l.Type})
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|