From 140c3bc3a39f348b32934f7532a953a573d1ac3c Mon Sep 17 00:00:00 2001 From: Cliff Tawiah Date: Fri, 30 Oct 2026 16:43:43 -0400 Subject: [PATCH] refactor(sync): remove Phase 1 source references (cherry picked from commit bfe91d038683751845db6bf848c6458fe34f3aee) --- cmd/sync/prompt.go | 55 ---- cmd/sync/prompt_test.go | 10 +- cmd/sync/scope_test.go | 25 +- internal/sync/detach/detach_test.go | 43 --- internal/sync/link/link.go | 310 ------------------ internal/sync/link/link_test.go | 279 ---------------- internal/sync/local/compile.go | 14 +- internal/sync/local/reference.go | 120 ------- internal/sync/local/reference_test.go | 185 ----------- internal/sync/local/replace.go | 54 +-- internal/sync/local/sources.go | 27 +- internal/sync/local/store.go | 6 +- internal/sync/local/variation.go | 44 +-- internal/sync/manifest/baseline_test.go | 22 -- internal/sync/manifest/model.go | 21 -- internal/sync/prompt/acceptance_test.go | 150 --------- internal/sync/prompt/action.go | 30 -- internal/sync/prompt/execute.go | 9 - internal/sync/prompt/plan.go | 2 - internal/sync/prompt/runner.go | 32 -- internal/sync/prompt/runner_test.go | 30 -- internal/sync/prompt/state.go | 40 --- internal/sync/prompt/watch_test.go | 146 --------- internal/sync/reference/adapters/adapter.go | 20 -- .../adapters/plainmarkdown/plainmarkdown.go | 39 --- .../plainmarkdown/plainmarkdown_test.go | 45 --- internal/sync/reference/format.go | 119 ------- internal/sync/reference/format_test.go | 79 ----- internal/sync/resource.go | 4 +- 29 files changed, 35 insertions(+), 1925 deletions(-) delete mode 100644 internal/sync/link/link.go delete mode 100644 internal/sync/link/link_test.go delete mode 100644 internal/sync/local/reference.go delete mode 100644 internal/sync/local/reference_test.go delete mode 100644 internal/sync/reference/adapters/adapter.go delete mode 100644 internal/sync/reference/adapters/plainmarkdown/plainmarkdown.go delete mode 100644 internal/sync/reference/adapters/plainmarkdown/plainmarkdown_test.go delete mode 100644 internal/sync/reference/format.go delete mode 100644 internal/sync/reference/format_test.go 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/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/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..870752377 100644 --- a/internal/sync/manifest/baseline_test.go +++ b/internal/sync/manifest/baseline_test.go @@ -276,25 +276,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/model.go b/internal/sync/manifest/model.go index d3c64345d..b7b860ea8 100644 --- a/internal/sync/manifest/model.go +++ b/internal/sync/manifest/model.go @@ -30,9 +30,6 @@ type Resource struct { // 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. @@ -71,24 +68,6 @@ func (manifest *Manifest) Remove(id syncdomain.ResourceID) { } } -// 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 - } - } -} - -// Ref returns the recorded linked prompt file of a resource, or nil. -func (manifest Manifest) Ref(id syncdomain.ResourceID) *syncdomain.Reference { - if index := manifest.index(id); index >= 0 { - return manifest.Resources[index].Ref - } - return nil -} - // SetAttachments records the state of each tool and skill. Many variations // can share one attachment, but the manifest tracks it once. func (manifest *Manifest) SetAttachments(projectKey string, attachments []syncdomain.Attachment) error { diff --git a/internal/sync/prompt/acceptance_test.go b/internal/sync/prompt/acceptance_test.go index 16affe581..3da41cbe9 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") @@ -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) 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..5dcfb77ea 100644 --- a/internal/sync/resource.go +++ b/internal/sync/resource.go @@ -156,9 +156,7 @@ type SyncedResource struct { ProjectKey string 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 + Upsert bool Variation Variation }