Release prep: 54 engines, self-hosted signatures, i18n, dashboard updates

This commit is contained in:
DmitrL-dev 2026-03-23 16:45:40 +10:00
parent 694e32be26
commit 41cbfd6e0a
178 changed files with 36008 additions and 399 deletions

View file

@ -0,0 +1,117 @@
// Package identity implements Non-Human Identity (NHI) for AI agents (SDD-003).
//
// Each agent has a unique AgentIdentity with capabilities (tool permissions),
// constraints, and a delegation chain showing trust ancestry.
package identity
import "time"
// AgentType classifies the autonomy level of an agent.
type AgentType string
const (
AgentAutonomous AgentType = "AUTONOMOUS" // Self-directed, no human in loop
AgentSupervised AgentType = "SUPERVISED" // Human-in-the-loop for critical decisions
AgentExternal AgentType = "EXTERNAL" // Third-party agent, minimal trust
)
// Permission represents an operation type for tool access control.
type Permission string
const (
PermRead Permission = "READ"
PermWrite Permission = "WRITE"
PermExecute Permission = "EXECUTE"
PermSend Permission = "SEND"
)
// AgentIdentity represents a Non-Human Identity (NHI) for an AI agent.
type AgentIdentity struct {
AgentID string `json:"agent_id"`
AgentName string `json:"agent_name"`
AgentType AgentType `json:"agent_type"`
CreatedBy string `json:"created_by"` // Human principal who deployed
DelegationChain []DelegationLink `json:"delegation_chain"` // Trust ancestry chain
Capabilities []ToolPermission `json:"capabilities"` // Per-tool allowlists
Constraints AgentConstraints `json:"constraints"` // Operational limits
Tags map[string]string `json:"tags,omitempty"` // Arbitrary metadata
CreatedAt time.Time `json:"created_at"`
LastSeenAt time.Time `json:"last_seen_at"`
}
// DelegationLink records one step in the trust delegation chain.
type DelegationLink struct {
DelegatorID string `json:"delegator_id"` // Who delegated
DelegatorType string `json:"delegator_type"` // "human" | "agent"
Scope string `json:"scope"` // What was delegated
GrantedAt time.Time `json:"granted_at"`
}
// ToolPermission defines what an agent is allowed to do with a specific tool.
type ToolPermission struct {
ToolName string `json:"tool_name"`
Permissions []Permission `json:"permissions"`
}
// AgentConstraints defines operational limits for an agent.
type AgentConstraints struct {
MaxTokensPerTurn int `json:"max_tokens_per_turn,omitempty"`
MaxToolCallsPerTurn int `json:"max_tool_calls_per_turn,omitempty"`
PIDetectionLevel string `json:"pi_detection_level"` // "strict" | "standard" | "relaxed"
AllowExternalComms bool `json:"allow_external_comms"`
}
// HasPermission checks if the agent has a specific permission for a specific tool.
// Returns false for unknown tools (fail-safe closed — SDD-003 M3).
func (a *AgentIdentity) HasPermission(toolName string, perm Permission) bool {
for _, cap := range a.Capabilities {
if cap.ToolName == toolName {
for _, p := range cap.Permissions {
if p == perm {
return true
}
}
return false // Tool known but permission not granted
}
}
return false // Unknown tool → DENY (fail-safe closed)
}
// HasTool returns true if the agent has ANY permission for the specified tool.
func (a *AgentIdentity) HasTool(toolName string) bool {
for _, cap := range a.Capabilities {
if cap.ToolName == toolName {
return len(cap.Permissions) > 0
}
}
return false
}
// ToolNames returns the list of all tools this agent has access to.
func (a *AgentIdentity) ToolNames() []string {
names := make([]string, 0, len(a.Capabilities))
for _, cap := range a.Capabilities {
names = append(names, cap.ToolName)
}
return names
}
// Validate checks required fields.
func (a *AgentIdentity) Validate() error {
if a.AgentID == "" {
return ErrMissingAgentID
}
if a.AgentName == "" {
return ErrMissingAgentName
}
if a.CreatedBy == "" {
return ErrMissingCreatedBy
}
switch a.AgentType {
case AgentAutonomous, AgentSupervised, AgentExternal:
// valid
default:
return ErrInvalidAgentType
}
return nil
}

View file

@ -0,0 +1,72 @@
package identity
// CapabilityDecision represents the result of a capability check.
type CapabilityDecision struct {
Allowed bool `json:"allowed"`
AgentID string `json:"agent_id"`
ToolName string `json:"tool_name"`
Reason string `json:"reason"`
}
// CapabilityChecker verifies agent permissions against the identity store.
// Integrates with DIP Oracle — called before tool execution.
type CapabilityChecker struct {
store *Store
}
// NewCapabilityChecker creates a capability checker backed by the identity store.
func NewCapabilityChecker(store *Store) *CapabilityChecker {
return &CapabilityChecker{store: store}
}
// Check verifies that the agent has the required permission for the tool.
// Returns DENY for: unknown agent, unknown tool, missing permission (fail-safe closed).
func (c *CapabilityChecker) Check(agentID, toolName string, perm Permission) CapabilityDecision {
agent, err := c.store.Get(agentID)
if err != nil {
return CapabilityDecision{
Allowed: false,
AgentID: agentID,
ToolName: toolName,
Reason: "agent_not_found",
}
}
if !agent.HasPermission(toolName, perm) {
// Determine specific denial reason
reason := "unknown_tool_for_agent"
if agent.HasTool(toolName) {
reason = "insufficient_permissions"
}
return CapabilityDecision{
Allowed: false,
AgentID: agentID,
ToolName: toolName,
Reason: reason,
}
}
// Update last seen timestamp
_ = c.store.UpdateLastSeen(agentID)
return CapabilityDecision{
Allowed: true,
AgentID: agentID,
ToolName: toolName,
Reason: "allowed",
}
}
// CheckExternal verifies capability for an EXTERNAL agent type.
// External agents have additional restrictions: no EXECUTE permission ever.
func (c *CapabilityChecker) CheckExternal(agentID, toolName string, perm Permission) CapabilityDecision {
if perm == PermExecute {
return CapabilityDecision{
Allowed: false,
AgentID: agentID,
ToolName: toolName,
Reason: "external_agents_cannot_execute",
}
}
return c.Check(agentID, toolName, perm)
}

View file

@ -0,0 +1,13 @@
package identity
import "errors"
// Sentinel errors for identity operations.
var (
ErrMissingAgentID = errors.New("identity: agent_id is required")
ErrMissingAgentName = errors.New("identity: agent_name is required")
ErrMissingCreatedBy = errors.New("identity: created_by is required")
ErrInvalidAgentType = errors.New("identity: invalid agent_type (valid: AUTONOMOUS, SUPERVISED, EXTERNAL)")
ErrAgentNotFound = errors.New("identity: agent not found")
ErrAgentExists = errors.New("identity: agent already exists")
)

View file

@ -0,0 +1,395 @@
package identity
import (
"testing"
)
// === Agent Identity Tests ===
func TestAgentIdentityValidation(t *testing.T) {
tests := []struct {
name string
agent AgentIdentity
wantErr error
}{
{
"valid autonomous",
AgentIdentity{AgentID: "a1", AgentName: "Test", CreatedBy: "admin", AgentType: AgentAutonomous},
nil,
},
{
"valid supervised",
AgentIdentity{AgentID: "a2", AgentName: "Test", CreatedBy: "admin", AgentType: AgentSupervised},
nil,
},
{
"valid external",
AgentIdentity{AgentID: "a3", AgentName: "Test", CreatedBy: "admin", AgentType: AgentExternal},
nil,
},
{
"missing agent_id",
AgentIdentity{AgentName: "Test", CreatedBy: "admin", AgentType: AgentAutonomous},
ErrMissingAgentID,
},
{
"missing agent_name",
AgentIdentity{AgentID: "a1", CreatedBy: "admin", AgentType: AgentAutonomous},
ErrMissingAgentName,
},
{
"missing created_by",
AgentIdentity{AgentID: "a1", AgentName: "Test", AgentType: AgentAutonomous},
ErrMissingCreatedBy,
},
{
"invalid agent_type",
AgentIdentity{AgentID: "a1", AgentName: "Test", CreatedBy: "admin", AgentType: "INVALID"},
ErrInvalidAgentType,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.agent.Validate()
if err != tt.wantErr {
t.Errorf("Validate() = %v, want %v", err, tt.wantErr)
}
})
}
}
func TestHasPermissionFailSafeClosed(t *testing.T) {
agent := AgentIdentity{
Capabilities: []ToolPermission{
{ToolName: "web_search", Permissions: []Permission{PermRead}},
{ToolName: "memory_store", Permissions: []Permission{PermRead, PermWrite}},
},
}
// Allowed
if !agent.HasPermission("web_search", PermRead) {
t.Error("should allow READ on web_search")
}
if !agent.HasPermission("memory_store", PermWrite) {
t.Error("should allow WRITE on memory_store")
}
// Deny: wrong permission on known tool
if agent.HasPermission("web_search", PermWrite) {
t.Error("should deny WRITE on web_search (insufficient_permissions)")
}
// Deny: unknown tool (fail-safe closed — SDD-003 M3)
if agent.HasPermission("unknown_tool", PermRead) {
t.Error("should deny READ on unknown_tool (fail-safe closed)")
}
}
func TestHasTool(t *testing.T) {
agent := AgentIdentity{
Capabilities: []ToolPermission{
{ToolName: "web_search", Permissions: []Permission{PermRead}},
},
}
if !agent.HasTool("web_search") {
t.Error("should have web_search")
}
if agent.HasTool("unknown") {
t.Error("should not have unknown")
}
}
func TestToolNames(t *testing.T) {
agent := AgentIdentity{
Capabilities: []ToolPermission{
{ToolName: "a", Permissions: []Permission{PermRead}},
{ToolName: "b", Permissions: []Permission{PermWrite}},
},
}
names := agent.ToolNames()
if len(names) != 2 {
t.Fatalf("expected 2 tool names, got %d", len(names))
}
}
// === Store Tests ===
func TestStoreRegisterAndGet(t *testing.T) {
s := NewStore()
agent := &AgentIdentity{
AgentID: "agent-01",
AgentName: "Task Manager",
CreatedBy: "admin@xn--80akacl3adqr.xn--p1acf",
AgentType: AgentSupervised,
}
if err := s.Register(agent); err != nil {
t.Fatalf("Register failed: %v", err)
}
got, err := s.Get("agent-01")
if err != nil {
t.Fatalf("Get failed: %v", err)
}
if got.AgentName != "Task Manager" {
t.Errorf("got name %q, want %q", got.AgentName, "Task Manager")
}
}
func TestStoreNotFound(t *testing.T) {
s := NewStore()
_, err := s.Get("nonexistent")
if err != ErrAgentNotFound {
t.Errorf("expected ErrAgentNotFound, got %v", err)
}
}
func TestStoreDuplicateReject(t *testing.T) {
s := NewStore()
agent := &AgentIdentity{
AgentID: "dup-01", AgentName: "A", CreatedBy: "admin", AgentType: AgentAutonomous,
}
_ = s.Register(agent)
err := s.Register(agent)
if err != ErrAgentExists {
t.Errorf("expected ErrAgentExists, got %v", err)
}
}
func TestStoreRemove(t *testing.T) {
s := NewStore()
_ = s.Register(&AgentIdentity{
AgentID: "rm-01", AgentName: "A", CreatedBy: "admin", AgentType: AgentAutonomous,
})
if err := s.Remove("rm-01"); err != nil {
t.Fatalf("Remove failed: %v", err)
}
if s.Count() != 0 {
t.Error("expected 0 agents after removal")
}
}
func TestStoreList(t *testing.T) {
s := NewStore()
_ = s.Register(&AgentIdentity{AgentID: "l1", AgentName: "A", CreatedBy: "admin", AgentType: AgentAutonomous})
_ = s.Register(&AgentIdentity{AgentID: "l2", AgentName: "B", CreatedBy: "admin", AgentType: AgentSupervised})
if len(s.List()) != 2 {
t.Errorf("expected 2 agents, got %d", len(s.List()))
}
}
// === Capability Check Tests ===
func TestCapabilityAllowed(t *testing.T) {
s := NewStore()
_ = s.Register(&AgentIdentity{
AgentID: "cap-01", AgentName: "A", CreatedBy: "admin", AgentType: AgentAutonomous,
Capabilities: []ToolPermission{
{ToolName: "web_search", Permissions: []Permission{PermRead}},
},
})
checker := NewCapabilityChecker(s)
d := checker.Check("cap-01", "web_search", PermRead)
if !d.Allowed {
t.Errorf("expected allowed, got denied: %s", d.Reason)
}
}
func TestCapabilityDeniedUnknownAgent(t *testing.T) {
s := NewStore()
checker := NewCapabilityChecker(s)
d := checker.Check("ghost", "web_search", PermRead)
if d.Allowed {
t.Error("should deny unknown agent")
}
if d.Reason != "agent_not_found" {
t.Errorf("expected reason agent_not_found, got %s", d.Reason)
}
}
func TestCapabilityDeniedUnknownTool(t *testing.T) {
s := NewStore()
_ = s.Register(&AgentIdentity{
AgentID: "cap-02", AgentName: "A", CreatedBy: "admin", AgentType: AgentAutonomous,
Capabilities: []ToolPermission{
{ToolName: "web_search", Permissions: []Permission{PermRead}},
},
})
checker := NewCapabilityChecker(s)
d := checker.Check("cap-02", "unknown_tool", PermRead)
if d.Allowed {
t.Error("should deny unknown tool (fail-safe closed)")
}
if d.Reason != "unknown_tool_for_agent" {
t.Errorf("expected reason unknown_tool_for_agent, got %s", d.Reason)
}
}
func TestCapabilityDeniedInsufficientPerms(t *testing.T) {
s := NewStore()
_ = s.Register(&AgentIdentity{
AgentID: "cap-03", AgentName: "A", CreatedBy: "admin", AgentType: AgentAutonomous,
Capabilities: []ToolPermission{
{ToolName: "web_search", Permissions: []Permission{PermRead}},
},
})
checker := NewCapabilityChecker(s)
d := checker.Check("cap-03", "web_search", PermWrite)
if d.Allowed {
t.Error("should deny WRITE on READ-only tool")
}
if d.Reason != "insufficient_permissions" {
t.Errorf("expected reason insufficient_permissions, got %s", d.Reason)
}
}
func TestExternalAgentCannotExecute(t *testing.T) {
s := NewStore()
_ = s.Register(&AgentIdentity{
AgentID: "ext-01", AgentName: "External", CreatedBy: "admin", AgentType: AgentExternal,
Capabilities: []ToolPermission{
{ToolName: "web_search", Permissions: []Permission{PermRead, PermExecute}},
},
})
checker := NewCapabilityChecker(s)
d := checker.CheckExternal("ext-01", "web_search", PermExecute)
if d.Allowed {
t.Error("external agents should never get EXECUTE permission")
}
}
// === Namespaced Memory Tests ===
func TestNamespacedMemoryIsolation(t *testing.T) {
m := NewNamespacedMemory()
// Agent A stores a value
m.Store("agent-a", "secret", "classified-data")
// Agent A can read it
val, ok := m.Get("agent-a", "secret")
if !ok || val.(string) != "classified-data" {
t.Error("agent-a should be able to read its own data")
}
// Agent B CANNOT read Agent A's data
_, ok = m.Get("agent-b", "secret")
if ok {
t.Error("agent-b should NOT be able to read agent-a's data")
}
}
func TestNamespacedMemoryKeys(t *testing.T) {
m := NewNamespacedMemory()
m.Store("agent-a", "key1", "v1")
m.Store("agent-a", "key2", "v2")
m.Store("agent-b", "key3", "v3")
keysA := m.Keys("agent-a")
if len(keysA) != 2 {
t.Errorf("agent-a should have 2 keys, got %d", len(keysA))
}
keysB := m.Keys("agent-b")
if len(keysB) != 1 {
t.Errorf("agent-b should have 1 key, got %d", len(keysB))
}
}
func TestNamespacedMemoryCount(t *testing.T) {
m := NewNamespacedMemory()
m.Store("a", "k1", "v1")
m.Store("a", "k2", "v2")
m.Store("b", "k1", "v1")
if m.Count("a") != 2 {
t.Errorf("agent a should have 2 entries, got %d", m.Count("a"))
}
if m.Count("b") != 1 {
t.Errorf("agent b should have 1 entry, got %d", m.Count("b"))
}
}
func TestNamespacedMemoryDelete(t *testing.T) {
m := NewNamespacedMemory()
m.Store("a", "key", "val")
m.Delete("a", "key")
_, ok := m.Get("a", "key")
if ok {
t.Error("key should be deleted")
}
}
// === Context Pinning Tests ===
func TestSecurityEventsPinned(t *testing.T) {
messages := []Message{
{Role: "user", Content: "hello", TokenCount: 100},
{Role: "security", Content: "injection detected", TokenCount: 50, IsPinned: true, EventType: "injection_detected"},
{Role: "user", Content: "more chat", TokenCount: 100},
{Role: "security", Content: "permission denied", TokenCount: 50, IsPinned: true, EventType: "permission_denied"},
{Role: "user", Content: "latest chat", TokenCount: 100},
}
// Total = 400 tokens, budget = 200
trimmed := TrimContext(messages, 200)
// Both security events MUST survive
secCount := 0
for _, m := range trimmed {
if m.IsPinned {
secCount++
}
}
if secCount != 2 {
t.Errorf("expected 2 pinned security events to survive, got %d", secCount)
}
}
func TestNonSecurityEventsTrimmed(t *testing.T) {
messages := []Message{
{Role: "user", Content: "old msg 1", TokenCount: 100},
{Role: "user", Content: "old msg 2", TokenCount: 100},
{Role: "user", Content: "old msg 3", TokenCount: 100},
{Role: "security", Content: "pinned event", TokenCount: 50, IsPinned: true},
{Role: "user", Content: "newest msg", TokenCount: 100},
}
// Total = 450, budget = 200
// Pinned = 50, remaining budget = 150 → keep newest msg (100), not enough for old msgs
trimmed := TrimContext(messages, 200)
totalTokens := 0
for _, m := range trimmed {
totalTokens += m.TokenCount
}
if totalTokens > 200 {
t.Errorf("trimmed context exceeds budget: %d > 200", totalTokens)
}
}
func TestPinnedByEventType(t *testing.T) {
if !IsPinnedEvent("injection_detected") {
t.Error("injection_detected should be pinned")
}
if !IsPinnedEvent("credential_access_blocked") {
t.Error("credential_access_blocked should be pinned")
}
if !IsPinnedEvent("genai_credential_access") {
t.Error("genai_credential_access should be pinned")
}
if IsPinnedEvent("normal_chat") {
t.Error("normal_chat should NOT be pinned")
}
}
func TestTrimContextWithinBudget(t *testing.T) {
messages := []Message{
{Role: "user", Content: "hello", TokenCount: 50},
{Role: "assistant", Content: "hi", TokenCount: 50},
}
// Within budget — no trimming
trimmed := TrimContext(messages, 1000)
if len(trimmed) != 2 {
t.Errorf("expected 2 messages (within budget), got %d", len(trimmed))
}
}

View file

@ -0,0 +1,79 @@
package identity
import (
"fmt"
"strings"
"sync"
)
// NamespacedMemory wraps any key-value store with agent-level namespace isolation.
// Agent A cannot read/write/query Agent B's memory (SDD-003 M4).
type NamespacedMemory struct {
mu sync.RWMutex
entries map[string]interface{} // "agentID::key" → value
}
// NewNamespacedMemory creates a new namespaced memory store.
func NewNamespacedMemory() *NamespacedMemory {
return &NamespacedMemory{
entries: make(map[string]interface{}),
}
}
// namespacedKey creates the internal key: "agentID::userKey".
func namespacedKey(agentID, key string) string {
return fmt.Sprintf("%s::%s", agentID, key)
}
// Store stores a value within the agent's namespace.
func (n *NamespacedMemory) Store(agentID, key string, value interface{}) {
n.mu.Lock()
defer n.mu.Unlock()
n.entries[namespacedKey(agentID, key)] = value
}
// Get retrieves a value from the agent's own namespace.
// Returns nil, false if the key doesn't exist.
func (n *NamespacedMemory) Get(agentID, key string) (interface{}, bool) {
n.mu.RLock()
defer n.mu.RUnlock()
val, ok := n.entries[namespacedKey(agentID, key)]
return val, ok
}
// Delete removes a value from the agent's own namespace.
func (n *NamespacedMemory) Delete(agentID, key string) {
n.mu.Lock()
defer n.mu.Unlock()
delete(n.entries, namespacedKey(agentID, key))
}
// Keys returns all keys within the agent's namespace (without the namespace prefix).
func (n *NamespacedMemory) Keys(agentID string) []string {
n.mu.RLock()
defer n.mu.RUnlock()
prefix := agentID + "::"
var keys []string
for k := range n.entries {
if strings.HasPrefix(k, prefix) {
keys = append(keys, k[len(prefix):])
}
}
return keys
}
// Count returns the number of entries in the agent's namespace.
func (n *NamespacedMemory) Count(agentID string) int {
n.mu.RLock()
defer n.mu.RUnlock()
prefix := agentID + "::"
count := 0
for k := range n.entries {
if strings.HasPrefix(k, prefix) {
count++
}
}
return count
}

View file

@ -0,0 +1,109 @@
package identity
// Context-aware trimming with security event pinning (SDD-003 M5).
//
// Security events are pinned in context and exempt from trimming
// when the context window overflows. This prevents attackers from
// waiting for security events to be evicted.
// Message represents a context window message.
type Message struct {
Role string `json:"role"` // "user", "assistant", "system", "security"
Content string `json:"content"`
TokenCount int `json:"token_count"`
IsPinned bool `json:"is_pinned"` // Security events are pinned
EventType string `json:"event_type,omitempty"` // For security messages
}
// PinnedEventTypes are security events that MUST NOT be trimmed from context.
var PinnedEventTypes = map[string]bool{
"permission_denied": true,
"injection_detected": true,
"circuit_breaker_open": true,
"credential_access_blocked": true,
"exfiltration_attempt": true,
"ssrf_blocked": true,
"genai_credential_access": true,
"genai_persistence": true,
}
// IsPinnedEvent returns true if the event type should be pinned (never trimmed).
func IsPinnedEvent(eventType string) bool {
return PinnedEventTypes[eventType]
}
// TrimContext trims context messages to fit within maxTokens,
// preserving all pinned security events.
//
// Algorithm:
// 1. Separate pinned and unpinned messages
// 2. Calculate token budget remaining after pinned messages
// 3. Trim unpinned messages (oldest first) to fit budget
// 4. Merge: pinned messages in original positions + surviving unpinned
func TrimContext(messages []Message, maxTokens int) []Message {
if len(messages) == 0 {
return messages
}
// Calculate total tokens
totalTokens := 0
for _, m := range messages {
totalTokens += m.TokenCount
}
// If within budget, return as-is
if totalTokens <= maxTokens {
return messages
}
// Separate pinned and unpinned, preserving original indices
type indexedMsg struct {
idx int
msg Message
}
var pinned, unpinned []indexedMsg
pinnedTokens := 0
for i, m := range messages {
if m.IsPinned || IsPinnedEvent(m.EventType) {
pinned = append(pinned, indexedMsg{i, m})
pinnedTokens += m.TokenCount
} else {
unpinned = append(unpinned, indexedMsg{i, m})
}
}
// Budget for unpinned messages
remainingBudget := maxTokens - pinnedTokens
if remainingBudget < 0 {
remainingBudget = 0
}
// Trim unpinned from the beginning (oldest first)
var survivingUnpinned []indexedMsg
usedTokens := 0
// Keep messages from the END (newest) that fit
for i := len(unpinned) - 1; i >= 0; i-- {
if usedTokens + unpinned[i].msg.TokenCount <= remainingBudget {
survivingUnpinned = append([]indexedMsg{unpinned[i]}, survivingUnpinned...)
usedTokens += unpinned[i].msg.TokenCount
}
}
// Merge by original index order
all := append(pinned, survivingUnpinned...)
// Sort by original index
for i := 0; i < len(all); i++ {
for j := i + 1; j < len(all); j++ {
if all[j].idx < all[i].idx {
all[i], all[j] = all[j], all[i]
}
}
}
result := make([]Message, len(all))
for i, im := range all {
result[i] = im.msg
}
return result
}

View file

@ -0,0 +1,99 @@
package identity
import (
"sync"
"time"
)
// Store manages AgentIdentity CRUD operations.
// Thread-safe for concurrent access from multiple goroutines.
type Store struct {
mu sync.RWMutex
agents map[string]*AgentIdentity // agent_id → identity
}
// NewStore creates a new in-memory identity store.
func NewStore() *Store {
return &Store{
agents: make(map[string]*AgentIdentity),
}
}
// Register adds a new agent identity to the store.
// Returns ErrAgentExists if the agent_id is already registered.
func (s *Store) Register(agent *AgentIdentity) error {
if err := agent.Validate(); err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
if _, exists := s.agents[agent.AgentID]; exists {
return ErrAgentExists
}
if agent.CreatedAt.IsZero() {
agent.CreatedAt = time.Now()
}
agent.LastSeenAt = time.Now()
s.agents[agent.AgentID] = agent
return nil
}
// Get retrieves an agent identity by ID.
// Returns ErrAgentNotFound if the agent doesn't exist.
func (s *Store) Get(agentID string) (*AgentIdentity, error) {
s.mu.RLock()
defer s.mu.RUnlock()
agent, ok := s.agents[agentID]
if !ok {
return nil, ErrAgentNotFound
}
return agent, nil
}
// UpdateLastSeen updates the last_seen_at timestamp for an agent.
func (s *Store) UpdateLastSeen(agentID string) error {
s.mu.Lock()
defer s.mu.Unlock()
agent, ok := s.agents[agentID]
if !ok {
return ErrAgentNotFound
}
agent.LastSeenAt = time.Now()
return nil
}
// Remove removes an agent identity from the store.
func (s *Store) Remove(agentID string) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.agents[agentID]; !ok {
return ErrAgentNotFound
}
delete(s.agents, agentID)
return nil
}
// List returns all registered agent identities.
func (s *Store) List() []*AgentIdentity {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]*AgentIdentity, 0, len(s.agents))
for _, agent := range s.agents {
result = append(result, agent)
}
return result
}
// Count returns the number of registered agents.
func (s *Store) Count() int {
s.mu.RLock()
defer s.mu.RUnlock()
return len(s.agents)
}