diff --git a/cmd/sync/prompt.go b/cmd/sync/prompt.go index 9b69806be..34d73fb2f 100644 --- a/cmd/sync/prompt.go +++ b/cmd/sync/prompt.go @@ -15,7 +15,6 @@ import ( "github.com/launchdarkly/ldcli/internal/output" "github.com/launchdarkly/ldcli/internal/resources" syncdomain "github.com/launchdarkly/ldcli/internal/sync" - synclink "github.com/launchdarkly/ldcli/internal/sync/link" syncprompt "github.com/launchdarkly/ldcli/internal/sync/prompt" ) @@ -68,7 +67,6 @@ func NewPromptCmd(client resources.Client) *cobra.Command { newAddCmd(client), newAttachCmd(client), newDetachCmd(client), - newLinkCmd(client), ) cmd.SetUsageTemplate(resourcescmd.SubcommandUsageTemplate()) @@ -270,37 +268,6 @@ func newDetachCmd(client resources.Client) *cobra.Command { return cmd } -func newLinkCmd(client resources.Client) *cobra.Command { - cmd := &cobra.Command{ - Use: "link ", - Short: "Link an external prompt file", - Example: ` # Link a Markdown file as a new variation - ldcli sync prompts link prompts/support.md \ - --format plain-markdown \ - --to production/support/default \ - --model-config-key claude \ - --yes --no-input`, - Args: validatedArgs(cobra.ExactArgs(1)), - RunE: runCommand(client, syncdomain.ScopePrompts, func(cmd *cobra.Command, args []string) (syncprompt.CommandAction, error) { - format, _ := cmd.Flags().GetString(formatFlag) - target, err := linkTarget(cmd) - if err != nil { - return nil, err - } - return syncprompt.LinkAction{File: args[0], Format: format, ProjectKey: targetFilter(cmd).Project, Target: target}, nil - }), - } - addTargetFlags(cmd, targetFlagHelp{project: questionFlagHelp.project}) - cmd.Flags().String(formatFlag, "", "Format adapter (for example, plain-markdown)") - cmd.Flags().String(toFlag, "", "New variation (project-key/config-key/variation-key)") - cmd.Flags().String(modelConfigKeyFlag, "", "Model config key for the new variation") - cmd.Flags().String(nameFlag, "", "Variation name when absent from the linked file") - cmd.Flags().String(contentFlag, "", "Prompt content when absent from the linked file") - _ = cmd.MarkFlagRequired(formatFlag) - addApplyFlags(cmd, syncdomain.ScopePrompts, false) - return cmd -} - // addApplyFlags registers the flags of a command that applies a plan. Only // the prompts scope has --resolve, which names variations. func addApplyFlags(cmd *cobra.Command, scope syncdomain.Scope, dryRun bool) { @@ -434,28 +401,6 @@ func optionalVariationFlag(cmd *cobra.Command, name string) (*syncdomain.Resourc return &variation, nil } -func linkTarget(cmd *cobra.Command) (*synclink.Target, error) { - variation, err := optionalVariationFlag(cmd, toFlag) - if err != nil { - return nil, err - } - modelConfig, _ := cmd.Flags().GetString(modelConfigKeyFlag) - name, _ := cmd.Flags().GetString(nameFlag) - content, _ := cmd.Flags().GetString(contentFlag) - if variation == nil { - if modelConfig != "" || name != "" || content != "" { - return nil, fmt.Errorf("--model-config-key, --name, and --content require --to") - } - return nil, nil - } - return &synclink.Target{ - Variation: *variation, - ModelConfigKey: modelConfig, - Name: name, - Content: content, - }, nil -} - func conflictPolicy(cmd *cobra.Command) (syncprompt.ConflictPolicy, error) { var policy syncprompt.ConflictPolicy if cmd.Flags().Lookup(conflictFlag) == nil { diff --git a/cmd/sync/prompt_test.go b/cmd/sync/prompt_test.go index fc7dbc954..f3708811c 100644 --- a/cmd/sync/prompt_test.go +++ b/cmd/sync/prompt_test.go @@ -17,21 +17,17 @@ func TestPromptCommandUsesActionSubcommands(t *testing.T) { command := NewPromptCmd(nil) assert.Equal(t, "prompts", command.Use) - for _, name := range []string{"watch", "add", "attach", "detach", "link"} { + for _, name := range []string{"watch", "add", "attach", "detach"} { child, _, err := command.Find([]string{name}) require.NoError(t, err) assert.Equal(t, name, child.Name()) } - for _, name := range []string{"add", "attach-tool", "attach-skill", "detach", "link", "watch"} { + for _, name := range []string{"add", "attach-tool", "attach-skill", "detach", "watch"} { assert.Nil(t, command.Flags().Lookup(name), "legacy --%s should not exist", name) } add, _, err := command.Find([]string{"add"}) require.NoError(t, err) assert.Equal(t, "add [project-key/config-key/variation-key...]", add.Use) - link, _, err := command.Find([]string{"link"}) - require.NoError(t, err) - assert.NotNil(t, link.Flags().Lookup(modelConfigKeyFlag)) - assert.Nil(t, link.Flags().Lookup("model-config")) } func TestSyncCommandIncludesExamples(t *testing.T) { @@ -57,7 +53,6 @@ func TestPromptCommandHelpIncludesExamples(t *testing.T) { "attach tool": {"attach", "tool"}, "attach skill": {"attach", "skill"}, "detach": {"detach"}, - "link": {"link"}, } for name, path := range tests { @@ -112,7 +107,6 @@ func TestPromptCommandsRegisterTargetFlags(t *testing.T) { "add": {path: []string{"add"}, help: questionFlagHelp}, "detach": {path: []string{"detach"}, help: questionFlagHelp}, "attach tool": {path: []string{"attach", "tool"}, help: targetFlagHelp{project: questionFlagHelp.project}}, - "link": {path: []string{"link"}, help: targetFlagHelp{project: questionFlagHelp.project}}, } for name, test := range tests { diff --git a/cmd/sync/scope_test.go b/cmd/sync/scope_test.go index a75d894cd..1d7a0ec9a 100644 --- a/cmd/sync/scope_test.go +++ b/cmd/sync/scope_test.go @@ -25,17 +25,14 @@ var syncCommandFlags = map[string][]string{ "sync prompts attach tool": {"conflict", "diff", "no-input", "project", "resolve", "to", "yes"}, "sync prompts attach skill": {"conflict", "diff", "no-input", "project", "resolve", "to", "yes"}, "sync prompts detach": {"archive", "config-key", "key", "no-input", "project", "yes"}, - "sync prompts link": { - "conflict", "content", "diff", "format", "model-config-key", "name", "no-input", "project", "resolve", "to", "yes", - }, - "sync tools": {"conflict", "diff", "dry-run", "key", "no-input", "project", "yes"}, - "sync tools watch": {"conflict", "diff", "key", "no-input", "project", "yes"}, - "sync tools add": {"description", "diff", "dry-run", "json-schema", "key", "no-input", "project", "yes"}, - "sync tools detach": {"key", "no-input", "project", "yes"}, - "sync skills": {"conflict", "diff", "dry-run", "key", "no-input", "project", "yes"}, - "sync skills watch": {"conflict", "diff", "key", "no-input", "project", "yes"}, - "sync skills add": {"content", "description", "diff", "dry-run", "key", "name", "no-input", "project", "yes"}, - "sync skills detach": {"key", "no-input", "project", "yes"}, + "sync tools": {"conflict", "diff", "dry-run", "key", "no-input", "project", "yes"}, + "sync tools watch": {"conflict", "diff", "key", "no-input", "project", "yes"}, + "sync tools add": {"description", "diff", "dry-run", "json-schema", "key", "no-input", "project", "yes"}, + "sync tools detach": {"key", "no-input", "project", "yes"}, + "sync skills": {"conflict", "diff", "dry-run", "key", "no-input", "project", "yes"}, + "sync skills watch": {"conflict", "diff", "key", "no-input", "project", "yes"}, + "sync skills add": {"content", "description", "diff", "dry-run", "key", "name", "no-input", "project", "yes"}, + "sync skills detach": {"key", "no-input", "project", "yes"}, } func TestSyncCommandsHaveExactFlags(t *testing.T) { @@ -75,11 +72,7 @@ func TestSyncCommandsReadSyncFlagsOnlyFromTheCommandLine(t *testing.T) { for _, command := range runnableSyncCommands(NewSyncCmd(nil, nil)) { require.NoError(t, command.ParseFlags(nil)) - var args []string - if command.Name() == "link" { - args = []string{"prompt.md"} - } - require.NoError(t, command.Args(command, args), command.CommandPath()) + require.NoError(t, command.Args(command, nil), command.CommandPath()) command.LocalNonPersistentFlags().VisitAll(func(flag *pflag.Flag) { assert.False(t, flag.Changed, "%s --%s", command.CommandPath(), flag.Name) }) diff --git a/internal/sync/api/client_test.go b/internal/sync/api/client_test.go index 16d6e8fdb..48de862da 100644 --- a/internal/sync/api/client_test.go +++ b/internal/sync/api/client_test.go @@ -463,6 +463,7 @@ func TestClientArchiveVariationReturnsTransportError(t *testing.T) { func TestClientGetsSyncManifest(t *testing.T) { transport := &recordingClient{Responses: [][]byte{[]byte(`{ + "formatVersion": 1, "source": "git:example.com/acme/repo", "items": [{ "resourceKind": "variation", @@ -487,6 +488,7 @@ func TestClientGetsSyncManifest(t *testing.T) { func TestClientPatchesSyncManifest(t *testing.T) { transport := &recordingClient{Responses: [][]byte{[]byte(`{ + "formatVersion": 1, "source": "git:example.com/acme/repo", "items": [] }`)}} @@ -508,6 +510,7 @@ func TestClientPatchesSyncManifest(t *testing.T) { assert.Equal(t, http.MethodPatch, request.Method) assert.Equal(t, "application/json", request.ContentType) assert.JSONEq(t, `{ + "formatVersion": 1, "source": "git:example.com/acme/repo", "upserts": [{ "resourceKind": "variation", @@ -519,6 +522,19 @@ func TestClientPatchesSyncManifest(t *testing.T) { }`, string(request.Body)) } +func TestClientRejectsUnknownSyncManifestVersion(t *testing.T) { + transport := &recordingClient{Responses: [][]byte{[]byte(`{ + "formatVersion": 99, + "source": "git:example.com/acme/repo", + "items": [] + }`)}} + client := NewClient(transport, "token", "https://example.com") + + _, err := client.GetSyncManifest("project", "git:example.com/acme/repo") + + require.ErrorContains(t, err, "unsupported formatVersion 99") +} + func TestIsConflictRecognizesAPIStatus(t *testing.T) { assert.True(t, IsConflict(errors.New(`{"code":"conflict","statusCode":409}`))) assert.False(t, IsConflict(errors.New(`{"code":"invalid_request","statusCode":400}`))) diff --git a/internal/sync/api/manifest.go b/internal/sync/api/manifest.go index ad997bc9d..958f2605f 100644 --- a/internal/sync/api/manifest.go +++ b/internal/sync/api/manifest.go @@ -9,27 +9,39 @@ import ( syncdomain "github.com/launchdarkly/ldcli/internal/sync" ) +// SyncManifestFormatVersion is the remote sync manifest schema. +const SyncManifestFormatVersion = 1 + // SyncManifest is the sync baseline that LaunchDarkly stores for one project // and one repository source. type SyncManifest struct { - Source string `json:"source"` - Items []SyncManifestResource `json:"items"` + FormatVersion int `json:"formatVersion"` + Source string `json:"source"` + Items []SyncManifestResource `json:"items"` } // SyncManifestResource is one versioned baseline entry. type SyncManifestResource struct { - ResourceKind syncdomain.Kind `json:"resourceKind"` - ResourceLookupKey string `json:"resourceLookupKey"` - Fingerprint string `json:"fingerprint"` - Version int `json:"version"` + ResourceKind syncdomain.Kind `json:"resourceKind"` + ResourceLookupKey string `json:"resourceLookupKey"` + Fingerprint string `json:"fingerprint"` + Version int `json:"version"` + FingerprintSchema string `json:"fingerprintSchema,omitempty"` + ReferenceDigest string `json:"referenceDigest,omitempty"` + AdapterBehaviorVersion int `json:"adapterBehaviorVersion,omitempty"` + AdapterBehaviorDigest string `json:"adapterBehaviorDigest,omitempty"` } // SyncManifestUpsert creates an entry, or updates the entry at Version. type SyncManifestUpsert struct { - ResourceKind syncdomain.Kind `json:"resourceKind"` - ResourceLookupKey string `json:"resourceLookupKey"` - Fingerprint string `json:"fingerprint"` - Version int `json:"version"` + ResourceKind syncdomain.Kind `json:"resourceKind"` + ResourceLookupKey string `json:"resourceLookupKey"` + Fingerprint string `json:"fingerprint"` + Version int `json:"version"` + FingerprintSchema string `json:"fingerprintSchema,omitempty"` + ReferenceDigest string `json:"referenceDigest,omitempty"` + AdapterBehaviorVersion int `json:"adapterBehaviorVersion,omitempty"` + AdapterBehaviorDigest string `json:"adapterBehaviorDigest,omitempty"` } // SyncManifestDeletion removes the entry at Version. @@ -48,7 +60,14 @@ func (client Client) GetSyncManifest(projectKey, source string) (SyncManifest, e if err != nil { return SyncManifest{}, err } - return decodeJSON[SyncManifest](response, fmt.Sprintf("sync manifest for project %q", projectKey)) + manifest, err := decodeJSON[SyncManifest](response, fmt.Sprintf("sync manifest for project %q", projectKey)) + if err != nil { + return SyncManifest{}, err + } + if err := validateSyncManifest(manifest); err != nil { + return SyncManifest{}, fmt.Errorf("sync manifest for project %q: %w", projectKey, err) + } + return manifest, nil } // PatchSyncManifest applies versioned changes and returns the new baseline. @@ -68,10 +87,16 @@ func (client Client) PatchSyncManifest( request := mutation{ method: http.MethodPatch, action: "update", resource: "sync manifest for project", key: projectKey, body: struct { - Source string `json:"source"` - Upserts []SyncManifestUpsert `json:"upserts"` - Deletions []SyncManifestDeletion `json:"deletions"` - }{Source: source, Upserts: upserts, Deletions: deletions}, + FormatVersion int `json:"formatVersion"` + Source string `json:"source"` + Upserts []SyncManifestUpsert `json:"upserts"` + Deletions []SyncManifestDeletion `json:"deletions"` + }{ + FormatVersion: SyncManifestFormatVersion, + Source: source, + Upserts: upserts, + Deletions: deletions, + }, } response, err := client.mutate(request, manifestPath(projectKey)...) if err != nil { @@ -84,9 +109,19 @@ func (client Client) PatchSyncManifest( // caller must read the manifest again to find its state. return SyncManifest{}, newMutationError("decode updated", request.resource, projectKey, err) } + if err := validateSyncManifest(manifest); err != nil { + return SyncManifest{}, newMutationError("decode updated", request.resource, projectKey, err) + } return manifest, nil } +func validateSyncManifest(manifest SyncManifest) error { + if manifest.FormatVersion != SyncManifestFormatVersion { + return fmt.Errorf("unsupported formatVersion %d", manifest.FormatVersion) + } + return nil +} + func manifestPath(projectKey string) []string { return projectPath(projectKey, "configs/sync/manifests") } diff --git a/internal/sync/attachment.go b/internal/sync/attachment.go index 8062cd15b..53b601892 100644 --- a/internal/sync/attachment.go +++ b/internal/sync/attachment.go @@ -91,6 +91,11 @@ func (attachment Attachment) ID(projectKey string) ResourceID { type SyncedAttachment struct { ProjectKey string Attachment Attachment + // Source is the descriptor-backed repository source, or nil for a managed file. + Source *Reference + Dependencies []string + IndexedSelections []IndexedSelection + Projection Projection } // ID returns the identity of the tool or the skill. diff --git a/internal/sync/detach/detach_test.go b/internal/sync/detach/detach_test.go index d7865a1e1..9c6aa7427 100644 --- a/internal/sync/detach/detach_test.go +++ b/internal/sync/detach/detach_test.go @@ -18,7 +18,6 @@ import ( syncapi "github.com/launchdarkly/ldcli/internal/sync/api" synclocal "github.com/launchdarkly/ldcli/internal/sync/local" syncmanifest "github.com/launchdarkly/ldcli/internal/sync/manifest" - syncreference "github.com/launchdarkly/ldcli/internal/sync/reference" ) func TestLoadResourcesUnionsLocalAndManifestResources(t *testing.T) { @@ -97,48 +96,6 @@ func TestDetachResourcesKeepsAttachmentManifestEntries(t *testing.T) { require.NoError(t, err, "the tool file stays") } -func TestDetachResourcesRemovesWrapperAndManifestButKeepsReferencedFile(t *testing.T) { - root := t.TempDir() - referencePath := filepath.Join(root, "prompts", "prompt.md") - require.NoError(t, os.MkdirAll(filepath.Dir(referencePath), 0o755)) - require.NoError(t, os.WriteFile(referencePath, []byte("Keep me.\n"), 0o644)) - - store := synclocal.NewStore(root) - variation := testVariation("prompt") - _, err := store.Add([]synclocal.VariationFile{{ - ProjectKey: "project", ConfigKey: "config", Variation: variation, - Ref: &synclocal.Reference{File: "prompts/prompt.md", Format: syncreference.PlainMarkdown}, - }}) - require.NoError(t, err) - - manifestStore := newMemoryManifestStore() - original := syncmanifest.Manifest{ - Resources: []syncmanifest.Resource{{ - ResourceKind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/prompt", - Fingerprint: testFingerprint(), - }}, - } - require.NoError(t, manifestStore.Write(original)) - - resource := syncdomain.ResourceID{Kind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/prompt"} - err = detachResources( - Options{RepositoryRoot: root, Store: store, Baselines: manifestStore}, - syncmanifest.Baseline{Lock: original}, - []syncdomain.ResourceID{resource}, - ) - - require.NoError(t, err) - exists, err := store.VariationExists("project", "config", "prompt") - require.NoError(t, err) - assert.False(t, exists) - _, err = os.Stat(referencePath) - require.NoError(t, err) - baseline, err := manifestStore.Load([]string{"project"}) - manifest := baseline.Lock - require.NoError(t, err) - assert.Empty(t, manifest.Resources) -} - func TestDetachResourcesRemovesManifestEntryWhenWrapperWasAlreadyDeleted(t *testing.T) { root := t.TempDir() store := synclocal.NewStore(root) diff --git a/internal/sync/error.go b/internal/sync/error.go new file mode 100644 index 000000000..cd32c1fbd --- /dev/null +++ b/internal/sync/error.go @@ -0,0 +1,105 @@ +package sync + +import ( + "errors" + "fmt" +) + +// ErrorCode identifies one stable sync failure. +type ErrorCode string + +const ( + ErrorInvalidPathSyntax ErrorCode = "invalid_path_syntax" + ErrorPathNotFound ErrorCode = "path_not_found" + ErrorPathAmbiguous ErrorCode = "path_ambiguous" + ErrorPathTypeMismatch ErrorCode = "path_type_mismatch" + ErrorFilterTypeMismatch ErrorCode = "filter_value_type_mismatch" + ErrorMapTypeMismatch ErrorCode = "map_value_type_mismatch" + ErrorIndexOutOfRange ErrorCode = "index_out_of_range" + ErrorIndexedSelectionChanged ErrorCode = "indexed_selection_changed" + ErrorUnsupportedFormat ErrorCode = "unsupported_source_format" + ErrorFieldReadOnly ErrorCode = "field_read_only" + ErrorDuplicateKey ErrorCode = "duplicate_key" + ErrorInvalidSource ErrorCode = "invalid_source" + ErrorInvalidReference ErrorCode = "invalid_reference" + ErrorDefinitionConflict ErrorCode = "resource_definition_conflict" + ErrorLinkedSourceMissing ErrorCode = "linked_source_missing" + ErrorSyncSettingUnavailable ErrorCode = "sync_setting_unavailable" + ErrorBindingStale ErrorCode = "binding_stale" + ErrorStateChanged ErrorCode = "state_changed" + ErrorPublicationUnsupported ErrorCode = "publication_not_supported" + ErrorOverlappingEdits ErrorCode = "overlapping_edits" + ErrorResourceDeleted ErrorCode = "resource_deleted_in_launchdarkly" + ErrorPlaceholderAmbiguous ErrorCode = "placeholder_translation_ambiguous" + ErrorImportUnresolved ErrorCode = "import_unresolved" +) + +// Problem describes one actionable sync error. +type Problem struct { + Code ErrorCode `json:"code"` + Message string `json:"message"` + Resource string `json:"resource,omitempty"` + File string `json:"file,omitempty"` + Line int `json:"line,omitempty"` + Column int `json:"column,omitempty"` + Suggestion string `json:"suggestion,omitempty"` + Cause error `json:"-"` +} + +// Error returns the message without embedding the stable code. +func (problem *Problem) Error() string { + if problem == nil { + return "" + } + if problem.Suggestion == "" { + return problem.Message + } + return problem.Message + ". " + problem.Suggestion +} + +// Unwrap returns the lower-level cause. +func (problem *Problem) Unwrap() error { + if problem == nil { + return nil + } + return problem.Cause +} + +// NewProblem creates one sync problem. +func NewProblem(code ErrorCode, message, suggestion string) *Problem { + return &Problem{Code: code, Message: message, Suggestion: suggestion} +} + +// ProblemFrom returns the first sync problem in an error chain. +func ProblemFrom(err error) (*Problem, bool) { + var problem *Problem + ok := errors.As(err, &problem) + return problem, ok +} + +// WithResource adds a resource identity to a problem. +func (problem *Problem) WithResource(id ResourceID) *Problem { + if problem != nil { + problem.Resource = id.Label() + } + return problem +} + +// WithFile adds a source location to a problem. +func (problem *Problem) WithFile(file string, line, column int) *Problem { + if problem != nil { + problem.File, problem.Line, problem.Column = file, line, column + } + return problem +} + +// WrapProblem adds a lower-level cause. +func WrapProblem(problem *Problem, cause error) *Problem { + if problem != nil { + problem.Cause = cause + if problem.Message == "" && cause != nil { + problem.Message = fmt.Sprint(cause) + } + } + return problem +} diff --git a/internal/sync/link/link.go b/internal/sync/link/link.go deleted file mode 100644 index bbf6ed760..000000000 --- a/internal/sync/link/link.go +++ /dev/null @@ -1,310 +0,0 @@ -// Package link creates a variation whose prompt lives in an external file in -// the repository. The variation file stores a reference to that file. -package link - -import ( - "bytes" - "cmp" - "errors" - "fmt" - "io" - "os" - "path/filepath" - "slices" - "strings" - - "github.com/charmbracelet/huh" - - syncdomain "github.com/launchdarkly/ldcli/internal/sync" - syncapi "github.com/launchdarkly/ldcli/internal/sync/api" - synccreate "github.com/launchdarkly/ldcli/internal/sync/create" - syncinteractive "github.com/launchdarkly/ldcli/internal/sync/interactive" - synclocal "github.com/launchdarkly/ldcli/internal/sync/local" - syncreference "github.com/launchdarkly/ldcli/internal/sync/reference" - "github.com/launchdarkly/ldcli/internal/sync/reference/adapters" -) - -// Options are the dependencies and the input of one link. -type Options struct { - Catalog synccreate.Catalog - Store synclocal.Store - RepositoryRoot string - WorkingDirectory string - File string - Format string - Input io.Reader - Output io.Writer - // ProjectKey skips the project question when Run asks for the destination. - ProjectKey string - // Target is the new variation. If it is nil, Run asks the user. - Target *Target - NoInput bool -} - -// Target is a new variation that the user named with flags. Name and Content -// replace values that the linked file does not have. -type Target struct { - Variation syncdomain.ResourceID - ModelConfigKey string - Name string - Content string -} - -// Selection is the complete destination of a new linked variation. -type Selection struct { - Project syncapi.Project - Config syncapi.Config - ModelConfig syncapi.ModelConfig - Key string - Name string -} - -// linkedPrompt is the external file. If the file has no prompt, content is -// the new prompt that link writes to the file. -type linkedPrompt struct { - reference synclocal.Reference - parsed adapters.Prompt - originalContent []byte - content []byte -} - -// Run creates the linked variation file and returns its path. The path is -// empty when the user cancels. -func Run(options Options) (string, error) { - prompt, err := readLinkedPrompt(options) - if err != nil { - return "", err - } - - var selection Selection - var content string - if options.Target != nil { - selection, content, err = targetSelection(options, prompt, *options.Target) - } else { - if err := syncinteractive.RequireTerminal( - options.Input, options.Output, options.NoInput, "--to and --model-config-key", "prompt linking", - ); err != nil { - return "", err - } - var canceled bool - selection, content, canceled, err = promptForSelection(options, prompt) - if canceled { - return "", nil - } - } - if err != nil { - return "", err - } - - prompt, err = addMissingPromptContent(prompt, selection.Config.Mode, selection.Key, selection.Name, content) - if err != nil { - return "", err - } - return createLinkedPrompt(options, selection, prompt) -} - -// targetSelection completes the destination that the user named with flags. -// It returns the prompt content to add when the linked file has none. -func targetSelection(options Options, prompt linkedPrompt, target Target) (Selection, string, error) { - configKey, variationKey, err := target.Variation.VariationKeys() - if err != nil { - return Selection{}, "", fmt.Errorf("link destination must be a variation: %w", err) - } - hasContent := len(prompt.parsed.Messages) != 0 - switch { - case prompt.parsed.Key != "" && prompt.parsed.Key != variationKey: - return Selection{}, "", fmt.Errorf("linked file key %q does not match destination key %q", prompt.parsed.Key, variationKey) - case target.Name != "" && strings.TrimSpace(target.Name) == "": - return Selection{}, "", errors.New("--name cannot be blank") - case target.Content != "" && strings.TrimSpace(target.Content) == "": - return Selection{}, "", errors.New("--content cannot be blank") - case prompt.parsed.Name != "" && target.Name != "" && prompt.parsed.Name != target.Name: - return Selection{}, "", fmt.Errorf("linked file name %q does not match --name %q", prompt.parsed.Name, target.Name) - case !hasContent && target.Content == "": - return Selection{}, "", errors.New("--content is required when the linked file has no prompt content") - case hasContent && target.Content != "": - return Selection{}, "", errors.New("--content cannot be used when the linked file already has prompt content") - } - - projectKey := target.Variation.ProjectKey - config, err := options.Catalog.Config(projectKey, configKey) - if err != nil { - return Selection{}, "", err - } - modelConfig, err := synccreate.FindModelConfig(options.Catalog, projectKey, target.ModelConfigKey) - if err != nil { - return Selection{}, "", err - } - - name := cmp.Or(prompt.parsed.Name, target.Name, syncdomain.DisplayName(variationKey)) - return Selection{ - Project: syncapi.Project{Key: projectKey}, - Config: config, - ModelConfig: modelConfig, - Key: variationKey, - Name: name, - }, target.Content, nil -} - -// promptForSelection asks the user for the destination, and for each value -// that the linked file does not have. The bool result is true when the user -// cancels. -func promptForSelection(options Options, prompt linkedPrompt) (Selection, string, bool, error) { - questions := synccreate.Questions{Allowed: true, Input: options.Input, Output: options.Output} - var modes []syncdomain.VariationMode - if prompt.parsed.Mode != "" { - modes = []syncdomain.VariationMode{prompt.parsed.Mode} - } - project, config, canceled, err := synccreate.SelectConfig(questions, options.Catalog, options.ProjectKey, modes) - if err != nil || canceled { - return Selection{}, "", canceled, err - } - modelConfig, canceled, err := synccreate.SelectModelConfig(questions, options.Catalog, project.Key) - if err != nil || canceled { - return Selection{}, "", canceled, err - } - - // Ask only for the values that the linked file does not have. - key, name, content := prompt.parsed.Key, prompt.parsed.Name, "" - var fields []huh.Field - if key == "" { - key = strings.TrimSuffix(filepath.Base(prompt.reference.File), filepath.Ext(prompt.reference.File)) - fields = append(fields, huh.NewInput().Title("Variation key").Value(&key).Validate(validateDerivedKey)) - } - if name == "" { - name = syncdomain.DisplayName(key) - fields = append(fields, huh.NewInput().Title("Variation name").Value(&name).Validate(synccreate.RequiredValue("variation name"))) - } - if len(prompt.parsed.Messages) == 0 { - fields = append(fields, huh.NewText().Title("Prompt content").Lines(8).Value(&content). - Validate(synccreate.RequiredValue("prompt content"))) - } - if len(fields) != 0 { - if canceled, err := syncinteractive.RunForm(options.Input, options.Output, fields...); err != nil || canceled { - return Selection{}, "", canceled, err - } - } - - return Selection{Project: project, Config: config, ModelConfig: modelConfig, Key: key, Name: name}, content, false, nil -} - -// addMissingPromptContent renders content as the new text of a linked file -// that has no prompt. It does not write the file. createLinkedPrompt writes -// it after every check passes. -func addMissingPromptContent( - prompt linkedPrompt, - mode syncdomain.VariationMode, - key, name, content string, -) (linkedPrompt, error) { - if len(prompt.parsed.Messages) != 0 { - return prompt, nil - } - variation := syncdomain.Variation{Mode: mode, Key: key, Name: name} - if mode == syncdomain.VariationModeAgent { - variation.Instructions = content - } else { - variation.Messages = []syncdomain.Message{{Role: syncdomain.RoleSystem, Content: content}} - } - rendered, err := syncreference.Render(prompt.reference.Format, variation) - if err != nil { - return linkedPrompt{}, err - } - prompt.content = rendered - return prompt, nil -} - -// createLinkedPrompt checks the destination, writes new prompt content to the -// linked file, and creates the variation file. If the variation file fails, -// it restores the linked file. -func createLinkedPrompt(options Options, selection Selection, prompt linkedPrompt) (string, error) { - variation := synccreate.NewVariation(selection.Config, selection.Key, selection.Name, selection.ModelConfig) - if err := syncreference.ApplyToVariation(prompt.reference.Format, prompt.content, &variation); err != nil { - return "", err - } - - // Every check must pass before the linked file changes. - if variation.Mode != selection.Config.Mode { - return "", fmt.Errorf("referenced prompt mode %q does not match config mode %q", variation.Mode, selection.Config.Mode) - } - if err := validateDerivedKey(variation.Key); err != nil { - return "", err - } - if err := variation.Validate(); err != nil { - return "", fmt.Errorf("variation %q: %w", variation.Key, err) - } - if slices.ContainsFunc(selection.Config.Variations, func(existing syncdomain.Variation) bool { - return existing.Key == variation.Key - }) { - return "", fmt.Errorf("variation %q already exists in config %q", variation.Key, selection.Config.Key) - } - exists, err := options.Store.VariationExists(selection.Project.Key, selection.Config.Key, variation.Key) - if err != nil { - return "", err - } - if exists { - return "", fmt.Errorf("variation %q is already linked locally", variation.Key) - } - - source := filepath.Join(options.RepositoryRoot, filepath.FromSlash(prompt.reference.File)) - sourceChanged := !bytes.Equal(prompt.originalContent, prompt.content) - var sourceMode os.FileMode - if sourceChanged { - info, err := os.Stat(source) - if err != nil { - return "", err - } - sourceMode = info.Mode().Perm() - err = synclocal.ReplaceFileAtomically(source, prompt.reference.File, prompt.originalContent, prompt.content, sourceMode) - if err != nil { - return "", fmt.Errorf("write linked file %q: %w", prompt.reference.File, err) - } - } - - creation, err := options.Store.Add([]synclocal.VariationFile{{ - ProjectKey: selection.Project.Key, - ConfigKey: selection.Config.Key, - Upsert: true, - Ref: &prompt.reference, - Variation: variation, - }}) - if err != nil { - if sourceChanged { - restoreErr := synclocal.ReplaceFileAtomically( - source, prompt.reference.File, prompt.content, prompt.originalContent, sourceMode, - ) - if restoreErr != nil { - err = errors.Join(err, fmt.Errorf("restore linked file %q: %w", prompt.reference.File, restoreErr)) - } - } - return "", err - } - return creation.VariationPaths[0], nil -} - -// readLinkedPrompt reads and parses the external file. -func readLinkedPrompt(options Options) (linkedPrompt, error) { - reference, err := synclocal.NewReference(options.RepositoryRoot, options.WorkingDirectory, options.File, options.Format) - if err != nil { - return linkedPrompt{}, err - } - content, err := os.ReadFile(filepath.Join(options.RepositoryRoot, filepath.FromSlash(reference.File))) - if err != nil { - return linkedPrompt{}, fmt.Errorf("read linked file %q: %w", reference.File, err) - } - prompt, err := syncreference.Parse(reference.Format, content) - if err != nil { - return linkedPrompt{}, err - } - return linkedPrompt{reference: reference, parsed: prompt, originalContent: content, content: content}, nil -} - -// validateDerivedKey makes sure that a key from a file name is a safe key. -func validateDerivedKey(key string) error { - if key == "" { - return errors.New("cannot derive a variation key from the linked filename") - } - if syncdomain.ValidateKey(key) != nil { - return fmt.Errorf("linked filename produces invalid variation key %q", key) - } - return nil -} diff --git a/internal/sync/link/link_test.go b/internal/sync/link/link_test.go deleted file mode 100644 index 6705904d0..000000000 --- a/internal/sync/link/link_test.go +++ /dev/null @@ -1,279 +0,0 @@ -package link - -import ( - "bytes" - "errors" - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/require" - - syncdomain "github.com/launchdarkly/ldcli/internal/sync" - syncapi "github.com/launchdarkly/ldcli/internal/sync/api" - synclocal "github.com/launchdarkly/ldcli/internal/sync/local" - syncreference "github.com/launchdarkly/ldcli/internal/sync/reference" -) - -func TestRunUsesExplicitTargetWithoutTerminal(t *testing.T) { - root := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(root, "prompt.md"), []byte("Be helpful.\n"), 0o644)) - target := syncdomain.ResourceID{ - Kind: syncdomain.KindVariation, ProjectKey: "production", LookupKey: "support/support-agent", - } - - path, err := Run(Options{ - Catalog: &fakeCatalog{ - config: syncapi.Config{Key: "support", Mode: syncdomain.VariationModeAgent}, - model: syncapi.ModelConfig{Key: "claude", ID: "claude-3", Version: 2}, - }, - Store: synclocal.NewStore(root), - RepositoryRoot: root, - WorkingDirectory: root, - File: "prompt.md", - Format: syncreference.PlainMarkdown, - NoInput: true, - Target: &Target{ - Variation: target, ModelConfigKey: "claude", - }, - }) - - require.NoError(t, err) - require.Equal(t, "production/configs/support/support-agent.prompt.md", path) - wrapper, err := os.ReadFile(filepath.Join(root, syncdomain.RootDir, filepath.FromSlash(path))) - require.NoError(t, err) - require.Contains(t, string(wrapper), "name: Support agent") -} - -func TestRunRejectsBlankExplicitMetadata(t *testing.T) { - tests := map[string]Target{ - "name": { - Name: " ", - }, - "content": { - Content: "\t", - }, - } - - for field, target := range tests { - t.Run(field, func(t *testing.T) { - root := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(root, "prompt.md"), nil, 0o644)) - target.Variation = syncdomain.ResourceID{ - Kind: syncdomain.KindVariation, ProjectKey: "production", LookupKey: "support/support-agent", - } - target.ModelConfigKey = "claude" - - _, err := Run(Options{ - Catalog: &fakeCatalog{}, - Store: synclocal.NewStore(root), - RepositoryRoot: root, - WorkingDirectory: root, - File: "prompt.md", - Format: syncreference.PlainMarkdown, - Target: &target, - }) - - require.ErrorContains(t, err, "--"+field+" cannot be blank") - }) - } -} - -func TestCreateLinkedPromptWritesLinkedVariationWrapper(t *testing.T) { - root := t.TempDir() - require.NoError(t, os.Mkdir(filepath.Join(root, "prompts"), 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(root, "prompts", "support-agent.md"), []byte("Be helpful.\n"), 0o644)) - - path, err := create(Options{ - Store: synclocal.NewStore(root), - RepositoryRoot: root, - WorkingDirectory: root, - File: "prompts/support-agent.md", - Format: syncreference.PlainMarkdown, - }, Selection{ - Project: syncapi.Project{Key: "production", Name: "Production"}, - Config: syncapi.Config{Key: "support", Name: "Support", Mode: syncdomain.VariationModeAgent}, - ModelConfig: syncapi.ModelConfig{ - Key: "claude", ID: "claude-3-5-sonnet-20241022", Name: "Claude", Version: 4, - Params: map[string]any{"temperature": 0.2}, CustomParams: map[string]any{"region": "us-east"}, - }, - Key: "support-agent", - Name: "Support agent", - }) - - require.NoError(t, err) - require.Equal(t, "production/configs/support/support-agent.prompt.md", path) - wrapper, err := os.ReadFile(filepath.Join(root, syncdomain.RootDir, filepath.FromSlash(path))) - require.NoError(t, err) - require.Equal(t, `--- -formatVersion: 1 -upsert: true -ref: - file: prompts/support-agent.md - format: plain-markdown -mode: agent -key: support-agent -name: Support agent -modelConfigKey: claude -modelConfigVersion: 4 -model: - custom: - region: us-east - modelName: claude-3-5-sonnet-20241022 - parameters: - temperature: 0.2 ---- -`, string(wrapper)) - - resources, err := synclocal.CompileWorkspace(root) - require.NoError(t, err) - require.Len(t, resources, 1) - require.Equal(t, "Be helpful.", resources[0].Variation.Instructions) - require.Equal(t, "claude-3-5-sonnet-20241022", resources[0].Variation.Model["modelName"]) -} - -func TestCreateLinkedPromptRejectsExistingServerVariation(t *testing.T) { - root := t.TempDir() - require.NoError(t, os.WriteFile(filepath.Join(root, "prompt.md"), []byte("Help"), 0o644)) - - _, err := create(Options{ - Store: synclocal.NewStore(root), - RepositoryRoot: root, - WorkingDirectory: root, - File: "prompt.md", - Format: syncreference.PlainMarkdown, - }, Selection{ - Project: syncapi.Project{Key: "production"}, - Config: syncapi.Config{ - Key: "support", Mode: syncdomain.VariationModeAgent, - Variations: []syncdomain.Variation{{Key: "prompt"}}, - }, - ModelConfig: syncapi.ModelConfig{Key: "claude", ID: "claude-3-5-sonnet-20241022"}, - Key: "prompt", - Name: "Prompt", - }) - - require.ErrorContains(t, err, `variation "prompt" already exists`) -} - -func TestCreateLinkedPromptValidatesDestinationBeforeUpdatingLinkedFile(t *testing.T) { - root := t.TempDir() - sourcePath := filepath.Join(root, "prompt.md") - require.NoError(t, os.WriteFile(sourcePath, nil, 0o644)) - options := Options{ - Store: synclocal.NewStore(root), - RepositoryRoot: root, - WorkingDirectory: root, - File: "prompt.md", - Format: syncreference.PlainMarkdown, - } - prompt, err := readLinkedPrompt(options) - require.NoError(t, err) - prompt, err = addMissingPromptContent(prompt, syncdomain.VariationModeAgent, "prompt", "Prompt", "Help.") - require.NoError(t, err) - - _, err = createLinkedPrompt(options, Selection{ - Project: syncapi.Project{Key: "production"}, - Config: syncapi.Config{ - Key: "support", Mode: syncdomain.VariationModeAgent, - Variations: []syncdomain.Variation{{Key: "prompt"}}, - }, - ModelConfig: syncapi.ModelConfig{Key: "claude", ID: "claude-3-5-sonnet-20241022"}, - Key: "prompt", - Name: "Prompt", - }, prompt) - - require.ErrorContains(t, err, `variation "prompt" already exists`) - content, readErr := os.ReadFile(sourcePath) - require.NoError(t, readErr) - require.Empty(t, content) -} - -func TestCreateDoesNotOverwriteLinkedFileChangedAfterRead(t *testing.T) { - root := t.TempDir() - sourcePath := filepath.Join(root, "prompt.md") - require.NoError(t, os.WriteFile(sourcePath, nil, 0o644)) - options := Options{ - Store: synclocal.NewStore(root), - RepositoryRoot: root, - WorkingDirectory: root, - File: "prompt.md", - Format: syncreference.PlainMarkdown, - } - prompt, err := readLinkedPrompt(options) - require.NoError(t, err) - prompt.content = []byte("content collected by the linker") - - require.NoError(t, os.WriteFile(sourcePath, []byte("newer content from the editor"), 0o644)) - - _, err = createLinkedPrompt(options, Selection{ - Project: syncapi.Project{Key: "production"}, - Config: syncapi.Config{Key: "support", Mode: syncdomain.VariationModeAgent}, - ModelConfig: syncapi.ModelConfig{Key: "claude", ID: "claude-3-5-sonnet-20241022"}, - Key: "prompt", - Name: "Prompt", - }, prompt) - require.ErrorContains(t, err, "changed while syncing") - - content, readErr := os.ReadFile(sourcePath) - require.NoError(t, readErr) - require.Equal(t, "newer content from the editor", string(content)) -} - -func TestPromptForSelectionSkipsTheProjectQuestionAndNamesAMissingProject(t *testing.T) { - catalog := &missingProjectCatalog{} - - _, _, _, err := promptForSelection(Options{ - Catalog: catalog, Input: bytes.NewBuffer(nil), Output: bytes.NewBuffer(nil), ProjectKey: "demo", - }, linkedPrompt{}) - - require.EqualError(t, err, `project "demo" was not found in LaunchDarkly, or the access token cannot read it`) - require.Equal(t, []string{"demo"}, catalog.configSearches) -} - -// missingProjectCatalog fails a project search, and answers a config search -// with a 404 as LaunchDarkly does for a project that does not exist. -type missingProjectCatalog struct { - fakeCatalog - configSearches []string -} - -func (*missingProjectCatalog) SearchProjects(string, int, int) (syncapi.Page[syncapi.Project], error) { - return syncapi.Page[syncapi.Project]{}, errors.New("asked for the project") -} - -func (catalog *missingProjectCatalog) SearchConfigs( - projectKey, _ string, _ []syncdomain.VariationMode, _, _ int, -) (syncapi.Page[syncapi.Config], error) { - catalog.configSearches = append(catalog.configSearches, projectKey) - return syncapi.Page[syncapi.Config]{}, errors.New(`{"code":"not_found","statusCode":404}`) -} - -type fakeCatalog struct { - config syncapi.Config - model syncapi.ModelConfig -} - -func (catalog *fakeCatalog) Config(string, string) (syncapi.Config, error) { - return catalog.config, nil -} - -func (*fakeCatalog) SearchProjects(string, int, int) (syncapi.Page[syncapi.Project], error) { - return syncapi.Page[syncapi.Project]{}, nil -} - -func (*fakeCatalog) SearchConfigs(string, string, []syncdomain.VariationMode, int, int) (syncapi.Page[syncapi.Config], error) { - return syncapi.Page[syncapi.Config]{}, nil -} - -func (catalog *fakeCatalog) ModelConfigs(string) ([]syncapi.ModelConfig, error) { - return []syncapi.ModelConfig{catalog.model}, nil -} - -func create(options Options, selection Selection) (string, error) { - prompt, err := readLinkedPrompt(options) - if err != nil { - return "", err - } - return createLinkedPrompt(options, selection, prompt) -} diff --git a/internal/sync/local/compile.go b/internal/sync/local/compile.go index 1c16f6fa6..6c92875d1 100644 --- a/internal/sync/local/compile.go +++ b/internal/sync/local/compile.go @@ -28,9 +28,7 @@ func (e ParseError) Unwrap() error { return e.Err } // Compile reads every variation in a file system that contains a managed // directory. Tests use it with an in-memory file system. func Compile(fsys fs.FS) ([]syncdomain.SyncedResource, error) { - return compile(fsys, func(reference Reference) ([]byte, error) { - return readReferenceFromFS(fsys, reference) - }) + return compile(fsys) } // CompileWorkspace reads every variation in a repository. It rejects a @@ -40,9 +38,7 @@ func CompileWorkspace(repositoryRoot string) ([]syncdomain.SyncedResource, error if err != nil { return nil, fmt.Errorf("resolve repository root: %w", err) } - return compile(workspaceFS{root: root}, func(reference Reference) ([]byte, error) { - return ReadReference(root, reference) - }) + return compile(workspaceFS{root: root}) } // Compile reads every variation in the repository of the store. @@ -72,7 +68,7 @@ func (fsys workspaceFS) Open(name string) (fs.File, error) { } // compile reads the variation files of each project, in identity order. -func compile(fsys fs.FS, readReference func(Reference) ([]byte, error)) ([]syncdomain.SyncedResource, error) { +func compile(fsys fs.FS) ([]syncdomain.SyncedResource, error) { entries, err := fs.ReadDir(fsys, syncdomain.RootDir) if errors.Is(err, fs.ErrNotExist) { return nil, ErrNoDirectory @@ -88,7 +84,7 @@ func compile(fsys fs.FS, readReference func(Reference) ([]byte, error)) ([]syncd if !entry.IsDir() { continue } - projectVariations, err := compileProject(fsys, entry.Name(), readReference) + projectVariations, err := compileProject(fsys, entry.Name()) if err != nil { return nil, err } @@ -105,7 +101,6 @@ func compile(fsys fs.FS, readReference func(Reference) ([]byte, error)) ([]syncd func compileProject( fsys fs.FS, projectKey string, - readReference func(Reference) ([]byte, error), ) ([]syncdomain.SyncedResource, error) { configsRoot := path.Join(syncdomain.RootDir, projectKey, configsDir) readAttachment := func(kind syncdomain.AttachmentKind, key string) (syncdomain.Attachment, error) { @@ -135,7 +130,6 @@ func compileProject( ProjectKey: projectKey, RelPath: strings.TrimPrefix(name, configsRoot+"/"), Data: data, - ReadReference: readReference, ReadAttachment: readAttachment, }) if err != nil { diff --git a/internal/sync/local/reference.go b/internal/sync/local/reference.go deleted file mode 100644 index 1ca8adeea..000000000 --- a/internal/sync/local/reference.go +++ /dev/null @@ -1,120 +0,0 @@ -package local - -import ( - "errors" - "fmt" - "io/fs" - "os" - "path" - "path/filepath" - "strings" - - syncdomain "github.com/launchdarkly/ldcli/internal/sync" -) - -// Reference is the "ref" field of a linked variation. It names a file in the -// repository and the format of that file. -type Reference = syncdomain.Reference - -// NewReference converts a path that the user entered to a reference. The -// path can be relative to workingDirectory. The file must be in the repository. -func NewReference(repositoryRoot, workingDirectory, file, format string) (Reference, error) { - if file == "" { - return Reference{}, errors.New("linked file path is required") - } - if !filepath.IsAbs(file) { - file = filepath.Join(workingDirectory, file) - } - // Compare resolved paths. A path that looks like it is in the repository - // can go out of it through a symbolic link. - target, err := filepath.EvalSymlinks(file) - if err != nil { - return Reference{}, fmt.Errorf("resolve linked file %q: %w", file, err) - } - root, err := filepath.EvalSymlinks(repositoryRoot) - if err != nil { - return Reference{}, fmt.Errorf("resolve repository root: %w", err) - } - if !isWithin(root, target) { - return Reference{}, errors.New("linked file must be inside the Git repository") - } - - relative, _ := filepath.Rel(root, target) - reference := Reference{File: filepath.ToSlash(relative), Format: format} - if _, err := resolveReferencePath(root, reference); err != nil { - return Reference{}, err - } - return reference, nil -} - -// validateReference makes sure that the file is a clean path in the -// repository and outside the managed directory. -func validateReference(reference Reference) error { - switch { - case reference.File == "": - return errors.New("ref.file is required") - case reference.Format == "": - return errors.New("ref.format is required") - case !fs.ValidPath(reference.File): - return fmt.Errorf("ref.file %q must be a repository-relative path", reference.File) - case reference.File == syncdomain.RootDir || strings.HasPrefix(reference.File, syncdomain.RootDir+"/"): - return fmt.Errorf("ref.file %q must be outside %s", reference.File, syncdomain.RootDir) - case path.Clean(reference.File) != reference.File: - return fmt.Errorf("ref.file %q must be a clean repository-relative path", reference.File) - default: - return nil - } -} - -// resolveReferencePath returns the absolute path of a linked file. It -// resolves symbolic links on each call, because a link can change after the -// variation was linked. -func resolveReferencePath(repositoryRoot string, reference Reference) (string, error) { - if err := validateReference(reference); err != nil { - return "", err - } - root, err := filepath.EvalSymlinks(repositoryRoot) - if err != nil { - return "", fmt.Errorf("resolve repository root: %w", err) - } - target, err := filepath.EvalSymlinks(filepath.Join(root, filepath.FromSlash(reference.File))) - if err != nil { - return "", fmt.Errorf("resolve referenced file %q: %w", reference.File, err) - } - if !isWithin(root, target) { - return "", fmt.Errorf("referenced file %q resolves outside the Git repository", reference.File) - } - info, err := os.Stat(target) - if err != nil { - return "", fmt.Errorf("inspect referenced file %q: %w", reference.File, err) - } - if !info.Mode().IsRegular() { - return "", fmt.Errorf("referenced file %q is not a regular file", reference.File) - } - return target, nil -} - -func readReferenceFromFS(fsys fs.FS, reference Reference) ([]byte, error) { - if err := validateReference(reference); err != nil { - return nil, err - } - data, err := fs.ReadFile(fsys, reference.File) - if err != nil { - return nil, fmt.Errorf("read referenced file %q: %w", reference.File, err) - } - return data, nil -} - -// ReadReference reads a linked file. It resolves symbolic links and makes sure -// that the file is a regular file in the repository. -func ReadReference(repositoryRoot string, reference Reference) ([]byte, error) { - target, err := resolveReferencePath(repositoryRoot, reference) - if err != nil { - return nil, err - } - data, err := os.ReadFile(target) - if err != nil { - return nil, fmt.Errorf("read referenced file %q: %w", reference.File, err) - } - return data, nil -} diff --git a/internal/sync/local/reference_test.go b/internal/sync/local/reference_test.go deleted file mode 100644 index a1cbc3fc7..000000000 --- a/internal/sync/local/reference_test.go +++ /dev/null @@ -1,185 +0,0 @@ -package local - -import ( - "os" - "path/filepath" - "testing" - - "github.com/stretchr/testify/require" - - syncdomain "github.com/launchdarkly/ldcli/internal/sync" - syncreference "github.com/launchdarkly/ldcli/internal/sync/reference" -) - -func TestNewReferenceStoresRepositoryRelativePath(t *testing.T) { - root := t.TempDir() - workingDirectory := filepath.Join(root, "app") - require.NoError(t, os.MkdirAll(filepath.Join(workingDirectory, "prompts"), 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(workingDirectory, "prompts", "support.md"), []byte("Help"), 0o644)) - - reference, err := NewReference(root, workingDirectory, "prompts/support.md", syncreference.PlainMarkdown) - - require.NoError(t, err) - require.Equal(t, Reference{File: "app/prompts/support.md", Format: syncreference.PlainMarkdown}, reference) -} - -func TestNewReferenceRejectsUnsafePaths(t *testing.T) { - root := t.TempDir() - outside := filepath.Join(t.TempDir(), "prompt.md") - require.NoError(t, os.WriteFile(outside, []byte("Help"), 0o644)) - - _, err := NewReference(root, root, outside, syncreference.PlainMarkdown) - require.ErrorContains(t, err, "inside the Git repository") - - managed := filepath.Join(root, syncdomain.RootDir, "prompt.md") - require.NoError(t, os.MkdirAll(filepath.Dir(managed), 0o755)) - require.NoError(t, os.WriteFile(managed, []byte("Help"), 0o644)) - _, err = NewReference(root, root, managed, syncreference.PlainMarkdown) - require.ErrorContains(t, err, "must be outside") -} - -func TestNewReferenceRejectsSymlinkEscape(t *testing.T) { - root := t.TempDir() - outside := filepath.Join(t.TempDir(), "prompt.md") - require.NoError(t, os.WriteFile(outside, []byte("Help"), 0o644)) - link := filepath.Join(root, "prompt.md") - require.NoError(t, os.Symlink(outside, link)) - - _, err := NewReference(root, root, link, syncreference.PlainMarkdown) - - require.ErrorContains(t, err, "inside the Git repository") -} - -func TestCompileWorkspaceReadsLinkedPrompt(t *testing.T) { - root := t.TempDir() - require.NoError(t, os.MkdirAll(filepath.Join(root, "prompts"), 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(root, "prompts", "support.md"), []byte("Be helpful.\n"), 0o644)) - _, err := NewStore(root).Add([]VariationFile{{ - ProjectKey: "project", - ConfigKey: "support", - Upsert: true, - Ref: &Reference{File: "prompts/support.md", Format: syncreference.PlainMarkdown}, - Variation: syncdomain.Variation{ - Mode: syncdomain.VariationModeAgent, - Key: "default", - Name: "Default", - }, - }}) - require.NoError(t, err) - - resources, err := CompileWorkspace(root) - - require.NoError(t, err) - require.Len(t, resources, 1) - variation := resources[0].Variation - require.Equal(t, "Be helpful.", variation.Instructions) -} - -func TestReplaceVariationsUpdatesLinkedWrapperAndPrompt(t *testing.T) { - root := t.TempDir() - referencePath := filepath.Join(root, "prompts", "support.md") - require.NoError(t, os.MkdirAll(filepath.Dir(referencePath), 0o755)) - require.NoError(t, os.WriteFile(referencePath, []byte("Old prompt\n"), 0o640)) - store := NewStore(root) - reference := &Reference{File: "prompts/support.md", Format: syncreference.PlainMarkdown} - _, err := store.Add([]VariationFile{{ - ProjectKey: "project", ConfigKey: "support", Upsert: true, Ref: reference, - Variation: syncdomain.Variation{ - Mode: syncdomain.VariationModeAgent, Key: "default", Name: "Old", Instructions: "Old prompt", - }, - }}) - require.NoError(t, err) - - serverVariation := syncdomain.Variation{ - Mode: syncdomain.VariationModeAgent, Key: "default", Name: "New", - Instructions: "First line.\r\nSecond line.\rThird line.", - } - paths, err := store.ReplaceVariations([]VariationReplacement{{ - ProjectKey: "project", - ConfigKey: "support", - Variation: serverVariation, - }}) - - require.NoError(t, err) - require.Equal(t, []string{"project/configs/support/default.prompt.md"}, paths) - content, err := os.ReadFile(referencePath) - require.NoError(t, err) - require.Equal(t, "First line.\nSecond line.\nThird line.\n", string(content)) - info, err := os.Stat(referencePath) - require.NoError(t, err) - require.Equal(t, os.FileMode(0o640), info.Mode().Perm()) - wrapper, err := os.ReadFile(filepath.Join(root, syncdomain.RootDir, paths[0])) - require.NoError(t, err) - require.Contains(t, string(wrapper), "name: New") - require.Contains(t, string(wrapper), "file: prompts/support.md") - - compiled, err := CompileWorkspace(root) - require.NoError(t, err) - resource := requireVariationResource(t, compiled, "support/default") - localVariation := resource.Variation - serverFingerprint, err := syncdomain.FingerprintVariation("project", "support/default", serverVariation) - require.NoError(t, err) - localFingerprint, err := syncdomain.FingerprintVariation("project", "support/default", localVariation) - require.NoError(t, err) - require.Equal(t, serverFingerprint, localFingerprint) -} - -func TestReplaceVariationsDoesNotChangeLinkedFilesWhenServerPromptIsNotRepresentable(t *testing.T) { - root := t.TempDir() - referencePath := filepath.Join(root, "prompt.md") - require.NoError(t, os.WriteFile(referencePath, []byte("Old prompt\n"), 0o644)) - store := NewStore(root) - _, err := store.Add([]VariationFile{{ - ProjectKey: "project", ConfigKey: "support", - Ref: &Reference{File: "prompt.md", Format: syncreference.PlainMarkdown}, - Variation: syncdomain.Variation{ - Mode: syncdomain.VariationModeCompletion, Key: "default", Name: "Old", - Messages: []syncdomain.Message{{Role: "system", Content: "Old prompt"}}, - }, - }}) - require.NoError(t, err) - wrapperPath := filepath.Join(root, syncdomain.RootDir, "project", configsDir, "support", "default"+variationFileSuffix) - originalWrapper, err := os.ReadFile(wrapperPath) - require.NoError(t, err) - - _, err = store.ReplaceVariations([]VariationReplacement{{ - ProjectKey: "project", ConfigKey: "support", - Variation: syncdomain.Variation{ - Mode: syncdomain.VariationModeCompletion, Key: "default", Name: "New", - Messages: []syncdomain.Message{ - {Role: "system", Content: "System"}, - {Role: "user", Content: "User"}, - }, - }, - }}) - - require.ErrorContains(t, err, "at most one system message") - content, readErr := os.ReadFile(referencePath) - require.NoError(t, readErr) - require.Equal(t, "Old prompt\n", string(content)) - wrapper, readErr := os.ReadFile(wrapperPath) - require.NoError(t, readErr) - require.Equal(t, originalWrapper, wrapper) -} - -func TestSourceFilesIncludesWrappersAndReferences(t *testing.T) { - root := t.TempDir() - require.NoError(t, os.Mkdir(filepath.Join(root, "prompts"), 0o755)) - require.NoError(t, os.WriteFile(filepath.Join(root, "prompts", "support.md"), []byte("Help"), 0o644)) - _, err := NewStore(root).Add([]VariationFile{{ - ProjectKey: "project", ConfigKey: "support", - Ref: &Reference{File: "prompts/support.md", Format: syncreference.PlainMarkdown}, - Variation: syncdomain.Variation{ - Mode: syncdomain.VariationModeAgent, Key: "default", Name: "Default", - }, - }}) - require.NoError(t, err) - - files, err := SourceFiles(root) - - require.NoError(t, err) - require.Equal(t, []string{ - ".launchdarkly/project/configs/support/default.prompt.md", - "prompts/support.md", - }, files) -} diff --git a/internal/sync/local/replace.go b/internal/sync/local/replace.go index 1556d1b8e..6d3146c14 100644 --- a/internal/sync/local/replace.go +++ b/internal/sync/local/replace.go @@ -8,7 +8,6 @@ import ( "path/filepath" syncdomain "github.com/launchdarkly/ldcli/internal/sync" - syncreference "github.com/launchdarkly/ldcli/internal/sync/reference" ) // stagedFile is one file in a replace transaction. The original content lets @@ -25,9 +24,8 @@ type stagedFile struct { } // ReplaceVariations replaces a batch of variation files in one transaction. -// The transaction also writes each linked file and each tool and skill file -// that the variations use. ReplaceVariations returns the replaced variation -// paths. +// The transaction also writes each tool and skill file that the variations +// use. ReplaceVariations returns the replaced variation paths. // // The transaction has three steps. First, it writes every new file to a // temporary path. Then it makes sure that no original changed during the @@ -138,12 +136,11 @@ func (store Store) prepareReplacements(replacements []VariationReplacement) ([]s if err != nil { return nil, err } - // Upsert and ref describe the local file, not LaunchDarkly. Keep them. + // Upsert describes the local file, not LaunchDarkly. Keep it. content, err := renderVariationFile(VariationFile{ ProjectKey: replacement.ProjectKey, ConfigKey: replacement.ConfigKey, Upsert: existing.frontMatter.Upsert, - Ref: existing.frontMatter.Ref, Variation: replacement.Variation, }) if err != nil { @@ -157,15 +154,6 @@ func (store Store) prepareReplacements(replacements []VariationReplacement) ([]s return nil, err } - if ref := existing.frontMatter.Ref; ref != nil { - linked, err := store.prepareLinkedFile(*ref, replacement) - if err != nil { - return nil, err - } - if err := add(linked); err != nil { - return nil, err - } - } attachmentSources = append(attachmentSources, VariationFile{ ProjectKey: replacement.ProjectKey, Variation: replacement.Variation, }) @@ -188,8 +176,7 @@ func (store Store) prepareReplacements(replacements []VariationReplacement) ([]s } // missingVariation describes a variation file that does not exist yet. A new -// file has upsert, so that a later sync can create the variation again, and -// it keeps the link of the variation that it restores. +// file has upsert, so that a later sync can create the variation again. func missingVariation(replacement VariationReplacement) (existingVariation, error) { relativePath, err := variationPath(replacement.ProjectKey, replacement.ConfigKey, replacement.Variation.Key) if err != nil { @@ -198,32 +185,7 @@ func missingVariation(replacement VariationReplacement) (existingVariation, erro return existingVariation{ relativePath: relativePath, mode: 0o644, - frontMatter: variationFrontMatter{Upsert: true, Ref: replacement.Ref}, - }, nil -} - -// prepareLinkedFile renders the new content of the external file that a -// linked variation uses. -func (store Store) prepareLinkedFile(ref Reference, replacement VariationReplacement) (stagedFile, error) { - path, err := resolveReferencePath(store.repositoryRoot, ref) - if err != nil { - return stagedFile{}, err - } - content, err := syncreference.Render(ref.Format, replacement.Variation) - if err != nil { - return stagedFile{}, err - } - info, err := os.Stat(path) - if err != nil { - return stagedFile{}, fmt.Errorf("inspect referenced file %q: %w", ref.File, err) - } - original, err := os.ReadFile(path) - if err != nil { - return stagedFile{}, fmt.Errorf("read referenced file %q: %w", ref.File, err) - } - return stagedFile{ - displayPath: ref.File, path: path, original: original, originalExists: true, - replacement: content, mode: info.Mode().Perm(), + frontMatter: variationFrontMatter{Upsert: true}, }, nil } @@ -299,8 +261,12 @@ func verifyOriginals(files []stagedFile) error { // commitFiles renames each staged file into place. Each rename is atomic, but // the batch is not, so a failure restores the files that commitFiles replaced. func commitFiles(files []stagedFile) error { + return commitFilesWithRename(files, os.Rename) +} + +func commitFilesWithRename(files []stagedFile, rename func(string, string) error) error { for index, file := range files { - if err := os.Rename(file.stagedPath, file.path); err != nil { + if err := rename(file.stagedPath, file.path); err != nil { return errors.Join(fmt.Errorf("replace variation %s: %w", file.displayPath, err), restoreFiles(files[:index])) } } diff --git a/internal/sync/local/sources.go b/internal/sync/local/sources.go index ac58271b0..4bf3716e6 100644 --- a/internal/sync/local/sources.go +++ b/internal/sync/local/sources.go @@ -6,15 +6,14 @@ import ( "os" "path/filepath" "slices" - "strings" syncdomain "github.com/launchdarkly/ldcli/internal/sync" ) // SourceFiles returns every file that can change the sync plan, relative to // the repository root. The list has each file in a project directory and -// each file that a variation links to. A file that is not valid stays in the -// list, so that a fix to the file starts a new sync in watch mode. +// each managed file. A file that is not valid stays in the list, so that a +// fix to the file starts a new sync in watch mode. func SourceFiles(repositoryRoot string) ([]string, error) { managedRoot := filepath.Join(repositoryRoot, syncdomain.RootDir) var files []string @@ -36,11 +35,6 @@ func SourceFiles(repositoryRoot string) ([]string, error) { if filepath.Dir(filePath) != managedRoot { files = append(files, filepath.ToSlash(relative)) } - if strings.HasSuffix(entry.Name(), variationFileSuffix) { - if reference, ok := linkedFile(filePath); ok { - files = append(files, reference) - } - } return nil }) if errors.Is(err, os.ErrNotExist) { @@ -53,20 +47,3 @@ func SourceFiles(repositoryRoot string) ([]string, error) { slices.Sort(files) return slices.Compact(files), nil } - -// linkedFile returns the file that a variation file links to. It ignores a -// variation file that it cannot parse, because compile reports that error. -func linkedFile(file string) (string, bool) { - content, err := os.ReadFile(file) - if err != nil { - return "", false - } - var metadata variationFrontMatter - if _, err := parseYAMLFrontMatter(content, &metadata); err != nil { - return "", false - } - if metadata.Ref == nil || validateReference(*metadata.Ref) != nil { - return "", false - } - return metadata.Ref.File, true -} diff --git a/internal/sync/local/store.go b/internal/sync/local/store.go index 1a63687cc..03a2c54cb 100644 --- a/internal/sync/local/store.go +++ b/internal/sync/local/store.go @@ -22,18 +22,14 @@ type VariationFile struct { ProjectKey string ConfigKey string Upsert bool - Ref *Reference Variation syncdomain.Variation } -// VariationReplacement is new content for a variation file. If -// CreateIfMissing is true and the file does not exist, the replace creates it -// with Ref as its link. An existing file keeps its own link. +// VariationReplacement is new content for a variation file. type VariationReplacement struct { ProjectKey string ConfigKey string CreateIfMissing bool - Ref *Reference Variation syncdomain.Variation } diff --git a/internal/sync/local/variation.go b/internal/sync/local/variation.go index 489b362a2..eff798f1c 100644 --- a/internal/sync/local/variation.go +++ b/internal/sync/local/variation.go @@ -12,7 +12,6 @@ import ( "gopkg.in/yaml.v3" syncdomain "github.com/launchdarkly/ldcli/internal/sync" - syncreference "github.com/launchdarkly/ldcli/internal/sync/reference" ) // A variation file has YAML front matter and a prompt body: @@ -29,29 +28,24 @@ import ( // // // An agent body is the instructions. A completion body is a list of role -// blocks. A completion body without role blocks is one system message. A -// linked variation has a "ref" field and no body. +// blocks. A completion body without role blocks is one system message. const variationFormatVersion = 1 type variationFrontMatter struct { - FormatVersion int `yaml:"formatVersion"` - Upsert bool `yaml:"upsert"` - Ref *Reference `yaml:"ref,omitempty"` + FormatVersion int `yaml:"formatVersion"` + Upsert bool `yaml:"upsert"` syncdomain.Variation `yaml:",inline"` } -// localFile is one variation file and the readers for the files that it references. +// localFile is one variation file and its attachment reader. type localFile struct { ProjectKey string RelPath string Data []byte - ReadReference func(Reference) ([]byte, error) ReadAttachment func(syncdomain.AttachmentKind, string) (syncdomain.Attachment, error) } -// parseVariation reads one variation file, including the content of a linked -// file and every attachment that it references. RelPath is relative to the -// configs directory, for example "support/default.prompt.md". +// parseVariation reads one variation file and every attachment it references. func parseVariation(file localFile) (syncdomain.SyncedResource, error) { meta, body, err := parseVariationFrontMatter(file.RelPath, file.Data) if err != nil { @@ -59,23 +53,7 @@ func parseVariation(file localFile) (syncdomain.SyncedResource, error) { } variation := meta.Variation - if meta.Ref != nil { - // The variation file owns the identity and the model settings. The - // linked file owns only the prompt content. - if strings.TrimSpace(string(body)) != "" { - return syncdomain.SyncedResource{}, errors.New("referenced variation cannot also contain an inline prompt body") - } - content, err := file.ReadReference(*meta.Ref) - if err != nil { - return syncdomain.SyncedResource{}, err - } - if err := syncreference.ApplyToVariation(meta.Ref.Format, content, &variation); err != nil { - return syncdomain.SyncedResource{}, err - } - if stem := variationStem(file.RelPath); variation.Key != stem { - return syncdomain.SyncedResource{}, fmt.Errorf("referenced prompt key %q does not match filename %q", variation.Key, stem) - } - } else if err := ParsePromptBody(string(body), &variation); err != nil { + if err := ParsePromptBody(string(body), &variation); err != nil { return syncdomain.SyncedResource{}, err } @@ -89,7 +67,6 @@ func parseVariation(file localFile) (syncdomain.SyncedResource, error) { ProjectKey: file.ProjectKey, LookupKey: configKey + "/" + variation.Key, Upsert: meta.Upsert, - Ref: meta.Ref, Variation: variation, }, nil } @@ -149,16 +126,9 @@ func renderVariationFile(file VariationFile) ([]byte, error) { case variation.Mode == syncdomain.VariationModeCompletion && variation.Instructions != "": return nil, fmt.Errorf("completion variation %q cannot contain instructions", variation.Key) } - if file.Ref != nil { - if err := validateReference(*file.Ref); err != nil { - return nil, err - } - } - frontMatter, err := marshalYAML(variationFrontMatter{ FormatVersion: variationFormatVersion, Upsert: file.Upsert, - Ref: file.Ref, Variation: variation, }) if err != nil { @@ -170,9 +140,7 @@ func renderVariationFile(file VariationFile) ([]byte, error) { content.Write(frontMatter) content.WriteString("---\n") - // A linked variation has no body, so that only one file holds the prompt. switch { - case file.Ref != nil: case variation.Mode == syncdomain.VariationModeAgent: if instructions := syncdomain.NormalizePromptText(variation.Instructions); instructions != "" { fmt.Fprintf(&content, "\n%s\n", instructions) diff --git a/internal/sync/manifest/baseline_test.go b/internal/sync/manifest/baseline_test.go index 84afcc53c..cad70b956 100644 --- a/internal/sync/manifest/baseline_test.go +++ b/internal/sync/manifest/baseline_test.go @@ -41,7 +41,7 @@ resources: func TestDecodeLockRejectsInvalidContent(t *testing.T) { for name, content := range map[string]string{ "unknown field": "formatVersion: 1\nresources: []\nextra: true\n", - "unknown version": "formatVersion: 2\nresources: []\n", + "unknown version": "formatVersion: 3\nresources: []\n", "bad fingerprint": "formatVersion: 1\nresources:\n - kind: tool\n project: p\n key: k\n fingerprint: x\n", } { _, err := decodeLock([]byte(content)) @@ -49,6 +49,34 @@ func TestDecodeLockRejectsInvalidContent(t *testing.T) { } } +func TestLockFormatOneContainsSourceMetadata(t *testing.T) { + content, err := encodeLock(Manifest{Resources: []Resource{{ + ResourceKind: syncdomain.KindSkill, + ProjectKey: "production", + LookupKey: "refunds", + Fingerprint: fingerprint("a"), + FingerprintSchema: syncdomain.ProjectionFingerprintSchema, + ReferenceDigest: fingerprint("b"), + AdapterBehaviorVersion: syncdomain.AdapterBehaviorVersion, + AdapterBehaviorDigest: syncdomain.AdapterBehaviorDigest, + IndexedSelections: []syncdomain.IndexedSelection{{ + Path: "select#0", CollectionLength: 2, Fingerprint: fingerprint("c"), + }}, + }}}) + + require.NoError(t, err) + assert.Contains(t, string(content), "formatVersion: 1") + assert.Contains(t, string(content), "fingerprintSchema: launchdarkly.config.projection/1") + assert.Contains(t, string(content), "indexedSelections:") + assert.Contains(t, string(content), "collectionLength: 2") + assert.NotContains(t, string(content), "file:") + assert.NotContains(t, string(content), "direction:") + decoded, err := decodeLock(content) + require.NoError(t, err) + require.Len(t, decoded.Resources[0].IndexedSelections, 1) + assert.Equal(t, 2, decoded.Resources[0].IndexedSelections[0].CollectionLength) +} + // Other branches add entries to the shared remote manifest. A working copy // without a sync.lock file must not track them. func TestBaselineStoreWithoutLockFileTracksNothing(t *testing.T) { @@ -97,7 +125,7 @@ func TestBaselineStaleComparesFingerprints(t *testing.T) { "newer version with a different fingerprint": {remote: remoteManifest(fingerprint("b"), 5), stale: true}, "newer version with the same fingerprint": {remote: remoteManifest(fingerprint("a"), 5)}, "same version with a different fingerprint": {remote: remoteManifest(fingerprint("b"), 4), stale: true}, - "remote entry removed": {remote: syncapi.SyncManifest{Source: testSource}}, + "remote entry removed": {remote: syncapi.SyncManifest{FormatVersion: syncapi.SyncManifestFormatVersion, Source: testSource}}, } for name, test := range tests { @@ -117,7 +145,8 @@ func TestBaselineStoreSaveSendsRemoteVersionsAndWritesLockWithoutThem(t *testing client := &manifestClient{ manifests: map[string]syncapi.SyncManifest{"production": remoteManifest(fingerprint("b"), 5)}, patchResponses: []syncapi.SyncManifest{{ - Source: testSource, + FormatVersion: syncapi.SyncManifestFormatVersion, + Source: testSource, Items: []syncapi.SyncManifestResource{ {ResourceKind: syncdomain.KindVariation, ResourceLookupKey: "support/default", Fingerprint: fingerprint("c"), Version: 6}, {ResourceKind: syncdomain.KindTool, ResourceLookupKey: "search", Fingerprint: fingerprint("d"), Version: 1}, @@ -181,8 +210,11 @@ func TestBaselineStoreSaveDoesNotWriteLockWhenRemoteSaveFails(t *testing.T) { func TestBaselineStoreSaveRemovesEmptyLock(t *testing.T) { client := &manifestClient{ - manifests: map[string]syncapi.SyncManifest{"production": remoteManifest(fingerprint("a"), 4)}, - patchResponses: []syncapi.SyncManifest{{Source: testSource}}, + manifests: map[string]syncapi.SyncManifest{"production": remoteManifest(fingerprint("a"), 4)}, + patchResponses: []syncapi.SyncManifest{{ + FormatVersion: syncapi.SyncManifestFormatVersion, + Source: testSource, + }}, } lock := lockWith(t, fingerprint("a"), 4) store := NewBaselineStore(NewStore(client, testSource), lock) @@ -246,7 +278,7 @@ func variationID() syncdomain.ResourceID { } func remoteManifest(fingerprint string, version int) syncapi.SyncManifest { - return syncapi.SyncManifest{Source: testSource, Items: []syncapi.SyncManifestResource{{ + return syncapi.SyncManifest{FormatVersion: syncapi.SyncManifestFormatVersion, Source: testSource, Items: []syncapi.SyncManifestResource{{ ResourceKind: syncdomain.KindVariation, ResourceLookupKey: "support/default", Fingerprint: fingerprint, Version: version, }}} } @@ -276,25 +308,3 @@ func (lock *memoryLock) WriteLock(content []byte) error { lock.content = content return nil } - -func TestLockKeepsTheLinkOfAVariation(t *testing.T) { - manifest := Manifest{Resources: []Resource{{ - ResourceKind: syncdomain.KindVariation, ProjectKey: "production", LookupKey: "support/default", - Fingerprint: fingerprint("a"), Version: 1, - }}} - ref := &syncdomain.Reference{File: "prompts/support.md", Format: "plain-markdown"} - manifest.SetRefs([]syncdomain.SyncedResource{{ - Kind: syncdomain.KindVariation, ProjectKey: "production", LookupKey: "support/default", Ref: ref, - }}) - - content, err := encodeLock(manifest) - require.NoError(t, err) - assert.Contains(t, string(content), " ref:\n file: prompts/support.md\n format: plain-markdown\n") - decoded, err := decodeLock(content) - require.NoError(t, err) - assert.Equal(t, ref, decoded.Ref(variationID())) - - // A variation without a local file keeps its recorded link. - manifest.SetRefs(nil) - assert.Equal(t, ref, manifest.Ref(variationID())) -} diff --git a/internal/sync/manifest/lock.go b/internal/sync/manifest/lock.go index ac90a4430..44c0b332f 100644 --- a/internal/sync/manifest/lock.go +++ b/internal/sync/manifest/lock.go @@ -12,7 +12,7 @@ import ( // The sync.lock file stores the baseline of one working copy. Engineers commit // it with the .launchdarkly files, so that the baseline moves with the files. const ( - lockFormatVersion = 1 + lockFormatVersion = FormatVersion lockHeader = "# Written by ldcli sync. Commit this file with the .launchdarkly files.\n" ) diff --git a/internal/sync/manifest/model.go b/internal/sync/manifest/model.go index d3c64345d..aa1909b8d 100644 --- a/internal/sync/manifest/model.go +++ b/internal/sync/manifest/model.go @@ -14,6 +14,9 @@ import ( var fingerprintPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) +// FormatVersion is the local sync manifest and sync.lock schema. +const FormatVersion = 1 + // Manifest is the baseline of every tracked resource. type Manifest struct { Resources []Resource @@ -26,13 +29,19 @@ type Resource struct { ProjectKey string `yaml:"project"` LookupKey string `yaml:"key"` Fingerprint string `yaml:"fingerprint"` + // FingerprintSchema identifies the meaning of a descriptor-backed fingerprint. + FingerprintSchema string `yaml:"fingerprintSchema,omitempty"` + // ReferenceDigest detects a descriptor mapping change. + ReferenceDigest string `yaml:"referenceDigest,omitempty"` + // AdapterBehaviorVersion and AdapterBehaviorDigest detect adapter changes. + AdapterBehaviorVersion int `yaml:"adapterBehaviorVersion,omitempty"` + AdapterBehaviorDigest string `yaml:"adapterBehaviorDigest,omitempty"` + // IndexedSelections detect a changed item at an indexed source path. + IndexedSelections []syncdomain.IndexedSelection `yaml:"indexedSelections,omitempty"` // Version is the version of the remote manifest entry, which LaunchDarkly // uses for optimistic locking. Only the remote manifest has it. The lock // does not store it, so that a lock entry changes only with its content. Version int `yaml:"-"` - // Ref is the linked prompt file of a variation. Only sync.lock stores it, - // so that sync can restore a missing variation file with its link. - Ref *syncdomain.Reference `yaml:"ref,omitempty"` } // ID returns the identity of the resource. @@ -64,29 +73,54 @@ func (manifest *Manifest) SetFingerprint(id syncdomain.ResourceID, fingerprint s }) } -// Remove stops tracking one resource. -func (manifest *Manifest) Remove(id syncdomain.ResourceID) { - if index := manifest.index(id); index >= 0 { - manifest.Resources = slices.Delete(manifest.Resources, index, index+1) +// SetSourceMetadata records generated metadata for a descriptor-backed resource. +func (manifest *Manifest) SetSourceMetadata(id syncdomain.ResourceID, reference *syncdomain.Reference) error { + index := manifest.index(id) + if index < 0 { + return nil } + resource := &manifest.Resources[index] + if reference == nil { + resource.FingerprintSchema = "" + resource.ReferenceDigest = "" + resource.AdapterBehaviorVersion = 0 + resource.AdapterBehaviorDigest = "" + resource.IndexedSelections = nil + return nil + } + digest, err := syncdomain.ReferenceDigest(*reference) + if err != nil { + return err + } + resource.FingerprintSchema = syncdomain.ProjectionFingerprintSchema + resource.ReferenceDigest = digest + resource.AdapterBehaviorVersion = syncdomain.AdapterBehaviorVersion + resource.AdapterBehaviorDigest = syncdomain.AdapterBehaviorDigest + resource.IndexedSelections = nil + return nil } -// SetRefs records the linked prompt file of each variation. A variation -// without a local file keeps its recorded link. -func (manifest *Manifest) SetRefs(variations []syncdomain.SyncedResource) { - for _, variation := range variations { - if index := manifest.index(variation.ID()); index >= 0 { - manifest.Resources[index].Ref = variation.Ref - } +// SetIndexedSelections records the indexed source items for one resource. +func (manifest *Manifest) SetIndexedSelections( + id syncdomain.ResourceID, + selections []syncdomain.IndexedSelection, +) { + index := manifest.index(id) + if index < 0 { + return } + selections = slices.Clone(selections) + slices.SortFunc(selections, func(left, right syncdomain.IndexedSelection) int { + return strings.Compare(left.Path, right.Path) + }) + manifest.Resources[index].IndexedSelections = selections } -// Ref returns the recorded linked prompt file of a resource, or nil. -func (manifest Manifest) Ref(id syncdomain.ResourceID) *syncdomain.Reference { +// Remove stops tracking one resource. +func (manifest *Manifest) Remove(id syncdomain.ResourceID) { if index := manifest.index(id); index >= 0 { - return manifest.Resources[index].Ref + manifest.Resources = slices.Delete(manifest.Resources, index, index+1) } - return nil } // SetAttachments records the state of each tool and skill. Many variations @@ -178,9 +212,36 @@ func (manifest Manifest) Validate() error { switch { case !fingerprintPattern.MatchString(resource.Fingerprint): return fmt.Errorf("invalid fingerprint for %s", id) + case resource.FingerprintSchema != "" && resource.FingerprintSchema != syncdomain.ProjectionFingerprintSchema: + return fmt.Errorf("unsupported fingerprint schema %q for %s", resource.FingerprintSchema, id) + case resource.FingerprintSchema != "" && + (resource.ReferenceDigest == "" || resource.AdapterBehaviorVersion == 0 || resource.AdapterBehaviorDigest == ""): + return fmt.Errorf("incomplete source metadata for %s", id) + case resource.FingerprintSchema == "" && + (resource.ReferenceDigest != "" || resource.AdapterBehaviorVersion != 0 || + resource.AdapterBehaviorDigest != "" || len(resource.IndexedSelections) != 0): + return fmt.Errorf("source metadata without a fingerprint schema for %s", id) + case resource.ReferenceDigest != "" && !fingerprintPattern.MatchString(resource.ReferenceDigest): + return fmt.Errorf("invalid reference digest for %s", id) + case resource.AdapterBehaviorDigest != "" && !fingerprintPattern.MatchString(resource.AdapterBehaviorDigest): + return fmt.Errorf("invalid adapter behavior digest for %s", id) + case resource.AdapterBehaviorVersion < 0: + return fmt.Errorf("invalid adapter behavior version for %s", id) case resource.Version < 0: return fmt.Errorf("invalid version for %s", id) } + for index, selection := range resource.IndexedSelections { + switch { + case selection.Path == "": + return fmt.Errorf("indexed selection path is empty for %s", id) + case selection.CollectionLength <= 0: + return fmt.Errorf("invalid indexed collection length for %s", id) + case !fingerprintPattern.MatchString(selection.Fingerprint): + return fmt.Errorf("invalid indexed selection fingerprint for %s", id) + case index > 0 && resource.IndexedSelections[index-1].Path >= selection.Path: + return fmt.Errorf("indexed selections are not in path order for %s", id) + } + } if _, duplicate := seen[id]; duplicate { return fmt.Errorf("duplicate manifest resource %s", id) } diff --git a/internal/sync/manifest/store.go b/internal/sync/manifest/store.go index 66c8d9f66..c2a6cec19 100644 --- a/internal/sync/manifest/store.go +++ b/internal/sync/manifest/store.go @@ -133,6 +133,13 @@ func (store Store) patch( } func (store Store) resourcesFromRemote(projectKey string, remote syncapi.SyncManifest) ([]Resource, error) { + if remote.FormatVersion != syncapi.SyncManifestFormatVersion { + return nil, fmt.Errorf( + "sync manifest for project %q has unsupported formatVersion %d", + projectKey, + remote.FormatVersion, + ) + } if remote.Source != "" && remote.Source != store.source { return nil, fmt.Errorf( "sync manifest for project %q returned source %q instead of %q", projectKey, remote.Source, store.source, @@ -147,11 +154,15 @@ func (store Store) resourcesFromRemote(projectKey string, remote syncapi.SyncMan ) } resources = append(resources, Resource{ - ResourceKind: item.ResourceKind, - ProjectKey: projectKey, - LookupKey: item.ResourceLookupKey, - Fingerprint: item.Fingerprint, - Version: item.Version, + ResourceKind: item.ResourceKind, + ProjectKey: projectKey, + LookupKey: item.ResourceLookupKey, + Fingerprint: item.Fingerprint, + Version: item.Version, + FingerprintSchema: item.FingerprintSchema, + ReferenceDigest: item.ReferenceDigest, + AdapterBehaviorVersion: item.AdapterBehaviorVersion, + AdapterBehaviorDigest: item.AdapterBehaviorDigest, }) } return resources, nil @@ -169,14 +180,18 @@ func changes(before, after []Resource) ([]syncapi.SyncManifestUpsert, []syncapi. for _, resource := range after { current, exists := remaining[resource.ID()] delete(remaining, resource.ID()) - if exists && current.Fingerprint == resource.Fingerprint { + if exists && sameManifestContent(current, resource) { continue } upserts = append(upserts, syncapi.SyncManifestUpsert{ - ResourceKind: resource.ResourceKind, - ResourceLookupKey: resource.LookupKey, - Fingerprint: resource.Fingerprint, - Version: current.Version, + ResourceKind: resource.ResourceKind, + ResourceLookupKey: resource.LookupKey, + Fingerprint: resource.Fingerprint, + Version: current.Version, + FingerprintSchema: resource.FingerprintSchema, + ReferenceDigest: resource.ReferenceDigest, + AdapterBehaviorVersion: resource.AdapterBehaviorVersion, + AdapterBehaviorDigest: resource.AdapterBehaviorDigest, }) } @@ -193,6 +208,14 @@ func changes(before, after []Resource) ([]syncapi.SyncManifestUpsert, []syncapi. return upserts, deletions } +func sameManifestContent(left, right Resource) bool { + return left.Fingerprint == right.Fingerprint && + left.FingerprintSchema == right.FingerprintSchema && + left.ReferenceDigest == right.ReferenceDigest && + left.AdapterBehaviorVersion == right.AdapterBehaviorVersion && + left.AdapterBehaviorDigest == right.AdapterBehaviorDigest +} + // changesApplied reports whether the remote manifest contains every change. func changesApplied( remote syncapi.SyncManifest, diff --git a/internal/sync/manifest/store_test.go b/internal/sync/manifest/store_test.go index bc19553a7..a19af8cfe 100644 --- a/internal/sync/manifest/store_test.go +++ b/internal/sync/manifest/store_test.go @@ -52,14 +52,16 @@ func (client *manifestClient) PatchSyncManifest( func TestStoreLoadsProjectManifests(t *testing.T) { client := &manifestClient{manifests: map[string]syncapi.SyncManifest{ "zeta": { - Source: "git:example/repo", + FormatVersion: syncapi.SyncManifestFormatVersion, + Source: "git:example/repo", Items: []syncapi.SyncManifestResource{{ ResourceKind: syncdomain.KindVariation, ResourceLookupKey: "config/b", Fingerprint: fingerprint("b"), Version: 4, }}, }, "alpha": { - Source: "git:example/repo", + FormatVersion: syncapi.SyncManifestFormatVersion, + Source: "git:example/repo", Items: []syncapi.SyncManifestResource{{ ResourceKind: syncdomain.KindVariation, ResourceLookupKey: "config/a", Fingerprint: fingerprint("a"), Version: 2, @@ -78,9 +80,15 @@ func TestStoreLoadsProjectManifests(t *testing.T) { func TestStoreUpdatesWithRemoteVersions(t *testing.T) { client := &manifestClient{patchResponses: []syncapi.SyncManifest{{ - Source: "git:example/repo", + FormatVersion: syncapi.SyncManifestFormatVersion, + Source: "git:example/repo", Items: []syncapi.SyncManifestResource{ - {ResourceKind: syncdomain.KindVariation, ResourceLookupKey: "config/existing", Fingerprint: fingerprint("c"), Version: 4}, + { + ResourceKind: syncdomain.KindVariation, ResourceLookupKey: "config/existing", + Fingerprint: fingerprint("c"), Version: 4, + FingerprintSchema: syncdomain.ProjectionFingerprintSchema, ReferenceDigest: fingerprint("e"), + AdapterBehaviorVersion: syncdomain.AdapterBehaviorVersion, AdapterBehaviorDigest: syncdomain.AdapterBehaviorDigest, + }, {ResourceKind: syncdomain.KindTool, ResourceLookupKey: "search", Fingerprint: fingerprint("d"), Version: 1}, }, }}} @@ -90,7 +98,12 @@ func TestStoreUpdatesWithRemoteVersions(t *testing.T) { {ResourceKind: syncdomain.KindSkill, ProjectKey: "project", LookupKey: "old", Fingerprint: fingerprint("b"), Version: 2}, }} next := Manifest{Resources: []Resource{ - {ResourceKind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/existing", Fingerprint: fingerprint("c"), Version: 3}, + { + ResourceKind: syncdomain.KindVariation, ProjectKey: "project", LookupKey: "config/existing", + Fingerprint: fingerprint("c"), Version: 3, + FingerprintSchema: syncdomain.ProjectionFingerprintSchema, ReferenceDigest: fingerprint("e"), + AdapterBehaviorVersion: syncdomain.AdapterBehaviorVersion, AdapterBehaviorDigest: syncdomain.AdapterBehaviorDigest, + }, {ResourceKind: syncdomain.KindTool, ProjectKey: "project", LookupKey: "search", Fingerprint: fingerprint("d")}, }} @@ -100,12 +113,18 @@ func TestStoreUpdatesWithRemoteVersions(t *testing.T) { require.Len(t, client.patches, 1) assert.Equal(t, []syncapi.SyncManifestUpsert{ {ResourceKind: syncdomain.KindTool, ResourceLookupKey: "search", Fingerprint: fingerprint("d"), Version: 0}, - {ResourceKind: syncdomain.KindVariation, ResourceLookupKey: "config/existing", Fingerprint: fingerprint("c"), Version: 3}, + { + ResourceKind: syncdomain.KindVariation, ResourceLookupKey: "config/existing", + Fingerprint: fingerprint("c"), Version: 3, + FingerprintSchema: syncdomain.ProjectionFingerprintSchema, ReferenceDigest: fingerprint("e"), + AdapterBehaviorVersion: syncdomain.AdapterBehaviorVersion, AdapterBehaviorDigest: syncdomain.AdapterBehaviorDigest, + }, }, client.patches[0].upserts) assert.Equal(t, []syncapi.SyncManifestDeletion{{ ResourceKind: syncdomain.KindSkill, ResourceLookupKey: "old", Version: 2, }}, client.patches[0].deletions) assert.Equal(t, 4, updated.Resources[1].Version) + assert.Equal(t, syncdomain.ProjectionFingerprintSchema, updated.Resources[1].FingerprintSchema) } func TestStoreReportsOptimisticConflict(t *testing.T) { @@ -126,7 +145,8 @@ func TestStoreReportsOptimisticConflict(t *testing.T) { func TestStoreReadsBackAnUncertainPatchThatSucceeded(t *testing.T) { applied := syncapi.SyncManifest{ - Source: "git:example/repo", + FormatVersion: syncapi.SyncManifestFormatVersion, + Source: "git:example/repo", Items: []syncapi.SyncManifestResource{{ ResourceKind: syncdomain.KindVariation, ResourceLookupKey: "config/variation", Fingerprint: fingerprint("b"), Version: 2, diff --git a/internal/sync/projection.go b/internal/sync/projection.go new file mode 100644 index 000000000..24a609c0b --- /dev/null +++ b/internal/sync/projection.go @@ -0,0 +1,690 @@ +package sync + +import ( + "encoding/json" + "fmt" + "maps" + "slices" + "strings" +) + +const projectionFingerprintSchema = "launchdarkly.config.projection/1" + +// ProjectionFingerprintSchema is the schema of descriptor-backed fingerprints. +const ProjectionFingerprintSchema = projectionFingerprintSchema + +// AdapterBehaviorVersion changes when adapter behavior changes mapped values. +const AdapterBehaviorVersion = 2 + +// AdapterBehaviorDigest identifies the parser, path, codec, and capability rules. +const AdapterBehaviorDigest = "sha256:e0c3bdc3ed2886e480b73a079f1e3ef3cd48c99015b4624d3bb044cecb8de2bf" + +// Field identifies one LaunchDarkly value that a repository source owns. +type Field string + +const ( + FieldPrompt Field = "prompt" + FieldMessages Field = "messages" + FieldName Field = "name" + FieldModel Field = "model" + FieldModelParameters Field = "model.parameters" + FieldTools Field = "tools" + FieldSkills Field = "skills" + FieldDescription Field = "description" + FieldSchema Field = "schema" + FieldCustomParameters Field = "customParameters" + FieldTags Field = "tags" + FieldMarkdown Field = "markdown" +) + +// Capability describes how one mapped field can change. +type Capability string + +const ( + CapabilityDirect Capability = "direct" + CapabilityPatch Capability = "patch" + CapabilityReadOnly Capability = "read-only" + CapabilityReject Capability = "reject" +) + +// FieldState records the value availability and write capability of one field. +type FieldState struct { + Capability Capability + Known bool + Reason string + Code ErrorCode + File string +} + +// Projection is the mapped content of one repository source. +type Projection interface { + Kind() Kind + projection() +} + +// VariationProjection contains only the mapped fields of one variation. +type VariationProjection struct { + Mode VariationMode `json:"mode"` + Instructions *string `json:"instructions,omitempty"` + Messages *[]Message `json:"messages,omitempty"` + Name *string `json:"name,omitempty"` + Model *string `json:"model,omitempty"` + Parameters *map[string]any `json:"parameters,omitempty"` + Tools *[]string `json:"tools,omitempty"` + Skills *[]string `json:"skills,omitempty"` + FieldStates map[Field]FieldState `json:"-"` +} + +func (VariationProjection) Kind() Kind { return KindVariation } +func (VariationProjection) projection() {} + +// ToolProjection contains only the mapped fields of one tool. +type ToolProjection struct { + Description *string `json:"description,omitempty"` + Schema *map[string]any `json:"schema,omitempty"` + CustomParameters *map[string]any `json:"customParameters,omitempty"` + Tags *[]string `json:"tags,omitempty"` + FieldStates map[Field]FieldState `json:"-"` +} + +func (ToolProjection) Kind() Kind { return KindTool } +func (ToolProjection) projection() {} + +// SkillProjection contains only the mapped fields of one skill. +type SkillProjection struct { + Description *string `json:"description,omitempty"` + Markdown *string `json:"markdown,omitempty"` + FieldStates map[Field]FieldState `json:"-"` +} + +func (SkillProjection) Kind() Kind { return KindSkill } +func (SkillProjection) projection() {} + +// ProjectionFields returns the fields that a reference maps. +func ProjectionFields(reference Reference) ([]Field, error) { + fields := make([]Field, 0, len(reference.Map)) + for name := range reference.Map { + field := Field(name) + if !field.valid() { + return nil, fmt.Errorf("unsupported mapped field %q", name) + } + fields = append(fields, field) + } + slices.Sort(fields) + return fields, nil +} + +func (field Field) valid() bool { + switch field { + case FieldPrompt, FieldMessages, FieldName, FieldModel, FieldModelParameters, FieldTools, FieldSkills, + FieldDescription, FieldSchema, FieldCustomParameters, FieldTags, FieldMarkdown: + return true + default: + _, ok := ModelParameterKey(field) + return ok + } +} + +// ModelParameterKey returns the exact key from a model parameter field. +func ModelParameterKey(field Field) (string, bool) { + const prefix = `model.parameters[` + text := string(field) + if !strings.HasPrefix(text, prefix) || !strings.HasSuffix(text, "]") { + return "", false + } + quoted := text[len(prefix) : len(text)-1] + if len(quoted) < 2 || quoted[0] != '"' { + return "", false + } + var key string + if err := json.Unmarshal([]byte(quoted), &key); err != nil { + return "", false + } + return key, true +} + +// ProjectVariation selects the mapped fields from a variation. +func ProjectVariation(variation Variation, reference Reference) (VariationProjection, error) { + fields, err := ProjectionFields(reference) + if err != nil { + return VariationProjection{}, err + } + projection := VariationProjection{Mode: variation.Mode, FieldStates: make(map[Field]FieldState)} + for _, field := range fields { + projection.FieldStates[field] = FieldState{Capability: CapabilityDirect, Known: true} + switch field { + case FieldPrompt, FieldMessages: + if variation.Mode == VariationModeCompletion { + messages := slices.Clone(variation.Messages) + projection.Messages = &messages + } else { + value := variation.Instructions + projection.Instructions = &value + } + case FieldName: + value := variation.Name + projection.Name = &value + case FieldModel: + if value, ok := variation.Model["modelName"].(string); ok { + projection.Model = &value + } + case FieldModelParameters: + parameters := canonicalModelParameters(variation) + projection.Parameters = ¶meters + case FieldTools: + values := referenceKeys(variation.Tools) + projection.Tools = &values + case FieldSkills: + values := referenceKeys(variation.Skills) + projection.Skills = &values + default: + key, ok := ModelParameterKey(field) + if !ok { + return VariationProjection{}, fmt.Errorf("field %q is not valid for a variation", field) + } + parameters := canonicalModelParameters(variation) + if parameter, present := parameters[key]; present { + if projection.Parameters == nil { + value := map[string]any{} + projection.Parameters = &value + } + (*projection.Parameters)[key] = cloneJSONValue(parameter) + } + } + } + return projection, nil +} + +// ApplyVariationProjection merges mapped fields into a variation. +func ApplyVariationProjection(variation Variation, projection VariationProjection) Variation { + if projection.Mode.Valid() { + variation.Mode = projection.Mode + } + if projection.Instructions != nil { + variation.Instructions, variation.Messages = *projection.Instructions, nil + } + if projection.Messages != nil { + variation.Instructions, variation.Messages = "", slices.Clone(*projection.Messages) + } + if projection.Name != nil { + variation.Name = *projection.Name + } + if projection.Model != nil { + variation.Model = cloneMap(variation.Model) + variation.Model["modelName"] = *projection.Model + } + if projection.Parameters != nil { + variation.Model = cloneMap(variation.Model) + if _, whole := projection.FieldStates[FieldModelParameters]; whole { + variation.Model["parameters"] = cloneMap(*projection.Parameters) + } else { + parameters, _ := variation.Model["parameters"].(map[string]any) + parameters = cloneMap(parameters) + for _, field := range projectionStateFields(projection) { + key, parameter := ModelParameterKey(field) + if !parameter { + continue + } + if value, present := (*projection.Parameters)[key]; present { + parameters[key] = cloneJSONValue(value) + } + } + variation.Model["parameters"] = parameters + } + } + if projection.Tools != nil { + variation.Tools = attachmentReferences(*projection.Tools) + } + if projection.Skills != nil { + variation.Skills = attachmentReferences(*projection.Skills) + } + variation.Model = CanonicalModel(variation.Model, variation.Tools) + return variation +} + +// ProjectAttachment selects the mapped fields from a tool or skill. +func ProjectAttachment(attachment Attachment, reference Reference) (Projection, error) { + fields, err := ProjectionFields(reference) + if err != nil { + return nil, err + } + switch { + case attachment.Tool != nil: + projection := ToolProjection{FieldStates: make(map[Field]FieldState)} + for _, field := range fields { + projection.FieldStates[field] = FieldState{Capability: CapabilityDirect, Known: true} + switch field { + case FieldDescription: + projection.Description = attachment.Tool.Description + case FieldSchema: + value := cloneMap(attachment.Tool.Schema) + projection.Schema = &value + case FieldCustomParameters: + value := cloneMap(attachment.Tool.CustomParameters) + projection.CustomParameters = &value + case FieldTags: + value := slices.Clone(attachment.Tool.Tags) + slices.Sort(value) + value = slices.Compact(value) + projection.Tags = &value + default: + return nil, fmt.Errorf("field %q is not valid for a tool", field) + } + } + return projection, nil + case attachment.Skill != nil: + projection := SkillProjection{FieldStates: make(map[Field]FieldState)} + for _, field := range fields { + projection.FieldStates[field] = FieldState{Capability: CapabilityDirect, Known: true} + switch field { + case FieldDescription: + value := attachment.Skill.Description + projection.Description = &value + case FieldMarkdown: + value := attachment.Skill.Markdown + projection.Markdown = &value + default: + return nil, fmt.Errorf("field %q is not valid for a skill", field) + } + } + return projection, nil + default: + return nil, fmt.Errorf("%s %q has no content", attachment.Kind, attachment.Key()) + } +} + +// ApplyAttachmentProjection merges mapped fields into an attachment. +func ApplyAttachmentProjection(attachment Attachment, projection Projection) (Attachment, error) { + switch value := projection.(type) { + case ToolProjection: + if attachment.Tool == nil { + return Attachment{}, fmt.Errorf("tool %q has no content", attachment.Key()) + } + tool := *attachment.Tool + if value.Description != nil { + description := *value.Description + tool.Description = &description + } + if value.Schema != nil { + tool.Schema = cloneMap(*value.Schema) + } + if value.CustomParameters != nil { + tool.CustomParameters = cloneMap(*value.CustomParameters) + } + if value.Tags != nil { + tool.Tags = slices.Clone(*value.Tags) + slices.Sort(tool.Tags) + tool.Tags = slices.Compact(tool.Tags) + } + attachment.Tool = &tool + case SkillProjection: + if attachment.Skill == nil { + return Attachment{}, fmt.Errorf("skill %q has no content", attachment.Key()) + } + skill := *attachment.Skill + if value.Description != nil { + skill.Description = *value.Description + } + if value.Markdown != nil { + skill.Markdown = *value.Markdown + } + attachment.Skill = &skill + default: + return Attachment{}, fmt.Errorf("unsupported projection %T", projection) + } + return attachment, nil +} + +// FingerprintProjection returns a stable fingerprint for mapped fields only. +func FingerprintProjection(id ResourceID, projection Projection) (string, error) { + return fingerprint("projection", struct { + Schema string `json:"schema"` + ResourceKind Kind `json:"resourceKind"` + ProjectKey string `json:"projectKey"` + LookupKey string `json:"lookupKey"` + Projection Projection `json:"projection"` + }{ + Schema: projectionFingerprintSchema, + ResourceKind: id.Kind, + ProjectKey: id.ProjectKey, + LookupKey: id.LookupKey, + Projection: projection, + }) +} + +// ReferenceDigest returns a stable digest of one editable descriptor. +func ReferenceDigest(reference Reference) (string, error) { + return fingerprint("reference descriptor", struct { + Schema string `json:"schema"` + Reference Reference `json:"reference"` + }{ + Schema: "launchdarkly.config.reference/1", + Reference: reference, + }) +} + +func referenceKeys(values []AttachmentRef) []string { + keys := make([]string, len(values)) + for index := range values { + keys[index] = values[index].Key + } + slices.Sort(keys) + return keys +} + +func attachmentReferences(keys []string) []AttachmentRef { + references := make([]AttachmentRef, len(keys)) + for index := range keys { + references[index] = AttachmentRef{Key: keys[index]} + } + return references +} + +func cloneMap(source map[string]any) map[string]any { + if source == nil { + return map[string]any{} + } + data, _ := json.Marshal(source) + var result map[string]any + _ = json.Unmarshal(data, &result) + return result +} + +func cloneJSONValue(source any) any { + data, err := json.Marshal(source) + if err != nil { + return source + } + var result any + if err := json.Unmarshal(data, &result); err != nil { + return source + } + return result +} + +func canonicalModelParameters(variation Variation) map[string]any { + model := CanonicalModel(variation.Model, variation.Tools) + parameters, _ := model["parameters"].(map[string]any) + return cloneMap(parameters) +} + +// ProjectionFieldState returns one mapped field capability. +func ProjectionFieldState(projection Projection, field Field) (FieldState, bool) { + var states map[Field]FieldState + switch value := projection.(type) { + case VariationProjection: + states = value.FieldStates + case ToolProjection: + states = value.FieldStates + case SkillProjection: + states = value.FieldStates + } + state, ok := states[field] + return state, ok +} + +// ProjectionFieldStates returns a copy of every mapped field state. +func ProjectionFieldStates(projection Projection) map[Field]FieldState { + result := map[Field]FieldState{} + for _, field := range projectionStateFields(projection) { + state, _ := ProjectionFieldState(projection, field) + result[field] = state + } + return result +} + +// ProjectionFieldValue returns one known mapped value. +func ProjectionFieldValue(projection Projection, field Field) (any, bool) { + switch value := projection.(type) { + case VariationProjection: + switch field { + case FieldPrompt, FieldMessages: + if value.Instructions != nil { + return *value.Instructions, true + } + if value.Messages != nil { + return *value.Messages, true + } + case FieldName: + if value.Name != nil { + return *value.Name, true + } + case FieldModel: + if value.Model != nil { + return *value.Model, true + } + case FieldModelParameters: + if value.Parameters != nil { + return cloneMap(*value.Parameters), true + } + case FieldTools: + if value.Tools != nil { + return *value.Tools, true + } + case FieldSkills: + if value.Skills != nil { + return *value.Skills, true + } + default: + if key, ok := ModelParameterKey(field); ok && value.Parameters != nil { + parameter, present := (*value.Parameters)[key] + return cloneJSONValue(parameter), present + } + } + case ToolProjection: + switch field { + case FieldDescription: + if value.Description != nil { + return *value.Description, true + } + case FieldSchema: + if value.Schema != nil { + return *value.Schema, true + } + case FieldCustomParameters: + if value.CustomParameters != nil { + return *value.CustomParameters, true + } + case FieldTags: + if value.Tags != nil { + return *value.Tags, true + } + } + case SkillProjection: + if field == FieldDescription && value.Description != nil { + return *value.Description, true + } + if field == FieldMarkdown && value.Markdown != nil { + return *value.Markdown, true + } + } + return nil, false +} + +// FillReadOnlyFields copies server values for local fields that cannot be read. +func FillReadOnlyFields(local, server Projection) (Projection, error) { + result := local + for _, field := range projectionStateFields(local) { + state, _ := ProjectionFieldState(local, field) + if state.Capability != CapabilityReadOnly || state.Known { + continue + } + if value, ok := ProjectionFieldValue(server, field); ok { + var err error + result, err = setProjectionField(result, field, value) + if err != nil { + return nil, err + } + result = setProjectionState(result, field, state) + } + } + return result, nil +} + +func setProjectionState(projection Projection, field Field, state FieldState) Projection { + switch value := projection.(type) { + case VariationProjection: + value.FieldStates[field] = state + return value + case ToolProjection: + value.FieldStates[field] = state + return value + case SkillProjection: + value.FieldStates[field] = state + return value + default: + return projection + } +} + +// ChangedProjection returns only server fields whose projected values differ. +func ChangedProjection(local, server Projection) (Projection, error) { + result := emptyProjection(server) + for _, field := range projectionStateFields(local) { + left, leftOK := ProjectionFieldValue(local, field) + right, rightOK := ProjectionFieldValue(server, field) + leftJSON, _ := json.Marshal(left) + rightJSON, _ := json.Marshal(right) + if leftOK == rightOK && string(leftJSON) == string(rightJSON) { + continue + } + if rightOK { + var err error + result, err = setProjectionField(result, field, right) + if err != nil { + return nil, err + } + } + } + return result, nil +} + +// ProjectionIncludes reports whether actual has every known value in expected. +func ProjectionIncludes(actual, expected Projection) bool { + for _, field := range projectionStateFields(expected) { + want, wantOK := ProjectionFieldValue(expected, field) + have, haveOK := ProjectionFieldValue(actual, field) + wantJSON, _ := json.Marshal(want) + haveJSON, _ := json.Marshal(have) + if wantOK != haveOK || string(wantJSON) != string(haveJSON) { + return false + } + } + return true +} + +func projectionStateFields(projection Projection) []Field { + var states map[Field]FieldState + switch value := projection.(type) { + case VariationProjection: + states = value.FieldStates + case ToolProjection: + states = value.FieldStates + case SkillProjection: + states = value.FieldStates + } + fields := slices.Collect(maps.Keys(states)) + slices.Sort(fields) + return fields +} + +func emptyProjection(projection Projection) Projection { + switch value := projection.(type) { + case VariationProjection: + return VariationProjection{Mode: value.Mode, FieldStates: make(map[Field]FieldState)} + case ToolProjection: + return ToolProjection{FieldStates: make(map[Field]FieldState)} + case SkillProjection: + return SkillProjection{FieldStates: make(map[Field]FieldState)} + default: + return projection + } +} + +func setProjectionField(projection Projection, field Field, raw any) (Projection, error) { + state := FieldState{Capability: CapabilityDirect, Known: true} + switch value := projection.(type) { + case VariationProjection: + if value.FieldStates == nil { + value.FieldStates = make(map[Field]FieldState) + } + value.FieldStates[field] = state + switch field { + case FieldPrompt, FieldMessages: + switch prompt := raw.(type) { + case string: + value.Instructions = &prompt + case []Message: + value.Messages = &prompt + } + case FieldName: + item := raw.(string) + value.Name = &item + case FieldModel: + item := raw.(string) + value.Model = &item + case FieldModelParameters: + item, ok := raw.(map[string]any) + if !ok { + return nil, fmt.Errorf("field %q needs an object", field) + } + item = cloneMap(item) + value.Parameters = &item + case FieldTools: + item := slices.Clone(raw.([]string)) + value.Tools = &item + case FieldSkills: + item := slices.Clone(raw.([]string)) + value.Skills = &item + default: + key, ok := ModelParameterKey(field) + if !ok { + return nil, fmt.Errorf("field %q is not valid for a variation", field) + } + if value.Parameters == nil { + parameters := map[string]any{} + value.Parameters = ¶meters + } + (*value.Parameters)[key] = cloneJSONValue(raw) + } + return value, nil + case ToolProjection: + if value.FieldStates == nil { + value.FieldStates = make(map[Field]FieldState) + } + value.FieldStates[field] = state + switch field { + case FieldDescription: + item := raw.(string) + value.Description = &item + case FieldSchema: + item := cloneMap(raw.(map[string]any)) + value.Schema = &item + case FieldCustomParameters: + item := cloneMap(raw.(map[string]any)) + value.CustomParameters = &item + case FieldTags: + item := slices.Clone(raw.([]string)) + value.Tags = &item + default: + return nil, fmt.Errorf("field %q is not valid for a tool", field) + } + return value, nil + case SkillProjection: + if value.FieldStates == nil { + value.FieldStates = make(map[Field]FieldState) + } + value.FieldStates[field] = state + item := raw.(string) + if field == FieldDescription { + value.Description = &item + } else if field == FieldMarkdown { + value.Markdown = &item + } else { + return nil, fmt.Errorf("field %q is not valid for a skill", field) + } + return value, nil + default: + return nil, fmt.Errorf("unsupported projection %T", projection) + } +} diff --git a/internal/sync/projection_parameters_test.go b/internal/sync/projection_parameters_test.go new file mode 100644 index 000000000..fa6842289 --- /dev/null +++ b/internal/sync/projection_parameters_test.go @@ -0,0 +1,170 @@ +package sync + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestModelParameterKeyPreservesExactKeys(t *testing.T) { + tests := []struct { + target string + key string + valid bool + }{ + {target: `model.parameters["temperature"]`, key: "temperature", valid: true}, + {target: `model.parameters["top_k"]`, key: "top_k", valid: true}, + {target: `model.parameters["top_p"]`, key: "top_p", valid: true}, + {target: `model.parameters["max_tokens"]`, key: "max_tokens", valid: true}, + {target: `model.parameters["Provider.Future-Key"]`, key: "Provider.Future-Key", valid: true}, + {target: `model.parameters["Case\u0053ensitive"]`, key: "CaseSensitive", valid: true}, + {target: `model.parameters[temperature]`}, + {target: `model.parameters.temperature`}, + {target: `model.parameters["temperature"].nested`}, + } + for _, test := range tests { + t.Run(test.target, func(t *testing.T) { + key, valid := ModelParameterKey(Field(test.target)) + assert.Equal(t, test.valid, valid) + assert.Equal(t, test.key, key) + }) + } +} + +func TestProjectVariationPreservesJSONModelParameterValues(t *testing.T) { + tests := []struct { + name string + key string + value any + }{ + {name: "temperature", key: "temperature", value: 0.2}, + {name: "top k", key: "top_k", value: 40.0}, + {name: "top p", key: "top_p", value: 0.9}, + {name: "max tokens", key: "max_tokens", value: 1024.0}, + {name: "future provider key", key: "Provider.Future-Key", value: "fast"}, + {name: "string", key: "response_style", value: "brief"}, + {name: "boolean", key: "reasoning", value: true}, + {name: "null", key: "seed", value: nil}, + {name: "nested object", key: "provider", value: map[string]any{"mode": "fast", "retry": true}}, + {name: "array", key: "stop", value: []any{"END", map[string]any{"token": 2.0}}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + target := Field(`model.parameters["` + test.key + `"]`) + variation := Variation{ + Mode: VariationModeAgent, + Model: map[string]any{"parameters": map[string]any{test.key: test.value}}, + } + projection, err := ProjectVariation(variation, Reference{ + Map: map[string]string{string(target): "value"}, + }) + require.NoError(t, err) + require.NotNil(t, projection.Parameters) + assert.Equal(t, test.value, (*projection.Parameters)[test.key]) + value, present := ProjectionFieldValue(projection, target) + assert.True(t, present) + assert.Equal(t, test.value, value) + }) + } +} + +func TestApplyVariationProjectionUsesWholeOrIndividualParameterOwnership(t *testing.T) { + base := Variation{ + Mode: VariationModeAgent, + Model: map[string]any{"parameters": map[string]any{ + "temperature": 0.2, + "top_k": 20.0, + "keep": true, + }}, + } + tests := []struct { + name string + projection VariationProjection + want map[string]any + }{ + { + name: "individual mapping merges one key", + projection: VariationProjection{ + Parameters: &map[string]any{"top_k": 50.0}, + FieldStates: map[Field]FieldState{`model.parameters["top_k"]`: {Known: true}}, + }, + want: map[string]any{"temperature": 0.2, "top_k": 50.0, "keep": true}, + }, + { + name: "individual mapping preserves null", + projection: VariationProjection{ + Parameters: &map[string]any{"top_p": nil}, + FieldStates: map[Field]FieldState{`model.parameters["top_p"]`: {Known: true}}, + }, + want: map[string]any{"temperature": 0.2, "top_k": 20.0, "top_p": nil, "keep": true}, + }, + { + name: "whole mapping replaces canonical parameters", + projection: VariationProjection{ + Parameters: &map[string]any{"max_tokens": 512.0}, + FieldStates: map[Field]FieldState{FieldModelParameters: {Known: true}}, + }, + want: map[string]any{"max_tokens": 512.0}, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + applied := ApplyVariationProjection(base, test.projection) + assert.Equal(t, test.want, applied.Model["parameters"]) + }) + } +} + +func TestModelParameterProjectionChangeAndFingerprint(t *testing.T) { + id := VariationID("project", "config", "default") + target := Field(`model.parameters["temperature"]`) + state := map[Field]FieldState{target: {Capability: CapabilityDirect, Known: true}} + localParameters := map[string]any{"temperature": 0.2} + serverParameters := map[string]any{"temperature": 0.7} + local := VariationProjection{Mode: VariationModeAgent, Parameters: &localParameters, FieldStates: state} + server := VariationProjection{Mode: VariationModeAgent, Parameters: &serverParameters, FieldStates: state} + + localFingerprint, err := FingerprintProjection(id, local) + require.NoError(t, err) + serverFingerprint, err := FingerprintProjection(id, server) + require.NoError(t, err) + assert.NotEqual(t, localFingerprint, serverFingerprint) + + changed, err := ChangedProjection(local, server) + require.NoError(t, err) + value, present := ProjectionFieldValue(changed, target) + assert.True(t, present) + assert.Equal(t, 0.7, value) + assert.False(t, ProjectionIncludes(local, server)) + assert.True(t, ProjectionIncludes(server, changed)) +} + +func TestModelParameterProjectionExcludesExpandedTools(t *testing.T) { + variation := Variation{ + Mode: VariationModeAgent, + Tools: []AttachmentRef{{Key: "search"}}, + Model: map[string]any{"parameters": map[string]any{ + "temperature": 0.2, + "tools": []any{map[string]any{"name": "search"}}, + }}, + } + projection, err := ProjectVariation(variation, Reference{ + Map: map[string]string{string(FieldModelParameters): "parameters"}, + }) + require.NoError(t, err) + require.NotNil(t, projection.Parameters) + assert.Equal(t, map[string]any{"temperature": 0.2}, *projection.Parameters) + + parameters := map[string]any{ + "temperature": 0.3, + "tools": []any{map[string]any{"name": "search"}}, + } + applied := ApplyVariationProjection(variation, VariationProjection{ + Parameters: ¶meters, + FieldStates: map[Field]FieldState{ + FieldModelParameters: {Known: true}, + }, + }) + assert.Equal(t, map[string]any{"temperature": 0.3}, applied.Model["parameters"]) +} diff --git a/internal/sync/prompt/acceptance_test.go b/internal/sync/prompt/acceptance_test.go index 16affe581..050de741d 100644 --- a/internal/sync/prompt/acceptance_test.go +++ b/internal/sync/prompt/acceptance_test.go @@ -28,7 +28,6 @@ import ( synclocal "github.com/launchdarkly/ldcli/internal/sync/local" syncmanifest "github.com/launchdarkly/ldcli/internal/sync/manifest" syncprompt "github.com/launchdarkly/ldcli/internal/sync/prompt" - syncreference "github.com/launchdarkly/ldcli/internal/sync/reference" ) type directAPI struct { @@ -1211,63 +1210,6 @@ func TestPromptPullPreservesLocalModelOverridesAcrossVersions(t *testing.T) { requireOnlyReads(t, api.requests) } -func TestPromptPushesLinkedFileContent(t *testing.T) { - root := initRepository(t) - local := variation("Linked") - writeLinkedVariation(t, root, local, "Local linked prompt") - api := &directAPI{} - - _, _, err := runPrompt(t, root, api, "--yes") - - require.NoError(t, err) - require.NotNil(t, api.variation) - assert.Equal(t, "Local linked prompt", api.variation.Instructions) - local.Instructions = "Local linked prompt" - assertManifestFingerprint(t, root, local) -} - -func TestPromptPullsServerContentIntoLinkedFile(t *testing.T) { - root := initRepository(t) - baseline := variation("Baseline") - baseline.Instructions = "Old prompt" - server := variation("Server") - server.Instructions = "Server prompt" - writeLinkedVariation(t, root, baseline, "Old prompt") - writeManifest(t, root, baseline) - api := &directAPI{variation: pointer(server)} - - _, _, err := runPrompt(t, root, api, "--yes") - - require.NoError(t, err) - content, err := os.ReadFile(filepath.Join(root, "prompts", "default.md")) - require.NoError(t, err) - assert.Equal(t, "Server prompt\n", string(content)) - assertManifestFingerprint(t, root, server) -} - -func TestPromptServerDeletionLeavesReferencedFile(t *testing.T) { - root := initRepository(t) - baseline := variation("Baseline") - baseline.Instructions = "Keep this file" - writeLinkedVariation(t, root, baseline, "Keep this file") - writeManifest(t, root, baseline) - api := &directAPI{} - - _, _, err := runPrompt(t, root, api, "--yes") - - require.NoError(t, err) - content, err := os.ReadFile(filepath.Join(root, "prompts", "default.md")) - require.NoError(t, err) - assert.Equal(t, "Keep this file\n", string(content)) - resources, err := synclocal.CompileWorkspace(root) - if errors.Is(err, synclocal.ErrNoDirectory) { - resources = nil - err = nil - } - require.NoError(t, err) - assert.Empty(t, resources) -} - // A working copy that is behind another working copy must pull the newer // LaunchDarkly state. It must not write its older file back to LaunchDarkly. func TestPromptStaleWorkingCopyPullsInsteadOfReverting(t *testing.T) { @@ -1299,61 +1241,6 @@ func TestPromptStaleWorkingCopyPullsInsteadOfReverting(t *testing.T) { assert.Equal(t, other.Name, resources[0].Variation.Name) } -func TestPromptRestoresLinkedVariationWithItsLink(t *testing.T) { - root := initRepository(t) - api := syncLinkedVariation(t, root) - deleteVariationFile(t, root) - - _, _, err := runPrompt(t, root, api, "--yes") - - require.NoError(t, err) - resources, err := synclocal.CompileWorkspace(root) - require.NoError(t, err) - require.Len(t, resources, 1) - require.NotNil(t, resources[0].Ref) - assert.Equal(t, "prompts/default.md", resources[0].Ref.File) - - // An edit in the linked file still reaches LaunchDarkly. - require.NoError(t, os.WriteFile(filepath.Join(root, "prompts", "default.md"), []byte("Edited prompt\n"), 0o644)) - _, _, err = runPrompt(t, root, api, "--yes") - require.NoError(t, err) - assert.Equal(t, "Edited prompt", api.variation.Instructions) -} - -func TestPromptStopsRestoreWhenLinkedFileIsNotSafe(t *testing.T) { - tests := map[string]struct { - change func(t *testing.T, linkedFile string) - wantError string - }{ - "linked file is missing": { - change: func(t *testing.T, linkedFile string) { require.NoError(t, os.Remove(linkedFile)) }, - wantError: `its linked file "prompts/default.md" is not available`, - }, - "linked file has local edits": { - change: func(t *testing.T, linkedFile string) { - require.NoError(t, os.WriteFile(linkedFile, []byte("Local edit\n"), 0o644)) - }, - wantError: `its linked file "prompts/default.md" differs from LaunchDarkly`, - }, - } - - for name, test := range tests { - t.Run(name, func(t *testing.T) { - root := initRepository(t) - api := syncLinkedVariation(t, root) - deleteVariationFile(t, root) - test.change(t, filepath.Join(root, "prompts", "default.md")) - - _, _, err := runPrompt(t, root, api, "--yes") - - require.ErrorContains(t, err, test.wantError) - assert.Equal(t, "Linked prompt", api.variation.Instructions) - _, statErr := os.Stat(filepath.Join(root, syncdomain.RootDir, "production", "configs", "support", "default.prompt.md")) - assert.ErrorIs(t, statErr, os.ErrNotExist) - }) - } -} - func TestPromptDetachArchive(t *testing.T) { tests := map[string]struct { arguments []string @@ -1402,25 +1289,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 syncLinkedVariation(t *testing.T, root string) *directAPI { - t.Helper() - linked := variation("Linked") - writeLinkedVariation(t, root, linked, "Linked prompt") - linked.Instructions = "Linked prompt" - api := &directAPI{variation: pointer(linked)} - _, _, err := runPrompt(t, root, api, "--yes") - require.NoError(t, err) - return api -} - -func deleteVariationFile(t *testing.T, root string) { - t.Helper() - _, err := synclocal.NewStore(root).DeleteVariations([]synclocal.VariationDeletion{{ - ProjectKey: "production", ConfigKey: "support", VariationKey: "default", - }}) - require.NoError(t, err) -} - func TestPromptFilterSyncsOnlyTheNamedVariation(t *testing.T) { root := initRepository(t) baseline := variation("Baseline") @@ -2092,7 +1960,7 @@ func runScoped(t *testing.T, root string, api *directAPI, options syncprompt.Opt func runWith(t *testing.T, root string, api *directAPI, runner syncprompt.Runner, options syncprompt.Options) (string, string, error) { t.Helper() if manifestsByRoot[root] == nil { - manifestsByRoot[root] = &syncapi.SyncManifest{Source: "git:example/repo", Items: []syncapi.SyncManifestResource{}} + manifestsByRoot[root] = &syncapi.SyncManifest{FormatVersion: syncapi.SyncManifestFormatVersion, Source: "git:example/repo", Items: []syncapi.SyncManifestResource{}} } api.manifest = manifestsByRoot[root] var output, notes bytes.Buffer @@ -2405,7 +2273,7 @@ func runPrompt(t *testing.T, root string, client resources.Client, arguments ... t.Setenv("XDG_CONFIG_HOME", t.TempDir()) manifest := manifestsByRoot[root] if manifest == nil { - manifest = &syncapi.SyncManifest{Source: "git:example/repo", Items: []syncapi.SyncManifestResource{}} + manifest = &syncapi.SyncManifest{FormatVersion: syncapi.SyncManifestFormatVersion, Source: "git:example/repo", Items: []syncapi.SyncManifestResource{}} manifestsByRoot[root] = manifest } switch api := client.(type) { @@ -2543,24 +2411,6 @@ func attachLocalTool(t *testing.T, root string, variation syncdomain.Variation, require.NoError(t, os.WriteFile(toolPath, []byte(content), 0o644)) } -func writeLinkedVariation(t *testing.T, root string, value syncdomain.Variation, content string) { - t.Helper() - referencePath := filepath.Join(root, "prompts", value.Key+".md") - require.NoError(t, os.MkdirAll(filepath.Dir(referencePath), 0o755)) - require.NoError(t, os.WriteFile(referencePath, []byte(content+"\n"), 0o644)) - value.Instructions = strings.TrimSpace(content) - _, err := synclocal.NewStore(root).Add([]synclocal.VariationFile{{ - ProjectKey: "production", - ConfigKey: "support", - Upsert: true, - Ref: &synclocal.Reference{ - File: "prompts/" + value.Key + ".md", Format: syncreference.PlainMarkdown, - }, - Variation: value, - }}) - require.NoError(t, err) -} - func writeManifest(t *testing.T, root string, value syncdomain.Variation) { t.Helper() writeManifestResources(t, root, value) @@ -2586,7 +2436,7 @@ func writeRemoteManifest(t *testing.T, root string, values ...syncdomain.Variati Kind: syncdomain.KindVariation, ProjectKey: "production", LookupKey: "support/" + value.Key, }, fingerprint(t, value)) } - remote := &syncapi.SyncManifest{Source: "git:example/repo"} + remote := &syncapi.SyncManifest{FormatVersion: syncapi.SyncManifestFormatVersion, Source: "git:example/repo"} for _, resource := range manifest.Resources { remote.Items = append(remote.Items, syncapi.SyncManifestResource{ ResourceKind: resource.ResourceKind, diff --git a/internal/sync/prompt/action.go b/internal/sync/prompt/action.go index 9c4199b0f..6278e892f 100644 --- a/internal/sync/prompt/action.go +++ b/internal/sync/prompt/action.go @@ -8,8 +8,6 @@ import ( syncdomain "github.com/launchdarkly/ldcli/internal/sync" synccreate "github.com/launchdarkly/ldcli/internal/sync/create" - synclink "github.com/launchdarkly/ldcli/internal/sync/link" - syncreference "github.com/launchdarkly/ldcli/internal/sync/reference" ) // CommandAction is one sync operation. scope is the scope of the command. @@ -56,15 +54,6 @@ type DetachAction struct { Archive bool } -// LinkAction creates a variation whose prompt is an external file, and then -// syncs. ProjectKey skips the project question. -type LinkAction struct { - File string - Format string - ProjectKey string - Target *synclink.Target -} - func (action SyncAction) validate(scope syncdomain.Scope) error { if action.Watch && action.DryRun { return errors.New("watch does not support --dry-run") @@ -104,25 +93,6 @@ func (action AttachAction) validate(syncdomain.Scope) error { return validateProjectFlag(action.ProjectKey, action.Target) } -func (action LinkAction) validate(syncdomain.Scope) error { - switch { - case action.File == "": - return errors.New("linked file is required") - case action.Format == "": - return errors.New("--format is required") - case action.Target != nil && action.Target.ModelConfigKey == "": - return errors.New("--model-config-key is required with --to") - } - var target *syncdomain.ResourceID - if action.Target != nil { - target = &action.Target.Variation - } - if err := validateProjectFlag(action.ProjectKey, target); err != nil { - return err - } - return syncreference.ValidateFormat(action.Format) -} - // 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. diff --git a/internal/sync/prompt/execute.go b/internal/sync/prompt/execute.go index 2b1aee5fc..a52581e2c 100644 --- a/internal/sync/prompt/execute.go +++ b/internal/sync/prompt/execute.go @@ -99,14 +99,6 @@ func executePlan( execute(index) } - if len(failures) == 0 { - variations, err := compileWorkspace(repositoryRoot) - if err != nil { - failures = append(failures, err) - } else { - next.SetRefs(variations) - } - } return outcomes, next, errors.Join(failures...) } @@ -325,7 +317,6 @@ func applyLocalChange(store synclocal.Store, resource PlannedResource) error { ProjectKey: resource.ID.ProjectKey, ConfigKey: configKey, CreateIfMissing: resource.Local == nil, - Ref: resource.restoreRef, Variation: *resource.Server, }}) return err diff --git a/internal/sync/prompt/plan.go b/internal/sync/prompt/plan.go index db9e9aeeb..a91aea21b 100644 --- a/internal/sync/prompt/plan.go +++ b/internal/sync/prompt/plan.go @@ -81,8 +81,6 @@ 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 - // restoreRef is the link that a restored variation file keeps. - restoreRef *syncdomain.Reference } // The sources of the name of a skill that sync creates. diff --git a/internal/sync/prompt/runner.go b/internal/sync/prompt/runner.go index 4e7437c99..88f7d9866 100644 --- a/internal/sync/prompt/runner.go +++ b/internal/sync/prompt/runner.go @@ -15,10 +15,8 @@ import ( syncdomain "github.com/launchdarkly/ldcli/internal/sync" syncapi "github.com/launchdarkly/ldcli/internal/sync/api" syncbootstrap "github.com/launchdarkly/ldcli/internal/sync/bootstrap" - syncconsole "github.com/launchdarkly/ldcli/internal/sync/console" 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" @@ -37,7 +35,6 @@ type Runner struct { client resources.Client bootstrap func(syncbootstrap.Options) error detach func(syncdetach.Options) error - link func(synclink.Options) (string, error) watch watchRunner isTerminal func(io.Reader, io.Writer) bool } @@ -48,7 +45,6 @@ func NewRunner(client resources.Client) Runner { client: client, bootstrap: syncbootstrap.Run, detach: syncdetach.Run, - link: synclink.Run, watch: watchWorkspace, isTerminal: syncinteractive.StreamsAreTerminal, } @@ -81,11 +77,6 @@ func (runner Runner) Run(options Options) error { return runner.runAdd(options, workspace, action) case DetachAction: return runner.runDetach(options, workspace, action) - case LinkAction: - linked, err := runner.runLink(options, workspace, action) - if err != nil || !linked { - return err - } case AttachAction: attached, err := runner.runAttach(options, workspace, action) if err != nil || !attached { @@ -179,29 +170,6 @@ func (runner Runner) runDetach(options Options, workspace syncWorkspace, action }) } -// runLink creates the linked variation file. It reports false when the user -// cancels. -func (runner Runner) runLink(options Options, workspace syncWorkspace, action LinkAction) (bool, error) { - path, err := runner.link(synclink.Options{ - Catalog: runner.api(options), - Store: workspace.local, - RepositoryRoot: workspace.root, - WorkingDirectory: options.WorkingDirectory, - File: action.File, - Format: action.Format, - Input: options.Input, - Output: options.Output, - ProjectKey: action.ProjectKey, - Target: action.Target, - NoInput: options.NoInput, - }) - if err != nil || path == "" { - return false, err - } - _ = syncconsole.New(options.Output).Printf("Linked %s/%s.\n", syncdomain.RootDir, path) - return true, nil -} - // runAttach adds the attachment to a local variation. It reports false when // the user cancels. func (runner Runner) runAttach(options Options, workspace syncWorkspace, action AttachAction) (bool, error) { diff --git a/internal/sync/prompt/runner_test.go b/internal/sync/prompt/runner_test.go index f5adec066..4d7d14725 100644 --- a/internal/sync/prompt/runner_test.go +++ b/internal/sync/prompt/runner_test.go @@ -19,9 +19,7 @@ import ( syncapi "github.com/launchdarkly/ldcli/internal/sync/api" syncbootstrap "github.com/launchdarkly/ldcli/internal/sync/bootstrap" syncdetach "github.com/launchdarkly/ldcli/internal/sync/detach" - synclink "github.com/launchdarkly/ldcli/internal/sync/link" synclocal "github.com/launchdarkly/ldcli/internal/sync/local" - syncreference "github.com/launchdarkly/ldcli/internal/sync/reference" syncsource "github.com/launchdarkly/ldcli/internal/sync/source" ) @@ -132,31 +130,6 @@ func TestRunnerWatchDoesNotRunInitialSync(t *testing.T) { assert.True(t, watchCalled) } -func TestRunnerLinksPrompt(t *testing.T) { - root := initGitRepository(t) - runner := NewRunner(noopResourceClient{}) - linkCalled := false - runner.link = func(options synclink.Options) (string, error) { - linkCalled = true - assert.Equal(t, "prompt.md", options.File) - assert.Equal(t, syncreference.PlainMarkdown, options.Format) - return "", nil - } - - err := runner.Run(Options{ - WorkingDirectory: root, - Action: LinkAction{ - File: "prompt.md", Format: syncreference.PlainMarkdown, - }, - Input: os.Stdin, - Output: io.Discard, - ErrorOutput: io.Discard, - }) - - require.NoError(t, err) - assert.True(t, linkCalled) -} - func TestRunnerDetachesWithoutCallingTheAPI(t *testing.T) { root := initGitRepository(t) runner := NewRunner(noopResourceClient{}) @@ -213,9 +186,6 @@ func TestRunnerChecksAttachmentTerminalOnOutput(t *testing.T) { } func TestValidateOptions(t *testing.T) { - require.ErrorContains(t, validateOptions(Options{ - Action: LinkAction{File: "prompt.md"}, - }), "--format is required") require.ErrorContains(t, validateOptions(Options{ Action: SyncAction{Watch: true, DryRun: true}, }), "watch does not support --dry-run") diff --git a/internal/sync/prompt/state.go b/internal/sync/prompt/state.go index 45519b45b..09f1e539c 100644 --- a/internal/sync/prompt/state.go +++ b/internal/sync/prompt/state.go @@ -11,7 +11,6 @@ import ( syncbootstrap "github.com/launchdarkly/ldcli/internal/sync/bootstrap" synclocal "github.com/launchdarkly/ldcli/internal/sync/local" syncmanifest "github.com/launchdarkly/ldcli/internal/sync/manifest" - syncreference "github.com/launchdarkly/ldcli/internal/sync/reference" ) // workspaceState is everything that one plan depends on. The plan compares @@ -195,9 +194,6 @@ func planVariationRows( server[id] = resource } plan := BuildPlan(tracked, local, server) - for index := range plan.Resources { - planRestore(repositoryRoot, baseline, &plan.Resources[index]) - } return plan, localFilesByID, nil } @@ -434,42 +430,6 @@ func references(variation *syncdomain.Variation, id ResourceID) bool { }) } -// planRestore checks a variation whose local file is missing. The restored -// file must keep the link that sync.lock records. When sync cannot restore -// the link safely, the resource becomes an error and sync writes nothing. -func planRestore(repositoryRoot string, baseline syncmanifest.Baseline, resource *PlannedResource) { - if resource.Action != ActionUpdateLocal || resource.Local != nil { - return - } - fail := func(problem, fix string) { - resource.Action = ActionError - resource.Error = "the variation file is missing, and " + problem + ". " + fix - } - ref := baseline.Lock.Ref(resource.ID) - if ref == nil { - return - } - - content, err := synclocal.ReadReference(repositoryRoot, *ref) - if err != nil { - fail(fmt.Sprintf("its linked file %q is not available (%s)", ref.File, err), - "Restore the files from Git, or stop syncing the variation with detach") - return - } - // The restore writes LaunchDarkly's prompt to the linked file. Allow that - // only when the file already has that prompt, so that no local edit is lost. - linked := *resource.Server - if err := syncreference.ApplyToVariation(ref.Format, content, &linked); err == nil { - fingerprint, err := syncdomain.FingerprintVariation(resource.ID.ProjectKey, resource.ID.LookupKey, linked) - if err == nil && fingerprint == resource.ServerFingerprint { - resource.restoreRef = ref - return - } - } - fail(fmt.Sprintf("its linked file %q differs from LaunchDarkly", ref.File), - "Restore the variation file from Git, and then run sync again") -} - // readServerResource reads one variation from LaunchDarkly with the content // of its tools and skills. func readServerResource(client syncapi.Client, attachments *attachmentCache, id syncdomain.ResourceID) (ServerResource, error) { diff --git a/internal/sync/prompt/watch_test.go b/internal/sync/prompt/watch_test.go index 4b60bdbaa..dcda1b84e 100644 --- a/internal/sync/prompt/watch_test.go +++ b/internal/sync/prompt/watch_test.go @@ -16,8 +16,6 @@ import ( "github.com/stretchr/testify/require" syncdomain "github.com/launchdarkly/ldcli/internal/sync" - synclocal "github.com/launchdarkly/ldcli/internal/sync/local" - syncreference "github.com/launchdarkly/ldcli/internal/sync/reference" ) func TestWatchWorkspaceWaitsForDebouncedChange(t *testing.T) { @@ -162,78 +160,6 @@ 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 - } - content := `--- -formatVersion: 1 -upsert: true -ref: - file: prompts/prompt.md - format: plain-markdown -mode: agent -key: prompt -name: Prompt ---- -` - if err := os.WriteFile(wrapper, []byte(content), 0o644); 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") @@ -331,41 +257,6 @@ func TestWatchedConflictPromptCancellationStopsWatch(t *testing.T) { require.ErrorIs(t, err, context.Canceled) } -func TestWatchWorkspaceTracksMissingReferencedFileParent(t *testing.T) { - root := t.TempDir() - _, err := synclocal.NewStore(root).Add([]synclocal.VariationFile{{ - ProjectKey: "project", ConfigKey: "config", - Ref: &synclocal.Reference{File: "prompts/nested/prompt.md", Format: syncreference.PlainMarkdown}, - Variation: syncdomain.Variation{ - Mode: syncdomain.VariationModeAgent, Key: "prompt", Name: "Prompt", - }, - }}) - 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") @@ -402,43 +293,6 @@ 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).Add([]synclocal.VariationFile{{ - ProjectKey: "project", ConfigKey: "config", - Ref: &synclocal.Reference{File: "prompt.md", Format: syncreference.PlainMarkdown}, - Variation: syncdomain.Variation{ - Mode: syncdomain.VariationModeAgent, Key: "prompt", Name: "Prompt", - }, - }}) - 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.prompt.md\n") - require.Contains(t, output.String(), "- prompt.md\n") -} - func TestSourceWatcherRecognizesNewManagedResourceKinds(t *testing.T) { root := t.TempDir() managedRoot := filepath.Join(root, syncdomain.RootDir) diff --git a/internal/sync/reference/adapters/adapter.go b/internal/sync/reference/adapters/adapter.go deleted file mode 100644 index 4bb1f9317..000000000 --- a/internal/sync/reference/adapters/adapter.go +++ /dev/null @@ -1,20 +0,0 @@ -// Package adapters defines the contract between sync and the external file -// formats that a linked variation can use. -package adapters - -import syncdomain "github.com/launchdarkly/ldcli/internal/sync" - -// Adapter converts between one external file format and a Prompt. -type Adapter interface { - Parse([]byte) (Prompt, error) - Render(Prompt) ([]byte, error) -} - -// Prompt is the content that an external file can supply. An empty field -// means that the file format does not store that value. -type Prompt struct { - Mode syncdomain.VariationMode - Key string - Name string - Messages []syncdomain.Message -} diff --git a/internal/sync/reference/adapters/plainmarkdown/plainmarkdown.go b/internal/sync/reference/adapters/plainmarkdown/plainmarkdown.go deleted file mode 100644 index fa92bac6c..000000000 --- a/internal/sync/reference/adapters/plainmarkdown/plainmarkdown.go +++ /dev/null @@ -1,39 +0,0 @@ -// Package plainmarkdown reads and writes a prompt as one plain Markdown file. -package plainmarkdown - -import ( - "errors" - - syncdomain "github.com/launchdarkly/ldcli/internal/sync" - "github.com/launchdarkly/ldcli/internal/sync/reference/adapters" -) - -// Adapter stores the prompt as one system message. The file has no mode, key, -// or name. -type Adapter struct{} - -var _ adapters.Adapter = Adapter{} - -// Parse reads the file text as one system message. -func (Adapter) Parse(content []byte) (adapters.Prompt, error) { - body := syncdomain.NormalizePromptText(string(content)) - if body == "" { - return adapters.Prompt{}, nil - } - return adapters.Prompt{Messages: []syncdomain.Message{{Role: syncdomain.RoleSystem, Content: body}}}, nil -} - -// Render writes the one system message of the prompt as the file text. -func (Adapter) Render(prompt adapters.Prompt) ([]byte, error) { - if len(prompt.Messages) > 1 || len(prompt.Messages) == 1 && prompt.Messages[0].Role != syncdomain.RoleSystem { - return nil, errors.New("plain-markdown supports at most one system message") - } - if len(prompt.Messages) == 0 { - return nil, nil - } - body := syncdomain.NormalizePromptText(prompt.Messages[0].Content) - if body == "" { - return nil, nil - } - return []byte(body + "\n"), nil -} diff --git a/internal/sync/reference/adapters/plainmarkdown/plainmarkdown_test.go b/internal/sync/reference/adapters/plainmarkdown/plainmarkdown_test.go deleted file mode 100644 index a33149032..000000000 --- a/internal/sync/reference/adapters/plainmarkdown/plainmarkdown_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package plainmarkdown - -import ( - "testing" - - "github.com/stretchr/testify/require" - - syncdomain "github.com/launchdarkly/ldcli/internal/sync" - "github.com/launchdarkly/ldcli/internal/sync/reference/adapters" -) - -func TestAdapterParsesAndRendersRawPrompt(t *testing.T) { - prompt, err := (Adapter{}).Parse([]byte("\nBe helpful.\n")) - - require.NoError(t, err) - require.Equal(t, adapters.Prompt{ - Messages: []syncdomain.Message{{Role: syncdomain.RoleSystem, Content: "Be helpful."}}, - }, prompt) - - rendered, err := (Adapter{}).Render(prompt) - require.NoError(t, err) - require.Equal(t, "Be helpful.\n", string(rendered)) -} - -func TestAdapterNormalizesLineEndings(t *testing.T) { - prompt, err := (Adapter{}).Parse([]byte("First line.\r\nSecond line.\rThird line.\r\n")) - - require.NoError(t, err) - require.Equal(t, adapters.Prompt{ - Messages: []syncdomain.Message{{ - Role: syncdomain.RoleSystem, - Content: "First line.\nSecond line.\nThird line.", - }}, - }, prompt) - - rendered, err := (Adapter{}).Render(adapters.Prompt{ - Messages: []syncdomain.Message{{ - Role: syncdomain.RoleSystem, - Content: "First line.\r\nSecond line.\rThird line.", - }}, - }) - - require.NoError(t, err) - require.Equal(t, "First line.\nSecond line.\nThird line.\n", string(rendered)) -} diff --git a/internal/sync/reference/format.go b/internal/sync/reference/format.go deleted file mode 100644 index 0a9b9fc58..000000000 --- a/internal/sync/reference/format.go +++ /dev/null @@ -1,119 +0,0 @@ -// Package reference converts a variation to and from the external file that a -// linked variation uses. Each file format has one adapter. -package reference - -import ( - "fmt" - - syncdomain "github.com/launchdarkly/ldcli/internal/sync" - "github.com/launchdarkly/ldcli/internal/sync/reference/adapters" - "github.com/launchdarkly/ldcli/internal/sync/reference/adapters/plainmarkdown" -) - -// PlainMarkdown identifies the built-in plain Markdown format. -const PlainMarkdown = "plain-markdown" - -// adapterFor is the registry of external formats. To add a format, implement -// adapters.Adapter and add one case here. -func adapterFor(format string) (adapters.Adapter, error) { - switch format { - case PlainMarkdown: - return plainmarkdown.Adapter{}, nil - default: - return nil, fmt.Errorf("unsupported referenced prompt format %q", format) - } -} - -// ValidateFormat reports whether a format has an adapter. -func ValidateFormat(format string) error { - _, err := adapterFor(format) - return err -} - -// Parse reads the content of an external file. -func Parse(format string, content []byte) (adapters.Prompt, error) { - adapter, err := adapterFor(format) - if err != nil { - return adapters.Prompt{}, err - } - return adapter.Parse(content) -} - -// ApplyToVariation replaces the prompt content of the variation with the -// content of an external file. If the file stores a mode, key, or name, that -// value also replaces the value in the variation. -func ApplyToVariation(format string, content []byte, variation *syncdomain.Variation) error { - prompt, err := Parse(format, content) - if err != nil { - return err - } - if prompt.Mode != "" { - if !prompt.Mode.Valid() { - return fmt.Errorf("unsupported referenced prompt mode %q", prompt.Mode) - } - variation.Mode = prompt.Mode - } - if prompt.Key != "" { - variation.Key = prompt.Key - } - if prompt.Name != "" { - variation.Name = prompt.Name - } - for _, message := range prompt.Messages { - if !syncdomain.ValidMessageRole(message.Role) { - return fmt.Errorf("unsupported referenced prompt role %q", message.Role) - } - } - - switch variation.Mode { - case syncdomain.VariationModeAgent: - if !atMostOneSystemMessage(prompt.Messages) { - return fmt.Errorf("agent variation %q requires one system message from its reference", variation.Key) - } - variation.Instructions = "" - variation.Messages = nil - if len(prompt.Messages) == 1 { - variation.Instructions = prompt.Messages[0].Content - } - case syncdomain.VariationModeCompletion: - variation.Instructions = "" - variation.Messages = append([]syncdomain.Message{}, prompt.Messages...) - default: - return fmt.Errorf("referenced prompt does not specify a supported mode") - } - return nil -} - -// Render converts the variation to the content of an external file. -func Render(format string, variation syncdomain.Variation) ([]byte, error) { - adapter, err := adapterFor(format) - if err != nil { - return nil, err - } - - prompt := adapters.Prompt{Mode: variation.Mode, Key: variation.Key, Name: variation.Name} - switch variation.Mode { - case syncdomain.VariationModeAgent: - if len(variation.Messages) != 0 { - return nil, fmt.Errorf("agent variation %q cannot be represented because it contains messages", variation.Key) - } - if variation.Instructions != "" { - prompt.Messages = []syncdomain.Message{{Role: syncdomain.RoleSystem, Content: variation.Instructions}} - } - case syncdomain.VariationModeCompletion: - for _, message := range variation.Messages { - if !syncdomain.ValidMessageRole(message.Role) { - return nil, fmt.Errorf("variation %q has unsupported message role %q", variation.Key, message.Role) - } - } - prompt.Messages = variation.Messages - default: - return nil, fmt.Errorf("referenced prompt does not support variation mode %q", variation.Mode) - } - return adapter.Render(prompt) -} - -// atMostOneSystemMessage reports whether messages is empty or is one system message. -func atMostOneSystemMessage(messages []syncdomain.Message) bool { - return len(messages) == 0 || len(messages) == 1 && messages[0].Role == syncdomain.RoleSystem -} diff --git a/internal/sync/reference/format_test.go b/internal/sync/reference/format_test.go deleted file mode 100644 index b6c20620b..000000000 --- a/internal/sync/reference/format_test.go +++ /dev/null @@ -1,79 +0,0 @@ -package reference - -import ( - "testing" - - "github.com/stretchr/testify/require" - - syncdomain "github.com/launchdarkly/ldcli/internal/sync" -) - -func TestPlainMarkdownAdapterReturnsCommonPromptDomain(t *testing.T) { - prompt, err := Parse(PlainMarkdown, []byte("Be helpful.")) - - require.NoError(t, err) - require.Empty(t, prompt.Mode) - require.Empty(t, prompt.Key) - require.Empty(t, prompt.Name) - require.Equal(t, []syncdomain.Message{{Role: syncdomain.RoleSystem, Content: "Be helpful."}}, prompt.Messages) -} - -func TestPlainMarkdownRoundTrip(t *testing.T) { - tests := []struct { - name string - variation syncdomain.Variation - assert func(*testing.T, syncdomain.Variation) - }{ - { - name: "agent instructions", - variation: syncdomain.Variation{Mode: syncdomain.VariationModeAgent, Key: "prompt"}, - assert: func(t *testing.T, variation syncdomain.Variation) { - require.Equal(t, "Be helpful.", variation.Instructions) - require.Empty(t, variation.Messages) - }, - }, - { - name: "completion system message", - variation: syncdomain.Variation{Mode: syncdomain.VariationModeCompletion, Key: "prompt"}, - assert: func(t *testing.T, variation syncdomain.Variation) { - require.Empty(t, variation.Instructions) - require.Equal(t, []syncdomain.Message{{Role: "system", Content: "Be helpful."}}, variation.Messages) - }, - }, - } - - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - err := ApplyToVariation(PlainMarkdown, []byte("\nBe helpful.\n"), &test.variation) - require.NoError(t, err) - test.assert(t, test.variation) - - rendered, err := Render(PlainMarkdown, test.variation) - require.NoError(t, err) - require.Equal(t, "Be helpful.\n", string(rendered)) - }) - } -} - -func TestPlainMarkdownRejectsUnrepresentableCompletion(t *testing.T) { - _, err := Render(PlainMarkdown, syncdomain.Variation{ - Mode: syncdomain.VariationModeCompletion, - Key: "prompt", - Messages: []syncdomain.Message{ - {Role: "system", Content: "System"}, - {Role: "user", Content: "User"}, - }, - }) - - require.ErrorContains(t, err, "at most one system message") -} - -func TestReferenceFormatRejectsUnknownFormat(t *testing.T) { - var variation syncdomain.Variation - err := ValidateFormat("anthropic-prompt") - require.ErrorContains(t, err, "unsupported referenced prompt format") - err = ApplyToVariation("anthropic-prompt", nil, &variation) - require.ErrorContains(t, err, "unsupported referenced prompt format") - _, err = Render("anthropic-prompt", variation) - require.ErrorContains(t, err, "unsupported referenced prompt format") -} diff --git a/internal/sync/resource.go b/internal/sync/resource.go index 0ec01adf0..4dbd86c06 100644 --- a/internal/sync/resource.go +++ b/internal/sync/resource.go @@ -22,6 +22,22 @@ const ( KindSkill Kind = "skill" ) +// SyncDirection is the LaunchDarkly setting that controls allowed writes. +type SyncDirection string + +const ( + SyncDirectionCodeCanonical SyncDirection = "code_canonical" + SyncDirectionServerCanonical SyncDirection = "server_canonical" + SyncDirectionBoth SyncDirection = "both" +) + +// Valid reports whether LaunchDarkly returned a supported sync direction. +func (direction SyncDirection) Valid() bool { + return direction == SyncDirectionCodeCanonical || + direction == SyncDirectionServerCanonical || + direction == SyncDirectionBoth +} + // ResourceID identifies one synchronized resource. // // For a variation, LookupKey is "config-key/variation-key". For a tool or a @@ -143,11 +159,21 @@ func ValidateCreate(kind Kind, key, name, description string) error { } } -// Reference links a variation to a prompt file elsewhere in the repository. -// Format names the adapter that reads and writes the file. +// Reference links one resource to a file elsewhere in the repository. +// Select identifies one value in the file. Map connects resource fields to +// paths below that value. type Reference struct { - File string `yaml:"file"` - Format string `yaml:"format"` + File string `yaml:"file"` + Format string `yaml:"format"` + Select string `yaml:"select,omitempty"` + Map map[string]string `yaml:"map,omitempty"` +} + +// IndexedSelection identifies one indexed item in a repository source. +type IndexedSelection struct { + Path string `yaml:"path"` + CollectionLength int `yaml:"collectionLength"` + Fingerprint string `yaml:"fingerprint"` } // SyncedResource is one variation compiled from the local workspace. @@ -157,9 +183,12 @@ type SyncedResource struct { LookupKey string // Upsert lets sync create the variation in LaunchDarkly when it is absent. Upsert bool - // Ref is the linked prompt file, or nil when the variation file holds the prompt. - Ref *Reference - Variation Variation + // Source is the descriptor-backed repository source, or nil for a managed file. + Source *Reference + Dependencies []string + IndexedSelections []IndexedSelection + Projection Projection + Variation Variation } // ID returns the identity of the compiled resource.