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
634 lines
16 KiB
Go
634 lines
16 KiB
Go
package llm
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// DaemonBackend speaks MCP over stdio to a long-running `gortex mcp`
|
|
// subprocess. The subprocess itself proxies to the running daemon
|
|
// (cross-repo state, watchers, snapshot) — so this backend's
|
|
// responses reflect the real graph the user has indexed.
|
|
//
|
|
// One subprocess per backend; requests are sequential (the agent
|
|
// loop only issues one tool call at a time). Concurrency is not
|
|
// supported; callers should hold one DaemonBackend per goroutine if
|
|
// they need it.
|
|
type DaemonBackend struct {
|
|
cmd *exec.Cmd
|
|
stdin io.WriteCloser
|
|
out *bufio.Reader
|
|
mu sync.Mutex
|
|
id int
|
|
}
|
|
|
|
// NewDaemonBackend spawns `gortex mcp` and performs the MCP handshake.
|
|
// gortexBin is the path/name of the gortex binary; empty falls back to
|
|
// "gortex" on PATH. extraEnv augments the subprocess environment.
|
|
//
|
|
// The subprocess is bound to the directory the calling process is in
|
|
// — make sure that directory is inside a workspace gortex knows about
|
|
// (or pass GORTEX_INDEX via extraEnv) or the handshake will fail.
|
|
func NewDaemonBackend(gortexBin string, extraEnv []string) (*DaemonBackend, error) {
|
|
if gortexBin == "" {
|
|
gortexBin = "gortex"
|
|
}
|
|
cmd := exec.Command(gortexBin, "mcp")
|
|
cmd.Env = append(os.Environ(), extraEnv...)
|
|
|
|
stdin, err := cmd.StdinPipe()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stdin pipe: %w", err)
|
|
}
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stdout pipe: %w", err)
|
|
}
|
|
stderr, err := cmd.StderrPipe()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stderr pipe: %w", err)
|
|
}
|
|
// Drain stderr to /dev/null but keep it readable so the
|
|
// subprocess never blocks on a full pipe.
|
|
go func() { _, _ = io.Copy(io.Discard, stderr) }()
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
return nil, fmt.Errorf("start gortex mcp: %w", err)
|
|
}
|
|
|
|
b := &DaemonBackend{
|
|
cmd: cmd,
|
|
stdin: stdin,
|
|
out: bufio.NewReaderSize(stdout, 1<<20),
|
|
}
|
|
|
|
// MCP initialise handshake — initialize → initialized notification.
|
|
if _, err := b.call("initialize", map[string]any{
|
|
"protocolVersion": "2024-11-05",
|
|
"capabilities": map[string]any{},
|
|
"clientInfo": map[string]any{"name": "gortex-llm", "version": "0.1"},
|
|
}); err != nil {
|
|
_ = b.Close()
|
|
return nil, fmt.Errorf("mcp initialize: %w", err)
|
|
}
|
|
if err := b.notify("notifications/initialized", map[string]any{}); err != nil {
|
|
_ = b.Close()
|
|
return nil, fmt.Errorf("mcp initialized notification: %w", err)
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
// Close terminates the subprocess. Safe to call more than once.
|
|
func (b *DaemonBackend) Close() error {
|
|
if b == nil || b.cmd == nil || b.cmd.Process == nil {
|
|
return nil
|
|
}
|
|
_ = b.stdin.Close()
|
|
_ = b.cmd.Process.Kill()
|
|
_, _ = b.cmd.Process.Wait()
|
|
b.cmd = nil
|
|
return nil
|
|
}
|
|
|
|
type jsonrpcRequest struct {
|
|
JSONRPC string `json:"jsonrpc"`
|
|
ID int `json:"id,omitempty"`
|
|
Method string `json:"method"`
|
|
Params any `json:"params,omitempty"`
|
|
}
|
|
|
|
type jsonrpcResponse struct {
|
|
JSONRPC string `json:"jsonrpc"`
|
|
ID json.RawMessage `json:"id"`
|
|
Result json.RawMessage `json:"result,omitempty"`
|
|
Error *struct {
|
|
Code int `json:"code"`
|
|
Message string `json:"message"`
|
|
} `json:"error,omitempty"`
|
|
}
|
|
|
|
// call sends a JSON-RPC request and waits for a matching response.
|
|
// Skips notifications and server-initiated requests on the wire.
|
|
func (b *DaemonBackend) call(method string, params any) (json.RawMessage, error) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
b.id++
|
|
id := b.id
|
|
|
|
req := jsonrpcRequest{JSONRPC: "2.0", ID: id, Method: method, Params: params}
|
|
buf, err := json.Marshal(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal %s: %w", method, err)
|
|
}
|
|
buf = append(buf, '\n')
|
|
if _, err := b.stdin.Write(buf); err != nil {
|
|
return nil, fmt.Errorf("write %s: %w", method, err)
|
|
}
|
|
|
|
for {
|
|
line, err := b.out.ReadBytes('\n')
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read %s: %w", method, err)
|
|
}
|
|
var resp jsonrpcResponse
|
|
if err := json.Unmarshal(line, &resp); err != nil {
|
|
// Non-JSON line (banner?) — ignore.
|
|
continue
|
|
}
|
|
if len(resp.ID) == 0 {
|
|
// Notification or server request — skip.
|
|
continue
|
|
}
|
|
var gotID int
|
|
if err := json.Unmarshal(resp.ID, &gotID); err != nil {
|
|
// Non-numeric id (server request) — skip.
|
|
continue
|
|
}
|
|
if gotID != id {
|
|
continue
|
|
}
|
|
if resp.Error != nil {
|
|
return nil, fmt.Errorf("%s: %s (code %d)", method, resp.Error.Message, resp.Error.Code)
|
|
}
|
|
return resp.Result, nil
|
|
}
|
|
}
|
|
|
|
// notify sends a JSON-RPC notification (no id, no response expected).
|
|
func (b *DaemonBackend) notify(method string, params any) error {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
req := jsonrpcRequest{JSONRPC: "2.0", Method: method, Params: params}
|
|
buf, err := json.Marshal(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
buf = append(buf, '\n')
|
|
_, err = b.stdin.Write(buf)
|
|
return err
|
|
}
|
|
|
|
// callTool invokes one MCP tool by name and returns the text content
|
|
// of the first content block (gortex tools always emit one).
|
|
func (b *DaemonBackend) callTool(ctx context.Context, name string, args map[string]any) ([]byte, error) {
|
|
_ = ctx // not honored for cancellation in this minimal stdio path
|
|
args["format"] = "json"
|
|
raw, err := b.call("tools/call", map[string]any{
|
|
"name": name,
|
|
"arguments": args,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var result struct {
|
|
IsError bool `json:"isError"`
|
|
Content []struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text"`
|
|
} `json:"content"`
|
|
}
|
|
if err := json.Unmarshal(raw, &result); err != nil {
|
|
return nil, fmt.Errorf("%s: parse envelope: %w", name, err)
|
|
}
|
|
if result.IsError {
|
|
var msg string
|
|
if len(result.Content) > 0 {
|
|
msg = result.Content[0].Text
|
|
}
|
|
return nil, fmt.Errorf("%s: tool error: %s", name, msg)
|
|
}
|
|
if len(result.Content) == 0 {
|
|
return nil, nil
|
|
}
|
|
return []byte(result.Content[0].Text), nil
|
|
}
|
|
|
|
func (b *DaemonBackend) SearchSymbols(ctx context.Context, query string, scope Scope, limit int) ([]Match, error) {
|
|
args := map[string]any{"query": query}
|
|
if limit > 0 {
|
|
args["limit"] = limit
|
|
}
|
|
applyScope(args, scope)
|
|
raw, err := b.callTool(ctx, "search_symbols", args)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// gortex search_symbols json: {"results":[{node…}], …} OR
|
|
// {"nodes":[…]} OR (older) a top-level array of nodes.
|
|
var wrap struct {
|
|
Results []rawGraphNode `json:"results"`
|
|
Nodes []rawGraphNode `json:"nodes"`
|
|
}
|
|
if err := json.Unmarshal(raw, &wrap); err == nil && (wrap.Results != nil || wrap.Nodes != nil) {
|
|
src := wrap.Results
|
|
if len(src) == 0 {
|
|
src = wrap.Nodes
|
|
}
|
|
out := make([]Match, len(src))
|
|
for i, r := range src {
|
|
out[i] = r.match()
|
|
}
|
|
return out, nil
|
|
}
|
|
var arr []rawGraphNode
|
|
if err := json.Unmarshal(raw, &arr); err == nil {
|
|
out := make([]Match, len(arr))
|
|
for i, r := range arr {
|
|
out[i] = r.match()
|
|
}
|
|
return out, nil
|
|
}
|
|
return nil, fmt.Errorf("search_symbols: unrecognised payload shape: %s", truncate(string(raw), 200))
|
|
}
|
|
|
|
// rawGraphNode is the gortex graph-tool node payload. Field names
|
|
// follow the canonical gortex shape (file_path, repo_prefix, start_line)
|
|
// with fallbacks to older variants (path, file, repo, line) and a
|
|
// nested meta sub-doc some versions wrap things in.
|
|
type rawGraphNode struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Kind string `json:"kind"`
|
|
FilePath string `json:"file_path"`
|
|
Path string `json:"path"`
|
|
File string `json:"file"`
|
|
RepoPrefix string `json:"repo_prefix"`
|
|
Repo string `json:"repo"`
|
|
StartLine int `json:"start_line"`
|
|
Line int `json:"line"`
|
|
Meta struct {
|
|
FilePath string `json:"file_path"`
|
|
Path string `json:"path"`
|
|
File string `json:"file"`
|
|
RepoPrefix string `json:"repo_prefix"`
|
|
Repo string `json:"repo"`
|
|
} `json:"meta"`
|
|
}
|
|
|
|
func (n rawGraphNode) file() string {
|
|
for _, s := range []string{n.FilePath, n.Path, n.File, n.Meta.FilePath, n.Meta.Path, n.Meta.File} {
|
|
if s != "" {
|
|
return s
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (n rawGraphNode) repo() string {
|
|
for _, s := range []string{n.RepoPrefix, n.Repo, n.Meta.RepoPrefix, n.Meta.Repo} {
|
|
if s != "" {
|
|
return s
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (n rawGraphNode) caller() Caller {
|
|
return Caller{ID: n.ID, File: n.file(), Repo: n.repo()}
|
|
}
|
|
|
|
func (n rawGraphNode) match() Match {
|
|
line := n.StartLine
|
|
if line == 0 {
|
|
line = n.Line
|
|
}
|
|
return Match{
|
|
ID: n.ID,
|
|
Name: n.Name,
|
|
Kind: n.Kind,
|
|
Path: n.file(),
|
|
Line: line,
|
|
Repo: n.repo(),
|
|
}
|
|
}
|
|
|
|
type rawGraphEdge struct {
|
|
From string `json:"from"`
|
|
To string `json:"to"`
|
|
Kind string `json:"kind"`
|
|
}
|
|
|
|
func (b *DaemonBackend) GetCallers(ctx context.Context, id string, scope Scope, limit int) ([]Caller, error) {
|
|
args := map[string]any{"id": id}
|
|
if limit > 0 {
|
|
args["limit"] = limit
|
|
}
|
|
applyScope(args, scope)
|
|
raw, err := b.callTool(ctx, "get_callers", args)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Canonical gortex graph-tool payload: { "nodes":[...], "edges":[...],
|
|
// "total_nodes":N, "total_edges":M, ...}. Each edge has from→to;
|
|
// callers are the nodes that originate edges pointing to `id`.
|
|
var wrap struct {
|
|
Nodes []rawGraphNode `json:"nodes"`
|
|
Edges []rawGraphEdge `json:"edges"`
|
|
// legacy shapes
|
|
Callers []rawGraphNode `json:"callers"`
|
|
}
|
|
if err := json.Unmarshal(raw, &wrap); err != nil {
|
|
return nil, fmt.Errorf("get_callers: parse: %w", err)
|
|
}
|
|
|
|
// Build node lookup for the from→Caller rendering step.
|
|
byID := make(map[string]rawGraphNode, len(wrap.Nodes))
|
|
for _, n := range wrap.Nodes {
|
|
byID[n.ID] = n
|
|
}
|
|
|
|
var callerIDs []string
|
|
if len(wrap.Edges) > 0 {
|
|
// Edges from the get_callers tool point caller→callee; the
|
|
// callee is the queried `id`, the caller is `from`.
|
|
seen := make(map[string]struct{})
|
|
for _, e := range wrap.Edges {
|
|
if e.To != id && e.From == id {
|
|
// Some tool versions invert the direction; fall back.
|
|
if _, ok := seen[e.To]; ok {
|
|
continue
|
|
}
|
|
seen[e.To] = struct{}{}
|
|
callerIDs = append(callerIDs, e.To)
|
|
continue
|
|
}
|
|
if e.To != id {
|
|
continue
|
|
}
|
|
if _, ok := seen[e.From]; ok {
|
|
continue
|
|
}
|
|
seen[e.From] = struct{}{}
|
|
callerIDs = append(callerIDs, e.From)
|
|
}
|
|
} else if len(wrap.Callers) > 0 {
|
|
out := make([]Caller, len(wrap.Callers))
|
|
for i, n := range wrap.Callers {
|
|
out[i] = n.caller()
|
|
}
|
|
return out, nil
|
|
} else {
|
|
// No edges payload AND no legacy callers — return every node
|
|
// in the response except the queried one. Conservative
|
|
// fallback for tool versions that just list neighbours.
|
|
for _, n := range wrap.Nodes {
|
|
if n.ID == id {
|
|
continue
|
|
}
|
|
callerIDs = append(callerIDs, n.ID)
|
|
}
|
|
}
|
|
|
|
out := make([]Caller, 0, len(callerIDs))
|
|
for _, cid := range callerIDs {
|
|
if n, ok := byID[cid]; ok {
|
|
out = append(out, n.caller())
|
|
continue
|
|
}
|
|
out = append(out, Caller{ID: cid})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (b *DaemonBackend) GetDependencies(ctx context.Context, id string, scope Scope, depth, limit int) ([]Dep, error) {
|
|
args := map[string]any{"id": id}
|
|
if depth > 0 {
|
|
args["depth"] = depth
|
|
}
|
|
if limit > 0 {
|
|
args["limit"] = limit
|
|
}
|
|
applyScope(args, scope)
|
|
raw, err := b.callTool(ctx, "get_dependencies", args)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// Canonical shape: { "nodes":[…], "edges":[{from,to,kind}], … }
|
|
// Edges from the queried `id` to other nodes describe dependencies.
|
|
var wrap struct {
|
|
Nodes []rawGraphNode `json:"nodes"`
|
|
Edges []rawGraphEdge `json:"edges"`
|
|
}
|
|
if err := json.Unmarshal(raw, &wrap); err != nil {
|
|
return nil, fmt.Errorf("get_dependencies: parse: %w", err)
|
|
}
|
|
byID := make(map[string]rawGraphNode, len(wrap.Nodes))
|
|
for _, n := range wrap.Nodes {
|
|
byID[n.ID] = n
|
|
}
|
|
var depIDs []struct {
|
|
ID string
|
|
Kind string
|
|
}
|
|
seen := map[string]struct{}{}
|
|
for _, e := range wrap.Edges {
|
|
if e.From != id {
|
|
continue
|
|
}
|
|
if _, ok := seen[e.To]; ok {
|
|
continue
|
|
}
|
|
seen[e.To] = struct{}{}
|
|
depIDs = append(depIDs, struct {
|
|
ID string
|
|
Kind string
|
|
}{e.To, e.Kind})
|
|
}
|
|
// If the graph payload omitted explicit edges, fall back to
|
|
// every non-self node as an opaque dependency.
|
|
if len(depIDs) == 0 {
|
|
for _, n := range wrap.Nodes {
|
|
if n.ID == id {
|
|
continue
|
|
}
|
|
depIDs = append(depIDs, struct {
|
|
ID string
|
|
Kind string
|
|
}{n.ID, ""})
|
|
}
|
|
}
|
|
out := make([]Dep, 0, len(depIDs))
|
|
for _, d := range depIDs {
|
|
n := byID[d.ID]
|
|
out = append(out, Dep{ID: d.ID, Kind: d.Kind, File: n.file(), Repo: n.repo()})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// rawContract mirrors a single contract entry in gortex's nested
|
|
// by_repo / by_type response shape. Field names match the canonical
|
|
// node-style (file_path, repo_prefix) with method/path lifted out of
|
|
// the meta sub-doc for HTTP contracts.
|
|
type rawContract struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
Role string `json:"role"`
|
|
RepoPrefix string `json:"repo_prefix"`
|
|
Repo string `json:"repo"`
|
|
FilePath string `json:"file_path"`
|
|
File string `json:"file"`
|
|
Line int `json:"line"`
|
|
SymbolID string `json:"symbol_id"`
|
|
Method string `json:"method"`
|
|
Path string `json:"path"`
|
|
Meta struct {
|
|
Method string `json:"method"`
|
|
Path string `json:"path"`
|
|
} `json:"meta"`
|
|
}
|
|
|
|
func (r rawContract) toContract() Contract {
|
|
method := r.Method
|
|
if method == "" {
|
|
method = r.Meta.Method
|
|
}
|
|
path := r.Path
|
|
if path == "" {
|
|
path = r.Meta.Path
|
|
}
|
|
// id format for HTTP is "http::METHOD::/path" — fall back to
|
|
// parsing the id when method/path are absent.
|
|
if (method == "" || path == "") && strings.HasPrefix(r.ID, "http::") {
|
|
parts := strings.SplitN(r.ID, "::", 3)
|
|
if len(parts) == 3 {
|
|
if method == "" {
|
|
method = parts[1]
|
|
}
|
|
if path == "" {
|
|
path = parts[2]
|
|
}
|
|
}
|
|
}
|
|
repo := r.RepoPrefix
|
|
if repo == "" {
|
|
repo = r.Repo
|
|
}
|
|
file := r.FilePath
|
|
if file == "" {
|
|
file = r.File
|
|
}
|
|
return Contract{
|
|
Type: r.Type, Role: r.Role, Repo: repo,
|
|
Method: method, Path: path,
|
|
File: file, Line: r.Line,
|
|
ID: r.ID, SymbolID: r.SymbolID,
|
|
}
|
|
}
|
|
|
|
func (b *DaemonBackend) ListContracts(ctx context.Context, f ContractFilter, scope Scope) ([]Contract, error) {
|
|
args := map[string]any{"action": "list"}
|
|
if f.Role != "" {
|
|
args["role"] = f.Role
|
|
}
|
|
if f.Type != "" {
|
|
args["type"] = f.Type
|
|
}
|
|
if f.Limit > 0 {
|
|
args["limit"] = f.Limit
|
|
}
|
|
// Chain tracing inherently spans repos. Disable the daemon's
|
|
// active-project auto-scope unless the caller pinned a specific
|
|
// repo.
|
|
if scope.Repo == "" && scope.Project == "" {
|
|
args["all_repos"] = true
|
|
}
|
|
applyScope(args, scope)
|
|
raw, err := b.callTool(ctx, "contracts", args)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// gortex contracts payload (canonical, json format):
|
|
// { "by_repo": { "<repo>": { "contracts": { "<type>": [contract…] }, "total": N } } }
|
|
// Flatten the nested structure into a single slice, then apply
|
|
// client-side method / path filters.
|
|
var wrap struct {
|
|
ByRepo map[string]struct {
|
|
Contracts map[string][]rawContract `json:"contracts"`
|
|
} `json:"by_repo"`
|
|
// Legacy / non-grouped variant.
|
|
Contracts []rawContract `json:"contracts"`
|
|
}
|
|
if err := json.Unmarshal(raw, &wrap); err != nil {
|
|
return nil, fmt.Errorf("contracts: parse: %w", err)
|
|
}
|
|
var out []Contract
|
|
add := func(rc rawContract) {
|
|
c := rc.toContract()
|
|
if f.Method != "" && c.Method != f.Method {
|
|
return
|
|
}
|
|
if f.Path != "" && c.Path != f.Path {
|
|
return
|
|
}
|
|
out = append(out, c)
|
|
}
|
|
for _, repo := range wrap.ByRepo {
|
|
for _, list := range repo.Contracts {
|
|
for _, rc := range list {
|
|
add(rc)
|
|
}
|
|
}
|
|
}
|
|
for _, rc := range wrap.Contracts {
|
|
add(rc)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (b *DaemonBackend) ListRepos(ctx context.Context) ([]Repo, error) {
|
|
raw, err := b.callTool(ctx, "list_repos", map[string]any{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
// list_repos shape varies a bit too. Look for "repos" key, fall
|
|
// back to top-level array.
|
|
var wrap struct {
|
|
Repos []struct {
|
|
Name string `json:"name"`
|
|
Root string `json:"root"`
|
|
Path string `json:"path"`
|
|
Nodes int `json:"nodes"`
|
|
} `json:"repos"`
|
|
}
|
|
if err := json.Unmarshal(raw, &wrap); err != nil || wrap.Repos == nil {
|
|
return nil, fmt.Errorf("list_repos: unrecognised payload shape: %s", truncate(string(raw), 200))
|
|
}
|
|
out := make([]Repo, len(wrap.Repos))
|
|
for i, r := range wrap.Repos {
|
|
root := r.Root
|
|
if root == "" {
|
|
root = r.Path
|
|
}
|
|
out[i] = Repo{Name: r.Name, Root: root, Nodes: r.Nodes}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func applyScope(args map[string]any, s Scope) {
|
|
if s.Repo != "" {
|
|
args["repo"] = s.Repo
|
|
}
|
|
if s.Project != "" {
|
|
args["project"] = s.Project
|
|
}
|
|
if s.Ref != "" {
|
|
args["ref"] = s.Ref
|
|
}
|
|
}
|
|
|
|
func truncate(s string, n int) string {
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
return s[:n] + "..."
|
|
}
|
|
|
|
var _ Backend = (*DaemonBackend)(nil)
|