diff --git a/api/acp_helpers.go b/api/acp_helpers.go index 2afbe4a..447be35 100644 --- a/api/acp_helpers.go +++ b/api/acp_helpers.go @@ -30,10 +30,16 @@ func spritzSupportsACPConversations(spritz *spritzv1.Spritz) bool { } func displayAgentName(spritz *spritzv1.Spritz) string { - if spritz == nil || spritz.Status.ACP == nil || spritz.Status.ACP.AgentInfo == nil { - if spritz == nil { - return "" - } + if spritz == nil { + return "" + } + if profile := currentSpritzStatusProfile(spritz); profile != nil && strings.TrimSpace(profile.Name) != "" { + return strings.TrimSpace(profile.Name) + } + if profile := normalizeSpritzAgentProfile(spritz.Spec.ProfileOverrides); profile != nil && strings.TrimSpace(profile.Name) != "" { + return strings.TrimSpace(profile.Name) + } + if spritz.Status.ACP == nil || spritz.Status.ACP.AgentInfo == nil { return spritz.Name } info := spritz.Status.ACP.AgentInfo diff --git a/api/acp_test.go b/api/acp_test.go index caad9b7..5ba3b00 100644 --- a/api/acp_test.go +++ b/api/acp_test.go @@ -313,6 +313,17 @@ func TestListACPAgentsUsesStoredStatusOnly(t *testing.T) { } } +func TestDisplayAgentNamePrefersSyncedProfile(t *testing.T) { + spritz := readyACPSpritz("tidy-otter", "user-1") + spritz.Status.Profile = &spritzv1.SpritzAgentProfileStatus{ + Name: "Helpful Otter", + } + + if got := displayAgentName(spritz); got != "Helpful Otter" { + t.Fatalf("expected synced profile name, got %q", got) + } +} + func TestCreateACPConversationGeneratesIndependentConversationID(t *testing.T) { spritz := readyACPSpritz("tidy-otter", "user-1") s := newACPTestServer(t, spritz) diff --git a/api/agent_profile.go b/api/agent_profile.go new file mode 100644 index 0000000..fa05ca9 --- /dev/null +++ b/api/agent_profile.go @@ -0,0 +1,329 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + apiequality "k8s.io/apimachinery/pkg/api/equality" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/util/retry" + "sigs.k8s.io/controller-runtime/pkg/client" + + spritzv1 "spritz.sh/operator/api/v1" +) + +type agentProfileSyncInput struct { + Owner spritzv1.SpritzOwner `json:"owner"` + AgentRef *spritzv1.SpritzAgentRef `json:"agentRef,omitempty"` + ProfileOverrides *spritzv1.SpritzAgentProfile `json:"profileOverrides,omitempty"` +} + +type agentProfileSyncOutput struct { + Profile *spritzv1.SpritzAgentProfile `json:"profile,omitempty"` +} + +type resolvedAgentProfile struct { + profile *spritzv1.SpritzAgentProfile + syncer string + syncedAt *metav1.Time + lastError string +} + +func normalizeSpritzAgentRef(value *spritzv1.SpritzAgentRef) *spritzv1.SpritzAgentRef { + if value == nil { + return nil + } + normalized := &spritzv1.SpritzAgentRef{ + Type: strings.TrimSpace(value.Type), + Provider: strings.TrimSpace(value.Provider), + ID: strings.TrimSpace(value.ID), + } + if normalized.Type == "" && normalized.Provider == "" && normalized.ID == "" { + return nil + } + return normalized +} + +func validateSpritzAgentRef(value *spritzv1.SpritzAgentRef) error { + normalized := normalizeSpritzAgentRef(value) + if normalized == nil { + return nil + } + if normalized.Type == "" { + return errors.New("spec.agentRef.type is required") + } + if normalized.Provider == "" { + return errors.New("spec.agentRef.provider is required") + } + if normalized.ID == "" { + return errors.New("spec.agentRef.id is required") + } + return nil +} + +func sameSpritzAgentRef(left, right *spritzv1.SpritzAgentRef) bool { + left = normalizeSpritzAgentRef(left) + right = normalizeSpritzAgentRef(right) + switch { + case left == nil && right == nil: + return true + case left == nil || right == nil: + return false + default: + return left.Type == right.Type && left.Provider == right.Provider && left.ID == right.ID + } +} + +func mergeSpritzAgentRefStrict(existing, resolved *spritzv1.SpritzAgentRef) (*spritzv1.SpritzAgentRef, error) { + resolved = normalizeSpritzAgentRef(resolved) + if resolved == nil { + return normalizeSpritzAgentRef(existing), nil + } + if err := validateSpritzAgentRef(resolved); err != nil { + return nil, err + } + existing = normalizeSpritzAgentRef(existing) + if existing != nil && !sameSpritzAgentRef(existing, resolved) { + return nil, errors.New("preset create resolver attempted to overwrite spec.agentRef") + } + return resolved, nil +} + +func normalizeSpritzAgentProfile(value *spritzv1.SpritzAgentProfile) *spritzv1.SpritzAgentProfile { + if value == nil { + return nil + } + normalized := &spritzv1.SpritzAgentProfile{ + Name: strings.TrimSpace(value.Name), + ImageURL: strings.TrimSpace(value.ImageURL), + } + if normalized.Name == "" && normalized.ImageURL == "" { + return nil + } + return normalized +} + +func buildSpritzAgentProfileStatus( + overrides *spritzv1.SpritzAgentProfile, + synced *spritzv1.SpritzAgentProfile, + generation int64, + syncer string, + syncedAt *metav1.Time, + lastError string, +) *spritzv1.SpritzAgentProfileStatus { + overrides = normalizeSpritzAgentProfile(overrides) + synced = normalizeSpritzAgentProfile(synced) + lastError = strings.TrimSpace(lastError) + + status := &spritzv1.SpritzAgentProfileStatus{ + ObservedGeneration: generation, + Syncer: strings.TrimSpace(syncer), + LastError: lastError, + } + + if overrides != nil { + status.Name = overrides.Name + status.ImageURL = overrides.ImageURL + status.Source = "override" + } + if synced != nil { + if status.Name == "" { + status.Name = synced.Name + } + if status.ImageURL == "" { + status.ImageURL = synced.ImageURL + } + if status.Source == "" { + status.Source = "synced" + } + } + if syncedAt != nil { + status.LastSyncedAt = syncedAt.DeepCopy() + } + if status.Name == "" && status.ImageURL == "" && status.LastError == "" { + return nil + } + return status +} + +func copySpritzAgentProfileStatus(value *spritzv1.SpritzAgentProfileStatus) *spritzv1.SpritzAgentProfileStatus { + if value == nil { + return nil + } + copied := *value + if value.LastSyncedAt != nil { + copied.LastSyncedAt = value.LastSyncedAt.DeepCopy() + } + return &copied +} + +func currentSpritzStatusProfile(spritz *spritzv1.Spritz) *spritzv1.SpritzAgentProfileStatus { + if spritz == nil || spritz.Status.Profile == nil { + return nil + } + return spritz.Status.Profile +} + +func parseAgentProfileSyncOutput(raw []byte) (*spritzv1.SpritzAgentProfile, error) { + if len(raw) == 0 { + return nil, nil + } + var payload agentProfileSyncOutput + if err := json.Unmarshal(raw, &payload); err != nil { + return nil, fmt.Errorf("invalid agent profile sync output: %w", err) + } + return normalizeSpritzAgentProfile(payload.Profile), nil +} + +func agentProfileSyncErrorMessage(status extensionResolverStatus) string { + switch status { + case extensionStatusUnresolved: + return "agent profile is unresolved" + case extensionStatusForbidden: + return "agent profile sync is forbidden" + case extensionStatusAmbiguous: + return "agent profile sync is ambiguous" + case extensionStatusInvalid: + return "agent profile sync is invalid" + case extensionStatusUnavailable: + return "agent profile sync is unavailable" + default: + return "" + } +} + +func createAgentProfileRequestContext(namespace string, body *createRequest) extensionRequestContext { + requestContext := extensionRequestContext{ + Namespace: strings.TrimSpace(namespace), + } + if body == nil { + return requestContext + } + requestContext.PresetID = strings.TrimSpace(body.PresetID) + if body.Annotations != nil { + requestContext.InstanceClassID = strings.TrimSpace(body.Annotations[instanceClassAnnotationKey]) + } + return requestContext +} + +func (s *server) resolveAgentProfile( + ctx context.Context, + principal principal, + namespace string, + body *createRequest, +) *resolvedAgentProfile { + if body == nil { + return nil + } + body.Spec.AgentRef = normalizeSpritzAgentRef(body.Spec.AgentRef) + body.Spec.ProfileOverrides = normalizeSpritzAgentProfile(body.Spec.ProfileOverrides) + if body.Spec.AgentRef == nil && body.Spec.ProfileOverrides == nil { + return nil + } + if body.Spec.AgentRef == nil { + return &resolvedAgentProfile{} + } + if body.Spec.ProfileOverrides != nil && body.Spec.ProfileOverrides.Name != "" && body.Spec.ProfileOverrides.ImageURL != "" { + return &resolvedAgentProfile{} + } + + requestContext := createAgentProfileRequestContext(namespace, body) + resolver, response, err := s.extensions.resolve( + ctx, + extensionOperationAgentProfileSync, + principal, + body.RequestID, + requestContext, + agentProfileSyncInput{ + Owner: body.Spec.Owner, + AgentRef: body.Spec.AgentRef, + ProfileOverrides: body.Spec.ProfileOverrides, + }, + ) + if err != nil { + lastError := fmt.Sprintf("agent profile sync failed: %v", err) + if resolver != nil { + return &resolvedAgentProfile{ + syncer: resolver.id, + lastError: lastError, + } + } + return &resolvedAgentProfile{lastError: lastError} + } + if resolver == nil { + return &resolvedAgentProfile{} + } + + result := &resolvedAgentProfile{syncer: resolver.id} + switch response.Status { + case "", extensionStatusResolved: + profile, parseErr := parseAgentProfileSyncOutput(response.Output) + if parseErr != nil { + result.lastError = parseErr.Error() + return result + } + result.profile = profile + now := metav1.Now() + result.syncedAt = &now + default: + result.lastError = agentProfileSyncErrorMessage(response.Status) + } + return result +} + +func (s *server) applyResolvedAgentProfileStatus( + ctx context.Context, + spritz *spritzv1.Spritz, + resolved *resolvedAgentProfile, +) (*spritzv1.Spritz, error) { + if spritz == nil { + return nil, nil + } + var statusProfile *spritzv1.SpritzAgentProfileStatus + if resolved != nil { + statusProfile = buildSpritzAgentProfileStatus( + spritz.Spec.ProfileOverrides, + resolved.profile, + spritz.Generation, + resolved.syncer, + resolved.syncedAt, + resolved.lastError, + ) + } else { + statusProfile = buildSpritzAgentProfileStatus( + spritz.Spec.ProfileOverrides, + nil, + spritz.Generation, + "", + nil, + "", + ) + } + if statusProfile == nil { + return spritz, nil + } + objectKey := client.ObjectKeyFromObject(spritz) + updated := spritz.DeepCopy() + if err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + current := &spritzv1.Spritz{} + if err := s.client.Get(ctx, objectKey, current); err != nil { + return err + } + if apiequality.Semantic.DeepEqual(current.Status.Profile, statusProfile) { + updated = current + return nil + } + current.Status.Profile = copySpritzAgentProfileStatus(statusProfile) + if err := s.client.Status().Update(ctx, current); err != nil { + return err + } + updated = current + return nil + }); err != nil { + return spritz, err + } + return updated, nil +} diff --git a/api/agent_profile_test.go b/api/agent_profile_test.go new file mode 100644 index 0000000..004b9cf --- /dev/null +++ b/api/agent_profile_test.go @@ -0,0 +1,183 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + + spritzv1 "spritz.sh/operator/api/v1" +) + +type interceptStatusClient struct { + client.Client + writer client.SubResourceWriter +} + +func (c *interceptStatusClient) Status() client.SubResourceWriter { + return c.writer +} + +type conflictOnceStatusWriter struct { + client.SubResourceWriter + onConflict func(context.Context) error + conflicted bool +} + +func (w *conflictOnceStatusWriter) Update(ctx context.Context, obj client.Object, opts ...client.SubResourceUpdateOption) error { + if !w.conflicted { + w.conflicted = true + if w.onConflict != nil { + if err := w.onConflict(ctx); err != nil { + return err + } + } + return apierrors.NewConflict( + schema.GroupResource{Group: spritzv1.GroupVersion.Group, Resource: "spritzes"}, + obj.GetName(), + errors.New("status updated"), + ) + } + return w.SubResourceWriter.Update(ctx, obj, opts...) +} + +func TestApplyResolvedAgentProfileStatusRetriesConflicts(t *testing.T) { + s := newCreateSpritzTestServer(t) + ctx := context.Background() + created := &spritzv1.Spritz{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tidy-otter", + Namespace: s.namespace, + }, + Spec: spritzv1.SpritzSpec{ + Image: "example.com/openclaw:latest", + Owner: spritzv1.SpritzOwner{ID: "user-1"}, + }, + } + if err := s.client.Create(ctx, created); err != nil { + t.Fatalf("expected spritz create to succeed: %v", err) + } + + objectKey := client.ObjectKeyFromObject(created) + baseClient := s.client + s.client = &interceptStatusClient{ + Client: baseClient, + writer: &conflictOnceStatusWriter{ + SubResourceWriter: baseClient.Status(), + onConflict: func(ctx context.Context) error { + latest := &spritzv1.Spritz{} + if err := baseClient.Get(ctx, objectKey, latest); err != nil { + return err + } + latest.Status.Phase = "Provisioning" + return baseClient.Status().Update(ctx, latest) + }, + }, + } + + now := metav1.Now() + updated, err := s.applyResolvedAgentProfileStatus(ctx, created, &resolvedAgentProfile{ + profile: &spritzv1.SpritzAgentProfile{ + Name: "Helpful Otter", + ImageURL: "https://example.com/otter.png", + }, + syncer: "agent-profile", + syncedAt: &now, + }) + if err != nil { + t.Fatalf("expected profile status update to retry conflicts: %v", err) + } + if updated.Status.Phase != "Provisioning" { + t.Fatalf("expected retry to keep latest status fields, got phase %q", updated.Status.Phase) + } + if updated.Status.Profile == nil { + t.Fatalf("expected profile status to be written after retry") + } + if updated.Status.Profile.Name != "Helpful Otter" { + t.Fatalf("expected synced profile name, got %#v", updated.Status.Profile.Name) + } + if updated.Status.Profile.ImageURL != "https://example.com/otter.png" { + t.Fatalf("expected synced profile image, got %#v", updated.Status.Profile.ImageURL) + } + if updated.Status.Profile.Syncer != "agent-profile" { + t.Fatalf("expected syncer id, got %#v", updated.Status.Profile.Syncer) + } + + stored := &spritzv1.Spritz{} + if err := s.client.Get(ctx, objectKey, stored); err != nil { + t.Fatalf("expected stored spritz to be readable: %v", err) + } + if stored.Status.Phase != "Provisioning" { + t.Fatalf("expected stored phase to come from the latest status object, got %q", stored.Status.Phase) + } + if stored.Status.Profile == nil || stored.Status.Profile.Name != "Helpful Otter" { + t.Fatalf("expected stored profile to be preserved after retry, got %#v", stored.Status.Profile) + } +} + +func TestResolveAgentProfileSkipsSyncWhenOverridesAreComplete(t *testing.T) { + s := newCreateSpritzTestServer(t) + called := false + resolver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "status": "resolved", + "output": map[string]any{ + "profile": map[string]string{ + "name": "Synced Otter", + "imageUrl": "https://example.com/synced.png", + }, + }, + }) + })) + defer resolver.Close() + configurePresetResolverTestServer(s, "", resolver.URL) + + body := &createRequest{ + RequestID: "req-1", + PresetID: "zeno", + Annotations: map[string]string{ + instanceClassAnnotationKey: "personal-agent", + }, + Spec: spritzv1.SpritzSpec{ + Owner: spritzv1.SpritzOwner{ID: "user-1"}, + AgentRef: &spritzv1.SpritzAgentRef{ + Type: "external", + Provider: "example-agent-catalog", + ID: "ag-123", + }, + ProfileOverrides: &spritzv1.SpritzAgentProfile{ + Name: "Helpful Otter", + ImageURL: "https://example.com/override.png", + }, + }, + } + + resolved := s.resolveAgentProfile( + context.Background(), + principal{ID: "user-1", Type: principalTypeHuman}, + s.namespace, + body, + ) + + if called { + t.Fatalf("expected complete overrides to skip profile sync") + } + if resolved == nil { + t.Fatalf("expected resolved placeholder result") + } + if resolved.profile != nil { + t.Fatalf("expected overrides-only flow to avoid synced profile payload, got %#v", resolved.profile) + } + if resolved.lastError != "" { + t.Fatalf("expected overrides-only flow to avoid sync errors, got %q", resolved.lastError) + } +} diff --git a/api/create_admission.go b/api/create_admission.go index c8b136d..572eb49 100644 --- a/api/create_admission.go +++ b/api/create_admission.go @@ -236,6 +236,11 @@ func applyPresetCreateResolverMutations(body *createRequest, response extensionR body.Spec.ServiceAccountName = resolvedServiceAccount result.serviceAccountResolved = true } + mergedAgentRef, err := mergeSpritzAgentRefStrict(body.Spec.AgentRef, response.Mutations.Spec.AgentRef) + if err != nil { + return presetCreateMutationResult{}, err + } + body.Spec.AgentRef = mergedAgentRef } annotations, err := mergeMetadataStrict(body.Annotations, response.Mutations.Annotations, "annotation") if err != nil { diff --git a/api/create_admission_test.go b/api/create_admission_test.go index 2c8d742..10b10fe 100644 --- a/api/create_admission_test.go +++ b/api/create_admission_test.go @@ -17,7 +17,7 @@ import ( spritzv1 "spritz.sh/operator/api/v1" ) -func configurePresetResolverTestServer(s *server, resolverURL string) { +func configurePresetResolverTestServer(s *server, resolverURL, profileResolverURL string) { s.presets = presetCatalog{ byID: []runtimePreset{{ ID: "zeno", @@ -39,9 +39,10 @@ func configurePresetResolverTestServer(s *server, resolverURL string) { }, }, } - if strings.TrimSpace(resolverURL) != "" { - s.extensions = extensionRegistry{ - resolvers: []configuredResolver{{ + if strings.TrimSpace(resolverURL) != "" || strings.TrimSpace(profileResolverURL) != "" { + resolvers := make([]configuredResolver, 0, 2) + if strings.TrimSpace(resolverURL) != "" { + resolvers = append(resolvers, configuredResolver{ id: "runtime-binding", extensionType: extensionTypeResolver, operation: extensionOperationPresetCreateResolve, @@ -52,34 +53,73 @@ func configurePresetResolverTestServer(s *server, resolverURL string) { url: resolverURL, timeout: time.Second, }, - }}, + }) } + if strings.TrimSpace(profileResolverURL) != "" { + resolvers = append(resolvers, configuredResolver{ + id: "agent-profile", + extensionType: extensionTypeResolver, + operation: extensionOperationAgentProfileSync, + match: extensionMatchRule{ + presetIDs: map[string]struct{}{"zeno": {}}, + }, + transport: configuredHTTPTransport{ + url: profileResolverURL, + timeout: time.Second, + }, + }) + } + s.extensions = extensionRegistry{resolvers: resolvers} } } func TestCreateSpritzAppliesPresetCreateResolverForHumanCaller(t *testing.T) { s := newCreateSpritzTestServer(t) - var received map[string]any + var presetReceived map[string]any + var profileReceived map[string]any resolver := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer r.Body.Close() + var received map[string]any if err := json.NewDecoder(r.Body).Decode(&received); err != nil { t.Fatalf("failed to decode resolver request: %v", err) } w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{ - "status": "resolved", - "mutations": map[string]any{ - "spec": map[string]any{ - "serviceAccountName": "zeno-agent-ag-123", + switch received["operation"] { + case string(extensionOperationPresetCreateResolve): + presetReceived = received + _ = json.NewEncoder(w).Encode(map[string]any{ + "status": "resolved", + "mutations": map[string]any{ + "spec": map[string]any{ + "serviceAccountName": "zeno-agent-ag-123", + "agentRef": map[string]string{ + "type": "external", + "provider": "example-agent-catalog", + "id": "ag-123", + }, + }, + "annotations": map[string]string{ + "spritz.sh/resolved-agent-id": "ag-123", + }, }, - "annotations": map[string]string{ - "spritz.sh/resolved-agent-id": "ag-123", + }) + case string(extensionOperationAgentProfileSync): + profileReceived = received + _ = json.NewEncoder(w).Encode(map[string]any{ + "status": "resolved", + "output": map[string]any{ + "profile": map[string]string{ + "name": "Helpful Lake Agent", + "imageUrl": "https://example.com/agent.png", + }, }, - }, - }) + }) + default: + t.Fatalf("unexpected resolver operation %#v", received["operation"]) + } })) defer resolver.Close() - configurePresetResolverTestServer(s, resolver.URL) + configurePresetResolverTestServer(s, resolver.URL, resolver.URL) e := echo.New() secured := e.Group("", s.authMiddleware()) @@ -101,19 +141,19 @@ func TestCreateSpritzAppliesPresetCreateResolverForHumanCaller(t *testing.T) { if rec.Code != http.StatusCreated { t.Fatalf("expected status 201, got %d: %s", rec.Code, rec.Body.String()) } - if received["operation"] != string(extensionOperationPresetCreateResolve) { - t.Fatalf("expected preset create operation, got %#v", received["operation"]) + if presetReceived["operation"] != string(extensionOperationPresetCreateResolve) { + t.Fatalf("expected preset create operation, got %#v", presetReceived["operation"]) } - contextPayload, ok := received["context"].(map[string]any) + contextPayload, ok := presetReceived["context"].(map[string]any) if !ok { - t.Fatalf("expected resolver context payload, got %#v", received["context"]) + t.Fatalf("expected resolver context payload, got %#v", presetReceived["context"]) } if contextPayload["presetId"] != "zeno" { t.Fatalf("expected resolver presetId zeno, got %#v", contextPayload["presetId"]) } - inputPayload, ok := received["input"].(map[string]any) + inputPayload, ok := presetReceived["input"].(map[string]any) if !ok { - t.Fatalf("expected resolver input payload, got %#v", received["input"]) + t.Fatalf("expected resolver input payload, got %#v", presetReceived["input"]) } presetInputs, ok := inputPayload["presetInputs"].(map[string]any) if !ok { @@ -130,6 +170,27 @@ func TestCreateSpritzAppliesPresetCreateResolverForHumanCaller(t *testing.T) { if stored.Spec.ServiceAccountName != "zeno-agent-ag-123" { t.Fatalf("expected resolved service account name, got %q", stored.Spec.ServiceAccountName) } + if stored.Spec.AgentRef == nil { + t.Fatalf("expected resolved agentRef to be stored") + } + if stored.Spec.AgentRef.Type != "external" || stored.Spec.AgentRef.Provider != "example-agent-catalog" || stored.Spec.AgentRef.ID != "ag-123" { + t.Fatalf("expected resolved agentRef, got %#v", stored.Spec.AgentRef) + } + if stored.Status.Profile == nil { + t.Fatalf("expected synced profile to be stored in status") + } + if stored.Status.Profile.Name != "Helpful Lake Agent" { + t.Fatalf("expected synced profile name, got %#v", stored.Status.Profile.Name) + } + if stored.Status.Profile.ImageURL != "https://example.com/agent.png" { + t.Fatalf("expected synced profile image URL, got %#v", stored.Status.Profile.ImageURL) + } + if stored.Status.Profile.Source != "synced" { + t.Fatalf("expected synced profile source, got %#v", stored.Status.Profile.Source) + } + if stored.Status.Profile.Syncer != "agent-profile" { + t.Fatalf("expected synced profile syncer id, got %#v", stored.Status.Profile.Syncer) + } if stored.Annotations["spritz.sh/resolved-agent-id"] != "ag-123" { t.Fatalf("expected resolver annotation, got %#v", stored.Annotations["spritz.sh/resolved-agent-id"]) } @@ -143,11 +204,22 @@ func TestCreateSpritzAppliesPresetCreateResolverForHumanCaller(t *testing.T) { if err := s.client.Get(context.Background(), client.ObjectKey{Name: "zeno-agent-ag-123", Namespace: s.namespace}, serviceAccount); err != nil { t.Fatalf("expected created service account: %v", err) } + profileInput, ok := profileReceived["input"].(map[string]any) + if !ok { + t.Fatalf("expected profile sync input payload, got %#v", profileReceived["input"]) + } + agentRef, ok := profileInput["agentRef"].(map[string]any) + if !ok { + t.Fatalf("expected profile sync agentRef payload, got %#v", profileInput["agentRef"]) + } + if agentRef["type"] != "external" || agentRef["provider"] != "example-agent-catalog" || agentRef["id"] != "ag-123" { + t.Fatalf("expected synced agentRef payload, got %#v", agentRef) + } } func TestCreateSpritzRejectsPresetInputsWithoutMatchingResolver(t *testing.T) { s := newCreateSpritzTestServer(t) - configurePresetResolverTestServer(s, "") + configurePresetResolverTestServer(s, "", "") e := echo.New() secured := e.Group("", s.authMiddleware()) secured.POST("/api/spritzes", s.createSpritz) @@ -472,7 +544,7 @@ func TestPresetCreateResolverIgnoresOwnerMutation(t *testing.T) { }) })) defer resolver.Close() - configurePresetResolverTestServer(s, resolver.URL) + configurePresetResolverTestServer(s, resolver.URL, "") e := echo.New() secured := e.Group("", s.authMiddleware()) diff --git a/api/create_request_normalization.go b/api/create_request_normalization.go index a1804c7..a5336d2 100644 --- a/api/create_request_normalization.go +++ b/api/create_request_normalization.go @@ -211,6 +211,11 @@ func validateCreateSpec(spec *spritzv1.SpritzSpec) error { return err } } + spec.AgentRef = normalizeSpritzAgentRef(spec.AgentRef) + if err := validateSpritzAgentRef(spec.AgentRef); err != nil { + return err + } + spec.ProfileOverrides = normalizeSpritzAgentProfile(spec.ProfileOverrides) if len(spec.SharedMounts) > 0 { normalized, err := normalizeSharedMounts(spec.SharedMounts) if err != nil { diff --git a/api/extensions.go b/api/extensions.go index 4ef468e..29a34fd 100644 --- a/api/extensions.go +++ b/api/extensions.go @@ -13,6 +13,8 @@ import ( "sort" "strings" "time" + + spritzv1 "spritz.sh/operator/api/v1" ) const ( @@ -33,6 +35,7 @@ type extensionOperation string const ( extensionOperationOwnerResolve extensionOperation = "owner.resolve" extensionOperationPresetCreateResolve extensionOperation = "preset.create.resolve" + extensionOperationAgentProfileSync extensionOperation = "agent.profile.sync" extensionOperationAuthLoginMetadata extensionOperation = "auth.login.metadata" extensionOperationIdentityLinkResolve extensionOperation = "identity.link.resolve" extensionOperationInstanceNotify extensionOperation = "instance.lifecycle.notify" @@ -119,7 +122,8 @@ type extensionResolverMutations struct { } type extensionResolverSpecMutation struct { - ServiceAccountName string `json:"serviceAccountName,omitempty"` + ServiceAccountName string `json:"serviceAccountName,omitempty"` + AgentRef *spritzv1.SpritzAgentRef `json:"agentRef,omitempty"` } type configuredResolver struct { @@ -241,6 +245,8 @@ func normalizeExtensionOperation(raw string) extensionOperation { return extensionOperationOwnerResolve case extensionOperationPresetCreateResolve: return extensionOperationPresetCreateResolve + case extensionOperationAgentProfileSync: + return extensionOperationAgentProfileSync case extensionOperationAuthLoginMetadata: return extensionOperationAuthLoginMetadata case extensionOperationIdentityLinkResolve: diff --git a/api/extensions_test.go b/api/extensions_test.go index 8c212a8..156861d 100644 --- a/api/extensions_test.go +++ b/api/extensions_test.go @@ -59,6 +59,26 @@ func TestNewExtensionRegistryAcceptsChannelRouteResolveOperation(t *testing.T) { } } +func TestNewExtensionRegistryAcceptsAgentProfileSyncOperation(t *testing.T) { + t.Setenv(extensionsEnvKey, `[{ + "id": "agent-profile", + "type": "resolver", + "operation": "agent.profile.sync", + "transport": {"url": "https://example.com/internal/extensions/agent-profile"} + }]`) + + registry, err := newExtensionRegistry() + if err != nil { + t.Fatalf("expected agent profile sync operation to be accepted, got %v", err) + } + if len(registry.resolvers) != 1 { + t.Fatalf("expected one resolver, got %d", len(registry.resolvers)) + } + if registry.resolvers[0].operation != extensionOperationAgentProfileSync { + t.Fatalf("expected agent.profile.sync operation, got %q", registry.resolvers[0].operation) + } +} + func TestNormalizeExtensionMatchSanitizesPresetIDs(t *testing.T) { match, err := normalizeExtensionMatch(extensionMatchInput{PresetIDs: []string{"Zeno", "my_preset"}}) if err != nil { diff --git a/api/main.go b/api/main.go index 528598b..55814d1 100644 --- a/api/main.go +++ b/api/main.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "log" "net/http" "net/url" "os" @@ -526,6 +527,7 @@ func (s *server) createSpritz(c echo.Context) error { if err := s.ensureServiceAccount(c.Request().Context(), namespace, body.Spec.ServiceAccountName); err != nil { return writeError(c, http.StatusInternalServerError, "failed to ensure service account") } + resolvedProfile := s.resolveAgentProfile(c.Request().Context(), principal, namespace, &body) labels := map[string]string{ ownerLabelKey: ownerLabelValue(owner.ID), @@ -652,6 +654,11 @@ func (s *server) createSpritz(c echo.Context) error { } return writeError(c, http.StatusInternalServerError, err.Error()) } + if updated, err := s.applyResolvedAgentProfileStatus(c.Request().Context(), spritz, resolvedProfile); err != nil { + log.Printf("spritz agent profile: failed to persist profile status name=%s namespace=%s err=%v", spritz.Name, spritz.Namespace, err) + } else if updated != nil { + spritz = updated + } if principal.isService() { if err := s.completeIdempotencyReservation(c.Request().Context(), principal.ID, body.IdempotencyKey, spritz); err != nil { return writeError(c, http.StatusInternalServerError, err.Error()) diff --git a/api/provisioning_test_helpers_test.go b/api/provisioning_test_helpers_test.go index 8d21412..378c17f 100644 --- a/api/provisioning_test_helpers_test.go +++ b/api/provisioning_test_helpers_test.go @@ -29,7 +29,10 @@ func newCreateSpritzTestServer(t *testing.T) *server { t.Helper() scheme := newTestSpritzScheme(t) return &server{ - client: fake.NewClientBuilder().WithScheme(scheme).Build(), + client: fake.NewClientBuilder(). + WithScheme(scheme). + WithStatusSubresource(&spritzv1.Spritz{}). + Build(), scheme: scheme, namespace: "spritz-test", controlNamespace: "spritz-test", diff --git a/crd/generated/spritz.sh_spritzconversations.yaml b/crd/generated/spritz.sh_spritzconversations.yaml index 4558404..55db34f 100644 --- a/crd/generated/spritz.sh_spritzconversations.yaml +++ b/crd/generated/spritz.sh_spritzconversations.yaml @@ -36,7 +36,7 @@ spec: schema: openAPIV3Schema: description: SpritzConversation stores ACP conversation metadata for a spritz - workspace. + instance. properties: apiVersion: description: |- diff --git a/crd/generated/spritz.sh_spritzes.yaml b/crd/generated/spritz.sh_spritzes.yaml index ddbf584..dfb6056 100644 --- a/crd/generated/spritz.sh_spritzes.yaml +++ b/crd/generated/spritz.sh_spritzes.yaml @@ -57,6 +57,20 @@ spec: spec: description: SpritzSpec defines the desired state of Spritz. properties: + agentRef: + description: SpritzAgentRef identifies a deployment-owned external + agent record. + properties: + id: + maxLength: 256 + type: string + provider: + maxLength: 128 + type: string + type: + maxLength: 64 + type: string + type: object annotations: additionalProperties: type: string @@ -309,6 +323,17 @@ spec: - name type: object type: array + profileOverrides: + description: ProfileOverrides stores optional local overrides for + UI-facing agent profile fields. + properties: + imageUrl: + maxLength: 2048 + type: string + name: + maxLength: 128 + type: string + type: object repo: description: SpritzRepo describes the repository to clone inside the workload. @@ -687,6 +712,31 @@ spec: - Terminating - Error type: string + profile: + description: SpritzAgentProfileStatus stores the synced UI-facing + profile for an instance. + properties: + imageUrl: + maxLength: 2048 + type: string + lastError: + type: string + lastSyncedAt: + format: date-time + type: string + name: + maxLength: 128 + type: string + observedGeneration: + format: int64 + type: integer + source: + maxLength: 32 + type: string + syncer: + maxLength: 128 + type: string + type: object readyAt: format: date-time type: string diff --git a/crd/spritz.sh_spritzconversations.yaml b/crd/spritz.sh_spritzconversations.yaml index 4558404..55db34f 100644 --- a/crd/spritz.sh_spritzconversations.yaml +++ b/crd/spritz.sh_spritzconversations.yaml @@ -36,7 +36,7 @@ spec: schema: openAPIV3Schema: description: SpritzConversation stores ACP conversation metadata for a spritz - workspace. + instance. properties: apiVersion: description: |- diff --git a/crd/spritz.sh_spritzes.yaml b/crd/spritz.sh_spritzes.yaml index ddbf584..dfb6056 100644 --- a/crd/spritz.sh_spritzes.yaml +++ b/crd/spritz.sh_spritzes.yaml @@ -57,6 +57,20 @@ spec: spec: description: SpritzSpec defines the desired state of Spritz. properties: + agentRef: + description: SpritzAgentRef identifies a deployment-owned external + agent record. + properties: + id: + maxLength: 256 + type: string + provider: + maxLength: 128 + type: string + type: + maxLength: 64 + type: string + type: object annotations: additionalProperties: type: string @@ -309,6 +323,17 @@ spec: - name type: object type: array + profileOverrides: + description: ProfileOverrides stores optional local overrides for + UI-facing agent profile fields. + properties: + imageUrl: + maxLength: 2048 + type: string + name: + maxLength: 128 + type: string + type: object repo: description: SpritzRepo describes the repository to clone inside the workload. @@ -687,6 +712,31 @@ spec: - Terminating - Error type: string + profile: + description: SpritzAgentProfileStatus stores the synced UI-facing + profile for an instance. + properties: + imageUrl: + maxLength: 2048 + type: string + lastError: + type: string + lastSyncedAt: + format: date-time + type: string + name: + maxLength: 128 + type: string + observedGeneration: + format: int64 + type: integer + source: + maxLength: 32 + type: string + syncer: + maxLength: 128 + type: string + type: object readyAt: format: date-time type: string diff --git a/docs/2026-03-30-agent-profile-api.md b/docs/2026-03-30-agent-profile-api.md new file mode 100644 index 0000000..b4c06f6 --- /dev/null +++ b/docs/2026-03-30-agent-profile-api.md @@ -0,0 +1,415 @@ +--- +date: 2026-03-30 +author: Onur Solmaz +title: Agent Profile API +tags: [spritz, agents, ui, api, architecture] +--- + +## Overview + +This document defines a provider-agnostic agent profile API for rendering a +Spritz instance with deployment-owned cosmetic metadata such as: + +- name +- image URL + +The goal is to let deployment-owned systems tell Spritz how an instance should +appear in the UI without making ACP runtime identity or deployment-wide +branding carry that responsibility. + +The design keeps three concepts separate: + +- deployment-wide product branding +- per-instance agent profile +- ACP runtime identity + +## Plain language + +- The external service owns the real agent profile. +- Spritz keeps a synced local copy of that profile for its UIs. +- The UI reads Spritz, not the external service. + +In practice: + +1. a Spritz instance can point at an external agent with `agentRef` +2. Spritz can ask the deployment system for that agent's profile +3. Spritz stores the result on the instance as `status.profile` +4. the UI renders from `status.profile` + +If local overrides exist, Spritz can replace parts of the synced profile +without changing the external source. + +## Goals + +- Make per-instance agent profile data a first-class Spritz concept. +- Keep the contract provider-agnostic and safe for open-source use. +- Preserve a clean control-plane split between desired state and synced + external state. +- Give all Spritz UIs one canonical read shape for agent name and image. +- Allow deployment-owned systems to sync profile data using the extension + framework. +- Keep ACP `agentInfo` focused on runtime protocol identity, not UI branding. + +## Non-goals + +- Do not add deployment-specific business logic to Spritz core. +- Do not turn ACP `agentInfo` into a mutable branding surface. +- Do not make browser clients call deployment-owned systems directly to fetch + profile data. +- Do not introduce per-tenant or per-user global UI theming here. +- Do not require every instance to have an external agent reference. + +## Problem statement + +Today, Spritz has two nearby but different concepts: + +- deployment-wide UI branding documented in + [2026-03-20-ui-branding-customization.md](2026-03-20-ui-branding-customization.md) +- ACP runtime identity in `status.acp.agentInfo` + +Neither is the right home for per-instance agent profile data. + +Deployment-wide branding is too coarse because one Spritz install may host many +instances that should present as different agents. + +ACP `agentInfo` is also the wrong source because it represents runtime protocol +identity exposed by the image or ACP adapter. It should not be overloaded to +carry deployment-owned display choices such as: + +- "show this instance as Research Assistant" +- "use this image from an external agent catalog" + +If Spritz keeps using ACP metadata for rendering, UI identity becomes coupled +to runtime image behavior instead of control-plane state. + +## Design principles + +### Profile data is control-plane data + +Per-instance profile data should be synced and stored in Spritz control-plane +state, not fetched by the browser at render time. + +### Desired and observed state stay separate + +Deployment-owned references and local overrides belong in `spec`. + +Resolved display values from external systems belong in `status`. + +### Profile data is not runtime identity + +ACP metadata continues to answer: + +- what runtime is this +- what protocol version and capabilities does it expose + +Profile data answers: + +- what should the UI call this instance +- what image should the UI show + +### UIs should read one canonical profile shape + +Native Spritz UI and embedded consumers should use the same precedence and the +same profile fields. + +### External sync should be explicit + +If a deployment wants Spritz to show an instance as an external agent, the +instance should contain an explicit opaque reference instead of encoding that +knowledge indirectly in annotations or ACP metadata. + +## Canonical resource model + +The recommended model is: + +```yaml +spec: + agentRef: + type: external + provider: example-catalog + id: agent-123 + profileOverrides: + name: "Example Assistant" + imageUrl: "https://console.example.com/assets/example-assistant.png" + +status: + profile: + name: "Example Assistant" + imageUrl: "https://console.example.com/assets/example-assistant.png" + source: synced + observedGeneration: 7 + syncer: deployment-agent-profile + lastSyncedAt: "2026-03-30T12:00:00Z" +``` + +Recommended types: + +- `spec.agentRef` + - optional + - opaque reference to a deployment-owned agent or catalog entry + - Spritz validates shape, not business semantics + - use `type` for the internal field name; if an external payload sends + `kind`, convert it at the boundary +- `spec.profileOverrides` + - optional + - operator- or caller-supplied local override values + - highest-priority desired-state input +- `status.profile` + - canonical UI output + - what every UI should read + +## Proposed type definitions + +Suggested CRD additions: + +```go +type SpritzAgentRef struct { + Type string `json:"type,omitempty"` + Provider string `json:"provider,omitempty"` + ID string `json:"id,omitempty"` +} + +type SpritzAgentProfile struct { + Name string `json:"name,omitempty"` + ImageURL string `json:"imageUrl,omitempty"` +} + +type SpritzAgentProfileStatus struct { + Name string `json:"name,omitempty"` + ImageURL string `json:"imageUrl,omitempty"` + Source string `json:"source,omitempty"` + Syncer string `json:"syncer,omitempty"` + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + LastSyncedAt *metav1.Time `json:"lastSyncedAt,omitempty"` + LastError string `json:"lastError,omitempty"` +} +``` + +Suggested placements: + +- `spritz.spec.agentRef` +- `spritz.spec.profileOverrides` +- `spritz.status.profile` + +If conversation resources need profile snapshots later, they should carry their +own optional derived snapshot as a cache, not as the canonical source of +truth. + +## Why this model is preferred + +This model is cleaner than storing synced profile data in `spec` because: + +- `spec` remains caller intent +- `status` remains observed and reconciled state +- the system can refresh external profile data later without rewriting desired + state +- UIs can trust one stable profile output +- overrides remain explicit and inspectable + +This is also cleaner than using only `metadata.annotations` because: + +- annotations are untyped +- validation is weaker +- UI consumers need field-specific parsing logic +- the contract becomes harder to evolve safely + +## Sync model + +Spritz should add one extension operation for agent profile sync: + +- `agent.profile.sync` + +Its input should contain only the facts needed to compute the profile: + +```json +{ + "version": "v1", + "extensionId": "deployment-agent-profile", + "type": "resolver", + "operation": "agent.profile.sync", + "context": { + "namespace": "spritz-system", + "instanceClassId": "personal-agent" + }, + "input": { + "owner": { "id": "user-123" }, + "agentRef": { + "type": "external", + "provider": "example-catalog", + "id": "agent-123" + }, + "profileOverrides": { + "name": "Example Assistant" + } + } +} +``` + +The response should be narrow: + +```json +{ + "status": "synced", + "output": { + "profile": { + "name": "Example Assistant", + "imageUrl": "https://console.example.com/assets/example-assistant.png" + } + } +} +``` + +The extension should return profile data only. It should not mutate arbitrary +resource state. + +## Precedence rules + +For the instance name, canonical precedence should be: + +1. `spec.profileOverrides.name` +2. synced extension output from `agent.profile.sync` +3. ACP `agentInfo.title` +4. ACP `agentInfo.name` +5. `metadata.name` + +For the image URL, canonical precedence should be: + +1. `spec.profileOverrides.imageUrl` +2. synced extension output from `agent.profile.sync` +3. no image URL + +This precedence should be materialized into `status.profile` so the UI does not +need to re-implement the logic in multiple places. + +That means the browser should normally read: + +- `status.profile.name` +- `status.profile.imageUrl` + +If `status.profile.imageUrl` is empty, the UI can fall back to initials or a +generic placeholder. + +## Conversation model + +The canonical source of per-instance profile data should stay on the instance +resource, not on `SpritzConversation`. + +Conversation resources already reference the parent instance by `spritzName`. +Native UI and embedded consumers can join against the parent instance when +needed. + +If later profiling shows that repeated joins are too expensive, Spritz can add +an optional derived snapshot to conversation state. That snapshot should still +be treated as a cache of instance profile data, not the source of truth. + +## API and controller changes + +### API changes + +- extend `operator/api/v1/spritz_types.go` with: + - `SpritzAgentRef` + - `SpritzAgentProfile` + - `SpritzAgentProfileStatus` +- update public API serialization so `profile` is included in + instance reads and lists +- keep `status.acp.agentInfo` unchanged + +### Extension framework changes + +- add `agent.profile.sync` as a supported operation in the extension registry +- define a typed request and response envelope for profile sync +- validate that the extension can only return profile fields + +### Reconciliation changes + +Spritz needs a control-plane component that computes `status.profile`. + +Recommended sequence: + +1. normalize `spec.agentRef` and `spec.profileOverrides` +2. if overrides fully satisfy the profile, use them directly +3. else, if `agentRef` is present, call `agent.profile.sync` +4. merge using the canonical precedence rules +5. write the result to `status.profile` +6. record sync metadata such as: + - `source` + - `syncer` + - `observedGeneration` + - `lastSyncedAt` + - `lastError` + +The first implementation can run this logic in the API create/update path plus +an explicit refresh endpoint if needed. + +The long-term preferred implementation is a reconciliation loop that keeps +`status.profile` current whenever: + +- `spec.agentRef` changes +- `spec.profileOverrides` changes +- a caller requests refresh + +## Suggested implementation phases + +### Phase 1: typed model and UI read path + +- add typed `agentRef`, `profileOverrides`, and `profile` +- add UI helpers that prefer `status.profile` +- keep ACP metadata as fallback only + +This phase creates the durable contract first. + +### Phase 2: extension integration + +- add `agent.profile.sync` +- sync profile data during create and update +- materialize the merged result into `status.profile` + +This phase gives deployments a provider-agnostic hook. + +### Phase 3: refresh and reconciliation + +- add explicit refresh semantics +- reconcile stale or missing profile data after create +- support background re-sync without rewriting `spec` + +This phase makes external profile data durable over time instead of treating it +as a one-time create artifact. + +## Validation + +Required validation: + +- unit tests for precedence logic +- unit tests for merge behavior between overrides, synced profile data, ACP + metadata, and instance name +- API tests for instance list and get responses +- extension tests for: + - synced + - missing + - forbidden + - invalid +- reconciliation tests proving `status.profile` updates when + `spec.profileOverrides` changes +- UI tests proving: + - `status.profile` is preferred + - ACP metadata remains a fallback + - instance name remains the final fallback + +## Migration notes + +Existing installations may already render from ACP metadata or instance name. + +Migration should therefore be additive: + +1. introduce the new fields +2. ship UIs that prefer `status.profile` +3. start writing `status.profile` +4. keep ACP fallback behavior until the new field is broadly available + +This avoids breaking existing runtimes or forcing immediate deployment-specific +extension adoption. + +## References + +- [2026-03-19-unified-extension-framework-architecture.md](2026-03-19-unified-extension-framework-architecture.md) +- [2026-03-20-ui-branding-customization.md](2026-03-20-ui-branding-customization.md) diff --git a/helm/spritz/crds/spritz.sh_spritzconversations.yaml b/helm/spritz/crds/spritz.sh_spritzconversations.yaml index 4558404..55db34f 100644 --- a/helm/spritz/crds/spritz.sh_spritzconversations.yaml +++ b/helm/spritz/crds/spritz.sh_spritzconversations.yaml @@ -36,7 +36,7 @@ spec: schema: openAPIV3Schema: description: SpritzConversation stores ACP conversation metadata for a spritz - workspace. + instance. properties: apiVersion: description: |- diff --git a/helm/spritz/crds/spritz.sh_spritzes.yaml b/helm/spritz/crds/spritz.sh_spritzes.yaml index d041214..dfb6056 100644 --- a/helm/spritz/crds/spritz.sh_spritzes.yaml +++ b/helm/spritz/crds/spritz.sh_spritzes.yaml @@ -57,6 +57,20 @@ spec: spec: description: SpritzSpec defines the desired state of Spritz. properties: + agentRef: + description: SpritzAgentRef identifies a deployment-owned external + agent record. + properties: + id: + maxLength: 256 + type: string + provider: + maxLength: 128 + type: string + type: + maxLength: 64 + type: string + type: object annotations: additionalProperties: type: string @@ -309,6 +323,17 @@ spec: - name type: object type: array + profileOverrides: + description: ProfileOverrides stores optional local overrides for + UI-facing agent profile fields. + properties: + imageUrl: + maxLength: 2048 + type: string + name: + maxLength: 128 + type: string + type: object repo: description: SpritzRepo describes the repository to clone inside the workload. @@ -456,6 +481,10 @@ spec: More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ type: object type: object + serviceAccountName: + maxLength: 63 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ + type: string sharedMounts: description: SharedMounts configures per-spritz shared directories. items: @@ -683,6 +712,31 @@ spec: - Terminating - Error type: string + profile: + description: SpritzAgentProfileStatus stores the synced UI-facing + profile for an instance. + properties: + imageUrl: + maxLength: 2048 + type: string + lastError: + type: string + lastSyncedAt: + format: date-time + type: string + name: + maxLength: 128 + type: string + observedGeneration: + format: int64 + type: integer + source: + maxLength: 32 + type: string + syncer: + maxLength: 128 + type: string + type: object readyAt: format: date-time type: string diff --git a/operator/api/v1/spritz_types.go b/operator/api/v1/spritz_types.go index 1952822..6a15ac1 100644 --- a/operator/api/v1/spritz_types.go +++ b/operator/api/v1/spritz_types.go @@ -33,15 +33,18 @@ type SpritzSpec struct { // +kubebuilder:validation:Pattern="^([0-9]+h)?([0-9]+m)?([0-9]+s)?$" TTL string `json:"ttl,omitempty"` // +kubebuilder:validation:Pattern="^([0-9]+h)?([0-9]+m)?([0-9]+s)?$" - IdleTTL string `json:"idleTtl,omitempty"` - Resources corev1.ResourceRequirements `json:"resources,omitempty"` - Owner SpritzOwner `json:"owner"` - Labels map[string]string `json:"labels,omitempty"` - Annotations map[string]string `json:"annotations,omitempty"` - Features *SpritzFeatures `json:"features,omitempty"` - SSH *SpritzSSH `json:"ssh,omitempty"` - Ports []SpritzPort `json:"ports,omitempty"` - Ingress *SpritzIngress `json:"ingress,omitempty"` + IdleTTL string `json:"idleTtl,omitempty"` + Resources corev1.ResourceRequirements `json:"resources,omitempty"` + Owner SpritzOwner `json:"owner"` + AgentRef *SpritzAgentRef `json:"agentRef,omitempty"` + // ProfileOverrides stores optional local overrides for UI-facing agent profile fields. + ProfileOverrides *SpritzAgentProfile `json:"profileOverrides,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + Annotations map[string]string `json:"annotations,omitempty"` + Features *SpritzFeatures `json:"features,omitempty"` + SSH *SpritzSSH `json:"ssh,omitempty"` + Ports []SpritzPort `json:"ports,omitempty"` + Ingress *SpritzIngress `json:"ingress,omitempty"` } // SpritzRepo describes the repository to clone inside the workload. @@ -76,6 +79,24 @@ type SpritzOwner struct { Team string `json:"team,omitempty"` } +// SpritzAgentRef identifies a deployment-owned external agent record. +type SpritzAgentRef struct { + // +kubebuilder:validation:MaxLength=64 + Type string `json:"type,omitempty"` + // +kubebuilder:validation:MaxLength=128 + Provider string `json:"provider,omitempty"` + // +kubebuilder:validation:MaxLength=256 + ID string `json:"id,omitempty"` +} + +// SpritzAgentProfile stores UI-facing agent profile fields. +type SpritzAgentProfile struct { + // +kubebuilder:validation:MaxLength=128 + Name string `json:"name,omitempty"` + // +kubebuilder:validation:MaxLength=2048 + ImageURL string `json:"imageUrl,omitempty"` +} + // SpritzFeatures toggles optional capabilities. type SpritzFeatures struct { // +kubebuilder:default=false @@ -139,17 +160,33 @@ type SpritzStatus struct { // +kubebuilder:validation:Enum=Provisioning;Ready;Expiring;Expired;Terminating;Error Phase string `json:"phase,omitempty"` // +kubebuilder:validation:Format=uri - URL string `json:"url,omitempty"` - ACP *SpritzACPStatus `json:"acp,omitempty"` - SSH *SpritzSSHInfo `json:"ssh,omitempty"` - Message string `json:"message,omitempty"` - LastActivityAt *metav1.Time `json:"lastActivityAt,omitempty"` - IdleExpiresAt *metav1.Time `json:"idleExpiresAt,omitempty"` - MaxExpiresAt *metav1.Time `json:"maxExpiresAt,omitempty"` - ExpiresAt *metav1.Time `json:"expiresAt,omitempty"` - LifecycleReason string `json:"lifecycleReason,omitempty"` - ReadyAt *metav1.Time `json:"readyAt,omitempty"` - Conditions []metav1.Condition `json:"conditions,omitempty"` + URL string `json:"url,omitempty"` + Profile *SpritzAgentProfileStatus `json:"profile,omitempty"` + ACP *SpritzACPStatus `json:"acp,omitempty"` + SSH *SpritzSSHInfo `json:"ssh,omitempty"` + Message string `json:"message,omitempty"` + LastActivityAt *metav1.Time `json:"lastActivityAt,omitempty"` + IdleExpiresAt *metav1.Time `json:"idleExpiresAt,omitempty"` + MaxExpiresAt *metav1.Time `json:"maxExpiresAt,omitempty"` + ExpiresAt *metav1.Time `json:"expiresAt,omitempty"` + LifecycleReason string `json:"lifecycleReason,omitempty"` + ReadyAt *metav1.Time `json:"readyAt,omitempty"` + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// SpritzAgentProfileStatus stores the synced UI-facing profile for an instance. +type SpritzAgentProfileStatus struct { + // +kubebuilder:validation:MaxLength=128 + Name string `json:"name,omitempty"` + // +kubebuilder:validation:MaxLength=2048 + ImageURL string `json:"imageUrl,omitempty"` + // +kubebuilder:validation:MaxLength=32 + Source string `json:"source,omitempty"` + // +kubebuilder:validation:MaxLength=128 + Syncer string `json:"syncer,omitempty"` + ObservedGeneration int64 `json:"observedGeneration,omitempty"` + LastSyncedAt *metav1.Time `json:"lastSyncedAt,omitempty"` + LastError string `json:"lastError,omitempty"` } // SpritzACPStatus describes ACP discovery state for the workload. @@ -440,6 +477,14 @@ func (in *SpritzSpec) DeepCopyInto(out *SpritzSpec) { copy(out.SharedMounts, in.SharedMounts) } in.Resources.DeepCopyInto(&out.Resources) + if in.AgentRef != nil { + out.AgentRef = &SpritzAgentRef{} + *out.AgentRef = *in.AgentRef + } + if in.ProfileOverrides != nil { + out.ProfileOverrides = &SpritzAgentProfile{} + *out.ProfileOverrides = *in.ProfileOverrides + } if in.Labels != nil { out.Labels = make(map[string]string, len(in.Labels)) for k, v := range in.Labels { @@ -491,6 +536,13 @@ func (in *SpritzSpec) DeepCopyInto(out *SpritzSpec) { func (in *SpritzStatus) DeepCopyInto(out *SpritzStatus) { *out = *in + if in.Profile != nil { + out.Profile = &SpritzAgentProfileStatus{} + *out.Profile = *in.Profile + if in.Profile.LastSyncedAt != nil { + out.Profile.LastSyncedAt = in.Profile.LastSyncedAt.DeepCopy() + } + } if in.ACP != nil { out.ACP = &SpritzACPStatus{} in.ACP.DeepCopyInto(out.ACP) diff --git a/operator/api/v1/spritz_types_test.go b/operator/api/v1/spritz_types_test.go index d11a26a..1c9ba4e 100644 --- a/operator/api/v1/spritz_types_test.go +++ b/operator/api/v1/spritz_types_test.go @@ -11,8 +11,14 @@ func TestSpritzStatusDeepCopyIntoCopiesLifecycleTimestamps(t *testing.T) { idle := metav1.NewTime(time.Date(2026, 3, 11, 12, 0, 0, 0, time.UTC)) max := metav1.NewTime(time.Date(2026, 3, 12, 12, 0, 0, 0, time.UTC)) ready := metav1.NewTime(time.Date(2026, 3, 11, 11, 0, 0, 0, time.UTC)) + synced := metav1.NewTime(time.Date(2026, 3, 11, 10, 0, 0, 0, time.UTC)) original := &SpritzStatus{ + Profile: &SpritzAgentProfileStatus{ + Name: "Helpful Otter", + ImageURL: "https://console.example.com/otter.png", + LastSyncedAt: &synced, + }, IdleExpiresAt: &idle, MaxExpiresAt: &max, ReadyAt: &ready, @@ -26,6 +32,12 @@ func TestSpritzStatusDeepCopyIntoCopiesLifecycleTimestamps(t *testing.T) { if copied.MaxExpiresAt == original.MaxExpiresAt { t.Fatal("expected max expiry timestamp pointer to be deep-copied") } + if copied.Profile == original.Profile { + t.Fatal("expected profile pointer to be deep-copied") + } + if copied.Profile.LastSyncedAt == original.Profile.LastSyncedAt { + t.Fatal("expected profile lastSyncedAt pointer to be deep-copied") + } updatedIdle := metav1.NewTime(copied.IdleExpiresAt.Add(2 * time.Hour)) updatedMax := metav1.NewTime(copied.MaxExpiresAt.Add(2 * time.Hour)) @@ -38,4 +50,7 @@ func TestSpritzStatusDeepCopyIntoCopiesLifecycleTimestamps(t *testing.T) { if !original.MaxExpiresAt.Equal(&max) { t.Fatalf("expected original max expiry to stay unchanged, got %#v", original.MaxExpiresAt) } + if !original.Profile.LastSyncedAt.Equal(&synced) { + t.Fatalf("expected original profile sync time to stay unchanged, got %#v", original.Profile.LastSyncedAt) + } } diff --git a/ui/src/components/acp/sidebar.test.tsx b/ui/src/components/acp/sidebar.test.tsx index 13b8cbd..f20beea 100644 --- a/ui/src/components/acp/sidebar.test.tsx +++ b/ui/src/components/acp/sidebar.test.tsx @@ -32,10 +32,14 @@ function createSpritz(name: string): Spritz { phase: 'Ready', acp: { state: 'ready' }, }, - } as Spritz; + }; } -function createConversation(name: string, title: string, spritzName: string): ConversationInfo { +function createConversation( + name: string, + title: string, + spritzName: string, +): ConversationInfo { return { metadata: { name }, spec: { @@ -46,7 +50,7 @@ function createConversation(name: string, title: string, spritzName: string): Co status: { bindingState: 'active', }, - } as ConversationInfo; + }; } const SidebarWithFocus = Sidebar as unknown as ( @@ -106,11 +110,25 @@ describe('Sidebar', () => { />, ); - const agentHeaders = screen.getAllByRole('button', { name: / conversations$/i }); + const agentHeaders = screen.getAllByRole('button', { + name: / conversations$/i, + }); expect(agentHeaders[0]?.getAttribute('aria-label')).toBe('beta conversations'); - expect(screen.getByRole('button', { name: 'beta conversations' }).getAttribute('aria-current')).toBe('true'); - expect(screen.getByRole('button', { name: 'beta conversations' }).getAttribute('aria-expanded')).toBe('true'); - expect(screen.getByRole('button', { name: 'alpha conversations' }).getAttribute('aria-expanded')).toBe('false'); + expect( + screen + .getByRole('button', { name: 'beta conversations' }) + .getAttribute('aria-current'), + ).toBe('true'); + expect( + screen + .getByRole('button', { name: 'beta conversations' }) + .getAttribute('aria-expanded'), + ).toBe('true'); + expect( + screen + .getByRole('button', { name: 'alpha conversations' }) + .getAttribute('aria-expanded'), + ).toBe('false'); }); it('shows a selected optimistic provisioning conversation for a focused route before the agent is discoverable', () => { @@ -131,6 +149,8 @@ describe('Sidebar', () => { expect(screen.getByText('zeno-fresh-ridge')).toBeTruthy(); expect(screen.getByText('Creating your agent instance.')).toBeTruthy(); - expect(screen.getByText('Starting…').closest('[aria-current="true"]')).toBeTruthy(); + expect( + screen.getByText('Starting…').closest('[aria-current="true"]'), + ).toBeTruthy(); }); }); diff --git a/ui/src/components/acp/sidebar.tsx b/ui/src/components/acp/sidebar.tsx index bc8d6a4..dad93fa 100644 --- a/ui/src/components/acp/sidebar.tsx +++ b/ui/src/components/acp/sidebar.tsx @@ -10,6 +10,13 @@ import { import { cn, timeAgo } from '@/lib/utils'; import { describeChatAction } from '@/lib/urls'; import { buildProvisioningPlaceholderSpritz, getProvisioningStatusLine } from '@/lib/provisioning'; +import { + getConversationAgentImageUrl, + getConversationAgentName, + getSpritzProfileImageUrl, + getSpritzProfileName, +} from '@/lib/spritz-profile'; +import { AgentAvatar } from '@/components/agent-avatar'; import { BrandHeader } from '@/components/brand-header'; import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'; import type { ConversationInfo } from '@/types/acp'; @@ -231,6 +238,8 @@ function FocusedAgentProvisioningSection({ selectedConversationId: string | null; }) { const name = spritz.metadata.name; + const displayName = getSpritzProfileName(spritz) || name; + const imageUrl = getSpritzProfileImageUrl(spritz); const statusLine = getProvisioningStatusLine(spritz); const conversationLabel = describeChatAction(spritz).label; const conversationSelected = !selectedConversationId; @@ -243,7 +252,8 @@ function FocusedAgentProvisioningSection({ className="flex flex-1 items-center gap-2 rounded-[var(--radius-lg)] bg-sidebar-accent px-3 py-1.5 text-left text-xs font-medium text-foreground" >