You've already forked agentic-coding-workflow
439 lines
12 KiB
Go
439 lines
12 KiB
Go
// relay.go 实现 ACP 双向 JSON-RPC 中继:
|
|
// - reader: 子进程 stdout → 解析 JSON-RPC → 落 events → fanout / handler dispatch
|
|
// - writer: 服务端发起 / WS 客户端透传 → 子进程 stdin
|
|
// - subscribers: 多 WS tab 共享 reader 的 fanout
|
|
//
|
|
// id 命名空间(spec §5.2 / §7.2):
|
|
// - 服务端发起:int64(atomic 递增)
|
|
// - 客户端 WS 发起:string(前端自生成)
|
|
// - inflight 仅存 int64 → response chan;string id 的 response 当 event fanout
|
|
package acp
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"sync"
|
|
"sync/atomic"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/yan1h/agent-coding-workflow/internal/acp/handlers"
|
|
)
|
|
|
|
// EventEnvelope 是推到 WS 的事件结构(与落库的 Event 字段同步,但带 ID 数值化方便 JSON)。
|
|
type EventEnvelope struct {
|
|
ID int64 `json:"id"`
|
|
Direction string `json:"direction"` // 'in' | 'out'
|
|
Kind string `json:"kind"` // 'request'|'response'|'notification'|'error'|'session_terminated'|'slow_consumer_disconnect'
|
|
Method string `json:"method,omitempty"`
|
|
Payload json.RawMessage `json:"payload"`
|
|
Truncated bool `json:"truncated"`
|
|
}
|
|
|
|
// Subscriber 是单个 WS tab 的订阅句柄。
|
|
type Subscriber struct {
|
|
ID uuid.UUID
|
|
UserID uuid.UUID
|
|
Send chan *EventEnvelope
|
|
closed atomic.Bool
|
|
}
|
|
|
|
// Relay 是单 session 的中继器。每个活跃 session 一个 Relay 实例。
|
|
type Relay struct {
|
|
sessionID uuid.UUID
|
|
|
|
// 协议层
|
|
enc *Encoder
|
|
dec *Decoder
|
|
|
|
// 服务端发起请求等响应(id=int64)
|
|
inflight sync.Map // int64 → chan *Message
|
|
nextID atomic.Int64
|
|
|
|
// WS subscribers(多 tab fanout)
|
|
subsMu sync.Mutex
|
|
subs map[uuid.UUID]*Subscriber
|
|
|
|
// 写入路径
|
|
serverInitiated chan *Message // 服务端发起的 request/notification
|
|
clientInitiated chan *Message // WS 上行(已校验 method 白名单)
|
|
|
|
// 依赖
|
|
repo Repository
|
|
fsHandler *handlers.FsHandler
|
|
permHandler *handlers.PermissionHandler
|
|
log *slog.Logger
|
|
cfg RelayConfig
|
|
|
|
// 终止控制
|
|
closed atomic.Bool
|
|
done chan struct{}
|
|
}
|
|
|
|
// RelayConfig 影响事件入库截断行为。
|
|
type RelayConfig struct {
|
|
EventMaxPayload int
|
|
EventTruncateField int
|
|
WSSendBuffer int
|
|
}
|
|
|
|
// NewRelay 构造一个空 Relay;启动 goroutine 走 Run(D7 实现)。
|
|
func NewRelay(sessionID uuid.UUID, dec *Decoder, enc *Encoder,
|
|
repo Repository, fs *handlers.FsHandler, perm *handlers.PermissionHandler,
|
|
cfg RelayConfig, log *slog.Logger) *Relay {
|
|
if log == nil {
|
|
log = slog.Default()
|
|
}
|
|
return &Relay{
|
|
sessionID: sessionID,
|
|
enc: enc,
|
|
dec: dec,
|
|
subs: map[uuid.UUID]*Subscriber{},
|
|
serverInitiated: make(chan *Message, 32),
|
|
clientInitiated: make(chan *Message, 32),
|
|
repo: repo,
|
|
fsHandler: fs,
|
|
permHandler: perm,
|
|
log: log,
|
|
cfg: cfg,
|
|
done: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
// Subscribe 注册一个 WS tab 订阅。
|
|
func (r *Relay) Subscribe(userID uuid.UUID) *Subscriber {
|
|
sub := &Subscriber{
|
|
ID: uuid.New(),
|
|
UserID: userID,
|
|
Send: make(chan *EventEnvelope, r.cfg.WSSendBuffer),
|
|
}
|
|
r.subsMu.Lock()
|
|
r.subs[sub.ID] = sub
|
|
r.subsMu.Unlock()
|
|
return sub
|
|
}
|
|
|
|
// Unsubscribe 移除订阅;幂等。
|
|
func (r *Relay) Unsubscribe(subID uuid.UUID) {
|
|
r.subsMu.Lock()
|
|
defer r.subsMu.Unlock()
|
|
if sub, ok := r.subs[subID]; ok {
|
|
if sub.closed.CompareAndSwap(false, true) {
|
|
close(sub.Send)
|
|
}
|
|
delete(r.subs, subID)
|
|
}
|
|
}
|
|
|
|
// fanout 把一条 envelope 推给所有 subscribers。慢消费者按 §7.6 自动断开。
|
|
func (r *Relay) fanout(env *EventEnvelope) {
|
|
r.subsMu.Lock()
|
|
defer r.subsMu.Unlock()
|
|
for id, sub := range r.subs {
|
|
select {
|
|
case sub.Send <- env:
|
|
default:
|
|
// buffer 满 → 慢消费者,标 closed + 通知 + close channel
|
|
if sub.closed.CompareAndSwap(false, true) {
|
|
disc := &EventEnvelope{Kind: "slow_consumer_disconnect"}
|
|
select {
|
|
case sub.Send <- disc:
|
|
default:
|
|
}
|
|
close(sub.Send)
|
|
delete(r.subs, id)
|
|
r.log.Warn("acp.relay.slow_subscriber_dropped",
|
|
"session_id", r.sessionID, "sub_id", id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Close 优雅关闭:先广播 final event,再关 channel,再标 closed。
|
|
// supervisor.onExit 调用,确保所有 WS 看到 session_terminated。
|
|
func (r *Relay) Close(status string, exitCode *int32) {
|
|
if !r.closed.CompareAndSwap(false, true) {
|
|
return
|
|
}
|
|
final := &EventEnvelope{
|
|
Kind: "session_terminated",
|
|
Payload: mustJSON(map[string]any{
|
|
"status": status,
|
|
"exit_code": exitCode,
|
|
}),
|
|
}
|
|
r.fanout(final)
|
|
|
|
r.subsMu.Lock()
|
|
for id, sub := range r.subs {
|
|
if sub.closed.CompareAndSwap(false, true) {
|
|
close(sub.Send)
|
|
}
|
|
delete(r.subs, id)
|
|
}
|
|
r.subsMu.Unlock()
|
|
|
|
close(r.done)
|
|
}
|
|
|
|
// FanoutControl 把一条控制类 envelope(如 permission_request/permission_resolved)
|
|
// 推给所有 WS 订阅者。这类帧不落 acp_events、ID 恒为 0,由 PermissionService 调用。
|
|
// relay 已关闭时静默丢弃,避免向已关 channel 写入。
|
|
func (r *Relay) FanoutControl(env *EventEnvelope) {
|
|
if r.closed.Load() {
|
|
return
|
|
}
|
|
r.fanout(env)
|
|
}
|
|
|
|
// Done returns a channel closed when Relay is terminated.
|
|
func (r *Relay) Done() <-chan struct{} { return r.done }
|
|
|
|
func mustJSON(v any) json.RawMessage {
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
return json.RawMessage(fmt.Sprintf(`{"_marshal_err":%q}`, err.Error()))
|
|
}
|
|
return b
|
|
}
|
|
|
|
// Run is the relay main loop; supervisor.Spawn calls it in a goroutine.
|
|
// It launches the reader and writer goroutines and waits for both.
|
|
func (r *Relay) Run(ctx context.Context, sess handlers.SessionContext) {
|
|
var wg sync.WaitGroup
|
|
wg.Add(2)
|
|
go func() { defer wg.Done(); r.reader(ctx, sess) }()
|
|
go func() { defer wg.Done(); r.writer(ctx) }()
|
|
wg.Wait()
|
|
}
|
|
|
|
func (r *Relay) reader(ctx context.Context, sess handlers.SessionContext) {
|
|
for {
|
|
msg, err := r.dec.DecodeMessage()
|
|
if err != nil {
|
|
if errors.Is(err, io.EOF) {
|
|
r.log.Info("acp.relay.eof", "session_id", r.sessionID)
|
|
return
|
|
}
|
|
r.log.Error("acp.relay.decode", "session_id", r.sessionID, "err", err.Error())
|
|
return
|
|
}
|
|
|
|
envelope := r.persistAndEnvelope(ctx, msg, "out")
|
|
if envelope != nil {
|
|
r.fanout(envelope)
|
|
}
|
|
|
|
switch {
|
|
case msg.IsResponse():
|
|
if id, ok := msg.IDInt64(); ok {
|
|
if ch, hit := r.inflight.LoadAndDelete(id); hit {
|
|
ch.(chan *Message) <- msg
|
|
} else {
|
|
r.log.Warn("acp.relay.orphan_response", "session_id", r.sessionID, "id", id)
|
|
}
|
|
}
|
|
// String IDs go to clients via fanout (handled above); no server action.
|
|
case msg.IsNotification():
|
|
// Already persisted + fanout'd. No reply.
|
|
case msg.IsRequest():
|
|
go r.handleAgentRequest(ctx, sess, msg)
|
|
}
|
|
}
|
|
}
|
|
|
|
// handleAgentRequest dispatches an agent → server request to the appropriate handler.
|
|
// 在独立 goroutine 中运行,故 defer recover 兜底任何 handler panic:记录日志并回送
|
|
// JSON-RPC internal error,避免 agent 构造的请求 panic 掉整个进程(DoS)。
|
|
func (r *Relay) handleAgentRequest(ctx context.Context, sess handlers.SessionContext, req *Message) {
|
|
defer func() {
|
|
if rec := recover(); rec != nil {
|
|
r.log.Error("acp.relay.handle_request_panic",
|
|
"session_id", r.sessionID, "method", req.Method, "panic", fmt.Sprintf("%v", rec))
|
|
resp := NewResponseErr(req.ID, JSONRPCInternalError, "internal error")
|
|
select {
|
|
case r.serverInitiated <- resp:
|
|
case <-ctx.Done():
|
|
}
|
|
}
|
|
}()
|
|
|
|
var resp *Message
|
|
switch req.Method {
|
|
case "fs/read_text_file":
|
|
out := r.fsHandler.Read.Handle(ctx, sess, req.Params)
|
|
resp = r.fsResultToResponse(req.ID, out)
|
|
case "fs/write_text_file":
|
|
out := r.fsHandler.Write.Handle(ctx, sess, req.Params)
|
|
resp = r.fsResultToResponse(req.ID, out)
|
|
case "session/request_permission":
|
|
out := r.permHandler.Handle(ctx, sess, string(req.ID), req.Params)
|
|
resp = r.fsResultToResponse(req.ID, out)
|
|
default:
|
|
resp = NewResponseErr(req.ID, JSONRPCMethodNotFound, "method not implemented: "+req.Method)
|
|
}
|
|
select {
|
|
case r.serverInitiated <- resp:
|
|
case <-ctx.Done():
|
|
}
|
|
}
|
|
|
|
func (r *Relay) fsResultToResponse(id json.RawMessage, res handlers.FsResult) *Message {
|
|
if res.Err != nil {
|
|
return NewResponseErr(id, res.Err.Code, res.Err.Message)
|
|
}
|
|
return &Message{JSONRPC: "2.0", ID: id, Result: res.OK}
|
|
}
|
|
|
|
func (r *Relay) writer(ctx context.Context) {
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-r.done:
|
|
return
|
|
case msg := <-r.serverInitiated:
|
|
r.persistAndEnvelope(ctx, msg, "in")
|
|
if err := r.enc.Encode(msg); err != nil {
|
|
r.log.Error("acp.relay.encode", "session_id", r.sessionID, "err", err.Error())
|
|
return
|
|
}
|
|
case msg := <-r.clientInitiated:
|
|
r.persistAndEnvelope(ctx, msg, "in")
|
|
if err := r.enc.Encode(msg); err != nil {
|
|
r.log.Error("acp.relay.encode", "session_id", r.sessionID, "err", err.Error())
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// SendClient is the WS handler entry point for forwarding prompt/cancel from a tab.
|
|
func (r *Relay) SendClient(msg *Message) error {
|
|
if r.closed.Load() {
|
|
return fmt.Errorf("relay closed")
|
|
}
|
|
select {
|
|
case r.clientInitiated <- msg:
|
|
return nil
|
|
default:
|
|
return fmt.Errorf("relay client channel full")
|
|
}
|
|
}
|
|
|
|
// Call performs a server-initiated request and waits for the response (e.g.
|
|
// initialize / session/new). Used by supervisor during handshake.
|
|
func (r *Relay) Call(ctx context.Context, method string, params any) (*Message, error) {
|
|
id := r.nextID.Add(1)
|
|
req, err := NewRequestInt(id, method, params)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ch := make(chan *Message, 1)
|
|
r.inflight.Store(id, ch)
|
|
defer r.inflight.Delete(id)
|
|
|
|
select {
|
|
case r.serverInitiated <- req:
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
}
|
|
|
|
select {
|
|
case resp := <-ch:
|
|
if resp.Error != nil {
|
|
return resp, resp.Error
|
|
}
|
|
return resp, nil
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
}
|
|
}
|
|
|
|
// persistAndEnvelope writes one event to the repo and returns the envelope for
|
|
// fanout. Returns nil if the write fails (logged).
|
|
func (r *Relay) persistAndEnvelope(ctx context.Context, msg *Message, direction string) *EventEnvelope {
|
|
rpcKind := classifyKind(msg)
|
|
method := msg.Method
|
|
payload, _ := json.Marshal(msg)
|
|
|
|
origSize := len(payload)
|
|
truncated := false
|
|
if origSize > r.cfg.EventMaxPayload {
|
|
payload, truncated = truncateLargeFields(msg, r.cfg.EventTruncateField, r.cfg.EventMaxPayload)
|
|
}
|
|
|
|
ev, err := r.repo.InsertEvent(ctx, &Event{
|
|
SessionID: r.sessionID,
|
|
Direction: EventDirection(direction),
|
|
RPCKind: RPCKind(rpcKind),
|
|
Method: ptrIfNotEmpty(method),
|
|
Payload: payload,
|
|
PayloadSize: int32(origSize),
|
|
Truncated: truncated,
|
|
})
|
|
if err != nil {
|
|
r.log.Error("acp.relay.persist_event", "session_id", r.sessionID, "err", err.Error())
|
|
return nil
|
|
}
|
|
return &EventEnvelope{
|
|
ID: ev.ID,
|
|
Direction: direction,
|
|
Kind: rpcKind,
|
|
Method: method,
|
|
Payload: payload,
|
|
Truncated: truncated,
|
|
}
|
|
}
|
|
|
|
func classifyKind(m *Message) string {
|
|
switch {
|
|
case m.IsRequest():
|
|
return string(RPCRequest)
|
|
case m.IsNotification():
|
|
return string(RPCNotification)
|
|
case m.IsResponse():
|
|
if m.Error != nil {
|
|
return string(RPCError)
|
|
}
|
|
return string(RPCResponse)
|
|
}
|
|
return "unknown"
|
|
}
|
|
|
|
func ptrIfNotEmpty(s string) *string {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
return &s
|
|
}
|
|
|
|
// truncateLargeFields replaces an oversize payload with a placeholder summary.
|
|
// Spec §3.5. MVP: full-payload truncate (don't try to be clever about which
|
|
// field is large — agents may put data in many places).
|
|
func truncateLargeFields(m *Message, fieldMax, totalMax int) ([]byte, bool) {
|
|
original, _ := json.Marshal(m)
|
|
if len(original) <= totalMax {
|
|
return original, false
|
|
}
|
|
hash := simpleSHA256Hex(original)
|
|
placeholder := fmt.Sprintf("%s... [TRUNCATED size=%d sha256=%s]",
|
|
string(original[:fieldMax]), len(original), hash)
|
|
out, _ := json.Marshal(map[string]any{
|
|
"_truncated_payload": placeholder,
|
|
"_method": m.Method,
|
|
})
|
|
return out, true
|
|
}
|
|
|
|
func simpleSHA256Hex(b []byte) string {
|
|
h := sha256.Sum256(b)
|
|
return hex.EncodeToString(h[:])
|
|
}
|