a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
167 lines
4.9 KiB
Go
167 lines
4.9 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
|
|
"github.com/zzet/gortex/internal/daemon"
|
|
"github.com/zzet/gortex/internal/pathkey"
|
|
)
|
|
|
|
// ErrNoExecutor signals that no warm daemon owns the repo and no explicit
|
|
// daemonless path (--oneshot) was selected — the caller decides whether to
|
|
// fall back (Stage 1) or refuse (Stage 3).
|
|
var ErrNoExecutor = errors.New("no warm daemon and --oneshot not set")
|
|
|
|
// ErrRepoNotTracked is the typed form of the daemon's repo_not_tracked
|
|
// refusal, distinguished so a CLI command can fall back rather than treat
|
|
// it as a hard error.
|
|
var ErrRepoNotTracked = errors.New("repository not tracked by the daemon")
|
|
|
|
// cliExecutor runs a registered MCP tool by name and returns its raw
|
|
// result JSON (the same payload the MCP server returns).
|
|
type cliExecutor interface {
|
|
CallTool(ctx context.Context, tool string, args map[string]any) (json.RawMessage, error)
|
|
Close() error
|
|
}
|
|
|
|
// daemonExecutor relays a one-shot tools/call over the daemon's AF_UNIX
|
|
// ModeMCP channel — the same warm graph the editor proxies hit, no cold
|
|
// index. It pins the JSON wire format so per-tool decoding is stable.
|
|
type daemonExecutor struct {
|
|
client *daemon.Client
|
|
nextID int
|
|
}
|
|
|
|
func (d *daemonExecutor) CallTool(_ context.Context, tool string, args map[string]any) (json.RawMessage, error) {
|
|
d.nextID++
|
|
frame, err := buildToolCallFrame(d.nextID, tool, args)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := d.client.WriteMCPFrame(frame); err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := d.client.ReadMCPFrame()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return extractToolResult(resp)
|
|
}
|
|
|
|
// buildToolCallFrame constructs the JSON-RPC tools/call frame, pinning the
|
|
// JSON wire format so the daemon's per-client GCX/TOON auto-selection does
|
|
// not defeat the per-tool decode.
|
|
func buildToolCallFrame(id int, tool string, args map[string]any) ([]byte, error) {
|
|
if args == nil {
|
|
args = map[string]any{}
|
|
}
|
|
// Default to JSON, but honour a caller-provided format (e.g.
|
|
// mermaid / dot for diagram output) so the CLI can request the
|
|
// daemon's other renderers.
|
|
if _, ok := args["format"]; !ok {
|
|
args["format"] = "json"
|
|
}
|
|
return json.Marshal(map[string]any{
|
|
"jsonrpc": "2.0",
|
|
"id": id,
|
|
"method": "tools/call",
|
|
"params": map[string]any{"name": tool, "arguments": args},
|
|
})
|
|
}
|
|
|
|
func (d *daemonExecutor) Close() error { return d.client.Close() }
|
|
|
|
// extractToolResult unwraps a JSON-RPC tools/call response: a
|
|
// repo_not_tracked error maps to the typed sentinel, any other error is
|
|
// surfaced verbatim, and a success returns the tool's JSON payload (the
|
|
// text of the first content block).
|
|
func extractToolResult(resp []byte) (json.RawMessage, error) {
|
|
var rpc struct {
|
|
Result json.RawMessage `json:"result"`
|
|
Error *struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
Data struct {
|
|
ErrorCode string `json:"error_code"`
|
|
} `json:"data"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(resp, &rpc); err != nil {
|
|
return nil, fmt.Errorf("decode daemon response: %w", err)
|
|
}
|
|
if rpc.Error != nil {
|
|
if rpc.Error.Data.ErrorCode == "repo_not_tracked" {
|
|
return nil, ErrRepoNotTracked
|
|
}
|
|
return nil, errors.New(rpc.Error.Message)
|
|
}
|
|
var res struct {
|
|
Content []struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text"`
|
|
} `json:"content"`
|
|
IsError bool `json:"isError"`
|
|
}
|
|
if err := json.Unmarshal(rpc.Result, &res); err != nil || len(res.Content) == 0 {
|
|
return rpc.Result, nil
|
|
}
|
|
payload := json.RawMessage(res.Content[0].Text)
|
|
if res.IsError {
|
|
return nil, errors.New(res.Content[0].Text)
|
|
}
|
|
return payload, nil
|
|
}
|
|
|
|
// resolveExecutor decides where a CLI graph query runs. This Stage-1 slice
|
|
// covers the daemon-first case (a warm daemon that owns the repo) and the
|
|
// no-executor case; --oneshot and autostart land with the shared
|
|
// constructor and the autostart primitive.
|
|
func resolveExecutor(repoPath string) (cliExecutor, error) {
|
|
abs, err := filepath.Abs(repoPath)
|
|
if err != nil {
|
|
abs = repoPath
|
|
}
|
|
if !daemon.IsRunning() {
|
|
return nil, ErrNoExecutor
|
|
}
|
|
if !daemonOwnsRepo(abs) {
|
|
return nil, ErrNoExecutor
|
|
}
|
|
c, err := daemon.Dial(daemon.Handshake{Mode: daemon.ModeMCP, ClientName: "cli", CWD: abs})
|
|
if err != nil {
|
|
return nil, ErrNoExecutor
|
|
}
|
|
return &daemonExecutor{client: c}, nil
|
|
}
|
|
|
|
// daemonOwnsRepo reports whether the running daemon tracks a repo that
|
|
// covers abs (so a daemon query won't answer empty for an untracked path).
|
|
func daemonOwnsRepo(abs string) bool {
|
|
c, err := daemon.Dial(daemon.Handshake{Mode: daemon.ModeControl, ClientName: "cli"})
|
|
if err != nil {
|
|
return false
|
|
}
|
|
defer c.Close()
|
|
resp, err := c.Control(daemon.ControlStatus, nil)
|
|
if err != nil || !resp.OK {
|
|
return false
|
|
}
|
|
var st daemon.StatusResponse
|
|
if err := json.Unmarshal(resp.Result, &st); err != nil {
|
|
return false
|
|
}
|
|
for _, repo := range st.TrackedRepos {
|
|
if repo.Path == "" {
|
|
continue
|
|
}
|
|
if pathkey.HasPathPrefix(abs, repo.Path) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|