iop/apps/edge/internal/node/store.go
toki dc9a9a8c59 feat(agent): 단일 요청 Agent 실행 경계를 구현한다
승인된 execution preset을 Edge 조정 경계와 Node workspace/tool 실행 경계로 연결해 단일 요청 수명주기와 관측 계약을 일관되게 처리한다.
2026-08-07 07:03:55 +09:00

206 lines
6.1 KiB
Go

package node
import (
"fmt"
"sort"
"strings"
"sync"
"github.com/google/uuid"
"iop/packages/go/config"
)
// NodeRecord is the pre-registered node definition stored in edge.
// Workspaces is compiled immutably from config at LoadFromConfig time and
// carried through the store. Runtime mutation of workspace definitions is
// restart-required; the store returns deep copies so callers cannot affect
// the stored catalog.
type NodeRecord struct {
ID string
Alias string
Token string
Index int
Adapters config.AdaptersConf
Providers []config.NodeProviderConf
Runtime config.RuntimeConf
Workspaces []config.WorkspaceDefinition
}
// NodeStore holds pre-registered node definitions, keyed by token.
type NodeStore struct {
mu sync.RWMutex
byToken map[string]*NodeRecord
byID map[string]*NodeRecord
}
func NewNodeStore() *NodeStore {
return &NodeStore{
byToken: make(map[string]*NodeRecord),
byID: make(map[string]*NodeRecord),
}
}
func (s *NodeStore) Add(rec *NodeRecord) {
s.mu.Lock()
defer s.mu.Unlock()
s.byToken[rec.Token] = rec
s.byID[rec.ID] = rec
}
func (s *NodeStore) FindByToken(token string) (*NodeRecord, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
r, ok := s.byToken[token]
return r, ok
}
func (s *NodeStore) FindByID(id string) (*NodeRecord, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
r, ok := s.byID[id]
return r, ok
}
// All returns all configured NodeRecords in deterministic, ascending Index order.
// Callers (e.g. status snapshot builder) rely on this ordering so the surface
// exposed to CLI/HTTP/Control Plane is reproducible across calls.
func (s *NodeStore) All() []*NodeRecord {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]*NodeRecord, 0, len(s.byID))
for _, r := range s.byID {
out = append(out, r)
}
sort.Slice(out, func(i, j int) bool {
return out[i].Index < out[j].Index
})
return out
}
// ResolveWorkspace returns the NodeRecord and deep-copied WorkspaceDefinition
// for the given ref, or an error if no matching workspace is found. The
// returned WorkspaceDefinition is a deep copy so callers cannot mutate the
// store's immutable catalog. Exactly one workspace across all nodes must
// match the ref; this is enforced at load time.
func (s *NodeStore) ResolveWorkspace(ref string) (*NodeRecord, config.WorkspaceDefinition, error) {
s.mu.RLock()
defer s.mu.RUnlock()
for _, rec := range s.byID {
for _, ws := range rec.Workspaces {
if ws.Ref == ref {
return cloneWorkspaceOwner(rec), cloneWorkspaceDefinition(ws), nil
}
}
}
return nil, config.WorkspaceDefinition{}, fmt.Errorf("workspace ref %q not found in any node", ref)
}
// LoadFromConfig seeds the store from EdgeConfig.Nodes.
func LoadFromConfig(defs []config.NodeDefinition) (*NodeStore, error) {
s := NewNodeStore()
seenToken := make(map[string]bool)
seenAlias := make(map[string]bool)
seenID := make(map[string]bool)
seenWorkspaceRef := make(map[string]struct{})
for i, d := range defs {
if d.Token == "" {
return nil, fmt.Errorf("node[%d] alias=%q: token must not be empty", i, d.Alias)
}
if seenToken[d.Token] {
return nil, fmt.Errorf("node[%d] alias=%q: duplicate token", i, d.Alias)
}
if seenAlias[d.Alias] {
return nil, fmt.Errorf("node[%d] alias=%q: duplicate alias", i, d.Alias)
}
if d.ID != "" && seenID[d.ID] {
return nil, fmt.Errorf("node[%d] alias=%q: duplicate id %q", i, d.Alias, d.ID)
}
seenToken[d.Token] = true
seenAlias[d.Alias] = true
nodeID := d.ID
if nodeID == "" {
nodeID = uuid.NewString()
} else {
seenID[d.ID] = true
}
adapters := d.Adapters
if err := config.NormalizeAdapters(&adapters); err != nil {
return nil, fmt.Errorf("node[%d] alias=%q: adapters: %w", i, d.Alias, err)
}
workspaces, err := cloneWorkspaceCatalog(d.Workspaces, seenWorkspaceRef, i)
if err != nil {
return nil, err
}
s.Add(&NodeRecord{
ID: nodeID,
Alias: d.Alias,
Token: d.Token,
Index: i,
Adapters: adapters,
Providers: d.Providers,
Runtime: d.Runtime,
Workspaces: workspaces,
})
}
return s, nil
}
func cloneWorkspaceOwner(rec *NodeRecord) *NodeRecord {
owner := *rec
owner.Workspaces = cloneWorkspaceCatalogUnchecked(rec.Workspaces)
return &owner
}
func cloneWorkspaceCatalog(defs []config.WorkspaceDefinition, seenRefs map[string]struct{}, nodeIndex int) ([]config.WorkspaceDefinition, error) {
if len(defs) == 0 {
return nil, nil
}
workspaces := make([]config.WorkspaceDefinition, len(defs))
for workspaceIndex, ws := range defs {
ws.Ref = strings.TrimSpace(ws.Ref)
if ws.Ref == "" {
return nil, fmt.Errorf("node[%d].workspaces[%d]: ref must not be empty after trim", nodeIndex, workspaceIndex)
}
if _, duplicate := seenRefs[ws.Ref]; duplicate {
return nil, fmt.Errorf("node[%d].workspaces[%d]: duplicate workspace ref %q", nodeIndex, workspaceIndex, ws.Ref)
}
seenRefs[ws.Ref] = struct{}{}
workspaces[workspaceIndex] = cloneWorkspaceDefinition(ws)
}
return workspaces, nil
}
func cloneWorkspaceCatalogUnchecked(defs []config.WorkspaceDefinition) []config.WorkspaceDefinition {
if len(defs) == 0 {
return nil
}
workspaces := make([]config.WorkspaceDefinition, len(defs))
for i, ws := range defs {
workspaces[i] = cloneWorkspaceDefinition(ws)
}
return workspaces
}
func cloneWorkspaceDefinition(ws config.WorkspaceDefinition) config.WorkspaceDefinition {
cp := config.WorkspaceDefinition{
Ref: ws.Ref,
Platform: ws.Platform,
Root: ws.Root,
MaxReadBytes: ws.MaxReadBytes,
MaxWriteBytes: ws.MaxWriteBytes,
MaxOutputBytes: ws.MaxOutputBytes,
MaxCommandTimeoutMS: ws.MaxCommandTimeoutMS,
}
cp.Operations = append([]config.WorkspaceOperation(nil), ws.Operations...)
cp.EnvironmentAllowlist = append([]string(nil), ws.EnvironmentAllowlist...)
cp.Commands = make([]config.WorkspaceCommandDefinition, len(ws.Commands))
for i, command := range ws.Commands {
cp.Commands[i] = config.WorkspaceCommandDefinition{
ID: command.ID,
Executable: command.Executable,
Args: append([]string(nil), command.Args...),
}
}
return cp
}