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) }