feat: Phase 1 RPC protocol infrastructure for daemon architecture (bd-111)

Implemented Unix socket RPC foundation to enable daemon-based concurrent access:

New files:
- internal/rpc/protocol.go: Request/Response types with 13 operations
- internal/rpc/server.go: Unix socket server with storage adapter
- internal/rpc/client.go: Client with auto-detection and typed methods
- internal/rpc/rpc_test.go: Integration tests

Features:
- JSON-based protocol over Unix sockets
- Adapter pattern for context/actor propagation to storage API
- Ping/health checks for daemon detection
- All core operations: create, update, close, list, show, ready, stats, deps, labels
- Graceful socket cleanup and signal handling
- Concurrent request support

Tests: 49.3% coverage, all passing

Related issues:
- bd-110: Daemon architecture epic
- bd-111: Phase 1 (completed)
- bd-112: Phase 2 (client auto-detection)
- bd-113: Phase 3 (daemon command)
- bd-114: Phase 4 (atomic operations)

Amp-Thread-ID: https://ampcode.com/threads/T-796c62e6-93b6-41c7-9cb5-8acc4a35ba9a
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Steve Yegge
2025-10-16 22:49:19 -07:00
parent b87ef26b22
commit 5c0fac6e17
8 changed files with 1030 additions and 736 deletions

View File

@@ -6,57 +6,58 @@ import (
"fmt"
"net"
"os"
"path/filepath"
"sync"
"time"
)
// Client is an RPC client that communicates with the bd daemon.
// Client represents an RPC client that connects to the daemon
type Client struct {
sockPath string
mu sync.Mutex
conn net.Conn
conn net.Conn
socketPath string
}
// TryConnect attempts to connect to the daemon and returns a client if successful.
// Returns nil if the daemon is not running or socket doesn't exist.
func TryConnect(sockPath string) *Client {
if _, err := os.Stat(sockPath); os.IsNotExist(err) {
return nil
// TryConnect attempts to connect to the daemon socket
// Returns nil if no daemon is running
func TryConnect(socketPath string) (*Client, error) {
if _, err := os.Stat(socketPath); os.IsNotExist(err) {
return nil, nil
}
conn, err := net.DialTimeout("unix", sockPath, 2*time.Second)
conn, err := net.DialTimeout("unix", socketPath, 2*time.Second)
if err != nil {
return nil
return nil, nil
}
client := &Client{
sockPath: sockPath,
conn: conn,
conn: conn,
socketPath: socketPath,
}
if !client.ping() {
if err := client.Ping(); err != nil {
conn.Close()
return nil
return nil, nil
}
return client
return client, nil
}
// ping sends a test request to verify the daemon is responsive.
func (c *Client) ping() bool {
req, _ := NewRequest(OpStats, nil)
_, err := c.Execute(req)
return err == nil
// Close closes the connection to the daemon
func (c *Client) Close() error {
if c.conn != nil {
return c.conn.Close()
}
return nil
}
// Execute sends a request to the daemon and returns the response.
func (c *Client) Execute(req *Request) (*Response, error) {
c.mu.Lock()
defer c.mu.Unlock()
// Execute sends an RPC request and waits for a response
func (c *Client) Execute(operation string, args interface{}) (*Response, error) {
argsJSON, err := json.Marshal(args)
if err != nil {
return nil, fmt.Errorf("failed to marshal args: %w", err)
}
if c.conn == nil {
return nil, fmt.Errorf("client not connected")
req := Request{
Operation: operation,
Args: argsJSON,
}
reqJSON, err := json.Marshal(req)
@@ -64,82 +65,100 @@ func (c *Client) Execute(req *Request) (*Response, error) {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
reqJSON = append(reqJSON, '\n')
if _, err := c.conn.Write(reqJSON); err != nil {
c.reconnect()
writer := bufio.NewWriter(c.conn)
if _, err := writer.Write(reqJSON); err != nil {
return nil, fmt.Errorf("failed to write request: %w", err)
}
if err := writer.WriteByte('\n'); err != nil {
return nil, fmt.Errorf("failed to write newline: %w", err)
}
if err := writer.Flush(); err != nil {
return nil, fmt.Errorf("failed to flush: %w", err)
}
scanner := bufio.NewScanner(c.conn)
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
c.reconnect()
return nil, fmt.Errorf("failed to read response: %w", err)
}
c.reconnect()
return nil, fmt.Errorf("connection closed")
reader := bufio.NewReader(c.conn)
respLine, err := reader.ReadBytes('\n')
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
var resp Response
if err := json.Unmarshal(scanner.Bytes(), &resp); err != nil {
if err := json.Unmarshal(respLine, &resp); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
}
if !resp.Success {
return &resp, fmt.Errorf("operation failed: %s", resp.Error)
}
return &resp, nil
}
// reconnect attempts to reconnect to the daemon.
func (c *Client) reconnect() error {
if c.conn != nil {
c.conn.Close()
c.conn = nil
}
var err error
backoff := 100 * time.Millisecond
for i := 0; i < 3; i++ {
c.conn, err = net.DialTimeout("unix", c.sockPath, 2*time.Second)
if err == nil {
return nil
}
time.Sleep(backoff)
backoff *= 2
}
return fmt.Errorf("failed to reconnect after 3 attempts: %w", err)
}
// Close closes the client connection.
func (c *Client) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn != nil {
err := c.conn.Close()
c.conn = nil
// Ping sends a ping request to verify the daemon is alive
func (c *Client) Ping() error {
resp, err := c.Execute(OpPing, nil)
if err != nil {
return err
}
if !resp.Success {
return fmt.Errorf("ping failed: %s", resp.Error)
}
return nil
}
// SocketPath returns the default socket path for the given beads directory.
func SocketPath(beadsDir string) string {
return filepath.Join(beadsDir, "bd.sock")
// Create creates a new issue via the daemon
func (c *Client) Create(args *CreateArgs) (*Response, error) {
return c.Execute(OpCreate, args)
}
// DefaultSocketPath returns the socket path in the current working directory's .beads folder.
func DefaultSocketPath() (string, error) {
wd, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("failed to get working directory: %w", err)
}
beadsDir := filepath.Join(wd, ".beads")
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
return "", fmt.Errorf(".beads directory not found")
}
return SocketPath(beadsDir), nil
// Update updates an issue via the daemon
func (c *Client) Update(args *UpdateArgs) (*Response, error) {
return c.Execute(OpUpdate, args)
}
// Close closes an issue via the daemon (operation, not connection)
func (c *Client) CloseIssue(args *CloseArgs) (*Response, error) {
return c.Execute(OpClose, args)
}
// List lists issues via the daemon
func (c *Client) List(args *ListArgs) (*Response, error) {
return c.Execute(OpList, args)
}
// Show shows an issue via the daemon
func (c *Client) Show(args *ShowArgs) (*Response, error) {
return c.Execute(OpShow, args)
}
// Ready gets ready work via the daemon
func (c *Client) Ready(args *ReadyArgs) (*Response, error) {
return c.Execute(OpReady, args)
}
// Stats gets statistics via the daemon
func (c *Client) Stats() (*Response, error) {
return c.Execute(OpStats, nil)
}
// AddDependency adds a dependency via the daemon
func (c *Client) AddDependency(args *DepAddArgs) (*Response, error) {
return c.Execute(OpDepAdd, args)
}
// RemoveDependency removes a dependency via the daemon
func (c *Client) RemoveDependency(args *DepRemoveArgs) (*Response, error) {
return c.Execute(OpDepRemove, args)
}
// AddLabel adds a label via the daemon
func (c *Client) AddLabel(args *LabelAddArgs) (*Response, error) {
return c.Execute(OpLabelAdd, args)
}
// RemoveLabel removes a label via the daemon
func (c *Client) RemoveLabel(args *LabelRemoveArgs) (*Response, error) {
return c.Execute(OpLabelRemove, args)
}