diff --git a/internal/sync/api/client_test.go b/internal/sync/api/client_test.go index 48de862da..ffe994f59 100644 --- a/internal/sync/api/client_test.go +++ b/internal/sync/api/client_test.go @@ -27,6 +27,9 @@ type recordingClient struct { Requests []recordedRequest Responses [][]byte Err error + // Errors fails one request by its index. A nil entry lets the request + // answer from Responses. + Errors []error } var _ resources.Client = &recordingClient{} @@ -52,6 +55,9 @@ func (client *recordingClient) MakeRequest( if client.Err != nil { return nil, client.Err } + if index := len(client.Requests) - 1; index < len(client.Errors) && client.Errors[index] != nil { + return nil, client.Errors[index] + } if len(client.Responses) < len(client.Requests) { return nil, nil } diff --git a/internal/sync/api/settings.go b/internal/sync/api/settings.go new file mode 100644 index 000000000..773e78ef0 --- /dev/null +++ b/internal/sync/api/settings.go @@ -0,0 +1,136 @@ +package api + +import ( + "fmt" + "maps" + "net/http" + "net/url" + "slices" + "strings" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +// syncSettingsPageLimit is the page size of the sync settings endpoint. It +// matches the most resources that one call accepts. +const syncSettingsPageLimit = 100 + +type resolvedSetting struct { + ResourceKind syncdomain.Kind `json:"resourceKind"` + ResourceLookupKey string `json:"resourceLookupKey"` + Direction syncdomain.SyncDirection `json:"direction"` +} + +// SyncDirections returns the effective LaunchDarkly setting for each +// resource. A resource of a kind that LaunchDarkly has no setting for is left +// out, and the caller decides what a missing setting means. +func (client Client) SyncDirections( + projectKey string, + ids []syncdomain.ResourceID, +) (map[syncdomain.ResourceID]syncdomain.SyncDirection, error) { + if len(ids) == 0 { + return map[syncdomain.ResourceID]syncdomain.SyncDirection{}, nil + } + if len(ids) > 100 { + return nil, fmt.Errorf("read sync settings: at most 100 resources are allowed") + } + byKind := map[syncdomain.Kind][]syncdomain.ResourceID{} + for _, id := range ids { + if id.ProjectKey != projectKey { + return nil, fmt.Errorf("read sync settings: %s is not in project %q", id.Label(), projectKey) + } + byKind[id.Kind] = append(byKind[id.Kind], id) + } + result := make(map[syncdomain.ResourceID]syncdomain.SyncDirection, len(ids)) + // The endpoint rejects a request that names a kind it has no setting for, + // and the rejection fails every resource of the request. Each kind travels + // alone, so a tool or a skill cannot take the variations down with it. + for _, kind := range slices.Sorted(maps.Keys(byKind)) { + directions, err := client.syncDirectionsOfKind(projectKey, byKind[kind]) + if err != nil { + return nil, err + } + maps.Copy(result, directions) + } + return result, nil +} + +// syncDirectionsOfKind reads the settings of resources of one kind. A kind +// that LaunchDarkly rejects as unsupported has no settings. +func (client Client) syncDirectionsOfKind( + projectKey string, + ids []syncdomain.ResourceID, +) (map[syncdomain.ResourceID]syncdomain.SyncDirection, error) { + query := url.Values{} + for _, id := range ids { + query.Add("resources", string(id.Kind)+":"+id.LookupKey) + } + result := make(map[syncdomain.ResourceID]syncdomain.SyncDirection, len(ids)) + // The endpoint pages its items. Read every page, so that a batch of more + // than one page of resources does not look like missing settings. + for offset := 0; ; { + page, err := client.readSyncSettingsPage(projectKey, query, offset) + if unsupportedResourceKind(err) { + return result, nil + } + if err != nil { + return nil, err + } + for _, item := range page.Items { + id := syncdomain.ResourceID{ + Kind: item.ResourceKind, ProjectKey: projectKey, LookupKey: item.ResourceLookupKey, + } + if !item.Direction.Valid() { + return nil, syncdomain.NewProblem( + syncdomain.ErrorSyncSettingUnavailable, + fmt.Sprintf("LaunchDarkly returned unsupported direction %q", item.Direction), + "Configure a supported sync setting in LaunchDarkly.", + ).WithResource(id) + } + result[id] = item.Direction + } + offset += len(page.Items) + // The total count ends the paging when the response has one. Without + // it, a short page is the last one. + switch { + case len(page.Items) == 0: + return result, nil + case page.TotalCount > 0 && offset >= page.TotalCount: + return result, nil + case page.TotalCount == 0 && len(page.Items) < syncSettingsPageLimit: + return result, nil + } + } +} + +// unsupportedResourceKind reports the 400 response of the sync settings +// endpoint for a resource kind that LaunchDarkly has no setting for. +func unsupportedResourceKind(err error) bool { + response, ok := responseError(err) + if !ok { + return false + } + status, _ := response["statusCode"].(float64) + message, _ := response["message"].(string) + return int(status) == http.StatusBadRequest && strings.Contains(strings.ToLower(message), "unsupported resource kind") +} + +// readSyncSettingsPage reads one page of resolved settings. A response +// without a body is an empty page. +func (client Client) readSyncSettingsPage(projectKey string, query url.Values, offset int) (Page[resolvedSetting], error) { + page := Page[resolvedSetting]{} + values := pageQuery(syncSettingsPageLimit, offset) + for key, value := range query { + values[key] = value + } + response, err := client.read( + fmt.Sprintf("get sync settings in project %q", projectKey), + projectKey, + values, + projectPath(projectKey, "configs", "sync", "settings")..., + ) + if err != nil || len(response) == 0 { + return page, err + } + return decodeJSON[Page[resolvedSetting]](response, "sync settings response") +} diff --git a/internal/sync/api/settings_test.go b/internal/sync/api/settings_test.go new file mode 100644 index 000000000..eb5a655f4 --- /dev/null +++ b/internal/sync/api/settings_test.go @@ -0,0 +1,132 @@ +package api + +import ( + "errors" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +// The endpoint rejects a request that names a kind it has no setting for, +// and the rejection fails every resource of the request, so each kind is +// asked for alone. +func TestSyncDirectionsAsksForEachKindAlone(t *testing.T) { + transport := &recordingClient{Responses: [][]byte{ + []byte(`{"items": [{"resourceKind":"tool","resourceLookupKey":"search","direction":"server_canonical"}]}`), + []byte(`{"items": [{"resourceKind":"variation","resourceLookupKey":"support/default","direction":"code_canonical"}]}`), + }} + client := NewClient(transport, "token", "https://example.com") + variation := syncdomain.VariationID("project", "support", "default") + tool := syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "project", LookupKey: "search"} + + directions, err := client.SyncDirections("project", []syncdomain.ResourceID{variation, tool}) + + require.NoError(t, err) + assert.Equal(t, syncdomain.SyncDirectionCodeCanonical, directions[variation]) + assert.Equal(t, syncdomain.SyncDirectionServerCanonical, directions[tool]) + require.Len(t, transport.Requests, 2, "one request per kind, in kind order") + for _, request := range transport.Requests { + assert.Equal(t, http.MethodGet, request.Method) + assert.Equal(t, "https://example.com/api/v2/projects/project/configs/sync/settings", request.Path) + } + assert.Equal(t, []string{"tool:search"}, transport.Requests[0].Query["resources"]) + assert.Equal(t, []string{"variation:support/default"}, transport.Requests[1].Query["resources"]) +} + +// LaunchDarkly answers 400 "unsupported resource kind" for a kind that has no +// sync setting yet. The variations of the same call still get their settings, +// and the resources of the rejected kind have none. +func TestSyncDirectionsTreatsAnUnsupportedKindAsHavingNoSetting(t *testing.T) { + unsupported := errors.New(`{"code":"invalid_request","message":"unsupported resource kind","statusCode":400}`) + // The kinds are asked for in name order: skill, tool, variation. + transport := &recordingClient{ + Errors: []error{unsupported, unsupported, nil}, + Responses: [][]byte{nil, nil, + []byte(`{"items": [{"resourceKind":"variation","resourceLookupKey":"support/default","direction":"both"}]}`), + }, + } + client := NewClient(transport, "token", "https://example.com") + variation := syncdomain.VariationID("project", "support", "default") + tool := syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "project", LookupKey: "search"} + skill := syncdomain.ResourceID{Kind: syncdomain.KindSkill, ProjectKey: "project", LookupKey: "refunds"} + + directions, err := client.SyncDirections("project", []syncdomain.ResourceID{variation, tool, skill}) + + require.NoError(t, err, "a kind without settings does not fail the variations") + assert.Equal(t, map[syncdomain.ResourceID]syncdomain.SyncDirection{variation: syncdomain.SyncDirectionBoth}, directions) + require.Len(t, transport.Requests, 3) + assert.Equal(t, []string{"variation:support/default"}, transport.Requests[2].Query["resources"]) + + other := errors.New(`{"code":"invalid_request","message":"resource key is not valid","statusCode":400}`) + transport = &recordingClient{Errors: []error{other}} + client = NewClient(transport, "token", "https://example.com") + _, err = client.SyncDirections("project", []syncdomain.ResourceID{tool}) + require.Error(t, err, "another 400 still fails the call") +} + +func TestSyncDirectionsReturnsOnlyResolvedResults(t *testing.T) { + transport := &recordingClient{Responses: [][]byte{[]byte(`{"items":[]}`)}} + client := NewClient(transport, "token", "https://example.com") + id := syncdomain.ResourceID{Kind: syncdomain.KindSkill, ProjectKey: "project", LookupKey: "refunds"} + + directions, err := client.SyncDirections("project", []syncdomain.ResourceID{id}) + + require.NoError(t, err) + assert.Empty(t, directions) +} + +func TestSyncDirectionsReadsEveryPage(t *testing.T) { + transport := &recordingClient{Responses: [][]byte{ + []byte(`{"items": [ + {"resourceKind":"tool","resourceLookupKey":"first","direction":"both"}, + {"resourceKind":"tool","resourceLookupKey":"second","direction":"both"} + ], "totalCount": 3}`), + []byte(`{"items": [ + {"resourceKind":"tool","resourceLookupKey":"third","direction":"code_canonical"} + ], "totalCount": 3}`), + }} + client := NewClient(transport, "token", "https://example.com") + ids := make([]syncdomain.ResourceID, 0, 3) + for _, key := range []string{"first", "second", "third"} { + ids = append(ids, syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "project", LookupKey: key}) + } + + directions, err := client.SyncDirections("project", ids) + + require.NoError(t, err) + assert.Len(t, directions, 3) + assert.Equal(t, syncdomain.SyncDirectionCodeCanonical, directions[ids[2]]) + require.Len(t, transport.Requests, 2) + assert.Equal(t, []string{"0"}, transport.Requests[0].Query["offset"]) + assert.Equal(t, []string{"2"}, transport.Requests[1].Query["offset"]) + assert.Equal(t, []string{"100"}, transport.Requests[1].Query["limit"]) + assert.Equal(t, []string{"tool:first", "tool:second", "tool:third"}, transport.Requests[1].Query["resources"]) +} + +func TestSyncDirectionsPagesWithoutATotalCount(t *testing.T) { + full := make([]string, 0, syncSettingsPageLimit) + ids := make([]syncdomain.ResourceID, 0, syncSettingsPageLimit) + for index := range syncSettingsPageLimit { + key := fmt.Sprintf("tool%d", index) + full = append(full, fmt.Sprintf(`{"resourceKind":"tool","resourceLookupKey":%q,"direction":"both"}`, key)) + ids = append(ids, syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "project", LookupKey: key}) + } + transport := &recordingClient{Responses: [][]byte{ + []byte(`{"items": [` + strings.Join(full, ",") + `]}`), + []byte(`{"items": []}`), + }} + client := NewClient(transport, "token", "https://example.com") + + directions, err := client.SyncDirections("project", ids) + + require.NoError(t, err) + assert.Len(t, directions, syncSettingsPageLimit) + require.Len(t, transport.Requests, 2, "a full page without a total count is followed by a request for the next page") + assert.Equal(t, []string{"100"}, transport.Requests[1].Query["offset"]) +} diff --git a/internal/sync/manifest/model.go b/internal/sync/manifest/model.go index 0db449a6d..caf07ed6b 100644 --- a/internal/sync/manifest/model.go +++ b/internal/sync/manifest/model.go @@ -263,6 +263,11 @@ func (manifest *Manifest) Sort() { }) } +// Has reports whether the manifest records a resource. +func (manifest Manifest) Has(id syncdomain.ResourceID) bool { + return manifest.index(id) >= 0 +} + func (manifest Manifest) index(id syncdomain.ResourceID) int { return slices.IndexFunc(manifest.Resources, func(resource Resource) bool { return resource.ID() == id }) } diff --git a/internal/sync/prompt/acceptance_test.go b/internal/sync/prompt/acceptance_test.go index 050de741d..cd181a51c 100644 --- a/internal/sync/prompt/acceptance_test.go +++ b/internal/sync/prompt/acceptance_test.go @@ -39,6 +39,7 @@ type directAPI struct { tools map[string]versionedTool skills map[string]versionedSkill manifest *syncapi.SyncManifest + directions map[string]syncdomain.SyncDirection requests []string // expandTools makes each read of the variation add the content of its // tools to model.parameters.tools, as LaunchDarkly does. @@ -51,6 +52,10 @@ type directAPI struct { // onRequest runs before each request, so that it can change LaunchDarkly // between two reads, as another working copy does. onRequest func(method, path string) + // manifestWriteErr fails each write of the remote manifest. + manifestWriteErr error + // toolWriteErr fails each update of a tool. + toolWriteErr error } type notFoundAPI struct{} @@ -97,8 +102,25 @@ func (api *directAPI) MakeRequest( api.onRequest(method, path) } if strings.HasSuffix(path, "/configs/sync/manifests") { + if api.manifestWriteErr != nil && method != http.MethodGet { + return nil, api.manifestWriteErr + } return handleManifestRequest(api.manifest, method, query, body) } + if strings.HasSuffix(path, "/configs/sync/settings") && method == http.MethodGet { + items := make([]map[string]any, 0, len(query["resources"])) + for _, resource := range query["resources"] { + kind, lookupKey, _ := strings.Cut(resource, ":") + direction := syncdomain.SyncDirectionBoth + if configured := api.directions[resource]; configured != "" { + direction = configured + } + items = append(items, map[string]any{ + "resourceKind": kind, "resourceLookupKey": lookupKey, "direction": direction, + }) + } + return json.Marshal(map[string]any{"items": items}) + } isCreate := method == http.MethodPost && (strings.HasSuffix(path, "/ai-tools") || strings.HasSuffix(path, "/ai-configs/skills") || strings.HasSuffix(path, "/variations")) if isCreate && api.createErr != nil { @@ -126,6 +148,9 @@ func (api *directAPI) MakeRequest( return nil, fmt.Errorf(`{"code":"not_found","message":"AI tool not found","statusCode":404}`) } if method == "PATCH" { + if api.toolWriteErr != nil { + return nil, api.toolWriteErr + } if err := json.Unmarshal(body, &tool.Tool); err != nil { return nil, err } @@ -218,6 +243,7 @@ func (api *directAPI) MakeRequest( ModelConfigVersion int `json:"modelConfigVersion"` Model map[string]any `json:"model"` OutputFormat map[string]any `json:"outputFormat"` + Messages *[]syncdomain.Message `json:"messages"` Tools *[]syncdomain.AttachmentRef `json:"tools"` Skills *[]syncdomain.AttachmentRef `json:"skills"` } @@ -238,12 +264,16 @@ func (api *directAPI) MakeRequest( if update.Skills != nil { skills = *update.Skills } + mode := cmp.Or(api.configMode, syncdomain.VariationModeAgent) api.variation = &syncdomain.Variation{ - Mode: syncdomain.VariationModeAgent, Key: key, Name: update.Name, + Mode: mode, Key: key, Name: update.Name, Instructions: update.Instructions, ModelConfigKey: update.ModelConfigKey, ModelConfigVersion: update.ModelConfigVersion, Model: update.Model, OutputFormat: update.OutputFormat, Tools: tools, Skills: skills, } + if update.Messages != nil { + api.variation.Messages = *update.Messages + } api.variationState = "published" } return []byte(`{}`), nil @@ -1287,8 +1317,6 @@ func TestPromptDetachArchive(t *testing.T) { } } -// syncLinkedVariation writes a linked variation that matches LaunchDarkly and -// runs one sync, so that sync.lock records the link. func TestPromptFilterSyncsOnlyTheNamedVariation(t *testing.T) { root := initRepository(t) baseline := variation("Baseline") @@ -1711,6 +1739,26 @@ func TestSyncPromptsCountsStandaloneFilesWithoutReadingThem(t *testing.T) { assert.Equal(t, "Note: 1 standalone skill and 3 standalone tools are not in this plan. Run ldcli sync all to include them.\n", notes) } +// A linked tool or skill has a descriptor instead of a managed file, and the +// count of standalone files includes it. +func TestSyncPromptsCountsLinkedStandaloneDescriptors(t *testing.T) { + root, api := writeStandaloneAttachmentWorkspace(t) + writeLocalFile(t, root, "../../tools/audit.json", `{"description": "Audit an order", "inputSchema": {"type": "object"}}`) + _, err := synclocal.NewStore(root).WriteDescriptor( + syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "production", LookupKey: "audit"}, + syncdomain.Reference{ + File: "tools/audit.json", Format: "json", Select: "$", + Map: map[string]string{"description": "description", "schema": "inputSchema"}, + }, + ) + require.NoError(t, err) + + _, notes, err := runScoped(t, root, api, syncprompt.Options{Action: syncprompt.SyncAction{DryRun: true}}) + + require.NoError(t, err) + assert.Equal(t, "Note: 1 standalone skill and 3 standalone tools are not in this plan. Run ldcli sync all to include them.\n", notes) +} + // --config-key and --key select variations, so a filtered sync of prompts // counts the standalone files of its project. func TestSyncPromptsWithAConfigFilterCountsStandaloneFiles(t *testing.T) { @@ -1947,6 +1995,599 @@ func runSyncAction( return stdout, err } +func TestSourceLinkUsesDirectionAndKeepsSourceInPlace(t *testing.T) { + root := initRepository(t) + source := []byte(`from agents import Agent +support = Agent(instructions="Local instructions.") +`) + require.NoError(t, os.WriteFile(filepath.Join(root, "agents.py"), source, 0o644)) + server := variation("Server instructions.") + api := &directAPI{ + variation: &server, + directions: map[string]syncdomain.SyncDirection{ + "variation:support/default": syncdomain.SyncDirectionCodeCanonical, + }, + } + id := syncdomain.VariationID("production", "support", "default") + + output, _, err := runScoped(t, root, api, syncprompt.Options{ + Scope: syncdomain.ScopePrompts, + Action: syncprompt.SourceLinkAction{ + ID: id, File: "agents.py", Format: "python", Select: `agents[id="support"]`, + Map: map[string]string{"prompt": "instructions"}, + }, + Yes: true, + }) + + require.NoError(t, err) + assert.Contains(t, output, `"action": "update_server"`) + require.NotNil(t, api.variation) + assert.Equal(t, "Local instructions.", api.variation.Instructions) + descriptor := filepath.Join(root, ".launchdarkly", "production", "configs", "support", "default.ref.yaml") + require.FileExists(t, descriptor) + actual, err := os.ReadFile(filepath.Join(root, "agents.py")) + require.NoError(t, err) + assert.Equal(t, source, actual) + lock, err := os.ReadFile(filepath.Join(root, ".launchdarkly", "sync.lock")) + require.NoError(t, err) + assert.Contains(t, string(lock), "formatVersion: 1") + assert.NotContains(t, string(lock), "file: agents.py") + + api.directions["variation:support/default"] = syncdomain.SyncDirectionServerCanonical + api.variation.Instructions = "Server instructions after link." + output, _, err = runScoped(t, root, api, syncprompt.Options{ + Scope: syncdomain.ScopePrompts, + Action: syncprompt.SyncAction{Filter: syncdomain.Filter{ + Project: "production", ConfigKey: "support", Keys: []string{"default"}, + }}, + Yes: true, + }) + require.NoError(t, err) + assert.Contains(t, output, `"action": "update_local"`) + actual, err = os.ReadFile(filepath.Join(root, "agents.py")) + require.NoError(t, err) + assert.Equal(t, `from agents import Agent +support = Agent(instructions="Server instructions after link.") +`, string(actual)) +} + +func TestPromptDescriptorTopLevelToolSyncsEndToEnd(t *testing.T) { + root := initRepository(t) + require.NoError(t, os.WriteFile(filepath.Join(root, "prompt.md"), []byte("Local instructions.\n"), 0o644)) + writeLocalFile(t, root, "configs/support/default.ref.yaml", + "# keep\nformatVersion: 1\nfile: prompt.md\nformat: markdown\n"+ + "tools:\n - key: search\n") + writeLocalFile(t, root, "tools/search.json", + `{"formatVersion":1,"key":"search","schema":{"type":"object"},`+ + `"customParameters":{"timeout":5}}`+"\n") + server := variation("Server name") + server.Instructions = "Local instructions." + api := &directAPI{ + variation: &server, + tools: map[string]versionedTool{ + "search": { + Tool: syncdomain.Tool{ + Key: "search", Schema: map[string]any{"type": "object"}, + CustomParameters: map[string]any{"timeout": float64(5)}, + }, + Version: 3, + }, + }, + } + + _, _, err := runPrompt(t, root, api, "--yes", "--conflict", "local") + + require.NoError(t, err) + require.NotNil(t, api.variation) + assert.Equal(t, []syncdomain.AttachmentRef{{Key: "search", Version: 3}}, api.variation.Tools) + descriptor, err := os.ReadFile(filepath.Join( + root, ".launchdarkly", "production", "configs", "support", "default.ref.yaml", + )) + require.NoError(t, err) + assert.Contains(t, string(descriptor), "# keep\n") + assert.NotContains(t, string(descriptor), "version:") +} + +// A variation that was a managed file has a lock entry with the fingerprint +// of that file. A link of the same variation to a source plans a first sync, +// because that fingerprint says nothing about the source projection. +func TestLinkOverAManagedBaselinePlansAFirstSync(t *testing.T) { + root := initRepository(t) + baseline := variation("Baseline") + writeManifest(t, root, baseline) + require.NoError(t, os.WriteFile(filepath.Join(root, "prompt.md"), []byte("Help"), 0o644)) + server := baseline + api := &directAPI{variation: &server} + id := syncdomain.VariationID("production", "support", "default") + + stdout, _, err := runScoped(t, root, api, syncprompt.Options{ + Scope: syncdomain.ScopePrompts, + Action: syncprompt.SourceLinkAction{ID: id, File: "prompt.md", Format: "markdown"}, + Yes: true, + }) + + require.NoError(t, err, "equal sides are adopted instead of reported as a conflict") + for _, row := range planRows(t, stdout) { + assert.NotContains(t, row, "conflict", "the managed-file fingerprint is not a baseline for the source") + } + assert.Equal(t, "Help", api.variation.Instructions) +} + +// The link note goes to the error output, so the JSON result stays one +// document. +func TestSourceLinkKeepsJSONOutputValid(t *testing.T) { + root := initRepository(t) + require.NoError(t, os.WriteFile(filepath.Join(root, "prompt.md"), []byte("Local prompt.\n"), 0o644)) + server := variation("Server") + server.Instructions = "Local prompt." + api := &directAPI{variation: &server} + id := syncdomain.VariationID("production", "support", "default") + + stdout, notes, err := runScoped(t, root, api, syncprompt.Options{ + Scope: syncdomain.ScopePrompts, + Action: syncprompt.SourceLinkAction{ID: id, File: "prompt.md", Format: "markdown"}, + Yes: true, + }) + + require.NoError(t, err) + assert.True(t, json.Valid([]byte(stdout)), "the standard output is one JSON document: %q", stdout) + assert.Contains(t, notes, "Linked ") +} + +func TestCompletionSourcePullsOneSystemMessage(t *testing.T) { + root := initRepository(t) + require.NoError(t, os.WriteFile(filepath.Join(root, "prompt.md"), []byte("Local prompt.\n"), 0o644)) + server := syncdomain.Variation{ + Mode: syncdomain.VariationModeCompletion, Key: "default", Name: "Default", + Messages: []syncdomain.Message{{Role: syncdomain.RoleSystem, Content: "Server prompt."}}, + } + api := &directAPI{ + variation: pointer(server), configMode: syncdomain.VariationModeCompletion, + directions: map[string]syncdomain.SyncDirection{ + "variation:support/default": syncdomain.SyncDirectionServerCanonical, + }, + } + id := syncdomain.VariationID("production", "support", "default") + + _, _, err := runScoped(t, root, api, syncprompt.Options{ + Scope: syncdomain.ScopePrompts, + Action: syncprompt.SourceLinkAction{ + ID: id, File: "prompt.md", Format: "markdown", + }, + Yes: true, + }) + + require.NoError(t, err) + content, err := os.ReadFile(filepath.Join(root, "prompt.md")) + require.NoError(t, err) + assert.Equal(t, "Server prompt.\n", string(content)) +} + +func TestCompletionMessageListPullsAndPushesLiteralContent(t *testing.T) { + root := initRepository(t) + source := `from agents import Agent +completion = Agent( + name="Completion", + messages=[ + {"role": "system", "content": "Local system."}, + {"role": "user", "content": "Local user."}, + ], + model="gpt-5", +) +` + require.NoError(t, os.WriteFile(filepath.Join(root, "messages.py"), []byte(source), 0o644)) + server := syncdomain.Variation{ + Mode: syncdomain.VariationModeCompletion, Key: "default", Name: "Default", + Messages: []syncdomain.Message{ + {Role: syncdomain.RoleSystem, Content: "Server system."}, + {Role: syncdomain.RoleUser, Content: "Server user."}, + }, + } + api := &directAPI{ + variation: pointer(server), configMode: syncdomain.VariationModeCompletion, + directions: map[string]syncdomain.SyncDirection{ + "variation:support/default": syncdomain.SyncDirectionServerCanonical, + }, + } + id := syncdomain.VariationID("production", "support", "default") + action := syncprompt.SourceLinkAction{ + ID: id, File: "messages.py", Format: "python", Select: `agents[id="completion"]`, + Map: map[string]string{"messages": "messages"}, + } + + _, _, err := runScoped(t, root, api, syncprompt.Options{ + Scope: syncdomain.ScopePrompts, Action: action, Yes: true, + }) + require.NoError(t, err) + content, err := os.ReadFile(filepath.Join(root, "messages.py")) + require.NoError(t, err) + assert.Contains(t, string(content), `"content": "Server system."`) + assert.Contains(t, string(content), `"content": "Server user."`) + + updated := strings.Replace(string(content), "Server user.", "Repository user.", 1) + require.NoError(t, os.WriteFile(filepath.Join(root, "messages.py"), []byte(updated), 0o644)) + api.directions["variation:support/default"] = syncdomain.SyncDirectionCodeCanonical + _, _, err = runScoped(t, root, api, syncprompt.Options{ + Scope: syncdomain.ScopePrompts, + Action: syncprompt.SyncAction{ + Filter: syncdomain.Filter{Project: "production", ConfigKey: "support", Keys: []string{"default"}}, + }, + Yes: true, + }) + require.NoError(t, err) + assert.Equal(t, "Repository user.", api.variation.Messages[1].Content) +} + +func TestStandaloneSkillSourceLinkPullsLaunchDarklyContent(t *testing.T) { + root := initRepository(t) + sourcePath := filepath.Join(root, ".agents", "skills", "refunds", "SKILL.md") + require.NoError(t, os.MkdirAll(filepath.Dir(sourcePath), 0o755)) + require.NoError(t, os.WriteFile(sourcePath, []byte(`--- +name: refunds +description: Local refund guidance. +license: Apache-2.0 +--- + +# Refunds + +Use the local policy. +`), 0o644)) + api := &directAPI{ + skills: map[string]versionedSkill{"refunds": { + Skill: syncdomain.Skill{ + Key: "refunds", Name: "Refunds", Description: "Server refund guidance.", + Markdown: "# Refunds\n\nUse the server policy.\n", + }, + Version: 2, + }}, + directions: map[string]syncdomain.SyncDirection{ + "skill:refunds": syncdomain.SyncDirectionServerCanonical, + }, + } + id := syncdomain.ResourceID{Kind: syncdomain.KindSkill, ProjectKey: "production", LookupKey: "refunds"} + + output, _, err := runScoped(t, root, api, syncprompt.Options{ + Scope: syncdomain.ScopeSkills, + Action: syncprompt.SourceLinkAction{ + ID: id, File: ".agents/skills/refunds/SKILL.md", Format: "agent-skill", + }, + Yes: true, + }) + + require.NoError(t, err) + assert.Contains(t, output, `"action": "update_local"`) + content, err := os.ReadFile(sourcePath) + require.NoError(t, err) + assert.Contains(t, string(content), "description: Server refund guidance.") + assert.Contains(t, string(content), "Use the server policy.") + assert.Contains(t, string(content), "license: Apache-2.0") + require.FileExists(t, filepath.Join(root, ".launchdarkly", "production", "skills", "refunds.ref.yaml")) +} + +func TestStandaloneToolSourceLinkPushesMappedFields(t *testing.T) { + root := initRepository(t) + source := []byte(`import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +const server = new McpServer({ name: "orders", version: "1" }); +server.registerTool("lookup", { + description: "Look up an order.", + inputSchema: { type: "object" }, +}, handler); +`) + require.NoError(t, os.WriteFile(filepath.Join(root, "tools.ts"), source, 0o644)) + api := &directAPI{ + tools: map[string]versionedTool{"lookup": { + Tool: syncdomain.Tool{Key: "lookup", Description: pointer("Old description."), Schema: map[string]any{"type": "string"}}, + Version: 1, + }}, + directions: map[string]syncdomain.SyncDirection{ + "tool:lookup": syncdomain.SyncDirectionCodeCanonical, + }, + } + id := syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "production", LookupKey: "lookup"} + + output, _, err := runScoped(t, root, api, syncprompt.Options{ + Scope: syncdomain.ScopeTools, + Action: syncprompt.SourceLinkAction{ + ID: id, File: "tools.ts", Format: "typescript", Select: `tools[id="lookup"]`, + Map: map[string]string{"description": "description", "schema": "inputSchema"}, + }, + Yes: true, + }) + + require.NoError(t, err) + assert.Contains(t, output, `"action": "update_server"`) + assert.Equal(t, "Look up an order.", *api.tools["lookup"].Description) + assert.Equal(t, "object", api.tools["lookup"].Schema["type"]) + actual, err := os.ReadFile(filepath.Join(root, "tools.ts")) + require.NoError(t, err) + assert.Equal(t, source, actual) + require.FileExists(t, filepath.Join(root, ".launchdarkly", "production", "tools", "lookup.ref.yaml")) +} + +func TestSourceLinkWithoutConfirmationWritesNothing(t *testing.T) { + root := initRepository(t) + require.NoError(t, os.WriteFile(filepath.Join(root, "prompt.md"), []byte("Server instructions.\n"), 0o644)) + server := variation("Default") + server.Instructions = "Server instructions." + api := &directAPI{ + variation: &server, + directions: map[string]syncdomain.SyncDirection{ + "variation:support/default": syncdomain.SyncDirectionBoth, + }, + } + id := syncdomain.VariationID("production", "support", "default") + + _, notes, err := runScoped(t, root, api, syncprompt.Options{ + Scope: syncdomain.ScopePrompts, + Action: syncprompt.SourceLinkAction{ + ID: id, File: "prompt.md", Format: "markdown", + }, + }) + + require.ErrorContains(t, err, "rerun with --yes") + assert.Contains(t, notes, "Proposed descriptor .launchdarkly/production/configs/support/default.ref.yaml:") + assert.Contains(t, notes, "format: markdown") + require.NoFileExists(t, filepath.Join( + root, ".launchdarkly", "production", "configs", "support", "default.ref.yaml", + )) +} + +func TestStructuredSourceLinksPushAndPull(t *testing.T) { + tests := []struct { + name string + scope syncdomain.Scope + id syncdomain.ResourceID + file string + format string + selectPath string + fieldMap map[string]string + source string + api *directAPI + changeServer func(*directAPI) + wantPush func(*testing.T, *directAPI) + wantPull string + }{ + { + name: "JSON tool", scope: syncdomain.ScopeTools, + id: syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "production", LookupKey: "lookup"}, + file: "tools.json", format: "json", selectPath: `tools[1]`, + fieldMap: map[string]string{"description": "description", "schema": "inputSchema"}, + source: `{"tools":[{"name":"keep","description":"Keep","inputSchema":{"type":"null"}},{"name":"lookup","description":"Local lookup","inputSchema":{"type":"object"}}]}` + "\n", + api: &directAPI{ + tools: map[string]versionedTool{"lookup": { + Tool: syncdomain.Tool{Key: "lookup", Description: pointer("Server lookup"), Schema: map[string]any{"type": "string"}}, + Version: 1, + }}, + directions: map[string]syncdomain.SyncDirection{"tool:lookup": syncdomain.SyncDirectionCodeCanonical}, + }, + changeServer: func(api *directAPI) { + tool := api.tools["lookup"] + tool.Description = pointer("Pulled lookup") + api.tools["lookup"] = tool + api.directions["tool:lookup"] = syncdomain.SyncDirectionServerCanonical + }, + wantPush: func(t *testing.T, api *directAPI) { + assert.Equal(t, "Local lookup", *api.tools["lookup"].Description) + assert.Equal(t, "object", api.tools["lookup"].Schema["type"]) + }, + wantPull: `"description":"Pulled lookup"`, + }, + { + name: "YAML variation", scope: syncdomain.ScopePrompts, + id: syncdomain.VariationID("production", "support", "default"), + file: "agents.yaml", format: "yaml", selectPath: `agents[1]`, + fieldMap: map[string]string{"prompt": "prompt", "model": "model"}, + source: "agents:\n - {name: keep, prompt: Keep, model: old}\n - name: support\n prompt: Local YAML prompt\n model: gpt-5\n", + api: &directAPI{ + variation: pointer(variation("Server")), + directions: map[string]syncdomain.SyncDirection{ + "variation:support/default": syncdomain.SyncDirectionCodeCanonical, + }, + }, + changeServer: func(api *directAPI) { + api.variation.Instructions = "Pulled YAML prompt" + api.directions["variation:support/default"] = syncdomain.SyncDirectionServerCanonical + }, + wantPush: func(t *testing.T, api *directAPI) { + assert.Equal(t, "Local YAML prompt", api.variation.Instructions) + assert.Equal(t, "gpt-5", api.variation.Model["modelName"]) + }, + wantPull: "prompt: Pulled YAML prompt", + }, + { + name: "TOML variation", scope: syncdomain.ScopePrompts, + id: syncdomain.VariationID("production", "support", "default"), + file: "agents.toml", format: "toml", selectPath: "agents[1]", + fieldMap: map[string]string{"prompt": "prompt", "model": "model"}, + source: "agents = [{ name = \"keep\", prompt = \"Keep\", model = \"old\" }, { name = \"support\", prompt = \"Local TOML prompt\", model = \"gpt-5\" }]\n", + api: &directAPI{ + variation: pointer(variation("Server")), + directions: map[string]syncdomain.SyncDirection{ + "variation:support/default": syncdomain.SyncDirectionCodeCanonical, + }, + }, + changeServer: func(api *directAPI) { + api.variation.Instructions = "Pulled TOML prompt" + api.directions["variation:support/default"] = syncdomain.SyncDirectionServerCanonical + }, + wantPush: func(t *testing.T, api *directAPI) { + assert.Equal(t, "Local TOML prompt", api.variation.Instructions) + assert.Equal(t, "gpt-5", api.variation.Model["modelName"]) + }, + wantPull: `prompt = "Pulled TOML prompt"`, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := initRepository(t) + require.NoError(t, os.WriteFile(filepath.Join(root, test.file), []byte(test.source), 0o644)) + _, _, err := runScoped(t, root, test.api, syncprompt.Options{ + Scope: test.scope, + Action: syncprompt.SourceLinkAction{ + ID: test.id, File: test.file, Format: test.format, + Select: test.selectPath, Map: test.fieldMap, + }, + Yes: true, + }) + require.NoError(t, err) + test.wantPush(t, test.api) + lock, err := os.ReadFile(filepath.Join(root, ".launchdarkly", "sync.lock")) + require.NoError(t, err) + assert.Contains(t, string(lock), "formatVersion: 1") + assert.Contains(t, string(lock), "indexedSelections:") + + test.changeServer(test.api) + filter := syncdomain.Filter{Project: "production", Keys: []string{test.id.LookupKey}} + if test.id.Kind == syncdomain.KindVariation { + filter.ConfigKey, filter.Keys[0], _ = test.id.VariationKeys() + } + _, _, err = runScoped(t, root, test.api, syncprompt.Options{ + Scope: test.scope, Action: syncprompt.SyncAction{Filter: filter}, Yes: true, + }) + require.NoError(t, err) + content, err := os.ReadFile(filepath.Join(root, test.file)) + require.NoError(t, err) + assert.Contains(t, string(content), test.wantPull) + assert.Contains(t, string(content), "Keep") + + _, _, err = runScoped(t, root, test.api, syncprompt.Options{ + Scope: test.scope, Action: syncprompt.SyncAction{Filter: filter}, Yes: true, + }) + require.NoError(t, err) + }) + } +} + +func TestIndexedSourceReorderBlocksSyncWithoutWrites(t *testing.T) { + root := initRepository(t) + sourcePath := filepath.Join(root, "tools.json") + original := `{"tools":[{"name":"keep","description":"Keep","inputSchema":{"type":"null"}},{"name":"lookup","description":"Local lookup","inputSchema":{"type":"object"}}]}` + "\n" + require.NoError(t, os.WriteFile(sourcePath, []byte(original), 0o644)) + api := &directAPI{ + tools: map[string]versionedTool{"lookup": { + Tool: syncdomain.Tool{ + Key: "lookup", Description: pointer("Server lookup"), Schema: map[string]any{"type": "string"}, + }, + Version: 1, + }}, + directions: map[string]syncdomain.SyncDirection{ + "tool:lookup": syncdomain.SyncDirectionCodeCanonical, + }, + } + id := syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "production", LookupKey: "lookup"} + _, _, err := runScoped(t, root, api, syncprompt.Options{ + Scope: syncdomain.ScopeTools, + Action: syncprompt.SourceLinkAction{ + ID: id, File: "tools.json", Format: "json", Select: "tools[1]", + Map: map[string]string{"description": "description", "schema": "inputSchema"}, + }, + Yes: true, + }) + require.NoError(t, err) + assert.Equal(t, "Local lookup", *api.tools["lookup"].Description) + + reordered := `{"tools":[{"name":"lookup","description":"Different item","inputSchema":{"type":"boolean"}},{"name":"keep","description":"Keep","inputSchema":{"type":"null"}}]}` + "\n" + require.NoError(t, os.WriteFile(sourcePath, []byte(reordered), 0o644)) + _, _, err = runScoped(t, root, api, syncprompt.Options{ + Scope: syncdomain.ScopeTools, + Action: syncprompt.SyncAction{Filter: syncdomain.Filter{ + Project: "production", Keys: []string{"lookup"}, + }}, + Yes: true, + }) + + problem, ok := syncdomain.ProblemFrom(err) + require.True(t, ok) + assert.Equal(t, syncdomain.ErrorIndexedSelectionChanged, problem.Code) + assert.Equal(t, "Local lookup", *api.tools["lookup"].Description) + content, readErr := os.ReadFile(sourcePath) + require.NoError(t, readErr) + assert.Equal(t, reordered, string(content)) +} + +func TestReadOnlyMappedFieldBlocksCompleteLink(t *testing.T) { + root := initRepository(t) + source := `import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +const server = new McpServer({ name: "orders", version: "1" }); +server.registerTool("lookup", { + description: "Local description", + inputSchema: makeSchema(), +}, handler); +` + require.NoError(t, os.WriteFile(filepath.Join(root, "tools.ts"), []byte(source), 0o644)) + api := &directAPI{ + tools: map[string]versionedTool{"lookup": { + Tool: syncdomain.Tool{ + Key: "lookup", Description: pointer("Server description"), + Schema: map[string]any{"type": "object"}, + }, + Version: 1, + }}, + directions: map[string]syncdomain.SyncDirection{ + "tool:lookup": syncdomain.SyncDirectionCodeCanonical, + }, + } + id := syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "production", LookupKey: "lookup"} + action := syncprompt.SourceLinkAction{ + ID: id, File: "tools.ts", Format: "typescript", Select: `tools[id="lookup"]`, + Map: map[string]string{"description": "description", "schema": "inputSchema"}, + } + + _, _, err := runScoped(t, root, api, syncprompt.Options{ + Scope: syncdomain.ScopeTools, Action: action, Yes: true, + }) + problem, ok := syncdomain.ProblemFrom(err) + require.True(t, ok) + assert.Equal(t, syncdomain.ErrorFieldReadOnly, problem.Code) + assert.Contains(t, problem.Message, `field "schema"`) + assert.Equal(t, "Server description", *api.tools["lookup"].Description) + assert.Equal(t, "object", api.tools["lookup"].Schema["type"]) + content, err := os.ReadFile(filepath.Join(root, "tools.ts")) + require.NoError(t, err) + assert.Contains(t, string(content), `description: "Local description"`) + assert.Contains(t, string(content), "inputSchema: makeSchema()") + require.NoFileExists(t, filepath.Join(root, ".launchdarkly", "production", "tools", "lookup.ref.yaml")) +} + +func TestMarkdownProviderProfilesPullAndPreserveMetadata(t *testing.T) { + tests := []struct { + name, format, source, metadata string + }{ + {name: "Cursor", format: "markdown", source: "---\nglobs: \"src/**/*.ts\"\nalwaysApply: false\n---\n\nLocal\n", metadata: "alwaysApply: false"}, + {name: "Copilot", format: "markdown", source: "---\napplyTo: \"src/**/*.ts\"\n---\n\nLocal\n", metadata: "applyTo: \"src/**/*.ts\""}, + {name: "Claude", format: "markdown", source: "---\nname: support\nmodel: sonnet\n---\n\nLocal\n", metadata: "model: sonnet"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := initRepository(t) + require.NoError(t, os.WriteFile(filepath.Join(root, "instructions.md"), []byte(test.source), 0o644)) + server := variation("Server") + server.Instructions = "Pulled profile instructions." + api := &directAPI{ + variation: &server, + directions: map[string]syncdomain.SyncDirection{ + "variation:support/default": syncdomain.SyncDirectionServerCanonical, + }, + } + id := syncdomain.VariationID("production", "support", "default") + + _, _, err := runScoped(t, root, api, syncprompt.Options{ + Scope: syncdomain.ScopePrompts, + Action: syncprompt.SourceLinkAction{ + ID: id, File: "instructions.md", Format: test.format, + }, + Yes: true, + }) + + require.NoError(t, err) + content, err := os.ReadFile(filepath.Join(root, "instructions.md")) + require.NoError(t, err) + assert.Contains(t, string(content), test.metadata) + assert.Contains(t, string(content), "Pulled profile instructions.") + }) + } +} + // runScoped runs options through Runner.Run without a terminal, and returns // the JSON output and the notes. A question fails the run. func runScoped(t *testing.T, root string, api *directAPI, options syncprompt.Options) (string, string, error) { @@ -2533,3 +3174,1079 @@ func toolAttachment(tool syncdomain.Tool, version int) syncdomain.Attachment { func pointer[T any](value T) *T { return &value } + +// A server-canonical linked variation whose only difference is an older tool +// pin must repin LaunchDarkly once. Without the repin, every sync plans the +// same pull again. +func TestServerCanonicalSourcePullRepinsStaleToolVersions(t *testing.T) { + root := initRepository(t) + require.NoError(t, os.WriteFile(filepath.Join(root, "prompt.md"), []byte("Help\n"), 0o644)) + writeLocalFile(t, root, "configs/support/default.ref.yaml", + "formatVersion: 1\nfile: prompt.md\nformat: markdown\ntools:\n - key: search\n") + writeLocalFile(t, root, "tools/search.json", + `{"formatVersion":1,"key":"search","schema":{"type":"object"}}`+"\n") + server := variation("Server") + server.Tools = []syncdomain.AttachmentRef{{Key: "search", Version: 1}} + api := &directAPI{ + variation: &server, + tools: map[string]versionedTool{"search": { + Tool: syncdomain.Tool{Key: "search", Schema: map[string]any{"type": "object"}}, Version: 2, + }}, + directions: map[string]syncdomain.SyncDirection{ + "variation:support/default": syncdomain.SyncDirectionServerCanonical, + }, + } + + stdout, _, err := runPrompt(t, root, api, "--dry-run", "--output", "json") + require.NoError(t, err) + assert.Equal(t, []string{"variation support/default update_local"}, planRows(t, stdout)) + + _, _, err = runPrompt(t, root, api, "--yes") + + require.NoError(t, err) + assert.Equal(t, []syncdomain.AttachmentRef{{Key: "search", Version: 2}}, api.variation.Tools) + stdout, _, err = runPrompt(t, root, api, "--dry-run", "--output", "json") + require.NoError(t, err) + assert.Equal(t, []string{"variation support/default in_sync"}, planRows(t, stdout), "the pins are current") +} + +// A linked pull that repins its tools writes the pins onto the latest +// LaunchDarkly copy, so an edit to an unowned field after the review survives. +func TestLinkedPullRepinsOnTheLatestLaunchDarklyCopy(t *testing.T) { + root := initRepository(t) + require.NoError(t, os.WriteFile(filepath.Join(root, "prompt.md"), []byte("Help\n"), 0o644)) + writeLocalFile(t, root, "configs/support/default.ref.yaml", + "formatVersion: 1\nfile: prompt.md\nformat: markdown\ntools:\n - key: search\n") + writeLocalFile(t, root, "tools/search.json", + `{"formatVersion":1,"key":"search","schema":{"type":"object"}}`+"\n") + server := variation("Server") + server.Tools = []syncdomain.AttachmentRef{{Key: "search", Version: 1}} + api := &directAPI{ + variation: &server, + tools: map[string]versionedTool{"search": { + Tool: syncdomain.Tool{Key: "search", Schema: map[string]any{"type": "object"}}, Version: 2, + }}, + directions: map[string]syncdomain.SyncDirection{ + "variation:support/default": syncdomain.SyncDirectionServerCanonical, + }, + } + onNthRead(api, "/ai-configs/support", 3, func() { api.variation.Name = "Renamed after review" }) + + _, _, err := runPrompt(t, root, api, "--yes") + + require.NoError(t, err) + assert.Equal(t, []syncdomain.AttachmentRef{{Key: "search", Version: 2}}, api.variation.Tools, "the pins move") + assert.Equal(t, "Renamed after review", api.variation.Name, "the unowned field keeps the LaunchDarkly edit") +} + +// A linked pull records the pulled content in sync.lock before it moves the +// pins, so a pin update that fails leaves the lock and the files in step, and +// the next run sees a LaunchDarkly change, not a local edit. +func TestLinkedPullRecordsTheLockBeforeAFailedPinRefresh(t *testing.T) { + root := initRepository(t) + require.NoError(t, os.WriteFile(filepath.Join(root, "prompt.md"), []byte("Help\n"), 0o644)) + writeLocalFile(t, root, "configs/support/default.ref.yaml", + "formatVersion: 1\nfile: prompt.md\nformat: markdown\ntools:\n - key: search\n") + writeLocalFile(t, root, "tools/search.json", + `{"formatVersion":1,"key":"search","schema":{"type":"object"}}`+"\n") + server := variation("Server") + server.Instructions = "Server text" + server.Tools = []syncdomain.AttachmentRef{{Key: "search", Version: 1}} + api := &directAPI{ + variation: &server, + tools: map[string]versionedTool{"search": { + Tool: syncdomain.Tool{Key: "search", Schema: map[string]any{"type": "object"}}, Version: 2, + }}, + directions: map[string]syncdomain.SyncDirection{ + "variation:support/default": syncdomain.SyncDirectionServerCanonical, + }, + } + pulled := server.Instructions + // The mapped field changes after the review, so the pin update stops. + onNthRead(api, "/ai-configs/support", 3, func() { api.variation.Instructions = "Changed after review" }) + + _, _, err := runPrompt(t, root, api, "--yes") + + require.Error(t, err) + assert.Contains(t, err.Error(), "changed in LaunchDarkly after review") + content, err := os.ReadFile(filepath.Join(root, "prompt.md")) + require.NoError(t, err) + assert.Equal(t, pulled+"\n", string(content), "the pull wrote the file") + assert.Equal(t, []syncdomain.AttachmentRef{{Key: "search", Version: 1}}, api.variation.Tools, "the pins stay") + stdout, _, err := runPrompt(t, root, api, "--dry-run", "--output", "json") + require.NoError(t, err) + assert.Equal(t, []string{"variation support/default update_local"}, planRows(t, stdout), "the lock holds the pull, so the later LaunchDarkly change is a pull, not a conflict") +} + +// A link that only rebinds a resource that is in sync must still write the +// descriptor and record the new mapping in sync.lock. Otherwise the next sync +// raises the same stale-binding error that the link was meant to fix. +func TestRelinkOfAnInSyncResourceRecordsTheNewBinding(t *testing.T) { + root := initRepository(t) + require.NoError(t, os.WriteFile(filepath.Join(root, "agents.py"), []byte(`from agents import Agent +support = Agent(name="support", instructions="Help") +`), 0o644)) + server := variation("Server") + api := &directAPI{ + variation: &server, + directions: map[string]syncdomain.SyncDirection{ + "variation:support/default": syncdomain.SyncDirectionBoth, + }, + } + id := syncdomain.VariationID("production", "support", "default") + link := func(selectPath string) (string, error) { + output, _, err := runScoped(t, root, api, syncprompt.Options{ + Scope: syncdomain.ScopePrompts, + Action: syncprompt.SourceLinkAction{ + ID: id, File: "agents.py", Format: "python", Select: selectPath, + Map: map[string]string{"prompt": "instructions"}, + }, + Yes: true, + }) + return output, err + } + _, err := link(`agents[id="support"]`) + require.NoError(t, err) + + // The second link changes only the selector, and the content stays in sync. + _, err = link(`agents[0]`) + require.NoError(t, err) + + descriptor, err := os.ReadFile(filepath.Join(root, ".launchdarkly", "production", "configs", "support", "default.ref.yaml")) + require.NoError(t, err) + assert.Contains(t, string(descriptor), "select: agents[0]") + stdout, _, err := runPrompt(t, root, api, "--dry-run", "--output", "json") + require.NoError(t, err) + assert.Equal(t, []string{"variation support/default in_sync"}, planRows(t, stdout), "the lock has the new binding") +} + +// A relink is the recovery for a descriptor whose mapping no longer compiles, +// so the current descriptor is not compiled before the proposal replaces it. +func TestRelinkReplacesADescriptorThatDoesNotCompile(t *testing.T) { + root := initRepository(t) + require.NoError(t, os.WriteFile(filepath.Join(root, "agents.py"), []byte(`from agents import Agent +support = Agent(name="support", instructions="Help") +`), 0o644)) + server := variation("Server") + api := &directAPI{ + variation: &server, + directions: map[string]syncdomain.SyncDirection{ + "variation:support/default": syncdomain.SyncDirectionBoth, + }, + } + id := syncdomain.VariationID("production", "support", "default") + link := func(selectPath string) error { + _, _, err := runScoped(t, root, api, syncprompt.Options{ + Scope: syncdomain.ScopePrompts, + Action: syncprompt.SourceLinkAction{ + ID: id, File: "agents.py", Format: "python", Select: selectPath, + Map: map[string]string{"prompt": "instructions"}, + }, + Yes: true, + }) + return err + } + require.NoError(t, link(`agents[id="support"]`)) + // The selector of the descriptor stops matching the source. + writeLocalFile(t, root, "configs/support/default.ref.yaml", + "formatVersion: 1\nfile: agents.py\nformat: python\nselect: 'agents[id=\"missing\"]'\nmap:\n prompt: instructions\n") + _, _, err := runPrompt(t, root, api, "--dry-run", "--output", "json") + require.Error(t, err, "the broken descriptor fails a sync") + + require.NoError(t, link(`agents[0]`), "the relink does not compile the descriptor that it replaces") + + descriptor, err := os.ReadFile(filepath.Join(root, ".launchdarkly", "production", "configs", "support", "default.ref.yaml")) + require.NoError(t, err) + assert.Contains(t, string(descriptor), "select: agents[0]") + stdout, _, err := runPrompt(t, root, api, "--dry-run", "--output", "json") + require.NoError(t, err) + assert.Equal(t, []string{"variation support/default in_sync"}, planRows(t, stdout)) +} + +// A variation push must not publish a linked tool against its LaunchDarkly +// sync setting. The tool gets a row of its own, so the setting decides. +func TestVariationPushRespectsTheSettingOfALinkedTool(t *testing.T) { + root := initRepository(t) + require.NoError(t, os.WriteFile(filepath.Join(root, "tool.json"), []byte(`{"description":"Local","schema":{"type":"object"}}`), 0o644)) + writeLocalFile(t, root, "tools/search.ref.yaml", + "formatVersion: 1\nfile: tool.json\nformat: json\nselect: $\nmap:\n description: description\n schema: schema\n") + local := variation("Local name") + local.Tools = []syncdomain.AttachmentRef{{Key: "search"}} + local.Attachments = []syncdomain.Attachment{{Kind: syncdomain.AttachmentTool, Tool: &syncdomain.Tool{ + Key: "search", Description: pointer("Local"), Schema: map[string]any{"type": "object"}, + }}} + writeVariation(t, root, local, true) + serverTool := syncdomain.Tool{Key: "search", Description: pointer("Server"), Schema: map[string]any{"type": "object"}} + server := variation("Server name") + server.Tools = []syncdomain.AttachmentRef{{Key: "search", Version: 1}} + server.Attachments = []syncdomain.Attachment{toolAttachment(serverTool, 1)} + // The last sync left LaunchDarkly as it is now, so only the local side changed. + writeManifest(t, root, server) + linked, err := synclocal.CompileAttachments(root, func(syncdomain.ResourceID) bool { return true }) + require.NoError(t, err) + require.Len(t, linked, 1) + recorded, err := syncdomain.SyncedAttachment{ + ProjectKey: "production", Attachment: toolAttachment(serverTool, 1), Source: linked[0].Source, + }.Fingerprint() + require.NoError(t, err) + lock := readLock(t, root) + lock.SetFingerprint(linked[0].ID(), recorded) + require.NoError(t, lock.SetSource(linked[0].ID(), linked[0].Source, "json/1", linked[0].IndexedSelections)) + require.NoError(t, syncmanifest.WriteLock(synclocal.NewStore(root), lock)) + api := &directAPI{ + variation: &server, + tools: map[string]versionedTool{"search": {Tool: serverTool, Version: 1}}, + directions: map[string]syncdomain.SyncDirection{"tool:search": syncdomain.SyncDirectionServerCanonical}, + } + + stdout, _, err := runPrompt(t, root, api, "--dry-run", "--output", "json") + require.NoError(t, err) + assert.Equal(t, []string{"tool search update_local", "variation support/default update_server"}, planRows(t, stdout)) + + _, _, err = runPrompt(t, root, api, "--yes") + + require.NoError(t, err) + assert.Equal(t, "Server", *api.tools["search"].Description, "the server-canonical tool is not overwritten") + assert.Equal(t, 1, api.tools["search"].Version) + assert.Equal(t, "Local name", api.variation.Name, "the variation is pushed") + data, err := os.ReadFile(filepath.Join(root, "tool.json")) + require.NoError(t, err) + assert.Contains(t, string(data), `"Server"`, "the linked source follows LaunchDarkly") +} + +// A variation push publishes the tools that it uses. A linked tool that +// LaunchDarkly deleted gets its row with the detach problem, so the push does +// not recreate the tool from the repository copy. +func TestVariationPushDoesNotRecreateADeletedLinkedTool(t *testing.T) { + root := initRepository(t) + require.NoError(t, os.WriteFile(filepath.Join(root, "tool.json"), []byte(`{"description":"Local","schema":{"type":"object"}}`), 0o644)) + writeLocalFile(t, root, "tools/search.ref.yaml", + "formatVersion: 1\nfile: tool.json\nformat: json\nselect: $\nmap:\n description: description\n schema: schema\n") + local := variation("Local name") + local.Tools = []syncdomain.AttachmentRef{{Key: "search"}} + local.Attachments = []syncdomain.Attachment{{Kind: syncdomain.AttachmentTool, Tool: &syncdomain.Tool{ + Key: "search", Description: pointer("Local"), Schema: map[string]any{"type": "object"}, + }}} + writeVariation(t, root, local, true) + // The last sync tracked the tool on its own, and the variation did not + // use it yet. Since then, the repository added the tool to the variation + // and LaunchDarkly deleted the tool. + syncedTool := syncdomain.Tool{Key: "search", Description: pointer("Local"), Schema: map[string]any{"type": "object"}} + server := variation("Server name") + writeManifest(t, root, server) + linked, err := synclocal.CompileAttachments(root, func(syncdomain.ResourceID) bool { return true }) + require.NoError(t, err) + require.Len(t, linked, 1) + recorded, err := syncdomain.SyncedAttachment{ + ProjectKey: "production", Attachment: toolAttachment(syncedTool, 1), Source: linked[0].Source, + }.Fingerprint() + require.NoError(t, err) + lock := readLock(t, root) + lock.SetFingerprint(linked[0].ID(), recorded) + require.NoError(t, lock.SetSource(linked[0].ID(), linked[0].Source, "json/1", linked[0].IndexedSelections)) + require.NoError(t, syncmanifest.WriteLock(synclocal.NewStore(root), lock)) + api := &directAPI{ + variation: &server, + tools: map[string]versionedTool{}, + directions: map[string]syncdomain.SyncDirection{"tool:search": syncdomain.SyncDirectionBoth}, + } + + stdout, _, err := runPrompt(t, root, api, "--dry-run", "--output", "json") + + require.Error(t, err, "the deleted linked tool blocks the plan") + assert.Equal(t, []string{"tool search error", "variation support/default update_server"}, planRows(t, stdout)) + assert.Contains(t, stdout, "linked resource no longer exists in LaunchDarkly") + _, _, err = runPrompt(t, root, api, "--yes") + require.Error(t, err) + assert.Empty(t, api.tools, "the push does not recreate the tool") + assert.Equal(t, "Server name", api.variation.Name, "the variation is not pushed") +} + +// A variation push publishes the tools that it uses. A linked tool that +// LaunchDarkly does not have yet keeps its error row, which a descriptor +// cannot clear with upsert, so the push does not create the tool against its +// server-canonical setting. +func TestVariationPushDoesNotCreateAServerCanonicalLinkedTool(t *testing.T) { + root := initRepository(t) + require.NoError(t, os.WriteFile(filepath.Join(root, "tool.json"), []byte(`{"description":"Local","schema":{"type":"object"}}`), 0o644)) + writeLocalFile(t, root, "tools/search.ref.yaml", + "formatVersion: 1\nfile: tool.json\nformat: json\nselect: $\nmap:\n description: description\n schema: schema\n") + local := variation("Local name") + local.Tools = []syncdomain.AttachmentRef{{Key: "search"}} + local.Attachments = []syncdomain.Attachment{{Kind: syncdomain.AttachmentTool, Tool: &syncdomain.Tool{ + Key: "search", Description: pointer("Local"), Schema: map[string]any{"type": "object"}, + }}} + writeVariation(t, root, local, true) + server := variation("Server name") + writeManifest(t, root, server) + api := &directAPI{ + variation: &server, + tools: map[string]versionedTool{}, + directions: map[string]syncdomain.SyncDirection{"tool:search": syncdomain.SyncDirectionServerCanonical}, + } + + stdout, _, err := runPrompt(t, root, api, "--dry-run", "--output", "json") + + require.Error(t, err, "the missing linked tool blocks the plan") + assert.Equal(t, []string{"tool search error", "variation support/default update_server"}, planRows(t, stdout)) + assert.Contains(t, stdout, `tool \"search\" does not exist in LaunchDarkly. Create it in LaunchDarkly and run sync again.`) + assert.NotContains(t, stdout, "upsert", "a descriptor has no upsert, so the message does not ask for one") + _, _, err = runPrompt(t, root, api, "--yes") + require.Error(t, err) + assert.Empty(t, api.tools, "the push does not create the tool") + assert.Equal(t, "Server name", api.variation.Name, "the variation is not pushed") +} + +// A link whose apply synced the resource keeps its descriptor, even when a +// later step fails, because LaunchDarkly has the new content. +func TestLinkKeepsTheDescriptorWhenAStepAfterTheSyncFails(t *testing.T) { + root := initRepository(t) + require.NoError(t, os.WriteFile(filepath.Join(root, "agents.py"), []byte("from agents import Agent\nsupport = Agent(instructions=\"Local text\")\n"), 0o644)) + server := variation("Server") + api := &directAPI{ + variation: &server, + directions: map[string]syncdomain.SyncDirection{"variation:support/default": syncdomain.SyncDirectionBoth}, + } + // The push of the variation succeeds, and the manifest write after it fails. + api.onRequest = func(method, path string) { + if method == http.MethodPatch && strings.Contains(path, "/variations/") { + api.manifestWriteErr = errors.New("manifest write failed") + } + } + id := syncdomain.VariationID("production", "support", "default") + // Both sides have text, so the first sync is a conflict that the local + // side wins, which pushes the linked text. + resolution, err := syncprompt.ParseConflictResolution("local") + require.NoError(t, err) + + _, _, err = runScoped(t, root, api, syncprompt.Options{ + Scope: syncdomain.ScopePrompts, + Action: syncprompt.SourceLinkAction{ + ID: id, File: "agents.py", Format: "python", Select: `agents[id="support"]`, + Map: map[string]string{"prompt": "instructions"}, + }, + Yes: true, NoInput: true, ConflictPolicy: syncprompt.ConflictPolicy{Default: resolution}, + }) + + require.Error(t, err, "the manifest write failed") + assert.Equal(t, "Local text", api.variation.Instructions, "the push happened") + descriptorPath := filepath.Join(root, ".launchdarkly", "production", "configs", "support", "default.ref.yaml") + require.FileExists(t, descriptorPath, "the descriptor stays, because LaunchDarkly has the linked content") +} + +// A link that writes its descriptor and then cannot finish puts the previous +// descriptor back, so the next sync does not see a binding that the lock +// never recorded. +func TestFailedLinkRestoresThePreviousDescriptor(t *testing.T) { + root := initRepository(t) + source := []byte("from agents import Agent\nsupport = Agent(instructions=\"Help\")\n") + require.NoError(t, os.WriteFile(filepath.Join(root, "agents.py"), source, 0o644)) + server := variation("Server") + api := &directAPI{ + variation: &server, + directions: map[string]syncdomain.SyncDirection{"variation:support/default": syncdomain.SyncDirectionBoth}, + } + id := syncdomain.VariationID("production", "support", "default") + link := func() error { + _, _, err := runScoped(t, root, api, syncprompt.Options{ + Scope: syncdomain.ScopePrompts, + Action: syncprompt.SourceLinkAction{ + ID: id, File: "agents.py", Format: "python", Select: `agents[id="support"]`, + Map: map[string]string{"prompt": "instructions"}, + }, + Yes: true, + }) + return err + } + require.NoError(t, link()) + descriptorPath := filepath.Join(root, ".launchdarkly", "production", "configs", "support", "default.ref.yaml") + previous, err := os.ReadFile(descriptorPath) + require.NoError(t, err) + + // LaunchDarkly changes between the review and the apply, so the second + // link cannot finish. + api.onRequest = func(method, path string) { + if method == http.MethodGet && strings.HasSuffix(path, "/ai-configs/support") && len(api.requests) > 8 { + api.variation.Instructions = "Changed after review" + } + } + require.NoError(t, os.WriteFile(filepath.Join(root, "agents.py"), []byte("from agents import Agent\nsupport = Agent(instructions=\"Help again\")\n"), 0o644)) + _, _, err = runScoped(t, root, api, syncprompt.Options{ + Scope: syncdomain.ScopePrompts, + Action: syncprompt.SourceLinkAction{ + ID: id, File: "agents.py", Format: "python", Select: `agents[0]`, + Map: map[string]string{"prompt": "instructions"}, + }, + Yes: true, + }) + require.Error(t, err) + current, err := os.ReadFile(descriptorPath) + require.NoError(t, err) + assert.Equal(t, string(previous), string(current), "the previous descriptor is back") +} + +// linkedVariationForPush links the variation to agents.py with a prompt map, +// records a baseline that matches LaunchDarkly, and changes the local text, +// so that the plan is a push of the mapped field. +func linkedVariationForPush(t *testing.T) (string, *directAPI) { + t.Helper() + root := initRepository(t) + require.NoError(t, os.WriteFile(filepath.Join(root, "agents.py"), []byte("from agents import Agent\nsupport = Agent(instructions=\"Local text\")\n"), 0o644)) + writeLocalFile(t, root, "configs/support/default.ref.yaml", + "formatVersion: 1\nfile: agents.py\nformat: python\nselect: 'agents[id=\"support\"]'\nmap:\n prompt: instructions\n") + compiled, err := synclocal.CompileWorkspace(root) + require.NoError(t, err) + require.Len(t, compiled, 1) + server := variation("Server name") + server.Instructions = "Old text" + writeManifestResources(t, root) + lock := readLock(t, root) + projection, err := syncdomain.ProjectVariation(server, *compiled[0].Source) + require.NoError(t, err) + recorded, err := syncdomain.FingerprintProjection(compiled[0].ID(), projection) + require.NoError(t, err) + lock.SetFingerprint(compiled[0].ID(), recorded) + require.NoError(t, lock.SetSource(compiled[0].ID(), compiled[0].Source, "python/1", compiled[0].IndexedSelections)) + require.NoError(t, syncmanifest.WriteLock(synclocal.NewStore(root), lock)) + api := &directAPI{ + variation: &server, + directions: map[string]syncdomain.SyncDirection{"variation:support/default": syncdomain.SyncDirectionBoth}, + } + return root, api +} + +// onNthRead runs change before the nth read of the path with the suffix. A +// push reads a resource for the plan, for the reload after the review, and +// for the merge in the apply, so the third read is the one after the review. +func onNthRead(api *directAPI, suffix string, n int, change func()) { + reads := 0 + api.onRequest = func(method, path string) { + if method == http.MethodGet && strings.HasSuffix(path, suffix) { + reads++ + if reads == n { + change() + } + } + } +} + +// A linked variation push merges its mapped fields onto the latest +// LaunchDarkly copy, so an edit to an unowned field after the review survives. +func TestLinkedVariationPushKeepsUnownedFieldsChangedAfterReview(t *testing.T) { + root, api := linkedVariationForPush(t) + onNthRead(api, "/ai-configs/support", 3, func() { api.variation.Name = "Renamed after review" }) + + _, _, err := runPrompt(t, root, api, "--yes") + + require.NoError(t, err) + assert.Equal(t, "Local text", api.variation.Instructions, "the mapped field is pushed") + assert.Equal(t, "Renamed after review", api.variation.Name, "the unowned field keeps the LaunchDarkly edit") +} + +// A change to a mapped field after the review is not pushed over, because the +// plan never compared it. +func TestLinkedVariationPushStopsWhenMappedFieldsChangeAfterReview(t *testing.T) { + root, api := linkedVariationForPush(t) + onNthRead(api, "/ai-configs/support", 3, func() { api.variation.Instructions = "Changed after review" }) + + _, _, err := runPrompt(t, root, api, "--yes") + + require.Error(t, err) + assert.Contains(t, err.Error(), "changed in LaunchDarkly after review") + assert.Equal(t, "Changed after review", api.variation.Instructions, "the push does not write over the change") +} + +// A linked tool push merges its mapped fields onto the latest LaunchDarkly +// copy. A change to a mapped field after the review is not pushed over, +// because the plan never compared it. +func TestLinkedToolPushStopsWhenMappedFieldsChangeAfterReview(t *testing.T) { + root := initRepository(t) + require.NoError(t, os.WriteFile(filepath.Join(root, "tool.json"), []byte(`{"description":"Local text","schema":{"type":"object"}}`), 0o644)) + writeLocalFile(t, root, "tools/search.ref.yaml", + "formatVersion: 1\nfile: tool.json\nformat: json\nselect: $\nmap:\n description: description\n schema: schema\n") + baseline := toolAttachment(syncdomain.Tool{Key: "search", Description: pointer("Old text"), Schema: map[string]any{"type": "object"}}, 1) + writeManifestResources(t, root) + trackAttachment(t, root, baseline) + linked, err := synclocal.CompileAttachments(root, func(syncdomain.ResourceID) bool { return true }) + require.NoError(t, err) + recorded, err := syncdomain.SyncedAttachment{ProjectKey: "production", Attachment: baseline, Source: linked[0].Source}.Fingerprint() + require.NoError(t, err) + lock := readLock(t, root) + lock.SetFingerprint(linked[0].ID(), recorded) + require.NoError(t, lock.SetSource(linked[0].ID(), linked[0].Source, "json/1", linked[0].IndexedSelections)) + require.NoError(t, syncmanifest.WriteLock(synclocal.NewStore(root), lock)) + serverTool := syncdomain.Tool{Key: "search", Description: pointer("Old text"), Schema: map[string]any{"type": "object"}} + api := &directAPI{ + tools: map[string]versionedTool{"search": {Tool: serverTool, Version: 1}}, + directions: map[string]syncdomain.SyncDirection{"tool:search": syncdomain.SyncDirectionBoth}, + } + onNthRead(api, "/ai-tools/search", 3, func() { + tool := api.tools["search"] + tool.Description = pointer("Changed after review") + api.tools["search"] = tool + }) + + _, _, err = runScoped(t, root, api, syncprompt.Options{ + Scope: syncdomain.ScopeTools, Action: syncprompt.SyncAction{Filter: syncdomain.Filter{Project: "production"}}, + Yes: true, NoInput: true, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "changed in LaunchDarkly after review") + assert.Equal(t, "Changed after review", *api.tools["search"].Description, "the push does not write over the change") +} + +// A prompts run with an in-sync variation does not rewrite the baseline of a +// linked tool that it did not sync, so a local edit to the tool stays a local +// change for the next tools run. +func TestInSyncPromptRunKeepsTheBaselineOfAnUnsyncedLinkedTool(t *testing.T) { + root := initRepository(t) + require.NoError(t, os.WriteFile(filepath.Join(root, "agents.py"), []byte("from agents import Agent\nsupport = Agent(instructions=\"Same text\")\n"), 0o644)) + writeLocalFile(t, root, "configs/support/default.ref.yaml", + "formatVersion: 1\nfile: agents.py\nformat: python\nselect: 'agents[id=\"support\"]'\nmap:\n prompt: instructions\ntools:\n - key: search\n") + require.NoError(t, os.WriteFile(filepath.Join(root, "tool.json"), []byte(`{"description":"Local edit","schema":{"type":"object"}}`), 0o644)) + writeLocalFile(t, root, "tools/search.ref.yaml", + "formatVersion: 1\nfile: tool.json\nformat: json\nselect: $\nmap:\n description: description\n schema: schema\n") + serverTool := syncdomain.Tool{Key: "search", Description: pointer("Server"), Schema: map[string]any{"type": "object"}} + server := variation("Server") + server.Instructions = "Same text" + server.Tools = []syncdomain.AttachmentRef{{Key: "search", Version: 1}} + server.Attachments = []syncdomain.Attachment{toolAttachment(serverTool, 1)} + compiled, err := synclocal.CompileWorkspace(root) + require.NoError(t, err) + require.Len(t, compiled, 1) + linked, err := synclocal.CompileAttachments(root, func(syncdomain.ResourceID) bool { return true }) + require.NoError(t, err) + require.Len(t, linked, 1) + // The last sync left the variation and the tool at the LaunchDarkly content. + writeManifestResources(t, root) + lock := readLock(t, root) + projection, err := syncdomain.ProjectVariation(server, *compiled[0].Source) + require.NoError(t, err) + variationFingerprint, err := syncdomain.FingerprintProjection(compiled[0].ID(), projection) + require.NoError(t, err) + lock.SetFingerprint(compiled[0].ID(), variationFingerprint) + require.NoError(t, lock.SetSource(compiled[0].ID(), compiled[0].Source, "python/1", compiled[0].IndexedSelections)) + recorded, err := syncdomain.SyncedAttachment{ + ProjectKey: "production", Attachment: toolAttachment(serverTool, 1), Source: linked[0].Source, + }.Fingerprint() + require.NoError(t, err) + lock.SetFingerprint(linked[0].ID(), recorded) + require.NoError(t, lock.SetSource(linked[0].ID(), linked[0].Source, "json/1", linked[0].IndexedSelections)) + require.NoError(t, syncmanifest.WriteLock(synclocal.NewStore(root), lock)) + // The push of the edited tool fails, so the tool stays unsynced while + // the in-sync variation row completes. + api := &directAPI{ + variation: &server, + tools: map[string]versionedTool{"search": {Tool: serverTool, Version: 1}}, + toolWriteErr: errors.New("tool write failed"), + directions: map[string]syncdomain.SyncDirection{ + "variation:support/default": syncdomain.SyncDirectionBoth, "tool:search": syncdomain.SyncDirectionBoth, + }, + } + + stdout, _, err := runPrompt(t, root, api, "--yes", "--output", "json") + + require.Error(t, err, "the tool push failed") + assert.Contains(t, stdout, `"action": "in_sync"`, "the linked variation is in sync on its mapped fields") + assert.Equal(t, "Server", *api.tools["search"].Description, "LaunchDarkly keeps its copy") + after := readLock(t, root) + index := slices.IndexFunc(after.Resources, func(resource syncmanifest.Resource) bool { return resource.ID() == linked[0].ID() }) + require.NotEqual(t, -1, index) + assert.Equal(t, recorded, after.Resources[index].Fingerprint, "the baseline of the unsynced tool stays the LaunchDarkly copy, so the local edit stays a local change") +} + +// A linked variation push does not write the repository source of a linked +// tool that it uses, so a change to that tool in LaunchDarkly is not a conflict +// of the variation. The tool gets a row of its own, which pulls the change +// before the variation pushes. +func TestLinkedVariationPushIsNotAConflictWhenALinkedToolChangedInLaunchDarkly(t *testing.T) { + root := initRepository(t) + require.NoError(t, os.WriteFile(filepath.Join(root, "agents.py"), []byte("from agents import Agent\nsupport = Agent(instructions=\"Local text\")\n"), 0o644)) + writeLocalFile(t, root, "configs/support/default.ref.yaml", + "formatVersion: 1\nfile: agents.py\nformat: python\nselect: 'agents[id=\"support\"]'\nmap:\n prompt: instructions\ntools:\n - key: search\n") + require.NoError(t, os.WriteFile(filepath.Join(root, "tool.json"), []byte(`{"description":"Old","schema":{"type":"object"}}`), 0o644)) + writeLocalFile(t, root, "tools/search.ref.yaml", + "formatVersion: 1\nfile: tool.json\nformat: json\nselect: $\nmap:\n description: description\n schema: schema\n") + oldTool := syncdomain.Tool{Key: "search", Description: pointer("Old"), Schema: map[string]any{"type": "object"}} + newTool := syncdomain.Tool{Key: "search", Description: pointer("New"), Schema: map[string]any{"type": "object"}} + server := variation("Server") + server.Instructions = "Same text" + server.Tools = []syncdomain.AttachmentRef{{Key: "search", Version: 2}} + server.Attachments = []syncdomain.Attachment{toolAttachment(newTool, 2)} + compiled, err := synclocal.CompileWorkspace(root) + require.NoError(t, err) + require.Len(t, compiled, 1) + linked, err := synclocal.CompileAttachments(root, func(syncdomain.ResourceID) bool { return true }) + require.NoError(t, err) + require.Len(t, linked, 1) + // The last sync left the variation at the LaunchDarkly prompt and the + // tool at its old content. The repository then edited the prompt, and + // LaunchDarkly edited the tool. + writeManifestResources(t, root) + lock := readLock(t, root) + projection, err := syncdomain.ProjectVariation(server, *compiled[0].Source) + require.NoError(t, err) + variationFingerprint, err := syncdomain.FingerprintProjection(compiled[0].ID(), projection) + require.NoError(t, err) + lock.SetFingerprint(compiled[0].ID(), variationFingerprint) + require.NoError(t, lock.SetSource(compiled[0].ID(), compiled[0].Source, "python/1", compiled[0].IndexedSelections)) + recorded, err := syncdomain.SyncedAttachment{ + ProjectKey: "production", Attachment: toolAttachment(oldTool, 1), Source: linked[0].Source, + }.Fingerprint() + require.NoError(t, err) + lock.SetFingerprint(linked[0].ID(), recorded) + require.NoError(t, lock.SetSource(linked[0].ID(), linked[0].Source, "json/1", linked[0].IndexedSelections)) + require.NoError(t, syncmanifest.WriteLock(synclocal.NewStore(root), lock)) + api := &directAPI{ + variation: &server, + tools: map[string]versionedTool{"search": {Tool: newTool, Version: 2}}, + directions: map[string]syncdomain.SyncDirection{ + "variation:support/default": syncdomain.SyncDirectionBoth, "tool:search": syncdomain.SyncDirectionBoth, + }, + } + + stdout, _, err := runPrompt(t, root, api, "--dry-run", "--output", "json") + require.NoError(t, err) + assert.Equal(t, []string{"tool search update_local", "variation support/default update_server"}, planRows(t, stdout), "the tool pulls on its own row and the variation pushes") + + _, _, err = runPrompt(t, root, api, "--yes") + + require.NoError(t, err) + assert.Equal(t, "Local text", api.variation.Instructions, "the variation pushes its prompt") + assert.Zero(t, countRequests(api.requests, "PATCH", "/ai-tools/search"), "the tool is not written") + content, err := os.ReadFile(filepath.Join(root, "tool.json")) + require.NoError(t, err) + assert.Contains(t, string(content), `"New"`, "the tool pull reaches the repository source") +} + +// A variation push must not write a linked tool whose owned fields match, +// even when LaunchDarkly holds extra fields that the link does not own. +func TestVariationPushKeepsUnownedFieldsOfALinkedTool(t *testing.T) { + root := initRepository(t) + require.NoError(t, os.WriteFile(filepath.Join(root, "tool.json"), []byte(`{"description":"Shared","schema":{"type":"object"}}`), 0o644)) + writeLocalFile(t, root, "tools/search.ref.yaml", + "formatVersion: 1\nfile: tool.json\nformat: json\nselect: $\nmap:\n description: description\n schema: schema\n") + local := variation("Local name") + local.Tools = []syncdomain.AttachmentRef{{Key: "search"}} + local.Attachments = []syncdomain.Attachment{{Kind: syncdomain.AttachmentTool, Tool: &syncdomain.Tool{ + Key: "search", Description: pointer("Shared"), Schema: map[string]any{"type": "object"}, + }}} + writeVariation(t, root, local, true) + serverTool := syncdomain.Tool{ + Key: "search", Description: pointer("Shared"), Schema: map[string]any{"type": "object"}, + CustomParameters: map[string]any{"timeout": float64(5)}, + } + server := variation("Server name") + server.Tools = []syncdomain.AttachmentRef{{Key: "search", Version: 1}} + server.Attachments = []syncdomain.Attachment{toolAttachment(serverTool, 1)} + // The last sync recorded the variation with the linked tool on its + // mapped fields, as the plan compares it. + baseline := server + baseline.Attachments = local.Attachments + writeManifest(t, root, baseline) + api := &directAPI{ + variation: &server, + tools: map[string]versionedTool{"search": {Tool: serverTool, Version: 1}}, + directions: map[string]syncdomain.SyncDirection{"tool:search": syncdomain.SyncDirectionServerCanonical}, + } + + stdout, _, err := runPrompt(t, root, api, "--dry-run", "--output", "json") + require.NoError(t, err) + assert.Equal(t, []string{"variation support/default update_server"}, planRows(t, stdout), "the tool has no owned change") + + _, _, err = runPrompt(t, root, api, "--yes") + + require.NoError(t, err) + assert.Zero(t, countRequests(api.requests, "PATCH", "/ai-tools/search"), "the linked tool is not written") + assert.Equal(t, map[string]any{"timeout": float64(5)}, api.tools["search"].CustomParameters, "LaunchDarkly keeps its own fields") + assert.Equal(t, "Local name", api.variation.Name) +} + +// A push of a managed variation that newly attaches a tracked tool pulls +// that tool first and settles the row with the pulled copy. The settled row +// is aligned on the mapped fields of its linked tools, and the aligned linked +// tool keeps its projection, so the push still merges its mapped fields onto +// the LaunchDarkly copy and a field that LaunchDarkly alone holds survives. +func TestVariationPushKeepsUnownedFieldsOfALinkedToolBesideAPulledManagedTool(t *testing.T) { + root := initRepository(t) + require.NoError(t, os.WriteFile(filepath.Join(root, "tool.json"), []byte(`{"description":"Shared","schema":{"type":"object"}}`), 0o644)) + writeLocalFile(t, root, "tools/search.ref.yaml", + "formatVersion: 1\nfile: tool.json\nformat: json\nselect: $\nmap:\n description: description\n schema: schema\n") + linkedLocal := syncdomain.Attachment{Kind: syncdomain.AttachmentTool, Tool: &syncdomain.Tool{ + Key: "search", Description: pointer("Shared"), Schema: map[string]any{"type": "object"}, + }} + oldLookup := syncdomain.Tool{Key: "lookup", Description: pointer("Old lookup"), Schema: map[string]any{"type": "object"}} + newLookup := syncdomain.Tool{Key: "lookup", Description: pointer("New lookup"), Schema: map[string]any{"type": "object"}} + serverSearch := syncdomain.Tool{ + Key: "search", Description: pointer("Shared"), Schema: map[string]any{"type": "object"}, + CustomParameters: map[string]any{"timeout": float64(5)}, + } + // The last sync recorded the variation with its linked tool on the + // mapped fields, and the managed tool on its own. + baseline := variation("Same name") + baseline.Tools = []syncdomain.AttachmentRef{{Key: "search", Version: 1}} + baseline.Attachments = []syncdomain.Attachment{linkedLocal} + writeManifest(t, root, baseline) + trackTool(t, root, oldLookup) + local := baseline + local.Tools = []syncdomain.AttachmentRef{{Key: "lookup"}, {Key: "search"}} + local.Attachments = []syncdomain.Attachment{toolAttachment(oldLookup, 0), linkedLocal} + writeVariation(t, root, local, true) + server := baseline + server.Attachments = []syncdomain.Attachment{toolAttachment(serverSearch, 1)} + api := &directAPI{ + variation: &server, + tools: map[string]versionedTool{ + "lookup": {Tool: newLookup, Version: 2}, + "search": {Tool: serverSearch, Version: 1}, + }, + directions: map[string]syncdomain.SyncDirection{"tool:search": syncdomain.SyncDirectionBoth}, + } + + stdout, _, err := runPrompt(t, root, api, "--dry-run", "--output", "json") + require.NoError(t, err) + assert.Equal(t, []string{"tool lookup update_local", "variation support/default update_server"}, planRows(t, stdout)) + + _, _, err = runPrompt(t, root, api, "--yes") + + require.NoError(t, err) + assert.Zero(t, countRequests(api.requests, "PATCH", "/ai-tools/search"), "the in-sync linked tool is not written") + assert.Equal(t, map[string]any{"timeout": float64(5)}, api.tools["search"].CustomParameters, "LaunchDarkly keeps its own field") + assert.Equal(t, []syncdomain.AttachmentRef{{Key: "lookup", Version: 2}, {Key: "search", Version: 1}}, api.variation.Tools, "the variation pins the pulled tool") + assert.Contains(t, readLocalFile(t, root, "tools/lookup.json"), "New lookup", "the managed tool pulls") +} + +// A managed variation that uses a linked tool compares the tool on the fields +// that the link maps, so a field that LaunchDarkly alone holds does not keep +// the variation out of sync, and a pull of the variation settles. +func TestManagedVariationWithALinkedToolIgnoresUnownedToolFields(t *testing.T) { + root := initRepository(t) + require.NoError(t, os.WriteFile(filepath.Join(root, "tool.json"), []byte(`{"description":"Shared","schema":{"type":"object"}}`), 0o644)) + writeLocalFile(t, root, "tools/search.ref.yaml", + "formatVersion: 1\nfile: tool.json\nformat: json\nselect: $\nmap:\n description: description\n schema: schema\n") + local := variation("Same name") + local.Tools = []syncdomain.AttachmentRef{{Key: "search", Version: 1}} + local.Attachments = []syncdomain.Attachment{{Kind: syncdomain.AttachmentTool, Tool: &syncdomain.Tool{ + Key: "search", Description: pointer("Shared"), Schema: map[string]any{"type": "object"}, + }}} + writeVariation(t, root, local, true) + writeManifest(t, root, local) + serverTool := syncdomain.Tool{ + Key: "search", Description: pointer("Shared"), Schema: map[string]any{"type": "object"}, + CustomParameters: map[string]any{"timeout": float64(5)}, + } + server := variation("Same name") + server.Tools = []syncdomain.AttachmentRef{{Key: "search", Version: 1}} + server.Attachments = []syncdomain.Attachment{toolAttachment(serverTool, 1)} + api := &directAPI{ + variation: &server, + tools: map[string]versionedTool{"search": {Tool: serverTool, Version: 1}}, + directions: map[string]syncdomain.SyncDirection{"tool:search": syncdomain.SyncDirectionBoth}, + } + + stdout, _, err := runPrompt(t, root, api, "--dry-run", "--output", "json") + require.NoError(t, err) + assert.Equal(t, []string{"variation support/default in_sync"}, planRows(t, stdout), "a field that only LaunchDarkly holds is not a change of the variation") + + // A change in LaunchDarkly pulls, and the pull settles although the + // local tool never holds the unowned field. + server.Name = "Server name" + stdout, _, err = runPrompt(t, root, api, "--dry-run", "--output", "json") + require.NoError(t, err) + assert.Equal(t, []string{"variation support/default update_local"}, planRows(t, stdout)) + _, _, err = runPrompt(t, root, api, "--yes") + require.NoError(t, err) + stdout, _, err = runPrompt(t, root, api, "--dry-run", "--output", "json") + require.NoError(t, err) + assert.Equal(t, []string{"variation support/default in_sync"}, planRows(t, stdout), "the pull reaches in sync") + assert.Equal(t, map[string]any{"timeout": float64(5)}, api.tools["search"].CustomParameters, "LaunchDarkly keeps its own field") +} + +// A pull of a variation that uses a linked tool gives the tool a row of its +// own, because the variation write does not touch the repository source of +// the tool, so a prompts-only pull still brings the tool to the repository. +func TestManagedVariationPullPlansARowForItsLinkedTool(t *testing.T) { + root := initRepository(t) + require.NoError(t, os.WriteFile(filepath.Join(root, "tool.json"), []byte(`{"description":"Old","schema":{"type":"object"}}`), 0o644)) + writeLocalFile(t, root, "tools/search.ref.yaml", + "formatVersion: 1\nfile: tool.json\nformat: json\nselect: $\nmap:\n description: description\n schema: schema\n") + local := variation("Same name") + local.Tools = []syncdomain.AttachmentRef{{Key: "search", Version: 1}} + local.Attachments = []syncdomain.Attachment{{Kind: syncdomain.AttachmentTool, Tool: &syncdomain.Tool{ + Key: "search", Description: pointer("Old"), Schema: map[string]any{"type": "object"}, + }}} + writeVariation(t, root, local, true) + writeManifest(t, root, local) + // The lock records a linked tool by the fingerprint of its projection. + linked, err := synclocal.CompileAttachments(root, func(syncdomain.ResourceID) bool { return true }) + require.NoError(t, err) + require.Len(t, linked, 1) + recorded, err := syncdomain.SyncedAttachment{ + ProjectKey: "production", Attachment: toolAttachment(*local.Attachments[0].Tool, 1), Source: linked[0].Source, + }.Fingerprint() + require.NoError(t, err) + lock := readLock(t, root) + lock.SetFingerprint(linked[0].ID(), recorded) + require.NoError(t, lock.SetSource(linked[0].ID(), linked[0].Source, "json/1", linked[0].IndexedSelections)) + require.NoError(t, syncmanifest.WriteLock(synclocal.NewStore(root), lock)) + serverTool := syncdomain.Tool{Key: "search", Description: pointer("New"), Schema: map[string]any{"type": "object"}} + server := variation("Server name") + server.Tools = []syncdomain.AttachmentRef{{Key: "search", Version: 2}} + server.Attachments = []syncdomain.Attachment{toolAttachment(serverTool, 2)} + api := &directAPI{ + variation: &server, + tools: map[string]versionedTool{"search": {Tool: serverTool, Version: 2}}, + directions: map[string]syncdomain.SyncDirection{"tool:search": syncdomain.SyncDirectionBoth}, + } + + stdout, _, err := runPrompt(t, root, api, "--dry-run", "--output", "json") + require.NoError(t, err) + assert.ElementsMatch(t, []string{"variation support/default update_local", "tool search update_local"}, planRows(t, stdout), "the linked tool of a pull gets a row") + + _, _, err = runPrompt(t, root, api, "--yes") + + require.NoError(t, err) + content, err := os.ReadFile(filepath.Join(root, "tool.json")) + require.NoError(t, err) + assert.Contains(t, string(content), `"New"`, "the pull brings the tool edit to the repository source") + stdout, _, err = runPrompt(t, root, api, "--dry-run", "--output", "json") + require.NoError(t, err) + assert.Equal(t, []string{"variation support/default in_sync"}, planRows(t, stdout)) +} + +// A variation push publishes the linked tools that it uses through the latest +// LaunchDarkly copy. A linked tool that was in sync at the review has no row, +// so a change to its mapped fields after the review stops the push instead of +// being written over. +func TestVariationPushStopsWhenAnInSyncLinkedToolChangesAfterReview(t *testing.T) { + root := initRepository(t) + require.NoError(t, os.WriteFile(filepath.Join(root, "tool.json"), []byte(`{"description":"Shared","schema":{"type":"object"}}`), 0o644)) + writeLocalFile(t, root, "tools/search.ref.yaml", + "formatVersion: 1\nfile: tool.json\nformat: json\nselect: $\nmap:\n description: description\n schema: schema\n") + local := variation("Local name") + local.Tools = []syncdomain.AttachmentRef{{Key: "search"}} + local.Attachments = []syncdomain.Attachment{{Kind: syncdomain.AttachmentTool, Tool: &syncdomain.Tool{ + Key: "search", Description: pointer("Shared"), Schema: map[string]any{"type": "object"}, + }}} + writeVariation(t, root, local, true) + serverTool := syncdomain.Tool{Key: "search", Description: pointer("Shared"), Schema: map[string]any{"type": "object"}} + server := variation("Server name") + server.Tools = []syncdomain.AttachmentRef{{Key: "search", Version: 1}} + server.Attachments = []syncdomain.Attachment{toolAttachment(serverTool, 1)} + writeManifest(t, root, server) + api := &directAPI{ + variation: &server, + tools: map[string]versionedTool{"search": {Tool: serverTool, Version: 1}}, + directions: map[string]syncdomain.SyncDirection{"tool:search": syncdomain.SyncDirectionServerCanonical}, + } + onNthRead(api, "/ai-tools/search", 3, func() { + tool := api.tools["search"] + tool.Description = pointer("Changed after review") + api.tools["search"] = tool + }) + + _, _, err := runPrompt(t, root, api, "--yes") + + require.Error(t, err) + assert.Contains(t, err.Error(), "changed in LaunchDarkly after review") + assert.Zero(t, countRequests(api.requests, "PATCH", "/ai-tools/search"), "the linked tool is not written") + assert.Equal(t, "Changed after review", *api.tools["search"].Description) + assert.Equal(t, "Server name", api.variation.Name, "the variation is not pushed") +} + +// A conflict that LaunchDarkly wins becomes a pull. The pull checks run on it +// before the review, so a pull that cannot clear a mapped field is a problem +// in the plan, not a failure after the confirmation. +func TestResolvedConflictPullIsCheckedBeforeTheReview(t *testing.T) { + root := initRepository(t) + require.NoError(t, os.WriteFile(filepath.Join(root, "tool.json"), []byte(`{"description":"Local text","schema":{"type":"object"}}`), 0o644)) + writeLocalFile(t, root, "tools/search.ref.yaml", + "formatVersion: 1\nfile: tool.json\nformat: json\nselect: $\nmap:\n description: description\n schema: schema\n") + baseline := toolAttachment(syncdomain.Tool{Key: "search", Description: pointer("Old text"), Schema: map[string]any{"type": "object"}}, 1) + writeManifestResources(t, root) + trackAttachment(t, root, baseline) + linked, err := synclocal.CompileAttachments(root, func(syncdomain.ResourceID) bool { return true }) + require.NoError(t, err) + recorded, err := syncdomain.SyncedAttachment{ProjectKey: "production", Attachment: baseline, Source: linked[0].Source}.Fingerprint() + require.NoError(t, err) + lock := readLock(t, root) + lock.SetFingerprint(linked[0].ID(), recorded) + require.NoError(t, lock.SetSource(linked[0].ID(), linked[0].Source, "json/1", linked[0].IndexedSelections)) + require.NoError(t, syncmanifest.WriteLock(synclocal.NewStore(root), lock)) + // LaunchDarkly dropped the description, so both sides changed. + api := &directAPI{tools: map[string]versionedTool{"search": { + Tool: syncdomain.Tool{Key: "search", Schema: map[string]any{"type": "object"}}, Version: 2, + }}} + + resolution, err := syncprompt.ParseConflictResolution("launchdarkly") + require.NoError(t, err) + stdout, _, err := runScoped(t, root, api, syncprompt.Options{ + Scope: syncdomain.ScopeTools, Action: syncprompt.SyncAction{Filter: syncdomain.Filter{Project: "production"}}, + Yes: true, NoInput: true, ConflictPolicy: syncprompt.ConflictPolicy{Default: resolution}, OutputKind: "json", + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), `mapped field "description"`, "the review reports the pull that cannot clear the field") + assert.NotContains(t, stdout, `"status": "failed"`, "nothing was applied") + data, readErr := os.ReadFile(filepath.Join(root, "tool.json")) + require.NoError(t, readErr) + assert.Contains(t, string(data), "Local text", "the source is unchanged") +} + +// A variation conflict that the user resolves to the local side becomes a +// push. A linked tool that the push would publish still follows its own sync +// setting, so it gets a row of its own in the conflicted plan. +func TestConflictResolvedToLocalRespectsTheSettingOfALinkedTool(t *testing.T) { + root := initRepository(t) + require.NoError(t, os.WriteFile(filepath.Join(root, "tool.json"), []byte(`{"description":"Local","schema":{"type":"object"}}`), 0o644)) + writeLocalFile(t, root, "tools/search.ref.yaml", + "formatVersion: 1\nfile: tool.json\nformat: json\nselect: $\nmap:\n description: description\n schema: schema\n") + local := variation("Local name") + local.Tools = []syncdomain.AttachmentRef{{Key: "search"}} + local.Attachments = []syncdomain.Attachment{{Kind: syncdomain.AttachmentTool, Tool: &syncdomain.Tool{ + Key: "search", Description: pointer("Local"), Schema: map[string]any{"type": "object"}, + }}} + writeVariation(t, root, local, true) + baselineTool := syncdomain.Tool{Key: "search", Description: pointer("Baseline"), Schema: map[string]any{"type": "object"}} + baseline := variation("Baseline name") + baseline.Tools = []syncdomain.AttachmentRef{{Key: "search", Version: 1}} + baseline.Attachments = []syncdomain.Attachment{toolAttachment(baselineTool, 1)} + writeManifest(t, root, baseline) + linked, err := synclocal.CompileAttachments(root, func(syncdomain.ResourceID) bool { return true }) + require.NoError(t, err) + recorded, err := syncdomain.SyncedAttachment{ProjectKey: "production", Attachment: toolAttachment(baselineTool, 1), Source: linked[0].Source}.Fingerprint() + require.NoError(t, err) + lock := readLock(t, root) + lock.SetFingerprint(linked[0].ID(), recorded) + require.NoError(t, lock.SetSource(linked[0].ID(), linked[0].Source, "json/1", linked[0].IndexedSelections)) + require.NoError(t, syncmanifest.WriteLock(synclocal.NewStore(root), lock)) + // Both sides changed the variation name, and the tool changed only locally. + serverTool := baselineTool + server := variation("Server name") + server.Tools = []syncdomain.AttachmentRef{{Key: "search", Version: 1}} + server.Attachments = []syncdomain.Attachment{toolAttachment(serverTool, 1)} + api := &directAPI{ + variation: &server, + tools: map[string]versionedTool{"search": {Tool: serverTool, Version: 1}}, + directions: map[string]syncdomain.SyncDirection{"tool:search": syncdomain.SyncDirectionServerCanonical}, + } + + stdout, _, err := runPrompt(t, root, api, "--dry-run", "--output", "json") + require.Error(t, err, "a dry run reports the conflict as blocking") + assert.Equal(t, []string{"tool search update_local", "variation support/default conflict"}, planRows(t, stdout), "the linked tool has a row in the conflicted plan") + + _, _, err = runPrompt(t, root, api, "--conflict", "local", "--yes", "--no-input") + + require.NoError(t, err) + assert.Equal(t, "Baseline", *api.tools["search"].Description, "the server-canonical tool is not published by the push") + assert.Equal(t, "Local name", api.variation.Name, "the variation push wins the conflict") +} + +// A pull over a linked tool that changed locally becomes a conflict in the +// baseline checks. The linked tool still gets its row, so that a choice of +// the local side cannot publish a server-canonical tool. +func TestConflictFromABaselineCheckStillPlansTheLinkedToolRow(t *testing.T) { + root := initRepository(t) + require.NoError(t, os.WriteFile(filepath.Join(root, "tool.json"), []byte(`{"description":"Local","schema":{"type":"object"}}`), 0o644)) + writeLocalFile(t, root, "tools/search.ref.yaml", + "formatVersion: 1\nfile: tool.json\nformat: json\nselect: $\nmap:\n description: description\n schema: schema\n") + local := variation("Baseline name") + local.Tools = []syncdomain.AttachmentRef{{Key: "search"}} + local.Attachments = []syncdomain.Attachment{{Kind: syncdomain.AttachmentTool, Tool: &syncdomain.Tool{ + Key: "search", Description: pointer("Local"), Schema: map[string]any{"type": "object"}, + }}} + writeVariation(t, root, local, true) + baselineTool := syncdomain.Tool{Key: "search", Description: pointer("Baseline"), Schema: map[string]any{"type": "object"}} + baseline := variation("Baseline name") + baseline.Tools = []syncdomain.AttachmentRef{{Key: "search", Version: 1}} + baseline.Attachments = []syncdomain.Attachment{toolAttachment(baselineTool, 1)} + writeManifest(t, root, baseline) + linked, err := synclocal.CompileAttachments(root, func(syncdomain.ResourceID) bool { return true }) + require.NoError(t, err) + recorded, err := syncdomain.SyncedAttachment{ProjectKey: "production", Attachment: toolAttachment(baselineTool, 1), Source: linked[0].Source}.Fingerprint() + require.NoError(t, err) + lock := readLock(t, root) + lock.SetFingerprint(linked[0].ID(), recorded) + require.NoError(t, lock.SetSource(linked[0].ID(), linked[0].Source, "json/1", linked[0].IndexedSelections)) + require.NoError(t, syncmanifest.WriteLock(synclocal.NewStore(root), lock)) + // Only LaunchDarkly changed the variation, so the row is a pull until + // the baseline check sees the local change to the tool. + server := variation("Server name") + server.Tools = []syncdomain.AttachmentRef{{Key: "search", Version: 1}} + server.Attachments = []syncdomain.Attachment{toolAttachment(baselineTool, 1)} + api := &directAPI{ + variation: &server, + tools: map[string]versionedTool{"search": {Tool: baselineTool, Version: 1}}, + directions: map[string]syncdomain.SyncDirection{"tool:search": syncdomain.SyncDirectionServerCanonical}, + } + + stdout, _, err := runPrompt(t, root, api, "--dry-run", "--output", "json") + require.Error(t, err, "a dry run reports the conflict as blocking") + assert.Equal(t, []string{"tool search update_local", "variation support/default conflict"}, planRows(t, stdout), "the linked tool has a row in the conflicted plan") + + _, _, err = runPrompt(t, root, api, "--conflict", "local", "--yes", "--no-input") + + require.NoError(t, err) + assert.Equal(t, "Baseline", *api.tools["search"].Description, "the server-canonical tool is not published by the push") + assert.Equal(t, "Baseline name", api.variation.Name, "the variation push wins the conflict") +} + +// A code-canonical setting turns a planned pull of a linked variation into a +// push. The linked tool that the push would publish still gets its row, so +// its own setting decides. +func TestSettingFlipToPushStillPlansTheLinkedToolRow(t *testing.T) { + root := initRepository(t) + require.NoError(t, os.WriteFile(filepath.Join(root, "agents.py"), []byte("from agents import Agent\nsupport = Agent(instructions=\"Local text\")\n"), 0o644)) + writeLocalFile(t, root, "configs/support/default.ref.yaml", + "formatVersion: 1\nfile: agents.py\nformat: python\nselect: 'agents[id=\"support\"]'\nmap:\n prompt: instructions\ntools:\n - key: search\n") + require.NoError(t, os.WriteFile(filepath.Join(root, "tool.json"), []byte(`{"description":"Local tool","schema":{"type":"object"}}`), 0o644)) + writeLocalFile(t, root, "tools/search.ref.yaml", + "formatVersion: 1\nfile: tool.json\nformat: json\nselect: $\nmap:\n description: description\n schema: schema\n") + baselineTool := syncdomain.Tool{Key: "search", Description: pointer("Baseline tool"), Schema: map[string]any{"type": "object"}} + compiled, err := synclocal.CompileWorkspace(root) + require.NoError(t, err) + require.Len(t, compiled, 1) + linked, err := synclocal.CompileAttachments(root, func(syncdomain.ResourceID) bool { return true }) + require.NoError(t, err) + // The last sync left the variation at "Local text" and the tool at its baseline. + server := variation("Server") + server.Instructions = "Local text" + server.Tools = []syncdomain.AttachmentRef{{Key: "search", Version: 1}} + server.Attachments = []syncdomain.Attachment{toolAttachment(baselineTool, 1)} + writeManifestResources(t, root) + lock := readLock(t, root) + projection, err := syncdomain.ProjectVariation(server, *compiled[0].Source) + require.NoError(t, err) + variationFingerprint, err := syncdomain.FingerprintProjection(compiled[0].ID(), projection) + require.NoError(t, err) + lock.SetFingerprint(compiled[0].ID(), variationFingerprint) + require.NoError(t, lock.SetSource(compiled[0].ID(), compiled[0].Source, "python/1", compiled[0].IndexedSelections)) + toolFingerprint, err := syncdomain.SyncedAttachment{ProjectKey: "production", Attachment: toolAttachment(baselineTool, 1), Source: linked[0].Source}.Fingerprint() + require.NoError(t, err) + lock.SetFingerprint(linked[0].ID(), toolFingerprint) + require.NoError(t, lock.SetSource(linked[0].ID(), linked[0].Source, "json/1", linked[0].IndexedSelections)) + require.NoError(t, syncmanifest.WriteLock(synclocal.NewStore(root), lock)) + // LaunchDarkly changed the instructions, so the plan is a pull until the + // code-canonical setting turns it into a push. + server.Instructions = "Server text" + api := &directAPI{ + variation: &server, + tools: map[string]versionedTool{"search": {Tool: baselineTool, Version: 1}}, + directions: map[string]syncdomain.SyncDirection{ + "variation:support/default": syncdomain.SyncDirectionCodeCanonical, + "tool:search": syncdomain.SyncDirectionServerCanonical, + }, + } + + stdout, _, err := runPrompt(t, root, api, "--dry-run", "--output", "json") + require.NoError(t, err) + assert.Equal(t, []string{"tool search update_local", "variation support/default update_server"}, planRows(t, stdout), + "the flipped push plans a row for its linked tool") + + _, _, err = runPrompt(t, root, api, "--yes") + + require.NoError(t, err) + assert.Equal(t, "Baseline tool", *api.tools["search"].Description, "the server-canonical tool is not published") + assert.Equal(t, "Local text", api.variation.Instructions, "the code-canonical variation is pushed") +} diff --git a/internal/sync/prompt/action.go b/internal/sync/prompt/action.go index 6278e892f..49e8d027d 100644 --- a/internal/sync/prompt/action.go +++ b/internal/sync/prompt/action.go @@ -7,6 +7,7 @@ import ( "io" syncdomain "github.com/launchdarkly/ldcli/internal/sync" + syncadapter "github.com/launchdarkly/ldcli/internal/sync/adapter" synccreate "github.com/launchdarkly/ldcli/internal/sync/create" ) @@ -54,6 +55,15 @@ type DetachAction struct { Archive bool } +// SourceLinkAction creates a .ref.yaml descriptor and then syncs its resource. +type SourceLinkAction struct { + ID syncdomain.ResourceID + File string + Format string + Select string + Map map[string]string +} + func (action SyncAction) validate(scope syncdomain.Scope) error { if action.Watch && action.DryRun { return errors.New("watch does not support --dry-run") @@ -93,6 +103,30 @@ func (action AttachAction) validate(syncdomain.Scope) error { return validateProjectFlag(action.ProjectKey, action.Target) } +func (action SourceLinkAction) validate(scope syncdomain.Scope) error { + switch { + case !scope.Includes(action.ID.Kind): + return fmt.Errorf("%s cannot link %s", scope, action.ID.Label()) + case action.File == "": + return errors.New("--file is required") + case action.Format == "": + return errors.New("--format is required") + case syncdomain.ValidateKey(action.ID.ProjectKey) != nil: + return fmt.Errorf("invalid project key %q", action.ID.ProjectKey) + case syncdomain.ValidateKey(action.ID.LookupKey) != nil && action.ID.Kind != syncdomain.KindVariation: + return fmt.Errorf("invalid resource key %q", action.ID.LookupKey) + } + if action.ID.Kind == syncdomain.KindVariation { + if _, _, err := action.ID.VariationKeys(); err != nil { + return err + } + } + _, err := syncadapter.NormalizeReference(action.ID, syncdomain.Reference{ + File: action.File, Format: action.Format, Select: action.Select, Map: action.Map, + }) + return err +} + // validateSelections rejects positional selectors outside the prompts scope, // and resources that come both from positional selectors and from target // flags. kind is the kind that --key names. @@ -198,6 +232,19 @@ func (options Options) watching() bool { // syncAction returns the sync that the command runs. After an attach or a // link, it is a sync of every resource. func (options Options) syncAction() SyncAction { - action, _ := options.Action.(SyncAction) - return action + switch action := options.Action.(type) { + case SyncAction: + return action + case SourceLinkAction: + configKey := "" + keys := []string{action.ID.LookupKey} + if action.ID.Kind == syncdomain.KindVariation { + configKey, keys[0], _ = action.ID.VariationKeys() + } + return SyncAction{Filter: syncdomain.Filter{ + Project: action.ID.ProjectKey, ConfigKey: configKey, Keys: keys, + }} + default: + return SyncAction{} + } } diff --git a/internal/sync/prompt/attachment_versions.go b/internal/sync/prompt/attachment_versions.go index 37c264d5c..955487721 100644 --- a/internal/sync/prompt/attachment_versions.go +++ b/internal/sync/prompt/attachment_versions.go @@ -19,6 +19,15 @@ type attachmentResolver struct { client syncapi.Client resolved map[ResourceID]syncdomain.Attachment failures map[ResourceID]error + reviewed map[ResourceID]reviewedAttachment +} + +// reviewedAttachment is the LaunchDarkly side of a linked tool or skill row +// as the review saw it: the source of the row and the fingerprint of the +// mapped fields. +type reviewedAttachment struct { + source syncdomain.Reference + fingerprint string } func newAttachmentResolver(client syncapi.Client) *attachmentResolver { @@ -26,7 +35,41 @@ func newAttachmentResolver(client syncapi.Client) *attachmentResolver { client: client, resolved: make(map[ResourceID]syncdomain.Attachment), failures: make(map[ResourceID]error), + reviewed: make(map[ResourceID]reviewedAttachment), + } +} + +// review records the reviewed LaunchDarkly side of a linked row, so that a +// publish stops when the mapped fields change after the review, as a linked +// variation push does. +func (resolver *attachmentResolver) review(resource PlannedResource) { + if resource.Source == nil || resource.ServerAttachment == nil { + return + } + resolver.reviewed[resource.ID] = reviewedAttachment{source: *resource.Source, fingerprint: resource.ServerFingerprint} +} + +// checkReviewed makes sure that the mapped fields of the latest LaunchDarkly +// copy of a linked tool or skill still have the reviewed content. +func (resolver *attachmentResolver) checkReviewed(id ResourceID, remote syncdomain.Attachment) error { + reviewed, ok := resolver.reviewed[id] + if !ok { + return nil + } + projection, err := syncdomain.ProjectAttachment(remote, reviewed.source) + if err != nil { + return err + } + fingerprint, err := syncdomain.FingerprintProjection(id, projection) + if err != nil { + return err + } + if fingerprint != reviewed.fingerprint { + return syncdomain.NewProblem( + syncdomain.ErrorStateChanged, "the mapped fields changed in LaunchDarkly after review", "Run sync again.", + ).WithResource(id) } + return nil } // settle records the result of a tool or skill row. A pull settles the @@ -46,9 +89,11 @@ func (resolver *attachmentResolver) settle(id ResourceID, attachment syncdomain. // before it pulled. If a tool or skill of the row failed, the row fails too. func (resolver *attachmentResolver) settledRow(resource PlannedResource) (PlannedResource, error) { side, fingerprint := &resource.Local, &resource.LocalFingerprint + localSide := true switch { case resource.Action == ActionUpdateLocal: side, fingerprint = &resource.Server, &resource.ServerFingerprint + localSide = false case !resource.Action.changesServer(): return resource, nil } @@ -67,7 +112,37 @@ func (resolver *attachmentResolver) settledRow(resource PlannedResource) (Planne } variation := **side variation.Attachments = attachments - value, err := syncdomain.FingerprintVariation(resource.ID.ProjectKey, resource.ID.LookupKey, variation) + var value string + var err error + if resource.Local != nil && resource.Source == nil { + // A settled linked tool or skill holds the full LaunchDarkly copy. A + // managed variation compares it on the mapped fields, as the plan + // did. + if variation, err = alignLinkedAttachments(variation, resource.Local.Attachments); err != nil { + return resource, err + } + if localSide { + // The aligned copy stands in for the local side, so each linked + // tool or skill keeps its projection. A publish merges the + // mapped fields onto the latest LaunchDarkly copy through it, + // and writes a copy without one whole. + keepProjections(variation.Attachments, resource.Local.Attachments) + } + } + if resource.Source != nil { + projection, projectionErr := syncdomain.ProjectVariation(variation, *resource.Source) + if projectionErr != nil { + return resource, projectionErr + } + value, err = syncdomain.FingerprintProjection(resource.ID, projection) + if localSide { + resource.LocalProjection = projection + } else { + resource.ServerProjection = projection + } + } else { + value, err = syncdomain.FingerprintVariation(resource.ID.ProjectKey, resource.ID.LookupKey, variation) + } if err != nil { return resource, err } @@ -93,6 +168,13 @@ func (resolver *attachmentResolver) settledAttachments( return result, changed } +// synced reports whether this execution settled a tool or skill: a row +// pulled it, pushed it, or found it in sync. +func (resolver *attachmentResolver) synced(id ResourceID) bool { + _, ok := resolver.resolved[id] + return ok +} + // resolveVariation returns a copy of the variation that pins the published // version of each attachment. func (resolver *attachmentResolver) resolveVariation(projectKey string, variation syncdomain.Variation) (syncdomain.Variation, error) { @@ -126,6 +208,26 @@ func (resolver *attachmentResolver) resolve(projectKey string, local syncdomain. func (resolver *attachmentResolver) publish(projectKey string, local syncdomain.Attachment, create bool) (syncdomain.Attachment, error) { remote, readErr := resolver.client.ReadAttachment(projectKey, local.Kind, local.Key()) + if readErr == nil && local.Projection != nil { + id := local.ID(projectKey) + if err := resolver.checkReviewed(id, remote); err != nil { + return syncdomain.Attachment{}, err + } + merged, err := syncdomain.ApplyAttachmentProjection(remote, local.Projection) + if err != nil { + return syncdomain.Attachment{}, err + } + if _, reviewed := resolver.reviewed[id]; !reviewed && !syncdomain.SameAttachmentContent(merged, remote) { + // A linked tool or skill without a row was in sync at the + // review, so a difference now is a change in LaunchDarkly after + // it, which a variation push must not write over. + return syncdomain.Attachment{}, syncdomain.NewProblem( + syncdomain.ErrorStateChanged, "the mapped fields changed in LaunchDarkly after review", "Run sync again.", + ).WithResource(id) + } + merged.Upsert = local.Upsert + local = merged + } var writeErr error switch { case readErr == nil && syncdomain.SameAttachmentContent(local, remote): @@ -137,6 +239,11 @@ func (resolver *attachmentResolver) publish(projectKey string, local syncdomain. writeErr = resolver.client.UpdateAttachment(projectKey, local) case syncapi.IsNotFound(readErr) && local.Upsert: writeErr = resolver.client.CreateAttachment(projectKey, local) + case syncapi.IsNotFound(readErr) && local.Projection != nil: + // A descriptor has no upsert, so a linked tool or skill that + // LaunchDarkly lost after the review gets the recovery of the + // plan: create it in LaunchDarkly, or detach. + return syncdomain.Attachment{}, errors.New(missingAttachmentError(local.ID(projectKey), true)) case syncapi.IsNotFound(readErr): return syncdomain.Attachment{}, errors.New(missingAttachment(local.ID(projectKey))) default: @@ -166,3 +273,16 @@ func (resolver *attachmentResolver) publish(projectKey string, local syncdomain. } return observed, nil } + +// keepProjections gives each attachment the projection of the local +// attachment with the same key. +func keepProjections(attachments, local []syncdomain.Attachment) { + for index, attachment := range attachments { + for _, source := range local { + if source.Kind == attachment.Kind && source.Key() == attachment.Key() { + attachments[index].Projection = source.Projection + break + } + } + } +} diff --git a/internal/sync/prompt/capability_test.go b/internal/sync/prompt/capability_test.go new file mode 100644 index 000000000..3a17b4b79 --- /dev/null +++ b/internal/sync/prompt/capability_test.go @@ -0,0 +1,156 @@ +package prompt + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +func TestUnchangedReadOnlyToolFieldBlocksCompleteResource(t *testing.T) { + id := ResourceID{Kind: syncdomain.KindTool, ProjectKey: "project", LookupKey: "lookup"} + oldDescription, newDescription := "Old", "New" + schema := map[string]any{"type": "object"} + reference := &syncdomain.Reference{ + File: "tools.ts", Format: "typescript", + Map: map[string]string{"description": "description", "schema": "inputSchema"}, + } + server := syncdomain.Attachment{ + Kind: syncdomain.AttachmentTool, + Tool: &syncdomain.Tool{Key: "lookup", Description: &oldDescription, Schema: schema}, + } + serverProjection, err := syncdomain.ProjectAttachment(server, *reference) + require.NoError(t, err) + baseline, err := syncdomain.FingerprintProjection(id, serverProjection) + require.NoError(t, err) + local := syncdomain.SyncedAttachment{ + ProjectKey: "project", + Attachment: syncdomain.Attachment{ + Kind: syncdomain.AttachmentTool, + Tool: &syncdomain.Tool{Key: "lookup", Description: &newDescription}, + }, + Source: reference, + Projection: syncdomain.ToolProjection{ + Description: &newDescription, + FieldStates: map[syncdomain.Field]syncdomain.FieldState{ + syncdomain.FieldDescription: {Capability: syncdomain.CapabilityDirect, Known: true}, + syncdomain.FieldSchema: { + Capability: syncdomain.CapabilityReadOnly, Reason: "helper call", + }, + }, + }, + } + + resource := planSyncedAttachment(id, &local, &server, baseline, true) + + assert.Equal(t, ActionError, resource.Action) + require.NotNil(t, resource.Problem) + assert.Equal(t, syncdomain.ErrorFieldReadOnly, resource.Problem.Code) + assert.Contains(t, resource.Problem.Message, `field "schema"`) + assert.Equal(t, schema, *resource.LocalProjection.(syncdomain.ToolProjection).Schema) + state, ok := resource.LocalProjection.States()[syncdomain.FieldSchema] + require.True(t, ok) + assert.Equal(t, syncdomain.CapabilityReadOnly, state.Capability) + assert.False(t, state.Known) + + local.Attachment.Tool.Description = &oldDescription + local.Projection = syncdomain.ToolProjection{ + Description: &oldDescription, + FieldStates: map[syncdomain.Field]syncdomain.FieldState{ + syncdomain.FieldDescription: {Capability: syncdomain.CapabilityDirect, Known: true}, + syncdomain.FieldSchema: { + Capability: syncdomain.CapabilityReadOnly, Reason: "helper call", + }, + }, + } + server.Tool.Description = &newDescription + resource = planSyncedAttachment(id, &local, &server, baseline, true) + assert.Equal(t, ActionError, resource.Action) + require.NotNil(t, resource.Problem) + assert.Equal(t, syncdomain.ErrorFieldReadOnly, resource.Problem.Code) +} + +func TestDynamicPromptBlocksVariationPush(t *testing.T) { + id := syncdomain.VariationID("project", "config", "default") + reference := &syncdomain.Reference{ + File: "agents.py", Format: "python", + Map: map[string]string{"prompt": "instructions", "model": "model"}, + } + oldModel, newModel, serverPrompt := "gpt-4", "gpt-5", "Server prompt" + server := syncdomain.Variation{ + Mode: syncdomain.VariationModeAgent, Key: "default", Name: "Default", + Instructions: serverPrompt, Model: map[string]any{"modelName": oldModel}, + } + serverProjection, err := syncdomain.ProjectVariation(server, *reference) + require.NoError(t, err) + baseline, err := syncdomain.FingerprintProjection(id, serverProjection) + require.NoError(t, err) + local := syncdomain.SyncedResource{ + Kind: id.Kind, ProjectKey: id.ProjectKey, LookupKey: id.LookupKey, + Source: reference, + Variation: syncdomain.Variation{ + Mode: syncdomain.VariationModeAgent, Key: "default", Name: "Default", + Model: map[string]any{"modelName": newModel}, + }, + Projection: syncdomain.VariationProjection{ + Mode: syncdomain.VariationModeAgent, Model: &newModel, + FieldStates: map[syncdomain.Field]syncdomain.FieldState{ + syncdomain.FieldPrompt: { + Capability: syncdomain.CapabilityReadOnly, Reason: "f-string", + }, + syncdomain.FieldModel: {Capability: syncdomain.CapabilityDirect, Known: true}, + }, + }, + } + + resource := planResource( + id, &local, ServerResource{Variation: &server, ConfigMode: syncdomain.VariationModeAgent}, + baseline, true, + ) + + assert.Equal(t, ActionError, resource.Action) + require.NotNil(t, resource.Problem) + assert.Equal(t, syncdomain.ErrorFieldReadOnly, resource.Problem.Code) + assert.Contains(t, resource.Problem.Message, `field "prompt"`) +} + +func TestUnresolvedImportSuggestsExactLinkCommand(t *testing.T) { + id := syncdomain.VariationID("acme", "support", "default") + reference := &syncdomain.Reference{ + File: "app/agent.py", Format: "python", Map: map[string]string{"prompt": "instructions"}, + } + server := syncdomain.Variation{ + Mode: syncdomain.VariationModeAgent, Key: "default", Name: "Default", + Instructions: "Server prompt", + } + local := syncdomain.SyncedResource{ + Kind: id.Kind, ProjectKey: id.ProjectKey, LookupKey: id.LookupKey, Source: reference, + Variation: syncdomain.Variation{ + Mode: syncdomain.VariationModeAgent, Key: "default", Name: "Default", + }, + Projection: syncdomain.VariationProjection{ + Mode: syncdomain.VariationModeAgent, + FieldStates: map[syncdomain.Field]syncdomain.FieldState{ + syncdomain.FieldPrompt: { + Capability: syncdomain.CapabilityReadOnly, + Reason: "import cannot be resolved", Code: syncdomain.ErrorImportUnresolved, + File: "app/prompts.py", + }, + }, + }, + } + + resource := planResource( + id, &local, ServerResource{Variation: &server, ConfigMode: syncdomain.VariationModeAgent}, + "", false, + ) + + require.NotNil(t, resource.Problem) + assert.Equal(t, syncdomain.ErrorImportUnresolved, resource.Problem.Code) + assert.Equal(t, + "Link the imported value directly: ldcli sync prompts link --file app/prompts.py --format python --project acme --config-key support --key default", + resource.Problem.Suggestion, + ) +} diff --git a/internal/sync/prompt/conflict.go b/internal/sync/prompt/conflict.go index 4fe26f819..271a8a750 100644 --- a/internal/sync/prompt/conflict.go +++ b/internal/sync/prompt/conflict.go @@ -321,8 +321,12 @@ func applyConflictResolutions(plan Plan, resolutions map[ResourceID]ConflictReso if !chosenSideUses(resource, resolutions) { continue } - if resolution, ok := resolutions[resource.ID]; ok { + if resolution, ok := resolutions[resource.ID]; ok && !settingDecides(resource) { resource.Action = resolvedConflictAction(resource, resolution) + if message := againstSetting(resource); message != "" { + resource.Action, resource.Error = ActionError, message + } + blockLinkedDelete(&resource) } resolved.Resources = append(resolved.Resources, resource) } @@ -330,6 +334,27 @@ func applyConflictResolutions(plan Plan, resolutions map[ResourceID]ConflictReso return resolved } +// againstSetting returns the error of a resolved variation whose chosen side +// its LaunchDarkly sync setting forbids, or an empty string. +func againstSetting(resource PlannedResource) string { + switch { + case resource.Direction == syncdomain.SyncDirectionServerCanonical && resource.Action.changesServer(): + return "server-canonical resource cannot push; choose launchdarkly" + case resource.Direction == syncdomain.SyncDirectionCodeCanonical && resource.Action.changesLocal(): + return "code-canonical resource cannot pull; choose local" + } + return "" +} + +// settingDecides reports whether the LaunchDarkly sync setting of a linked +// tool or skill fixed the action of its row. The setting outranks a conflict +// choice, so a server-canonical tool is never pushed because a variation +// that uses it chose the local side. +func settingDecides(resource PlannedResource) bool { + return resource.ID.Kind != syncdomain.KindVariation && resource.Action != ActionConflict && + resource.Direction != "" && resource.Direction != syncdomain.SyncDirectionBoth +} + // chosenSideUses reports whether a row stays after the conflict choices. A // row that only conflicts need stays when the side that the user chose for // one of them uses its tool or skill. Then the row keeps its own action, so diff --git a/internal/sync/prompt/diff.go b/internal/sync/prompt/diff.go index 8e37710b2..b9ab4a05c 100644 --- a/internal/sync/prompt/diff.go +++ b/internal/sync/prompt/diff.go @@ -46,6 +46,15 @@ func attachmentDiff(before, after *syncdomain.Attachment) variationDiffFields { return diffParts(nil, nil, beforeAttachments, afterAttachments) } +func projectionDiff(before, after syncdomain.Projection) variationDiffFields { + beforeJSON, _ := json.Marshal(before) + afterJSON, _ := json.Marshal(after) + if string(beforeJSON) == string(afterJSON) { + return nil + } + return variationDiffFields{"projection": {Before: beforeJSON, After: afterJSON}} +} + // diffParts returns each part that differs, or nil. func diffParts(beforeVariation, afterVariation json.RawMessage, beforeAttachments, afterAttachments []syncdomain.Attachment) variationDiffFields { fields := variationDiffFields{} @@ -143,7 +152,7 @@ func changedAttachmentIDs(resource PlannedResource) []ResourceID { var changed []ResourceID for id, attachment := range before { - if other, exists := after[id]; !exists || !syncdomain.SameAttachmentContent(attachment, other) { + if other, exists := after[id]; !exists || attachmentContentChanged(attachment, other) { changed = append(changed, id) } } @@ -156,6 +165,63 @@ func changedAttachmentIDs(resource PlannedResource) []ResourceID { return changed } +// attachmentContentChanged reports whether a local tool or skill differs from +// the LaunchDarkly copy. A linked one owns only its mapped fields, so a +// difference in a field that LaunchDarkly alone holds does not count. +func attachmentContentChanged(server, local syncdomain.Attachment) bool { + if local.Projection != nil { + if merged, err := syncdomain.ApplyAttachmentProjection(server, local.Projection); err == nil { + return !syncdomain.SameAttachmentContent(server, merged) + } + } + return !syncdomain.SameAttachmentContent(server, local) +} + +// alignLinkedAttachments returns the variation with each tool or skill that +// the repository links reduced to the fields that the link maps, with the +// values of the variation. local holds the local attachments, where a linked +// one carries its projection. A fingerprint of the aligned variation then +// compares only the fields that the link owns, so a field that LaunchDarkly +// alone holds, such as customParameters, does not keep a managed variation +// out of sync. A managed tool or skill stays whole. +func alignLinkedAttachments(variation syncdomain.Variation, local []syncdomain.Attachment) (syncdomain.Variation, error) { + linked := make(map[string]syncdomain.Attachment) + for _, attachment := range local { + if attachment.Projection != nil { + linked[string(attachment.Kind)+"/"+attachment.Key()] = attachment + } + } + if len(linked) == 0 { + return variation, nil + } + attachments := slices.Clone(variation.Attachments) + for index, attachment := range attachments { + localAttachment, ok := linked[string(attachment.Kind)+"/"+attachment.Key()] + if !ok { + continue + } + reference := syncdomain.Reference{Map: make(map[string]string)} + for field := range localAttachment.Projection.States() { + reference.Map[string(field)] = string(field) + } + projected, err := syncdomain.ProjectAttachment(attachment, reference) + if err != nil { + return variation, err + } + // The local copy has the shape of the link. The values of the + // mapped fields come from the variation. + base := localAttachment + base.Version, base.Upsert, base.Projection = attachment.Version, attachment.Upsert, nil + aligned, err := syncdomain.ApplyAttachmentProjection(base, projected) + if err != nil { + return variation, err + } + attachments[index] = aligned + } + variation.Attachments = attachments + return variation, nil +} + func attachmentsByID(projectKey string, variation *syncdomain.Variation) map[ResourceID]syncdomain.Attachment { attachments := map[ResourceID]syncdomain.Attachment{} if variation != nil { diff --git a/internal/sync/prompt/direction_test.go b/internal/sync/prompt/direction_test.go new file mode 100644 index 000000000..c345aa82e --- /dev/null +++ b/internal/sync/prompt/direction_test.go @@ -0,0 +1,161 @@ +package prompt + +import ( + "net/url" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/launchdarkly/ldcli/internal/resources" + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + syncapi "github.com/launchdarkly/ldcli/internal/sync/api" + synclocal "github.com/launchdarkly/ldcli/internal/sync/local" + syncmanifest "github.com/launchdarkly/ldcli/internal/sync/manifest" +) + +type emptySettingsTransport struct{} + +var _ resources.Client = emptySettingsTransport{} + +func (emptySettingsTransport) MakeRequest( + string, string, string, string, url.Values, []byte, bool, +) ([]byte, error) { + return []byte(`{"items":[]}`), nil +} + +func (emptySettingsTransport) MakeUnauthenticatedRequest(string, string, []byte) ([]byte, error) { + return nil, nil +} + +func TestEnforceDirectionUsesLaunchDarklySetting(t *testing.T) { + local := testVariation("local") + server := testVariation("server") + source := &syncdomain.Reference{File: "agents.py", Format: "python"} + + codeCanonical := PlannedResource{ + ID: testResourceID(), Action: ActionUpdateLocal, Local: &local, Server: &server, Source: source, + } + enforceDirection(&codeCanonical, syncdomain.SyncDirectionCodeCanonical) + assert.Equal(t, ActionUpdateServer, codeCanonical.Action) + + serverCanonical := PlannedResource{ + ID: testResourceID(), Action: ActionUpdateServer, Local: &local, Server: &server, Source: source, + } + enforceDirection(&serverCanonical, syncdomain.SyncDirectionServerCanonical) + assert.Equal(t, ActionUpdateLocal, serverCanonical.Action) +} + +func TestDeletedLinkedResourceRequiresDetach(t *testing.T) { + resource := PlannedResource{ + ID: testResourceID(), Action: ActionDeleteLocal, + Source: &syncdomain.Reference{File: "agents.py", Format: "python"}, + } + + enforceDirection(&resource, syncdomain.SyncDirectionBoth) + + assert.Equal(t, ActionError, resource.Action) + require.NotNil(t, resource.Problem) + assert.Equal(t, syncdomain.ErrorResourceDeleted, resource.Problem.Code) + assert.Equal(t, + "Run ldcli sync prompts detach --project production --config-key support --key default.", + resource.Problem.Suggestion, + ) + + resource.Action, resource.Error, resource.Problem = ActionDeleteLocal, "", nil + enforceDirection(&resource, syncdomain.SyncDirectionCodeCanonical) + assert.Equal(t, ActionError, resource.Action) + assert.Equal(t, syncdomain.ErrorResourceDeleted, resource.Problem.Code) + + toolID := ResourceID{Kind: syncdomain.KindTool, ProjectKey: "acme", LookupKey: "refund"} + tool := PlannedResource{ + ID: toolID, Action: ActionDeleteLocal, + Source: &syncdomain.Reference{File: "tools.json", Format: "json"}, + } + enforceDirection(&tool, syncdomain.SyncDirectionCodeCanonical) + assert.Equal(t, + "Run ldcli sync tools detach --project acme --key refund.", + tool.Problem.Suggestion, + ) +} + +func TestMissingToolSettingIsActionable(t *testing.T) { + id := ResourceID{Kind: syncdomain.KindTool, ProjectKey: "project", LookupKey: "lookup"} + resources := []PlannedResource{{ + ID: id, Source: &syncdomain.Reference{File: "tools.json", Format: "json"}, + }} + client := syncapi.NewClient(emptySettingsTransport{}, "token", "https://example.com") + + err := applySourceDirections(client, resources) + + problem, ok := syncdomain.ProblemFrom(err) + require.True(t, ok) + assert.Equal(t, syncdomain.ErrorSyncSettingUnavailable, problem.Code) + assert.Equal(t, id.Label(), problem.Resource) + assert.Contains(t, problem.Suggestion, "does not provide a tool sync setting") +} + +func TestFormatOneBaselineWithoutOptionalMetadataRemainsReadable(t *testing.T) { + resource := PlannedResource{ + ID: testResourceID(), + Action: ActionInSync, + Source: &syncdomain.Reference{ + File: "agents.py", Format: "python", Select: `agents[id="support"]`, + Map: map[string]string{"prompt": "instructions"}, + }, + } + baseline := syncmanifest.Resource{ + ResourceKind: resource.ID.Kind, + ProjectKey: resource.ID.ProjectKey, + LookupKey: resource.ID.LookupKey, + Fingerprint: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + } + + checkSourceBaseline(&resource, baseline, true) + + assert.Equal(t, ActionInSync, resource.Action) + assert.Nil(t, resource.Problem) +} + +func TestRecordAttachmentStateKeepsProjectionFingerprint(t *testing.T) { + root := t.TempDir() + source := `import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +const server = new McpServer({ name: "orders", version: "1" }); +server.registerTool("lookup", { + description: "Look up an order.", + inputSchema: { type: "object" }, +}, handler); +` + require.NoError(t, os.WriteFile(filepath.Join(root, "tools.ts"), []byte(source), 0o644)) + id := syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "project", LookupKey: "lookup"} + reference := syncdomain.Reference{ + File: "tools.ts", Format: "typescript", Select: `tools[id="lookup"]`, + Map: map[string]string{"description": "description"}, + } + content, err := synclocal.RenderDescriptor(id, reference) + require.NoError(t, err) + descriptor, err := synclocal.ReferencePath(id) + require.NoError(t, err) + descriptor = filepath.Join(root, filepath.FromSlash(descriptor)) + require.NoError(t, os.MkdirAll(filepath.Dir(descriptor), 0o755)) + require.NoError(t, os.WriteFile(descriptor, content, 0o644)) + description := "Look up an order." + attachment := syncdomain.Attachment{ + Kind: syncdomain.AttachmentTool, + Tool: &syncdomain.Tool{ + Key: "lookup", Description: &description, Schema: map[string]any{"type": "object"}, + }, + } + manifest := syncmanifest.New() + + err = recordAttachmentState(&manifest, root, "project", []syncdomain.Attachment{attachment}, func(ResourceID) bool { return true }) + + require.NoError(t, err) + require.Len(t, manifest.Resources, 1) + assert.Equal(t, syncdomain.ProjectionFingerprintSchema, manifest.Resources[0].FingerprintSchema) + full, err := syncdomain.FingerprintAttachment("project", attachment) + require.NoError(t, err) + assert.NotEqual(t, full, manifest.Resources[0].Fingerprint) +} diff --git a/internal/sync/prompt/execute.go b/internal/sync/prompt/execute.go index a52581e2c..a2b4d0a0d 100644 --- a/internal/sync/prompt/execute.go +++ b/internal/sync/prompt/execute.go @@ -3,9 +3,12 @@ package prompt import ( "errors" "fmt" + "maps" + "path/filepath" "slices" syncdomain "github.com/launchdarkly/ldcli/internal/sync" + syncadapter "github.com/launchdarkly/ldcli/internal/sync/adapter" syncapi "github.com/launchdarkly/ldcli/internal/sync/api" synclocal "github.com/launchdarkly/ldcli/internal/sync/local" syncmanifest "github.com/launchdarkly/ldcli/internal/sync/manifest" @@ -34,18 +37,34 @@ func executePlan( outcomes := make([]ResourceOutcome, len(plan.Resources)) var failures []error attachments := newAttachmentResolver(client) + handled := make(map[int]bool) execute := func(index int) { + if handled[index] { + return + } resource := plan.Resources[index] outcome := ResourceOutcome{ID: resource.ID, Action: resource.Action, Status: OutcomeSucceeded} fail := func(err error) { outcome.Status, outcome.Error = OutcomeFailed, err.Error() failures = append(failures, fmt.Errorf("%s: %w", outcome.ID, err)) } + var pinFailure error switch { case resource.Action == ActionInSync: + settleUnchangedAttachment(attachments, resource) + // A relink changes only the descriptor, so the lock records the + // new mapping digest, adapter behavior, and indexed selections. + if resource.Source != nil { + if err := recordSource(&next, resource.ID, resource.Source, resource.IndexedSelections); err != nil { + fail(err) + } + } case resource.Action == ActionUpdateManifest: - next.SetFingerprint(resource.ID, resource.LocalFingerprint) + settleUnchangedAttachment(attachments, resource) + if err := recordSuccessfulChange(&next, resource); err != nil { + fail(err) + } case resource.Action == ActionRemoveManifest: next.Remove(resource.ID) case resource.Action.changesServer() || resource.Action.changesLocal(): @@ -63,12 +82,21 @@ func executePlan( } else { version, err = applyAttachmentChange(repositoryRoot, localStore, attachments, resource) } + // A failed pin update follows a local write that succeeded, so + // the lock records the pull below and the row fails at the end. + var pinRefresh *pinRefreshError + if errors.As(err, &pinRefresh) { + pinFailure, err = pinRefresh, nil + } if err != nil { fail(err) break } outcome.pins, outcome.Version = pins, version - recordSuccessfulChange(&next, resource) + if err := recordSuccessfulChange(&next, resource); err != nil { + fail(err) + break + } default: outcome.Status, outcome.Error = OutcomeSkipped, "resource is not executable" } @@ -76,25 +104,112 @@ func executePlan( if outcome.Status == OutcomeSucceeded { if variation := attachmentManifestState(resource); variation != nil { settled, _ := attachments.settledAttachments(resource.ID.ProjectKey, variation.Attachments) - if err := next.SetAttachments(resource.ID.ProjectKey, settled); err != nil { + // A row that synced nothing records only the tools and + // skills that the lock does not have yet. A tool or skill + // that this run did not sync keeps its baseline, so that a + // local edit to it stays a local change. + if !resource.Action.changesServer() && !resource.Action.changesLocal() { + settled = slices.DeleteFunc(settled, func(attachment syncdomain.Attachment) bool { + return next.Has(attachment.ID(resource.ID.ProjectKey)) + }) + } + if err := recordAttachmentState(&next, repositoryRoot, resource.ID.ProjectKey, settled, attachments.synced); err != nil { fail(err) } } } + if pinFailure != nil && outcome.Status == OutcomeSucceeded { + // The files and the lock hold the pull. The pins in LaunchDarkly + // are still stale, so the next run moves them again. + fail(pinFailure) + } outcomes[index] = outcome } - // The plan puts tools and skills before the variations that pin them. A - // deletion of a tool or a skill runs last instead, so that the pull of a - // variation that drops the reference runs first. + // Settle tools and skills before linked prompt pulls. A failed dependency + // must block its prompt before the prompt changes any repository file. var deletions []int for index, resource := range plan.Resources { - if resource.ID.Kind != syncdomain.KindVariation && resource.Action == ActionDeleteLocal { + if resource.ID.Kind == syncdomain.KindVariation || + resource.Action == ActionUpdateLocal && resource.Source != nil { + continue + } + if resource.Action == ActionDeleteLocal { deletions = append(deletions, index) continue } execute(index) } + + // Resources that can edit the same physical file form one atomic group. + // An error blocks only that group, so unrelated source files can continue. + for _, indexes := range sourcePullGroups(repositoryRoot, plan.Resources) { + resources, replacements, groupErr := settleSourcePulls(plan.Resources, indexes, attachments) + if groupErr == nil { + _, groupErr = localStore.ReplaceSources(replacements) + } + if groupErr == nil { + // The written files must compile to the content that LaunchDarkly + // has for every member, as a managed pull is checked. The group + // wrote its files as one unit, so one mismatch fails the group + // before the lock records any member against the new files. + groupErr = verifySourceGroup(repositoryRoot, resources) + } + for position, index := range indexes { + resource := resources[position] + outcome := ResourceOutcome{ID: resource.ID, Action: resource.Action, Status: OutcomeSucceeded} + switch { + case groupErr != nil: + outcome.Status, outcome.Error = OutcomeFailed, groupErr.Error() + if resource.ID.Kind != syncdomain.KindVariation { + attachments.settle(resource.ID, syncdomain.Attachment{}, groupErr) + } + default: + // The files hold the pull, so the lock records it before the + // pin update, which can fail on its own. + if err := recordSuccessfulChange(&next, resource); err != nil { + outcome.Status, outcome.Error = OutcomeFailed, err.Error() + failures = append(failures, fmt.Errorf("%s: %w", resource.ID, err)) + break + } + if resource.ID.Kind != syncdomain.KindVariation && resource.ServerAttachment != nil { + attachments.settle(resource.ID, *resource.ServerAttachment, nil) + } + if resource.Server != nil { + // The settled row holds the content that the rows + // before it gave each tool and skill. + err := recordAttachmentState( + &next, repositoryRoot, resource.ID.ProjectKey, resource.Server.Attachments, attachments.synced, + ) + if err != nil { + outcome.Status, outcome.Error = OutcomeFailed, err.Error() + failures = append(failures, fmt.Errorf("%s: %w", resource.ID, err)) + break + } + } + pins, pinErr := refreshLinkedPins(client, resource) + outcome.pins = pins + if pinErr != nil { + // The pins in LaunchDarkly are still stale, so the next + // run moves them again. + outcome.Status, outcome.Error = OutcomeFailed, pinErr.Error() + failures = append(failures, fmt.Errorf("%s: %w", resource.ID, pinErr)) + } + } + outcomes[index], handled[index] = outcome, true + } + if groupErr != nil { + failures = append(failures, groupErr) + } + } + + for index, resource := range plan.Resources { + if resource.ID.Kind == syncdomain.KindVariation { + execute(index) + } + } + + // A deletion runs last, so that a variation pull can drop its reference. for _, index := range deletions { execute(index) } @@ -102,6 +217,294 @@ func executePlan( return outcomes, next, errors.Join(failures...) } +// verifySourceGroup checks the local result of every member of a document +// group, and returns the first mismatch with the resource that it belongs to. +func verifySourceGroup(repositoryRoot string, resources []PlannedResource) error { + for _, resource := range resources { + if err := verifyLocalResult(repositoryRoot, resource); err != nil { + return fmt.Errorf("%s: %w", resource.ID, err) + } + } + return nil +} + +// settleSourcePulls resolves the attachment versions of each variation in a +// document group and builds its source replacements. On an error, every row +// keeps its planned state, so the caller can report the group as failed. +func settleSourcePulls( + resources []PlannedResource, + indexes []int, + attachments *attachmentResolver, +) ([]PlannedResource, []synclocal.SourceReplacement, error) { + settled := make([]PlannedResource, len(indexes)) + replacements := make([]synclocal.SourceReplacement, 0, len(indexes)) + var groupErr error + for position, index := range indexes { + resource := resources[index] + settled[position] = resource + if groupErr != nil { + continue + } + if resource.ID.Kind == syncdomain.KindVariation { + if resource, groupErr = attachments.settledRow(resource); groupErr != nil { + continue + } + settled[position] = resource + } + replacement, err := sourcePullReplacement(resource) + if err != nil { + groupErr = err + continue + } + replacements = append(replacements, replacement) + } + return settled, replacements, groupErr +} + +func sourcePullReplacement(resource PlannedResource) (synclocal.SourceReplacement, error) { + // A source edit can replace a value but not remove it. If LaunchDarkly + // has no value for a mapped field, the pull cannot make the source match, + // so it stops here instead of recording a state that the next plan + // would see as a local edit. + if field, missing := missingServerField(resource.LocalProjection, resource.ServerProjection); missing { + return synclocal.SourceReplacement{}, syncdomain.NewProblem( + syncdomain.ErrorSourceNotWritable, + fmt.Sprintf("LaunchDarkly has no value for mapped field %q, so the pull cannot clear it", field), + "Set the field in LaunchDarkly, or remove it from the map and link again.", + ).WithResource(resource.ID).WithFile(resource.Source.File, 0, 0) + } + projection, err := syncdomain.ChangedProjection(resource.LocalProjection, resource.ServerProjection) + if err != nil { + return synclocal.SourceReplacement{}, err + } + var relationshipAttachments []syncdomain.Attachment + if resource.Server != nil { + relationshipAttachments = slices.Clone(resource.Server.Attachments) + } + return synclocal.SourceReplacement{ + ID: resource.ID, Reference: *resource.Source, + Projection: projection, ExpectedFingerprint: resource.LocalFingerprint, + ExpectedProjection: resource.LocalProjection, + ExpectedIndexedSelections: resource.IndexedSelections, + RelationshipAttachments: relationshipAttachments, + }, nil +} + +// resolvedSourcePath returns the absolute path of the file that a +// repository-relative source path resolves to. A path that does not resolve +// keeps its absolute form, so every group key has one form. +func resolvedSourcePath(repositoryRoot, file string) string { + absolute := filepath.Join(repositoryRoot, filepath.FromSlash(file)) + resolved, err := filepath.EvalSymlinks(absolute) + if err != nil { + return absolute + } + return resolved +} + +// missingServerField returns the first mapped field, in name order, that the +// local projection has a value for and the server projection does not. +func missingServerField(local, server syncdomain.Projection) (syncdomain.Field, bool) { + return missingField(local, server) +} + +// checkSourcePushClears stops a push whose source has no value for a mapped +// field that LaunchDarkly has a value for. A push merges the mapped values +// onto the LaunchDarkly copy and cannot clear a field, so the push would +// record a state that the next plan would pull back into the source. +func checkSourcePushClears(resource PlannedResource) error { + if field, missing := missingField(resource.ServerProjection, resource.LocalProjection); missing { + return syncdomain.NewProblem( + syncdomain.ErrorSourceNotWritable, + fmt.Sprintf("the source has no value for mapped field %q, so the push cannot clear it", field), + "Set the field in the source, or remove it from the map and link again.", + ).WithResource(resource.ID).WithFile(resource.Source.File, 0, 0) + } + return nil +} + +// missingField returns the first mapped field, in name order, that the +// first projection has a value for and the second does not. Both projections +// come from one reference, so they map the same fields, but the walk covers +// the fields of both, so a field that only one side knows still counts. +func missingField(from, to syncdomain.Projection) (syncdomain.Field, bool) { + if from == nil || to == nil { + return "", false + } + fields := maps.Clone(from.States()) + maps.Copy(fields, to.States()) + for _, field := range slices.Sorted(maps.Keys(fields)) { + _, fromOK := from.Value(field) + _, toOK := to.Value(field) + if fromOK && !toOK { + return field, true + } + } + return "", false +} + +// sourcePullGroups groups the pull rows that can edit one file. Two +// descriptors can name one file two ways, for example through a symbolic +// link, so a file is known by its resolved path. +func sourcePullGroups(repositoryRoot string, resources []PlannedResource) [][]int { + var indexes []int + var files []map[string]struct{} + for index, resource := range resources { + if resource.Action != ActionUpdateLocal || resource.Source == nil { + continue + } + indexes = append(indexes, index) + groupFiles := map[string]struct{}{resolvedSourcePath(repositoryRoot, resource.Source.File): {}} + for _, dependency := range resource.SourceDependencies { + groupFiles[resolvedSourcePath(repositoryRoot, dependency)] = struct{}{} + } + files = append(files, groupFiles) + } + parent := make([]int, len(indexes)) + for index := range parent { + parent[index] = index + } + var root func(int) int + root = func(index int) int { + if parent[index] != index { + parent[index] = root(parent[index]) + } + return parent[index] + } + for left := range files { + for right := left + 1; right < len(files); right++ { + overlap := false + for file := range files[left] { + if _, overlap = files[right][file]; overlap { + break + } + } + if overlap { + parent[root(right)] = root(left) + } + } + } + positions := make(map[int]int) + var groups [][]int + for position, index := range indexes { + groupRoot := root(position) + group, ok := positions[groupRoot] + if !ok { + group = len(groups) + positions[groupRoot] = group + groups = append(groups, nil) + } + groups[group] = append(groups[group], index) + } + return groups +} + +func settleUnchangedAttachment(resolver *attachmentResolver, resource PlannedResource) { + if resource.ID.Kind == syncdomain.KindVariation || resource.ServerAttachment == nil { + return + } + attachment := *resource.ServerAttachment + if resource.Source != nil && resource.LocalProjection != nil { + if merged, err := syncdomain.ApplyAttachmentProjection(attachment, resource.LocalProjection); err == nil { + merged.Version = attachment.Version + attachment = merged + } + } + resolver.settle(resource.ID, attachment, nil) +} + +// recordAttachmentState records the baseline of each tool and skill that a +// variation write settled. A managed tool or skill is part of the variation +// write, so the write synced it. A linked one lives in a repository source +// that the write does not touch, so it keeps its baseline unless a row of +// this run synced it, as reported by synced. A scoped run can leave a linked +// tool without a row, and moving its baseline to the LaunchDarkly copy would +// turn a LaunchDarkly edit into a local change on the next run. +func recordAttachmentState( + manifest *syncmanifest.Manifest, + repositoryRoot, projectKey string, + attachments []syncdomain.Attachment, + synced func(ResourceID) bool, +) error { + if len(attachments) == 0 { + return manifest.SetAttachments(projectKey, attachments) + } + ids := make(map[ResourceID]struct{}, len(attachments)) + for _, attachment := range attachments { + ids[attachment.ID(projectKey)] = struct{}{} + } + // The linked tools and skills compile before the manifest changes, so a + // compile error leaves the manifest as it was instead of recording a + // fingerprint of the wrong schema. + local, err := synclocal.CompileAttachments(repositoryRoot, func(id ResourceID) bool { + _, included := ids[id] + return included + }) + if err != nil { + return err + } + unsynced := make(map[ResourceID]bool) + for _, attachment := range local { + if attachment.Source != nil && !synced(attachment.ID()) { + unsynced[attachment.ID()] = true + } + } + attachments = slices.DeleteFunc(slices.Clone(attachments), func(attachment syncdomain.Attachment) bool { + return unsynced[attachment.ID(projectKey)] + }) + if err := manifest.SetAttachments(projectKey, attachments); err != nil { + return err + } + settled := make(map[ResourceID]syncdomain.Attachment, len(attachments)) + for _, attachment := range attachments { + settled[attachment.ID(projectKey)] = attachment + } + for _, attachment := range local { + if attachment.Source == nil { + continue + } + // The baseline is the content that LaunchDarkly has after the + // action, seen through the local mapping. The repository copy can + // hold an edit that no tool or skill row synced in this run, and + // that edit must stay a local change. + synced, ok := settled[attachment.ID()] + if !ok { + continue + } + fingerprint, err := syncdomain.SyncedAttachment{ + ProjectKey: projectKey, Attachment: synced, Source: attachment.Source, + }.Fingerprint() + if err != nil { + return err + } + manifest.SetFingerprint(attachment.ID(), fingerprint) + if err := recordSource(manifest, attachment.ID(), attachment.Source, attachment.IndexedSelections); err != nil { + return err + } + } + return nil +} + +// attachmentToPublish returns the tool or skill that a push sends. A linked +// row merges its mapped fields onto the plan-time LaunchDarkly copy and keeps +// its projection, so the publish merges them again onto the latest copy and +// an edit to an unowned field in LaunchDarkly survives. +func attachmentToPublish(resource PlannedResource) (syncdomain.Attachment, error) { + local := *resource.LocalAttachment + if resource.Source == nil || resource.ServerAttachment == nil { + return local, nil + } + if err := checkSourcePushClears(resource); err != nil { + return syncdomain.Attachment{}, err + } + merged, err := syncdomain.ApplyAttachmentProjection(*resource.ServerAttachment, resource.LocalProjection) + if err != nil { + return syncdomain.Attachment{}, err + } + merged.Projection = resource.LocalProjection + return merged, nil +} + // attachmentManifestState returns the variation whose attachments the // manifest records after the action, or nil. func attachmentManifestState(resource PlannedResource) *syncdomain.Variation { @@ -116,15 +519,38 @@ func attachmentManifestState(resource PlannedResource) *syncdomain.Variation { } // recordSuccessfulChange records the state that a completed action chose. -func recordSuccessfulChange(manifest *syncmanifest.Manifest, resource PlannedResource) { +// The content hash and the descriptor binding are one record, so an error +// of the binding fails the row. +func recordSuccessfulChange(manifest *syncmanifest.Manifest, resource PlannedResource) error { switch { case resource.Action == ActionDeleteLocal: manifest.Remove(resource.ID) + case resource.Action == ActionUpdateManifest: + manifest.SetFingerprint(resource.ID, resource.LocalFingerprint) case resource.Action.changesServer(): manifest.SetFingerprint(resource.ID, resource.LocalFingerprint) default: manifest.SetFingerprint(resource.ID, resource.ServerFingerprint) } + return recordSource(manifest, resource.ID, resource.Source, resource.IndexedSelections) +} + +// recordSource records the mapping digest, the adapter behavior, and the +// indexed selections of a descriptor-backed resource in the manifest. +func recordSource( + manifest *syncmanifest.Manifest, + id ResourceID, + source *syncdomain.Reference, + selections []syncdomain.IndexedSelection, +) error { + behavior := "" + if source != nil { + var err error + if behavior, err = syncadapter.Behavior(source.Format); err != nil { + return err + } + } + return manifest.SetSource(id, source, behavior, selections) } // applyResourceChange applies one action that writes to LaunchDarkly or to a @@ -159,19 +585,100 @@ func applyResourceChange( } // The local file now matches LaunchDarkly. If LaunchDarkly pins an older - // attachment version, update the pin too. - if resource.ServerHasStaleAttachmentPins && server != nil { - configKey, _, err := resource.ID.VariationKeys() - if err != nil { - return nil, err - } - pinned, err := server.PinnedToLatest() - if err != nil { - return nil, err - } - return pinsOf(pinned), client.UpdateVariation(resource.ID.ProjectKey, configKey, pinned) + // attachment version, update the pin too. A failure of that update comes + // after the local write, which stays. + pins, err := refreshStalePins(client, resource, server) + if err != nil { + return pins, &pinRefreshError{err: err} + } + return pins, nil +} + +// pinRefreshError is the failure of the pin update that follows a pull. The +// local write succeeded, so the lock records the pull and the row reports the +// failure, and the next run moves the pins again. +type pinRefreshError struct{ err error } + +func (e *pinRefreshError) Error() string { return e.err.Error() } +func (e *pinRefreshError) Unwrap() error { return e.err } + +// refreshStalePins moves the tool and skill pins of a variation in +// LaunchDarkly to the latest versions after a pull, when LaunchDarkly pins +// older versions. server is the complete variation that LaunchDarkly has. It +// returns the pins that it wrote, or nil when the pins are current. +func refreshStalePins( + client syncapi.Client, + resource PlannedResource, + server *syncdomain.Variation, +) ([]syncdomain.AttachmentRef, error) { + if !resource.ServerHasStaleAttachmentPins || server == nil { + return nil, nil + } + configKey, _, err := resource.ID.VariationKeys() + if err != nil { + return nil, err + } + pinned, err := server.PinnedToLatest() + if err != nil { + return nil, err + } + return pinsOf(pinned), client.UpdateVariation(resource.ID.ProjectKey, configKey, pinned) +} + +// refreshLinkedPins moves the stale tool and skill pins of a linked variation +// after a pull. The write goes onto the latest LaunchDarkly copy, as a linked +// push does, so a field that the link does not own keeps an edit made after +// the review, and a change to a mapped field after the review stops the +// write. +func refreshLinkedPins(client syncapi.Client, resource PlannedResource) ([]syncdomain.AttachmentRef, error) { + if !resource.ServerHasStaleAttachmentPins || resource.Source == nil { + return refreshStalePins(client, resource, resource.Server) } - return nil, nil + configKey, variationKey, err := resource.ID.VariationKeys() + if err != nil { + return nil, err + } + latest, err := latestLinkedVariation(client, resource, configKey, variationKey) + if err != nil { + return nil, err + } + return refreshStalePins(client, resource, &latest) +} + +// latestLinkedVariation reads the variation that a linked push merges its +// mapped fields onto. The mapped fields must still have the content that the +// review saw, or the push would write over a change that the plan did not +// compare. +func latestLinkedVariation( + client syncapi.Client, + resource PlannedResource, + configKey, variationKey string, +) (syncdomain.Variation, error) { + projectKey := resource.ID.ProjectKey + state, err := client.ReadVariation(projectKey, configKey, variationKey) + if err != nil { + return syncdomain.Variation{}, err + } + if !state.Exists { + return syncdomain.Variation{}, linkedResourceDeleted(&resource) + } + if err := newAttachmentCache(client).hydrate(projectKey, &state.Variation); err != nil { + return syncdomain.Variation{}, err + } + projection, err := syncdomain.ProjectVariation(state.Variation, *resource.Source) + if err != nil { + return syncdomain.Variation{}, err + } + fingerprint, err := syncdomain.FingerprintProjection(resource.ID, projection) + if err != nil { + return syncdomain.Variation{}, err + } + if fingerprint != resource.ServerFingerprint { + return syncdomain.Variation{}, syncdomain.NewProblem( + syncdomain.ErrorStateChanged, "the mapped fields changed in LaunchDarkly after review", "Run sync again.", + ).WithResource(resource.ID) + } + return state.Variation, nil } // applyServerChange writes one variation to LaunchDarkly. If the write fails @@ -197,7 +704,28 @@ func applyServerChange( } writeErr = client.CreateVariation(projectKey, configKey, variation) case ActionUpdateServer: - local := withExplicitDetach(*resource.Local, resource.Server) + local := *resource.Local + server := resource.Server + if resource.Source != nil && resource.Server != nil { + projection, ok := resource.LocalProjection.(syncdomain.VariationProjection) + if !ok { + return nil, fmt.Errorf("source projection for %s is %T", resource.ID.Label(), resource.LocalProjection) + } + if err := checkSourcePushClears(resource); err != nil { + return nil, err + } + // The push merges the mapped fields onto the latest LaunchDarkly + // copy, as a tool or skill publish does, so an edit to an + // unowned field after the review survives. + latest, err := latestLinkedVariation(client, resource, configKey, variationKey) + if err != nil { + return nil, err + } + server = &latest + local = syncdomain.ApplyVariationProjection(latest, projection) + local = withOwnedRelationshipContent(local, *resource.Local, *resource.Source) + } + local = withExplicitDetach(local, server) if variation, err = attachments.resolveVariation(projectKey, local); err != nil { return nil, err } @@ -222,7 +750,24 @@ func applyServerChange( if err := newAttachmentCache(client).hydrate(projectKey, &state.Variation); err != nil { return nil, errors.Join(writeErr, err) } - if actual, err = syncdomain.FingerprintVariation(projectKey, resource.ID.LookupKey, state.Variation); err != nil { + if resource.Source != nil { + var projection syncdomain.VariationProjection + projection, err = syncdomain.ProjectVariation(state.Variation, *resource.Source) + if err == nil { + actual, err = syncdomain.FingerprintProjection(resource.ID, projection) + } + } else { + // The plan compared the linked tools and skills of a managed + // variation on their mapped fields, so the read does too. + aligned := state.Variation + if resource.Local != nil { + aligned, err = alignLinkedAttachments(state.Variation, resource.Local.Attachments) + } + if err == nil { + actual, err = syncdomain.FingerprintVariation(projectKey, resource.ID.LookupKey, aligned) + } + } + if err != nil { return nil, errors.Join(writeErr, err) } } @@ -236,6 +781,25 @@ func applyServerChange( } } +func withOwnedRelationshipContent( + merged, authored syncdomain.Variation, + reference syncdomain.Reference, +) syncdomain.Variation { + attachments := slices.DeleteFunc(slices.Clone(merged.Attachments), func(attachment syncdomain.Attachment) bool { + return reference.Tools != nil && attachment.Kind == syncdomain.AttachmentTool || + reference.Skills != nil && attachment.Kind == syncdomain.AttachmentSkill + }) + for _, attachment := range authored.Attachments { + if reference.Tools != nil && attachment.Kind == syncdomain.AttachmentTool || + reference.Skills != nil && attachment.Kind == syncdomain.AttachmentSkill { + attachments = append(attachments, attachment) + } + } + merged.Attachments = attachments + merged.SortAttachments() + return merged +} + // pinsOf returns the tool and skill references of a variation with their versions. func pinsOf(variation syncdomain.Variation) []syncdomain.AttachmentRef { return slices.Concat(variation.Tools, variation.Skills) @@ -254,8 +818,12 @@ func applyAttachmentChange( // A create row exists only with upsert, or after the user chose the // local side of a conflict in which LaunchDarkly has no copy. Both // allow the create, but not the update of a copy that appears later. - local := *resource.LocalAttachment + local, err := attachmentToPublish(resource) + if err != nil { + return 0, err + } local.Upsert = local.Upsert || resource.Action == ActionCreateServer + attachments.review(resource) published, err := attachments.resolve(resource.ID.ProjectKey, local, resource.Action == ActionCreateServer) return published.Version, err case ActionUpdateLocal: @@ -269,7 +837,7 @@ func applyAttachmentChange( case ActionDeleteLocal: // The pull of a variation that drops the reference ran first. If that // pull failed, the variation still uses the file, so keep it. - variations, err := compileWorkspace(repositoryRoot) + variations, err := compileWorkspace(repositoryRoot, nil) if err != nil { return 0, err } @@ -278,7 +846,9 @@ func applyAttachmentChange( }); index >= 0 { return 0, fmt.Errorf("%s still uses it", variations[index].ID().Label()) } - if _, err := localStore.DeleteResources([]syncdomain.ResourceID{resource.ID}); err != nil { + // DeleteDefinitions removes a leftover link with the descriptor beside + // it, so that no link stands alone where compile rejects it. + if _, err := localStore.DeleteDefinitions([]syncdomain.ResourceID{resource.ID}); err != nil { return 0, err } return 0, verifyLocalResult(repositoryRoot, resource) @@ -334,7 +904,9 @@ func applyLocalChange(store synclocal.Store, resource PlannedResource) error { // action wrote. For ActionUpdateLocal on a variation, resource.Server is in // the form of the local file. func verifyLocalResult(repositoryRoot string, resource PlannedResource) error { - actual, err := readLocalFingerprint(repositoryRoot, resource.ID) + actual, err := readLocalFingerprint( + repositoryRoot, resource.ID, resource.ServerMode, resource.ServerProjection, + ) if err != nil { return err } @@ -342,6 +914,9 @@ func verifyLocalResult(repositoryRoot string, resource PlannedResource) error { expected := "" switch { case resource.Action != ActionUpdateLocal: + case resource.Source != nil: + // A linked resource compares the projection of its mapped fields. + expected = resource.ServerFingerprint case resource.ID.Kind == syncdomain.KindVariation: expected, err = syncdomain.FingerprintVariation(resource.ID.ProjectKey, resource.ID.LookupKey, *resource.Server) if err != nil { @@ -358,20 +933,52 @@ func verifyLocalResult(repositoryRoot string, resource PlannedResource) error { // readLocalFingerprint returns the fingerprint of one local resource, or an // empty string when the resource has no local file. -func readLocalFingerprint(repositoryRoot string, id ResourceID) (string, error) { +func readLocalFingerprint( + repositoryRoot string, + id ResourceID, + mode syncdomain.VariationMode, + expected syncdomain.Projection, +) (string, error) { if id.Kind != syncdomain.KindVariation { attachments, err := synclocal.CompileAttachments(repositoryRoot, func(candidate ResourceID) bool { return candidate == id }) if err != nil || len(attachments) == 0 { return "", err } + if attachments[0].Source != nil { + projection := attachments[0].Projection + if expected != nil { + projection, err = syncdomain.FillReadOnlyFields(projection, expected) + } + if err != nil { + return "", err + } + return syncdomain.FingerprintProjection(id, projection) + } return syncdomain.FingerprintAttachment(id.ProjectKey, attachments[0].Attachment) } - variations, err := compileWorkspace(repositoryRoot) + variations, err := compileWorkspace(repositoryRoot, nil) if err != nil { return "", err } for _, variation := range variations { if variation.ID() == id { + if variation.Source != nil { + typed, ok := variation.Projection.(syncdomain.VariationProjection) + if !ok { + return "", fmt.Errorf("source returned %T for a variation", variation.Projection) + } + projection, err := typed.ForMode(mode) + if err != nil { + return "", err + } + var aligned syncdomain.Projection = projection + if expected != nil { + if aligned, err = syncdomain.FillReadOnlyFields(aligned, expected); err != nil { + return "", err + } + } + return syncdomain.FingerprintProjection(id, aligned) + } return syncdomain.FingerprintVariation(id.ProjectKey, id.LookupKey, variation.Variation) } } diff --git a/internal/sync/prompt/execute_test.go b/internal/sync/prompt/execute_test.go index 7ea901694..1443012c9 100644 --- a/internal/sync/prompt/execute_test.go +++ b/internal/sync/prompt/execute_test.go @@ -158,6 +158,84 @@ func TestAttachmentVersioningDoesNotMutateReviewedVariation(t *testing.T) { assert.Equal(t, 2, pinned.Tools[0].Version) } +func TestLinkedToolKeepsUnownedCustomParametersWhenPinned(t *testing.T) { + oldDescription, newDescription := "Old", "New" + transport := &attachmentMutationAPI{ + current: syncdomain.Tool{ + Key: "search", Description: &oldDescription, + Schema: map[string]any{"type": "object"}, + CustomParameters: map[string]any{"timeout": float64(5)}, + }, + version: 4, + } + client := syncapi.NewClient(transport, "token", "https://example.com") + projection := syncdomain.ToolProjection{ + Description: &newDescription, + FieldStates: map[syncdomain.Field]syncdomain.FieldState{ + syncdomain.FieldDescription: {Capability: syncdomain.CapabilityDirect, Known: true}, + }, + } + variation := testVariation("local") + variation.Tools = []syncdomain.AttachmentRef{{Key: "search"}} + variation.Attachments = []syncdomain.Attachment{{ + Kind: syncdomain.AttachmentTool, + Tool: &syncdomain.Tool{ + Key: "search", Description: &newDescription, + Schema: map[string]any{"type": "object"}, + }, + Projection: projection, + }} + + // The tool row reviewed the LaunchDarkly copy, so the push of the + // variation can write the merge onto it. + id := syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "project", LookupKey: "search"} + source := syncdomain.Reference{File: "tool.json", Format: "json", Select: "$", Map: map[string]string{"description": "description"}} + server := syncdomain.Attachment{Kind: syncdomain.AttachmentTool, Version: 4, Tool: &transport.current} + reviewed, err := syncdomain.ProjectAttachment(server, source) + require.NoError(t, err) + fingerprint, err := syncdomain.FingerprintProjection(id, reviewed) + require.NoError(t, err) + resolver := newAttachmentResolver(client) + resolver.review(PlannedResource{ID: id, Source: &source, ServerAttachment: &server, ServerFingerprint: fingerprint}) + + resolved, err := resolver.resolveVariation("project", variation) + require.NoError(t, err) + require.Len(t, resolved.Tools, 1) + assert.Equal(t, 5, resolved.Tools[0].Version) + assert.Equal(t, map[string]any{"timeout": float64(5)}, transport.current.CustomParameters) + assert.Equal(t, newDescription, *transport.current.Description) +} + +func TestLinkedToolWithoutARowIsNotWrittenByAVariationPush(t *testing.T) { + oldDescription, newDescription := "Old", "New" + transport := &attachmentMutationAPI{ + current: syncdomain.Tool{Key: "search", Description: &oldDescription, Schema: map[string]any{"type": "object"}}, + version: 4, + } + client := syncapi.NewClient(transport, "token", "https://example.com") + variation := testVariation("local") + variation.Tools = []syncdomain.AttachmentRef{{Key: "search"}} + variation.Attachments = []syncdomain.Attachment{{ + Kind: syncdomain.AttachmentTool, + Tool: &syncdomain.Tool{Key: "search", Description: &newDescription, Schema: map[string]any{"type": "object"}}, + Projection: syncdomain.ToolProjection{ + Description: &newDescription, + FieldStates: map[syncdomain.Field]syncdomain.FieldState{ + syncdomain.FieldDescription: {Capability: syncdomain.CapabilityDirect, Known: true}, + }, + }, + }} + + _, err := newAttachmentResolver(client).resolveVariation("project", variation) + + require.Error(t, err, "a linked tool without a row was in sync at the review, so the difference is a change after it") + problem, ok := syncdomain.ProblemFrom(err) + require.True(t, ok) + assert.Equal(t, syncdomain.ErrorStateChanged, problem.Code) + assert.Equal(t, oldDescription, *transport.current.Description, "LaunchDarkly is not written") + assert.Equal(t, 4, transport.version) +} + func TestApplyResourceChangeRefreshesPinsWithCompleteServerModel(t *testing.T) { root := t.TempDir() store := synclocal.NewStore(root) @@ -617,6 +695,226 @@ func TestExecutePlanPullKeepsAToolThatTheSamePlanPushed(t *testing.T) { } } +func TestExecutePlanDefersLinkedPromptPullUntilSharedAttachmentSucceeds(t *testing.T) { + root := t.TempDir() + store := synclocal.NewStore(root) + require.NoError(t, os.WriteFile(filepath.Join(root, "prompt.md"), []byte("Local prompt.\n"), 0o644)) + empty := []syncdomain.AttachmentRef{} + id := testResourceID() + reference := syncdomain.Reference{File: "prompt.md", Format: "markdown", Tools: &empty} + _, err := store.WriteDescriptor(id, reference) + require.NoError(t, err) + compiled, err := synclocal.CompileWorkspace(root) + require.NoError(t, err) + require.Len(t, compiled, 1) + + edited := testAttachment(syncdomain.AttachmentTool, "new") + remote := testAttachment(syncdomain.AttachmentTool, "old") + remote.Version = 2 + _, err = store.ReplaceAttachments([]syncdomain.SyncedAttachment{{ + ProjectKey: "production", Attachment: edited, + }}) + require.NoError(t, err) + toolRow := planAttachment(edited.ID("production"), &edited, &remote, attachmentFingerprint(t, remote), true) + require.Equal(t, ActionUpdateServer, toolRow.Action) + + server := compiled[0].Variation + server.Instructions = "Server prompt." + server.Tools = []syncdomain.AttachmentRef{{Key: "search", Version: 2}} + server.Attachments = []syncdomain.Attachment{remote} + serverProjection, err := syncdomain.ProjectVariation(server, reference) + require.NoError(t, err) + localFingerprint, err := syncdomain.FingerprintProjection(id, compiled[0].Projection) + require.NoError(t, err) + serverFingerprint, err := syncdomain.FingerprintProjection(id, serverProjection) + require.NoError(t, err) + pull := PlannedResource{ + ID: id, Action: ActionUpdateLocal, Source: &reference, + Local: &compiled[0].Variation, Server: &server, ServerMode: syncdomain.VariationModeAgent, + LocalProjection: compiled[0].Projection, ServerProjection: serverProjection, + LocalFingerprint: localFingerprint, ServerFingerprint: serverFingerprint, + } + current := testAttachment(syncdomain.AttachmentTool, "old") + api := &settleAPI{tool: attachmentMutationAPI{ + current: *current.Tool, version: 2, + updateErr: errors.New(`{"code":"forbidden","statusCode":403}`), + }} + + outcomes, _, err := executePlan( + root, store, syncapi.NewClient(api, "token", "https://example.com"), + syncmanifest.New(), Plan{Resources: []PlannedResource{toolRow, pull}}, nil, + ) + + require.ErrorContains(t, err, "forbidden") + assert.Equal(t, OutcomeFailed, outcomes[0].Status) + assert.Equal(t, OutcomeFailed, outcomes[1].Status) + content, readErr := os.ReadFile(filepath.Join(root, "prompt.md")) + require.NoError(t, readErr) + assert.Equal(t, "Local prompt.\n", string(content)) + descriptor, readErr := os.ReadFile(filepath.Join( + root, syncdomain.RootDir, "production", "configs", "support", "default.ref.yaml", + )) + require.NoError(t, readErr) + assert.Contains(t, string(descriptor), "tools: []") + assert.NotContains(t, string(descriptor), "key: search") +} + +func TestExecutePlanAppliesIndependentSourceGroups(t *testing.T) { + root := t.TempDir() + store := synclocal.NewStore(root) + ids := []syncdomain.ResourceID{ + syncdomain.VariationID("production", "first", "default"), + syncdomain.VariationID("production", "second", "default"), + } + references := []syncdomain.Reference{ + {File: "first.md", Format: "markdown"}, + {File: "second.md", Format: "markdown"}, + } + for index, reference := range references { + require.NoError(t, os.WriteFile( + filepath.Join(root, reference.File), + []byte("Local "+ids[index].LookupKey+".\n"), + 0o644, + )) + _, err := store.WriteDescriptor(ids[index], reference) + require.NoError(t, err) + } + compiled, err := synclocal.CompileWorkspace(root) + require.NoError(t, err) + require.Len(t, compiled, 2) + + var resources []PlannedResource + for index := range compiled { + local := compiled[index] + server := local.Variation + server.Instructions = "Server " + local.LookupKey + "." + serverProjection, projectionErr := syncdomain.ProjectVariation(server, *local.Source) + require.NoError(t, projectionErr) + localFingerprint, fingerprintErr := syncdomain.FingerprintProjection(local.ID(), local.Projection) + require.NoError(t, fingerprintErr) + serverFingerprint, fingerprintErr := syncdomain.FingerprintProjection(local.ID(), serverProjection) + require.NoError(t, fingerprintErr) + resources = append(resources, PlannedResource{ + ID: local.ID(), Action: ActionUpdateLocal, Source: local.Source, + Local: &local.Variation, Server: &server, ServerMode: syncdomain.VariationModeAgent, + LocalProjection: local.Projection, ServerProjection: serverProjection, + LocalFingerprint: localFingerprint, ServerFingerprint: serverFingerprint, + }) + } + resources[0].LocalFingerprint = "sha256:stale" + + reviewed := append([]PlannedResource(nil), resources...) + checkSourcePublications(root, reviewed) + assert.Equal(t, ActionError, reviewed[0].Action) + assert.Equal(t, ActionUpdateLocal, reviewed[1].Action) + + outcomes, _, err := executePlan( + root, store, syncapi.Client{}, syncmanifest.New(), Plan{Resources: resources}, nil, + ) + + require.Error(t, err) + assert.Equal(t, OutcomeFailed, outcomes[0].Status) + assert.Equal(t, OutcomeSucceeded, outcomes[1].Status) + first, readErr := os.ReadFile(filepath.Join(root, "first.md")) + require.NoError(t, readErr) + assert.Equal(t, "Local first/default.\n", string(first)) + second, readErr := os.ReadFile(filepath.Join(root, "second.md")) + require.NoError(t, readErr) + assert.Equal(t, "Server second/default.\n", string(second)) +} + +func TestExecutePlanFailsEveryRowOfAGroupWhenOneMemberDoesNotVerify(t *testing.T) { + root := t.TempDir() + store := synclocal.NewStore(root) + require.NoError(t, os.MkdirAll(filepath.Join(root, "lib"), 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(root, "lib", "prompts.py"), []byte("A = \"Local a\"\nB = \"Local b\"\n"), 0o644, + )) + for _, name := range []string{"A", "B"} { + _, err := store.WriteDescriptor( + syncdomain.VariationID("production", "support", strings.ToLower(name)), + syncdomain.Reference{ + File: "lib/prompts.py", Format: "python", Select: `constants[id="` + name + `"]`, + Map: map[string]string{"prompt": "$"}, + }, + ) + require.NoError(t, err) + } + compiled, err := synclocal.CompileWorkspace(root) + require.NoError(t, err) + require.Len(t, compiled, 2) + + var resources []PlannedResource + for index := range compiled { + local := compiled[index] + server := local.Variation + server.Instructions = "Server " + local.LookupKey + "." + serverProjection, projectionErr := syncdomain.ProjectVariation(server, *local.Source) + require.NoError(t, projectionErr) + localFingerprint, fingerprintErr := syncdomain.FingerprintProjection(local.ID(), local.Projection) + require.NoError(t, fingerprintErr) + serverFingerprint, fingerprintErr := syncdomain.FingerprintProjection(local.ID(), serverProjection) + require.NoError(t, fingerprintErr) + resources = append(resources, PlannedResource{ + ID: local.ID(), Action: ActionUpdateLocal, Source: local.Source, + Local: &local.Variation, Server: &server, ServerMode: syncdomain.VariationModeAgent, + LocalProjection: local.Projection, ServerProjection: serverProjection, + LocalFingerprint: localFingerprint, ServerFingerprint: serverFingerprint, + }) + } + // The second member does not compile to the reviewed server state. + resources[1].ServerFingerprint = "sha256:other" + + outcomes, next, err := executePlan( + root, store, syncapi.Client{}, syncmanifest.New(), Plan{Resources: resources}, nil, + ) + + require.Error(t, err) + assert.Equal(t, OutcomeFailed, outcomes[0].Status, "the member that verified fails with its group") + assert.Equal(t, OutcomeFailed, outcomes[1].Status) + assert.Contains(t, outcomes[0].Error, resources[1].ID.String()) + assert.False(t, next.Has(resources[0].ID), "no member of the group is recorded against the rewritten file") + assert.False(t, next.Has(resources[1].ID)) +} + +// notFoundAPI answers every read with a 404. +type notFoundAPI struct{} + +func (notFoundAPI) MakeRequest(string, string, string, string, url.Values, []byte, bool) ([]byte, error) { + return nil, errors.New(`{"code":"not_found","statusCode":404}`) +} + +func (notFoundAPI) MakeUnauthenticatedRequest(string, string, []byte) ([]byte, error) { + return nil, nil +} + +func TestLinkedPublishOfAMissingToolAsksToCreateOrDetach(t *testing.T) { + client := syncapi.NewClient(notFoundAPI{}, "token", "https://example.com") + description := "Linked" + linked := syncdomain.Attachment{ + Kind: syncdomain.AttachmentTool, + Tool: &syncdomain.Tool{Key: "search", Description: &description, Schema: map[string]any{"type": "object"}}, + Projection: syncdomain.ToolProjection{ + Description: &description, + FieldStates: map[syncdomain.Field]syncdomain.FieldState{ + syncdomain.FieldDescription: {Capability: syncdomain.CapabilityDirect, Known: true}, + }, + }, + } + managed := syncdomain.Attachment{ + Kind: syncdomain.AttachmentTool, + Tool: &syncdomain.Tool{Key: "lookup", Description: &description, Schema: map[string]any{"type": "object"}}, + } + + _, linkedErr := newAttachmentResolver(client).publish("project", linked, false) + _, managedErr := newAttachmentResolver(client).publish("project", managed, false) + + require.EqualError(t, linkedErr, `tool "search" does not exist in LaunchDarkly. Create it in LaunchDarkly and run sync again. `+ + "To stop syncing it, run ldcli sync tools detach --project project --key search.") + require.EqualError(t, managedErr, `tool "lookup" does not exist in LaunchDarkly. `+ + `To create it, add "upsert": true to its file and run ldcli sync tools --project project --key lookup.`) +} + // settleAPI serves the tool "search" like attachmentMutationAPI, and records // the body of each variation update. type settleAPI struct { @@ -665,3 +963,38 @@ func readLocalTool(t *testing.T, root string) syncdomain.Tool { require.Len(t, local, 1) return *local[0].Attachment.Tool } + +// A settled row of a managed variation is aligned on the mapped fields of +// its linked tools. The aligned linked tool keeps its projection on the local +// side, because a publish merges the mapped fields onto the LaunchDarkly copy +// through it, and a copy without one is written whole. +func TestSettledRowKeepsTheProjectionOfALinkedTool(t *testing.T) { + direct := syncdomain.FieldState{Capability: syncdomain.CapabilityDirect, Known: true} + description := "Shared" + linked := syncdomain.Attachment{ + Kind: syncdomain.AttachmentTool, + Tool: &syncdomain.Tool{Key: "search", Description: &description, Schema: map[string]any{"type": "object"}}, + Projection: syncdomain.ToolProjection{ + Description: &description, Schema: &map[string]any{"type": "object"}, + FieldStates: map[syncdomain.Field]syncdomain.FieldState{syncdomain.FieldDescription: direct, syncdomain.FieldSchema: direct}, + }, + } + oldLookup := "Old lookup" + newLookup := "New lookup" + managed := syncdomain.Attachment{Kind: syncdomain.AttachmentTool, Tool: &syncdomain.Tool{Key: "lookup", Description: &oldLookup}} + settled := syncdomain.Attachment{Kind: syncdomain.AttachmentTool, Version: 2, Tool: &syncdomain.Tool{Key: "lookup", Description: &newLookup}} + local := testVariation("local") + local.Tools = []syncdomain.AttachmentRef{{Key: "lookup"}, {Key: "search"}} + local.Attachments = []syncdomain.Attachment{managed, linked} + resolver := newAttachmentResolver(syncapi.Client{}) + resolver.settle(syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "production", LookupKey: "lookup"}, settled, nil) + resource := PlannedResource{ID: testResourceID(), Action: ActionUpdateServer, Local: &local} + + result, err := resolver.settledRow(resource) + + require.NoError(t, err) + require.Len(t, result.Local.Attachments, 2) + assert.Equal(t, "New lookup", *result.Local.Attachments[0].Tool.Description, "the pulled managed tool is settled") + assert.NotNil(t, result.Local.Attachments[1].Projection, "the linked tool keeps its projection for the publish") + assert.Nil(t, result.Local.Attachments[1].Tool.CustomParameters) +} diff --git a/internal/sync/prompt/model_parameters_test.go b/internal/sync/prompt/model_parameters_test.go new file mode 100644 index 000000000..d31c1e8e0 --- /dev/null +++ b/internal/sync/prompt/model_parameters_test.go @@ -0,0 +1,67 @@ +package prompt + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +func TestIndividualModelParameterPlansPushPullAndConflict(t *testing.T) { + id := syncdomain.VariationID("project", "config", "default") + target := `model.parameters["temperature"]` + reference := &syncdomain.Reference{ + File: "agent.json", Format: "json", + Map: map[string]string{"prompt": "prompt", target: "temperature"}, + } + baselineVariation := parameterVariation(0.2) + baselineProjection, err := syncdomain.ProjectVariation(baselineVariation, *reference) + require.NoError(t, err) + baseline, err := syncdomain.FingerprintProjection(id, baselineProjection) + require.NoError(t, err) + + tests := []struct { + name string + localTemperature float64 + serverTemperature float64 + action Action + }{ + {name: "push", localTemperature: 0.3, serverTemperature: 0.2, action: ActionUpdateServer}, + {name: "pull", localTemperature: 0.2, serverTemperature: 0.3, action: ActionUpdateLocal}, + {name: "conflict", localTemperature: 0.3, serverTemperature: 0.4, action: ActionConflict}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + localVariation := parameterVariation(test.localTemperature) + localProjection, err := syncdomain.ProjectVariation(localVariation, *reference) + require.NoError(t, err) + local := syncdomain.SyncedResource{ + Kind: id.Kind, ProjectKey: id.ProjectKey, LookupKey: id.LookupKey, + Source: reference, Projection: localProjection, Variation: localVariation, + } + server := parameterVariation(test.serverTemperature) + + resource := planResource( + id, + &local, + ServerResource{Variation: &server, ConfigMode: syncdomain.VariationModeAgent}, + baseline, + true, + ) + + assert.Equal(t, test.action, resource.Action) + }) + } +} + +func parameterVariation(temperature float64) syncdomain.Variation { + return syncdomain.Variation{ + Mode: syncdomain.VariationModeAgent, Key: "default", Name: "Default", Instructions: "Help", + Model: map[string]any{"parameters": map[string]any{ + "temperature": temperature, + "top_p": 0.9, + }}, + } +} diff --git a/internal/sync/prompt/output.go b/internal/sync/prompt/output.go index 40a45c1dd..892329354 100644 --- a/internal/sync/prompt/output.go +++ b/internal/sync/prompt/output.go @@ -57,6 +57,26 @@ func newResourceOutput(id ResourceID) resourceOutput { return resourceOutput{ResourceKind: string(id.Kind), ProjectKey: id.ProjectKey, LookupKey: id.LookupKey} } +type sourceOutput struct { + File string `json:"file"` + Format string `json:"format"` + Select string `json:"select"` + Map map[string]string `json:"map"` + Direction syncdomain.SyncDirection `json:"direction"` + Dependencies []string `json:"dependencies,omitempty"` +} + +func plannedSource(resource PlannedResource) *sourceOutput { + if resource.Source == nil { + return nil + } + return &sourceOutput{ + File: resource.Source.File, Format: resource.Source.Format, + Select: resource.Source.Select, Map: resource.Source.Map, + Direction: resource.Direction, Dependencies: resource.SourceDependencies, + } +} + // writePlanOutput writes the plan in the selected output kind. Plain text and // Markdown show a full diff for a conflict, and for every row when showDiffs // is true. @@ -71,22 +91,37 @@ func writePlanOutput(out io.Writer, outputKind string, plan Plan, showDiffs bool type planResourceOutput struct { resourceOutput - Action Action `json:"action"` - SyncedElsewhere bool `json:"syncedElsewhere,omitempty"` - Suggestion string `json:"suggestion,omitempty"` - Error string `json:"error,omitempty"` - Diff variationDiffFields `json:"diff,omitempty"` + Action Action `json:"action"` + SyncedElsewhere bool `json:"syncedElsewhere,omitempty"` + Suggestion string `json:"suggestion,omitempty"` + Error string `json:"error,omitempty"` + ErrorCode syncdomain.ErrorCode `json:"errorCode,omitempty"` + ErrorFile string `json:"errorFile,omitempty"` + ErrorLine int `json:"errorLine,omitempty"` + ErrorColumn int `json:"errorColumn,omitempty"` + NextStep string `json:"nextStep,omitempty"` + Diff variationDiffFields `json:"diff,omitempty"` + Source *sourceOutput `json:"source,omitempty"` } resources := make([]planResourceOutput, 0, len(plan.Resources)) for _, resource := range plan.Resources { - resources = append(resources, planResourceOutput{ + item := planResourceOutput{ resourceOutput: newResourceOutput(resource.ID), Action: resource.Action, SyncedElsewhere: resource.SyncedElsewhere, Suggestion: staleSuggestion(resource), Error: resource.Error, Diff: resource.Diff, - }) + Source: plannedSource(resource), + } + if resource.Problem != nil { + item.ErrorCode = resource.Problem.Code + item.ErrorFile = resource.Problem.File + item.ErrorLine = resource.Problem.Line + item.ErrorColumn = resource.Problem.Column + item.NextStep = resource.Problem.Suggestion + } + resources = append(resources, item) } return writeJSON(out, map[string]any{"resources": resources}) } @@ -108,11 +143,13 @@ func writeOutcomeOutput(out io.Writer, outputKind string, plan Plan, outcomes [] resourceOutput ResourceOutcome Create *skillCreateOutput `json:"create,omitempty"` + Source *sourceOutput `json:"source,omitempty"` } resources := make([]outcomeOutput, 0, len(outcomes)) for _, outcome := range outcomes { resources = append(resources, outcomeOutput{ - resourceOutput: newResourceOutput(outcome.ID), ResourceOutcome: outcome, Create: skillCreate(planned[outcome.ID]), + resourceOutput: newResourceOutput(outcome.ID), ResourceOutcome: outcome, + Create: skillCreate(planned[outcome.ID]), Source: plannedSource(planned[outcome.ID]), }) } return writeJSON(out, map[string]any{"resources": resources}) @@ -395,6 +432,10 @@ func planReason(resource PlannedResource) string { return "new" case repinsOnly(resource): return repinReason(resource.Diff["attachment versions"]) + case resource.directionOverride && resource.Action == ActionUpdateServer: + return "the repository owns it, the LaunchDarkly change is replaced" + case resource.directionOverride && resource.Action == ActionUpdateLocal: + return "LaunchDarkly owns it, the local change is replaced" case resource.Action == ActionUpdateServer: return changedFieldsReason(resource, "changed") case resource.Action == ActionUpdateLocal && !resource.hasLocal(): @@ -441,6 +482,9 @@ func changedFieldsReason(resource PlannedResource, verb string) string { if part, ok := resource.Diff["variation"]; ok { fields = append(fields, changedKeys(part.Before, part.After)...) } + if part, ok := resource.Diff["projection"]; ok { + fields = append(fields, changedKeys(part.Before, part.After)...) + } for _, name := range []string{"tools", "skills"} { part, ok := resource.Diff[name] switch { diff --git a/internal/sync/prompt/plan.go b/internal/sync/prompt/plan.go index a91aea21b..33219d0ee 100644 --- a/internal/sync/prompt/plan.go +++ b/internal/sync/prompt/plan.go @@ -7,6 +7,7 @@ import ( "slices" syncdomain "github.com/launchdarkly/ldcli/internal/sync" + syncadapter "github.com/launchdarkly/ldcli/internal/sync/adapter" syncmanifest "github.com/launchdarkly/ldcli/internal/sync/manifest" ) @@ -57,10 +58,16 @@ type PlannedResource struct { LocalFingerprint string ServerFingerprint string ServerMode syncdomain.VariationMode + Direction syncdomain.SyncDirection Local *syncdomain.Variation Server *syncdomain.Variation LocalAttachment *syncdomain.Attachment ServerAttachment *syncdomain.Attachment + Source *syncdomain.Reference + SourceDependencies []string + IndexedSelections []syncdomain.IndexedSelection + LocalProjection syncdomain.Projection + ServerProjection syncdomain.Projection // SyncedElsewhere is true when another working copy synced the resource // after this working copy wrote its sync.lock file. The plan is still // correct, because it compares against this working copy's lock. @@ -70,9 +77,13 @@ type PlannedResource struct { ServerHasStaleAttachmentPins bool Diff variationDiffFields Error string + Problem *syncdomain.Problem // changedAttachments are the tools and skills whose content differs // between the local file and LaunchDarkly. changedAttachments []ResourceID + // attachmentConflict is true when the row is a conflict because a shared + // tool or skill changed on the side that the row would overwrite. + attachmentConflict bool // conflictUses are the conflicted variations that newly use the tool or // skill of this row on one side, when no push or pull needs the row. conflictUses []conflictUse @@ -81,6 +92,61 @@ type PlannedResource struct { // nameSource is where the name of a local skill comes from: a name in its // file, its key, or a flag of the add that creates it. nameSource string + // directionOverride is true when the LaunchDarkly sync setting of the + // resource chose the side that wins, instead of the three-way comparison. + directionOverride bool +} + +func setProblem(resource *PlannedResource, problem *syncdomain.Problem) { + resource.Action = ActionError + resource.Problem = problem + if problem != nil { + resource.Error = problem.Message + } +} + +func blockUnsupportedFields(resource *PlannedResource) { + if resource.Source == nil || resource.LocalProjection == nil { + return + } + states := resource.LocalProjection.States() + // The first field in name order is the one that the problem reports, so + // the same plan always gives the same problem. + for _, field := range slices.Sorted(maps.Keys(states)) { + state := states[field] + if state.Capability == syncdomain.CapabilityDirect { + continue + } + code := state.Code + if code == "" { + code = syncdomain.ErrorFieldReadOnly + } + suggestion := "Map only syncable fields, or link the value's real source." + if code == syncdomain.ErrorImportUnresolved && state.File != "" { + suggestion = "Link the imported value directly: " + + sourceLinkCommand(resource.ID, state.File, resource.Source.Format) + } + setProblem(resource, syncdomain.NewProblem( + code, + fmt.Sprintf("field %q is %s: %s", field, state.Capability, state.Reason), + suggestion, + ).WithResource(resource.ID).WithFile(resource.Source.File, 0, 0)) + return + } +} + +func sourceLinkCommand(id ResourceID, file, format string) string { + if id.Kind == syncdomain.KindVariation { + configKey, variationKey, _ := id.VariationKeys() + return fmt.Sprintf( + "ldcli sync prompts link --file %s --format %s --project %s --config-key %s --key %s", + file, format, id.ProjectKey, configKey, variationKey, + ) + } + return fmt.Sprintf( + "ldcli sync %ss link --file %s --format %s --project %s --key %s", + id.Kind, file, format, id.ProjectKey, id.LookupKey, + ) } // The sources of the name of a skill that sync creates. @@ -132,6 +198,8 @@ type Plan struct { // variation uses. Only a prompts plan counts them, because it leaves them // out. standalone map[syncdomain.Kind]int + // ForceConfirmation makes link confirm a descriptor-only change. + ForceConfirmation bool } // BuildPlan compares the baseline with the local and the server state. The @@ -141,10 +209,10 @@ func BuildPlan(baseline syncmanifest.Manifest, local []syncdomain.SyncedResource for _, resource := range local { localByID[resource.ID()] = resource } - baselineByID := make(map[ResourceID]string, len(baseline.Resources)) + baselineByID := make(map[ResourceID]syncmanifest.Resource, len(baseline.Resources)) for _, resource := range baseline.Resources { if resource.ResourceKind == syncdomain.KindVariation { - baselineByID[resource.ID()] = resource.Fingerprint + baselineByID[resource.ID()] = resource } } @@ -166,8 +234,14 @@ func BuildPlan(baseline syncmanifest.Manifest, local []syncdomain.SyncedResource if isLocal { localState = &localResource } - baselineFingerprint, tracked := baselineByID[id] - plan.Resources = append(plan.Resources, planResource(id, localState, server[id], baselineFingerprint, tracked)) + baselineResource, tracked := baselineByID[id] + if localState != nil { + tracked = baselineApplies(baselineResource, tracked, localState.Source) + } + row := planResource(id, localState, server[id], baselineResource.Fingerprint, tracked) + checkSourceBaseline(&row, baselineResource, tracked) + blockMissingDescriptor(&row, localState == nil, baselineResource, tracked) + plan.Resources = append(plan.Resources, row) } return plan } @@ -192,41 +266,105 @@ func planResource( var err error if local != nil { resource.Upsert = local.Upsert + resource.Source = local.Source + resource.SourceDependencies = slices.Clone(local.Dependencies) + resource.IndexedSelections = slices.Clone(local.IndexedSelections) variation := local.Variation + if local.Source != nil { + // The source implies a mode. The config decides the real one. + projection, ok := local.Projection.(syncdomain.VariationProjection) + if !ok { + return fail(fmt.Sprintf("source returned %T for a variation", local.Projection)) + } + if projection, err = projection.ForMode(server.ConfigMode); err != nil { + return fail(err.Error()) + } + variation = syncdomain.ApplyVariationProjection(variation, projection) + resource.LocalProjection = projection + } if variation.Mode != server.ConfigMode { return fail(fmt.Sprintf("local mode %q does not match config mode %q", variation.Mode, server.ConfigMode)) } - resource.Local = &variation - if resource.LocalFingerprint, err = syncdomain.FingerprintVariation(id.ProjectKey, id.LookupKey, variation); err != nil { + if err := syncdomain.ValidateAttachmentRefs(variation.Mode, variation.Tools, variation.Skills); err != nil { return fail(err.Error()) } + resource.Local = &variation + if local.Source == nil { + if resource.LocalFingerprint, err = syncdomain.FingerprintVariation(id.ProjectKey, id.LookupKey, variation); err != nil { + return fail(err.Error()) + } + if server.Variation != nil { + // A linked tool or skill of a managed variation compares on + // the fields that the link maps, so the LaunchDarkly copy is + // reduced to them here, for the fingerprint and for every + // later check of the row. + aligned, err := alignLinkedAttachments(*server.Variation, variation.Attachments) + if err != nil { + return fail(fmt.Sprintf("invalid server variation: %s", err)) + } + server.Variation, resource.Server = &aligned, &aligned + } + } } if server.Variation != nil { - if resource.ServerFingerprint, err = syncdomain.FingerprintVariation(id.ProjectKey, id.LookupKey, *server.Variation); err != nil { + if local != nil && local.Source != nil { + resource.ServerProjection, err = syncdomain.ProjectVariation(*server.Variation, *local.Source) + if err == nil { + resource.ServerFingerprint, err = syncdomain.FingerprintProjection(id, resource.ServerProjection) + } + } else { + resource.ServerFingerprint, err = syncdomain.FingerprintVariation(id.ProjectKey, id.LookupKey, *server.Variation) + } + if err != nil { return fail(fmt.Sprintf("invalid server variation: %s", err)) } } + if resource.Source != nil && resource.LocalProjection != nil { + if resource.ServerProjection != nil { + resource.LocalProjection, err = syncdomain.FillReadOnlyFields( + resource.LocalProjection, resource.ServerProjection, + ) + } + if err == nil { + resource.LocalFingerprint, err = syncdomain.FingerprintProjection(id, resource.LocalProjection) + } + if err != nil { + return fail(err.Error()) + } + } resource.Action = chooseAction(tracked, resource) if resource.Action == ActionError { resource.Error = "variation does not exist in LaunchDarkly; set upsert: true to create it" + if resource.Source != nil { + // A descriptor has no upsert, so the recovery is to create the + // variation in LaunchDarkly or to detach it. + configKey, variationKey, _ := id.VariationKeys() + resource.Error = fmt.Sprintf( + "variation does not exist in LaunchDarkly. Create it in LaunchDarkly and run sync again. To stop syncing it, run ldcli sync prompts detach --project %s --config-key %s --key %s.", + id.ProjectKey, configKey, variationKey, + ) + } } if resource.Action == ActionCreateServer { _, variationKey, _ := id.VariationKeys() rejectInvalidCreate(&resource, syncdomain.ValidateCreate(id.Kind, variationKey, local.Variation.Name, "")) } + blockUnsupportedFields(&resource) // The fingerprints do not include versions. If LaunchDarkly pins an older // attachment version, sync updates the pin even when the content matches. currentPins, latestPins, stalePins := attachmentPinDiff(resource.Server) resource.ServerHasStaleAttachmentPins = stalePins - if stalePins && (resource.Action == ActionInSync || resource.Action == ActionUpdateManifest) { - resource.Action = ActionUpdateServer - } + upgradeStalePins(&resource) // The diff shows only changes in behavior. The raw JSON can differ when // the fingerprints match, for example because the API adds defaults. if resource.LocalFingerprint != resource.ServerFingerprint { - resource.Diff = variationDiff(resource.Server, resource.Local) + if resource.Source != nil { + resource.Diff = projectionDiff(resource.ServerProjection, resource.LocalProjection) + } else { + resource.Diff = variationDiff(resource.Server, resource.Local) + } } if stalePins && resource.Action != ActionConflict { if resource.Diff == nil { @@ -246,14 +384,14 @@ func planAttachments( local []syncdomain.SyncedAttachment, server map[ResourceID]*syncdomain.Attachment, ) []PlannedResource { - localByID := make(map[ResourceID]*syncdomain.Attachment, len(local)) + localByID := make(map[ResourceID]*syncdomain.SyncedAttachment, len(local)) for index := range local { - localByID[local[index].ID()] = &local[index].Attachment + localByID[local[index].ID()] = &local[index] } - baselineByID := make(map[ResourceID]string, len(baseline.Resources)) + baselineByID := make(map[ResourceID]syncmanifest.Resource, len(baseline.Resources)) for _, resource := range baseline.Resources { if resource.ResourceKind != syncdomain.KindVariation { - baselineByID[resource.ID()] = resource.Fingerprint + baselineByID[resource.ID()] = resource } } @@ -267,12 +405,76 @@ func planAttachments( resources := make([]PlannedResource, 0, len(ids)) for _, id := range ids { - baselineFingerprint, tracked := baselineByID[id] - resources = append(resources, planAttachment(id, localByID[id], server[id], baselineFingerprint, tracked)) + baselineResource, tracked := baselineByID[id] + if local := localByID[id]; local != nil { + tracked = baselineApplies(baselineResource, tracked, local.Source) + } + row := planSyncedAttachment(id, localByID[id], server[id], baselineResource.Fingerprint, tracked) + checkSourceBaseline(&row, baselineResource, tracked) + blockMissingDescriptor(&row, localByID[id] == nil, baselineResource, tracked) + resources = append(resources, row) } return resources } +// baselineApplies reports whether a lock entry is a baseline for the local +// side. A managed file and a source projection have fingerprints of +// different schemas, so a lock entry without a projection schema says +// nothing about a linked resource, and the link plans it as a first sync. +func baselineApplies(baseline syncmanifest.Resource, tracked bool, source *syncdomain.Reference) bool { + return tracked && (source == nil || baseline.FingerprintSchema != "") +} + +// blockMissingDescriptor stops the row of a linked resource whose descriptor +// is gone. A restore would write a managed copy and drop the mapping, so the +// user restores the descriptor or detaches the resource. +func blockMissingDescriptor(resource *PlannedResource, missingLocal bool, baseline syncmanifest.Resource, tracked bool) { + if !missingLocal || !tracked || baseline.FingerprintSchema == "" || resource.Action == ActionError { + return + } + setProblem(resource, syncdomain.NewProblem( + syncdomain.ErrorLinkedSourceMissing, + "the descriptor of a linked resource is missing", + "Restore the .ref.yaml descriptor, or run "+detachCommand(resource.ID)+".", + ).WithResource(resource.ID)) +} + +func checkSourceBaseline(resource *PlannedResource, baseline syncmanifest.Resource, tracked bool) { + if resource.Source == nil || !tracked || resource.Action == ActionError { + return + } + digest, err := syncdomain.ReferenceDigest(*resource.Source) + behavior, behaviorErr := syncadapter.Behavior(resource.Source.Format) + schemaChanged := baseline.FingerprintSchema != "" && + baseline.FingerprintSchema != syncdomain.ProjectionFingerprintSchema + referenceChanged := baseline.ReferenceDigest != "" && baseline.ReferenceDigest != digest + adapterChanged := baseline.AdapterBehavior != "" && baseline.AdapterBehavior != behavior + switch { + case err != nil: + resource.Action, resource.Error = ActionError, err.Error() + case behaviorErr != nil: + resource.Action, resource.Error = ActionError, behaviorErr.Error() + case schemaChanged || referenceChanged: + setProblem(resource, syncdomain.NewProblem( + syncdomain.ErrorBindingStale, + "the source descriptor changed", + "Run link again to review the mapping.", + ).WithResource(resource.ID)) + case adapterChanged: + setProblem(resource, syncdomain.NewProblem( + syncdomain.ErrorBindingStale, + "adapter behavior changed", + "Review the descriptor, then run link again.", + ).WithResource(resource.ID)) + case baseline.IndexedSelections != nil && !slices.Equal(baseline.IndexedSelections, resource.IndexedSelections): + setProblem(resource, syncdomain.NewProblem( + syncdomain.ErrorIndexedSelectionChanged, + "an indexed source selection changed", + "Review the descriptor and run link again. Use a keyed selector when the item has a stable key.", + ).WithResource(resource.ID).WithFile(resource.Source.File, 0, 0)) + } +} + // planAttachment fingerprints both sides of one tool or skill and chooses its // action with the same rules as a variation. func planAttachment( @@ -280,9 +482,23 @@ func planAttachment( local, server *syncdomain.Attachment, baselineFingerprint string, tracked bool, +) PlannedResource { + var synced *syncdomain.SyncedAttachment + if local != nil { + synced = &syncdomain.SyncedAttachment{ProjectKey: id.ProjectKey, Attachment: *local} + } + return planSyncedAttachment(id, synced, server, baselineFingerprint, tracked) +} + +func planSyncedAttachment( + id ResourceID, + local *syncdomain.SyncedAttachment, + server *syncdomain.Attachment, + baselineFingerprint string, + tracked bool, ) PlannedResource { resource := PlannedResource{ - ID: id, BaselineFingerprint: baselineFingerprint, LocalAttachment: local, ServerAttachment: server, + ID: id, BaselineFingerprint: baselineFingerprint, ServerAttachment: server, } fail := func(message string) PlannedResource { resource.Action, resource.Error = ActionError, message @@ -291,32 +507,67 @@ func planAttachment( var err error if local != nil { - resource.Upsert = local.Upsert - if resource.LocalFingerprint, err = syncdomain.FingerprintAttachment(id.ProjectKey, *local); err != nil { + resource.Upsert = local.Attachment.Upsert + resource.LocalAttachment = &local.Attachment + resource.Source = local.Source + resource.SourceDependencies = slices.Clone(local.Dependencies) + resource.IndexedSelections = slices.Clone(local.IndexedSelections) + if resource.Source != nil { + resource.LocalProjection = local.Projection + } else { + resource.LocalFingerprint, err = syncdomain.FingerprintAttachment(id.ProjectKey, local.Attachment) + } + if err != nil { return fail(err.Error()) } } - if local != nil && local.Skill != nil { + if local != nil && local.Attachment.Skill != nil { resource.nameSource = nameFromKey - if local.Skill.Name != "" { + if local.Attachment.Skill.Name != "" { resource.nameSource = nameFromFrontMatter } } if server != nil { - if resource.ServerFingerprint, err = syncdomain.FingerprintAttachment(id.ProjectKey, *server); err != nil { + if resource.Source != nil { + resource.ServerProjection, err = syncdomain.ProjectAttachment(*server, *resource.Source) + if err == nil { + resource.ServerFingerprint, err = syncdomain.FingerprintProjection(id, resource.ServerProjection) + } + } else { + resource.ServerFingerprint, err = syncdomain.FingerprintAttachment(id.ProjectKey, *server) + } + if err != nil { return fail(fmt.Sprintf("invalid server %s: %s", id.Kind, err)) } } + if resource.Source != nil && resource.LocalProjection != nil { + if resource.ServerProjection != nil { + resource.LocalProjection, err = syncdomain.FillReadOnlyFields( + resource.LocalProjection, resource.ServerProjection, + ) + } + if err == nil { + resource.LocalFingerprint, err = syncdomain.FingerprintProjection(id, resource.LocalProjection) + } + if err != nil { + return fail(err.Error()) + } + } resource.Action = chooseAction(tracked, resource) switch resource.Action { case ActionError: - resource.Error = missingAttachmentError(id) + resource.Error = missingAttachmentError(id, resource.Source != nil) case ActionCreateServer: - rejectInvalidCreate(&resource, validateAttachmentCreate(*local)) + rejectInvalidCreate(&resource, validateAttachmentCreate(local.Attachment)) } + blockUnsupportedFields(&resource) if resource.LocalFingerprint != resource.ServerFingerprint { - resource.Diff = attachmentDiff(server, local) + if resource.Source != nil { + resource.Diff = projectionDiff(resource.ServerProjection, resource.LocalProjection) + } else { + resource.Diff = attachmentDiff(server, resource.LocalAttachment) + } } resource.changedAttachments = changedAttachmentIDs(resource) return resource @@ -324,9 +575,16 @@ func planAttachment( // missingAttachmentError explains how to create a tool or skill that exists // only in a local file, or how to stop syncing it. -func missingAttachmentError(id ResourceID) string { +func missingAttachmentError(id ResourceID, linked bool) string { + missing := missingAttachment(id) + if linked { + // A descriptor has no upsert, so the recovery is to create the + // resource in LaunchDarkly. + missing = fmt.Sprintf("%s %q does not exist in LaunchDarkly. Create it in LaunchDarkly and run sync again.", + id.Kind, id.LookupKey) + } return fmt.Sprintf("%s To stop syncing it, run ldcli sync %ss detach --project %s --key %s.", - missingAttachment(id), id.Kind, id.ProjectKey, id.LookupKey) + missing, id.Kind, id.ProjectKey, id.LookupKey) } // missingAttachment says that LaunchDarkly does not have the tool or skill @@ -419,9 +677,21 @@ func chooseAction(tracked bool, resource PlannedResource) Action { } } +// upgradeStalePins turns a row that has nothing else to do into a push, when +// LaunchDarkly pins older tool or skill versions than the latest. +func upgradeStalePins(resource *PlannedResource) { + if resource.ServerHasStaleAttachmentPins && + (resource.Action == ActionInSync || resource.Action == ActionUpdateManifest) { + resource.Action = ActionUpdateServer + } +} + // RequiresConfirmation reports whether the plan changes a local file or // LaunchDarkly. A change to the manifest only does not need a confirmation. func (plan Plan) RequiresConfirmation() bool { + if plan.ForceConfirmation { + return true + } return slices.ContainsFunc(plan.Resources, func(resource PlannedResource) bool { return resource.Action.changesServer() || resource.Action.changesLocal() }) @@ -451,7 +721,11 @@ func (plan Plan) BlockingError() error { case ActionConflict: blocking = append(blocking, fmt.Errorf("cannot sync conflicted resource %s", resource.ID.Label())) case ActionError: - blocking = append(blocking, fmt.Errorf("cannot sync %s: %s", resource.ID.Label(), resource.Error)) + if resource.Problem != nil { + blocking = append(blocking, fmt.Errorf("cannot sync %s: %w", resource.ID.Label(), resource.Problem)) + } else { + blocking = append(blocking, fmt.Errorf("cannot sync %s: %s", resource.ID.Label(), resource.Error)) + } } } return errors.Join(blocking...) diff --git a/internal/sync/prompt/plan_test.go b/internal/sync/prompt/plan_test.go index 675752b65..ebc8d6974 100644 --- a/internal/sync/prompt/plan_test.go +++ b/internal/sync/prompt/plan_test.go @@ -1,6 +1,8 @@ package prompt import ( + "os" + "path/filepath" "slices" "strings" "testing" @@ -9,6 +11,8 @@ import ( "github.com/stretchr/testify/require" syncdomain "github.com/launchdarkly/ldcli/internal/sync" + synclink "github.com/launchdarkly/ldcli/internal/sync/link" + synclocal "github.com/launchdarkly/ldcli/internal/sync/local" syncmanifest "github.com/launchdarkly/ldcli/internal/sync/manifest" ) @@ -58,6 +62,51 @@ func TestBuildPlanThreeWayMatrix(t *testing.T) { } } +func TestIndexedSelectionBaselineBlocksReorderedItems(t *testing.T) { + reference := syncdomain.Reference{ + File: "agents.json", Format: "json", Select: "agents[0]", + Map: map[string]string{"prompt": "prompt"}, + } + digest, err := syncdomain.ReferenceDigest(reference) + require.NoError(t, err) + baselineSelection := syncdomain.IndexedSelection{ + Path: "select#0", CollectionLength: 2, Fingerprint: "sha256:" + strings.Repeat("a", 64), + } + baseline := syncmanifest.Resource{ + ResourceKind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/default", + Fingerprint: "sha256:" + strings.Repeat("b", 64), FingerprintSchema: syncdomain.ProjectionFingerprintSchema, + ReferenceDigest: digest, AdapterBehavior: "json/1", + IndexedSelections: []syncdomain.IndexedSelection{baselineSelection}, + } + resource := PlannedResource{ + ID: syncdomain.VariationID("project", "config", "default"), + Action: ActionUpdateServer, Source: &reference, + IndexedSelections: []syncdomain.IndexedSelection{baselineSelection}, + } + + checkSourceBaseline(&resource, baseline, true) + assert.Equal(t, ActionUpdateServer, resource.Action) + + resource.IndexedSelections[0].Fingerprint = "sha256:" + strings.Repeat("c", 64) + checkSourceBaseline(&resource, baseline, true) + require.NotNil(t, resource.Problem) + assert.Equal(t, syncdomain.ErrorIndexedSelectionChanged, resource.Problem.Code) + assert.Equal(t, ActionError, resource.Action) + + // A lock written before indexed selections were recorded has none, like + // the other optional baseline fields, and that is not a change. + older := baseline + older.IndexedSelections = nil + resource = PlannedResource{ + ID: syncdomain.VariationID("project", "config", "default"), + Action: ActionUpdateServer, Source: &reference, + IndexedSelections: []syncdomain.IndexedSelection{baselineSelection}, + } + checkSourceBaseline(&resource, older, true) + assert.Nil(t, resource.Problem) + assert.Equal(t, ActionUpdateServer, resource.Action) +} + func TestBuildPlanFirstSync(t *testing.T) { local := testVariation("local") different := testVariation("server") @@ -100,6 +149,42 @@ func TestBuildPlanDoesNotTreatManifestAttachmentsAsIndependentTargets(t *testing require.Empty(t, plan.Resources) } +func TestDescriptorRelationshipsUseServerConfigMode(t *testing.T) { + id := testResourceID() + tools := []syncdomain.AttachmentRef{{Key: "search"}} + skills := []syncdomain.AttachmentRef{{Key: "support"}} + reference := syncdomain.Reference{ + File: "prompt.md", Format: "markdown", Tools: &tools, Skills: &skills, + } + local := syncdomain.SyncedResource{ + Kind: id.Kind, ProjectKey: id.ProjectKey, LookupKey: id.LookupKey, + Source: &reference, Upsert: true, + Variation: syncdomain.Variation{ + Mode: syncdomain.VariationModeAgent, Key: "default", Name: "Default", + Tools: tools, Skills: skills, + }, + Projection: syncdomain.VariationProjection{ + Mode: syncdomain.VariationModeAgent, + FieldStates: map[syncdomain.Field]syncdomain.FieldState{}, + }, + } + + row := planResource(id, &local, ServerResource{ + ConfigMode: syncdomain.VariationModeCompletion, + }, "", false) + + assert.Equal(t, ActionError, row.Action) + assert.Equal(t, "skills can only be attached to agent-mode configs", row.Error) + + local.Variation.Skills = nil + reference.Skills = nil + local.Source = &reference + row = planResource(id, &local, ServerResource{ + ConfigMode: syncdomain.VariationModeCompletion, + }, "", false) + assert.NotEqual(t, ActionError, row.Action) +} + func TestBuildPlanOmitsDiffForEquivalentAPIDefaults(t *testing.T) { local := testVariation("example") local.Model = map[string]any{"modelName": "gpt-5"} @@ -382,7 +467,7 @@ func TestCheckAttachmentBaselinesStopsAVariationFromOverwritingAnUnsyncedEdit(t lock := syncmanifest.New() lock.SetFingerprint(old.ID("production"), attachmentFingerprint(t, old)) - checkAttachmentBaselines(resources, lock) + require.NoError(t, checkAttachmentBaselines(t.TempDir(), resources, lock)) assert.Equal(t, ActionUpdateServer, resources[0].Action, "LaunchDarkly still has the recorded tool") assert.Equal(t, ActionConflict, resources[1].Action, "the pull would overwrite the local edit") @@ -393,7 +478,7 @@ func TestCheckAttachmentBaselinesStopsAVariationFromOverwritingAnUnsyncedEdit(t untracked := []PlannedResource{pushEdit} untracked[0].changedAttachments = changedAttachmentIDs(pushEdit) - checkAttachmentBaselines(untracked, syncmanifest.New()) + require.NoError(t, checkAttachmentBaselines(t.TempDir(), untracked, syncmanifest.New())) assert.Equal(t, ActionConflict, untracked[0].Action, "without a baseline, two versions of the tool conflict") } @@ -511,3 +596,548 @@ func attachmentFingerprint(t *testing.T, attachment syncdomain.Attachment) strin require.NoError(t, err) return fingerprint } + +// sync.lock records the projection fingerprint of a linked tool, so the +// baseline check must project the overwritten copy the same way. Otherwise an +// unowned LaunchDarkly field makes every push of a variation a conflict. +func TestCheckAttachmentBaselinesProjectsALinkedTool(t *testing.T) { + root := t.TempDir() + writeTestDescriptor := func(relativePath, content string) { + path := filepath.Join(root, filepath.FromSlash(relativePath)) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) + } + writeTestDescriptor("tool.json", `{"description":"Linked","schema":{"type":"object"}}`) + writeTestDescriptor(".launchdarkly/production/tools/search.ref.yaml", + "formatVersion: 1\nfile: tool.json\nformat: json\nselect: $\nmap:\n description: description\n") + local, err := synclocal.CompileAttachments(root, func(ResourceID) bool { return true }) + require.NoError(t, err) + require.Len(t, local, 1) + baseline, err := local[0].Fingerprint() + require.NoError(t, err) + lock := syncmanifest.New() + lock.SetFingerprint(local[0].ID(), baseline) + + withTool := func(name string, tool syncdomain.Attachment) *syncdomain.Variation { + variation := testVariation(name) + variation.Tools = []syncdomain.AttachmentRef{{Key: "search"}} + variation.Attachments = []syncdomain.Attachment{tool} + return &variation + } + + // A field that only LaunchDarkly holds is not a change of the linked tool. + unowned := local[0].Attachment + unownedTool := *unowned.Tool + unownedTool.CustomParameters = map[string]any{"timeout": 5.0} + unowned.Tool = &unownedTool + same := PlannedResource{ + ID: testResourceID(), Action: ActionUpdateServer, + Local: withTool("local", local[0].Attachment), Server: withTool("server", unowned), + } + assert.Empty(t, changedAttachmentIDs(same)) + + // The mapped description differs, and LaunchDarkly still has the + // recorded one, so the push is not a conflict. + serverCopy := local[0].Attachment + serverTool := *serverCopy.Tool + serverTool.Description = pointerTo("Recorded") + serverCopy.Tool = &serverTool + serverCopy.Projection = nil + recorded, err := syncdomain.SyncedAttachment{ProjectKey: "production", Attachment: serverCopy, Source: local[0].Source}.Fingerprint() + require.NoError(t, err) + lock.SetFingerprint(local[0].ID(), recorded) + push := PlannedResource{ + ID: testResourceID(), Action: ActionUpdateServer, + Local: withTool("local", local[0].Attachment), Server: withTool("server", serverCopy), + } + push.changedAttachments = changedAttachmentIDs(push) + require.Equal(t, []ResourceID{local[0].ID()}, push.changedAttachments) + resources := []PlannedResource{push} + + require.NoError(t, checkAttachmentBaselines(root, resources, lock)) + + assert.Equal(t, ActionUpdateServer, resources[0].Action, "the mapped description did not change in LaunchDarkly") +} + +func TestBlockUnsupportedFieldsReportsTheSameFieldEveryTime(t *testing.T) { + projection := syncdomain.ToolProjection{FieldStates: map[syncdomain.Field]syncdomain.FieldState{ + syncdomain.FieldSchema: {Capability: syncdomain.CapabilityReadOnly, Reason: "schema is dynamic"}, + syncdomain.FieldDescription: {Capability: syncdomain.CapabilityReadOnly, Reason: "description is dynamic"}, + }} + for range 50 { + resource := PlannedResource{ + ID: syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "production", LookupKey: "search"}, + Action: ActionUpdateServer, + Source: &syncdomain.Reference{File: "tool.json", Format: "json"}, + LocalProjection: projection, + } + + blockUnsupportedFields(&resource) + + require.NotNil(t, resource.Problem) + assert.Equal(t, `field "description" is read-only: description is dynamic`, resource.Problem.Message) + } +} + +func TestSourcePullStopsWhenLaunchDarklyHasNoValueForAMappedField(t *testing.T) { + description := "Local" + direct := syncdomain.FieldState{Capability: syncdomain.CapabilityDirect, Known: true} + resource := PlannedResource{ + ID: syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "production", LookupKey: "search"}, + Action: ActionUpdateLocal, + Source: &syncdomain.Reference{File: "tool.json", Format: "json"}, + LocalProjection: syncdomain.ToolProjection{ + Description: &description, + FieldStates: map[syncdomain.Field]syncdomain.FieldState{syncdomain.FieldDescription: direct}, + }, + ServerProjection: syncdomain.ToolProjection{ + FieldStates: map[syncdomain.Field]syncdomain.FieldState{syncdomain.FieldDescription: direct}, + }, + } + + _, err := sourcePullReplacement(resource) + + problem, ok := syncdomain.ProblemFrom(err) + require.True(t, ok) + assert.Equal(t, syncdomain.ErrorSourceNotWritable, problem.Code) + assert.Contains(t, problem.Message, `mapped field "description"`) +} + +func TestAllowRelinkKeepsTheStalePinRefresh(t *testing.T) { + local := testVariation("same") + server := local + resource := PlannedResource{ + ID: testResourceID(), Action: ActionError, Local: &local, Server: &server, + Source: &syncdomain.Reference{File: "agents.py", Format: "python"}, + BaselineFingerprint: "same", LocalFingerprint: "same", ServerFingerprint: "same", + Problem: syncdomain.NewProblem(syncdomain.ErrorBindingStale, "stale", ""), + ServerHasStaleAttachmentPins: true, + } + resources := []PlannedResource{resource} + + allowRelink(resources, resource.ID) + + assert.Nil(t, resources[0].Problem) + assert.Equal(t, ActionUpdateServer, resources[0].Action, "the relink still repins LaunchDarkly") +} + +// A variation sync records, for each linked tool or skill that the variation +// uses, the content that LaunchDarkly has, not the repository copy, so that a +// local edit that no row synced stays a local change. +func TestRecordAttachmentStateKeepsAnUnsyncedLinkedEditLocal(t *testing.T) { + root := t.TempDir() + writeTestDescriptor := func(relativePath, content string) { + path := filepath.Join(root, filepath.FromSlash(relativePath)) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) + } + writeTestDescriptor("tool.json", `{"description":"Edited locally","schema":{"type":"object"}}`) + writeTestDescriptor(".launchdarkly/production/tools/search.ref.yaml", + "formatVersion: 1\nfile: tool.json\nformat: json\nselect: $\nmap:\n description: description\n") + local, err := synclocal.CompileAttachments(root, func(ResourceID) bool { return true }) + require.NoError(t, err) + server := syncdomain.Attachment{Kind: syncdomain.AttachmentTool, Version: 3, Tool: &syncdomain.Tool{ + Key: "search", Description: pointerTo("In LaunchDarkly"), Schema: map[string]any{"type": "object"}, + }} + expected, err := syncdomain.SyncedAttachment{ProjectKey: "production", Attachment: server, Source: local[0].Source}.Fingerprint() + require.NoError(t, err) + manifest := syncmanifest.New() + + require.NoError(t, recordAttachmentState(&manifest, root, "production", []syncdomain.Attachment{server}, func(ResourceID) bool { return true })) + + index := slices.IndexFunc(manifest.Resources, func(resource syncmanifest.Resource) bool { return resource.ID() == local[0].ID() }) + require.GreaterOrEqual(t, index, 0) + assert.Equal(t, expected, manifest.Resources[index].Fingerprint, "the baseline is the LaunchDarkly content") +} + +func TestRecordAttachmentStateKeepsTheBaselineOfALinkedToolThatNoRowSynced(t *testing.T) { + root := t.TempDir() + writeTestFile := func(relativePath, content string) { + path := filepath.Join(root, filepath.FromSlash(relativePath)) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) + } + writeTestFile("tool.json", `{"description":"Local","schema":{"type":"object"}}`) + writeTestFile(".launchdarkly/production/tools/search.ref.yaml", + "formatVersion: 1\nfile: tool.json\nformat: json\nselect: $\nmap:\n description: description\n") + linked := syncdomain.Attachment{Kind: syncdomain.AttachmentTool, Version: 3, Tool: &syncdomain.Tool{ + Key: "search", Description: pointerTo("LaunchDarkly edit"), Schema: map[string]any{"type": "object"}, + }} + managed := syncdomain.Attachment{Kind: syncdomain.AttachmentTool, Version: 2, Tool: &syncdomain.Tool{ + Key: "lookup", Description: pointerTo("Written with the variation"), Schema: map[string]any{"type": "object"}, + }} + linkedID := syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "production", LookupKey: "search"} + managedID := syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "production", LookupKey: "lookup"} + manifest := syncmanifest.New() + manifest.SetFingerprint(linkedID, "sha256:last-sync") + manifest.SetFingerprint(managedID, "sha256:last-sync") + + // No row of this run synced either tool. + err := recordAttachmentState(&manifest, root, "production", []syncdomain.Attachment{linked, managed}, func(ResourceID) bool { return false }) + + require.NoError(t, err) + fingerprints := map[ResourceID]string{} + for _, resource := range manifest.Resources { + fingerprints[resource.ID()] = resource.Fingerprint + } + assert.Equal(t, "sha256:last-sync", fingerprints[linkedID], "the linked tool lives in a source that the variation write does not touch, so its baseline stays") + expected, err := syncdomain.FingerprintAttachment("production", managed) + require.NoError(t, err) + assert.Equal(t, expected, fingerprints[managedID], "the variation write synced the managed tool, so its baseline moves") +} + +// A conflict over a shared tool survives the sync setting of the variation, +// and a choice that the setting forbids is an error, not a write. +func TestAttachmentConflictSurvivesASyncSetting(t *testing.T) { + local := testVariation("same") + server := local + resource := PlannedResource{ + ID: testResourceID(), Action: ActionConflict, Local: &local, Server: &server, + Source: &syncdomain.Reference{File: "agents.py", Format: "python"}, attachmentConflict: true, + } + + enforceDirection(&resource, syncdomain.SyncDirectionServerCanonical) + assert.Equal(t, ActionConflict, resource.Action, "the setting does not resolve a shared-attachment conflict") + + resolved := applyConflictResolutions(Plan{Resources: []PlannedResource{resource}}, map[ResourceID]ConflictResolution{ + resource.ID: ConflictUseLocal, + }) + require.Len(t, resolved.Resources, 1) + assert.Equal(t, ActionError, resolved.Resources[0].Action) + assert.Contains(t, resolved.Resources[0].Error, "cannot push") + + resolved = applyConflictResolutions(Plan{Resources: []PlannedResource{resource}}, map[ResourceID]ConflictResolution{ + resource.ID: ConflictUseLaunchDarkly, + }) + assert.Equal(t, ActionUpdateLocal, resolved.Resources[0].Action, "the side that the setting allows applies") +} + +func TestSourcePushStopsWhenTheSourceHasNoValueForAMappedField(t *testing.T) { + description := "Server" + direct := syncdomain.FieldState{Capability: syncdomain.CapabilityDirect, Known: true} + resource := PlannedResource{ + ID: syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "production", LookupKey: "search"}, + Action: ActionUpdateServer, + Source: &syncdomain.Reference{File: "tool.json", Format: "json"}, + LocalProjection: syncdomain.ToolProjection{ + FieldStates: map[syncdomain.Field]syncdomain.FieldState{ + syncdomain.FieldDescription: {Capability: syncdomain.CapabilityDirect}, + }, + }, + ServerProjection: syncdomain.ToolProjection{ + Description: &description, + FieldStates: map[syncdomain.Field]syncdomain.FieldState{syncdomain.FieldDescription: direct}, + }, + } + + err := checkSourcePushClears(resource) + + problem, ok := syncdomain.ProblemFrom(err) + require.True(t, ok) + assert.Equal(t, syncdomain.ErrorSourceNotWritable, problem.Code) + assert.Equal(t, `the source has no value for mapped field "description", so the push cannot clear it`, problem.Message) + assert.Equal(t, "tool.json", problem.File) + + local := "Local" + resource.LocalProjection = syncdomain.ToolProjection{ + Description: &local, + FieldStates: map[syncdomain.Field]syncdomain.FieldState{syncdomain.FieldDescription: direct}, + } + assert.NoError(t, checkSourcePushClears(resource), "a source with a value for every mapped field pushes") +} + +// Both projections of a row come from one reference, so they map the same +// fields. The check still walks the fields of both sides, so a field that only +// the side with the value knows cannot slip past it. +func TestMissingFieldWalksTheFieldsOfBothProjections(t *testing.T) { + description := "Server" + direct := syncdomain.FieldState{Capability: syncdomain.CapabilityDirect, Known: true} + withValue := syncdomain.ToolProjection{ + Description: &description, + FieldStates: map[syncdomain.Field]syncdomain.FieldState{syncdomain.FieldDescription: direct}, + } + withoutField := syncdomain.ToolProjection{FieldStates: map[syncdomain.Field]syncdomain.FieldState{}} + + field, missing := missingField(withValue, withoutField) + assert.True(t, missing, "the field is missing when only the first projection maps it") + assert.Equal(t, syncdomain.FieldDescription, field) + + _, missing = missingField(withoutField, withValue) + assert.False(t, missing, "a field that only the second projection has a value for is not missing") + + _, missing = missingField(withValue, withValue) + assert.False(t, missing) +} + +func TestConflictResolvedToLaunchDarklyNeedsDetachForALinkedResource(t *testing.T) { + local := testVariation("local") + resource := PlannedResource{ + ID: testResourceID(), Action: ActionConflict, Local: &local, + Source: &syncdomain.Reference{File: "agents.py", Format: "python"}, + } + + resolved := applyConflictResolutions(Plan{Resources: []PlannedResource{resource}}, map[ResourceID]ConflictResolution{ + resource.ID: ConflictUseLaunchDarkly, + }) + + require.Len(t, resolved.Resources, 1) + assert.Equal(t, ActionError, resolved.Resources[0].Action, "a linked resource is detached, not deleted") + require.NotNil(t, resolved.Resources[0].Problem) + assert.Equal(t, syncdomain.ErrorResourceDeleted, resolved.Resources[0].Problem.Code) + assert.Contains(t, resolved.Resources[0].Problem.Suggestion, "detach") +} + +func TestPushClearCheckIsAPlanProblem(t *testing.T) { + description := "Server" + direct := syncdomain.FieldState{Capability: syncdomain.CapabilityDirect, Known: true} + resources := []PlannedResource{{ + ID: syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "production", LookupKey: "search"}, + Action: ActionUpdateServer, + Source: &syncdomain.Reference{File: "tool.json", Format: "json"}, + LocalProjection: syncdomain.ToolProjection{ + FieldStates: map[syncdomain.Field]syncdomain.FieldState{ + syncdomain.FieldDescription: {Capability: syncdomain.CapabilityDirect}, + }, + }, + ServerProjection: syncdomain.ToolProjection{ + Description: &description, + FieldStates: map[syncdomain.Field]syncdomain.FieldState{syncdomain.FieldDescription: direct}, + }, + }} + + checkSourcePublications(t.TempDir(), resources) + + assert.Equal(t, ActionError, resources[0].Action, "the plan shows the problem before the review") + require.NotNil(t, resources[0].Problem) + assert.Equal(t, syncdomain.ErrorSourceNotWritable, resources[0].Problem.Code) +} + +func TestLockRecordsLinkOnlyAfterTheLinkedResourceSyncs(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(root, syncdomain.RootDir), 0o755)) + workspace := syncWorkspace{root: root, local: synclocal.NewStore(root)} + id := syncdomain.VariationID("project", "support", "default") + reference := syncdomain.Reference{File: "prompt.md", Format: "markdown", Select: "$", Map: map[string]string{"prompt": "body"}} + prepared := synclink.Prepared{ID: id, Reference: reference} + + assert.False(t, lockRecordsLink(workspace, prepared), "an empty lock does not record the link") + + lock := syncmanifest.Manifest{Resources: []syncmanifest.Resource{{ + ResourceKind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "support/default", + Fingerprint: "sha256:" + strings.Repeat("a", 64), + }}} + require.NoError(t, syncmanifest.WriteLock(workspace.local, lock)) + assert.False(t, lockRecordsLink(workspace, prepared), "a lock without the descriptor does not record the link") + + require.NoError(t, lock.SetSource(id, &reference, "markdown/1", nil)) + require.NoError(t, syncmanifest.WriteLock(workspace.local, lock)) + assert.True(t, lockRecordsLink(workspace, prepared), "a lock with the descriptor digest records the link") +} + +func TestSourcePullGroupsJoinTwoNamesOfOneFile(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(root, "lib"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(root, "lib", "prompts.json"), []byte(`{"a": "1", "b": "2"}`), 0o644)) + require.NoError(t, os.Symlink(filepath.Join(root, "lib"), filepath.Join(root, "linked"))) + resources := []PlannedResource{ + {ID: syncdomain.VariationID("project", "support", "a"), Action: ActionUpdateLocal, + Source: &syncdomain.Reference{File: "lib/prompts.json", Format: "json"}}, + {ID: syncdomain.VariationID("project", "support", "b"), Action: ActionUpdateLocal, + Source: &syncdomain.Reference{File: "linked/prompts.json", Format: "json"}}, + {ID: syncdomain.VariationID("project", "support", "c"), Action: ActionUpdateLocal, + Source: &syncdomain.Reference{File: "other.json", Format: "json"}}, + } + + groups := sourcePullGroups(root, resources) + + require.Len(t, groups, 2, "two names of one file form one group") + assert.ElementsMatch(t, []int{0, 1}, groups[0]) + assert.Equal(t, []int{2}, groups[1]) +} + +// A group key has one form, whether or not the path resolves, so a pull of a +// file that does not exist yet and a pull that depends on that file share a +// group. +func TestSourcePullGroupsUseOneFormForAPathThatDoesNotResolve(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "other.json"), []byte(`{"a": "1"}`), 0o644)) + assert.Equal(t, filepath.Join(root, "missing.json"), resolvedSourcePath(root, "missing.json"), "an absolute path, as for a file that resolves") + resources := []PlannedResource{ + {ID: syncdomain.VariationID("project", "support", "a"), Action: ActionUpdateLocal, + Source: &syncdomain.Reference{File: "missing.json", Format: "json"}}, + {ID: syncdomain.VariationID("project", "support", "b"), Action: ActionUpdateLocal, + Source: &syncdomain.Reference{File: "other.json", Format: "json"}, SourceDependencies: []string{"missing.json"}}, + } + + groups := sourcePullGroups(root, resources) + + require.Len(t, groups, 1) + assert.ElementsMatch(t, []int{0, 1}, groups[0]) +} + +func TestRecordAttachmentStateLeavesTheManifestWhenALinkedToolDoesNotCompile(t *testing.T) { + root := t.TempDir() + writeTestDescriptor := filepath.Join(root, syncdomain.RootDir, "project", "tools", "search.ref.yaml") + require.NoError(t, os.MkdirAll(filepath.Dir(writeTestDescriptor), 0o755)) + require.NoError(t, os.WriteFile(writeTestDescriptor, []byte("invalid: [\n"), 0o644)) + manifest := syncmanifest.Manifest{} + tool := syncdomain.Attachment{Kind: syncdomain.AttachmentTool, Tool: &syncdomain.Tool{ + Key: "search", Schema: map[string]any{"type": "object"}, + }} + + err := recordAttachmentState(&manifest, root, "project", []syncdomain.Attachment{tool}, func(ResourceID) bool { return true }) + + require.Error(t, err) + assert.Empty(t, manifest.Resources, "a compile error records nothing") +} + +func TestCodeCanonicalLinkedResourceDeletedInLaunchDarklyAsksForDetach(t *testing.T) { + local := testVariation("local") + resource := PlannedResource{ + ID: testResourceID(), Action: ActionConflict, Local: &local, + Source: &syncdomain.Reference{File: "agents.py", Format: "python"}, + } + + enforceDirection(&resource, syncdomain.SyncDirectionCodeCanonical) + + assert.Equal(t, ActionError, resource.Action) + require.NotNil(t, resource.Problem, "the repository has a value, and LaunchDarkly deleted the resource") + assert.Equal(t, syncdomain.ErrorResourceDeleted, resource.Problem.Code) + assert.Contains(t, resource.Problem.Suggestion, "detach") +} + +func TestLinkedResourceDeletedInLaunchDarklyWithLocalChangesAsksForDetachUnderBoth(t *testing.T) { + local := testVariation("local") + resource := PlannedResource{ + ID: testResourceID(), Action: ActionConflict, Local: &local, + Source: &syncdomain.Reference{File: "agents.py", Format: "python"}, + } + + enforceDirection(&resource, syncdomain.SyncDirectionBoth) + + assert.Equal(t, ActionError, resource.Action, "the conflict does not stay a conflict that a choice turns into a create") + require.NotNil(t, resource.Problem) + assert.Equal(t, syncdomain.ErrorResourceDeleted, resource.Problem.Code) + assert.Contains(t, resource.Problem.Suggestion, "detach") +} + +func TestVerifyLocalResultComparesTheProjectionOfALinkedVariation(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "agents.py"), []byte("from agents import Agent\nsupport = Agent(instructions=\"Local text\")\n"), 0o644)) + id := syncdomain.VariationID("production", "support", "default") + _, err := synclocal.NewStore(root).WriteDescriptor(id, syncdomain.Reference{ + File: "agents.py", Format: "python", Select: `agents[id="support"]`, Map: map[string]string{"prompt": "instructions"}, + }) + require.NoError(t, err) + compiled, err := synclocal.CompileWorkspace(root) + require.NoError(t, err) + require.Len(t, compiled, 1) + resource := func(text string) PlannedResource { + server := testVariation("Server") + server.Instructions = text + projection, err := syncdomain.ProjectVariation(server, *compiled[0].Source) + require.NoError(t, err) + fingerprint, err := syncdomain.FingerprintProjection(id, projection) + require.NoError(t, err) + return PlannedResource{ + ID: id, Action: ActionUpdateLocal, Source: compiled[0].Source, Server: &server, + ServerMode: syncdomain.VariationModeAgent, ServerProjection: projection, ServerFingerprint: fingerprint, + } + } + + require.NoError(t, verifyLocalResult(root, resource("Local text")), "the source holds the mapped fields of LaunchDarkly") + err = verifyLocalResult(root, resource("Other text")) + require.ErrorContains(t, err, "did not match the expected state", "a source without the pulled text is not in sync") +} + +func TestServerCanonicalLinkedResourceDeletedInLaunchDarklyAsksForDetach(t *testing.T) { + local := testVariation("local") + resource := PlannedResource{ + ID: testResourceID(), Action: ActionConflict, Local: &local, + Source: &syncdomain.Reference{File: "agents.py", Format: "python"}, + } + + enforceDirection(&resource, syncdomain.SyncDirectionServerCanonical) + + assert.Equal(t, ActionError, resource.Action) + require.NotNil(t, resource.Problem, "LaunchDarkly deleted the resource, so the recovery is detach") + assert.Equal(t, syncdomain.ErrorResourceDeleted, resource.Problem.Code) +} + +func TestSourceLinkValidationNamesTheRealFileAndTheFlag(t *testing.T) { + action := SourceLinkAction{ + ID: syncdomain.VariationID("production", "support", "default"), + File: "agents.py", Format: "python", Select: "agents[", Map: map[string]string{"prompt": "instructions"}, + } + + err := action.validate(syncdomain.ScopePrompts) + + problem, ok := syncdomain.ProblemFrom(err) + require.True(t, ok) + assert.Equal(t, "agents.py", problem.File, "the problem names the --file value") + assert.Contains(t, problem.Message, `select "agents["`, "the problem names the part of the reference that failed") +} + +func TestRecordSuccessfulChangeFailsWhenTheBindingCannotBeRecorded(t *testing.T) { + manifest := syncmanifest.Manifest{Resources: []syncmanifest.Resource{{ + ResourceKind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/default", + Fingerprint: "sha256:" + strings.Repeat("a", 64), + }}} + resource := PlannedResource{ + ID: syncdomain.VariationID("project", "config", "default"), Action: ActionUpdateServer, + LocalFingerprint: "sha256:" + strings.Repeat("b", 64), + Source: &syncdomain.Reference{File: "prompt.txt", Format: "no-such-format"}, + } + + err := recordSuccessfulChange(&manifest, resource) + + require.Error(t, err, "a binding that cannot be recorded fails the row") +} + +func TestMissingDescriptorOfALinkedResourceIsAProblemNotARestore(t *testing.T) { + id := testResourceID() + server := testVariation("server") + manifest := syncmanifest.Manifest{Resources: []syncmanifest.Resource{{ + ResourceKind: id.Kind, ProjectKey: id.ProjectKey, LookupKey: id.LookupKey, + Fingerprint: "sha256:" + strings.Repeat("a", 64), FingerprintSchema: syncdomain.ProjectionFingerprintSchema, + ReferenceDigest: "sha256:" + strings.Repeat("b", 64), AdapterBehavior: "markdown/1", + }}} + + plan := BuildPlan(manifest, nil, map[ResourceID]ServerResource{ + id: {Variation: &server, ConfigMode: syncdomain.VariationModeAgent}, + }) + + require.Len(t, plan.Resources, 1) + assert.Equal(t, ActionError, plan.Resources[0].Action, "a missing descriptor is not restored as a managed file") + require.NotNil(t, plan.Resources[0].Problem) + assert.Equal(t, syncdomain.ErrorLinkedSourceMissing, plan.Resources[0].Problem.Code) + assert.Contains(t, plan.Resources[0].Problem.Suggestion, "detach") +} + +func TestLinkedAttachmentPushKeepsItsProjection(t *testing.T) { + description, serverDescription := "Local", "Server" + direct := syncdomain.FieldState{Capability: syncdomain.CapabilityDirect, Known: true} + projection := syncdomain.ToolProjection{ + Description: &description, + FieldStates: map[syncdomain.Field]syncdomain.FieldState{syncdomain.FieldDescription: direct}, + } + server := syncdomain.Attachment{Kind: syncdomain.AttachmentTool, Tool: &syncdomain.Tool{ + Key: "search", Description: &serverDescription, Schema: map[string]any{"type": "object"}, Tags: []string{"kept"}, + }} + resource := PlannedResource{ + ID: syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "production", LookupKey: "search"}, + Action: ActionUpdateServer, + Source: &syncdomain.Reference{File: "tool.json", Format: "json"}, + LocalAttachment: &syncdomain.Attachment{Kind: syncdomain.AttachmentTool, Tool: &syncdomain.Tool{Key: "search"}}, + ServerAttachment: &server, + LocalProjection: projection, + ServerProjection: syncdomain.ToolProjection{Description: &serverDescription, FieldStates: projection.FieldStates}, + } + + local, err := attachmentToPublish(resource) + + require.NoError(t, err) + assert.Equal(t, "Local", *local.Tool.Description, "the mapped field comes from the source") + assert.Equal(t, []string{"kept"}, local.Tool.Tags, "an unowned field comes from LaunchDarkly") + assert.Equal(t, projection, local.Projection, "the publish merges the projection onto the latest copy") +} diff --git a/internal/sync/prompt/review.go b/internal/sync/prompt/review.go index 181fdbad9..43a3f2fdf 100644 --- a/internal/sync/prompt/review.go +++ b/internal/sync/prompt/review.go @@ -24,7 +24,9 @@ func reviewAndConfirmPlan(options Options, plan Plan, interactive bool) (bool, e if err := plan.BlockingError(); err != nil { return false, errors.Join(review(), err) } - if !plan.HasChanges() { + // A link that only rebinds a resource has no plan changes, but it still + // writes the descriptor and the lock, so it asks for the confirmation. + if !plan.HasChanges() && !plan.ForceConfirmation { if options.OutputKind != "" && options.OutputKind != outputPlaintext { return false, writePlanOutput(options.Output, options.OutputKind, plan, options.Diff) } diff --git a/internal/sync/prompt/runner.go b/internal/sync/prompt/runner.go index 88f7d9866..a6739a01b 100644 --- a/internal/sync/prompt/runner.go +++ b/internal/sync/prompt/runner.go @@ -17,6 +17,7 @@ import ( syncbootstrap "github.com/launchdarkly/ldcli/internal/sync/bootstrap" syncdetach "github.com/launchdarkly/ldcli/internal/sync/detach" syncinteractive "github.com/launchdarkly/ldcli/internal/sync/interactive" + synclink "github.com/launchdarkly/ldcli/internal/sync/link" synclocal "github.com/launchdarkly/ldcli/internal/sync/local" syncmanifest "github.com/launchdarkly/ldcli/internal/sync/manifest" syncsource "github.com/launchdarkly/ldcli/internal/sync/source" @@ -77,6 +78,8 @@ func (runner Runner) Run(options Options) error { return runner.runAdd(options, workspace, action) case DetachAction: return runner.runDetach(options, workspace, action) + case SourceLinkAction: + return runner.runSourceLink(options, workspace, action) case AttachAction: attached, err := runner.runAttach(options, workspace, action) if err != nil || !attached { @@ -90,6 +93,23 @@ func (runner Runner) Run(options Options) error { return runner.runWorkspaceSync(options, workspace, nil) } +func (runner Runner) runSourceLink(options Options, workspace syncWorkspace, action SourceLinkAction) error { + prepared, err := synclink.Prepare(synclink.Options{ + Store: workspace.local, + RepositoryRoot: workspace.root, + WorkingDirectory: options.WorkingDirectory, + ID: action.ID, + File: action.File, + Format: action.Format, + Select: action.Select, + Map: action.Map, + }) + if err != nil { + return err + } + return runner.runWorkspaceSyncWithSource(options, workspace, nil, &prepared) +} + // nothingToSync tells the user how to start a sync of every kind. const nothingToSync = "Nothing to sync in this repository. " + "To start, run ldcli sync prompts add, ldcli sync tools add, or ldcli sync skills add." diff --git a/internal/sync/prompt/runner_test.go b/internal/sync/prompt/runner_test.go index 4d7d14725..9f2cac76e 100644 --- a/internal/sync/prompt/runner_test.go +++ b/internal/sync/prompt/runner_test.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "path/filepath" + "slices" "strings" "testing" "time" @@ -186,6 +187,12 @@ func TestRunnerChecksAttachmentTerminalOnOutput(t *testing.T) { } func TestValidateOptions(t *testing.T) { + require.ErrorContains(t, validateOptions(Options{ + Scope: syncdomain.ScopePrompts, + Action: SourceLinkAction{ + ID: syncdomain.VariationID("project", "config", "default"), File: "prompt.md", + }, + }), "--format is required") require.ErrorContains(t, validateOptions(Options{ Action: SyncAction{Watch: true, DryRun: true}, }), "watch does not support --dry-run") @@ -211,6 +218,21 @@ func TestSamePlannedResourceStateDetectsChangedAttachmentPins(t *testing.T) { assert.False(t, samePlannedResourceState(reviewed, current)) } +func TestSamePlannedResourceStateDetectsChangedIndexedSelection(t *testing.T) { + reviewed := PlannedResource{ + ID: ResourceID{Kind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/default"}, + Action: ActionUpdateServer, + IndexedSelections: []syncdomain.IndexedSelection{{ + Path: "select#0", CollectionLength: 2, Fingerprint: "sha256:" + strings.Repeat("a", 64), + }}, + } + current := reviewed + current.IndexedSelections = slices.Clone(reviewed.IndexedSelections) + current.IndexedSelections[0].Fingerprint = "sha256:" + strings.Repeat("b", 64) + + assert.False(t, samePlannedResourceState(reviewed, current)) +} + func TestAttachToVariationPreservesExistingSharedAttachmentEdits(t *testing.T) { root := t.TempDir() store := synclocal.NewStore(root) diff --git a/internal/sync/prompt/state.go b/internal/sync/prompt/state.go index 09f1e539c..a6949969d 100644 --- a/internal/sync/prompt/state.go +++ b/internal/sync/prompt/state.go @@ -3,6 +3,7 @@ package prompt import ( "errors" "fmt" + "maps" "slices" "strings" @@ -29,11 +30,32 @@ func (workspace syncWorkspace) loadState( client syncapi.Client, scope syncdomain.Scope, filter syncdomain.Filter, +) (workspaceState, error) { + return workspace.loadStateWithSource(client, scope, filter, nil) +} + +// sourceLink is the resource that a link command rebinds. Before the +// descriptor is written, proposal is the compiled resource of the new +// descriptor. A stale baseline of the resource does not block the plan, +// because the user reviews the new mapping in the same command. +type sourceLink struct { + id ResourceID + proposal *synclocal.SourceDefinition +} + +func (workspace syncWorkspace) loadStateWithSource( + client syncapi.Client, + scope syncdomain.Scope, + filter syncdomain.Filter, + link *sourceLink, ) (workspaceState, error) { synced, err := workspace.syncedResources() if err != nil { return workspaceState{}, err } + if link != nil { + synced = append(synced, link.id) + } if err := checkFilter(scope, filter, synced); err != nil { return workspaceState{}, err } @@ -44,7 +66,7 @@ func (workspace syncWorkspace) loadState( if err != nil { return workspaceState{}, err } - plan, localFiles, err := loadWorkspacePlan(workspace.root, baseline, client, scope, filter) + plan, localFiles, err := loadWorkspacePlan(workspace.root, baseline, client, scope, filter, link) if err != nil { return workspaceState{}, err } @@ -60,11 +82,27 @@ func loadWorkspacePlan( client syncapi.Client, scope syncdomain.Scope, filter syncdomain.Filter, + link *sourceLink, ) (Plan, map[ResourceID]syncdomain.SyncedResource, error) { - variations, err := compileWorkspace(repositoryRoot) + var proposal *synclocal.SourceDefinition + if link != nil { + proposal = link.proposal + } + // A link replaces the descriptor of its resource, so the current + // descriptor is not compiled: a relink is the recovery for a mapping + // that no longer compiles. + var exclude func(ResourceID) bool + if proposal != nil && proposal.Variation != nil { + id := proposal.Variation.ID() + exclude = func(other ResourceID) bool { return other == id } + } + variations, err := compileWorkspace(repositoryRoot, exclude) if err != nil { return Plan{}, nil, err } + if proposal != nil && proposal.Variation != nil { + variations = append(variations, *proposal.Variation) + } // A tool that a variation uses can also have a row of its own. The cache // reads it from LaunchDarkly once. attachments := newAttachmentCache(client) @@ -73,20 +111,35 @@ func loadWorkspacePlan( var localFiles map[ResourceID]syncdomain.SyncedResource if scope.Includes(syncdomain.KindVariation) { var rows Plan - if rows, localFiles, err = planVariationRows(repositoryRoot, baseline, client, attachments, filter, variations); err != nil { + if rows, localFiles, err = planVariationRows(baseline, client, attachments, filter, variations); err != nil { return Plan{}, nil, err } plan.Resources = rows.Resources } if scope.Includes(syncdomain.KindTool) || scope.Includes(syncdomain.KindSkill) { include := func(id ResourceID) bool { return scope.Includes(id.Kind) && filter.Includes(id) } - rows, err := planAttachmentRows(repositoryRoot, baseline.Lock, attachments, include) + rows, err := planAttachmentRows(repositoryRoot, baseline.Lock, attachments, include, proposal) if err != nil { return Plan{}, nil, err } plan.Resources = append(plan.Resources, rows...) } - referenced, err := planReferencedAttachments(repositoryRoot, baseline.Lock, attachments, plan.Resources) + // A relink restores the action of its row, the LaunchDarkly sync + // settings turn pulls into pushes and back, and the baseline checks turn + // a pull over a changed tool into a conflict, before anything looks at + // the actions: the rows for the tools and skills that a push or a + // conflict can publish, and the publication checks, all see the final + // side. + if link != nil { + allowRelink(plan.Resources, link.id) + } + if err := applySourceDirections(client, plan.Resources); err != nil { + return Plan{}, nil, err + } + if err := checkAttachmentBaselines(repositoryRoot, plan.Resources, baseline.Lock); err != nil { + return Plan{}, nil, err + } + referenced, err := planReferencedAttachments(repositoryRoot, client, baseline.Lock, attachments, plan.Resources, proposal) if err != nil { return Plan{}, nil, err } @@ -95,8 +148,8 @@ func loadWorkspacePlan( return syncdomain.CompareResourceIDs(left.ID, right.ID) }) - checkAttachmentBaselines(plan.Resources, baseline.Lock) checkAttachmentDeletes(plan.Resources, variations) + checkSourcePublications(repositoryRoot, plan.Resources) for index := range plan.Resources { resource := &plan.Resources[index] resource.SyncedElsewhere = baseline.Stale(resource.ID) @@ -112,6 +165,195 @@ func loadWorkspacePlan( return plan, localFiles, nil } +func checkSourcePublications(repositoryRoot string, resources []PlannedResource) { + // A push merges onto the LaunchDarkly copy and cannot clear a field, so + // the plan reports that before the review, as it does for a pull. + for index := range resources { + if resources[index].Source == nil || !resources[index].Action.changesServer() { + continue + } + if err := checkSourcePushClears(resources[index]); err != nil { + if problem, ok := syncdomain.ProblemFrom(err); ok { + setProblem(&resources[index], problem) + } + } + } + for _, indexes := range sourcePullGroups(repositoryRoot, resources) { + replacements := make([]synclocal.SourceReplacement, 0, len(indexes)) + var groupErr error + for _, index := range indexes { + replacement, err := sourcePullReplacement(resources[index]) + if err != nil { + groupErr = err + break + } + replacements = append(replacements, replacement) + } + if groupErr == nil { + groupErr = synclocal.NewStore(repositoryRoot).CheckSources(replacements) + } + if groupErr != nil { + for _, index := range indexes { + if problem, ok := syncdomain.ProblemFrom(groupErr); ok { + setProblem(&resources[index], problem) + } else { + resources[index].Action, resources[index].Error = ActionError, groupErr.Error() + } + } + } + } +} + +// allowRelink lifts a stale-binding block from the resource that a link +// command rebinds. The user reviews the new descriptor in that command. +func allowRelink(resources []PlannedResource, id ResourceID) { + for index := range resources { + resource := &resources[index] + if resource.ID != id || resource.Source == nil || resource.Problem == nil { + continue + } + if code := resource.Problem.Code; code == syncdomain.ErrorBindingStale || code == syncdomain.ErrorIndexedSelectionChanged { + resource.Error, resource.Problem = "", nil + resource.Action = chooseAction(resource.BaselineFingerprint != "", *resource) + upgradeStalePins(resource) + } + return + } +} + +func applySourceDirections(client syncapi.Client, resources []PlannedResource) error { + byProject := make(map[string][]ResourceID) + for _, resource := range resources { + if resource.Source != nil { + byProject[resource.ID.ProjectKey] = append(byProject[resource.ID.ProjectKey], resource.ID) + } + } + for projectKey, ids := range byProject { + for start := 0; start < len(ids); start += 100 { + end := min(start+100, len(ids)) + directions, err := client.SyncDirections(projectKey, ids[start:end]) + if err != nil { + return err + } + for index := range resources { + resource := &resources[index] + if resource.ID.ProjectKey != projectKey || + !slices.Contains(ids[start:end], resource.ID) { + continue + } + direction, ok := directions[resource.ID] + if !ok { + suggestion := "Configure the resource sync setting in LaunchDarkly, then run link again." + if resource.ID.Kind != syncdomain.KindVariation { + suggestion = fmt.Sprintf( + "LaunchDarkly does not provide a %s sync setting. Configure one when support is available, or run detach.", + resource.ID.Kind, + ) + } + return syncdomain.NewProblem( + syncdomain.ErrorSyncSettingUnavailable, + "LaunchDarkly returned no sync setting", + suggestion, + ).WithResource(resource.ID) + } + enforceDirection(resource, direction) + } + } + } + return nil +} + +func enforceDirection(resource *PlannedResource, direction syncdomain.SyncDirection) { + resource.Direction = direction + // A conflict over a shared tool or skill is about that attachment, not + // about the direction of the variation, so the user resolves it. + if resource.attachmentConflict && resource.Action == ActionConflict { + return + } + if blockLinkedDelete(resource) { + return + } + // LaunchDarkly deleted the linked resource and the source changed too. + // A descriptor cannot create the resource, so the conflict does not + // become a create under any setting. The recovery is detach. + if resource.Source != nil && resource.Action == ActionConflict && !resource.hasServer() { + setProblem(resource, linkedResourceDeleted(resource)) + return + } + switch direction { + case syncdomain.SyncDirectionBoth: + return + case syncdomain.SyncDirectionCodeCanonical: + switch resource.Action { + case ActionUpdateLocal, ActionConflict: + switch { + case resource.hasLocal() && resource.hasServer(): + resource.Action, resource.directionOverride = ActionUpdateServer, true + case resource.Source != nil && !resource.hasServer(): + // LaunchDarkly deleted the linked resource, and the repository + // still has its value, so the recovery is detach. + setProblem(resource, linkedResourceDeleted(resource)) + default: + resource.Action = ActionError + resource.Error = "code-canonical resource has no repository value to publish" + } + } + case syncdomain.SyncDirectionServerCanonical: + switch resource.Action { + case ActionUpdateServer, ActionConflict: + switch { + case resource.hasServer(): + resource.Action, resource.directionOverride = ActionUpdateLocal, true + case resource.Source != nil: + // LaunchDarkly deleted the linked resource, so the recovery is + // detach, as it is under every other setting. + setProblem(resource, linkedResourceDeleted(resource)) + default: + resource.Action = ActionError + resource.Error = "server-canonical resource does not exist in LaunchDarkly" + } + case ActionCreateServer: + resource.Action = ActionError + resource.Error = "server-canonical resource does not exist in LaunchDarkly" + } + } +} + +// blockLinkedDelete turns the deletion of a linked resource into a problem +// that asks for detach, because a descriptor-backed resource has no managed +// file to delete and the source stays in the repository. +func blockLinkedDelete(resource *PlannedResource) bool { + if resource.Source == nil || resource.Action != ActionDeleteLocal { + return false + } + setProblem(resource, linkedResourceDeleted(resource)) + return true +} + +// linkedResourceDeleted returns the problem of a linked resource that no +// longer exists in LaunchDarkly, which asks for detach. +func linkedResourceDeleted(resource *PlannedResource) *syncdomain.Problem { + return syncdomain.NewProblem( + syncdomain.ErrorResourceDeleted, + "linked resource no longer exists in LaunchDarkly", + "Run "+detachCommand(resource.ID)+".", + ).WithResource(resource.ID).WithFile(resource.Source.File, 0, 0) +} + +func detachCommand(id ResourceID) string { + if id.Kind == syncdomain.KindVariation { + configKey, variationKey, _ := id.VariationKeys() + return fmt.Sprintf( + "ldcli sync prompts detach --project %s --config-key %s --key %s", + id.ProjectKey, configKey, variationKey, + ) + } + return fmt.Sprintf( + "ldcli sync %ss detach --project %s --key %s", + id.Kind, id.ProjectKey, id.LookupKey, + ) +} + // usersOf returns the local variations that use a tool or a skill. func usersOf(variations []syncdomain.SyncedResource, id ResourceID) []ResourceID { var users []ResourceID @@ -141,6 +383,9 @@ func countStandalone( counts := make(map[syncdomain.Kind]int) for _, file := range files { id, ok := synclocal.ParseManagedPath(file) + if !ok { + id, ok = synclocal.ParseReferencePath(file) + } standalone := ok && id.Kind != syncdomain.KindVariation && (project == "" || id.ProjectKey == project) && len(usersOf(variations, id)) == 0 && !slices.ContainsFunc(planned, func(resource PlannedResource) bool { return resource.ID == id }) @@ -154,7 +399,6 @@ func countStandalone( // planVariationRows reads the variations that the filter keeps from // LaunchDarkly, and compares both sides with the lock. func planVariationRows( - repositoryRoot string, baseline syncmanifest.Baseline, client syncapi.Client, attachments *attachmentCache, @@ -204,11 +448,22 @@ func planAttachmentRows( lock syncmanifest.Manifest, attachments *attachmentCache, include func(ResourceID) bool, + proposal *synclocal.SourceDefinition, ) ([]PlannedResource, error) { - local, err := synclocal.CompileAttachments(repositoryRoot, include) + compiled := include + if proposal != nil && proposal.Attachment != nil { + // The link replaces the descriptor of its tool or skill, so the + // current descriptor is not compiled. + id := proposal.Attachment.ID() + compiled = func(other ResourceID) bool { return other != id && include(other) } + } + local, err := synclocal.CompileAttachments(repositoryRoot, compiled) if err != nil { return nil, err } + if proposal != nil && proposal.Attachment != nil && include(proposal.Attachment.ID()) { + local = append(local, *proposal.Attachment) + } tracked := syncmanifest.New() for _, resource := range lock.Resources { if resource.ResourceKind != syncdomain.KindVariation && include(resource.ID()) { @@ -239,21 +494,58 @@ func planAttachmentRows( // the content, and executePlan applies it before the variation rows. // checkAttachmentBaselines covers the tools and skills that both sides use. // +// A row that its sync setting or a check blocks also stays, so that the +// variation row does not recreate a linked tool that LaunchDarkly deleted, +// create a server-canonical one, or publish a linked tool whose mapped field +// is read-only. +// // A conflicted variation writes the side that the user chooses, so each side // of it counts. A row that only conflicts need records them in conflictUses, // and applyConflictResolutions keeps it only for the chosen side. func planReferencedAttachments( repositoryRoot string, + client syncapi.Client, lock syncmanifest.Manifest, attachments *attachmentCache, resources []PlannedResource, + proposal *synclocal.SourceDefinition, ) ([]PlannedResource, error) { + linked, err := linkedAttachments(repositoryRoot, resources) + if err != nil { + return nil, err + } written := make(map[ResourceID]bool) conflictUses := make(map[ResourceID][]conflictUse) for _, resource := range resources { + if resource.ID.Kind == syncdomain.KindVariation && resource.Source != nil && + (resource.Source.Tools != nil || resource.Source.Skills != nil) { + for _, variation := range []*syncdomain.Variation{resource.Local, resource.Server} { + if variation == nil { + continue + } + for _, attachment := range variation.Attachments { + written[attachment.ID(resource.ID.ProjectKey)] = true + } + } + } for _, id := range newReferences(resource) { written[id] = true } + // A variation push publishes each tool and skill whose content + // differs between the sides, and a variation pull writes them. A + // linked one lives in a repository source that the variation write + // does not touch, and follows its own sync setting, so it needs a + // row that applySourceDirections can enforce and that executePlan + // applies before the variation. A conflict can become a push or a + // pull when the user chooses a side. + if resource.ID.Kind == syncdomain.KindVariation && + (resource.Action.changesServer() || resource.Action.changesLocal() || resource.Action == ActionConflict) { + for _, id := range resource.changedAttachments { + if linked[id] { + written[id] = true + } + } + } if resource.ID.Kind == syncdomain.KindVariation && resource.Action == ActionConflict { for _, id := range newAttachments(resource.ID.ProjectKey, resource.Local, resource.Server) { conflictUses[id] = append(conflictUses[id], conflictUse{variation: resource.ID, local: true}) @@ -268,12 +560,20 @@ func planReferencedAttachments( !slices.ContainsFunc(resources, func(resource PlannedResource) bool { return resource.ID == id }) } - rows, err := planAttachmentRows(repositoryRoot, lock, attachments, referenced) + rows, err := planAttachmentRows(repositoryRoot, lock, attachments, referenced, proposal) if err != nil { return nil, err } + // The sync settings decide the side of each row before the unchanged + // rows go, so that a row that a setting blocks stays. + if err := applySourceDirections(client, rows); err != nil { + return nil, err + } rows = slices.DeleteFunc(rows, func(row PlannedResource) bool { - return !row.hasLocal() || !row.hasServer() || row.LocalFingerprint == row.ServerFingerprint + // A problem of a check, or an error that the sync setting of a linked + // row gives, blocks the plan, so the row stays. + blocked := row.Problem != nil || row.Source != nil && row.Action == ActionError + return !blocked && (!row.hasLocal() || !row.hasServer() || row.LocalFingerprint == row.ServerFingerprint) }) for index := range rows { if !written[rows[index].ID] { @@ -283,6 +583,34 @@ func planReferencedAttachments( return rows, nil } +// linkedAttachments reports which of the tools and skills that the variation +// rows change are descriptor-backed in the repository. +func linkedAttachments(repositoryRoot string, resources []PlannedResource) (map[ResourceID]bool, error) { + changed := make(map[ResourceID]bool) + for _, resource := range resources { + if resource.ID.Kind == syncdomain.KindVariation && + (resource.Action.changesServer() || resource.Action.changesLocal() || resource.Action == ActionConflict) { + for _, id := range resource.changedAttachments { + changed[id] = true + } + } + } + linked := make(map[ResourceID]bool) + if len(changed) == 0 { + return linked, nil + } + local, err := synclocal.CompileAttachments(repositoryRoot, func(id ResourceID) bool { return changed[id] }) + if err != nil && !errors.Is(err, synclocal.ErrNoDirectory) { + return nil, err + } + for _, attachment := range local { + if attachment.Source != nil { + linked[attachment.ID()] = true + } + } + return linked, nil +} + // newReferences returns each tool and skill that a push or a pull writes to a // side that does not use it yet. func newReferences(resource PlannedResource) []ResourceID { @@ -315,12 +643,32 @@ func hasAttachment(variation syncdomain.Variation, attachment syncdomain.Attachm // tool or a skill past a change on the other side. sync.lock records the // state of each tool and skill on its own. A row becomes a conflict when the // side that it overwrites changed since that state, or when the attachment has -// no state and both sides have different content. -func checkAttachmentBaselines(resources []PlannedResource, lock syncmanifest.Manifest) { +// no state and both sides have different content. A linked tool or skill is +// not checked, because the variation row never writes its repository source. +func checkAttachmentBaselines(repositoryRoot string, resources []PlannedResource, lock syncmanifest.Manifest) error { baselines := make(map[ResourceID]string, len(lock.Resources)) for _, resource := range lock.Resources { baselines[resource.ID()] = resource.Fingerprint } + checked := make(map[ResourceID]bool) + for _, resource := range resources { + if source, target := resource.writtenSides(); source != nil && target != nil { + for _, id := range resource.changedAttachments { + checked[id] = true + } + } + } + if len(checked) == 0 { + return nil + } + local, err := synclocal.CompileAttachments(repositoryRoot, func(id ResourceID) bool { return checked[id] }) + if err != nil && !errors.Is(err, synclocal.ErrNoDirectory) { + return err + } + sources := make(map[ResourceID]*syncdomain.Reference, len(local)) + for _, attachment := range local { + sources[attachment.ID()] = attachment.Source + } for index := range resources { resource := &resources[index] @@ -329,6 +677,14 @@ func checkAttachmentBaselines(resources []PlannedResource, lock syncmanifest.Man continue } for _, id := range resource.changedAttachments { + // A linked tool or skill lives in a repository source that the + // variation write does not touch. It gets a row of its own, which + // executePlan applies before the variation, and a push merges its + // mapped fields onto the latest LaunchDarkly copy, so the + // variation row does not move it past a change. + if sources[id] != nil { + continue + } kind := syncdomain.AttachmentKind(id.Kind) overwritten, ok := target.Attachment(kind, id.LookupKey) if !ok { @@ -338,15 +694,18 @@ func checkAttachmentBaselines(resources []PlannedResource, lock syncmanifest.Man // different versions conflict. A new attachment on one side does not. _, unsynced := source.Attachment(kind, id.LookupKey) if baseline, tracked := baselines[id]; tracked { - fingerprint, err := syncdomain.FingerprintAttachment(id.ProjectKey, overwritten) + fingerprint, err := syncdomain.SyncedAttachment{ + ProjectKey: id.ProjectKey, Attachment: overwritten, Source: sources[id], + }.Fingerprint() unsynced = err != nil || fingerprint != baseline } if unsynced { - resource.Action = ActionConflict + resource.Action, resource.attachmentConflict = ActionConflict, true break } } } + return nil } // checkAttachmentDeletes keeps a tool or a skill that LaunchDarkly deleted @@ -471,7 +830,11 @@ func (workspace syncWorkspace) syncedResources() ([]ResourceID, error) { synced = append(synced, resource.ID()) } for _, file := range files { - if id, ok := synclocal.ParseManagedPath(file); ok { + id, ok := synclocal.ParseManagedPath(file) + if !ok { + id, ok = synclocal.ParseReferencePath(file) + } + if ok { synced = append(synced, id) } } @@ -525,8 +888,8 @@ func checkFilter(scope syncdomain.Scope, filter syncdomain.Filter, synced []Reso // compileWorkspace reads the local variations. A workspace without a managed // directory has no variations. -func compileWorkspace(repositoryRoot string) ([]syncdomain.SyncedResource, error) { - resources, err := synclocal.CompileWorkspace(repositoryRoot) +func compileWorkspace(repositoryRoot string, exclude func(ResourceID) bool) ([]syncdomain.SyncedResource, error) { + resources, err := synclocal.CompileWorkspaceExcept(repositoryRoot, exclude) if errors.Is(err, synclocal.ErrNoDirectory) { return nil, nil } @@ -599,11 +962,31 @@ func samePlannedResourceState(reviewed, current PlannedResource) bool { reviewed.LocalFingerprint == current.LocalFingerprint && reviewed.ServerFingerprint == current.ServerFingerprint && reviewed.ServerMode == current.ServerMode && + reviewed.Direction == current.Direction && reviewed.Upsert == current.Upsert && reviewed.ServerHasStaleAttachmentPins == current.ServerHasStaleAttachmentPins && + sameSourceReference(reviewed.Source, current.Source) && + slices.Equal(reviewed.IndexedSelections, current.IndexedSelections) && sameAttachmentPins(reviewed.Server, current.Server) } +func sameSourceReference(left, right *syncdomain.Reference) bool { + if left == nil || right == nil { + return left == right + } + return left.File == right.File && left.Format == right.Format && left.Select == right.Select && + maps.Equal(left.Map, right.Map) && + sameOptionalRefs(left.Tools, right.Tools) && + sameOptionalRefs(left.Skills, right.Skills) +} + +func sameOptionalRefs(left, right *[]syncdomain.AttachmentRef) bool { + if left == nil || right == nil { + return left == right + } + return slices.Equal(*left, *right) +} + // sameAttachmentPins compares the exact versions that LaunchDarkly pins. The // fingerprints do not include versions. func sameAttachmentPins(reviewed, current *syncdomain.Variation) bool { diff --git a/internal/sync/prompt/sync.go b/internal/sync/prompt/sync.go index 93fb8fead..35dba1016 100644 --- a/internal/sync/prompt/sync.go +++ b/internal/sync/prompt/sync.go @@ -1,11 +1,16 @@ package prompt import ( + "bytes" "errors" + "io" "slices" syncdomain "github.com/launchdarkly/ldcli/internal/sync" syncapi "github.com/launchdarkly/ldcli/internal/sync/api" + syncconsole "github.com/launchdarkly/ldcli/internal/sync/console" + synclink "github.com/launchdarkly/ldcli/internal/sync/link" + syncmanifest "github.com/launchdarkly/ldcli/internal/sync/manifest" ) // runWorkspaceSync runs one sync in five steps: @@ -19,6 +24,15 @@ import ( // In watch mode, watcher is not nil. A file change during the review returns // errRefreshWatchPlan, so that the watch loop builds a new plan. func (runner Runner) runWorkspaceSync(options Options, workspace syncWorkspace, watcher *sourceWatcher) error { + return runner.runWorkspaceSyncWithSource(options, workspace, watcher, nil) +} + +func (runner Runner) runWorkspaceSyncWithSource( + options Options, + workspace syncWorkspace, + watcher *sourceWatcher, + prepared *synclink.Prepared, +) error { options = options.withDefaults() client := runner.api(options) @@ -32,10 +46,18 @@ func (runner Runner) runWorkspaceSync(options Options, workspace syncWorkspace, } target := options.syncAction() - reviewed, err := workspace.loadState(client, options.Scope, target.Filter) + var link *sourceLink + if prepared != nil { + link = &sourceLink{id: prepared.ID, proposal: &prepared.Definition} + } + reviewed, err := workspace.loadStateWithSource(client, options.Scope, target.Filter, link) if err != nil { return err } + if prepared != nil { + reviewed.plan.ForceConfirmation = true + writeDescriptorPreview(options.ErrorOutput, *prepared) + } if options.dryRun() { writePlanNotes(options.ErrorOutput, reviewed.plan) if err := writePlanOutput(options.Output, options.OutputKind, reviewed.plan, options.Diff); err != nil { @@ -56,6 +78,10 @@ func (runner Runner) runWorkspaceSync(options Options, workspace syncWorkspace, } resolved := applyConflictResolutions(reviewed.plan, conflicts.resolutions) + // A conflict that LaunchDarkly wins becomes a pull now, so the source + // publication checks run on it before the review, as they did on the + // pulls of the unresolved plan. + checkSourcePublications(workspace.root, resolved.Resources) proceed, err := reviewAndConfirmPlan(options, resolved, interactive) if err != nil { return err @@ -63,19 +89,84 @@ func (runner Runner) runWorkspaceSync(options Options, workspace syncWorkspace, if !proceed { return nil } + if prepared != nil { + if _, err := prepared.Write(workspace.local); err != nil { + return err + } + _ = syncconsole.New(options.ErrorOutput).Printf("Linked %s.\n", prepared.Path) + } - // Read both sides again, so that no action uses state from before the review. - current, err := workspace.loadState(client, options.Scope, target.Filter) + // Read both sides again, so that no action uses state from before the + // review. The descriptor exists now, so the link has no proposal. + if link != nil { + link = &sourceLink{id: link.id} + } + current, err := workspace.loadStateWithSource(client, options.Scope, target.Filter, link) + if err == nil { + err = checkUnchanged(options, reviewed, current) + } if err != nil { + // The lock does not record the new descriptor, so the next sync + // would report a stale binding. Put the previous descriptor back. + if prepared != nil { + err = errors.Join(err, prepared.Rollback(workspace.local)) + } return err } - if err := checkUnchanged(options, reviewed, current); err != nil { - return err + outcomes, err := runner.applyPlan(options, workspace, client, current, conflicts.resolutions) + if err != nil && prepared != nil && !lockRecordsLink(workspace, *prepared) && !outcomeSucceeded(outcomes, prepared.ID) { + // The apply did not sync the linked resource and did not record the + // new descriptor, so the next sync would report a stale binding. Put + // the previous descriptor back. After a sync of the resource, the + // descriptor stays, because LaunchDarkly or the source has the new + // content, even when a later step such as the lock write failed. + err = errors.Join(err, prepared.Rollback(workspace.local)) } - _, err = runner.applyPlan(options, workspace, client, current, conflicts.resolutions) return err } +// outcomeSucceeded reports whether the apply succeeded for a resource. +func outcomeSucceeded(outcomes []ResourceOutcome, id syncdomain.ResourceID) bool { + return slices.ContainsFunc(outcomes, func(outcome ResourceOutcome) bool { + return outcome.ID == id && outcome.Status == OutcomeSucceeded + }) +} + +// lockRecordsLink reports whether the lock records the descriptor that a +// link wrote. An apply records it once the linked resource syncs, and then +// the descriptor stays even when another row of the plan failed. +func lockRecordsLink(workspace syncWorkspace, prepared synclink.Prepared) bool { + lock, err := syncmanifest.ReadLock(workspace.local) + if err != nil { + return false + } + digest, err := syncdomain.ReferenceDigest(prepared.Reference) + if err != nil { + return false + } + return slices.ContainsFunc(lock.Resources, func(resource syncmanifest.Resource) bool { + return resource.ID() == prepared.ID && resource.ReferenceDigest == digest + }) +} + +func writeDescriptorPreview(output io.Writer, prepared synclink.Prepared) { + console := syncconsole.New(output) + if len(prepared.Previous) != 0 && !bytes.Equal(prepared.Previous, prepared.Content) { + _ = console.Printf("Current descriptor %s:\n%s\n", prepared.Path, prepared.Previous) + } + _ = console.Printf("Proposed descriptor %s:\n%s\n", prepared.Path, prepared.Content) + var dependencies []string + if prepared.Definition.Variation != nil { + dependencies = prepared.Definition.Variation.Dependencies + } + if prepared.Definition.Attachment != nil { + dependencies = prepared.Definition.Attachment.Dependencies + } + for _, dependency := range dependencies { + _ = console.Printf("Mapped dependency: %s\n", dependency) + } +} + // checkUnchanged returns an error if the state changed after the review. func checkUnchanged(options Options, reviewed, current workspaceState) error { if slices.Equal(reviewed.projectKeys, current.projectKeys) && samePlanState(reviewed.plan, current.plan) { diff --git a/internal/sync/prompt/watch_test.go b/internal/sync/prompt/watch_test.go index dcda1b84e..ebb1990ee 100644 --- a/internal/sync/prompt/watch_test.go +++ b/internal/sync/prompt/watch_test.go @@ -16,6 +16,7 @@ import ( "github.com/stretchr/testify/require" syncdomain "github.com/launchdarkly/ldcli/internal/sync" + synclocal "github.com/launchdarkly/ldcli/internal/sync/local" ) func TestWatchWorkspaceWaitsForDebouncedChange(t *testing.T) { @@ -160,6 +161,73 @@ func TestWatchWorkspaceRefreshesPlanAfterSourceChangesDuringSync(t *testing.T) { require.NoError(t, <-done) } +func TestWatchWorkspaceRefreshesReferencedFilesBeforeRebuildingPlan(t *testing.T) { + root := t.TempDir() + store := synclocal.NewStore(root) + _, err := store.Add([]synclocal.VariationFile{{ + ProjectKey: "project", + ConfigKey: "config", + Variation: syncdomain.Variation{ + Mode: syncdomain.VariationModeAgent, Key: "prompt", Name: "Prompt", Instructions: "Initial", + }, + }}) + require.NoError(t, err) + wrapper := filepath.Join(root, syncdomain.RootDir, "project", "configs", "config", "prompt.prompt.md") + referencePath := filepath.Join(root, "prompts", "prompt.md") + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + var attempts atomic.Int32 + attempted := make(chan struct{}, 3) + done := make(chan error, 1) + go func() { + done <- watchWorkspace(ctx, root, 15*time.Millisecond, func(*sourceWatcher) error { + attempt := attempts.Add(1) + if attempt == 1 { + if err := os.MkdirAll(filepath.Dir(referencePath), 0o755); err != nil { + return err + } + if err := os.WriteFile(referencePath, []byte("Referenced"), 0o644); err != nil { + return err + } + if err := os.Remove(wrapper); err != nil { + return err + } + if _, err := store.WriteDescriptor( + syncdomain.VariationID("project", "config", "prompt"), + synclocal.Reference{File: "prompts/prompt.md", Format: "markdown"}, + ); err != nil { + return err + } + attempted <- struct{}{} + return errRefreshWatchPlan + } + attempted <- struct{}{} + return nil + }, io.Discard) + }() + + time.Sleep(30 * time.Millisecond) + require.NoError(t, os.WriteFile(wrapper, []byte("trigger"), 0o644)) + for attempt := 0; attempt < 2; attempt++ { + select { + case <-attempted: + case <-time.After(time.Second): + t.Fatal("watch did not rebuild the plan") + } + } + + require.NoError(t, os.WriteFile(referencePath, []byte("Changed"), 0o644)) + select { + case <-attempted: + case <-time.After(time.Second): + t.Fatal("watch did not track the newly referenced file") + } + + cancel() + require.NoError(t, <-done) +} + func TestWatchWorkspaceRetriesEditsMadeDuringSuccessfulSync(t *testing.T) { root := t.TempDir() wrapper := filepath.Join(root, syncdomain.RootDir, "project", "configs", "config", "prompt.prompt.md") @@ -257,6 +325,38 @@ func TestWatchedConflictPromptCancellationStopsWatch(t *testing.T) { require.ErrorIs(t, err, context.Canceled) } +func TestWatchWorkspaceTracksMissingReferencedFileParent(t *testing.T) { + root := t.TempDir() + _, err := synclocal.NewStore(root).WriteDescriptor( + syncdomain.VariationID("project", "config", "prompt"), + synclocal.Reference{File: "prompts/nested/prompt.md", Format: "markdown"}, + ) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + synced := make(chan struct{}, 1) + done := make(chan error, 1) + go func() { + done <- watchWorkspace(ctx, root, 15*time.Millisecond, func(*sourceWatcher) error { + synced <- struct{}{} + return nil + }, io.Discard) + }() + + time.Sleep(30 * time.Millisecond) + referencePath := filepath.Join(root, "prompts", "nested", "prompt.md") + require.NoError(t, os.MkdirAll(filepath.Dir(referencePath), 0o755)) + require.NoError(t, os.WriteFile(referencePath, []byte("created"), 0o644)) + select { + case <-synced: + case <-time.After(time.Second): + t.Fatal("watch did not detect a referenced file created under a missing parent") + } + + cancel() + require.NoError(t, <-done) +} + func TestWatchWorkspaceRewatchesRecreatedManagedTree(t *testing.T) { root := t.TempDir() wrapper := filepath.Join(root, syncdomain.RootDir, "project", "configs", "config", "prompt.prompt.md") @@ -293,6 +393,40 @@ func TestWatchWorkspaceRewatchesRecreatedManagedTree(t *testing.T) { require.NoError(t, <-done) } +func TestWatchWorkspaceDetectsReferencedFileChange(t *testing.T) { + root := t.TempDir() + referencePath := filepath.Join(root, "prompt.md") + require.NoError(t, os.WriteFile(referencePath, []byte("initial"), 0o644)) + _, err := synclocal.NewStore(root).WriteDescriptor( + syncdomain.VariationID("project", "config", "prompt"), + synclocal.Reference{File: "prompt.md", Format: "markdown"}, + ) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + synced := make(chan struct{}, 1) + done := make(chan error, 1) + var output bytes.Buffer + go func() { + done <- watchWorkspace(ctx, root, 15*time.Millisecond, func(*sourceWatcher) error { + synced <- struct{}{} + return nil + }, &output) + }() + + time.Sleep(30 * time.Millisecond) + require.NoError(t, os.WriteFile(referencePath, []byte("changed"), 0o644)) + select { + case <-synced: + case <-time.After(time.Second): + t.Fatal("watch did not detect referenced file change") + } + cancel() + require.NoError(t, <-done) + require.Contains(t, output.String(), "- .launchdarkly/project/configs/config/prompt.ref.yaml\n") + require.Contains(t, output.String(), "- prompt.md\n") +} + func TestSourceWatcherRecognizesNewManagedResourceKinds(t *testing.T) { root := t.TempDir() managedRoot := filepath.Join(root, syncdomain.RootDir)