You've already forked agentic-coding-workflow
215 lines
5.2 KiB
Go
215 lines
5.2 KiB
Go
package run
|
|
|
|
import (
|
|
"log/slog"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// LogEnvelope 是推到 WS / 经日志 tail 返回的一行日志或一个控制帧。
|
|
// - kind="log":普通日志行(level=info 来自 stdout,level=error 来自 stderr)
|
|
// - kind="state":运行态变化(Status/ExitCode 有意义)
|
|
// - kind="slow_consumer_disconnect":慢消费者被动断开的控制帧
|
|
type LogEnvelope struct {
|
|
ID int64 `json:"id"`
|
|
Kind string `json:"kind"`
|
|
Level string `json:"level,omitempty"`
|
|
Text string `json:"text,omitempty"`
|
|
TS time.Time `json:"ts"`
|
|
Status string `json:"status,omitempty"`
|
|
ExitCode *int32 `json:"exit_code,omitempty"`
|
|
}
|
|
|
|
// Subscriber 是单个 WS 连接的订阅句柄。
|
|
type Subscriber struct {
|
|
ID uuid.UUID
|
|
UserID uuid.UUID
|
|
Send chan *LogEnvelope
|
|
closed atomic.Bool
|
|
}
|
|
|
|
// outRing 是固定行数的环形缓冲:满则淘汰最旧行。承载某次运行的合并 stdout+stderr
|
|
// 日志,既供 logs(tail/since) 查询,也供 onExit 取尾部写 last_error。
|
|
type outRing struct {
|
|
mu sync.Mutex
|
|
lines []*LogEnvelope
|
|
cap int
|
|
}
|
|
|
|
func newOutRing(capacity int) *outRing {
|
|
if capacity <= 0 {
|
|
capacity = 1000
|
|
}
|
|
return &outRing{cap: capacity}
|
|
}
|
|
|
|
func (r *outRing) append(e *LogEnvelope) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if len(r.lines) >= r.cap {
|
|
r.lines = r.lines[1:]
|
|
}
|
|
r.lines = append(r.lines, e)
|
|
}
|
|
|
|
// since 返回 ID > sinceID 的日志行(最多 limit 条;limit<=0 表示不限)。
|
|
func (r *outRing) since(sinceID int64, limit int) []*LogEnvelope {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
out := make([]*LogEnvelope, 0, len(r.lines))
|
|
for _, e := range r.lines {
|
|
if e.ID > sinceID {
|
|
out = append(out, e)
|
|
}
|
|
}
|
|
if limit > 0 && len(out) > limit {
|
|
out = out[len(out)-limit:]
|
|
}
|
|
return out
|
|
}
|
|
|
|
// tailText 反向重建尾部文本(不超过 maxBytes),供 onExit 写 last_error。
|
|
func (r *outRing) tailText(maxBytes int) string {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
out := ""
|
|
for i := len(r.lines) - 1; i >= 0 && len(out) < maxBytes; i-- {
|
|
out = r.lines[i].Text + "\n" + out
|
|
}
|
|
if len(out) > maxBytes {
|
|
out = out[len(out)-maxBytes:]
|
|
}
|
|
return out
|
|
}
|
|
|
|
// runRelay 是单次运行(一个 RunProc)的日志中继:合并 stdout+stderr 进环形
|
|
// 缓冲并向所有 WS 订阅者 fanout。结构参照 acp/relay.go,但只做单向日志广播,
|
|
// 无 JSON-RPC 双向中继。
|
|
type runRelay struct {
|
|
profileID uuid.UUID
|
|
ring *outRing
|
|
nextID atomic.Int64
|
|
|
|
subsMu sync.Mutex
|
|
subs map[uuid.UUID]*Subscriber
|
|
sendBuf int
|
|
|
|
closed atomic.Bool
|
|
done chan struct{}
|
|
log *slog.Logger
|
|
}
|
|
|
|
func newRunRelay(profileID uuid.UUID, bufLines, sendBuf int, log *slog.Logger) *runRelay {
|
|
if log == nil {
|
|
log = slog.Default()
|
|
}
|
|
if sendBuf <= 0 {
|
|
sendBuf = 256
|
|
}
|
|
return &runRelay{
|
|
profileID: profileID,
|
|
ring: newOutRing(bufLines),
|
|
subs: map[uuid.UUID]*Subscriber{},
|
|
sendBuf: sendBuf,
|
|
done: make(chan struct{}),
|
|
log: log,
|
|
}
|
|
}
|
|
|
|
// emit 记录一行日志(落环形缓冲 + fanout)。drainPipe goroutine 调用。
|
|
func (r *runRelay) emit(level, text string) {
|
|
env := &LogEnvelope{
|
|
ID: r.nextID.Add(1),
|
|
Kind: "log",
|
|
Level: level,
|
|
Text: text,
|
|
TS: time.Now(),
|
|
}
|
|
r.ring.append(env)
|
|
r.fanout(env)
|
|
}
|
|
|
|
// Subscribe 注册一个 WS 订阅。
|
|
func (r *runRelay) Subscribe(userID uuid.UUID) *Subscriber {
|
|
sub := &Subscriber{
|
|
ID: uuid.New(),
|
|
UserID: userID,
|
|
Send: make(chan *LogEnvelope, r.sendBuf),
|
|
}
|
|
r.subsMu.Lock()
|
|
r.subs[sub.ID] = sub
|
|
r.subsMu.Unlock()
|
|
return sub
|
|
}
|
|
|
|
// Unsubscribe 移除订阅;幂等。
|
|
func (r *runRelay) 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 推给所有订阅者;慢消费者(Send 满)自动断开,
|
|
// 不阻塞其他订阅者(同 acp relay 约定)。
|
|
func (r *runRelay) fanout(env *LogEnvelope) {
|
|
r.subsMu.Lock()
|
|
defer r.subsMu.Unlock()
|
|
for id, sub := range r.subs {
|
|
select {
|
|
case sub.Send <- env:
|
|
default:
|
|
if sub.closed.CompareAndSwap(false, true) {
|
|
select {
|
|
case sub.Send <- &LogEnvelope{Kind: "slow_consumer_disconnect", TS: time.Now()}:
|
|
default:
|
|
}
|
|
close(sub.Send)
|
|
delete(r.subs, id)
|
|
r.log.Warn("run.relay.slow_subscriber_dropped",
|
|
"profile_id", r.profileID, "sub_id", id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// since 返回 ID > sinceID 的历史日志行(供 logs tail / WS 续传)。
|
|
func (r *runRelay) since(sinceID int64, limit int) []*LogEnvelope {
|
|
return r.ring.since(sinceID, limit)
|
|
}
|
|
|
|
// tailText 返回尾部文本(供 onExit 写 last_error)。
|
|
func (r *runRelay) tailText(maxBytes int) string {
|
|
return r.ring.tailText(maxBytes)
|
|
}
|
|
|
|
// Close 广播终止 state 帧后关闭所有订阅。幂等。
|
|
func (r *runRelay) Close(status string, exitCode *int32) {
|
|
if !r.closed.CompareAndSwap(false, true) {
|
|
return
|
|
}
|
|
r.fanout(&LogEnvelope{
|
|
ID: r.nextID.Add(1),
|
|
Kind: "state",
|
|
Status: status,
|
|
ExitCode: exitCode,
|
|
TS: time.Now(),
|
|
})
|
|
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)
|
|
}
|