Implement herdr integration layer
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
// Package herdr contains the small, protocol-oriented execution seam used by
|
||||
// the orchestration layer. It deliberately does not shell out to a harness.
|
||||
package herdr
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrProtocol = errors.New("herdr protocol error")
|
||||
|
||||
type Request struct {
|
||||
ID string `json:"id"`
|
||||
Method string `json:"method"`
|
||||
Params any `json:"params,omitempty"`
|
||||
}
|
||||
type Response struct {
|
||||
ID string `json:"id"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error *struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
type Client struct {
|
||||
Path string
|
||||
Timeout time.Duration
|
||||
dial func() (net.Conn, error)
|
||||
mu sync.Mutex
|
||||
next uint64
|
||||
}
|
||||
|
||||
func New(path string) *Client { return &Client{Path: path, Timeout: 10 * time.Second} }
|
||||
func (c *Client) conn() (net.Conn, error) {
|
||||
if c.dial != nil {
|
||||
return c.dial()
|
||||
}
|
||||
return net.DialTimeout("unix", c.Path, c.Timeout)
|
||||
}
|
||||
func (c *Client) Call(ctx context.Context, method string, params any, out any) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.next++
|
||||
id := fmt.Sprint(c.next)
|
||||
cn, err := c.conn()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cn.Close()
|
||||
if d, ok := ctx.Deadline(); ok {
|
||||
_ = cn.SetDeadline(d)
|
||||
} else if c.Timeout > 0 {
|
||||
_ = cn.SetDeadline(time.Now().Add(c.Timeout))
|
||||
}
|
||||
if err = json.NewEncoder(cn).Encode(Request{ID: id, Method: method, Params: params}); err != nil {
|
||||
return err
|
||||
}
|
||||
var r Response
|
||||
if err = json.NewDecoder(bufio.NewReader(cn)).Decode(&r); err != nil {
|
||||
return err
|
||||
}
|
||||
if r.Error != nil {
|
||||
return fmt.Errorf("%w: %s", ErrProtocol, r.Error.Message)
|
||||
}
|
||||
if out != nil && len(r.Result) > 0 {
|
||||
return json.Unmarshal(r.Result, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type PingResult struct {
|
||||
Protocol string `json:"protocol"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
func (c *Client) Ping(ctx context.Context) (PingResult, error) {
|
||||
var p PingResult
|
||||
err := c.Call(ctx, "ping", nil, &p)
|
||||
return p, err
|
||||
}
|
||||
func (c *Client) CheckProtocol(ctx context.Context, want string) error {
|
||||
p, e := c.Ping(ctx)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if want != "" && p.Protocol != want {
|
||||
return fmt.Errorf("%w: want %s, got %s", ErrProtocol, want, p.Protocol)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
PaneID string `json:"pane_id"`
|
||||
Worktree string `json:"worktree"`
|
||||
Harness string `json:"harness"`
|
||||
}
|
||||
|
||||
func (c *Client) Prompt(ctx context.Context, pane, text string, wait time.Duration) error {
|
||||
p := map[string]any{"pane_id": pane, "prompt": text, "wait": map[string]any{"until": "turn_end", "timeout_ms": wait.Milliseconds()}}
|
||||
return c.Call(ctx, "agent.prompt", p, nil)
|
||||
}
|
||||
func (c *Client) Worktree(ctx context.Context, path, branch string) (string, error) {
|
||||
var r struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
e := c.Call(ctx, "worktree.create", map[string]string{"path": path, "branch": branch}, &r)
|
||||
return r.Path, e
|
||||
}
|
||||
|
||||
// AnchorValid checks the split-then-close safety condition without trusting a
|
||||
// predecessor's prose. dirty maps contain path -> expected SHA-256.
|
||||
func AnchorValid(root, sha string, dirty map[string]string) error {
|
||||
out, e := exec.Command("git", "-C", root, "rev-parse", "HEAD").Output()
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if string(out) != sha+"\n" {
|
||||
return fmt.Errorf("anchor: HEAD mismatch")
|
||||
}
|
||||
for p, want := range dirty {
|
||||
b, e := os.ReadFile(filepath.Join(root, p))
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
h := sha256.Sum256(b)
|
||||
if fmt.Sprintf("%x", h) != want {
|
||||
return fmt.Errorf("anchor: %s changed", p)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user