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>
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-squad/squad"
|
||||
)
|
||||
|
||||
func TestFileEventSinkPersistAndRedact(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
var got []squad.Event
|
||||
next := squad.ProgressFunc(func(ev squad.Event) { got = append(got, ev) })
|
||||
sink := NewFileEventSink(dir, next)
|
||||
defer sink.Close()
|
||||
|
||||
sink.Emit(squad.Event{Type: squad.EvRunStarted, RunID: "run_x", Text: "开始 sk-api-8abcdefgh1234567890"})
|
||||
sink.Emit(squad.Event{Type: squad.EvDetail, RunID: "run_x", StepID: "s1",
|
||||
Text: "Bearer abcdefghijklmnopqrst",
|
||||
Data: json.RawMessage(`{"note":"api_key=abcdefghijklmnopqrstuvwxyz123456"}`)})
|
||||
sink.Emit(squad.Event{Type: "no_run"}) // 无 RunID:不落盘但转发
|
||||
|
||||
// 转发不丢。
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("forwarded events want 3, got %d", len(got))
|
||||
}
|
||||
// 落盘 2 行,带 seq。
|
||||
f, err := os.Open(filepath.Join(dir, "runs", "run_x", "events.jsonl"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
sc := bufio.NewScanner(f)
|
||||
var lines []persistedEvent
|
||||
for sc.Scan() {
|
||||
var pe persistedEvent
|
||||
if err := json.Unmarshal(sc.Bytes(), &pe); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines = append(lines, pe)
|
||||
}
|
||||
if len(lines) != 2 || lines[0].Seq != 1 || lines[1].Seq != 2 {
|
||||
t.Fatalf("seq/lines wrong: %+v", lines)
|
||||
}
|
||||
// 脱敏生效。
|
||||
if strings.Contains(lines[0].Text, "sk-api-8") || !strings.Contains(lines[0].Text, "***") {
|
||||
t.Fatalf("sk key not redacted: %q", lines[0].Text)
|
||||
}
|
||||
if strings.Contains(lines[1].Text, "abcdefghijklmnopqrst") {
|
||||
t.Fatalf("bearer not redacted: %q", lines[1].Text)
|
||||
}
|
||||
if strings.Contains(string(lines[1].Data), "abcdefghijklmnopqrstuvwxyz123456") {
|
||||
t.Fatalf("api key in data not redacted: %s", lines[1].Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRedactShapes(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"key sk-proj-1234567890abcdef": "key ***",
|
||||
"Authorization: Bearer abcdef1234567890abc": "Authorization: Bearer ***",
|
||||
`{"api_key":"abcdefghijklmnop12"}`: `{"api_key":"***"}`,
|
||||
"eyJhbGciOiJIU.eyJzdWIiOiJI.SflKxwRJSMeK": "***",
|
||||
`{"password":"hunter2secret"}`: `{"password":"***"}`,
|
||||
"普通文本不受影响 sk-short": "普通文本不受影响 sk-short",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := Redact(in); got != want {
|
||||
t.Errorf("Redact(%q)=%q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
_ = time.Now
|
||||
}
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
// Package store 提供 agent-squad 的文件持久化:
|
||||
// roles.d/*.json、blueprints.d/*.json;运行状态由引擎直接管理 runs/<id>/。
|
||||
package store
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"agent-squad/squad"
|
||||
)
|
||||
|
||||
// FileStore 数据目录级文件存储。
|
||||
type FileStore struct {
|
||||
DataDir string
|
||||
}
|
||||
|
||||
// New 创建文件存储并确保目录存在。
|
||||
func New(dataDir string) (*FileStore, error) {
|
||||
s := &FileStore{DataDir: dataDir}
|
||||
for _, d := range []string{filepath.Join(dataDir, "roles.d"), filepath.Join(dataDir, "blueprints.d")} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// LoadRoles 加载 roles.d/*.json 到注册表,并挂接增量落盘钩子。
|
||||
// 文件名(去扩展名)不作为权威,以 JSON 内 role 字段为准。
|
||||
func (s *FileStore) LoadRoles(reg *squad.RoleRegistry) error {
|
||||
entries, err := os.ReadDir(filepath.Join(s.DataDir, "roles.d"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, ent := range entries {
|
||||
if ent.IsDir() || (!strings.HasSuffix(ent.Name(), ".json")) {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(s.DataDir, "roles.d", ent.Name()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var spec squad.RoleSpec
|
||||
if err := json.Unmarshal(data, &spec); err != nil {
|
||||
return fmt.Errorf("roles.d/%s: %w", ent.Name(), err)
|
||||
}
|
||||
if err := reg.Register(spec); err != nil {
|
||||
return fmt.Errorf("roles.d/%s: %w", ent.Name(), err)
|
||||
}
|
||||
}
|
||||
reg.OnChange = s.SaveRole
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveRole 落盘单个角色。
|
||||
func (s *FileStore) SaveRole(spec squad.RoleSpec) {
|
||||
data, err := json.MarshalIndent(spec, "", " ")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.WriteFile(filepath.Join(s.DataDir, "roles.d", spec.Role+".json"), data, 0o644)
|
||||
}
|
||||
|
||||
// DeleteRole 删除角色文件(注册表删除由调用方完成)。
|
||||
func (s *FileStore) DeleteRole(key string) {
|
||||
_ = os.Remove(filepath.Join(s.DataDir, "roles.d", key+".json"))
|
||||
}
|
||||
|
||||
// LoadBlueprints 加载 blueprints.d/*.json 并挂接落盘钩子。
|
||||
func (s *FileStore) LoadBlueprints(bps *squad.BlueprintRegistry) error {
|
||||
entries, err := os.ReadDir(filepath.Join(s.DataDir, "blueprints.d"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, ent := range entries {
|
||||
if ent.IsDir() || !strings.HasSuffix(ent.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(s.DataDir, "blueprints.d", ent.Name()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var bp squad.Blueprint
|
||||
if err := json.Unmarshal(data, &bp); err != nil {
|
||||
return fmt.Errorf("blueprints.d/%s: %w", ent.Name(), err)
|
||||
}
|
||||
if err := bps.Register(bp); err != nil {
|
||||
return fmt.Errorf("blueprints.d/%s: %w", ent.Name(), err)
|
||||
}
|
||||
}
|
||||
bps.OnChange = s.SaveBlueprint
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveBlueprint 落盘单个蓝图。
|
||||
func (s *FileStore) SaveBlueprint(bp squad.Blueprint) {
|
||||
data, err := json.MarshalIndent(bp, "", " ")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.WriteFile(filepath.Join(s.DataDir, "blueprints.d", bp.Key+".json"), data, 0o644)
|
||||
}
|
||||
|
||||
// DeleteBlueprint 删除蓝图文件。
|
||||
func (s *FileStore) DeleteBlueprint(key string) {
|
||||
_ = os.Remove(filepath.Join(s.DataDir, "blueprints.d", key+".json"))
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"agent-squad/squad"
|
||||
)
|
||||
|
||||
func TestRolesPersistAcrossReload(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, err := New(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg1 := squad.NewRoleRegistry()
|
||||
if err := st.LoadRoles(reg1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := reg1.Register(squad.RoleSpec{Role: "researcher", Backend: "stub", Keywords: []string{"调研"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// OnChange 落盘。
|
||||
if _, err := os.Stat(filepath.Join(dir, "roles.d", "researcher.json")); err != nil {
|
||||
t.Fatalf("role file missing: %v", err)
|
||||
}
|
||||
// 新注册表重载:角色可恢复。
|
||||
reg2 := squad.NewRoleRegistry()
|
||||
st2, _ := New(dir)
|
||||
if err := st2.LoadRoles(reg2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if spec, ok := reg2.Get("researcher"); !ok || spec.Backend != "stub" {
|
||||
t.Fatalf("role not reloaded: %+v ok=%v", spec, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlueprintsPersistAcrossReload(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, _ := New(dir)
|
||||
bps1 := squad.NewBlueprintRegistry()
|
||||
if err := st.LoadBlueprints(bps1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := bps1.Register(squad.Blueprint{
|
||||
Key: "report", Title: "报告",
|
||||
Steps: []squad.StepSpec{{ID: "s1", Role: "writer", Title: "成文"}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st2, _ := New(dir)
|
||||
bps2 := squad.NewBlueprintRegistry()
|
||||
if err := st2.LoadBlueprints(bps2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bp, ok := bps2.Get("report"); !ok || len(bp.Steps) != 1 {
|
||||
t.Fatalf("blueprint not reloaded: %+v ok=%v", bp, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRolesRejectsInvalidFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, _ := New(dir)
|
||||
if err := os.WriteFile(filepath.Join(dir, "roles.d", "bad.json"), []byte(`{"role":""}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg := squad.NewRoleRegistry()
|
||||
if err := st.LoadRoles(reg); err == nil {
|
||||
t.Fatal("invalid role file must fail load")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user