diff --git a/internal/sync/api/settings.go b/internal/sync/api/settings.go new file mode 100644 index 00000000..93b6ddba --- /dev/null +++ b/internal/sync/api/settings.go @@ -0,0 +1,69 @@ +package api + +import ( + "fmt" + "net/url" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +type settingsResponse struct { + Items []resolvedSetting `json:"items"` +} + +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. +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") + } + query := url.Values{} + for _, id := range ids { + if id.ProjectKey != projectKey { + return nil, fmt.Errorf("read sync settings: %s is not in project %q", id.Label(), projectKey) + } + query.Add("resources", string(id.Kind)+":"+id.LookupKey) + } + response, err := client.read( + fmt.Sprintf("get sync settings in project %q", projectKey), + projectKey, + query, + projectPath(projectKey, "configs", "sync", "settings")..., + ) + if err != nil { + return nil, err + } + if len(response) == 0 { + return map[syncdomain.ResourceID]syncdomain.SyncDirection{}, nil + } + settings, err := decodeJSON[settingsResponse](response, "sync settings response") + if err != nil { + return nil, err + } + result := make(map[syncdomain.ResourceID]syncdomain.SyncDirection, len(settings.Items)) + for _, item := range settings.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 + } + return result, nil +} diff --git a/internal/sync/api/settings_test.go b/internal/sync/api/settings_test.go new file mode 100644 index 00000000..fb111be7 --- /dev/null +++ b/internal/sync/api/settings_test.go @@ -0,0 +1,44 @@ +package api + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" +) + +func TestSyncDirections(t *testing.T) { + transport := &recordingClient{Responses: [][]byte{[]byte(`{ + "items": [ + {"resourceKind":"variation","resourceLookupKey":"support/default","direction":"code_canonical"}, + {"resourceKind":"tool","resourceLookupKey":"search","direction":"server_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, 1) + request := transport.Requests[0] + 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{"variation:support/default", "tool:search"}, request.Query["resources"]) +} + +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) +} diff --git a/internal/sync/prompt/acceptance_test.go b/internal/sync/prompt/acceptance_test.go index 050de741..f05d82ad 100644 --- a/internal/sync/prompt/acceptance_test.go +++ b/internal/sync/prompt/acceptance_test.go @@ -1287,8 +1287,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") diff --git a/internal/sync/prompt/attachment_versions.go b/internal/sync/prompt/attachment_versions.go index 37c264d5..8745b29f 100644 --- a/internal/sync/prompt/attachment_versions.go +++ b/internal/sync/prompt/attachment_versions.go @@ -46,9 +46,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 +69,22 @@ 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.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 } diff --git a/internal/sync/prompt/capability_test.go b/internal/sync/prompt/capability_test.go new file mode 100644 index 00000000..15f99572 --- /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 := syncdomain.ProjectionFieldState(resource.LocalProjection, 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/diff.go b/internal/sync/prompt/diff.go index 8e37710b..c4ec500c 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{} diff --git a/internal/sync/prompt/direction_test.go b/internal/sync/prompt/direction_test.go new file mode 100644 index 00000000..59d71bad --- /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}) + + 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 a52581e2..e87c4bf7 100644 --- a/internal/sync/prompt/execute.go +++ b/internal/sync/prompt/execute.go @@ -34,7 +34,46 @@ func executePlan( outcomes := make([]ResourceOutcome, len(plan.Resources)) var failures []error attachments := newAttachmentResolver(client) + handled := make(map[int]bool) + + var sourceReplacements []synclocal.SourceReplacement + var sourceIndexes []int + for index, resource := range plan.Resources { + if resource.Action != ActionUpdateLocal || resource.Source == nil { + continue + } + projection, err := syncdomain.ChangedProjection(resource.LocalProjection, resource.ServerProjection) + if err != nil { + return nil, manifest, err + } + sourceReplacements = append(sourceReplacements, synclocal.SourceReplacement{ + ID: resource.ID, Mode: resource.ServerMode, Reference: *resource.Source, + Projection: projection, ExpectedFingerprint: resource.LocalFingerprint, + ExpectedProjection: resource.LocalProjection, + ExpectedIndexedSelections: resource.IndexedSelections, + }) + sourceIndexes = append(sourceIndexes, index) + } + if len(sourceReplacements) != 0 { + _, sourceErr := localStore.ReplaceSources(sourceReplacements) + for _, index := range sourceIndexes { + resource := plan.Resources[index] + outcome := ResourceOutcome{ID: resource.ID, Action: resource.Action, Status: OutcomeSucceeded} + if sourceErr != nil { + outcome.Status, outcome.Error = OutcomeFailed, sourceErr.Error() + } else { + recordSuccessfulChange(&next, resource) + } + outcomes[index], handled[index] = outcome, true + } + if sourceErr != nil { + failures = append(failures, sourceErr) + } + } 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) { @@ -45,7 +84,7 @@ func executePlan( switch { case resource.Action == ActionInSync: case resource.Action == ActionUpdateManifest: - next.SetFingerprint(resource.ID, resource.LocalFingerprint) + recordSuccessfulChange(&next, resource) case resource.Action == ActionRemoveManifest: next.Remove(resource.ID) case resource.Action.changesServer() || resource.Action.changesLocal(): @@ -76,7 +115,7 @@ 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 { + if err := recordAttachmentState(&next, repositoryRoot, resource.ID.ProjectKey, settled); err != nil { fail(err) } } @@ -102,6 +141,49 @@ func executePlan( return outcomes, next, errors.Join(failures...) } +func recordAttachmentState( + manifest *syncmanifest.Manifest, + repositoryRoot, projectKey string, + attachments []syncdomain.Attachment, +) error { + if err := manifest.SetAttachments(projectKey, attachments); err != nil { + return err + } + if len(attachments) == 0 { + return nil + } + ids := make(map[ResourceID]struct{}, len(attachments)) + for _, attachment := range attachments { + ids[attachment.ID(projectKey)] = struct{}{} + } + local, err := synclocal.CompileAttachments(repositoryRoot, func(id ResourceID) bool { + _, included := ids[id] + return included + }) + if err != nil { + return err + } + for _, attachment := range local { + if attachment.Source == nil { + continue + } + projection, err := syncdomain.ProjectAttachment(attachment.Attachment, *attachment.Source) + if err != nil { + return err + } + fingerprint, err := syncdomain.FingerprintProjection(attachment.ID(), projection) + if err != nil { + return err + } + manifest.SetFingerprint(attachment.ID(), fingerprint) + if err := manifest.SetSourceMetadata(attachment.ID(), attachment.Source); err != nil { + return err + } + manifest.SetIndexedSelections(attachment.ID(), attachment.IndexedSelections) + } + return nil +} + // attachmentManifestState returns the variation whose attachments the // manifest records after the action, or nil. func attachmentManifestState(resource PlannedResource) *syncdomain.Variation { @@ -120,11 +202,17 @@ func recordSuccessfulChange(manifest *syncmanifest.Manifest, resource PlannedRes 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) } + _ = manifest.SetSourceMetadata(resource.ID, resource.Source) + if resource.Source != nil { + manifest.SetIndexedSelections(resource.ID, resource.IndexedSelections) + } } // applyResourceChange applies one action that writes to LaunchDarkly or to a @@ -197,7 +285,15 @@ func applyServerChange( } writeErr = client.CreateVariation(projectKey, configKey, variation) case ActionUpdateServer: - local := withExplicitDetach(*resource.Local, resource.Server) + local := *resource.Local + 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) + } + local = syncdomain.ApplyVariationProjection(*resource.Server, projection) + } + local = withExplicitDetach(local, resource.Server) if variation, err = attachments.resolveVariation(projectKey, local); err != nil { return nil, err } @@ -222,7 +318,16 @@ 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 { + actual, err = syncdomain.FingerprintVariation(projectKey, resource.ID.LookupKey, state.Variation) + } + if err != nil { return nil, errors.Join(writeErr, err) } } @@ -255,6 +360,13 @@ func applyAttachmentChange( // 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 + if resource.Source != nil && resource.ServerAttachment != nil { + var err error + local, err = syncdomain.ApplyAttachmentProjection(*resource.ServerAttachment, resource.LocalProjection) + if err != nil { + return 0, err + } + } local.Upsert = local.Upsert || resource.Action == ActionCreateServer published, err := attachments.resolve(resource.ID.ProjectKey, local, resource.Action == ActionCreateServer) return published.Version, err @@ -334,7 +446,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 } @@ -358,12 +472,27 @@ 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) @@ -372,6 +501,23 @@ func readLocalFingerprint(repositoryRoot string, id ResourceID) (string, error) } for _, variation := range variations { if variation.ID() == id { + if variation.Source != nil { + value := variationForSourceMode(variation.Variation, mode) + projection := variation.Projection + if typed, ok := projection.(syncdomain.VariationProjection); ok && typed.Mode != value.Mode { + var converted syncdomain.VariationProjection + converted, err = syncdomain.ProjectVariation(value, *variation.Source) + converted.FieldStates = typed.FieldStates + projection = converted + } + if err == nil && expected != nil { + projection, err = syncdomain.FillReadOnlyFields(projection, expected) + } + if err != nil { + return "", err + } + return syncdomain.FingerprintProjection(id, projection) + } return syncdomain.FingerprintVariation(id.ProjectKey, id.LookupKey, variation.Variation) } } diff --git a/internal/sync/prompt/model_parameters_test.go b/internal/sync/prompt/model_parameters_test.go new file mode 100644 index 00000000..d31c1e8e --- /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/plan.go b/internal/sync/prompt/plan.go index a91aea21..80fb2f4b 100644 --- a/internal/sync/prompt/plan.go +++ b/internal/sync/prompt/plan.go @@ -57,10 +57,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,6 +76,7 @@ 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 @@ -83,6 +90,54 @@ type PlannedResource struct { nameSource string } +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 + } + for field, state := range syncdomain.ProjectionFieldStates(resource.LocalProjection) { + 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. const ( nameFromFlag = "flag" @@ -132,6 +187,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 +198,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 +223,10 @@ 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] + row := planResource(id, localState, server[id], baselineResource.Fingerprint, tracked) + checkSourceBaseline(&row, baselineResource, tracked) + plan.Resources = append(plan.Resources, row) } return plan } @@ -192,20 +251,59 @@ 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 { + variation = variationForSourceMode(variation, server.ConfigMode) + } 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 local.Source != nil { + resource.LocalProjection = local.Projection + if projection, ok := resource.LocalProjection.(syncdomain.VariationProjection); ok && + projection.Mode != variation.Mode { + var converted syncdomain.VariationProjection + converted, err = syncdomain.ProjectVariation(variation, *local.Source) + converted.FieldStates = projection.FieldStates + resource.LocalProjection = converted + } + } else { + resource.LocalFingerprint, err = syncdomain.FingerprintVariation(id.ProjectKey, id.LookupKey, variation) + } + if err != nil { return fail(err.Error()) } } 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 { @@ -215,6 +313,7 @@ func planResource( _, 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) @@ -226,7 +325,11 @@ func planResource( // 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 { @@ -238,6 +341,19 @@ func planResource( return resource } +func variationForSourceMode(variation syncdomain.Variation, mode syncdomain.VariationMode) syncdomain.Variation { + if mode == syncdomain.VariationModeCompletion && variation.Mode == syncdomain.VariationModeAgent { + variation.Mode = mode + if variation.Instructions != "" { + variation.Messages = []syncdomain.Message{{ + Role: syncdomain.RoleSystem, Content: variation.Instructions, + }} + variation.Instructions = "" + } + } + return variation +} + // planAttachments plans one row for each tool and skill that has a local file // or an entry in the baseline. server must have an entry for each of them, // which is nil when LaunchDarkly does not have the attachment. @@ -246,14 +362,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 +383,50 @@ 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] + row := planSyncedAttachment(id, localByID[id], server[id], baselineResource.Fingerprint, tracked) + checkSourceBaseline(&row, baselineResource, tracked) + resources = append(resources, row) } return resources } +func checkSourceBaseline(resource *PlannedResource, baseline syncmanifest.Resource, tracked bool) { + if resource.Source == nil || !tracked || resource.Action == ActionError { + return + } + digest, err := syncdomain.ReferenceDigest(*resource.Source) + schemaChanged := baseline.FingerprintSchema != "" && + baseline.FingerprintSchema != syncdomain.ProjectionFingerprintSchema + referenceChanged := baseline.ReferenceDigest != "" && baseline.ReferenceDigest != digest + adapterChanged := baseline.AdapterBehaviorVersion != 0 && + baseline.AdapterBehaviorVersion != syncdomain.AdapterBehaviorVersion || + baseline.AdapterBehaviorDigest != "" && + baseline.AdapterBehaviorDigest != syncdomain.AdapterBehaviorDigest + switch { + case err != nil: + resource.Action, resource.Error = ActionError, err.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 !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 +434,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 +459,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) 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 @@ -422,6 +625,9 @@ func chooseAction(tracked bool, resource PlannedResource) Action { // 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 +657,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 675752b6..ee2e4e0d 100644 --- a/internal/sync/prompt/plan_test.go +++ b/internal/sync/prompt/plan_test.go @@ -58,6 +58,39 @@ 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, AdapterBehaviorVersion: syncdomain.AdapterBehaviorVersion, + AdapterBehaviorDigest: syncdomain.AdapterBehaviorDigest, + 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) +} + func TestBuildPlanFirstSync(t *testing.T) { local := testVariation("local") different := testVariation("server") diff --git a/internal/sync/prompt/state.go b/internal/sync/prompt/state.go index 09f1e539..f77a75a6 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,23 @@ func (workspace syncWorkspace) loadState( client syncapi.Client, scope syncdomain.Scope, filter syncdomain.Filter, +) (workspaceState, error) { + return workspace.loadStateWithSource(client, scope, filter, nil) +} + +func (workspace syncWorkspace) loadStateWithSource( + client syncapi.Client, + scope syncdomain.Scope, + filter syncdomain.Filter, + proposal *synclocal.SourceDefinition, ) (workspaceState, error) { synced, err := workspace.syncedResources() if err != nil { return workspaceState{}, err } + if proposal != nil { + synced = append(synced, proposal.ID()) + } if err := checkFilter(scope, filter, synced); err != nil { return workspaceState{}, err } @@ -44,7 +57,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, proposal) if err != nil { return workspaceState{}, err } @@ -60,11 +73,19 @@ func loadWorkspacePlan( client syncapi.Client, scope syncdomain.Scope, filter syncdomain.Filter, + proposal *synclocal.SourceDefinition, ) (Plan, map[ResourceID]syncdomain.SyncedResource, error) { variations, err := compileWorkspace(repositoryRoot) if err != nil { return Plan{}, nil, err } + if proposal != nil && proposal.Variation != nil { + id := proposal.Variation.ID() + variations = slices.DeleteFunc(variations, func(resource syncdomain.SyncedResource) bool { + return resource.ID() == id + }) + 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 +94,20 @@ 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) + referenced, err := planReferencedAttachments(repositoryRoot, baseline.Lock, attachments, plan.Resources, proposal) if err != nil { return Plan{}, nil, err } @@ -97,6 +118,10 @@ func loadWorkspacePlan( checkAttachmentBaselines(plan.Resources, baseline.Lock) checkAttachmentDeletes(plan.Resources, variations) + if err := applySourceDirections(client, plan.Resources); err != nil { + return Plan{}, nil, err + } + checkSourcePublications(repositoryRoot, plan.Resources) for index := range plan.Resources { resource := &plan.Resources[index] resource.SyncedElsewhere = baseline.Stale(resource.ID) @@ -112,6 +137,135 @@ func loadWorkspacePlan( return plan, localFiles, nil } +func checkSourcePublications(repositoryRoot string, resources []PlannedResource) { + var replacements []synclocal.SourceReplacement + for index := range resources { + resource := &resources[index] + if resource.Action == ActionUpdateLocal && resource.Source != nil { + projection, err := syncdomain.ChangedProjection(resource.LocalProjection, resource.ServerProjection) + if err != nil { + resource.Action, resource.Error = ActionError, err.Error() + continue + } + replacements = append(replacements, synclocal.SourceReplacement{ + ID: resource.ID, Mode: resource.ServerMode, Reference: *resource.Source, + Projection: projection, ExpectedFingerprint: resource.LocalFingerprint, + ExpectedProjection: resource.LocalProjection, + ExpectedIndexedSelections: resource.IndexedSelections, + }) + } + } + if len(replacements) == 0 { + return + } + if err := synclocal.NewStore(repositoryRoot).CheckSources(replacements); err != nil { + for index := range resources { + if resources[index].Action == ActionUpdateLocal && resources[index].Source != nil { + if problem, ok := syncdomain.ProblemFrom(err); ok { + setProblem(&resources[index], problem) + } else { + resources[index].Action, resources[index].Error = ActionError, err.Error() + } + } + } + } +} + +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 + if resource.Source != nil && resource.Action == ActionDeleteLocal { + setProblem(resource, syncdomain.NewProblem( + syncdomain.ErrorResourceDeleted, + "linked resource no longer exists in LaunchDarkly", + "Run "+detachCommand(resource.ID)+".", + ).WithResource(resource.ID).WithFile(resource.Source.File, 0, 0)) + return + } + switch direction { + case syncdomain.SyncDirectionBoth: + return + case syncdomain.SyncDirectionCodeCanonical: + switch resource.Action { + case ActionUpdateLocal, ActionConflict: + if resource.hasLocal() && resource.hasServer() { + resource.Action = ActionUpdateServer + } else { + resource.Action = ActionError + resource.Error = "code-canonical resource has no repository value to publish" + } + } + case syncdomain.SyncDirectionServerCanonical: + switch resource.Action { + case ActionUpdateServer, ActionConflict: + if resource.hasServer() { + resource.Action = ActionUpdateLocal + } else { + 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" + } + } +} + +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 @@ -154,7 +308,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 +357,19 @@ func planAttachmentRows( lock syncmanifest.Manifest, attachments *attachmentCache, include func(ResourceID) bool, + proposal *synclocal.SourceDefinition, ) ([]PlannedResource, error) { local, err := synclocal.CompileAttachments(repositoryRoot, include) if err != nil { return nil, err } + if proposal != nil && proposal.Attachment != nil && include(proposal.Attachment.ID()) { + id := proposal.Attachment.ID() + local = slices.DeleteFunc(local, func(attachment syncdomain.SyncedAttachment) bool { + return attachment.ID() == id + }) + local = append(local, *proposal.Attachment) + } tracked := syncmanifest.New() for _, resource := range lock.Resources { if resource.ResourceKind != syncdomain.KindVariation && include(resource.ID()) { @@ -247,6 +408,7 @@ func planReferencedAttachments( lock syncmanifest.Manifest, attachments *attachmentCache, resources []PlannedResource, + proposal *synclocal.SourceDefinition, ) ([]PlannedResource, error) { written := make(map[ResourceID]bool) conflictUses := make(map[ResourceID][]conflictUse) @@ -268,7 +430,7 @@ 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 } @@ -471,7 +633,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) } } @@ -599,11 +765,22 @@ 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) +} + // sameAttachmentPins compares the exact versions that LaunchDarkly pins. The // fingerprints do not include versions. func sameAttachmentPins(reviewed, current *syncdomain.Variation) bool {