chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user