Files
Agent-Squad/store/eventsink.go
T
weichenandCursor bfec3c8930
ci / vet-and-test (push) Failing after 3s
ci / docker-checkout-hostgateway (push) Failing after 3s
Import agent-squad as a standalone repository.
Pin Agent-Native as a submodule so the module builds without the multi-repo sibling path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-29 12:38:35 +00:00

117 lines
3.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package store
import (
"encoding/json"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"agent-squad/squad"
)
// FileEventSink 事件持久化装饰器:把引擎事件按行追加到
// runs/<run_id>/events.jsonl(每行带自增 seq,天然即回放顺序),
// 落盘前做秘密形状脱敏,然后转发给下游(FanoutSink → SSE)。
// 机制参考 Multica 的 (task_id, seq) 消息表 + 写前 redact,文件版实现。
type FileEventSink struct {
DataDir string
Next squad.ProgressSink
mu sync.Mutex
files map[string]*os.File
seqs map[string]int64
}
// NewFileEventSink 创建事件落盘 sink。
func NewFileEventSink(dataDir string, next squad.ProgressSink) *FileEventSink {
return &FileEventSink{DataDir: dataDir, Next: next, files: map[string]*os.File{}, seqs: map[string]int64{}}
}
// Emit 落盘(脱敏)并转发。
func (s *FileEventSink) Emit(ev squad.Event) {
defer func() {
if s.Next != nil {
s.Next.Emit(ev)
}
}()
if ev.RunID == "" {
return
}
if ev.At.IsZero() {
ev.At = time.Now()
}
s.mu.Lock()
defer s.mu.Unlock()
f, ok := s.files[ev.RunID]
if !ok {
dir := filepath.Join(s.DataDir, "runs", ev.RunID)
if err := os.MkdirAll(dir, 0o755); err != nil {
return
}
fp, err := os.OpenFile(filepath.Join(dir, "events.jsonl"),
os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return
}
f = fp
s.files[ev.RunID] = f
}
s.seqs[ev.RunID]++
line := persistedEvent{Seq: s.seqs[ev.RunID], Type: ev.Type, StepID: ev.StepID,
Role: ev.Role, Text: Redact(ev.Text), At: ev.At}
if len(ev.Data) > 0 {
line.Data = json.RawMessage(Redact(string(ev.Data)))
}
data, err := json.Marshal(line)
if err != nil {
return
}
_, _ = f.Write(append(data, '\n'))
}
// Close 关闭全部文件句柄。
func (s *FileEventSink) Close() {
s.mu.Lock()
defer s.mu.Unlock()
for _, f := range s.files {
_ = f.Close()
}
s.files = map[string]*os.File{}
}
type persistedEvent struct {
Seq int64 `json:"seq"`
Type string `json:"type"`
StepID string `json:"step_id,omitempty"`
Role string `json:"role,omitempty"`
Text string `json:"text,omitempty"`
Data json.RawMessage `json:"data,omitempty"`
At time.Time `json:"at"`
}
// secret 形状:API key(sk-/api key 风格长串)与 Bearer 凭证。
var (
reSKKey = regexp.MustCompile(`(?:sk|rk|pk)-[A-Za-z0-9_\-]{12,}`)
reBearer = regexp.MustCompile(`(?i)bearer\s+[A-Za-z0-9._\-]{12,}`)
reAPIKey = regexp.MustCompile(`(?i)(api[_-]?key["':=\s]+)[A-Za-z0-9_\-\.]{12,}`)
reJWT = regexp.MustCompile(`eyJ[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}\.[A-Za-z0-9_\-]{8,}`)
)
// Redact 脱敏:把秘密形状子串替换为 ***(宁可多脱,不漏一个)。
func Redact(s string) string {
if s == "" {
return s
}
s = reJWT.ReplaceAllString(s, "***")
s = reSKKey.ReplaceAllString(s, "***")
s = reBearer.ReplaceAllString(s, "Bearer ***")
s = reAPIKey.ReplaceAllString(s, "${1}***")
if strings.Contains(strings.ToLower(s), "password") {
s = regexp.MustCompile(`(?i)(password["':=\s]+)[^\s"',}]+`).ReplaceAllString(s, "${1}***")
}
return s
}