Why: A step confirmation cancelled the whole run, approvals and repair budget vanished on restart, and the HTTP server accepted anonymous and oversized requests. Evidence: GOWORK=off go vet ./... && GOWORK=off go test -count=1 ./... && GOWORK=off go test -race ./squad/... passed. scripts/submodule-consistency.sh ok. Impact: Approve and skip apply to one step; runstate.json and unix flock survive restart; wakeup leaves permission holds pending; serve defaults to loopback and requires a token off-loopback. Refs: Q1 Q2 Q3 Q4 Q5 Q8 Q9 Q14 Q15 Q16 Co-authored-by: Cursor <cursoragent@cursor.com>
104 lines
2.4 KiB
Go
104 lines
2.4 KiB
Go
package squad
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// BlueprintRegistry 任务模板注册中心:空表起步。
|
|
// 命中模板的 Goal 走基线工作流,未命中走规划器自主拆解。
|
|
type BlueprintRegistry struct {
|
|
mu sync.RWMutex
|
|
bps map[string]Blueprint
|
|
// OnChange 可选钩子:注册/删除后回调(store 层用于落盘)。
|
|
OnChange func(bp Blueprint) error
|
|
}
|
|
|
|
// NewBlueprintRegistry 创建空注册表。
|
|
func NewBlueprintRegistry() *BlueprintRegistry {
|
|
return &BlueprintRegistry{bps: make(map[string]Blueprint)}
|
|
}
|
|
|
|
// Register 注册或更新模板。
|
|
func (r *BlueprintRegistry) Register(bp Blueprint) error {
|
|
if !ValidID(bp.Key) {
|
|
return invalidID(bp.Key)
|
|
}
|
|
if len(bp.Steps) == 0 {
|
|
return fmt.Errorf("blueprint %s has no steps", bp.Key)
|
|
}
|
|
for i := range bp.Steps {
|
|
if bp.Steps[i].ID == "" {
|
|
bp.Steps[i].ID = fmt.Sprintf("step_%02d", i+1)
|
|
}
|
|
if bp.Steps[i].Targets == nil {
|
|
bp.Steps[i].Targets = []string{"output.md"}
|
|
}
|
|
}
|
|
r.mu.Lock()
|
|
r.bps[bp.Key] = bp
|
|
r.mu.Unlock()
|
|
if r.OnChange != nil {
|
|
if err := r.OnChange(bp); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Get 按 Key 获取模板。
|
|
func (r *BlueprintRegistry) Get(key string) (Blueprint, bool) {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
bp, ok := r.bps[key]
|
|
return bp, ok
|
|
}
|
|
|
|
// List 列出全部模板(按 Key 排序)。
|
|
func (r *BlueprintRegistry) List() []Blueprint {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
out := make([]Blueprint, 0, len(r.bps))
|
|
for _, bp := range r.bps {
|
|
out = append(out, bp)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
|
|
return out
|
|
}
|
|
|
|
// Remove 删除模板。
|
|
func (r *BlueprintRegistry) Remove(key string) bool {
|
|
r.mu.Lock()
|
|
_, ok := r.bps[key]
|
|
if ok {
|
|
delete(r.bps, key)
|
|
}
|
|
r.mu.Unlock()
|
|
return ok
|
|
}
|
|
|
|
// Match 按目标文本匹配模板:命中 Match 关键词数最多者;无命中返回 false。
|
|
// 显式 taskKey 非空时直接按 Key 精确查找。
|
|
func (r *BlueprintRegistry) Match(goalText, taskKey string) (Blueprint, bool) {
|
|
if taskKey != "" {
|
|
return r.Get(taskKey)
|
|
}
|
|
q := strings.ToLower(goalText)
|
|
var best Blueprint
|
|
bestScore := 0
|
|
for _, bp := range r.List() {
|
|
score := 0
|
|
for _, kw := range bp.Match {
|
|
if kw != "" && strings.Contains(q, strings.ToLower(kw)) {
|
|
score++
|
|
}
|
|
}
|
|
if score > bestScore {
|
|
best, bestScore = bp, score
|
|
}
|
|
}
|
|
return best, bestScore > 0
|
|
}
|