mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 12:16:54 +00:00
* feat(mcp): expand CRUD MCP server to near-complete CLI parity Closes the CLI-vs-MCP tool coverage gap identified by auditing every `goclaw` CLI command against the existing goclaw_* MCP tool set. Adds skill grant/revoke, agent skill pin/unpin, and full CRUD/inspection surfaces for memory, knowledge graph (including dedup/merge/prune), tenants, providers, LLM traces, channel contacts, pending messages, audit activity, system config, tenant storage (list/size/delete/move), scoped agent config export/import, secure-CLI binary registry, and a DB-backed health check. Deliberately out of scope, documented inline where relevant: - `goclaw credentials`: confirmed CLI-local (~/.goclaw/config.yaml + keychain), no server resource to wrap. goclaw_secure_cli_binaries_* covers the closest real, previously-uncovered server resource instead. - Full tar-archive agent export/import (KG + workspace files): the CLI's version streams a multi-section archive with progress events, a shape that doesn't map to a single MCP tool call. Config + context files (the portable "brain") is covered. - `kg extract` (LLM-driven text extraction): goclaw_kg_ingest accepts the same Entity/Relation shapes the extractor produces, so a caller can run extraction itself and hand off the result. Wires 8 new store dependencies (Memory, KnowledgeGraph, Tracing, Contacts, PendingMessages, Activity, SystemConfigs, SecureCLI) through gateway.Server setters -> cmd/gateway.go -> CRUDDeps, following the existing Providers/Tenants pattern. Storage and secure-CLI-binary handlers duplicate internal/http's path-escape/symlink-hiding validation logic (documented inline) since internal/http already imports internal/mcp and the reverse would cycle. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(mcp): return sessionKey in goclaw_chat_send response for persistent conversations The goclaw_chat_send tool creates a new session internally when sessionKey is empty, but never returned the key to the caller. This prevented using goclaw_chat_history to fetch previous messages in the session. Add SessionKey field to ChatSendResult so callers can: 1. Start a new agent chat without providing sessionKey 2. Receive the sessionKey back in the response 3. Use that sessionKey for follow-up messages and history queries Fixes the training loop pattern: start chat → get sessionKey → call goclaw_chat_history with that key → iterate skill based on actual failures. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat(mcp): add timing diagnostics to goclaw_chat_send for timeout troubleshooting Log request arrival, processing duration, and errors with millisecond precision. Helps identify whether timeouts occur at MCP client→goclaw, goclaw→ollama, or during agent execution. Critical for production debugging. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> * fix(gateway): add http.Server timeouts for defensive timeout handling Set explicit timeouts in http.Server: - ReadTimeout: 1h (allow large uploads, long-running agent operations) - WriteTimeout: 1h (allow streaming responses to slow clients) - IdleTimeout: 30s (close idle keep-alive connections quickly) Provides defense-in-depth when Nginx/Traefik timeouts are misconfigured. Matches Nginx timeout (3600s) to prevent race conditions. Timeout chain: traefik 3600s = nginx 3600s = goclaw 3600s Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> --------- Co-authored-by: Bruno Clermont <bruno.clermont@gmail.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
861 lines
22 KiB
Go
861 lines
22 KiB
Go
package mcp
|
|
|
|
// crud_fakes_test.go holds hand-written in-memory fake stores shared across
|
|
// the crud_*_test.go files. Following the convention already used in
|
|
// internal/gateway/methods/*_test.go (see hooks_test.go, sessions_test.go):
|
|
// each fake embeds the real store interface (nil) so any method the tests
|
|
// don't exercise panics loudly instead of silently returning zero values —
|
|
// intentional, since a panic means a test started depending on untested
|
|
// behavior and should implement it explicitly.
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/hooks"
|
|
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// ---- fakeAgentStore ----
|
|
|
|
type fakeAgentStore struct {
|
|
store.AgentStore
|
|
byID map[uuid.UUID]*store.AgentData
|
|
byKey map[string]*store.AgentData
|
|
contextFiles map[uuid.UUID][]store.AgentContextFileData
|
|
createErr error
|
|
updateErr error
|
|
deleteErr error
|
|
lastUpdate map[string]any
|
|
lastUpdateID uuid.UUID
|
|
propagateN int
|
|
}
|
|
|
|
func newFakeAgentStore() *fakeAgentStore {
|
|
return &fakeAgentStore{
|
|
byID: map[uuid.UUID]*store.AgentData{},
|
|
byKey: map[string]*store.AgentData{},
|
|
contextFiles: map[uuid.UUID][]store.AgentContextFileData{},
|
|
}
|
|
}
|
|
|
|
func (f *fakeAgentStore) add(ag *store.AgentData) {
|
|
f.byID[ag.ID] = ag
|
|
f.byKey[ag.AgentKey] = ag
|
|
}
|
|
|
|
func (f *fakeAgentStore) Create(_ context.Context, agent *store.AgentData) error {
|
|
if f.createErr != nil {
|
|
return f.createErr
|
|
}
|
|
f.add(agent)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeAgentStore) GetByID(_ context.Context, id uuid.UUID) (*store.AgentData, error) {
|
|
if ag, ok := f.byID[id]; ok {
|
|
return ag, nil
|
|
}
|
|
return nil, errors.New("agent not found")
|
|
}
|
|
|
|
func (f *fakeAgentStore) GetByKey(_ context.Context, key string) (*store.AgentData, error) {
|
|
if ag, ok := f.byKey[key]; ok {
|
|
return ag, nil
|
|
}
|
|
return nil, errors.New("agent not found")
|
|
}
|
|
|
|
func (f *fakeAgentStore) List(_ context.Context, ownerID string) ([]store.AgentData, error) {
|
|
var out []store.AgentData
|
|
for _, ag := range f.byID {
|
|
if ownerID != "" && ag.OwnerID != ownerID {
|
|
continue
|
|
}
|
|
out = append(out, *ag)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakeAgentStore) Update(_ context.Context, id uuid.UUID, updates map[string]any) error {
|
|
if f.updateErr != nil {
|
|
return f.updateErr
|
|
}
|
|
ag, ok := f.byID[id]
|
|
if !ok {
|
|
return errors.New("agent not found")
|
|
}
|
|
f.lastUpdate = updates
|
|
f.lastUpdateID = id
|
|
if v, ok := updates["display_name"].(string); ok {
|
|
ag.DisplayName = v
|
|
}
|
|
if v, ok := updates["status"].(string); ok {
|
|
ag.Status = v
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeAgentStore) Delete(_ context.Context, id uuid.UUID) error {
|
|
if f.deleteErr != nil {
|
|
return f.deleteErr
|
|
}
|
|
ag, ok := f.byID[id]
|
|
if !ok {
|
|
return errors.New("agent not found")
|
|
}
|
|
delete(f.byID, id)
|
|
delete(f.byKey, ag.AgentKey)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeAgentStore) GetAgentContextFiles(_ context.Context, agentID uuid.UUID) ([]store.AgentContextFileData, error) {
|
|
return f.contextFiles[agentID], nil
|
|
}
|
|
|
|
func (f *fakeAgentStore) SetAgentContextFile(_ context.Context, agentID uuid.UUID, fileName, content string) error {
|
|
files := f.contextFiles[agentID]
|
|
for i, existing := range files {
|
|
if existing.FileName == fileName {
|
|
files[i].Content = content
|
|
f.contextFiles[agentID] = files
|
|
return nil
|
|
}
|
|
}
|
|
f.contextFiles[agentID] = append(files, store.AgentContextFileData{FileName: fileName, Content: content})
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeAgentStore) PropagateContextFile(_ context.Context, _ uuid.UUID, _ string) (int, error) {
|
|
return f.propagateN, nil
|
|
}
|
|
|
|
// ---- fakeSessionStore ----
|
|
|
|
type fakeSessionStore struct {
|
|
store.SessionStore
|
|
sessions map[string]*store.SessionData
|
|
deleted []string
|
|
resetKeys []string
|
|
labels map[string]string
|
|
metadata map[string]map[string]string
|
|
history map[string][]providers.Message
|
|
deleteErr error
|
|
}
|
|
|
|
func newFakeSessionStore() *fakeSessionStore {
|
|
return &fakeSessionStore{
|
|
sessions: map[string]*store.SessionData{},
|
|
labels: map[string]string{},
|
|
metadata: map[string]map[string]string{},
|
|
history: map[string][]providers.Message{},
|
|
}
|
|
}
|
|
|
|
func (f *fakeSessionStore) add(key string, sess *store.SessionData) {
|
|
f.sessions[key] = sess
|
|
f.history[key] = sess.Messages
|
|
}
|
|
|
|
func (f *fakeSessionStore) Get(_ context.Context, key string) *store.SessionData {
|
|
return f.sessions[key]
|
|
}
|
|
|
|
func (f *fakeSessionStore) List(_ context.Context, _ string) []store.SessionInfo {
|
|
var out []store.SessionInfo
|
|
for k := range f.sessions {
|
|
out = append(out, store.SessionInfo{Key: k})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (f *fakeSessionStore) GetHistory(_ context.Context, key string) []providers.Message {
|
|
return f.history[key]
|
|
}
|
|
|
|
func (f *fakeSessionStore) AddMessage(_ context.Context, key string, msg providers.Message) {
|
|
f.history[key] = append(f.history[key], msg)
|
|
}
|
|
|
|
func (f *fakeSessionStore) SetLabel(_ context.Context, key, label string) {
|
|
f.labels[key] = label
|
|
}
|
|
|
|
func (f *fakeSessionStore) UpdateMetadata(_ context.Context, _, _, _, _ string) {}
|
|
|
|
func (f *fakeSessionStore) SetSessionMetadata(_ context.Context, key string, metadata map[string]string) {
|
|
f.metadata[key] = metadata
|
|
}
|
|
|
|
func (f *fakeSessionStore) Delete(_ context.Context, key string) error {
|
|
if f.deleteErr != nil {
|
|
return f.deleteErr
|
|
}
|
|
f.deleted = append(f.deleted, key)
|
|
delete(f.sessions, key)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeSessionStore) Reset(_ context.Context, key string) {
|
|
f.resetKeys = append(f.resetKeys, key)
|
|
}
|
|
|
|
func (f *fakeSessionStore) TruncateHistory(_ context.Context, key string, keepLast int) {
|
|
h := f.history[key]
|
|
if len(h) > keepLast {
|
|
f.history[key] = h[len(h)-keepLast:]
|
|
}
|
|
}
|
|
|
|
// ---- fakeSkillStore (+ manage) ----
|
|
|
|
type fakeSkillStore struct {
|
|
store.SkillStore
|
|
skills map[string]store.SkillInfo
|
|
bumpCount int
|
|
updateCalls map[uuid.UUID]map[string]any
|
|
updateErr error
|
|
}
|
|
|
|
func newFakeSkillStore() *fakeSkillStore {
|
|
return &fakeSkillStore{
|
|
skills: map[string]store.SkillInfo{},
|
|
updateCalls: map[uuid.UUID]map[string]any{},
|
|
}
|
|
}
|
|
|
|
func (f *fakeSkillStore) ListSkills(_ context.Context) []store.SkillInfo {
|
|
var out []store.SkillInfo
|
|
for _, s := range f.skills {
|
|
out = append(out, s)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (f *fakeSkillStore) GetSkill(_ context.Context, name string) (*store.SkillInfo, bool) {
|
|
s, ok := f.skills[name]
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
return &s, true
|
|
}
|
|
|
|
func (f *fakeSkillStore) BumpVersion() {
|
|
f.bumpCount++
|
|
}
|
|
|
|
// fakeSkillManageStore implements store.SkillManageStore (SkillStore +
|
|
// management CRUD). It does not embed fakeSkillStore directly: both
|
|
// fakeSkillStore and store.SkillManageStore separately embed store.SkillStore
|
|
// (nil), and Go forbids ambiguous promoted-method satisfaction of an
|
|
// interface — so this type re-implements the read path against its own maps
|
|
// instead of delegating to a fakeSkillStore instance.
|
|
type fakeSkillManageStore struct {
|
|
store.SkillManageStore
|
|
skills map[string]store.SkillInfo
|
|
bumpCount int
|
|
updateCalls map[uuid.UUID]map[string]any
|
|
updateErr error
|
|
}
|
|
|
|
func newFakeSkillManageStore() *fakeSkillManageStore {
|
|
return &fakeSkillManageStore{
|
|
skills: map[string]store.SkillInfo{},
|
|
updateCalls: map[uuid.UUID]map[string]any{},
|
|
}
|
|
}
|
|
|
|
func (f *fakeSkillManageStore) ListSkills(_ context.Context) []store.SkillInfo {
|
|
var out []store.SkillInfo
|
|
for _, s := range f.skills {
|
|
out = append(out, s)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (f *fakeSkillManageStore) GetSkill(_ context.Context, name string) (*store.SkillInfo, bool) {
|
|
s, ok := f.skills[name]
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
return &s, true
|
|
}
|
|
|
|
func (f *fakeSkillManageStore) BumpVersion() {
|
|
f.bumpCount++
|
|
}
|
|
|
|
func (f *fakeSkillManageStore) UpdateSkill(_ context.Context, id uuid.UUID, updates map[string]any) error {
|
|
if f.updateErr != nil {
|
|
return f.updateErr
|
|
}
|
|
f.updateCalls[id] = updates
|
|
return nil
|
|
}
|
|
|
|
// ---- fakeCronStore ----
|
|
|
|
type fakeCronStore struct {
|
|
store.CronStore
|
|
jobs map[string]*store.CronJob
|
|
removeErr error
|
|
updateErr error
|
|
runErr error
|
|
runLogTotal int
|
|
}
|
|
|
|
func newFakeCronStore() *fakeCronStore {
|
|
return &fakeCronStore{jobs: map[string]*store.CronJob{}}
|
|
}
|
|
|
|
func (f *fakeCronStore) AddJob(_ context.Context, name string, schedule store.CronSchedule, message string, deliver bool, channel, to, agentID, userID string) (*store.CronJob, error) {
|
|
job := &store.CronJob{
|
|
ID: uuid.NewString(),
|
|
Name: name,
|
|
Schedule: schedule,
|
|
Enabled: true,
|
|
}
|
|
f.jobs[job.ID] = job
|
|
return job, nil
|
|
}
|
|
|
|
func (f *fakeCronStore) GetJob(_ context.Context, jobID string) (*store.CronJob, bool) {
|
|
j, ok := f.jobs[jobID]
|
|
return j, ok
|
|
}
|
|
|
|
func (f *fakeCronStore) ListJobs(_ context.Context, includeDisabled bool, _, _ string) []store.CronJob {
|
|
var out []store.CronJob
|
|
for _, j := range f.jobs {
|
|
if !includeDisabled && !j.Enabled {
|
|
continue
|
|
}
|
|
out = append(out, *j)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (f *fakeCronStore) RemoveJob(_ context.Context, jobID string) error {
|
|
if f.removeErr != nil {
|
|
return f.removeErr
|
|
}
|
|
if _, ok := f.jobs[jobID]; !ok {
|
|
return errors.New("job not found")
|
|
}
|
|
delete(f.jobs, jobID)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeCronStore) UpdateJob(_ context.Context, jobID string, patch store.CronJobPatch) (*store.CronJob, error) {
|
|
if f.updateErr != nil {
|
|
return nil, f.updateErr
|
|
}
|
|
j, ok := f.jobs[jobID]
|
|
if !ok {
|
|
return nil, errors.New("job not found")
|
|
}
|
|
if patch.Name != "" {
|
|
j.Name = patch.Name
|
|
}
|
|
if patch.Enabled != nil {
|
|
j.Enabled = *patch.Enabled
|
|
}
|
|
return j, nil
|
|
}
|
|
|
|
func (f *fakeCronStore) EnableJob(_ context.Context, jobID string, enabled bool) error {
|
|
j, ok := f.jobs[jobID]
|
|
if !ok {
|
|
return errors.New("job not found")
|
|
}
|
|
j.Enabled = enabled
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeCronStore) RunJob(_ context.Context, jobID string, force bool) (bool, string, error) {
|
|
if f.runErr != nil {
|
|
return false, "", f.runErr
|
|
}
|
|
if _, ok := f.jobs[jobID]; !ok {
|
|
return false, "", errors.New("job not found")
|
|
}
|
|
return true, "", nil
|
|
}
|
|
|
|
func (f *fakeCronStore) GetRunLog(_ context.Context, _ string, _, _ int) ([]store.CronRunLogEntry, int) {
|
|
return nil, f.runLogTotal
|
|
}
|
|
|
|
func (f *fakeCronStore) Status() map[string]any {
|
|
return map[string]any{"jobs": len(f.jobs)}
|
|
}
|
|
|
|
// ---- fakeHookStore (mirrors internal/gateway/methods/hooks_test.go's fakeStore) ----
|
|
|
|
type fakeHookStore struct {
|
|
created map[uuid.UUID]hooks.HookConfig
|
|
createErr error
|
|
updateErr error
|
|
deleteErr error
|
|
}
|
|
|
|
func newFakeHookStore() *fakeHookStore {
|
|
return &fakeHookStore{created: map[uuid.UUID]hooks.HookConfig{}}
|
|
}
|
|
|
|
func (f *fakeHookStore) Create(_ context.Context, cfg hooks.HookConfig) (uuid.UUID, error) {
|
|
if f.createErr != nil {
|
|
return uuid.Nil, f.createErr
|
|
}
|
|
id := uuid.New()
|
|
cfg.ID = id
|
|
f.created[id] = cfg
|
|
return id, nil
|
|
}
|
|
|
|
func (f *fakeHookStore) GetByID(_ context.Context, id uuid.UUID) (*hooks.HookConfig, error) {
|
|
if cfg, ok := f.created[id]; ok {
|
|
return &cfg, nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeHookStore) List(_ context.Context, _ hooks.ListFilter) ([]hooks.HookConfig, error) {
|
|
out := make([]hooks.HookConfig, 0, len(f.created))
|
|
for _, cfg := range f.created {
|
|
out = append(out, cfg)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakeHookStore) Update(_ context.Context, id uuid.UUID, updates map[string]any) error {
|
|
if f.updateErr != nil {
|
|
return f.updateErr
|
|
}
|
|
cfg, ok := f.created[id]
|
|
if !ok {
|
|
return errors.New("not found")
|
|
}
|
|
if v, ok := updates["enabled"].(bool); ok {
|
|
cfg.Enabled = v
|
|
}
|
|
f.created[id] = cfg
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeHookStore) Delete(_ context.Context, id uuid.UUID) error {
|
|
if f.deleteErr != nil {
|
|
return f.deleteErr
|
|
}
|
|
if _, ok := f.created[id]; !ok {
|
|
return errors.New("not found")
|
|
}
|
|
delete(f.created, id)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeHookStore) ResolveForEvent(_ context.Context, _ hooks.Event) ([]hooks.HookConfig, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeHookStore) WriteExecution(_ context.Context, _ hooks.HookExecution) error { return nil }
|
|
func (f *fakeHookStore) SetHookAgents(_ context.Context, _ uuid.UUID, _ []uuid.UUID) error {
|
|
return nil
|
|
}
|
|
func (f *fakeHookStore) GetHookAgents(_ context.Context, _ uuid.UUID) ([]uuid.UUID, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
// ---- fakeHeartbeatStore ----
|
|
|
|
type fakeHeartbeatStore struct {
|
|
store.HeartbeatStore
|
|
byAgent map[uuid.UUID]*store.AgentHeartbeat
|
|
upsertErr error
|
|
}
|
|
|
|
func newFakeHeartbeatStore() *fakeHeartbeatStore {
|
|
return &fakeHeartbeatStore{byAgent: map[uuid.UUID]*store.AgentHeartbeat{}}
|
|
}
|
|
|
|
func (f *fakeHeartbeatStore) Get(_ context.Context, agentID uuid.UUID) (*store.AgentHeartbeat, error) {
|
|
if h, ok := f.byAgent[agentID]; ok {
|
|
return h, nil
|
|
}
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
|
|
func (f *fakeHeartbeatStore) Upsert(_ context.Context, hb *store.AgentHeartbeat) error {
|
|
if f.upsertErr != nil {
|
|
return f.upsertErr
|
|
}
|
|
f.byAgent[hb.AgentID] = hb
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeHeartbeatStore) ListLogs(_ context.Context, _ uuid.UUID, _, _ int) ([]store.HeartbeatRunLog, int, error) {
|
|
return nil, 0, nil
|
|
}
|
|
|
|
func (f *fakeHeartbeatStore) ListDeliveryTargets(_ context.Context, _ uuid.UUID) ([]store.DeliveryTarget, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
// ---- fakePairingStore ----
|
|
|
|
type fakePairingStore struct {
|
|
store.PairingStore
|
|
pending []store.PairingRequestData
|
|
paired []store.PairedDeviceData
|
|
requestErr error
|
|
approveErr error
|
|
denyErr error
|
|
revokeErr error
|
|
}
|
|
|
|
func newFakePairingStore() *fakePairingStore {
|
|
return &fakePairingStore{}
|
|
}
|
|
|
|
func (f *fakePairingStore) RequestPairing(_ context.Context, senderID, channel, chatID, accountID string, _ map[string]string) (string, error) {
|
|
if f.requestErr != nil {
|
|
return "", f.requestErr
|
|
}
|
|
code := "CODE123"
|
|
f.pending = append(f.pending, store.PairingRequestData{Code: code, SenderID: senderID, Channel: channel, ChatID: chatID, AccountID: accountID})
|
|
return code, nil
|
|
}
|
|
|
|
func (f *fakePairingStore) ApprovePairing(_ context.Context, code, approvedBy string) (*store.PairedDeviceData, error) {
|
|
if f.approveErr != nil {
|
|
return nil, f.approveErr
|
|
}
|
|
for i, p := range f.pending {
|
|
if p.Code == code {
|
|
f.pending = append(f.pending[:i], f.pending[i+1:]...)
|
|
dev := store.PairedDeviceData{SenderID: p.SenderID, Channel: p.Channel, ChatID: p.ChatID, PairedBy: approvedBy}
|
|
f.paired = append(f.paired, dev)
|
|
return &dev, nil
|
|
}
|
|
}
|
|
return nil, errors.New("code not found")
|
|
}
|
|
|
|
func (f *fakePairingStore) DenyPairing(_ context.Context, code string) error {
|
|
if f.denyErr != nil {
|
|
return f.denyErr
|
|
}
|
|
for i, p := range f.pending {
|
|
if p.Code == code {
|
|
f.pending = append(f.pending[:i], f.pending[i+1:]...)
|
|
return nil
|
|
}
|
|
}
|
|
return errors.New("code not found")
|
|
}
|
|
|
|
func (f *fakePairingStore) RevokePairing(_ context.Context, senderID, channel string) error {
|
|
if f.revokeErr != nil {
|
|
return f.revokeErr
|
|
}
|
|
for i, p := range f.paired {
|
|
if p.SenderID == senderID && p.Channel == channel {
|
|
f.paired = append(f.paired[:i], f.paired[i+1:]...)
|
|
return nil
|
|
}
|
|
}
|
|
return errors.New("not paired")
|
|
}
|
|
|
|
func (f *fakePairingStore) IsPaired(_ context.Context, senderID, channel string) (bool, error) {
|
|
for _, p := range f.paired {
|
|
if p.SenderID == senderID && p.Channel == channel {
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
func (f *fakePairingStore) ListPending(_ context.Context) []store.PairingRequestData {
|
|
return f.pending
|
|
}
|
|
|
|
func (f *fakePairingStore) ListPaired(_ context.Context) []store.PairedDeviceData {
|
|
return f.paired
|
|
}
|
|
|
|
// ---- fakeTeamStore ----
|
|
|
|
type fakeTeamStore struct {
|
|
store.TeamStore
|
|
teams map[uuid.UUID]*store.TeamData
|
|
members map[uuid.UUID][]store.TeamMemberData
|
|
createErr error
|
|
getErr error
|
|
deleteErr error
|
|
}
|
|
|
|
func newFakeTeamStore() *fakeTeamStore {
|
|
return &fakeTeamStore{
|
|
teams: map[uuid.UUID]*store.TeamData{},
|
|
members: map[uuid.UUID][]store.TeamMemberData{},
|
|
}
|
|
}
|
|
|
|
func (f *fakeTeamStore) CreateTeam(_ context.Context, team *store.TeamData) error {
|
|
if f.createErr != nil {
|
|
return f.createErr
|
|
}
|
|
if team.ID == uuid.Nil {
|
|
team.ID = uuid.New()
|
|
}
|
|
f.teams[team.ID] = team
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeTeamStore) GetTeam(_ context.Context, teamID uuid.UUID) (*store.TeamData, error) {
|
|
if f.getErr != nil {
|
|
return nil, f.getErr
|
|
}
|
|
t, ok := f.teams[teamID]
|
|
if !ok {
|
|
return nil, errors.New("team not found")
|
|
}
|
|
return t, nil
|
|
}
|
|
|
|
func (f *fakeTeamStore) ListTeams(_ context.Context) ([]store.TeamData, error) {
|
|
var out []store.TeamData
|
|
for _, t := range f.teams {
|
|
out = append(out, *t)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakeTeamStore) DeleteTeam(_ context.Context, teamID uuid.UUID) error {
|
|
if f.deleteErr != nil {
|
|
return f.deleteErr
|
|
}
|
|
if _, ok := f.teams[teamID]; !ok {
|
|
return errors.New("team not found")
|
|
}
|
|
delete(f.teams, teamID)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeTeamStore) UpdateTeam(_ context.Context, teamID uuid.UUID, updates map[string]any) error {
|
|
t, ok := f.teams[teamID]
|
|
if !ok {
|
|
return errors.New("team not found")
|
|
}
|
|
if v, ok := updates["name"].(string); ok {
|
|
t.Name = v
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeTeamStore) AddMember(_ context.Context, teamID, agentID uuid.UUID, role string) error {
|
|
f.members[teamID] = append(f.members[teamID], store.TeamMemberData{TeamID: teamID, AgentID: agentID, Role: role})
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeTeamStore) RemoveMember(_ context.Context, teamID, agentID uuid.UUID) error {
|
|
members := f.members[teamID]
|
|
for i, m := range members {
|
|
if m.AgentID == agentID {
|
|
f.members[teamID] = append(members[:i], members[i+1:]...)
|
|
return nil
|
|
}
|
|
}
|
|
return errors.New("member not found")
|
|
}
|
|
|
|
func (f *fakeTeamStore) ListMembers(_ context.Context, teamID uuid.UUID) ([]store.TeamMemberData, error) {
|
|
return f.members[teamID], nil
|
|
}
|
|
|
|
func (f *fakeTeamStore) GetTeamForAgent(_ context.Context, agentID uuid.UUID) (*store.TeamData, error) {
|
|
for _, t := range f.teams {
|
|
if t.LeadAgentID == agentID {
|
|
return t, nil
|
|
}
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeTeamStore) KnownUserIDs(_ context.Context, _ uuid.UUID, _ int) ([]string, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeTeamStore) ListTaskScopes(_ context.Context, _ uuid.UUID) ([]store.ScopeEntry, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeTeamStore) ListTeamEvents(_ context.Context, _ uuid.UUID, _, _ int) ([]store.TeamTaskEventData, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
// ---- fakeChannelInstanceStore ----
|
|
|
|
type fakeChannelInstanceStore struct {
|
|
store.ChannelInstanceStore
|
|
byID map[uuid.UUID]*store.ChannelInstanceData
|
|
createErr error
|
|
deleteErr error
|
|
}
|
|
|
|
func newFakeChannelInstanceStore() *fakeChannelInstanceStore {
|
|
return &fakeChannelInstanceStore{byID: map[uuid.UUID]*store.ChannelInstanceData{}}
|
|
}
|
|
|
|
func (f *fakeChannelInstanceStore) Create(_ context.Context, inst *store.ChannelInstanceData) error {
|
|
if f.createErr != nil {
|
|
return f.createErr
|
|
}
|
|
if inst.ID == uuid.Nil {
|
|
inst.ID = uuid.New()
|
|
}
|
|
f.byID[inst.ID] = inst
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeChannelInstanceStore) Get(_ context.Context, id uuid.UUID) (*store.ChannelInstanceData, error) {
|
|
if inst, ok := f.byID[id]; ok {
|
|
return inst, nil
|
|
}
|
|
return nil, errors.New("instance not found")
|
|
}
|
|
|
|
func (f *fakeChannelInstanceStore) Update(_ context.Context, id uuid.UUID, updates map[string]any) error {
|
|
inst, ok := f.byID[id]
|
|
if !ok {
|
|
return errors.New("instance not found")
|
|
}
|
|
if v, ok := updates["enabled"].(bool); ok {
|
|
inst.Enabled = v
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeChannelInstanceStore) Delete(_ context.Context, id uuid.UUID) error {
|
|
if f.deleteErr != nil {
|
|
return f.deleteErr
|
|
}
|
|
if _, ok := f.byID[id]; !ok {
|
|
return errors.New("instance not found")
|
|
}
|
|
delete(f.byID, id)
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeChannelInstanceStore) ListAll(_ context.Context) ([]store.ChannelInstanceData, error) {
|
|
var out []store.ChannelInstanceData
|
|
for _, inst := range f.byID {
|
|
out = append(out, *inst)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ---- fakeProviderStore ----
|
|
|
|
type fakeProviderStore struct {
|
|
store.ProviderStore
|
|
byName map[string]*store.LLMProviderData
|
|
}
|
|
|
|
func newFakeProviderStore() *fakeProviderStore {
|
|
return &fakeProviderStore{byName: map[string]*store.LLMProviderData{}}
|
|
}
|
|
|
|
func (f *fakeProviderStore) GetProviderByName(_ context.Context, name string) (*store.LLMProviderData, error) {
|
|
if p, ok := f.byName[name]; ok {
|
|
return p, nil
|
|
}
|
|
return nil, errors.New("provider not found")
|
|
}
|
|
|
|
// ---- fakeTenantStore ----
|
|
|
|
type fakeTenantStore struct {
|
|
store.TenantStore
|
|
byID map[uuid.UUID]*store.TenantData
|
|
bySlug map[string]*store.TenantData
|
|
}
|
|
|
|
func newFakeTenantStore() *fakeTenantStore {
|
|
return &fakeTenantStore{
|
|
byID: map[uuid.UUID]*store.TenantData{},
|
|
bySlug: map[string]*store.TenantData{},
|
|
}
|
|
}
|
|
|
|
func (f *fakeTenantStore) addTenant(t *store.TenantData) {
|
|
f.byID[t.ID] = t
|
|
f.bySlug[t.Slug] = t
|
|
}
|
|
|
|
func (f *fakeTenantStore) GetTenant(_ context.Context, id uuid.UUID) (*store.TenantData, error) {
|
|
if t, ok := f.byID[id]; ok {
|
|
return t, nil
|
|
}
|
|
return nil, errors.New("tenant not found")
|
|
}
|
|
|
|
func (f *fakeTenantStore) GetTenantBySlug(_ context.Context, slug string) (*store.TenantData, error) {
|
|
if t, ok := f.bySlug[slug]; ok {
|
|
return t, nil
|
|
}
|
|
return nil, errors.New("tenant not found")
|
|
}
|
|
|
|
// ---- fakeChatRunner ----
|
|
|
|
type fakeChatRunner struct {
|
|
sendResult *ChatSendResult
|
|
sendErr error
|
|
abortResult *ChatAbortResult
|
|
abortErr error
|
|
statusResult *ChatSessionStatusResult
|
|
statusErr error
|
|
lastAgentID string
|
|
lastSessKey string
|
|
lastMessage string
|
|
}
|
|
|
|
func (f *fakeChatRunner) Send(_ context.Context, agentID, sessionKey, message string, _ []ChatMediaItem) (*ChatSendResult, error) {
|
|
f.lastAgentID = agentID
|
|
f.lastSessKey = sessionKey
|
|
f.lastMessage = message
|
|
if f.sendErr != nil {
|
|
return nil, f.sendErr
|
|
}
|
|
if f.sendResult != nil {
|
|
return f.sendResult, nil
|
|
}
|
|
return &ChatSendResult{RunID: "run-1", SessionKey: sessionKey, Content: "ok"}, nil
|
|
}
|
|
|
|
func (f *fakeChatRunner) Abort(_ context.Context, _, _ string) (*ChatAbortResult, error) {
|
|
if f.abortErr != nil {
|
|
return nil, f.abortErr
|
|
}
|
|
if f.abortResult != nil {
|
|
return f.abortResult, nil
|
|
}
|
|
return &ChatAbortResult{OK: true}, nil
|
|
}
|
|
|
|
func (f *fakeChatRunner) SessionStatus(_ context.Context, _ string) (*ChatSessionStatusResult, error) {
|
|
if f.statusErr != nil {
|
|
return nil, f.statusErr
|
|
}
|
|
if f.statusResult != nil {
|
|
return f.statusResult, nil
|
|
}
|
|
return &ChatSessionStatusResult{IsRunning: false}, nil
|
|
}
|