Skip to content

Commit 4e5006b

Browse files
jamestexasclaude
andauthored
feat(graph): Act() on Graph interface + CompositeGraph multiplexer (#49)
* feat(graph): add Act() to Graph interface and CompositeGraph multiplexer Extends the Graph interface with Act(id, action, payload) for interactive graphs (browser DOM, terminal sessions, macOS AX elements). Passive graphs (MemoryStore, SQLiteGraph, WritableGraph) return ErrActNotSupported. CompositeGraph routes path prefixes to mounted sub-graphs, enabling multi-source filesystems (/browser/, /iterm/, /macos/) through a single Graph interface. The Navigator's ActTool delegates to engine.Act() with zero knowledge of mount points. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(graph): re-prefix paths in CompositeGraph to maintain namespace isolation Sub-graph methods (GetNode, ListChildren, GetCallers, GetCallees, Act) returned paths relative to the sub-graph. Callers seeing "header/nav" instead of "browser/header/nav" would fail on subsequent lookups. Add reprefixNode helper and fix all delegation methods to return globally valid composite paths. Also fix Act's HasPrefix check to use prefix+"/" avoiding false matches like "app" matching "application/...". --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8f3d9dc commit 4e5006b

8 files changed

Lines changed: 550 additions & 0 deletions

File tree

graph/graph.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,21 @@ type CallExtractor = ig.CallExtractor
3535
// QualifiedCall represents a function call with optional package qualifier.
3636
type QualifiedCall = ig.QualifiedCall
3737

38+
// CompositeGraph multiplexes multiple Graph backends under path prefixes.
39+
// Mount "browser" → /browser/... routes to that sub-graph.
40+
type CompositeGraph = ig.CompositeGraph
41+
42+
// ActionResult is returned when an action is performed on a graph node.
43+
type ActionResult = ig.ActionResult
44+
3845
// NewMemoryStore creates a new in-memory graph store.
3946
var NewMemoryStore = ig.NewMemoryStore
4047

48+
// NewCompositeGraph creates an empty composite graph for multi-mount routing.
49+
var NewCompositeGraph = ig.NewCompositeGraph
50+
4151
// ErrNotFound is returned when a node ID does not exist in the graph.
4252
var ErrNotFound = ig.ErrNotFound
53+
54+
// ErrActNotSupported is returned by Graph implementations that do not support actions.
55+
var ErrActNotSupported = ig.ErrActNotSupported

internal/graph/composite.go

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
package graph
2+
3+
import (
4+
"fmt"
5+
"io/fs"
6+
"strings"
7+
"sync"
8+
"time"
9+
)
10+
11+
// CompositeGraph multiplexes multiple Graph backends under path prefixes.
12+
// Mount "browser" → paths under /browser/ route to that sub-graph.
13+
// Mount "iterm" → paths under /iterm/ route to that sub-graph.
14+
// Root ListChildren returns the list of mount point names.
15+
type CompositeGraph struct {
16+
mu sync.RWMutex
17+
mounts map[string]Graph // prefix → sub-graph
18+
}
19+
20+
// NewCompositeGraph creates an empty composite graph.
21+
func NewCompositeGraph() *CompositeGraph {
22+
return &CompositeGraph{mounts: make(map[string]Graph)}
23+
}
24+
25+
// Mount registers a sub-graph under the given prefix.
26+
// Paths like "/<prefix>/..." are routed to this graph with the prefix stripped.
27+
func (c *CompositeGraph) Mount(prefix string, g Graph) error {
28+
c.mu.Lock()
29+
defer c.mu.Unlock()
30+
if _, ok := c.mounts[prefix]; ok {
31+
return fmt.Errorf("mount %q already exists", prefix)
32+
}
33+
c.mounts[prefix] = g
34+
return nil
35+
}
36+
37+
// Unmount removes a previously mounted sub-graph.
38+
func (c *CompositeGraph) Unmount(prefix string) error {
39+
c.mu.Lock()
40+
defer c.mu.Unlock()
41+
if _, ok := c.mounts[prefix]; !ok {
42+
return fmt.Errorf("mount %q not found", prefix)
43+
}
44+
delete(c.mounts, prefix)
45+
return nil
46+
}
47+
48+
// resolve splits id into (prefix, sub-path, sub-graph).
49+
// Returns ("", "", nil) if no mount matches.
50+
func (c *CompositeGraph) resolve(id string) (string, string, Graph) {
51+
id = strings.TrimPrefix(id, "/")
52+
if id == "" {
53+
return "", "", nil
54+
}
55+
prefix, subPath, _ := strings.Cut(id, "/")
56+
g, ok := c.mounts[prefix]
57+
if !ok {
58+
return "", "", nil
59+
}
60+
return prefix, subPath, g
61+
}
62+
63+
// GetNode implements Graph.
64+
func (c *CompositeGraph) GetNode(id string) (*Node, error) {
65+
c.mu.RLock()
66+
defer c.mu.RUnlock()
67+
68+
id = strings.TrimPrefix(id, "/")
69+
if id == "" {
70+
return &Node{
71+
ID: "",
72+
Mode: fs.ModeDir | 0o555,
73+
ModTime: time.Now(),
74+
}, nil
75+
}
76+
77+
prefix, subPath, g := c.resolve(id)
78+
if g == nil {
79+
return nil, ErrNotFound
80+
}
81+
// Mount point directory itself (e.g., "browser" with no sub-path)
82+
if subPath == "" {
83+
return &Node{
84+
ID: id,
85+
Mode: fs.ModeDir | 0o555,
86+
ModTime: time.Now(),
87+
}, nil
88+
}
89+
n, err := g.GetNode(subPath)
90+
if err != nil {
91+
return nil, err
92+
}
93+
return c.reprefixNode(prefix, n), nil
94+
}
95+
96+
// ListChildren implements Graph.
97+
func (c *CompositeGraph) ListChildren(id string) ([]string, error) {
98+
c.mu.RLock()
99+
defer c.mu.RUnlock()
100+
101+
id = strings.TrimPrefix(id, "/")
102+
103+
// Root: return mount point names
104+
if id == "" {
105+
names := make([]string, 0, len(c.mounts))
106+
for prefix := range c.mounts {
107+
names = append(names, prefix)
108+
}
109+
return names, nil
110+
}
111+
112+
prefix, subPath, g := c.resolve(id)
113+
if g == nil {
114+
return nil, ErrNotFound
115+
}
116+
var children []string
117+
var err error
118+
if subPath == "" {
119+
children, err = g.ListChildren("")
120+
} else {
121+
children, err = g.ListChildren(subPath)
122+
}
123+
if err != nil {
124+
return nil, err
125+
}
126+
res := make([]string, len(children))
127+
for i, child := range children {
128+
res[i] = prefix + "/" + strings.TrimPrefix(child, "/")
129+
}
130+
return res, nil
131+
}
132+
133+
// ReadContent implements Graph.
134+
func (c *CompositeGraph) ReadContent(id string, buf []byte, offset int64) (int, error) {
135+
c.mu.RLock()
136+
defer c.mu.RUnlock()
137+
138+
_, subPath, g := c.resolve(id)
139+
if g == nil {
140+
return 0, ErrNotFound
141+
}
142+
return g.ReadContent(subPath, buf, offset)
143+
}
144+
145+
// GetCallers implements Graph. Searches all mounted sub-graphs.
146+
func (c *CompositeGraph) GetCallers(token string) ([]*Node, error) {
147+
c.mu.RLock()
148+
defer c.mu.RUnlock()
149+
150+
var all []*Node
151+
for prefix, g := range c.mounts {
152+
nodes, err := g.GetCallers(token)
153+
if err != nil {
154+
continue
155+
}
156+
for _, n := range nodes {
157+
all = append(all, c.reprefixNode(prefix, n))
158+
}
159+
}
160+
return all, nil
161+
}
162+
163+
// GetCallees implements Graph.
164+
func (c *CompositeGraph) GetCallees(id string) ([]*Node, error) {
165+
c.mu.RLock()
166+
defer c.mu.RUnlock()
167+
168+
prefix, subPath, g := c.resolve(id)
169+
if g == nil {
170+
return nil, ErrNotFound
171+
}
172+
nodes, err := g.GetCallees(subPath)
173+
if err != nil {
174+
return nil, err
175+
}
176+
res := make([]*Node, len(nodes))
177+
for i, n := range nodes {
178+
res[i] = c.reprefixNode(prefix, n)
179+
}
180+
return res, nil
181+
}
182+
183+
// Invalidate implements Graph.
184+
func (c *CompositeGraph) Invalidate(id string) {
185+
c.mu.RLock()
186+
defer c.mu.RUnlock()
187+
188+
_, subPath, g := c.resolve(id)
189+
if g != nil {
190+
g.Invalidate(subPath)
191+
}
192+
}
193+
194+
// Act implements Graph. Routes to the appropriate sub-graph.
195+
func (c *CompositeGraph) Act(id, action, payload string) (*ActionResult, error) {
196+
c.mu.RLock()
197+
defer c.mu.RUnlock()
198+
199+
prefix, subPath, g := c.resolve(id)
200+
if g == nil {
201+
return nil, ErrNotFound
202+
}
203+
result, err := g.Act(subPath, action, payload)
204+
if err != nil {
205+
return nil, err
206+
}
207+
// Re-prefix paths in the result so the caller sees full composite paths
208+
if result != nil {
209+
if result.Path != "" && !strings.HasPrefix(result.Path, prefix+"/") {
210+
result.Path = prefix + "/" + strings.TrimPrefix(result.Path, "/")
211+
}
212+
if result.NodeID != "" && !strings.HasPrefix(result.NodeID, prefix+"/") {
213+
result.NodeID = prefix + "/" + strings.TrimPrefix(result.NodeID, "/")
214+
}
215+
}
216+
return result, nil
217+
}
218+
219+
// reprefixNode returns a shallow copy of n with ID and Children prefixed by the mount point.
220+
func (c *CompositeGraph) reprefixNode(prefix string, n *Node) *Node {
221+
nCopy := *n
222+
nCopy.ID = prefix + "/" + nCopy.ID
223+
if len(nCopy.Children) > 0 {
224+
nCopy.Children = make([]string, len(n.Children))
225+
for i, child := range n.Children {
226+
nCopy.Children[i] = prefix + "/" + child
227+
}
228+
}
229+
return &nCopy
230+
}
231+
232+
// Verify interface compliance at compile time.
233+
var _ Graph = (*CompositeGraph)(nil)

0 commit comments

Comments
 (0)