From 65287a6864b9fcf5ab4ae50382a4d2147a2da236 Mon Sep 17 00:00:00 2001 From: Yun Long Date: Tue, 9 Jun 2026 10:26:05 +0800 Subject: [PATCH] Fix(migration): repair CSGClaw agent participants --- internal/participant/store.go | 128 ++++++++++++++++++++++++++++- internal/participant/store_test.go | 69 ++++++++++++++++ 2 files changed, 196 insertions(+), 1 deletion(-) diff --git a/internal/participant/store.go b/internal/participant/store.go index 063da6ae..0ed27c3c 100644 --- a/internal/participant/store.go +++ b/internal/participant/store.go @@ -144,11 +144,14 @@ func (s *Store) load() error { if err != nil { return err } + repairedLegacyIDs := migrateLegacyCSGClawAgentParticipantIDs(items) s.items = items - if legacyExists { + if legacyExists || repairedLegacyIDs { if err := s.saveLocked(); err != nil { return fmt.Errorf("write migrated participant state: %w", err) } + } + if legacyExists { if err := os.Remove(legacyPath); err != nil && !os.IsNotExist(err) { return fmt.Errorf("delete legacy bot state after participant migration: %w", err) } @@ -257,6 +260,129 @@ func storeKey(channel, id string) string { return strings.TrimSpace(channel) + "\x00" + strings.TrimSpace(id) } +func migrateLegacyCSGClawAgentParticipantIDs(items map[string]apitypes.Participant) bool { + if len(items) == 0 { + return false + } + keys := make([]string, 0, len(items)) + for key := range items { + keys = append(keys, key) + } + slices.Sort(keys) + + changed := false + for _, key := range keys { + item, ok := items[key] + if !ok { + continue + } + nextID, ok := legacyCSGClawAgentParticipantID(item) + if !ok { + continue + } + next := item + legacyID := strings.TrimSpace(next.ID) + next.ID = nextID + if strings.TrimSpace(next.ChannelUserRef) == "" { + next.ChannelUserRef = legacyID + } + if strings.TrimSpace(next.AgentID) == "" { + next.AgentID = legacyID + } + next = normalizeStoredParticipant(next) + + nextKey := storeKey(next.Channel, next.ID) + if nextKey == key { + continue + } + if existing, exists := items[nextKey]; exists { + if sameAgentParticipantBinding(existing, next) { + items[nextKey] = mergeAgentParticipant(existing, next) + delete(items, key) + changed = true + } + continue + } + items[nextKey] = next + delete(items, key) + changed = true + } + return changed +} + +func legacyCSGClawAgentParticipantID(item apitypes.Participant) (string, bool) { + item = normalizeStoredParticipant(item) + if item.Channel != ChannelCSGClaw || item.Type != TypeAgent { + return "", false + } + if item.ID == "" || item.ID == agent.ManagerUserID || !strings.HasPrefix(item.ID, "u-") { + return "", false + } + if item.AgentID != "" && item.AgentID != item.ID { + return "", false + } + id := strings.TrimPrefix(item.ID, "u-") + if id == "" || id == item.ID { + return "", false + } + return id, true +} + +func sameAgentParticipantBinding(a, b apitypes.Participant) bool { + a = normalizeStoredParticipant(a) + b = normalizeStoredParticipant(b) + if a.Channel != b.Channel || a.Type != b.Type || a.Type != TypeAgent { + return false + } + if a.AgentID != "" && b.AgentID != "" && a.AgentID == b.AgentID { + return true + } + return a.ChannelUserRef != "" && b.ChannelUserRef != "" && a.ChannelUserRef == b.ChannelUserRef +} + +func mergeAgentParticipant(existing, legacy apitypes.Participant) apitypes.Participant { + merged := normalizeStoredParticipant(existing) + legacy = normalizeStoredParticipant(legacy) + if merged.Name == "" { + merged.Name = legacy.Name + } + if merged.Avatar == "" { + merged.Avatar = legacy.Avatar + } + if merged.ChannelUserRef == "" { + merged.ChannelUserRef = legacy.ChannelUserRef + } + if merged.ChannelUserKind == "" { + merged.ChannelUserKind = legacy.ChannelUserKind + } + if merged.AgentID == "" { + merged.AgentID = legacy.AgentID + } + if merged.LifecycleStatus == "" { + merged.LifecycleStatus = legacy.LifecycleStatus + } + if merged.Presence == "" { + merged.Presence = legacy.Presence + } + merged.Mentionable = merged.Mentionable || legacy.Mentionable + if merged.Metadata == nil { + merged.Metadata = cloneParticipant(legacy).Metadata + } else { + for key, value := range legacy.Metadata { + if _, ok := merged.Metadata[key]; !ok { + merged.Metadata[key] = value + } + } + } + if merged.CreatedAt.IsZero() || (!legacy.CreatedAt.IsZero() && legacy.CreatedAt.Before(merged.CreatedAt)) { + merged.CreatedAt = legacy.CreatedAt + } + if merged.UpdatedAt.IsZero() || legacy.UpdatedAt.After(merged.UpdatedAt) { + merged.UpdatedAt = legacy.UpdatedAt + } + return merged +} + func mergeLegacyBotState(participantPath string, items map[string]apitypes.Participant) (string, bool, error) { if strings.TrimSpace(participantPath) == "" { return "", false, nil diff --git a/internal/participant/store_test.go b/internal/participant/store_test.go index 35298147..a8bc8867 100644 --- a/internal/participant/store_test.go +++ b/internal/participant/store_test.go @@ -57,6 +57,17 @@ func TestNewStoreMigratesLegacyBotsAndDeletesSource(t *testing.T) { }, CreatedAt: createdAt.Add(2 * time.Minute), }, + { + ID: "u-alice", + Name: "alice", + Type: "normal", + Role: "worker", + Channel: ChannelCSGClaw, + AgentID: "u-alice", + UserID: "u-alice", + Available: true, + CreatedAt: createdAt.Add(3 * time.Minute), + }, }}) store, err := NewStore(participantsPath) @@ -95,6 +106,64 @@ func TestNewStoreMigratesLegacyBotsAndDeletesSource(t *testing.T) { if _, ok := store.Get(ChannelCSGClaw, "dev"); !ok { t.Fatal("existing participant was not preserved during migration") } + worker, ok := store.Get(ChannelCSGClaw, "alice") + if !ok { + t.Fatal("worker participant was not migrated to stripped participant ID") + } + if worker.Type != TypeAgent || worker.AgentID != "u-alice" || worker.ChannelUserRef != "u-alice" { + t.Fatalf("worker participant = %+v, want participant alice bound to channel/agent u-alice", worker) + } + if _, ok := store.Get(ChannelCSGClaw, "u-alice"); ok { + t.Fatal("worker participant was left under legacy agent ID u-alice") + } +} + +func TestNewStoreRepairsLegacyPrefixedAgentParticipants(t *testing.T) { + dir := t.TempDir() + participantsPath := filepath.Join(dir, "participants.json") + createdAt := time.Date(2026, 6, 4, 14, 0, 7, 0, time.UTC) + writeJSONFile(t, participantsPath, persistedState{Participants: []apitypes.Participant{ + { + ID: "u-alice", + Channel: ChannelCSGClaw, + Type: TypeAgent, + Name: "alice", + ChannelUserRef: "u-alice", + ChannelUserKind: ChannelUserKindLocalUserID, + AgentID: "u-alice", + LifecycleStatus: LifecycleStatusActive, + Mentionable: true, + CreatedAt: createdAt, + UpdatedAt: createdAt, + }, + }}) + + store, err := NewStore(participantsPath) + if err != nil { + t.Fatalf("NewStore() error = %v", err) + } + + worker, ok := store.Get(ChannelCSGClaw, "alice") + if !ok { + t.Fatal("worker participant was not repaired to stripped participant ID") + } + if worker.AgentID != "u-alice" || worker.ChannelUserRef != "u-alice" { + t.Fatalf("worker participant = %+v, want participant alice bound to channel/agent u-alice", worker) + } + if _, ok := store.Get(ChannelCSGClaw, "u-alice"); ok { + t.Fatal("legacy prefixed participant u-alice still exists after repair") + } + + reloaded, err := NewStore(participantsPath) + if err != nil { + t.Fatalf("reload NewStore() error = %v", err) + } + if _, ok := reloaded.Get(ChannelCSGClaw, "alice"); !ok { + t.Fatal("reloaded store missing repaired participant alice") + } + if _, ok := reloaded.Get(ChannelCSGClaw, "u-alice"); ok { + t.Fatal("reloaded store still has legacy participant u-alice") + } } func writeJSONFile(t *testing.T, path string, value any) {