diff --git a/.gitignore b/.gitignore index ccf99673..5ceeb568 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,10 @@ dist/ *.test *.out +# Python cache artifacts +__pycache__/ +*.py[cod] + # Web frontend local artifacts node_modules/ web/app/playwright-report/ diff --git a/cli/app.go b/cli/app.go index 898bd50a..4867e9f2 100644 --- a/cli/app.go +++ b/cli/app.go @@ -221,7 +221,7 @@ func (a *App) usage() { fmt.Fprintln(a.stderr, " csgclaw hub -h") fmt.Fprintln(a.stderr, " csgclaw skill -h") fmt.Fprintln(a.stderr, " csgclaw agent create -h") - fmt.Fprintln(a.stderr, " csgclaw message create --channel csgclaw --room-id room-1 --sender-id u-admin --content hello") + fmt.Fprintln(a.stderr, " csgclaw message create --channel csgclaw --room-id room-1 --sender-id admin --content hello") fmt.Fprintln(a.stderr) fmt.Fprintln(a.stderr, "Global flags:") fmt.Fprintf(a.stderr, " --endpoint string HTTP server endpoint (default %s)\n", envBaseURL) diff --git a/cli/serve/serve_test.go b/cli/serve/serve_test.go index 33afc83a..0ac1a773 100644 --- a/cli/serve/serve_test.go +++ b/cli/serve/serve_test.go @@ -146,6 +146,7 @@ func TestServeRunSkipsAutoBootstrapWhenStateComplete(t *testing.T) { ConfigComplete: true, IMBootstrapComplete: true, ManagerAgentComplete: true, + AdminParticipantComplete: true, ManagerParticipantComplete: true, }, nil } @@ -219,6 +220,7 @@ debian_registries_override = [] ConfigComplete: true, IMBootstrapComplete: true, ManagerAgentComplete: true, + AdminParticipantComplete: true, ManagerParticipantComplete: true, }, nil } @@ -425,6 +427,7 @@ func TestServeRunRepeatedAutoBootstrapRemainsIdempotent(t *testing.T) { ConfigComplete: complete, IMBootstrapComplete: complete, ManagerAgentComplete: complete, + AdminParticipantComplete: complete, ManagerParticipantComplete: complete, }, nil } @@ -1404,6 +1407,7 @@ func stubServeDependencies(t *testing.T) func() { ConfigComplete: true, IMBootstrapComplete: true, ManagerAgentComplete: true, + AdminParticipantComplete: true, ManagerParticipantComplete: true, }, nil } diff --git a/internal/agent/image_migration.go b/internal/agent/image_migration.go index 6c1c96cd..9e1beade 100644 --- a/internal/agent/image_migration.go +++ b/internal/agent/image_migration.go @@ -2,13 +2,17 @@ package agent import ( "context" + "strconv" "strings" ) func (s *Service) withRuntimeImageMigrationStatus(ctx context.Context, a Agent) Agent { + return s.withRuntimeImageMigrationStatusFromCandidates(ctx, a, s.localImageCandidates(ctx)) +} + +func (s *Service) withRuntimeImageMigrationStatusFromCandidates(ctx context.Context, a Agent, localImages []string) Agent { a = *cloneAgent(&a) - latestImage, ok := s.currentDefaultImageForAgent(ctx, a) - if !ok || !imageNeedsDefaultRecreate(a.Image, latestImage) { + if _, ok := s.imageUpgradeCandidateFromCandidates(ctx, a, localImages); !ok { return a } a.AgentProfile.ImageUpgradeRequired = true @@ -17,8 +21,7 @@ func (s *Service) withRuntimeImageMigrationStatus(ctx context.Context, a Agent) func (s *Service) imageForRecreate(ctx context.Context, a Agent) string { current := strings.TrimSpace(a.Image) - latestImage, ok := s.currentDefaultImageForAgent(ctx, a) - if ok && imageNeedsDefaultRecreate(current, latestImage) { + if latestImage, ok := s.imageUpgradeCandidateFromCandidates(ctx, a, s.localImageCandidates(ctx)); ok { return latestImage } if isGatewayRuntimeKind(strings.TrimSpace(a.RuntimeKind)) && current == "" { @@ -30,6 +33,49 @@ func (s *Service) imageForRecreate(ctx context.Context, a Agent) string { return current } +func (s *Service) imageForUpgrade(ctx context.Context, a Agent) (string, bool) { + if image, ok := s.imageUpgradeCandidateFromCandidates(ctx, a, s.localImageCandidates(ctx)); ok { + return image, true + } + image, ok := s.currentDefaultImageForAgent(ctx, a) + if isDevImageTag(dockerImageTag(image)) { + return "", false + } + return strings.TrimSpace(image), ok && strings.TrimSpace(image) != "" +} + +func (s *Service) imageUpgradeCandidateFromCandidates(ctx context.Context, a Agent, localImages []string) (string, bool) { + if image, ok := latestNewerLocalImageForReference(a.Image, localImages); ok { + return image, true + } + latestImage, ok := s.currentDefaultImageForAgent(ctx, a) + if ok && imageNeedsDefaultRecreate(a.Image, latestImage) { + return latestImage, true + } + return "", false +} + +func (s *Service) localImageCandidates(ctx context.Context) []string { + if s == nil { + return nil + } + s.mu.RLock() + provider := s.sandbox + s.mu.RUnlock() + if provider == nil { + return nil + } + homeDir, err := SandboxRuntimeHome(ManagerName) + if err != nil { + return nil + } + images, err := provider.ListImages(ctx, homeDir) + if err != nil { + return nil + } + return images +} + func (s *Service) currentDefaultImageForAgent(ctx context.Context, a Agent) (string, bool) { if s == nil || !isGatewayRuntimeKind(strings.TrimSpace(a.RuntimeKind)) { return "", false @@ -80,6 +126,36 @@ func defaultTemplateMatchesAgent(templateRole, templateRuntimeKind, agentRole, a return templateRuntimeKind == strings.TrimSpace(agentRuntimeKind) } +func latestNewerLocalImageForReference(current string, candidates []string) (string, bool) { + current = strings.TrimSpace(current) + currentRepo := dockerImageRepository(current) + currentTag := dockerImageTag(current) + if current == "" || currentRepo == "" || currentTag == "" || isDevImageTag(currentTag) { + return "", false + } + best := "" + for _, candidate := range candidates { + candidate = strings.TrimSpace(candidate) + if candidate == "" || candidate == current { + continue + } + if !strings.EqualFold(currentRepo, dockerImageRepository(candidate)) { + continue + } + if cmp, ok := compareImageTags(currentTag, dockerImageTag(candidate)); !ok || cmp >= 0 { + continue + } + if best == "" { + best = candidate + continue + } + if cmp, ok := compareImageTags(dockerImageTag(best), dockerImageTag(candidate)); ok && cmp < 0 { + best = candidate + } + } + return best, best != "" +} + func imageNeedsDefaultRecreate(current, latest string) bool { current = strings.TrimSpace(current) latest = strings.TrimSpace(latest) @@ -91,7 +167,25 @@ func imageNeedsDefaultRecreate(current, latest string) bool { } currentRepo := dockerImageRepository(current) latestRepo := dockerImageRepository(latest) - return currentRepo != "" && latestRepo != "" && strings.EqualFold(currentRepo, latestRepo) + if currentRepo == "" || latestRepo == "" || !strings.EqualFold(currentRepo, latestRepo) { + return false + } + currentTag := dockerImageTag(current) + latestTag := dockerImageTag(latest) + if currentTag == latestTag { + return false + } + if isDevImageTag(currentTag) || isDevImageTag(latestTag) { + return false + } + if cmp, ok := compareImageTags(currentTag, latestTag); ok { + return cmp < 0 + } + return true +} + +func isDevImageTag(tag string) bool { + return strings.EqualFold(strings.TrimSpace(tag), "dev") } func dockerImageRepository(ref string) string { @@ -109,3 +203,77 @@ func dockerImageRepository(ref string) string { } return strings.TrimSpace(ref) } + +func dockerImageTag(ref string) string { + ref = strings.TrimSpace(ref) + if ref == "" { + return "" + } + if beforeDigest, _, ok := strings.Cut(ref, "@"); ok { + ref = beforeDigest + } + lastSlash := strings.LastIndex(ref, "/") + lastColon := strings.LastIndex(ref, ":") + if lastColon > lastSlash { + return strings.TrimSpace(ref[lastColon+1:]) + } + return "" +} + +func compareImageTags(current, latest string) (int, bool) { + currentParts, ok := parseNumericImageTag(current) + if !ok { + return 0, false + } + latestParts, ok := parseNumericImageTag(latest) + if !ok { + return 0, false + } + maxLen := len(currentParts) + if len(latestParts) > maxLen { + maxLen = len(latestParts) + } + for i := 0; i < maxLen; i++ { + currentPart := 0 + if i < len(currentParts) { + currentPart = currentParts[i] + } + latestPart := 0 + if i < len(latestParts) { + latestPart = latestParts[i] + } + if currentPart < latestPart { + return -1, true + } + if currentPart > latestPart { + return 1, true + } + } + return 0, true +} + +func parseNumericImageTag(tag string) ([]int, bool) { + tag = strings.TrimSpace(tag) + tag = strings.TrimPrefix(strings.ToLower(tag), "v") + if tag == "" { + return nil, false + } + fields := strings.FieldsFunc(tag, func(r rune) bool { + return r == '.' || r == '-' || r == '_' + }) + if len(fields) == 0 { + return nil, false + } + parts := make([]int, 0, len(fields)) + for _, field := range fields { + if field == "" { + return nil, false + } + part, err := strconv.Atoi(field) + if err != nil { + return nil, false + } + parts = append(parts, part) + } + return parts, true +} diff --git a/internal/agent/service.go b/internal/agent/service.go index 86825bcf..86b948c2 100644 --- a/internal/agent/service.go +++ b/internal/agent/service.go @@ -1443,8 +1443,9 @@ func (s *Service) List() []Agent { agents := sortedAgentsFromMap(s.agents) s.mu.RUnlock() ctx := context.Background() + localImages := s.localImageCandidates(ctx) for idx := range agents { - agents[idx] = s.withRuntimeImageMigrationStatus(ctx, s.hydrateAgentStatus(ctx, agents[idx])) + agents[idx] = s.withRuntimeImageMigrationStatusFromCandidates(ctx, s.hydrateAgentStatus(ctx, agents[idx]), localImages) } return agents } diff --git a/internal/agent/service_profiles.go b/internal/agent/service_profiles.go index 6f9e2f15..4fda2d40 100644 --- a/internal/agent/service_profiles.go +++ b/internal/agent/service_profiles.go @@ -304,7 +304,7 @@ func (s *Service) Recreate(ctx context.Context, id string) (Agent, error) { func (s *Service) Upgrade(ctx context.Context, id string) (Agent, error) { return s.recreate(ctx, id, func(ctx context.Context, got Agent) (string, error) { - latest, ok := s.currentDefaultImageForAgent(ctx, got) + latest, ok := s.imageForUpgrade(ctx, got) if !ok || strings.TrimSpace(latest) == "" { return "", fmt.Errorf("agent %q has no default image to upgrade", got.ID) } diff --git a/internal/agent/service_test.go b/internal/agent/service_test.go index 99d12326..6bf7922b 100644 --- a/internal/agent/service_test.go +++ b/internal/agent/service_test.go @@ -4179,6 +4179,114 @@ func TestAgentMarksOutdatedManagerImageUpgradeRequiredWhenGatewayRuntimeChanged( } } +func TestAgentMarksOutdatedManagerImageUpgradeRequiredFromLocalSameRepositoryCandidate(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + provider := sandboxtest.NewProvider() + provider.Images = []string{ + "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/picoclaw-manager:dev", + "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/picoclaw:2026.6.8", + "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/picoclaw:participant-local", + "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/picoclaw:2026.5.27", + } + + svc, err := NewService( + testModelConfig(), + config.ServerConfig{}, + "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/picoclaw-manager:dev", + "", + WithSandboxProvider(provider), + WithRuntime(fakeAgentRuntime{ + kind: RuntimeKindPicoClawSandbox, + info: func(_ context.Context, h agentruntime.Handle) (agentruntime.Info, error) { + return agentruntime.Info{HandleID: h.HandleID, State: agentruntime.StateRunning}, nil + }, + }), + ) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + svc.agents[ManagerUserID] = Agent{ + ID: ManagerUserID, + Name: ManagerName, + RuntimeID: runtimeIDForAgentID(ManagerUserID), + RuntimeKind: RuntimeKindPicoClawSandbox, + Image: "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/picoclaw:2026.5.27", + BoxID: "box-manager", + Role: RoleManager, + Status: string(agentruntime.StateRunning), + CreatedAt: time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC), + AgentProfile: AgentProfile{ + Name: ManagerName, + Provider: ProviderCodex, + ModelID: "gpt-5.5", + ProfileComplete: true, + }, + ProfileComplete: true, + } + + got, ok := svc.Agent(ManagerUserID) + if !ok { + t.Fatal("Agent() ok = false, want true") + } + if !got.AgentProfile.ImageUpgradeRequired { + t.Fatalf("Agent().AgentProfile.ImageUpgradeRequired = false, want true for newer same-repository local image") + } +} + +func TestAgentDevImageDoesNotRequireUpgrade(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + provider := sandboxtest.NewProvider() + provider.Images = []string{ + "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/picoclaw:2026.6.8", + "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/picoclaw:dev", + } + + svc, err := NewService( + testModelConfig(), + config.ServerConfig{}, + "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/picoclaw:2026.6.8", + "", + WithSandboxProvider(provider), + WithRuntime(fakeAgentRuntime{ + kind: RuntimeKindPicoClawSandbox, + info: func(_ context.Context, h agentruntime.Handle) (agentruntime.Info, error) { + return agentruntime.Info{HandleID: h.HandleID, State: agentruntime.StateRunning}, nil + }, + }), + ) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + svc.agents[ManagerUserID] = Agent{ + ID: ManagerUserID, + Name: ManagerName, + RuntimeID: runtimeIDForAgentID(ManagerUserID), + RuntimeKind: RuntimeKindPicoClawSandbox, + Image: "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/picoclaw:dev", + BoxID: "box-manager", + Role: RoleManager, + Status: string(agentruntime.StateRunning), + CreatedAt: time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC), + AgentProfile: AgentProfile{ + Name: ManagerName, + Provider: ProviderCodex, + ModelID: "gpt-5.5", + ProfileComplete: true, + }, + ProfileComplete: true, + } + + got, ok := svc.Agent(ManagerUserID) + if !ok { + t.Fatal("Agent() ok = false, want true") + } + if got.AgentProfile.ImageUpgradeRequired { + t.Fatalf("Agent().AgentProfile.ImageUpgradeRequired = true, want false for dev image") + } +} + func TestRecreateUsesLatestDefaultTemplateImageAndPreservesUserSkills(t *testing.T) { homeDir := t.TempDir() t.Setenv("HOME", homeDir) diff --git a/internal/api/csgclaw_channel_test.go b/internal/api/csgclaw_channel_test.go index a5430ed6..f66fed56 100644 --- a/internal/api/csgclaw_channel_test.go +++ b/internal/api/csgclaw_channel_test.go @@ -43,7 +43,7 @@ func TestHandleCsgclawChannelRoutesMirrorLocalCollections(t *testing.T) { if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { t.Fatalf("decode users: %v", err) } - if len(got) < 2 || got[0].ID != "u-admin" { + if len(got) < 2 || got[0].ID != "admin" { t.Fatalf("users = %+v, want local users through csgclaw channel route", got) } }) diff --git a/internal/api/handler.go b/internal/api/handler.go index 9a7478e8..87645311 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -1470,7 +1470,7 @@ func (h *Handler) handleCreateUser(w http.ResponseWriter, r *http.Request) { } if user, ok := h.im.User(created.ChannelUserRef); ok { h.publishUserEvent(im.EventTypeUserCreated, user) - if room, ok := h.directRoomWithMembers("u-admin", user.ID); ok { + if room, ok := h.directRoomWithMembers(im.AdminUserID, user.ID); ok { h.publishRoomEvent(im.EventTypeRoomCreated, room) } writeJSON(w, http.StatusCreated, user) @@ -1497,7 +1497,7 @@ func (h *Handler) handleCreateUser(w http.ResponseWriter, r *http.Request) { func shouldCreateWorkerForUser(id, role string) bool { id = strings.TrimSpace(id) switch strings.ToLower(id) { - case "", "u-admin", agent.ManagerUserID: + case "", im.AdminUserID, "u-admin", agent.ManagerUserID: return false } diff --git a/internal/api/handler_test.go b/internal/api/handler_test.go index 18ad81b0..c0afc520 100644 --- a/internal/api/handler_test.go +++ b/internal/api/handler_test.go @@ -357,7 +357,7 @@ func TestHandleRoomsMembersListsCsgclawMembers(t *testing.T) { if err := json.NewDecoder(rec.Body).Decode(&members); err != nil { t.Fatalf("decode members: %v", err) } - if len(members) != 2 || members[0].ID != "u-admin" || members[1].ID != "u-alice" { + if len(members) != 2 || members[0].ID != "admin" || members[1].ID != "u-alice" { t.Fatalf("members = %+v, want room members", members) } } @@ -558,6 +558,372 @@ func TestHandleAgentUpgradeUsesLatestDefaultImage(t *testing.T) { } } +func TestHandleAgentsListReportsImageUpgradeRequiredByImageTag(t *testing.T) { + tests := []struct { + name string + currentImage string + latestImage string + wantRequired bool + }{ + { + name: "older tag requires upgrade", + currentImage: "registry.example/picoclaw-worker:2026.05.27", + latestImage: "registry.example/picoclaw-worker:2026.06.03", + wantRequired: true, + }, + { + name: "newer tag does not require upgrade", + currentImage: "registry.example/picoclaw-worker:2026.06.09", + latestImage: "registry.example/picoclaw-worker:2026.06.03", + wantRequired: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Cleanup(agent.TestOnlySetSandboxProvider(sandboxtest.NewProvider())) + hubSvc := mustNewLocalTemplateHubServiceWithoutWorkspace(t, "frontend-worker", hub.Template{ + ID: "frontend-worker", + Name: "frontend-worker", + Description: "frontend worker", + Role: hub.TemplateRoleWorker, + RuntimeKind: agent.RuntimeKindPicoClawSandbox, + Image: tt.latestImage, + }) + statePath := filepath.Join(t.TempDir(), "agents.json") + if err := writeSeededAgents(statePath, []agent.Agent{ + { + ID: "u-alice", + Name: "alice", + RuntimeID: "rt-u-alice", + RuntimeKind: agent.RuntimeKindPicoClawSandbox, + Image: tt.currentImage, + BoxID: "box-alice", + Role: agent.RoleWorker, + Status: string(agentruntime.StateRunning), + AgentProfile: agent.AgentProfile{Name: "alice", Provider: agent.ProviderCodex, ModelID: "gpt-5.5", ProfileComplete: true}, + CreatedAt: time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC), + }, + }); err != nil { + t.Fatalf("writeSeededAgents() error = %v", err) + } + svc, err := agent.NewService( + config.ModelConfig{}, + config.ServerConfig{}, + "manager-image:test", + statePath, + agent.WithHubService(hubSvc), + agent.WithBootstrapDefaultTemplates(config.BootstrapConfig{DefaultWorkerTemplate: "local/frontend-worker"}), + agent.WithRuntime(fakeCompatRuntime{ + info: func(_ context.Context, h agentruntime.Handle) (agentruntime.Info, error) { + return agentruntime.Info{HandleID: h.HandleID, State: agentruntime.StateRunning}, nil + }, + }), + ) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + + srv := &Handler{svc: svc} + req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil) + rec := httptest.NewRecorder() + + srv.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + var got []agentResponse + if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(got) != 1 { + t.Fatalf("len(agents) = %d, want 1", len(got)) + } + if got[0].AgentProfile.ImageUpgradeRequired != tt.wantRequired { + t.Fatalf("image_upgrade_required = %t, want %t; response=%+v", got[0].AgentProfile.ImageUpgradeRequired, tt.wantRequired, got[0]) + } + }) + } +} + +func TestHandleManagerGetReportsImageUpgradeRequiredByImageTag(t *testing.T) { + tests := []struct { + name string + currentImage string + latestImage string + wantRequired bool + }{ + { + name: "older manager tag requires upgrade", + currentImage: "registry.example/opencsghq/picoclaw:2026.5.22", + latestImage: "registry.example/opencsghq/picoclaw:2026.6.3", + wantRequired: true, + }, + { + name: "newer manager tag does not require upgrade", + currentImage: "registry.example/opencsghq/picoclaw:2026.6.9", + latestImage: "registry.example/opencsghq/picoclaw:2026.6.3", + wantRequired: false, + }, + { + name: "dev manager tag does not require upgrade", + currentImage: "registry.example/opencsghq/picoclaw:dev", + latestImage: "registry.example/opencsghq/picoclaw:2026.6.3", + wantRequired: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Cleanup(agent.TestOnlySetSandboxProvider(sandboxtest.NewProvider())) + statePath := filepath.Join(t.TempDir(), "agents.json") + if err := writeSeededAgents(statePath, []agent.Agent{ + { + ID: agent.ManagerUserID, + Name: agent.ManagerName, + RuntimeID: "rt-manager", + RuntimeKind: agent.RuntimeKindPicoClawSandbox, + Image: tt.currentImage, + BoxID: "box-manager", + Role: agent.RoleManager, + Status: string(agentruntime.StateRunning), + AgentProfile: agent.AgentProfile{Name: agent.ManagerName, Provider: agent.ProviderCodex, ModelID: "gpt-5.5", ProfileComplete: true}, + CreatedAt: time.Date(2026, 5, 22, 12, 0, 0, 0, time.UTC), + }, + }); err != nil { + t.Fatalf("writeSeededAgents() error = %v", err) + } + svc, err := agent.NewService( + config.ModelConfig{}, + config.ServerConfig{}, + tt.latestImage, + statePath, + agent.WithRuntime(fakeCompatRuntime{ + info: func(_ context.Context, h agentruntime.Handle) (agentruntime.Info, error) { + return agentruntime.Info{HandleID: h.HandleID, State: agentruntime.StateRunning}, nil + }, + }), + ) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + + srv := &Handler{svc: svc} + req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/u-manager", nil) + rec := httptest.NewRecorder() + + srv.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + var got agentResponse + if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { + t.Fatalf("decode response: %v", err) + } + if got.AgentProfile.ImageUpgradeRequired != tt.wantRequired { + t.Fatalf("manager image_upgrade_required = %t, want %t; response=%+v", got.AgentProfile.ImageUpgradeRequired, tt.wantRequired, got) + } + }) + } +} + +func TestHandleAgentUpgradeClearsOutdatedImageFlag(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Cleanup(agent.TestOnlySetSandboxProvider(sandboxtest.NewProvider())) + hubSvc := mustNewLocalTemplateHubServiceWithoutWorkspace(t, "frontend-worker", hub.Template{ + ID: "frontend-worker", + Name: "frontend-worker", + Description: "frontend worker", + Role: hub.TemplateRoleWorker, + RuntimeKind: agent.RuntimeKindPicoClawSandbox, + Image: "registry.example/picoclaw-worker:2026.06.03", + }) + statePath := filepath.Join(t.TempDir(), "agents.json") + if err := writeSeededAgents(statePath, []agent.Agent{ + { + ID: "u-alice", + Name: "alice", + RuntimeID: "rt-u-alice", + RuntimeKind: agent.RuntimeKindPicoClawSandbox, + Image: "registry.example/picoclaw-worker:2026.05.27", + BoxID: "box-alice-old", + Role: agent.RoleWorker, + Status: string(agentruntime.StateRunning), + AgentProfile: agent.AgentProfile{Name: "alice", Provider: agent.ProviderCodex, ModelID: "gpt-5.5", ProfileComplete: true}, + CreatedAt: time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC), + }, + }); err != nil { + t.Fatalf("writeSeededAgents() error = %v", err) + } + var newImage string + svc, err := agent.NewService( + config.ModelConfig{}, + config.ServerConfig{}, + "manager-image:test", + statePath, + agent.WithHubService(hubSvc), + agent.WithBootstrapDefaultTemplates(config.BootstrapConfig{DefaultWorkerTemplate: "local/frontend-worker"}), + agent.WithRuntime(fakeCompatRuntime{ + new: func(_ context.Context, spec agentruntime.Spec) (agentruntime.Handle, error) { + newImage = spec.Image + return agentruntime.Handle{RuntimeID: spec.RuntimeID, HandleID: "box-alice-new"}, nil + }, + info: func(_ context.Context, h agentruntime.Handle) (agentruntime.Info, error) { + return agentruntime.Info{HandleID: h.HandleID, State: agentruntime.StateRunning, CreatedAt: time.Date(2026, 6, 3, 10, 0, 0, 0, time.UTC)}, nil + }, + }), + ) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + + srv := &Handler{svc: svc} + beforeReq := httptest.NewRequest(http.MethodGet, "/api/v1/agents/u-alice", nil) + beforeRec := httptest.NewRecorder() + srv.Routes().ServeHTTP(beforeRec, beforeReq) + if beforeRec.Code != http.StatusOK { + t.Fatalf("pre-upgrade status = %d, want %d; body=%s", beforeRec.Code, http.StatusOK, beforeRec.Body.String()) + } + var before agentResponse + if err := json.NewDecoder(beforeRec.Body).Decode(&before); err != nil { + t.Fatalf("decode pre-upgrade response: %v", err) + } + if !before.AgentProfile.ImageUpgradeRequired { + t.Fatalf("pre-upgrade image_upgrade_required = false, want true; response=%+v", before) + } + + upgradeReq := httptest.NewRequest(http.MethodPost, "/api/v1/agents/u-alice/upgrade", nil) + upgradeRec := httptest.NewRecorder() + srv.Routes().ServeHTTP(upgradeRec, upgradeReq) + if upgradeRec.Code != http.StatusOK { + t.Fatalf("upgrade status = %d, want %d; body=%s", upgradeRec.Code, http.StatusOK, upgradeRec.Body.String()) + } + if newImage != "registry.example/picoclaw-worker:2026.06.03" { + t.Fatalf("runtime New() image = %q, want latest default image", newImage) + } + var upgraded agentResponse + if err := json.NewDecoder(upgradeRec.Body).Decode(&upgraded); err != nil { + t.Fatalf("decode upgrade response: %v", err) + } + if upgraded.Image != "registry.example/picoclaw-worker:2026.06.03" { + t.Fatalf("upgrade response Image = %q, want latest default image", upgraded.Image) + } + if upgraded.AgentProfile.ImageUpgradeRequired { + t.Fatalf("upgrade response image_upgrade_required = true, want false; response=%+v", upgraded) + } + + afterReq := httptest.NewRequest(http.MethodGet, "/api/v1/agents/u-alice", nil) + afterRec := httptest.NewRecorder() + srv.Routes().ServeHTTP(afterRec, afterReq) + if afterRec.Code != http.StatusOK { + t.Fatalf("post-upgrade status = %d, want %d; body=%s", afterRec.Code, http.StatusOK, afterRec.Body.String()) + } + var after agentResponse + if err := json.NewDecoder(afterRec.Body).Decode(&after); err != nil { + t.Fatalf("decode post-upgrade response: %v", err) + } + if after.Image != "registry.example/picoclaw-worker:2026.06.03" || after.AgentProfile.ImageUpgradeRequired { + t.Fatalf("post-upgrade response = %+v, want latest image and no image upgrade flag", after) + } +} + +func TestHandleManagerUpgradeUsesNewerLocalSameRepositoryImage(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + provider := sandboxtest.NewProvider() + provider.Images = []string{ + "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/picoclaw-manager:dev", + "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/picoclaw:2026.6.8", + "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/picoclaw:participant-local", + "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/picoclaw:2026.5.27", + } + statePath := filepath.Join(t.TempDir(), "agents.json") + if err := writeSeededAgents(statePath, []agent.Agent{ + { + ID: agent.ManagerUserID, + Name: agent.ManagerName, + RuntimeID: "rt-manager", + RuntimeKind: agent.RuntimeKindPicoClawSandbox, + Image: "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/picoclaw:2026.5.27", + BoxID: "box-manager-old", + Role: agent.RoleManager, + Status: string(agentruntime.StateRunning), + AgentProfile: agent.AgentProfile{Name: agent.ManagerName, Provider: agent.ProviderCodex, ModelID: "gpt-5.5", ProfileComplete: true}, + CreatedAt: time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC), + }, + }); err != nil { + t.Fatalf("writeSeededAgents() error = %v", err) + } + var newImage string + svc, err := agent.NewService( + config.ModelConfig{}, + config.ServerConfig{}, + "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/picoclaw-manager:dev", + statePath, + agent.WithSandboxProvider(provider), + agent.WithRuntime(fakeCompatRuntime{ + new: func(_ context.Context, spec agentruntime.Spec) (agentruntime.Handle, error) { + newImage = spec.Image + return agentruntime.Handle{RuntimeID: spec.RuntimeID, HandleID: "box-manager-new"}, nil + }, + info: func(_ context.Context, h agentruntime.Handle) (agentruntime.Info, error) { + return agentruntime.Info{HandleID: h.HandleID, State: agentruntime.StateRunning, CreatedAt: time.Date(2026, 6, 8, 10, 0, 0, 0, time.UTC)}, nil + }, + }), + ) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + + srv := &Handler{svc: svc} + beforeReq := httptest.NewRequest(http.MethodGet, "/api/v1/agents/u-manager", nil) + beforeRec := httptest.NewRecorder() + srv.Routes().ServeHTTP(beforeRec, beforeReq) + if beforeRec.Code != http.StatusOK { + t.Fatalf("pre-upgrade status = %d, want %d; body=%s", beforeRec.Code, http.StatusOK, beforeRec.Body.String()) + } + var before agentResponse + if err := json.NewDecoder(beforeRec.Body).Decode(&before); err != nil { + t.Fatalf("decode pre-upgrade response: %v", err) + } + if !before.AgentProfile.ImageUpgradeRequired { + t.Fatalf("pre-upgrade image_upgrade_required = false, want true; response=%+v", before) + } + + upgradeReq := httptest.NewRequest(http.MethodPost, "/api/v1/agents/u-manager/upgrade", nil) + upgradeRec := httptest.NewRecorder() + srv.Routes().ServeHTTP(upgradeRec, upgradeReq) + if upgradeRec.Code != http.StatusOK { + t.Fatalf("upgrade status = %d, want %d; body=%s", upgradeRec.Code, http.StatusOK, upgradeRec.Body.String()) + } + wantImage := "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/picoclaw:2026.6.8" + if newImage != wantImage { + t.Fatalf("runtime New() image = %q, want %q", newImage, wantImage) + } + var upgraded agentResponse + if err := json.NewDecoder(upgradeRec.Body).Decode(&upgraded); err != nil { + t.Fatalf("decode upgrade response: %v", err) + } + if upgraded.Image != wantImage || upgraded.AgentProfile.ImageUpgradeRequired { + t.Fatalf("upgrade response = %+v, want latest same-repository image and no image upgrade flag", upgraded) + } + + afterReq := httptest.NewRequest(http.MethodGet, "/api/v1/agents/u-manager", nil) + afterRec := httptest.NewRecorder() + srv.Routes().ServeHTTP(afterRec, afterReq) + if afterRec.Code != http.StatusOK { + t.Fatalf("post-upgrade status = %d, want %d; body=%s", afterRec.Code, http.StatusOK, afterRec.Body.String()) + } + var after agentResponse + if err := json.NewDecoder(afterRec.Body).Decode(&after); err != nil { + t.Fatalf("decode post-upgrade response: %v", err) + } + if after.Image != wantImage || after.AgentProfile.ImageUpgradeRequired { + t.Fatalf("post-upgrade response = %+v, want latest same-repository image and no image upgrade flag", after) + } +} + func TestHandleAgentsListRedactsProfileAPIKey(t *testing.T) { svc := mustNewSeededService(t, []agent.Agent{ { @@ -1967,7 +2333,7 @@ func TestHandleUsersCreateProvisionsIMUser(t *testing.T) { t.Fatal("User(u-alice) ok = false, want true after create") } rooms := srv.im.ListRooms() - if len(rooms) != 1 || !containsMember(rooms[0].Members, "u-admin") || !containsMember(rooms[0].Members, "u-alice") { + if len(rooms) != 1 || !containsMember(rooms[0].Members, "admin") || !containsMember(rooms[0].Members, "u-alice") { t.Fatalf("rooms = %+v, want one bootstrap room with admin and u-alice", rooms) } @@ -2252,7 +2618,7 @@ func TestHandleMessagesPostCreatesMessage(t *testing.T) { if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { t.Fatalf("decode response: %v", err) } - if got.SenderID != "u-admin" || got.Content != "hello @manager" { + if got.SenderID != "admin" || got.Content != "hello @manager" { t.Fatalf("message = %+v, want sender/content populated", got) } if len(got.Mentions) != 1 || got.Mentions[0].ID != "manager" || got.Mentions[0].Name != "manager" { @@ -2903,7 +3269,7 @@ func TestHandleRoomsPostCreatesRoom(t *testing.T) { if got.Title != "Launch" { t.Fatalf("conversation.Title = %q, want Launch", got.Title) } - if !containsMember(got.Members, "u-admin") || !containsMember(got.Members, "u-alice") || !containsMember(got.Members, "manager") { + if !containsMember(got.Members, "admin") || !containsMember(got.Members, "u-alice") || !containsMember(got.Members, "manager") { t.Fatalf("members = %+v, want admin, alice, and manager", got.Members) } } @@ -2931,7 +3297,7 @@ func TestHandleRoomsPostUsesCsgclawChannelAdapter(t *testing.T) { if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { t.Fatalf("decode response: %v", err) } - if !containsMember(got.Members, "u-admin") || !containsMember(got.Members, "u-alice") { + if !containsMember(got.Members, "admin") || !containsMember(got.Members, "u-alice") { t.Fatalf("members = %+v, want trimmed bot IDs", got.Members) } } diff --git a/internal/im/provisioning.go b/internal/im/provisioning.go index ecd08976..679a8f3d 100644 --- a/internal/im/provisioning.go +++ b/internal/im/provisioning.go @@ -71,7 +71,7 @@ func (p *Provisioner) scheduleBootstrapMessage(roomID, name, description string) time.Sleep(p.bootstrapDelay) message, err := p.service.CreateMessage(CreateMessageRequest{ RoomID: roomID, - SenderID: "u-admin", + SenderID: adminUserID, Content: buildWorkerBootstrapMessage(name, description), }) if err != nil { diff --git a/internal/im/provisioning_test.go b/internal/im/provisioning_test.go index c4cd39cc..3b027d0f 100644 --- a/internal/im/provisioning_test.go +++ b/internal/im/provisioning_test.go @@ -42,7 +42,7 @@ func TestProvisionerEnsureAgentUserPublishesBootstrapRoom(t *testing.T) { if second.Room.Title != "alice" { t.Fatalf("second event.Room.Title = %q, want %q", second.Room.Title, "alice") } - if !containsUserIDInRoom(*second.Room, "u-admin") || !containsUserIDInRoom(*second.Room, "u-alice") { + if !containsUserIDInRoom(*second.Room, "admin") || !containsUserIDInRoom(*second.Room, "u-alice") { t.Fatalf("second event.Room.Members = %+v, want admin and worker", second.Room.Members) } @@ -59,15 +59,15 @@ func TestProvisionerEnsureAgentUserPublishesBootstrapRoom(t *testing.T) { if third.Message == nil { t.Fatal("third event.Message = nil, want bootstrap message") } - if third.Message.SenderID != "u-admin" { - t.Fatalf("third event.Message.SenderID = %q, want %q", third.Message.SenderID, "u-admin") + if third.Message.SenderID != "admin" { + t.Fatalf("third event.Message.SenderID = %q, want %q", third.Message.SenderID, "admin") } wantContent := "Write this down in your memory: your name is Alice. Your responsibility is test lead" if third.Message.Content != wantContent { t.Fatalf("third event.Message.Content = %q, want %q", third.Message.Content, wantContent) } - if third.Sender == nil || third.Sender.ID != "u-admin" { - t.Fatalf("third event.Sender = %+v, want u-admin", third.Sender) + if third.Sender == nil || third.Sender.ID != "admin" { + t.Fatalf("third event.Sender = %+v, want admin", third.Sender) } } diff --git a/internal/im/service.go b/internal/im/service.go index 6b637163..00f16d6a 100644 --- a/internal/im/service.go +++ b/internal/im/service.go @@ -160,7 +160,9 @@ func HasMentionTagForUser(content, userID string) bool { const ( sessionsDirName = "sessions" - adminUserID = "u-admin" + AdminUserID = "admin" + adminUserID = AdminUserID + legacyAdminUserID = "u-admin" managerParticipantUserID = "manager" legacyManagerUserID = "u-manager" ) @@ -237,7 +239,7 @@ func NewServiceFromBootstrapWithBus(state Bootstrap, bus *Bus) *Service { func DefaultBootstrap() Bootstrap { return Bootstrap{ - CurrentUserID: "u-admin", + CurrentUserID: adminUserID, Users: nil, Rooms: nil, } @@ -514,10 +516,13 @@ func normalizeBootstrap(state Bootstrap) Bootstrap { if state.CurrentUserID == "" { state.CurrentUserID = DefaultBootstrap().CurrentUserID } + adminAliases := adminUserAliases(state.Users) managerAliases := managerUserAliases(state.Users) state.Users = ensureUsers(state.Users) - state.Rooms = migrateLegacyManagerRoomRefs(cloneRooms(state.Rooms), managerAliases) + state.Rooms = migrateLegacyAdminRoomRefs(cloneRooms(state.Rooms), adminAliases) + state.Rooms = migrateLegacyManagerRoomRefs(state.Rooms, managerAliases) if !containsUserID(state.Users, state.CurrentUserID) { + state.CurrentUserID = migrateLegacyAdminID(state.CurrentUserID, adminAliases) state.CurrentUserID = migrateLegacyManagerID(state.CurrentUserID, managerAliases) if !containsUserID(state.Users, state.CurrentUserID) { state.CurrentUserID = defaultCurrentUserID(state.Users) @@ -544,6 +549,7 @@ func ensureUsers(users []User) []User { } else { for i := range result { if strings.EqualFold(strings.TrimSpace(result[i].Handle), "admin") { + result[i].ID = adminUserID result[i].Name = "admin" result[i].Role = "admin" } @@ -568,10 +574,33 @@ func ensureUsers(users []User) []User { } } } + result = dropLegacyAdminUserDuplicates(result) result = dropLegacyManagerUserDuplicates(result) return result } +func dropLegacyAdminUserDuplicates(users []User) []User { + out := make([]User, 0, len(users)) + seen := make(map[string]struct{}, len(users)) + for _, user := range users { + id := strings.TrimSpace(user.ID) + if id == "" || id == legacyAdminUserID { + if strings.EqualFold(strings.TrimSpace(user.Handle), "admin") || + strings.EqualFold(strings.TrimSpace(user.Name), "admin") || + strings.EqualFold(strings.TrimSpace(user.Role), "admin") { + id = adminUserID + user.ID = adminUserID + } + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, user) + } + return out +} + func dropLegacyManagerUserDuplicates(users []User) []User { out := make([]User, 0, len(users)) seen := make(map[string]struct{}, len(users)) @@ -631,6 +660,25 @@ func defaultCurrentUserID(users []User) string { return "" } +func adminUserAliases(users []User) map[string]struct{} { + aliases := map[string]struct{}{ + legacyAdminUserID: {}, + adminUserID: {}, + } + for _, user := range users { + id := strings.TrimSpace(user.ID) + if id == "" { + continue + } + if strings.EqualFold(strings.TrimSpace(user.Handle), "admin") || + strings.EqualFold(strings.TrimSpace(user.Name), "admin") || + strings.EqualFold(strings.TrimSpace(user.Role), "admin") { + aliases[id] = struct{}{} + } + } + return aliases +} + func managerUserAliases(users []User) map[string]struct{} { aliases := map[string]struct{}{ legacyManagerUserID: {}, @@ -658,12 +706,34 @@ func cloneRooms(rooms []Room) []Room { return cloned } +func migrateLegacyAdminRoomRefs(rooms []Room, adminAliases map[string]struct{}) []Room { + for i := range rooms { + rooms[i].Members = migrateLegacyAdminIDs(rooms[i].Members, adminAliases) + for j := range rooms[i].Messages { + rooms[i].Messages[j].SenderID = migrateLegacyAdminID(rooms[i].Messages[j].SenderID, adminAliases) + rooms[i].Messages[j].Content = migrateLegacyAdminMentionTags(rooms[i].Messages[j].Content, adminAliases) + if rooms[i].Messages[j].Event != nil { + rooms[i].Messages[j].Event.ActorID = migrateLegacyAdminID(rooms[i].Messages[j].Event.ActorID, adminAliases) + rooms[i].Messages[j].Event.TargetIDs = migrateLegacyAdminIDs(rooms[i].Messages[j].Event.TargetIDs, adminAliases) + } + for k := range rooms[i].Messages[j].Mentions { + rooms[i].Messages[j].Mentions[k].ID = migrateLegacyAdminID(rooms[i].Messages[j].Mentions[k].ID, adminAliases) + } + } + } + return rooms +} + func migrateLegacyManagerRoomRefs(rooms []Room, managerAliases map[string]struct{}) []Room { for i := range rooms { rooms[i].Members = migrateLegacyManagerIDs(rooms[i].Members, managerAliases) for j := range rooms[i].Messages { rooms[i].Messages[j].SenderID = migrateLegacyManagerID(rooms[i].Messages[j].SenderID, managerAliases) rooms[i].Messages[j].Content = migrateLegacyManagerMentionTags(rooms[i].Messages[j].Content, managerAliases) + if rooms[i].Messages[j].Event != nil { + rooms[i].Messages[j].Event.ActorID = migrateLegacyManagerID(rooms[i].Messages[j].Event.ActorID, managerAliases) + rooms[i].Messages[j].Event.TargetIDs = migrateLegacyManagerIDs(rooms[i].Messages[j].Event.TargetIDs, managerAliases) + } for k := range rooms[i].Messages[j].Mentions { rooms[i].Messages[j].Mentions[k].ID = migrateLegacyManagerID(rooms[i].Messages[j].Mentions[k].ID, managerAliases) } @@ -672,6 +742,26 @@ func migrateLegacyManagerRoomRefs(rooms []Room, managerAliases map[string]struct return rooms } +func migrateLegacyAdminIDs(ids []string, adminAliases map[string]struct{}) []string { + if len(ids) == 0 { + return nil + } + out := make([]string, 0, len(ids)) + seen := make(map[string]struct{}, len(ids)) + for _, id := range ids { + id = migrateLegacyAdminID(id, adminAliases) + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out +} + func migrateLegacyManagerIDs(ids []string, managerAliases map[string]struct{}) []string { if len(ids) == 0 { return nil @@ -692,6 +782,14 @@ func migrateLegacyManagerIDs(ids []string, managerAliases map[string]struct{}) [ return out } +func migrateLegacyAdminID(id string, adminAliases map[string]struct{}) string { + id = strings.TrimSpace(id) + if _, ok := adminAliases[id]; ok { + return adminUserID + } + return id +} + func migrateLegacyManagerID(id string, managerAliases map[string]struct{}) string { id = strings.TrimSpace(id) if _, ok := managerAliases[id]; ok { @@ -700,6 +798,16 @@ func migrateLegacyManagerID(id string, managerAliases map[string]struct{}) strin return id } +func migrateLegacyAdminMentionTags(content string, adminAliases map[string]struct{}) string { + for id := range adminAliases { + if id == adminUserID { + continue + } + content = strings.ReplaceAll(content, `user_id="`+id+`"`, `user_id="`+adminUserID+`"`) + } + return content +} + func migrateLegacyManagerMentionTags(content string, managerAliases map[string]struct{}) string { for id := range managerAliases { if id == managerParticipantUserID { @@ -1088,6 +1196,7 @@ func (s *Service) DeleteUser(userID string) error { } s.mu.Lock() + userID = s.resolveUserIDLocked(userID) user, ok := s.users[userID] if !ok { s.mu.Unlock() @@ -1266,10 +1375,11 @@ func (s *Service) UpdateAgentUser(req UpdateAgentUserRequest) (User, bool, error func (s *Service) CreateMessage(req CreateMessageRequest) (Message, error) { content := strings.TrimSpace(req.Content) roomID := strings.TrimSpace(req.RoomID) + senderID := strings.TrimSpace(req.SenderID) if roomID == "" { return Message{}, fmt.Errorf("room_id is required") } - if req.SenderID == "" { + if senderID == "" { return Message{}, fmt.Errorf("sender_id is required") } if content == "" { @@ -1279,7 +1389,8 @@ func (s *Service) CreateMessage(req CreateMessageRequest) (Message, error) { s.mu.Lock() defer s.mu.Unlock() - if _, ok := s.users[req.SenderID]; !ok { + senderID = s.resolveUserIDLocked(senderID) + if _, ok := s.users[senderID]; !ok { return Message{}, fmt.Errorf("sender not found") } content, err := s.contentWithMentionPrefixLocked(content, req.MentionID) @@ -1299,7 +1410,7 @@ func (s *Service) CreateMessage(req CreateMessageRequest) (Message, error) { s.ensureThreadStateLocked(room, relatesTo.EventID) } - message := s.newMessage("", req.SenderID, MessageKindMessage, content) + message := s.newMessage("", senderID, MessageKindMessage, content) message.RelatesTo = relatesTo room.Messages = append(room.Messages, message) if err := s.saveLocked(); err != nil { @@ -1399,21 +1510,23 @@ func (s *Service) publishMessageCreatedLocked(roomID, senderID string, message M func (s *Service) CreateRoom(req CreateRoomRequest) (Room, error) { title := strings.TrimSpace(req.Title) description := strings.TrimSpace(req.Description) + creatorID := strings.TrimSpace(req.CreatorID) if title == "" { return Room{}, fmt.Errorf("title is required") } - if req.CreatorID == "" { + if creatorID == "" { return Room{}, fmt.Errorf("creator_id is required") } s.mu.Lock() defer s.mu.Unlock() - if _, ok := s.users[req.CreatorID]; !ok { + creatorID = s.resolveUserIDLocked(creatorID) + if _, ok := s.users[creatorID]; !ok { return Room{}, fmt.Errorf("creator not found") } - members, err := s.normalizeMembers(req.CreatorID, req.MemberIDs) + members, err := s.normalizeMembers(creatorID, req.MemberIDs) if err != nil { return Room{}, err } @@ -1428,11 +1541,11 @@ func (s *Service) CreateRoom(req CreateRoomRequest) (Room, error) { Messages: []Message{ { ID: fmt.Sprintf("msg-%d", time.Now().UnixNano()), - SenderID: req.CreatorID, + SenderID: creatorID, Kind: MessageKindEvent, Event: &EventPayload{ Key: "room_created", - ActorID: req.CreatorID, + ActorID: creatorID, Title: title, }, CreatedAt: time.Now().UTC(), @@ -1453,10 +1566,11 @@ func (s *Service) CreateConversation(req CreateConversationRequest) (Conversatio func (s *Service) AddRoomMembers(req AddRoomMembersRequest) (Room, error) { roomID := strings.TrimSpace(req.RoomID) + inviterID := strings.TrimSpace(req.InviterID) if roomID == "" { return Room{}, fmt.Errorf("room_id is required") } - if req.InviterID == "" { + if inviterID == "" { return Room{}, fmt.Errorf("inviter_id is required") } if len(req.UserIDs) == 0 { @@ -1470,10 +1584,11 @@ func (s *Service) AddRoomMembers(req AddRoomMembersRequest) (Room, error) { if !ok { return Room{}, fmt.Errorf("room not found") } - if _, ok := s.users[req.InviterID]; !ok { + inviterID = s.resolveUserIDLocked(inviterID) + if _, ok := s.users[inviterID]; !ok { return Room{}, fmt.Errorf("inviter not found") } - if !slices.Contains(room.Members, req.InviterID) { + if !slices.Contains(room.Members, inviterID) { return Room{}, fmt.Errorf("inviter is not a room member") } if room.IsDirect { @@ -1487,7 +1602,7 @@ func (s *Service) AddRoomMembers(req AddRoomMembersRequest) (Room, error) { addedIDs := make([]string, 0, len(req.UserIDs)) for _, userID := range req.UserIDs { - userID = strings.TrimSpace(userID) + userID = s.resolveUserIDLocked(userID) if userID == "" { continue } @@ -1508,11 +1623,11 @@ func (s *Service) AddRoomMembers(req AddRoomMembersRequest) (Room, error) { room.Subtitle = formatRoomSubtitle(len(room.Members)) room.Messages = append(room.Messages, Message{ ID: fmt.Sprintf("msg-%d", time.Now().UnixNano()), - SenderID: req.InviterID, + SenderID: inviterID, Kind: MessageKindEvent, Event: &EventPayload{ Key: "room_members_added", - ActorID: req.InviterID, + ActorID: inviterID, TargetIDs: append([]string(nil), addedIDs...), }, CreatedAt: time.Now().UTC(), @@ -1566,6 +1681,7 @@ func (s *Service) User(userID string) (User, bool) { s.mu.RLock() defer s.mu.RUnlock() + userID = s.resolveUserIDLocked(userID) user, ok := s.users[userID] return user, ok } @@ -1614,7 +1730,7 @@ func (s *Service) normalizeMembers(creatorID string, memberIDs []string) ([]stri seen := map[string]struct{}{creatorID: {}} members := []string{creatorID} for _, userID := range memberIDs { - userID = strings.TrimSpace(userID) + userID = s.resolveUserIDLocked(userID) if userID == "" { continue } @@ -2179,7 +2295,7 @@ func (s *Service) newMessage(messageID, senderID, kind, content string) Message } func (s *Service) contentWithMentionPrefixLocked(content, mentionID string) (string, error) { - mentionID = strings.TrimSpace(mentionID) + mentionID = s.resolveUserIDLocked(mentionID) if mentionID == "" { return content, nil } @@ -2308,7 +2424,7 @@ func (s *Service) ensureAdminAgentRoomLocked(agentID, agentName string) (*Room, if len(room.Members) != 2 { continue } - if containsUserIDInRoom(*room, "u-admin") && containsUserIDInRoom(*room, agentID) { + if containsUserIDInRoom(*room, adminUserID) && containsUserIDInRoom(*room, agentID) { presented := s.presentRoomLocked(*room) return &presented, false } @@ -2321,7 +2437,7 @@ func (s *Service) ensureAdminAgentRoomLocked(agentID, agentName string) (*Room, Subtitle: formatRoomSubtitle(2), Description: fmt.Sprintf("Bootstrap room for admin and %s.", agentName), IsDirect: true, - Members: []string{"u-admin", agentID}, + Members: []string{adminUserID, agentID}, Messages: []Message{ { ID: fmt.Sprintf("msg-%d", now.UnixNano()+1), diff --git a/internal/im/service_test.go b/internal/im/service_test.go index be69655f..2b10bb2a 100644 --- a/internal/im/service_test.go +++ b/internal/im/service_test.go @@ -30,7 +30,7 @@ func TestEnsureWorkerUserCreatesUserAndBootstrapRoom(t *testing.T) { if !room.IsDirect { t.Fatalf("EnsureWorkerUser() room.IsDirect = %v, want true", room.IsDirect) } - if len(room.Members) != 2 || !containsUserIDInRoom(*room, "u-admin") || !containsUserIDInRoom(*room, "u-alice") { + if len(room.Members) != 2 || !containsUserIDInRoom(*room, "admin") || !containsUserIDInRoom(*room, "u-alice") { t.Fatalf("EnsureWorkerUser() room members = %+v, want admin and worker", room.Members) } } @@ -72,7 +72,7 @@ func TestListMembersReturnsRoomMembers(t *testing.T) { if err != nil { t.Fatalf("ListMembers() error = %v", err) } - if len(members) != 2 || members[0].ID != "u-admin" || members[1].ID != "u-alice" { + if len(members) != 2 || members[0].ID != "admin" || members[1].ID != "u-alice" { t.Fatalf("ListMembers() = %+v, want room members in member order", members) } } @@ -111,8 +111,8 @@ func TestAddAgentToRoomSupportsRoomID(t *testing.T) { t.Fatalf("AddAgentToRoom() members = %+v, want agent joined", updated.Members) } last := updated.Messages[len(updated.Messages)-1] - if last.Event == nil || last.Event.Key != "room_members_added" || last.Event.ActorID != "u-admin" { - t.Fatalf("AddAgentToRoom() event = %+v, want structured room_members_added by u-admin", last) + if last.Event == nil || last.Event.Key != "room_members_added" || last.Event.ActorID != "admin" { + t.Fatalf("AddAgentToRoom() event = %+v, want structured room_members_added by admin", last) } if len(last.Event.TargetIDs) != 1 || last.Event.TargetIDs[0] != "u-alice" { t.Fatalf("AddAgentToRoom() target_ids = %+v, want [u-alice]", last.Event.TargetIDs) @@ -141,7 +141,7 @@ func TestCreateRoomStoresStructuredEvent(t *testing.T) { t.Fatalf("CreateRoom() room.IsDirect = %v, want false", room.IsDirect) } got := room.Messages[0] - if got.Kind != MessageKindEvent || got.Event == nil || got.Event.Key != "room_created" || got.Event.ActorID != "u-admin" || got.Event.Title != "Ops" { + if got.Kind != MessageKindEvent || got.Event == nil || got.Event.Key != "room_created" || got.Event.ActorID != "admin" || got.Event.Title != "Ops" { t.Fatalf("CreateRoom() event = %+v, want structured room_created event", got) } if got.Content != "" { @@ -830,7 +830,7 @@ func TestEnsureBootstrapStateCreatesAdminManagerDMWhenOnlyGroupExists(t *testing var dm *Room for i := range loaded.Rooms { room := &loaded.Rooms[i] - if room.IsDirect && len(room.Members) == 2 && containsUserIDInRoom(*room, "u-admin") && containsUserIDInRoom(*room, "manager") { + if room.IsDirect && len(room.Members) == 2 && containsUserIDInRoom(*room, "admin") && containsUserIDInRoom(*room, "manager") { dm = room break } @@ -899,6 +899,62 @@ func TestEnsureBootstrapStateMigratesMisspelledManagerReferences(t *testing.T) { } } +func TestEnsureBootstrapStateMigratesLegacyAdminReferences(t *testing.T) { + dir := t.TempDir() + statePath := filepath.Join(dir, "state.json") + + state := Bootstrap{ + CurrentUserID: "u-admin", + Users: []User{ + {ID: "u-admin", Name: "admin", Handle: "admin", Role: "admin"}, + {ID: "manager", Name: "manager", Handle: "manager", Role: "manager"}, + {ID: "u-alice", Name: "Alice", Handle: "alice", Role: "worker"}, + }, + Rooms: []Room{{ + ID: "room-1", + Title: "Ops", + Members: []string{"u-admin", "manager", "u-alice"}, + Messages: []Message{{ + ID: "msg-1", + SenderID: "u-admin", + Event: &EventPayload{Key: "room_created", ActorID: "u-admin", TargetIDs: []string{"u-admin"}}, + Content: `admin hello`, + CreatedAt: time.Now().UTC(), + Mentions: []Mention{{ID: "u-admin", Name: "admin"}}, + }}, + }}, + } + if err := SaveBootstrap(statePath, state); err != nil { + t.Fatalf("SaveBootstrap() error = %v", err) + } + + if err := EnsureBootstrapState(statePath); err != nil { + t.Fatalf("EnsureBootstrapState() error = %v", err) + } + + loaded, err := LoadBootstrap(statePath) + if err != nil { + t.Fatalf("LoadBootstrap() error = %v", err) + } + if loaded.CurrentUserID != "admin" { + t.Fatalf("CurrentUserID = %q, want admin", loaded.CurrentUserID) + } + if containsUserID(loaded.Users, "u-admin") { + t.Fatal("legacy admin user u-admin still exists") + } + room := loaded.Rooms[0] + if !containsUserIDInRoom(room, "admin") || containsUserIDInRoom(room, "u-admin") { + t.Fatalf("room.Members = %+v, want admin only", room.Members) + } + got := room.Messages[0] + if got.SenderID != "admin" || got.Event == nil || got.Event.ActorID != "admin" || len(got.Event.TargetIDs) != 1 || got.Event.TargetIDs[0] != "admin" || len(got.Mentions) != 1 || got.Mentions[0].ID != "admin" { + t.Fatalf("message = %+v, want admin sender and mention", got) + } + if !strings.Contains(got.Content, `user_id="admin"`) || strings.Contains(got.Content, "u-admin") { + t.Fatalf("message.Content = %q, want admin mention tag", got.Content) + } +} + func TestReloadRefreshesRoomsFromStateFile(t *testing.T) { dir := t.TempDir() statePath := filepath.Join(dir, "state.json") diff --git a/internal/im/user_resolve.go b/internal/im/user_resolve.go index e533e2b8..c25feffe 100644 --- a/internal/im/user_resolve.go +++ b/internal/im/user_resolve.go @@ -19,6 +19,11 @@ func (s *Service) resolveUserIDLocked(userID string) string { if userID == "" || s == nil { return userID } + if userID == legacyAdminUserID { + if _, ok := s.users[adminUserID]; ok { + return adminUserID + } + } if _, ok := s.users[userID]; ok { return userID } diff --git a/internal/onboard/detect.go b/internal/onboard/detect.go index 2b58fcf0..a6ad0d9d 100644 --- a/internal/onboard/detect.go +++ b/internal/onboard/detect.go @@ -47,6 +47,7 @@ type DetectStateResult struct { ConfigComplete bool IMBootstrapComplete bool ManagerAgentComplete bool + AdminParticipantComplete bool ManagerParticipantComplete bool } @@ -55,6 +56,7 @@ func (r DetectStateResult) Complete() bool { r.ConfigComplete && r.IMBootstrapComplete && r.ManagerAgentComplete && + r.AdminParticipantComplete && r.ManagerParticipantComplete } @@ -115,7 +117,9 @@ func DetectState(opts DetectStateOptions) (DetectStateResult, error) { if err != nil { return DetectStateResult{}, err } - result.ManagerParticipantComplete = managerParticipantComplete(store.List(participant.ListOptions{Channel: participant.ChannelCSGClaw})) + participants := store.List(participant.ListOptions{Channel: participant.ChannelCSGClaw}) + result.AdminParticipantComplete = adminParticipantComplete(participants) + result.ManagerParticipantComplete = managerParticipantComplete(participants) return result, nil } @@ -125,7 +129,7 @@ func imBootstrapComplete(state im.Bootstrap) bool { if len(state.InviteDraftUserIDs) > 0 { return false } - if !hasIMUser(state.Users, "u-admin", "admin", "admin") { + if !hasIMUser(state.Users, im.AdminUserID, "admin", "admin") { return false } if !hasIMUser(state.Users, agent.ManagerParticipantID, "manager", "manager") { @@ -134,7 +138,7 @@ func imBootstrapComplete(state im.Bootstrap) bool { for _, room := range state.Rooms { if room.IsDirect && len(room.Members) == 2 && - containsMember(room.Members, "u-admin") && + containsMember(room.Members, im.AdminUserID) && containsMember(room.Members, agent.ManagerParticipantID) { return true } @@ -184,6 +188,25 @@ func managerAgentComplete(state agentStateReader) bool { return strings.EqualFold(strings.TrimSpace(managerAgent.Role), agent.RoleManager) } +func adminParticipantComplete(items []participant.Participant) bool { + for _, item := range items { + if strings.TrimSpace(item.Channel) != participant.ChannelCSGClaw { + continue + } + if strings.TrimSpace(item.ID) != im.AdminUserID { + continue + } + if !strings.EqualFold(strings.TrimSpace(item.Type), participant.TypeHuman) { + return false + } + if strings.TrimSpace(item.AgentID) != "" { + return false + } + return strings.TrimSpace(item.ChannelUserRef) == im.AdminUserID + } + return false +} + func managerParticipantComplete(items []participant.Participant) bool { for _, item := range items { if strings.TrimSpace(item.Channel) != participant.ChannelCSGClaw { diff --git a/internal/onboard/detect_test.go b/internal/onboard/detect_test.go index 44b5894b..6cf92c69 100644 --- a/internal/onboard/detect_test.go +++ b/internal/onboard/detect_test.go @@ -34,6 +34,9 @@ func TestDetectStateFreshHomeReportsIncompleteBootstrap(t *testing.T) { if result.ManagerAgentComplete { t.Fatal("ManagerAgentComplete = true, want false") } + if result.AdminParticipantComplete { + t.Fatal("AdminParticipantComplete = true, want false") + } if result.ManagerParticipantComplete { t.Fatal("ManagerParticipantComplete = true, want false") } @@ -90,13 +93,27 @@ func TestDetectStateCompleteBootstrapReportsComplete(t *testing.T) { }); err != nil { t.Fatalf("writeManagerBotState() error = %v", err) } + if err := writeParticipantsState(t, []apitypes.Participant{{ + ID: im.AdminUserID, + Channel: participant.ChannelCSGClaw, + Type: participant.TypeHuman, + Name: "admin", + ChannelUserRef: im.AdminUserID, + ChannelUserKind: participant.ChannelUserKindLocalUserID, + LifecycleStatus: participant.LifecycleStatusActive, + Mentionable: true, + CreatedAt: time.Date(2026, 5, 1, 8, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 5, 1, 8, 0, 0, 0, time.UTC), + }}); err != nil { + t.Fatalf("writeParticipantsState() error = %v", err) + } result, err := DetectState(DetectStateOptions{}) if err != nil { t.Fatalf("DetectState() error = %v", err) } - if !result.ConfigExists || !result.ConfigComplete || !result.IMBootstrapComplete || !result.ManagerAgentComplete || !result.ManagerParticipantComplete { + if !result.ConfigExists || !result.ConfigComplete || !result.IMBootstrapComplete || !result.ManagerAgentComplete || !result.AdminParticipantComplete || !result.ManagerParticipantComplete { t.Fatalf("DetectState() completeness = %+v, want all true", result) } if !result.Complete() { @@ -135,6 +152,9 @@ func TestDetectStateFlagsMissingManagerBotWhenOtherBootstrapStateExists(t *testi if !result.ConfigExists || !result.ConfigComplete || !result.IMBootstrapComplete || !result.ManagerAgentComplete { t.Fatalf("DetectState() = %+v, want config/im/agent complete", result) } + if result.AdminParticipantComplete { + t.Fatal("AdminParticipantComplete = true, want false") + } if result.ManagerParticipantComplete { t.Fatal("ManagerParticipantComplete = true, want false") } @@ -143,6 +163,58 @@ func TestDetectStateFlagsMissingManagerBotWhenOtherBootstrapStateExists(t *testi } } +func TestDetectStateFlagsMissingAdminParticipantWhenManagerParticipantExists(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + configPath, err := config.DefaultPath() + if err != nil { + t.Fatalf("DefaultPath() error = %v", err) + } + if err := defaultConfig().Save(configPath); err != nil { + t.Fatalf("Save() error = %v", err) + } + imStatePath, err := config.DefaultIMStatePath() + if err != nil { + t.Fatalf("DefaultIMStatePath() error = %v", err) + } + if err := im.EnsureBootstrapState(imStatePath); err != nil { + t.Fatalf("EnsureBootstrapState() error = %v", err) + } + if err := writeManagerAgentState(t); err != nil { + t.Fatalf("writeManagerAgentState() error = %v", err) + } + if err := writeParticipantsState(t, []apitypes.Participant{{ + ID: agent.ManagerParticipantID, + Channel: participant.ChannelCSGClaw, + Type: participant.TypeAgent, + Name: "manager", + ChannelUserRef: agent.ManagerParticipantID, + ChannelUserKind: participant.ChannelUserKindLocalUserID, + AgentID: agent.ManagerUserID, + LifecycleStatus: participant.LifecycleStatusActive, + Mentionable: true, + CreatedAt: time.Date(2026, 5, 1, 8, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2026, 5, 1, 8, 0, 0, 0, time.UTC), + }}); err != nil { + t.Fatalf("writeParticipantsState() error = %v", err) + } + + result, err := DetectState(DetectStateOptions{}) + if err != nil { + t.Fatalf("DetectState() error = %v", err) + } + + if result.AdminParticipantComplete { + t.Fatal("AdminParticipantComplete = true for manager-only participant state, want false") + } + if !result.ManagerParticipantComplete { + t.Fatal("ManagerParticipantComplete = false, want true for manager participant fixture") + } + if result.Complete() { + t.Fatalf("Complete() = true for manager-only participant state: %+v", result) + } +} + func writeManagerAgentState(t *testing.T) error { t.Helper() @@ -195,6 +267,27 @@ func writeManagerBotState(t *testing.T, manager apitypes.LegacyBot) error { return os.WriteFile(path, append(data, '\n'), 0o600) } +func writeParticipantsState(t *testing.T, participants []apitypes.Participant) error { + t.Helper() + + imStatePath, err := config.DefaultIMStatePath() + if err != nil { + return err + } + path := filepath.Join(filepath.Dir(imStatePath), "participants.json") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + + data, err := json.MarshalIndent(map[string]any{ + "participants": participants, + }, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, append(data, '\n'), 0o600) +} + func assertLegacyBotsMigrated(t *testing.T) { t.Helper() diff --git a/internal/onboard/onboard.go b/internal/onboard/onboard.go index 0e97a55c..8fe1dd1f 100644 --- a/internal/onboard/onboard.go +++ b/internal/onboard/onboard.go @@ -150,6 +150,9 @@ func createManagerParticipant(ctx context.Context, agentsPath, imStatePath strin participant.WithAgentService(agentSvc), participant.WithIMService(imSvc), ) + if _, err := participantSvc.EnsureBootstrapAdmin(ctx); err != nil { + return participant.Participant{}, err + } created, err := participantSvc.EnsureBootstrapManager(ctx) if err != nil { return participant.Participant{}, err diff --git a/internal/onboard/onboard_test.go b/internal/onboard/onboard_test.go index a547610f..40701f1d 100644 --- a/internal/onboard/onboard_test.go +++ b/internal/onboard/onboard_test.go @@ -9,6 +9,7 @@ import ( "csgclaw/internal/agent" "csgclaw/internal/config" + "csgclaw/internal/im" "csgclaw/internal/participant" ) @@ -93,6 +94,39 @@ func TestEnsureStateCreatesConfigAndBootstrapsManagerState(t *testing.T) { } } +func TestCreateManagerParticipantBootstrapsAdminParticipant(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + dir := t.TempDir() + agentsPath := filepath.Join(dir, "agents.json") + imStatePath := filepath.Join(dir, "im", "state.json") + if err := im.EnsureBootstrapState(imStatePath); err != nil { + t.Fatalf("EnsureBootstrapState() error = %v", err) + } + + if _, err := createManagerParticipant(context.Background(), agentsPath, imStatePath, defaultConfig()); err != nil { + t.Fatalf("createManagerParticipant() error = %v", err) + } + + store, err := participant.NewStore(filepath.Join(filepath.Dir(imStatePath), "participants.json")) + if err != nil { + t.Fatalf("participant.NewStore() error = %v", err) + } + admin, ok := store.Get(participant.ChannelCSGClaw, im.AdminUserID) + if !ok { + t.Fatal("admin participant was not created") + } + if admin.Type != participant.TypeHuman { + t.Fatalf("admin participant type = %q, want %q", admin.Type, participant.TypeHuman) + } + if admin.AgentID != "" { + t.Fatalf("admin participant agent_id = %q, want empty", admin.AgentID) + } + if admin.ChannelUserRef != im.AdminUserID { + t.Fatalf("admin participant channel_user_ref = %q, want %q", admin.ChannelUserRef, im.AdminUserID) + } +} + func TestEnsureStatePreservesExistingStaticLLMConfig(t *testing.T) { t.Setenv("HOME", t.TempDir()) diff --git a/internal/participant/service.go b/internal/participant/service.go index f77eac12..152df44b 100644 --- a/internal/participant/service.go +++ b/internal/participant/service.go @@ -108,6 +108,71 @@ func (s *Service) Create(ctx context.Context, req CreateRequest) (apitypes.Parti return created, nil } +func (s *Service) EnsureBootstrapAdmin(_ context.Context) (apitypes.Participant, error) { + if s == nil || s.store == nil { + return apitypes.Participant{}, fmt.Errorf("participant store is required") + } + + now := time.Now().UTC() + createdAt := now + existing, ok := s.store.Get(ChannelCSGClaw, im.AdminUserID) + legacyExisting, legacyOK := s.store.Get(ChannelCSGClaw, legacyAdminParticipantID) + source := existing + hasLegacySource := false + if !ok && legacyOK && isLegacyAdminParticipant(legacyExisting) { + source = legacyExisting + hasLegacySource = true + } + if (ok || hasLegacySource) && !source.CreatedAt.IsZero() { + createdAt = source.CreatedAt.UTC() + } + + name := strings.TrimSpace(source.Name) + if name == "" { + name = "admin" + } + avatar := strings.TrimSpace(source.Avatar) + metadata := map[string]any(nil) + if ok || hasLegacySource { + metadata = cloneMetadata(source.Metadata) + } + if s.im != nil { + if _, _, err := s.im.EnsureAgentUser(im.EnsureAgentUserRequest{ + ID: im.AdminUserID, + Name: "admin", + Handle: "admin", + Role: "admin", + Avatar: avatar, + }); err != nil { + return apitypes.Participant{}, err + } + } + + item := apitypes.Participant{ + ID: im.AdminUserID, + Channel: ChannelCSGClaw, + Type: TypeHuman, + Name: name, + Avatar: avatar, + ChannelUserRef: im.AdminUserID, + ChannelUserKind: ChannelUserKindLocalUserID, + LifecycleStatus: LifecycleStatusActive, + Mentionable: true, + Metadata: metadata, + CreatedAt: createdAt, + UpdatedAt: now, + } + if err := s.store.Save(item); err != nil { + return apitypes.Participant{}, err + } + if legacyOK && isLegacyAdminParticipant(legacyExisting) { + if _, _, err := s.store.Delete(ChannelCSGClaw, legacyAdminParticipantID); err != nil { + return apitypes.Participant{}, err + } + } + return item, nil +} + func (s *Service) EnsureBootstrapManager(ctx context.Context) (apitypes.Participant, error) { if s == nil || s.store == nil { return apitypes.Participant{}, fmt.Errorf("participant store is required") @@ -193,6 +258,21 @@ func (s *Service) EnsureBootstrapManager(ctx context.Context) (apitypes.Particip return item, nil } +const ( + bootstrapAdminParticipantID = "admin" + legacyAdminParticipantID = "u-admin" +) + +func isLegacyAdminParticipant(item apitypes.Participant) bool { + if strings.TrimSpace(item.ID) != legacyAdminParticipantID { + return false + } + if strings.TrimSpace(item.Channel) != ChannelCSGClaw { + return false + } + return true +} + func (s *Service) legacyManagerParticipants() []apitypes.Participant { if s == nil || s.store == nil { return nil diff --git a/internal/participant/service_test.go b/internal/participant/service_test.go index 3b7ff973..3a411726 100644 --- a/internal/participant/service_test.go +++ b/internal/participant/service_test.go @@ -105,6 +105,84 @@ func TestCreateAgentParticipantCanReuseExistingAgentWithDifferentParticipantID(t } } +func TestEnsureBootstrapAdminCreatesHumanParticipantWithoutAgent(t *testing.T) { + imSvc := im.NewService() + store := NewMemoryStore(nil) + svc := NewService(store, WithIMService(imSvc)) + + created, err := svc.EnsureBootstrapAdmin(context.Background()) + if err != nil { + t.Fatalf("EnsureBootstrapAdmin() error = %v", err) + } + + if created.ID != im.AdminUserID { + t.Fatalf("participant ID = %q, want %q", created.ID, im.AdminUserID) + } + if created.Type != TypeHuman { + t.Fatalf("participant type = %q, want %q", created.Type, TypeHuman) + } + if created.AgentID != "" { + t.Fatalf("agent ID = %q, want empty for human admin", created.AgentID) + } + if created.ChannelUserRef != im.AdminUserID { + t.Fatalf("channel user ref = %q, want %q", created.ChannelUserRef, im.AdminUserID) + } + if created.ChannelUserKind != ChannelUserKindLocalUserID { + t.Fatalf("channel user kind = %q, want %q", created.ChannelUserKind, ChannelUserKindLocalUserID) + } + if !created.Mentionable { + t.Fatal("admin participant Mentionable = false, want true") + } + if _, ok := store.Get(ChannelCSGClaw, im.AdminUserID); !ok { + t.Fatal("store missing admin participant") + } + if user, ok := imSvc.User(im.AdminUserID); !ok || user.ID != im.AdminUserID || user.Handle != "admin" || user.Role != "admin" { + t.Fatalf("admin channel user = %+v, ok=%v; want local admin user", user, ok) + } +} + +func TestEnsureBootstrapAdminRenamesLegacyAdminParticipant(t *testing.T) { + imSvc := im.NewService() + createdAt := time.Date(2026, 6, 9, 10, 5, 0, 0, time.UTC) + store := NewMemoryStore([]Participant{{ + ID: "u-admin", + Channel: ChannelCSGClaw, + Type: TypeHuman, + Name: "Local Admin", + Avatar: "avatar.png", + ChannelUserRef: "u-admin", + ChannelUserKind: ChannelUserKindLocalUserID, + AgentID: "u-admin", + LifecycleStatus: LifecycleStatusActive, + Mentionable: true, + Metadata: map[string]any{"legacy": "kept"}, + CreatedAt: createdAt, + UpdatedAt: createdAt, + }}) + svc := NewService(store, WithIMService(imSvc)) + + created, err := svc.EnsureBootstrapAdmin(context.Background()) + if err != nil { + t.Fatalf("EnsureBootstrapAdmin() error = %v", err) + } + + if created.ID != im.AdminUserID || created.Type != TypeHuman { + t.Fatalf("admin participant = %+v, want human participant %q", created, im.AdminUserID) + } + if created.AgentID != "" { + t.Fatalf("agent ID = %q, want empty after admin migration", created.AgentID) + } + if created.ChannelUserRef != im.AdminUserID { + t.Fatalf("channel user ref = %q, want %q", created.ChannelUserRef, im.AdminUserID) + } + if !created.CreatedAt.Equal(createdAt) || created.Avatar != "avatar.png" || created.Metadata["legacy"] != "kept" { + t.Fatalf("admin participant did not preserve legacy fields: %+v", created) + } + if _, ok := store.Get(ChannelCSGClaw, "u-admin"); ok { + t.Fatal("legacy admin participant u-admin was not deleted") + } +} + func TestEnsureBootstrapManagerUsesDefaultParticipantIDSeparateFromAgentID(t *testing.T) { agentSvc := mustNewManagerAgentService(t) imSvc := im.NewService() diff --git a/internal/participant/store.go b/internal/participant/store.go index 0ed27c3c..360ad508 100644 --- a/internal/participant/store.go +++ b/internal/participant/store.go @@ -144,9 +144,10 @@ func (s *Store) load() error { if err != nil { return err } + repairedLegacyAdmin := migrateLegacyCSGClawAdminParticipant(items) repairedLegacyIDs := migrateLegacyCSGClawAgentParticipantIDs(items) s.items = items - if legacyExists || repairedLegacyIDs { + if legacyExists || repairedLegacyAdmin || repairedLegacyIDs { if err := s.saveLocked(); err != nil { return fmt.Errorf("write migrated participant state: %w", err) } @@ -260,6 +261,105 @@ func storeKey(channel, id string) string { return strings.TrimSpace(channel) + "\x00" + strings.TrimSpace(id) } +func migrateLegacyCSGClawAdminParticipant(items map[string]apitypes.Participant) bool { + if len(items) == 0 { + return false + } + + changed := false + adminKey := storeKey(ChannelCSGClaw, bootstrapAdminParticipantID) + legacyKey := storeKey(ChannelCSGClaw, legacyAdminParticipantID) + if legacy, ok := items[legacyKey]; ok && isLegacyStoredAdminParticipant(legacy) { + next := repairStoredAdminParticipant(legacy) + if existing, exists := items[adminKey]; exists { + next = mergeAdminParticipant(existing, next) + } + items[adminKey] = next + delete(items, legacyKey) + changed = true + } + + if existing, ok := items[adminKey]; ok && adminParticipantNeedsRepair(existing) { + items[adminKey] = repairStoredAdminParticipant(existing) + changed = true + } + return changed +} + +func isLegacyStoredAdminParticipant(item apitypes.Participant) bool { + item = normalizeStoredParticipant(item) + return item.Channel == ChannelCSGClaw && item.ID == legacyAdminParticipantID +} + +func adminParticipantNeedsRepair(item apitypes.Participant) bool { + item = normalizeStoredParticipant(item) + if item.Channel != ChannelCSGClaw || item.ID != bootstrapAdminParticipantID { + return false + } + return item.Type != TypeHuman || + item.Name == "" || + item.ChannelUserRef != bootstrapAdminParticipantID || + item.ChannelUserKind != ChannelUserKindLocalUserID || + item.AgentID != "" || + item.LifecycleStatus == "" || + !item.Mentionable +} + +func repairStoredAdminParticipant(item apitypes.Participant) apitypes.Participant { + item = normalizeStoredParticipant(item) + item.ID = bootstrapAdminParticipantID + item.Channel = ChannelCSGClaw + item.Type = TypeHuman + if item.Name == "" { + item.Name = bootstrapAdminParticipantID + } + item.ChannelUserRef = bootstrapAdminParticipantID + item.ChannelUserKind = ChannelUserKindLocalUserID + item.AgentID = "" + if item.LifecycleStatus == "" { + item.LifecycleStatus = LifecycleStatusActive + } + item.Mentionable = true + return item +} + +func mergeAdminParticipant(existing, legacy apitypes.Participant) apitypes.Participant { + merged := repairStoredAdminParticipant(existing) + legacy = repairStoredAdminParticipant(legacy) + if merged.Name == "" { + merged.Name = legacy.Name + } + if merged.Avatar == "" { + merged.Avatar = legacy.Avatar + } + if merged.ChannelUserKind == "" { + merged.ChannelUserKind = legacy.ChannelUserKind + } + if merged.LifecycleStatus == "" { + merged.LifecycleStatus = legacy.LifecycleStatus + } + if merged.Presence == "" { + merged.Presence = legacy.Presence + } + merged.Mentionable = true + 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 migrateLegacyCSGClawAgentParticipantIDs(items map[string]apitypes.Participant) bool { if len(items) == 0 { return false diff --git a/internal/participant/store_test.go b/internal/participant/store_test.go index a8bc8867..a8e2c1ce 100644 --- a/internal/participant/store_test.go +++ b/internal/participant/store_test.go @@ -166,6 +166,68 @@ func TestNewStoreRepairsLegacyPrefixedAgentParticipants(t *testing.T) { } } +func TestNewStoreRepairsLegacyAdminParticipant(t *testing.T) { + dir := t.TempDir() + participantsPath := filepath.Join(dir, "participants.json") + createdAt := time.Date(2026, 6, 9, 11, 30, 0, 0, time.UTC) + writeJSONFile(t, participantsPath, persistedState{Participants: []apitypes.Participant{ + { + ID: "u-admin", + Channel: ChannelCSGClaw, + Type: TypeAgent, + Name: "Admin", + Avatar: "avatar.png", + ChannelUserRef: "u-admin", + ChannelUserKind: ChannelUserKindLocalUserID, + AgentID: "u-admin", + LifecycleStatus: LifecycleStatusActive, + Mentionable: true, + Metadata: map[string]any{"legacy": "kept"}, + CreatedAt: createdAt, + UpdatedAt: createdAt.Add(time.Minute), + }, + }}) + + store, err := NewStore(participantsPath) + if err != nil { + t.Fatalf("NewStore() error = %v", err) + } + + admin, ok := store.Get(ChannelCSGClaw, "admin") + if !ok { + t.Fatal("admin participant was not repaired from legacy u-admin") + } + if admin.Type != TypeHuman { + t.Fatalf("admin type = %q, want %q", admin.Type, TypeHuman) + } + if admin.AgentID != "" { + t.Fatalf("admin agent_id = %q, want empty for human participant", admin.AgentID) + } + if admin.ChannelUserRef != "admin" || admin.ChannelUserKind != ChannelUserKindLocalUserID { + t.Fatalf("admin channel identity = %+v, want local user admin", admin) + } + if !admin.Mentionable || admin.LifecycleStatus != LifecycleStatusActive { + t.Fatalf("admin lifecycle fields = %+v, want active mentionable participant", admin) + } + if !admin.CreatedAt.Equal(createdAt) || !admin.UpdatedAt.Equal(createdAt.Add(time.Minute)) || admin.Avatar != "avatar.png" || admin.Metadata["legacy"] != "kept" { + t.Fatalf("admin preserved fields = %+v, want legacy fields preserved", admin) + } + if _, ok := store.Get(ChannelCSGClaw, "u-admin"); ok { + t.Fatal("legacy admin participant u-admin still exists after repair") + } + + reloaded, err := NewStore(participantsPath) + if err != nil { + t.Fatalf("reload NewStore() error = %v", err) + } + if _, ok := reloaded.Get(ChannelCSGClaw, "admin"); !ok { + t.Fatal("reloaded store missing repaired admin participant") + } + if _, ok := reloaded.Get(ChannelCSGClaw, "u-admin"); ok { + t.Fatal("reloaded store still has legacy participant u-admin") + } +} + func writeJSONFile(t *testing.T, path string, value any) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { diff --git a/internal/templates/embed/openclaw-manager/workspace/skills/manager-worker-dispatch/scripts/manager_worker_api.py b/internal/templates/embed/openclaw-manager/workspace/skills/manager-worker-dispatch/scripts/manager_worker_api.py index b04055c0..04a0a8a5 100644 --- a/internal/templates/embed/openclaw-manager/workspace/skills/manager-worker-dispatch/scripts/manager_worker_api.py +++ b/internal/templates/embed/openclaw-manager/workspace/skills/manager-worker-dispatch/scripts/manager_worker_api.py @@ -499,7 +499,7 @@ def _mock_response( payload: dict[str, Any] | None, ) -> dict[str, Any] | list[dict[str, Any]]: if method == "GET" and path == "/api/v1/bootstrap": - return {"current_user_id": "u-admin", "users": [], "rooms": []} + return {"current_user_id": "admin", "users": [], "rooms": []} if method == "GET" and ( path.startswith("/api/v1/messages?") or path.startswith("/api/v1/channels/feishu/messages?") diff --git a/internal/templates/embed/openclaw-manager/workspace/skills/manager-worker-dispatch/scripts/test_manager_worker_api.py b/internal/templates/embed/openclaw-manager/workspace/skills/manager-worker-dispatch/scripts/test_manager_worker_api.py index a98fc04b..aa34ee42 100644 --- a/internal/templates/embed/openclaw-manager/workspace/skills/manager-worker-dispatch/scripts/test_manager_worker_api.py +++ b/internal/templates/embed/openclaw-manager/workspace/skills/manager-worker-dispatch/scripts/test_manager_worker_api.py @@ -42,7 +42,7 @@ def make_message(sender_id, content, created_at): def make_bootstrap(): return { - "current_user_id": "u-admin", + "current_user_id": "admin", "users": [ {"id": "u-manager", "handle": "manager", "name": "manager"}, {"id": "u-ux", "handle": "ux", "name": "ux"}, @@ -52,7 +52,7 @@ def make_bootstrap(): "rooms": [ { "id": ROOM_ID, - "members": ["u-admin", "u-manager", "u-ux", "u-dev", "u-qa"], + "members": ["admin", "u-manager", "u-ux", "u-dev", "u-qa"], } ], } diff --git a/internal/templates/embed/picoclaw-manager/workspace/skills/manager-worker-dispatch/scripts/manager_worker_api.py b/internal/templates/embed/picoclaw-manager/workspace/skills/manager-worker-dispatch/scripts/manager_worker_api.py index b04055c0..04a0a8a5 100644 --- a/internal/templates/embed/picoclaw-manager/workspace/skills/manager-worker-dispatch/scripts/manager_worker_api.py +++ b/internal/templates/embed/picoclaw-manager/workspace/skills/manager-worker-dispatch/scripts/manager_worker_api.py @@ -499,7 +499,7 @@ def _mock_response( payload: dict[str, Any] | None, ) -> dict[str, Any] | list[dict[str, Any]]: if method == "GET" and path == "/api/v1/bootstrap": - return {"current_user_id": "u-admin", "users": [], "rooms": []} + return {"current_user_id": "admin", "users": [], "rooms": []} if method == "GET" and ( path.startswith("/api/v1/messages?") or path.startswith("/api/v1/channels/feishu/messages?") diff --git a/internal/templates/embed/picoclaw-manager/workspace/skills/manager-worker-dispatch/scripts/test_manager_worker_api.py b/internal/templates/embed/picoclaw-manager/workspace/skills/manager-worker-dispatch/scripts/test_manager_worker_api.py index a98fc04b..aa34ee42 100644 --- a/internal/templates/embed/picoclaw-manager/workspace/skills/manager-worker-dispatch/scripts/test_manager_worker_api.py +++ b/internal/templates/embed/picoclaw-manager/workspace/skills/manager-worker-dispatch/scripts/test_manager_worker_api.py @@ -42,7 +42,7 @@ def make_message(sender_id, content, created_at): def make_bootstrap(): return { - "current_user_id": "u-admin", + "current_user_id": "admin", "users": [ {"id": "u-manager", "handle": "manager", "name": "manager"}, {"id": "u-ux", "handle": "ux", "name": "ux"}, @@ -52,7 +52,7 @@ def make_bootstrap(): "rooms": [ { "id": ROOM_ID, - "members": ["u-admin", "u-manager", "u-ux", "u-dev", "u-qa"], + "members": ["admin", "u-manager", "u-ux", "u-dev", "u-qa"], } ], } diff --git a/internal/upgrade/restart_daemon.go b/internal/upgrade/restart_daemon.go index b8ff1262..019f95c9 100644 --- a/internal/upgrade/restart_daemon.go +++ b/internal/upgrade/restart_daemon.go @@ -39,7 +39,7 @@ func RestartDaemon(ctx context.Context, exePath string, opts RestartOptions) (Re return RestartResult{}, fmt.Errorf("stop running daemon: %w", err) } - if err := runUpgradeCommand(ctx, exePath, commandArgsWithConfig(opts.ConfigPath, "serve", "-d")...); err != nil { + if err := runUpgradeCommand(ctx, exePath, commandArgsWithConfig(opts.ConfigPath, "serve", "--daemon")...); err != nil { return RestartResult{}, fmt.Errorf("restart daemon: %w", err) } diff --git a/internal/upgrade/upgrade_test.go b/internal/upgrade/upgrade_test.go index f14ef356..5df2390f 100644 --- a/internal/upgrade/upgrade_test.go +++ b/internal/upgrade/upgrade_test.go @@ -682,7 +682,7 @@ func TestClientRestartIfRunningStopsAndStartsDaemon(t *testing.T) { } want := [][]string{ {filepath.Join(installRoot, "bin", "csgclaw"), "stop"}, - {filepath.Join(installRoot, "bin", "csgclaw"), "--config", "/tmp/custom.toml", "serve", "-d"}, + {filepath.Join(installRoot, "bin", "csgclaw"), "--config", "/tmp/custom.toml", "serve", "--daemon"}, } if !reflect.DeepEqual(calls, want) { t.Fatalf("exec calls = %#v, want %#v", calls, want) diff --git a/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.tsx b/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.tsx index 70c26fa6..58b225db 100644 --- a/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.tsx +++ b/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.tsx @@ -116,6 +116,7 @@ export function AgentDetailPane({ onStart, onStop, onRecreate, + onUpgrade, onDelete, onInvite, onOpenDM, @@ -172,6 +173,16 @@ export function AgentDetailPane({ > {t("openDM")} + {onUpgrade ? ( + + ) : null} @@ -117,6 +119,7 @@ export function AgentRow({ onStart, onStop, onRecreate, + onUpgrade, onDelete, onInvite, }: AgentRowProps) { @@ -177,6 +180,15 @@ export function AgentRow({ ) : null} {!isNotification ? ( <> + {onUpgrade ? ( + + ) : null}