From 3888fcbf1871f8dd5aed6ed977e3f51c823ddc03 Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Thu, 24 Sep 2026 00:10:58 -0400 Subject: [PATCH] feat(sync): establish prompt sync foundations --- internal/sync/api/catalog.go | 275 ++++++++++++++++ internal/sync/api/catalog_test.go | 292 +++++++++++++++++ internal/sync/api/client.go | 260 +++++++++++++++ internal/sync/api/client_test.go | 510 ++++++++++++++++++++++++++++++ internal/sync/fingerprint.go | 75 +++++ internal/sync/fingerprint_test.go | 81 +++++ internal/sync/resource.go | 80 +++++ 7 files changed, 1573 insertions(+) create mode 100644 internal/sync/api/catalog.go create mode 100644 internal/sync/api/catalog_test.go create mode 100644 internal/sync/api/client.go create mode 100644 internal/sync/api/client_test.go create mode 100644 internal/sync/fingerprint.go create mode 100644 internal/sync/fingerprint_test.go create mode 100644 internal/sync/resource.go diff --git a/internal/sync/api/catalog.go b/internal/sync/api/catalog.go new file mode 100644 index 00000000..fb74282a --- /dev/null +++ b/internal/sync/api/catalog.go @@ -0,0 +1,275 @@ +package api + +import ( + "encoding/json" + "fmt" + "maps" + "net/http" + "net/url" + + "github.com/launchdarkly/ldcli/internal/resources" + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +const catalogPageLimit = 25 + +// Project is the project metadata needed by interactive sync flows. +type Project struct { + Key string `json:"key"` + Name string `json:"name"` +} + +// ModelConfig contains the model settings assigned to a new variation. +type ModelConfig struct { + Key string `json:"key"` + ID string `json:"id"` + Name string `json:"name"` + Version int `json:"version"` + Params map[string]any `json:"params"` + CustomParams map[string]any `json:"customParams"` +} + +// VariationModel returns the model representation used by variation APIs. +func (config ModelConfig) VariationModel() map[string]any { + params := config.Params + if params == nil { + params = map[string]any{} + } + custom := config.CustomParams + if custom == nil { + custom = map[string]any{} + } + return map[string]any{ + "modelName": config.ID, + "parameters": params, + "custom": custom, + } +} + +// Config contains a supported config and its prompt variations. +type Config struct { + Key string `json:"key"` + Name string `json:"name"` + Mode syncdomain.VariationMode `json:"mode"` + Variations []syncdomain.Variation `json:"variations"` +} + +type configVariationResponse struct { + syncdomain.Variation + State string `json:"state"` +} + +// UnmarshalJSON excludes archived variations at the API boundary. LaunchDarkly +// retains archived variations in a config response, but sync treats them as +// absent and keeps lifecycle state out of the canonical variation model. +func (config *Config) UnmarshalJSON(data []byte) error { + var response struct { + Key string `json:"key"` + Name string `json:"name"` + Mode syncdomain.VariationMode `json:"mode"` + Variations []configVariationResponse `json:"variations"` + } + if err := json.Unmarshal(data, &response); err != nil { + return err + } + + config.Key = response.Key + config.Name = response.Name + config.Mode = response.Mode + config.Variations = make([]syncdomain.Variation, 0, len(response.Variations)) + for _, variation := range response.Variations { + if variation.State != "archived" { + config.Variations = append(config.Variations, variation.Variation) + } + } + return nil +} + +// CatalogClient lists resources used by bootstrap and link flows. +type CatalogClient struct { + transport resources.Client + accessToken string + baseURI string +} + +// NewCatalogClient creates a client for sync catalog reads. +func NewCatalogClient(transport resources.Client, accessToken, baseURI string) CatalogClient { + return CatalogClient{ + transport: transport, + accessToken: accessToken, + baseURI: baseURI, + } +} + +// Projects returns projects ordered by name. +func (client CatalogClient) Projects() ([]Project, error) { + endpoint, err := url.JoinPath(client.baseURI, "api/v2/projects") + if err != nil { + return nil, fmt.Errorf("build projects endpoint: %w", err) + } + + return listCatalog[Project]( + client, + endpoint, + "projects", + false, + url.Values{"sort": {"name"}}, + ) +} + +// ModelConfigs returns model configs available to one project. +func (client CatalogClient) ModelConfigs(projectKey string) ([]ModelConfig, error) { + endpoint, err := url.JoinPath(client.baseURI, "api/v2/projects", projectKey, "ai-configs/model-configs") + if err != nil { + return nil, fmt.Errorf("build model configs endpoint: %w", err) + } + + response, err := client.transport.MakeRequest( + client.accessToken, + http.MethodGet, + endpoint, + "", + nil, + nil, + false, + ) + if err != nil { + return nil, fmt.Errorf("list model configs: %w", err) + } + + var modelConfigs []ModelConfig + if err := json.Unmarshal(response, &modelConfigs); err != nil { + return nil, fmt.Errorf("decode model configs response: %w", err) + } + + return modelConfigs, nil +} + +// Configs returns the agent and completion configs in one project. +func (client CatalogClient) Configs(projectKey string) ([]Config, error) { + endpoint, err := url.JoinPath(client.baseURI, "api/v2/projects", projectKey, "ai-configs") + if err != nil { + return nil, fmt.Errorf("build configs endpoint: %w", err) + } + + configs, err := listCatalog[Config]( + client, + endpoint, + "configs", + false, + url.Values{ + "sort": {"name"}, + "filter": {`mode anyOf ["agent","completion"]`}, + }, + ) + if err != nil { + return nil, err + } + + for index := range configs { + if err := configs[index].applyMode(); err != nil { + return nil, err + } + } + + return configs, nil +} + +// Config returns one config with its variation modes normalized. +func (client CatalogClient) Config(projectKey, configKey string) (Config, error) { + endpoint, err := url.JoinPath(client.baseURI, "api/v2/projects", projectKey, "ai-configs", configKey) + if err != nil { + return Config{}, fmt.Errorf("build config endpoint: %w", err) + } + + response, err := client.transport.MakeRequest( + client.accessToken, + http.MethodGet, + endpoint, + "", + nil, + nil, + false, + ) + if err != nil { + return Config{}, fmt.Errorf("get config %q: %w", configKey, err) + } + + var config Config + if err := json.Unmarshal(response, &config); err != nil { + return Config{}, fmt.Errorf("decode config response: %w", err) + } + if err := config.applyMode(); err != nil { + return Config{}, err + } + + return config, nil +} + +// applyMode validates the parent config mode and copies it onto every +// variation, whose API representation does not contain its own mode. +func (config *Config) applyMode() error { + if config.Mode == "" { + config.Mode = syncdomain.VariationModeCompletion + } + if !config.Mode.Valid() { + return fmt.Errorf( + "config %q has unsupported mode %q", + config.Key, + config.Mode, + ) + } + + for index := range config.Variations { + config.Variations[index].Mode = config.Mode + } + + return nil +} + +type catalogPage[T any] struct { + Items []T `json:"items"` + TotalCount int `json:"totalCount"` +} + +// listCatalog retrieves every page from a list endpoint while preserving the +// server's requested sort order. +func listCatalog[T any]( + client CatalogClient, + endpoint string, + resourceName string, + beta bool, + baseQuery url.Values, +) ([]T, error) { + var items []T + + for offset := 0; ; offset += catalogPageLimit { + query := maps.Clone(baseQuery) + query.Set("limit", fmt.Sprintf("%d", catalogPageLimit)) + query.Set("offset", fmt.Sprintf("%d", offset)) + + response, err := client.transport.MakeRequest( + client.accessToken, + http.MethodGet, + endpoint, + "", + query, + nil, + beta, + ) + if err != nil { + return nil, fmt.Errorf("list %s: %w", resourceName, err) + } + + var page catalogPage[T] + if err := json.Unmarshal(response, &page); err != nil { + return nil, fmt.Errorf("decode %s response: %w", resourceName, err) + } + + items = append(items, page.Items...) + if len(page.Items) < catalogPageLimit || + (page.TotalCount > 0 && len(items) >= page.TotalCount) { + return items, nil + } + } +} diff --git a/internal/sync/api/catalog_test.go b/internal/sync/api/catalog_test.go new file mode 100644 index 00000000..7fdf3204 --- /dev/null +++ b/internal/sync/api/catalog_test.go @@ -0,0 +1,292 @@ +package api + +import ( + "encoding/json" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +func TestCatalogClientProjectsPaginatesInNameOrder(t *testing.T) { + firstPage := make([]Project, catalogPageLimit) + for index := range firstPage { + firstPage[index] = Project{ + Key: string(rune('a' + index)), + Name: string(rune('A' + index)), + } + } + + transport := &recordingClient{Responses: [][]byte{ + mustCatalogJSON(t, catalogPage[Project]{ + Items: firstPage, + TotalCount: 26, + }), + mustCatalogJSON(t, catalogPage[Project]{ + Items: []Project{{Key: "z", Name: "Z"}}, + TotalCount: 26, + }), + }} + + projects, err := NewCatalogClient( + transport, + "token", + "https://example.com", + ).Projects() + + require.NoError(t, err) + require.Len(t, projects, 26) + require.Len(t, transport.Requests, 2) + + firstRequest := transport.Requests[0] + assert.Equal(t, "GET", firstRequest.Method) + assert.Equal(t, "token", firstRequest.AccessToken) + assert.Equal(t, "https://example.com/api/v2/projects", firstRequest.Path) + assert.Equal(t, "name", firstRequest.Query.Get("sort")) + assert.Equal(t, "25", firstRequest.Query.Get("limit")) + assert.Equal(t, "0", firstRequest.Query.Get("offset")) + assert.False(t, firstRequest.IsBeta) + assert.Equal(t, "25", transport.Requests[1].Query.Get("offset")) + assert.Equal(t, "Z", projects[25].Name) +} + +func TestCatalogClientConfigsPaginatesAndAppliesMode(t *testing.T) { + transport := &recordingClient{Responses: [][]byte{ + mustCatalogJSON(t, catalogPage[Config]{ + Items: []Config{{ + Key: "support", + Name: "Support agent", + Mode: syncdomain.VariationModeAgent, + Variations: []syncdomain.Variation{{ + Key: "helpful", + Name: "Helpful", + }}, + }}, + TotalCount: 1, + }), + }} + + configs, err := NewCatalogClient( + transport, + "token", + "https://example.com", + ).Configs("project") + + require.NoError(t, err) + require.Len(t, configs, 1) + require.Len(t, configs[0].Variations, 1) + assert.Equal( + t, + syncdomain.VariationModeAgent, + configs[0].Variations[0].Mode, + ) + + request := transport.Requests[0] + assert.Equal( + t, + "https://example.com/api/v2/projects/project/ai-configs", + request.Path, + ) + assert.Equal(t, "name", request.Query.Get("sort")) + assert.Equal( + t, + `mode anyOf ["agent","completion"]`, + request.Query.Get("filter"), + ) + assert.False(t, request.IsBeta) +} + +func TestCatalogClientConfigsDefaultsCompletionMode(t *testing.T) { + transport := &recordingClient{Responses: [][]byte{ + mustCatalogJSON(t, catalogPage[Config]{ + Items: []Config{{ + Key: "completion", + Name: "Completion", + Variations: []syncdomain.Variation{{Key: "strict", Name: "Strict"}}, + }}, + TotalCount: 1, + }), + }} + + configs, err := NewCatalogClient( + transport, + "token", + "https://example.com", + ).Configs("project") + + require.NoError(t, err) + require.Len(t, configs, 1) + require.Len(t, configs[0].Variations, 1) + assert.Equal( + t, + syncdomain.VariationModeCompletion, + configs[0].Variations[0].Mode, + ) +} + +func TestCatalogClientConfigsRejectsJudgeMode(t *testing.T) { + transport := &recordingClient{Responses: [][]byte{ + mustCatalogJSON(t, catalogPage[Config]{ + Items: []Config{{ + Key: "config", + Name: "Config", + Mode: "judge", + }}, + TotalCount: 1, + }), + }} + + _, err := NewCatalogClient( + transport, + "token", + "https://example.com", + ).Configs("project") + + require.ErrorContains(t, err, `unsupported mode "judge"`) +} + +func TestCatalogClientConfigGetsExactConfigAndAppliesMode(t *testing.T) { + transport := &recordingClient{Responses: [][]byte{ + mustCatalogJSON(t, Config{ + Key: "support", + Name: "Support agent", + Mode: syncdomain.VariationModeAgent, + Variations: []syncdomain.Variation{{ + Key: "helpful", + Name: "Helpful", + Instructions: "Help the user.", + }}, + }), + }} + + config, err := NewCatalogClient( + transport, + "token", + "https://example.com", + ).Config("project", "support") + + require.NoError(t, err) + require.Len(t, config.Variations, 1) + assert.Equal(t, syncdomain.VariationModeAgent, config.Variations[0].Mode) + + require.Len(t, transport.Requests, 1) + request := transport.Requests[0] + assert.Equal(t, "GET", request.Method) + assert.Equal( + t, + "https://example.com/api/v2/projects/project/ai-configs/support", + request.Path, + ) + assert.Empty(t, request.Query) + assert.False(t, request.IsBeta) +} + +func TestCatalogClientConfigRejectsInvalidResponse(t *testing.T) { + client := NewCatalogClient( + &recordingClient{Responses: [][]byte{[]byte(`not json`)}}, + "token", + "https://example.com", + ) + + _, err := client.Config("project", "support") + + require.ErrorContains(t, err, "decode config response") +} + +func TestCatalogClientProjectsRejectsInvalidResponse(t *testing.T) { + client := NewCatalogClient( + &recordingClient{Responses: [][]byte{[]byte(`not json`)}}, + "token", + "https://example.com", + ) + + _, err := client.Projects() + + require.ErrorContains(t, err, "decode projects response") +} + +func TestCatalogClientModelConfigsGetsBareArray(t *testing.T) { + transport := &recordingClient{Responses: [][]byte{[]byte(`[ + { + "key":"claude-sonnet", + "id":"claude-3-5-sonnet-20241022", + "name":"Claude Sonnet", + "version":4, + "provider":"anthropic", + "params":{"temperature":0.2}, + "customParams":{"region":"us-east"} + }, + {"key":"gpt-5","id":"gpt-5-2025-08-07","name":"GPT-5","provider":"openai"} + ]`)}} + + modelConfigs, err := NewCatalogClient( + transport, + "token", + "https://example.com", + ).ModelConfigs("project") + + require.NoError(t, err) + assert.Equal(t, []ModelConfig{ + { + Key: "claude-sonnet", ID: "claude-3-5-sonnet-20241022", Name: "Claude Sonnet", Version: 4, + Params: map[string]any{"temperature": 0.2}, CustomParams: map[string]any{"region": "us-east"}, + }, + {Key: "gpt-5", ID: "gpt-5-2025-08-07", Name: "GPT-5"}, + }, modelConfigs) + assert.Equal(t, map[string]any{ + "modelName": "claude-3-5-sonnet-20241022", + "parameters": map[string]any{"temperature": 0.2}, + "custom": map[string]any{"region": "us-east"}, + }, modelConfigs[0].VariationModel()) + assert.Equal(t, map[string]any{ + "modelName": "gpt-5-2025-08-07", "parameters": map[string]any{}, "custom": map[string]any{}, + }, modelConfigs[1].VariationModel()) + + require.Len(t, transport.Requests, 1) + request := transport.Requests[0] + assert.Equal(t, "GET", request.Method) + assert.Equal(t, "token", request.AccessToken) + assert.Equal( + t, + "https://example.com/api/v2/projects/project/ai-configs/model-configs", + request.Path, + ) + assert.Empty(t, request.Query) + assert.False(t, request.IsBeta) +} + +func TestCatalogClientModelConfigsReturnsRequestError(t *testing.T) { + client := NewCatalogClient( + &recordingClient{Err: errors.New("unavailable")}, + "token", + "https://example.com", + ) + + _, err := client.ModelConfigs("project") + + require.ErrorContains(t, err, "list model configs: unavailable") +} + +func TestCatalogClientModelConfigsRejectsInvalidResponse(t *testing.T) { + client := NewCatalogClient( + &recordingClient{Responses: [][]byte{[]byte(`{"items":[]}`)}}, + "token", + "https://example.com", + ) + + _, err := client.ModelConfigs("project") + + require.ErrorContains(t, err, "decode model configs response") +} + +func mustCatalogJSON(t *testing.T, value any) []byte { + t.Helper() + + data, err := json.Marshal(value) + require.NoError(t, err) + + return data +} diff --git a/internal/sync/api/client.go b/internal/sync/api/client.go new file mode 100644 index 00000000..14b5bb28 --- /dev/null +++ b/internal/sync/api/client.go @@ -0,0 +1,260 @@ +package api + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + + "github.com/launchdarkly/ldcli/internal/resources" + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +// Client reads and mutates prompt variations through the existing config endpoints. +type Client struct { + transport resources.Client + accessToken string + baseURI string +} + +// VariationState includes the parent config mode even when the requested +// variation does not exist. +type VariationState struct { + Variation syncdomain.Variation + Exists bool + ConfigMode syncdomain.VariationMode +} + +type createVariationRequest struct { + Key string `json:"key"` + Name string `json:"name"` + Instructions string `json:"instructions,omitempty"` + ModelConfigKey string `json:"modelConfigKey,omitempty"` + ModelConfigVersion int `json:"modelConfigVersion,omitempty"` + Model map[string]any `json:"model,omitempty"` + Messages []syncdomain.Message `json:"messages,omitempty"` +} + +type updateVariationRequest struct { + Name string `json:"name"` + Instructions *string `json:"instructions,omitempty"` + ModelConfigKey string `json:"modelConfigKey"` + ModelConfigVersion int `json:"modelConfigVersion,omitempty"` + Model map[string]any `json:"model"` + Messages *[]syncdomain.Message `json:"messages,omitempty"` +} + +type mutationError struct { + err error + message string + uncertain bool +} + +// Error returns the contextual mutation failure shown to the user. +func (err mutationError) Error() string { + return err.message +} + +// Unwrap exposes the transport or API error for errors.Is and errors.As. +func (err mutationError) Unwrap() error { + return err.err +} + +// MutationMayHaveSucceeded reports whether a transport failure prevented the +// client from receiving a definitive API response. +func MutationMayHaveSucceeded(err error) bool { + var mutation mutationError + return errors.As(err, &mutation) && mutation.uncertain +} + +// NewClient creates a direct config variation client. +func NewClient(transport resources.Client, accessToken, baseURI string) Client { + return Client{ + transport: transport, + accessToken: accessToken, + baseURI: baseURI, + } +} + +// ReadVariation returns one variation and its parent config mode. +func (client Client) ReadVariation(projectKey, configKey, variationKey string) (VariationState, error) { + config, err := NewCatalogClient(client.transport, client.accessToken, client.baseURI).Config(projectKey, configKey) + if err != nil { + return VariationState{}, err + } + + for _, variation := range config.Variations { + if variation.Key != variationKey { + continue + } + if err := syncdomain.ValidateDirectAPIVariation(variation); err != nil { + return VariationState{}, fmt.Errorf( + "read config variation %q: %w", + variationKey, + err, + ) + } + return VariationState{Variation: variation, Exists: true, ConfigMode: config.Mode}, nil + } + + return VariationState{ConfigMode: config.Mode}, nil +} + +// CreateVariation creates a variation using only fields supported by the +// existing public endpoint. +func (client Client) CreateVariation(projectKey, configKey string, variation syncdomain.Variation) error { + if err := syncdomain.ValidateDirectAPIVariation(variation); err != nil { + return fmt.Errorf("create config variation %q: %w", variation.Key, err) + } + + request := createVariationRequest{ + Key: variation.Key, + Name: variation.Name, + ModelConfigKey: variation.ModelConfigKey, + ModelConfigVersion: variation.ModelConfigVersion, + Model: variation.Model, + } + if variation.Mode == syncdomain.VariationModeAgent { + request.Instructions = variation.Instructions + } else { + request.Messages = variation.Messages + } + + body, err := json.Marshal(request) + if err != nil { + return fmt.Errorf("encode config variation %q: %w", variation.Key, err) + } + + endpoint, err := client.variationEndpoint(projectKey, configKey) + if err != nil { + return err + } + + _, err = client.transport.MakeRequest( + client.accessToken, + http.MethodPost, + endpoint, + "application/json", + nil, + body, + false, + ) + if err != nil { + return newMutationError("create", variation.Key, err) + } + + return nil +} + +// UpdateVariation replaces all supported, locally owned variation fields. +func (client Client) UpdateVariation(projectKey, configKey string, variation syncdomain.Variation) error { + if err := syncdomain.ValidateDirectAPIVariation(variation); err != nil { + return fmt.Errorf("update config variation %q: %w", variation.Key, err) + } + + model := variation.Model + if model == nil { + model = map[string]any{} + } + request := updateVariationRequest{ + Name: variation.Name, + ModelConfigKey: variation.ModelConfigKey, + ModelConfigVersion: variation.ModelConfigVersion, + Model: model, + } + + // Agent and completion configs reject fields owned by the other mode, even + // when those fields are empty. Include only the prompt field for this mode. + if variation.Mode == syncdomain.VariationModeAgent { + request.Instructions = &variation.Instructions + } else { + messages := variation.Messages + if messages == nil { + messages = []syncdomain.Message{} + } + request.Messages = &messages + } + + body, err := json.Marshal(request) + if err != nil { + return fmt.Errorf("encode config variation %q: %w", variation.Key, err) + } + + endpoint, err := client.variationEndpoint(projectKey, configKey, variation.Key) + if err != nil { + return err + } + + _, err = client.transport.MakeRequest( + client.accessToken, + http.MethodPatch, + endpoint, + "application/json", + nil, + body, + false, + ) + if err != nil { + return newMutationError("update", variation.Key, err) + } + + return nil +} + +// ArchiveVariation archives one variation without permanently deleting it. +func (client Client) ArchiveVariation(projectKey, configKey, variationKey string) error { + endpoint, err := client.variationEndpoint(projectKey, configKey, variationKey) + if err != nil { + return err + } + + _, err = client.transport.MakeRequest( + client.accessToken, + http.MethodPatch, + endpoint, + "application/json", + nil, + []byte(`{"state":"archived"}`), + false, + ) + if err != nil { + return newMutationError("archive", variationKey, err) + } + + return nil +} + +// variationEndpoint builds an escaped direct-variation API URL. +func (client Client) variationEndpoint(projectKey, configKey string, path ...string) (string, error) { + parts := []string{ + "api/v2/projects", + projectKey, + "ai-configs", + configKey, + "variations", + } + parts = append(parts, path...) + + endpoint, err := url.JoinPath(client.baseURI, parts...) + if err != nil { + return "", fmt.Errorf("build config variation endpoint: %w", err) + } + return endpoint, nil +} + +// newMutationError records whether a failed request received a definitive API +// response. Errors without a status code may represent a committed write whose +// response was lost, so the reconciliation layer verifies those with a read. +func newMutationError(action, variationKey string, err error) error { + var response struct { + StatusCode int `json:"statusCode"` + } + definitiveResponse := json.Unmarshal([]byte(err.Error()), &response) == nil && response.StatusCode != 0 + + return mutationError{ + err: err, + message: fmt.Sprintf("%s config variation %q: %s", action, variationKey, err), + uncertain: !definitiveResponse, + } +} diff --git a/internal/sync/api/client_test.go b/internal/sync/api/client_test.go new file mode 100644 index 00000000..5a45de60 --- /dev/null +++ b/internal/sync/api/client_test.go @@ -0,0 +1,510 @@ +package api + +import ( + "errors" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/launchdarkly/ldcli/internal/resources" + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +type recordedRequest struct { + AccessToken string + Method string + Path string + ContentType string + Query url.Values + Body []byte + IsBeta bool +} + +type recordingClient struct { + Requests []recordedRequest + Responses [][]byte + Err error +} + +var _ resources.Client = &recordingClient{} + +func (client *recordingClient) MakeRequest( + accessToken string, + method string, + path string, + contentType string, + query url.Values, + body []byte, + isBeta bool, +) ([]byte, error) { + client.Requests = append(client.Requests, recordedRequest{ + AccessToken: accessToken, + Method: method, + Path: path, + ContentType: contentType, + Query: query, + Body: append([]byte(nil), body...), + IsBeta: isBeta, + }) + if client.Err != nil { + return nil, client.Err + } + if len(client.Responses) < len(client.Requests) { + return nil, nil + } + + return client.Responses[len(client.Requests)-1], nil +} + +func (*recordingClient) MakeUnauthenticatedRequest(string, string, []byte) ([]byte, error) { + return nil, nil +} + +func TestClientVariationReadsParentConfig(t *testing.T) { + transport := &recordingClient{Responses: [][]byte{[]byte(`{ + "key": "support", + "name": "Support agent", + "mode": "agent", + "variations": [ + { + "mode": "completion", + "key": "other", + "name": "Other" + }, + { + "mode": "completion", + "key": "helpful", + "name": "Helpful", + "instructions": "Help the user.", + "model": {"modelName": "claude"} + } + ] + }`)}} + client := NewClient(transport, "token", "https://example.com") + + state, err := client.ReadVariation("project", "support", "helpful") + + require.NoError(t, err) + assert.True(t, state.Exists) + assert.Equal(t, syncdomain.VariationModeAgent, state.ConfigMode) + assert.Equal(t, syncdomain.VariationModeAgent, state.Variation.Mode) + assert.Equal(t, "helpful", state.Variation.Key) + assert.Equal(t, "Help the user.", state.Variation.Instructions) + assert.Equal(t, "claude", state.Variation.Model["modelName"]) + + require.Len(t, transport.Requests, 1) + request := transport.Requests[0] + assert.Equal(t, "token", request.AccessToken) + assert.Equal(t, "GET", request.Method) + assert.Equal( + t, + "https://example.com/api/v2/projects/project/ai-configs/support", + request.Path, + ) + assert.Empty(t, request.ContentType) + assert.Empty(t, request.Query) + assert.Empty(t, request.Body) + assert.False(t, request.IsBeta) +} + +func TestClientVariationReturnsFalseWhenMissing(t *testing.T) { + transport := &recordingClient{Responses: [][]byte{[]byte(`{ + "key": "support", + "mode": "agent", + "variations": [{"key": "other", "name": "Other"}] + }`)}} + client := NewClient(transport, "token", "https://example.com") + + state, err := client.ReadVariation("project", "support", "missing") + + require.NoError(t, err) + assert.False(t, state.Exists) + assert.Equal(t, syncdomain.VariationModeAgent, state.ConfigMode) + assert.Equal(t, syncdomain.Variation{}, state.Variation) +} + +func TestClientVariationTreatsArchivedVariationAsMissing(t *testing.T) { + transport := &recordingClient{Responses: [][]byte{[]byte(`{ + "key": "support", + "mode": "agent", + "variations": [{ + "key": "helpful", + "name": "Helpful", + "state": "archived", + "instructions": "Help the user." + }] + }`)}} + client := NewClient(transport, "token", "https://example.com") + + state, err := client.ReadVariation("project", "support", "helpful") + + require.NoError(t, err) + assert.False(t, state.Exists) + assert.Equal(t, syncdomain.VariationModeAgent, state.ConfigMode) + assert.Equal(t, syncdomain.Variation{}, state.Variation) +} + +func TestClientVariationReturnsTransportError(t *testing.T) { + client := NewClient( + &recordingClient{Err: errors.New("unavailable")}, + "token", + "https://example.com", + ) + + _, err := client.ReadVariation("project", "support", "helpful") + + require.ErrorContains(t, err, `get config "support": unavailable`) +} + +func TestClientVariationRejectsInvalidResponse(t *testing.T) { + client := NewClient( + &recordingClient{Responses: [][]byte{[]byte(`not json`)}}, + "token", + "https://example.com", + ) + + _, err := client.ReadVariation("project", "support", "helpful") + + require.ErrorContains(t, err, "decode config response") +} + +func TestClientVariationReadsModelConfigVersion(t *testing.T) { + transport := &recordingClient{Responses: [][]byte{[]byte(`{ + "key": "support", + "mode": "agent", + "variations": [{ + "key": "helpful", + "name": "Helpful", + "modelConfigVersion": 2 + }] + }`)}} + client := NewClient(transport, "token", "https://example.com") + + state, err := client.ReadVariation("project", "support", "helpful") + + require.NoError(t, err) + assert.Equal(t, 2, state.Variation.ModelConfigVersion) +} + +func TestClientCreateVariation(t *testing.T) { + transport := &recordingClient{Responses: [][]byte{[]byte(`{ + "key": "concise", + "name": "Concise" + }`)}} + client := NewClient(transport, "token", "https://example.com") + variation := syncdomain.Variation{ + Mode: syncdomain.VariationModeCompletion, + Key: "concise", + Name: "Concise", + ModelConfigKey: "claude", + ModelConfigVersion: 2, + Model: map[string]any{ + "modelName": "claude-3-5-sonnet", + "parameters": map[string]any{ + "temperature": 0.2, + }, + }, + Messages: []syncdomain.Message{{ + Role: "system", + Content: "Be concise.", + }}, + Instructions: "stale agent instructions", + } + + err := client.CreateVariation("project", "support", variation) + + require.NoError(t, err) + require.Len(t, transport.Requests, 1) + request := transport.Requests[0] + assert.Equal(t, "token", request.AccessToken) + assert.Equal(t, "POST", request.Method) + assert.Equal( + t, + "https://example.com/api/v2/projects/project/ai-configs/support/variations", + request.Path, + ) + assert.Equal(t, "application/json", request.ContentType) + assert.Empty(t, request.Query) + assert.False(t, request.IsBeta) + assert.JSONEq(t, `{ + "key": "concise", + "name": "Concise", + "modelConfigKey": "claude", + "modelConfigVersion": 2, + "model": { + "modelName": "claude-3-5-sonnet", + "parameters": {"temperature": 0.2} + }, + "messages": [{"role": "system", "content": "Be concise."}] + }`, string(request.Body)) + assert.NotContains(t, string(request.Body), `"mode"`) + assert.NotContains(t, string(request.Body), `"outputFormat"`) + assert.NotContains(t, string(request.Body), `"instructions"`) +} + +func TestClientCreateAgentVariationOmitsCompletionFields(t *testing.T) { + transport := &recordingClient{} + client := NewClient(transport, "token", "https://example.com") + variation := testVariation(syncdomain.VariationModeAgent) + variation.Messages = []syncdomain.Message{{Role: "user", Content: "stale completion message"}} + + require.NoError(t, client.CreateVariation("project", "support", variation)) + + require.Len(t, transport.Requests, 1) + assert.Contains(t, string(transport.Requests[0].Body), `"instructions":"Help the user."`) + assert.NotContains(t, string(transport.Requests[0].Body), `"messages"`) +} + +func TestClientCreateVariationReturnsTransportError(t *testing.T) { + client := NewClient( + &recordingClient{Err: errors.New("forbidden")}, + "token", + "https://example.com", + ) + + err := client.CreateVariation( + "project", + "support", + testVariation(syncdomain.VariationModeAgent), + ) + + require.ErrorContains(t, err, `create config variation "helpful": forbidden`) + assert.True(t, MutationMayHaveSucceeded(err)) +} + +func TestClientMutationRecognizesDefinitiveAPIError(t *testing.T) { + client := NewClient( + &recordingClient{Err: errors.New(`{"code":"invalid_request","statusCode":400}`)}, + "token", + "https://example.com", + ) + + err := client.CreateVariation( + "project", + "support", + testVariation(syncdomain.VariationModeAgent), + ) + + require.ErrorContains(t, err, `"statusCode":400`) + assert.False(t, MutationMayHaveSucceeded(err)) +} + +func TestClientCreateVariationRejectsUnsupportedDirectAPIFields(t *testing.T) { + tests := map[string]syncdomain.Variation{ + "output format": func() syncdomain.Variation { + variation := testVariation(syncdomain.VariationModeAgent) + variation.OutputFormat = map[string]any{"type": "object"} + return variation + }(), + } + + for name, variation := range tests { + t.Run(name, func(t *testing.T) { + transport := &recordingClient{} + client := NewClient(transport, "token", "https://example.com") + + err := client.CreateVariation("project", "support", variation) + + require.Error(t, err) + assert.Empty(t, transport.Requests) + }) + } +} + +func TestClientCreateVariationReturnsEncodeError(t *testing.T) { + transport := &recordingClient{} + client := NewClient(transport, "token", "https://example.com") + variation := testVariation(syncdomain.VariationModeAgent) + variation.Model = map[string]any{"invalid": make(chan int)} + + err := client.CreateVariation("project", "support", variation) + + require.ErrorContains(t, err, `encode config variation "helpful"`) + assert.Empty(t, transport.Requests) +} + +func TestClientUpdateVariation(t *testing.T) { + transport := &recordingClient{Responses: [][]byte{[]byte(`{ + "key": "helpful", + "name": "Very helpful" + }`)}} + client := NewClient(transport, "token", "https://example.com") + variation := testVariation(syncdomain.VariationModeAgent) + variation.Name = "Very helpful" + variation.ModelConfigVersion = 3 + + err := client.UpdateVariation("project", "support", variation) + + require.NoError(t, err) + require.Len(t, transport.Requests, 1) + request := transport.Requests[0] + assert.Equal(t, "token", request.AccessToken) + assert.Equal(t, "PATCH", request.Method) + assert.Equal( + t, + "https://example.com/api/v2/projects/project/ai-configs/support/variations/helpful", + request.Path, + ) + assert.Equal(t, "application/json", request.ContentType) + assert.Empty(t, request.Query) + assert.NotEmpty(t, request.Body) + assert.False(t, request.IsBeta) + assert.JSONEq(t, `{ + "name": "Very helpful", + "instructions": "Help the user.", + "modelConfigKey": "claude", + "modelConfigVersion": 3, + "model": {"modelName": "claude-3-5-sonnet"} + }`, string(request.Body)) + assert.NotContains(t, string(request.Body), `"key"`) + assert.NotContains(t, string(request.Body), `"mode"`) + assert.NotContains(t, string(request.Body), `"outputFormat"`) +} + +func TestClientUpdateVariationSendsEmptyOwnedFieldsToClearThem(t *testing.T) { + transport := &recordingClient{} + client := NewClient(transport, "token", "https://example.com") + variation := syncdomain.Variation{ + Mode: syncdomain.VariationModeAgent, + Key: "helpful", + Name: "Helpful", + } + + require.NoError(t, client.UpdateVariation("project", "support", variation)) + + require.Len(t, transport.Requests, 1) + assert.JSONEq(t, `{ + "name": "Helpful", + "instructions": "", + "modelConfigKey": "", + "model": {} + }`, string(transport.Requests[0].Body)) +} + +func TestClientUpdateCompletionVariationOmitsAgentFields(t *testing.T) { + transport := &recordingClient{} + client := NewClient(transport, "token", "https://example.com") + variation := testVariation(syncdomain.VariationModeCompletion) + variation.Messages = []syncdomain.Message{{Role: "system", Content: "Help the user."}} + + require.NoError(t, client.UpdateVariation("project", "support", variation)) + + require.Len(t, transport.Requests, 1) + assert.JSONEq(t, `{ + "name": "Helpful", + "modelConfigKey": "claude", + "model": {"modelName": "claude-3-5-sonnet"}, + "messages": [{"role": "system", "content": "Help the user."}] + }`, string(transport.Requests[0].Body)) + assert.NotContains(t, string(transport.Requests[0].Body), `"instructions"`) + assert.NotContains(t, string(transport.Requests[0].Body), `"description"`) +} + +func TestClientUpdateCompletionVariationSendsEmptyMessagesToClearThem(t *testing.T) { + transport := &recordingClient{} + client := NewClient(transport, "token", "https://example.com") + variation := testVariation(syncdomain.VariationModeCompletion) + + require.NoError(t, client.UpdateVariation("project", "support", variation)) + + require.Len(t, transport.Requests, 1) + assert.Contains(t, string(transport.Requests[0].Body), `"messages":[]`) + assert.NotContains(t, string(transport.Requests[0].Body), `"instructions"`) +} + +func TestClientUpdateVariationReturnsTransportError(t *testing.T) { + client := NewClient( + &recordingClient{Err: errors.New("conflict")}, + "token", + "https://example.com", + ) + + err := client.UpdateVariation( + "project", + "support", + testVariation(syncdomain.VariationModeAgent), + ) + + require.ErrorContains(t, err, `update config variation "helpful": conflict`) +} + +func TestClientUpdateVariationRejectsUnsupportedDirectAPIFields(t *testing.T) { + tests := map[string]syncdomain.Variation{ + "output format": func() syncdomain.Variation { + variation := testVariation(syncdomain.VariationModeAgent) + variation.OutputFormat = map[string]any{"type": "object"} + return variation + }(), + } + + for name, variation := range tests { + t.Run(name, func(t *testing.T) { + transport := &recordingClient{} + client := NewClient(transport, "token", "https://example.com") + + err := client.UpdateVariation("project", "support", variation) + + require.Error(t, err) + assert.Empty(t, transport.Requests) + }) + } +} + +func TestClientUpdateVariationReturnsEncodeError(t *testing.T) { + transport := &recordingClient{} + client := NewClient(transport, "token", "https://example.com") + variation := testVariation(syncdomain.VariationModeAgent) + variation.Model = map[string]any{"invalid": make(chan int)} + + err := client.UpdateVariation("project", "support", variation) + + require.ErrorContains(t, err, `encode config variation "helpful"`) + assert.Empty(t, transport.Requests) +} + +func TestClientArchiveVariation(t *testing.T) { + transport := &recordingClient{} + client := NewClient(transport, "token", "https://example.com") + + err := client.ArchiveVariation("project", "support", "helpful") + + require.NoError(t, err) + require.Len(t, transport.Requests, 1) + request := transport.Requests[0] + assert.Equal(t, "token", request.AccessToken) + assert.Equal(t, "PATCH", request.Method) + assert.Equal( + t, + "https://example.com/api/v2/projects/project/ai-configs/support/variations/helpful", + request.Path, + ) + assert.Equal(t, "application/json", request.ContentType) + assert.Empty(t, request.Query) + assert.JSONEq(t, `{"state":"archived"}`, string(request.Body)) + assert.False(t, request.IsBeta) +} + +func TestClientArchiveVariationReturnsTransportError(t *testing.T) { + client := NewClient( + &recordingClient{Err: errors.New("in use")}, + "token", + "https://example.com", + ) + + err := client.ArchiveVariation("project", "support", "helpful") + + require.ErrorContains(t, err, `archive config variation "helpful": in use`) +} + +func testVariation(mode syncdomain.VariationMode) syncdomain.Variation { + return syncdomain.Variation{ + Mode: mode, + Key: "helpful", + Name: "Helpful", + Instructions: "Help the user.", + ModelConfigKey: "claude", + Model: map[string]any{"modelName": "claude-3-5-sonnet"}, + } +} diff --git a/internal/sync/fingerprint.go b/internal/sync/fingerprint.go new file mode 100644 index 00000000..1029d40b --- /dev/null +++ b/internal/sync/fingerprint.go @@ -0,0 +1,75 @@ +package sync + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "strings" +) + +const variationFingerprintSchema = "launchdarkly.config.variation/v1" + +// FingerprintVariation returns a stable fingerprint for the variation fields +// supported by the existing config variation APIs. +func FingerprintVariation(projectKey, lookupKey string, variation Variation) (string, error) { + if err := ValidateDirectAPIVariation(variation); err != nil { + return "", err + } + + normalized := variation + if len(normalized.Model) == 0 { + normalized.Model = nil + } + if len(normalized.Messages) == 0 { + normalized.Messages = nil + } + switch normalized.Mode { + case VariationModeAgent: + normalized.Instructions = strings.TrimSpace(normalized.Instructions) + normalized.Messages = nil + case VariationModeCompletion: + normalized.Instructions = "" + normalized.Messages = append([]Message(nil), normalized.Messages...) + for index := range normalized.Messages { + normalized.Messages[index].Content = strings.TrimSpace(normalized.Messages[index].Content) + } + } + + value := struct { + Schema string `json:"schema"` + ResourceKind Kind `json:"resourceKind"` + ProjectKey string `json:"projectKey"` + LookupKey string `json:"lookupKey"` + Variation Variation `json:"variation"` + }{ + Schema: variationFingerprintSchema, + ResourceKind: KindVariation, + ProjectKey: projectKey, + LookupKey: lookupKey, + Variation: normalized, + } + + canonical, err := json.Marshal(value) + if err != nil { + return "", fmt.Errorf("encode variation fingerprint: %w", err) + } + sum := sha256.Sum256(canonical) + return "sha256:" + hex.EncodeToString(sum[:]), nil +} + +// ValidateDirectAPIVariation rejects fields that the existing variation APIs +// cannot round-trip without the sync endpoints. +func ValidateDirectAPIVariation(variation Variation) error { + switch { + case !variation.Mode.Valid(): + return fmt.Errorf("unsupported variation mode %q", variation.Mode) + case variation.Key == "": + return fmt.Errorf("variation key is required") + case variation.Name == "": + return fmt.Errorf("variation name is required") + case len(variation.OutputFormat) != 0: + return fmt.Errorf("outputFormat is not supported by direct config variation APIs") + } + return nil +} diff --git a/internal/sync/fingerprint_test.go b/internal/sync/fingerprint_test.go new file mode 100644 index 00000000..7ee34329 --- /dev/null +++ b/internal/sync/fingerprint_test.go @@ -0,0 +1,81 @@ +package sync + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFingerprintVariationIsStable(t *testing.T) { + variation := Variation{ + Mode: VariationModeCompletion, + Key: "default", + Name: "Default", + Model: map[string]any{ + "temperature": 0.5, + "provider": "openai", + }, + Messages: []Message{{Role: "system", Content: "Be concise."}}, + } + + first, err := FingerprintVariation("production", "support/default", variation) + require.NoError(t, err) + + variation.Model = map[string]any{"provider": "openai", "temperature": 0.5} + variation.Instructions = "ignored for completion mode" + second, err := FingerprintVariation("production", "support/default", variation) + require.NoError(t, err) + + require.Equal(t, first, second) + require.Regexp(t, `^sha256:[0-9a-f]{64}$`, first) +} + +func TestFingerprintVariationIncludesResourceIdentity(t *testing.T) { + variation := Variation{Mode: VariationModeAgent, Key: "default", Name: "Default", Instructions: "Help"} + one, err := FingerprintVariation("one", "config/default", variation) + require.NoError(t, err) + two, err := FingerprintVariation("two", "config/default", variation) + require.NoError(t, err) + + require.NotEqual(t, one, two) +} + +func TestFingerprintVariationNormalizesRenderedPromptWhitespace(t *testing.T) { + agent := Variation{Mode: VariationModeAgent, Key: "agent", Name: "Agent", Instructions: "\n Help the user. \n"} + trimmedAgent := agent + trimmedAgent.Instructions = "Help the user." + + agentFingerprint, err := FingerprintVariation("project", "config/agent", agent) + require.NoError(t, err) + trimmedAgentFingerprint, err := FingerprintVariation("project", "config/agent", trimmedAgent) + require.NoError(t, err) + require.Equal(t, agentFingerprint, trimmedAgentFingerprint) + + completion := Variation{ + Mode: VariationModeCompletion, + Key: "completion", + Name: "Completion", + Messages: []Message{{Role: "system", Content: "\n Be concise. \n"}}, + } + trimmedCompletion := completion + trimmedCompletion.Messages = []Message{{Role: "system", Content: "Be concise."}} + + completionFingerprint, err := FingerprintVariation("project", "config/completion", completion) + require.NoError(t, err) + trimmedCompletionFingerprint, err := FingerprintVariation("project", "config/completion", trimmedCompletion) + require.NoError(t, err) + require.Equal(t, completionFingerprint, trimmedCompletionFingerprint) + require.Equal(t, "\n Be concise. \n", completion.Messages[0].Content) +} + +func TestValidateDirectAPIVariationSupportsModelConfigVersion(t *testing.T) { + base := Variation{Mode: VariationModeAgent, Key: "default", Name: "Default"} + + withVersion := base + withVersion.ModelConfigVersion = 3 + require.NoError(t, ValidateDirectAPIVariation(withVersion)) + + withOutput := base + withOutput.OutputFormat = map[string]any{"type": "json"} + require.ErrorContains(t, ValidateDirectAPIVariation(withOutput), "outputFormat") +} diff --git a/internal/sync/resource.go b/internal/sync/resource.go new file mode 100644 index 00000000..d4ee9e74 --- /dev/null +++ b/internal/sync/resource.go @@ -0,0 +1,80 @@ +package sync + +import ( + "encoding/json" + "strings" +) + +// RootDir is the repository-relative directory containing sync state. +const RootDir = ".launchdarkly" + +// Kind identifies a synchronized resource type. +type Kind string + +const ( + KindVariation Kind = "variation" +) + +// ResourceID uniquely identifies a synchronized resource. +type ResourceID struct { + Kind Kind + ProjectKey string + LookupKey string +} + +// CompareResourceIDs orders resource identities for deterministic plans and output. +func CompareResourceIDs(left, right ResourceID) int { + if result := strings.Compare(string(left.Kind), string(right.Kind)); result != 0 { + return result + } + if result := strings.Compare(left.ProjectKey, right.ProjectKey); result != 0 { + return result + } + return strings.Compare(left.LookupKey, right.LookupKey) +} + +// SyncedResource contains one compiled local resource. +type SyncedResource struct { + Kind Kind + ProjectKey string + LookupKey string + Payload json.RawMessage + Upsert bool +} + +// VariationMode identifies how a prompt variation stores its content. +type VariationMode string + +const ( + VariationModeAgent VariationMode = "agent" + VariationModeCompletion VariationMode = "completion" +) + +// Valid reports whether the mode is supported for synchronized config variations. +func (mode VariationMode) Valid() bool { + switch mode { + case VariationModeAgent, VariationModeCompletion: + return true + default: + return false + } +} + +// Message is one role/content pair in a completion prompt. +type Message struct { + Role string `json:"role"` + Content string `json:"content"` +} + +// Variation is the common prompt variation representation used by sync. +type Variation struct { + Mode VariationMode `json:"mode" yaml:"mode"` + Key string `json:"key" yaml:"key"` + Name string `json:"name" yaml:"name"` + Instructions string `json:"instructions,omitempty" yaml:"-"` + ModelConfigKey string `json:"modelConfigKey,omitempty" yaml:"modelConfigKey,omitempty"` + ModelConfigVersion int `json:"modelConfigVersion,omitempty" yaml:"modelConfigVersion,omitempty"` + Model map[string]any `json:"model,omitempty" yaml:"model,omitempty"` + OutputFormat map[string]any `json:"outputFormat,omitempty" yaml:"outputFormat,omitempty"` + Messages []Message `json:"messages,omitempty" yaml:"-"` +}