Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ dist/
*.test
*.out

# Python cache artifacts
__pycache__/
*.py[cod]

# Web frontend local artifacts
node_modules/
web/app/playwright-report/
Expand Down
2 changes: 1 addition & 1 deletion cli/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions cli/serve/serve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ func TestServeRunSkipsAutoBootstrapWhenStateComplete(t *testing.T) {
ConfigComplete: true,
IMBootstrapComplete: true,
ManagerAgentComplete: true,
AdminParticipantComplete: true,
ManagerParticipantComplete: true,
}, nil
}
Expand Down Expand Up @@ -219,6 +220,7 @@ debian_registries_override = []
ConfigComplete: true,
IMBootstrapComplete: true,
ManagerAgentComplete: true,
AdminParticipantComplete: true,
ManagerParticipantComplete: true,
}, nil
}
Expand Down Expand Up @@ -425,6 +427,7 @@ func TestServeRunRepeatedAutoBootstrapRemainsIdempotent(t *testing.T) {
ConfigComplete: complete,
IMBootstrapComplete: complete,
ManagerAgentComplete: complete,
AdminParticipantComplete: complete,
ManagerParticipantComplete: complete,
}, nil
}
Expand Down Expand Up @@ -1404,6 +1407,7 @@ func stubServeDependencies(t *testing.T) func() {
ConfigComplete: true,
IMBootstrapComplete: true,
ManagerAgentComplete: true,
AdminParticipantComplete: true,
ManagerParticipantComplete: true,
}, nil
}
Expand Down
178 changes: 173 additions & 5 deletions internal/agent/image_migration.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 == "" {
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand All @@ -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
}
3 changes: 2 additions & 1 deletion internal/agent/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion internal/agent/service_profiles.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
Loading
Loading