diff --git a/cmd/sync/link.go b/cmd/sync/link.go index a81cd765b..daac914c2 100644 --- a/cmd/sync/link.go +++ b/cmd/sync/link.go @@ -21,6 +21,16 @@ var linkExamples = map[syncdomain.Kind]string{ --select 'agents[id="support"]' \ --map prompt=instructions \ --project production --config-key support --key default \ + --yes --no-input + + # Create the config and the variation when LaunchDarkly has none + ldcli sync prompts link \ + --file app/agents.py \ + --format python \ + --select 'agents[id="support"]' \ + --map prompt=instructions \ + --project production --config-key support --key default \ + --upsert --model-config-key gpt-5 \ --yes --no-input`, syncdomain.KindTool: ` # Link one TypeScript MCP tool ldcli sync tools link \ @@ -52,7 +62,7 @@ func newLinkCmd(client resources.Client, kind syncdomain.Kind) *cobra.Command { Short: fmt.Sprintf("Link a repository source to one %s", noun), Long: fmt.Sprintf("Link a repository source to one %s. "+ "Link reads only the named file, shows the descriptor and the sync plan, and writes the descriptor after you confirm. "+ - "It then syncs the %s.", noun, noun), + "It then syncs the %s. With --upsert, it creates the %s in LaunchDarkly when it does not exist, and the config of a variation too.", noun, noun, noun), Example: linkExamples[kind], Args: validatedArgs(cobra.NoArgs), RunE: runCommand(client, scope, func(cmd *cobra.Command, _ []string) (syncprompt.CommandAction, error) { @@ -64,12 +74,16 @@ func newLinkCmd(client resources.Client, kind syncdomain.Kind) *cobra.Command { if err != nil { return nil, err } + upsert, _ := cmd.Flags().GetBool(upsertFlag) return syncprompt.SourceLinkAction{ - ID: id, - File: mustString(cmd, fileFlag), - Format: mustString(cmd, formatFlag), - Select: mustString(cmd, selectFlag), - Map: fieldMap, + ID: id, + File: mustString(cmd, fileFlag), + Format: mustString(cmd, formatFlag), + Select: mustString(cmd, selectFlag), + Map: fieldMap, + Upsert: upsert, + ModelConfigKey: mustString(cmd, modelConfigKeyFlag), + Mode: syncdomain.VariationMode(mustString(cmd, modeFlag)), }, nil }), } @@ -78,6 +92,11 @@ func newLinkCmd(client resources.Client, kind syncdomain.Kind) *cobra.Command { cmd.Flags().String(formatFlag, "", "Source format, for example python, typescript, markdown, or json") cmd.Flags().String(selectFlag, "", "Source value to select, for example 'agents[id=\"support\"]'") cmd.Flags().StringArray(mapFlag, nil, "Mapped field in field=path form. Repeat for more fields.") + cmd.Flags().Bool(upsertFlag, false, fmt.Sprintf("Create the %s in LaunchDarkly when it does not exist", noun)) + if kind == syncdomain.KindVariation { + cmd.Flags().String(modelConfigKeyFlag, "", "Model config of a variation that --upsert creates. The create uses its latest version.") + cmd.Flags().String(modeFlag, "", "Mode of a config that --upsert creates: agent or completion. The default comes from the source.") + } _ = cmd.MarkFlagRequired(fileFlag) _ = cmd.MarkFlagRequired(formatFlag) addApplyFlags(cmd, scope, false) diff --git a/cmd/sync/prompt.go b/cmd/sync/prompt.go index 53a3a368c..82264850e 100644 --- a/cmd/sync/prompt.go +++ b/cmd/sync/prompt.go @@ -32,6 +32,7 @@ const ( formatFlag = "format" jsonSchemaFlag = "json-schema" keyFlag = "key" + modeFlag = "mode" modelConfigKeyFlag = "model-config-key" nameFlag = "name" noInputFlag = "no-input" @@ -39,6 +40,7 @@ const ( selectFlag = "select" mapFlag = "map" toFlag = "to" + upsertFlag = "upsert" yesFlag = "yes" ) diff --git a/cmd/sync/prompt_test.go b/cmd/sync/prompt_test.go index 43ffa5994..38ed4db5a 100644 --- a/cmd/sync/prompt_test.go +++ b/cmd/sync/prompt_test.go @@ -34,7 +34,13 @@ func TestPromptCommandUsesActionSubcommands(t *testing.T) { link, _, err := command.Find([]string{"link"}) require.NoError(t, err) assert.Nil(t, link.Flags().Lookup(toFlag)) - assert.Nil(t, link.Flags().Lookup(modelConfigKeyFlag)) + assert.NotNil(t, link.Flags().Lookup(upsertFlag)) + assert.NotNil(t, link.Flags().Lookup(modelConfigKeyFlag), "a link with --upsert can name the model config of the new variation") + assert.NotNil(t, link.Flags().Lookup(modeFlag)) + toolLink := newLinkCmd(nil, syncdomain.KindTool) + assert.NotNil(t, toolLink.Flags().Lookup(upsertFlag)) + assert.Nil(t, toolLink.Flags().Lookup(modelConfigKeyFlag), "a tool has no model config") + assert.Nil(t, toolLink.Flags().Lookup(modeFlag)) } func TestSourceLinkFlagsBuildTargetAndMappings(t *testing.T) { @@ -57,6 +63,21 @@ func TestSourceLinkFlagsBuildTargetAndMappings(t *testing.T) { assert.Equal(t, map[string]string{"prompt": "instructions"}, mappings) } +func TestSourceLinkFlagsCarryTheUpsertIntent(t *testing.T) { + command := newLinkCmd(nil, syncdomain.KindVariation) + require.NoError(t, command.ParseFlags([]string{ + "--file", "agents.py", "--format", "python", "--select", `agents[id="support"]`, + "--project", "production", "--config-key", "support", "--key", "default", + "--upsert", "--model-config-key", "gpt-5", "--mode", "completion", + })) + + upsert, err := command.Flags().GetBool(upsertFlag) + require.NoError(t, err) + assert.True(t, upsert) + assert.Equal(t, "gpt-5", mustString(command, modelConfigKeyFlag)) + assert.Equal(t, "completion", mustString(command, modeFlag)) +} + func TestSyncProblemRendersStableFields(t *testing.T) { problem := syncdomain.NewProblem( syncdomain.ErrorPathNotFound, diff --git a/cmd/sync/scope_test.go b/cmd/sync/scope_test.go index 954775d35..091f4c7fe 100644 --- a/cmd/sync/scope_test.go +++ b/cmd/sync/scope_test.go @@ -27,18 +27,18 @@ var syncCommandFlags = map[string][]string{ "sync prompts detach": {"archive", "config-key", "key", "no-input", "project", "yes"}, "sync prompts link": { "config-key", "conflict", "diff", "file", "format", "key", "map", - "no-input", "project", "resolve", "select", "yes", + "mode", "model-config-key", "no-input", "project", "resolve", "select", "upsert", "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 tools link": {"conflict", "diff", "file", "format", "key", "map", "no-input", "project", "select", "yes"}, + "sync tools link": {"conflict", "diff", "file", "format", "key", "map", "no-input", "project", "select", "upsert", "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 skills link": {"conflict", "diff", "file", "format", "key", "map", "no-input", "project", "select", "yes"}, + "sync skills link": {"conflict", "diff", "file", "format", "key", "map", "no-input", "project", "select", "upsert", "yes"}, } func TestSyncCommandsHaveExactFlags(t *testing.T) { @@ -72,7 +72,7 @@ func TestSyncCommandsReadSyncFlagsOnlyFromTheCommandLine(t *testing.T) { } for _, name := range []string{ "config-key", "conflict", "content", "description", "file", "format", "json-schema", "key", - "map", "model-config-key", "name", "resolve", "select", "to", + "map", "mode", "model-config-key", "name", "resolve", "select", "to", } { t.Setenv("LD_"+strings.ToUpper(strings.ReplaceAll(name, "-", "_")), "from-environment") } diff --git a/internal/sync/api/catalog.go b/internal/sync/api/catalog.go index 96e29803d..8f73a2a95 100644 --- a/internal/sync/api/catalog.go +++ b/internal/sync/api/catalog.go @@ -3,6 +3,7 @@ package api import ( "encoding/json" "fmt" + "net/http" "strconv" "strings" @@ -115,6 +116,21 @@ func (client Client) Config(projectKey, configKey string) (Config, error) { return config, nil } +// CreateConfig creates an AI Config with no variations. A sync creates one +// before the first variation of a linked source when the user asks for it. +func (client Client) CreateConfig(projectKey string, config Config) error { + request := mutation{ + method: http.MethodPost, action: "create", resource: "config", key: config.Key, + body: struct { + Key string `json:"key"` + Name string `json:"name"` + Mode syncdomain.VariationMode `json:"mode"` + }{Key: config.Key, Name: config.Name, Mode: config.Mode}, + } + _, err := client.mutate(request, projectPath(projectKey, "ai-configs")...) + return err +} + // ModelConfig returns the latest version of one model config. func (client Client) ModelConfig(projectKey, modelConfigKey string) (ModelConfig, error) { response, err := client.read( diff --git a/internal/sync/api/catalog_test.go b/internal/sync/api/catalog_test.go index 3e7964e7b..2ffa59c1d 100644 --- a/internal/sync/api/catalog_test.go +++ b/internal/sync/api/catalog_test.go @@ -3,6 +3,7 @@ package api import ( "encoding/json" "errors" + "net/http" "testing" "github.com/stretchr/testify/assert" @@ -322,3 +323,17 @@ func mustCatalogJSON(t *testing.T, value any) []byte { return data } + +func TestClientCreatesConfig(t *testing.T) { + transport := &recordingClient{Responses: [][]byte{[]byte(`{"key":"support","name":"Support","mode":"agent","variations":[]}`)}} + client := NewClient(transport, "token", "https://example.com") + + err := client.CreateConfig("project", Config{Key: "support", Name: "Support", Mode: syncdomain.VariationModeAgent}) + + require.NoError(t, err) + require.Len(t, transport.Requests, 1) + request := transport.Requests[0] + assert.Equal(t, http.MethodPost, request.Method) + assert.Equal(t, "https://example.com/api/v2/projects/project/ai-configs", request.Path) + assert.JSONEq(t, `{"key":"support","name":"Support","mode":"agent"}`, string(request.Body)) +} diff --git a/internal/sync/prompt/acceptance_test.go b/internal/sync/prompt/acceptance_test.go index 7b6119bef..6e8d36745 100644 --- a/internal/sync/prompt/acceptance_test.go +++ b/internal/sync/prompt/acceptance_test.go @@ -34,13 +34,16 @@ type directAPI struct { variation *syncdomain.Variation variationState string // configMode is the mode of the config "support". The empty mode is agent. - configMode syncdomain.VariationMode - modelConfigs []syncapi.ModelConfig - tools map[string]versionedTool - skills map[string]versionedSkill - manifest *syncapi.SyncManifest - directions map[string]syncdomain.SyncDirection - requests []string + configMode syncdomain.VariationMode + // configMissing makes every read of the config "support" a 404 until a + // create of the config, as a project without the config does. + configMissing bool + modelConfigs []syncapi.ModelConfig + tools map[string]versionedTool + skills map[string]versionedSkill + manifest *syncapi.SyncManifest + directions map[string]syncdomain.SyncDirection + requests []string // expandTools makes each read of the variation add the content of its // tools to model.parameters.tools, as LaunchDarkly does. expandTools bool @@ -199,6 +202,24 @@ func (api *directAPI) MakeRequest( } return nil, fmt.Errorf("model config not found") } + if method == http.MethodPost && strings.HasSuffix(path, "/ai-configs") { + var request struct { + Key string `json:"key"` + Name string `json:"name"` + Mode syncdomain.VariationMode `json:"mode"` + } + if err := json.Unmarshal(body, &request); err != nil { + return nil, err + } + if !api.configMissing { + return nil, fmt.Errorf(`{"code":"conflict","message":"AI config already exists","statusCode":409}`) + } + api.configMissing, api.configMode = false, request.Mode + return json.Marshal(map[string]any{"key": request.Key, "name": request.Name, "mode": request.Mode, "variations": []any{}}) + } + if api.configMissing { + return nil, fmt.Errorf(`{"code":"not_found","message":"AI config not found","statusCode":404}`) + } switch method { case "GET": @@ -2051,6 +2072,135 @@ support = Agent(instructions="Server instructions after link.") `, string(actual)) } +// A link with --upsert creates the config and the variation that +// LaunchDarkly does not have, in one run, from the source alone. The config +// takes the mode that the source implies and the name of its key. +func TestLinkUpsertCreatesTheConfigAndTheVariation(t *testing.T) { + root := initRepository(t) + source := []byte("from agents import Agent\nsupport = Agent(instructions=\"Local instructions.\")\n") + require.NoError(t, os.WriteFile(filepath.Join(root, "agents.py"), source, 0o644)) + api := &directAPI{configMissing: true} + id := syncdomain.VariationID("production", "support", "default") + + output, _, err := runScoped(t, root, api, syncprompt.Options{ + Scope: syncdomain.ScopePrompts, + Action: syncprompt.SourceLinkAction{ + ID: id, File: "agents.py", Format: "python", Select: `agents[id="support"]`, + Map: map[string]string{"prompt": "instructions"}, Upsert: true, + }, + Yes: true, + }) + + require.NoError(t, err) + assert.Contains(t, output, `"action": "create_server"`) + assert.Equal(t, 1, countRequests(api.requests, "POST", "/ai-configs"), "the config is created once") + assert.False(t, api.configMissing) + assert.Equal(t, syncdomain.VariationModeAgent, api.configMode, "a text prompt makes an agent config") + require.NotNil(t, api.variation) + assert.Equal(t, "Local instructions.", api.variation.Instructions) + assert.Equal(t, "Default", api.variation.Name, "the name comes from the key") + require.FileExists(t, filepath.Join(root, ".launchdarkly", "production", "configs", "support", "default.ref.yaml")) + lock, err := os.ReadFile(filepath.Join(root, ".launchdarkly", "sync.lock")) + require.NoError(t, err) + assert.Contains(t, string(lock), "key: support/default", "the lock records the created variation") + actual, err := os.ReadFile(filepath.Join(root, "agents.py")) + require.NoError(t, err) + assert.Equal(t, source, actual, "the source is not changed") +} + +// --model-config-key gives the created variation a model config at its +// latest version, as an add does. An existing config is not created again. +func TestLinkUpsertCreatesTheVariationWithAModelConfig(t *testing.T) { + root := initRepository(t) + require.NoError(t, os.WriteFile(filepath.Join(root, "prompt.md"), []byte("Local instructions.\n"), 0o644)) + api := &directAPI{modelConfigs: []syncapi.ModelConfig{{Key: "gpt-5", Name: "GPT-5", Version: 3}}} + id := syncdomain.VariationID("production", "support", "default") + + output, _, err := runScoped(t, root, api, syncprompt.Options{ + Scope: syncdomain.ScopePrompts, + Action: syncprompt.SourceLinkAction{ + ID: id, File: "prompt.md", Format: "markdown", Upsert: true, ModelConfigKey: "gpt-5", + }, + Yes: true, + }) + + require.NoError(t, err) + assert.Contains(t, output, `"action": "create_server"`) + assert.Zero(t, countRequests(api.requests, "POST", "/ai-configs"), "the config exists") + require.NotNil(t, api.variation) + assert.Equal(t, "gpt-5", api.variation.ModelConfigKey) + assert.Equal(t, 3, api.variation.ModelConfigVersion) + assert.Equal(t, "Local instructions.", api.variation.Instructions) +} + +// --mode decides the mode of a config that --upsert creates. A text prompt +// becomes the one system message of a completion variation. +func TestLinkUpsertModeOverridesTheSourceMode(t *testing.T) { + root := initRepository(t) + require.NoError(t, os.WriteFile(filepath.Join(root, "prompt.md"), []byte("Local instructions.\n"), 0o644)) + api := &directAPI{configMissing: true} + id := syncdomain.VariationID("production", "support", "default") + + _, _, err := runScoped(t, root, api, syncprompt.Options{ + Scope: syncdomain.ScopePrompts, + Action: syncprompt.SourceLinkAction{ + ID: id, File: "prompt.md", Format: "markdown", Upsert: true, Mode: syncdomain.VariationModeCompletion, + }, + Yes: true, + }) + + require.NoError(t, err) + assert.Equal(t, syncdomain.VariationModeCompletion, api.configMode) + require.NotNil(t, api.variation) + assert.Equal(t, []syncdomain.Message{{Role: syncdomain.RoleSystem, Content: "Local instructions."}}, api.variation.Messages) +} + +// Without --upsert, a missing config is a row of the plan that names the +// config and the flag, and it blocks the apply without a write. +func TestLinkWithoutUpsertNamesTheMissingConfig(t *testing.T) { + root := initRepository(t) + require.NoError(t, os.WriteFile(filepath.Join(root, "prompt.md"), []byte("Local instructions.\n"), 0o644)) + api := &directAPI{configMissing: true} + id := syncdomain.VariationID("production", "support", "default") + + _, _, err := runScoped(t, root, api, syncprompt.Options{ + Scope: syncdomain.ScopePrompts, + Action: syncprompt.SourceLinkAction{ID: id, File: "prompt.md", Format: "markdown"}, + Yes: true, + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), `config "support" does not exist in LaunchDarkly`) + assert.Contains(t, err.Error(), "--upsert") + assert.Zero(t, countRequests(api.requests, "POST", "/ai-configs")) + assert.True(t, api.configMissing) + assert.NoFileExists(t, filepath.Join(root, ".launchdarkly", "production", "configs", "support", "default.ref.yaml"), "a blocked link writes no descriptor") +} + +// A link of a tool with --upsert creates the tool that LaunchDarkly does not +// have, through the same publish as a managed tool file with upsert. +func TestLinkUpsertCreatesAMissingTool(t *testing.T) { + root := initRepository(t) + require.NoError(t, os.WriteFile(filepath.Join(root, "tool.json"), []byte(`{"description":"Local","schema":{"type":"object"}}`), 0o644)) + api := &directAPI{tools: map[string]versionedTool{}} + id := syncdomain.ResourceID{Kind: syncdomain.KindTool, ProjectKey: "production", LookupKey: "search"} + + output, _, err := runScoped(t, root, api, syncprompt.Options{ + Scope: syncdomain.ScopeTools, + Action: syncprompt.SourceLinkAction{ + ID: id, File: "tool.json", Format: "json", Select: "$", + Map: map[string]string{"description": "description", "schema": "schema"}, Upsert: true, + }, + Yes: true, + }) + + require.NoError(t, err) + assert.Contains(t, output, `"action": "create_server"`) + require.Contains(t, api.tools, "search") + assert.Equal(t, "Local", *api.tools["search"].Description) + require.FileExists(t, filepath.Join(root, ".launchdarkly", "production", "tools", "search.ref.yaml")) +} + func TestPromptDescriptorTopLevelToolSyncsEndToEnd(t *testing.T) { root := initRepository(t) require.NoError(t, os.WriteFile(filepath.Join(root, "prompt.md"), []byte("Local instructions.\n"), 0o644)) @@ -3489,8 +3639,7 @@ func TestVariationPushDoesNotCreateAServerCanonicalLinkedTool(t *testing.T) { require.Error(t, err, "the missing linked tool blocks the plan") assert.Equal(t, []string{"tool search error", "variation support/default update_server"}, planRows(t, stdout)) - assert.Contains(t, stdout, `tool \"search\" does not exist in LaunchDarkly. Create it in LaunchDarkly and run sync again.`) - assert.NotContains(t, stdout, "upsert", "a descriptor has no upsert, so the message does not ask for one") + assert.Contains(t, stdout, `tool \"search\" does not exist in LaunchDarkly. Run link again with --upsert to create it, or create it in LaunchDarkly and run sync again.`) _, _, err = runPrompt(t, root, api, "--yes") require.Error(t, err) assert.Empty(t, api.tools, "the push does not create the tool") diff --git a/internal/sync/prompt/action.go b/internal/sync/prompt/action.go index 49e8d027d..bfb631a1a 100644 --- a/internal/sync/prompt/action.go +++ b/internal/sync/prompt/action.go @@ -62,6 +62,15 @@ type SourceLinkAction struct { Format string Select string Map map[string]string + // Upsert creates the resource in LaunchDarkly when it does not exist, + // and the config of a variation when LaunchDarkly has none. + Upsert bool + // ModelConfigKey is the model config of a variation that Upsert + // creates. The create uses its latest version. + ModelConfigKey string + // Mode is the mode of a config that Upsert creates. Without it, the + // source decides: messages make a completion config, text an agent one. + Mode syncdomain.VariationMode } func (action SyncAction) validate(scope syncdomain.Scope) error { @@ -121,6 +130,18 @@ func (action SourceLinkAction) validate(scope syncdomain.Scope) error { return err } } + switch { + case action.ModelConfigKey != "" && action.ID.Kind != syncdomain.KindVariation: + return errors.New("--model-config-key applies to a prompt variation") + case action.Mode != "" && action.ID.Kind != syncdomain.KindVariation: + return errors.New("--mode applies to a prompt variation") + case action.Mode != "" && !action.Mode.Valid(): + return fmt.Errorf("--mode must be agent or completion, not %q", action.Mode) + case (action.ModelConfigKey != "" || action.Mode != "") && !action.Upsert: + return errors.New("--model-config-key and --mode need --upsert") + case action.ModelConfigKey != "" && syncdomain.ValidateKey(action.ModelConfigKey) != nil: + return fmt.Errorf("invalid --model-config-key %q", action.ModelConfigKey) + } _, err := syncadapter.NormalizeReference(action.ID, syncdomain.Reference{ File: action.File, Format: action.Format, Select: action.Select, Map: action.Map, }) diff --git a/internal/sync/prompt/direction_test.go b/internal/sync/prompt/direction_test.go index c345aa82e..2c4d8e3de 100644 --- a/internal/sync/prompt/direction_test.go +++ b/internal/sync/prompt/direction_test.go @@ -81,6 +81,22 @@ func TestDeletedLinkedResourceRequiresDetach(t *testing.T) { ) } +// A create of a linked resource has nothing in LaunchDarkly for a setting +// to govern yet, so a missing setting does not block it. +func TestCreateOfALinkedResourceNeedsNoSetting(t *testing.T) { + local := testVariation("local") + resources := []PlannedResource{{ + ID: testResourceID(), Action: ActionCreateServer, Upsert: true, Local: &local, + Source: &syncdomain.Reference{File: "agents.py", Format: "python"}, + }} + client := syncapi.NewClient(emptySettingsTransport{}, "token", "https://example.com") + + err := applySourceDirections(client, resources) + + require.NoError(t, err) + assert.Equal(t, ActionCreateServer, resources[0].Action) +} + func TestMissingToolSettingIsActionable(t *testing.T) { id := ResourceID{Kind: syncdomain.KindTool, ProjectKey: "project", LookupKey: "lookup"} resources := []PlannedResource{{ diff --git a/internal/sync/prompt/execute.go b/internal/sync/prompt/execute.go index a2b4d0a0d..cd29c04da 100644 --- a/internal/sync/prompt/execute.go +++ b/internal/sync/prompt/execute.go @@ -217,6 +217,20 @@ func executePlan( return outcomes, next, errors.Join(failures...) } +// createConfig creates the config of a variation that a create needs, with +// the name of its key and the mode of the variation. A config that another +// working copy created after the plan is kept, and the variation create that +// follows decides the rest. +func createConfig(client syncapi.Client, projectKey, configKey string, mode syncdomain.VariationMode) error { + err := client.CreateConfig(projectKey, syncapi.Config{ + Key: configKey, Name: syncdomain.DisplayName(configKey), Mode: mode, + }) + if err != nil && !syncapi.IsConflict(err) { + return err + } + return nil +} + // verifySourceGroup checks the local result of every member of a document // group, and returns the first mismatch with the resource that it belongs to. func verifySourceGroup(repositoryRoot string, resources []PlannedResource) error { @@ -702,6 +716,11 @@ func applyServerChange( if variation, err = attachments.resolveVariation(projectKey, *resource.Local); err != nil { return nil, err } + if resource.createsConfig { + if err := createConfig(client, projectKey, configKey, variation.Mode); err != nil { + return nil, err + } + } writeErr = client.CreateVariation(projectKey, configKey, variation) case ActionUpdateServer: local := *resource.Local diff --git a/internal/sync/prompt/execute_test.go b/internal/sync/prompt/execute_test.go index 1443012c9..4ab64c8fa 100644 --- a/internal/sync/prompt/execute_test.go +++ b/internal/sync/prompt/execute_test.go @@ -909,7 +909,7 @@ func TestLinkedPublishOfAMissingToolAsksToCreateOrDetach(t *testing.T) { _, linkedErr := newAttachmentResolver(client).publish("project", linked, false) _, managedErr := newAttachmentResolver(client).publish("project", managed, false) - require.EqualError(t, linkedErr, `tool "search" does not exist in LaunchDarkly. Create it in LaunchDarkly and run sync again. `+ + require.EqualError(t, linkedErr, `tool "search" does not exist in LaunchDarkly. Run link again with --upsert to create it, or create it in LaunchDarkly and run sync again. `+ "To stop syncing it, run ldcli sync tools detach --project project --key search.") require.EqualError(t, managedErr, `tool "lookup" does not exist in LaunchDarkly. `+ `To create it, add "upsert": true to its file and run ldcli sync tools --project project --key lookup.`) diff --git a/internal/sync/prompt/output.go b/internal/sync/prompt/output.go index 892329354..5f1627faf 100644 --- a/internal/sync/prompt/output.go +++ b/internal/sync/prompt/output.go @@ -428,6 +428,9 @@ func planReason(resource PlannedResource) string { switch { case resource.Action == ActionCreateServer && resource.LocalAttachment != nil && resource.LocalAttachment.Skill != nil: return fmt.Sprintf("new, name %q", resource.LocalAttachment.Skill.CreateName()) + case resource.Action == ActionCreateServer && resource.createsConfig: + configKey, _, _ := resource.ID.VariationKeys() + return fmt.Sprintf("new, creates config %q", configKey) case resource.Action == ActionCreateServer: return "new" case repinsOnly(resource): diff --git a/internal/sync/prompt/plan.go b/internal/sync/prompt/plan.go index 33219d0ee..3c3e19bea 100644 --- a/internal/sync/prompt/plan.go +++ b/internal/sync/prompt/plan.go @@ -45,6 +45,9 @@ type ResourceID = syncdomain.ResourceID type ServerResource struct { Variation *syncdomain.Variation ConfigMode syncdomain.VariationMode + // ConfigMissing is true when LaunchDarkly has no config for the + // variation. ConfigMode is then the mode that a create gives the config. + ConfigMissing bool } // PlannedResource is the local and the server state of one resource, and the @@ -95,6 +98,9 @@ type PlannedResource struct { // directionOverride is true when the LaunchDarkly sync setting of the // resource chose the side that wins, instead of the three-way comparison. directionOverride bool + // createsConfig is true when the create of the variation also creates + // its config, because LaunchDarkly has none. + createsConfig bool } func setProblem(resource *PlannedResource, problem *syncdomain.Problem) { @@ -257,6 +263,7 @@ func planResource( ) PlannedResource { resource := PlannedResource{ ID: id, BaselineFingerprint: baselineFingerprint, Server: server.Variation, ServerMode: server.ConfigMode, + createsConfig: server.ConfigMissing, } fail := func(message string) PlannedResource { resource.Action, resource.Error = ActionError, message @@ -335,16 +342,10 @@ func planResource( resource.Action = chooseAction(tracked, resource) if resource.Action == ActionError { - resource.Error = "variation does not exist in LaunchDarkly; set upsert: true to create it" - if resource.Source != nil { - // A descriptor has no upsert, so the recovery is to create the - // variation in LaunchDarkly or to detach it. - configKey, variationKey, _ := id.VariationKeys() - resource.Error = fmt.Sprintf( - "variation does not exist in LaunchDarkly. Create it in LaunchDarkly and run sync again. To stop syncing it, run ldcli sync prompts detach --project %s --config-key %s --key %s.", - id.ProjectKey, configKey, variationKey, - ) - } + resource.Error = missingVariationError(id, server.ConfigMissing, resource.Source != nil) + } + if resource.Action != ActionCreateServer { + resource.createsConfig = false } if resource.Action == ActionCreateServer { _, variationKey, _ := id.VariationKeys() @@ -417,6 +418,31 @@ func planAttachments( return resources } +// missingVariationError is the error of a local variation that LaunchDarkly +// does not have and that the plan cannot create. A managed file creates it +// with upsert. A descriptor has no upsert, so a link with --upsert creates +// it, and the config too when LaunchDarkly has none. +func missingVariationError(id ResourceID, configMissing, linked bool) string { + configKey, variationKey, _ := id.VariationKeys() + missing := "variation does not exist in LaunchDarkly" + create := "set upsert: true to create it" + if configMissing { + missing = fmt.Sprintf("config %q does not exist in LaunchDarkly", configKey) + create = "set upsert: true in the variation file to create the config and the variation" + } + if !linked { + return missing + "; " + create + } + create = "Run link again with --upsert to create it, or create it in LaunchDarkly and run sync again." + if configMissing { + create = "Run link again with --upsert to create the config and the variation, or create them in LaunchDarkly and run sync again." + } + return fmt.Sprintf( + "%s. %s To stop syncing it, run ldcli sync prompts detach --project %s --config-key %s --key %s.", + missing, create, id.ProjectKey, configKey, variationKey, + ) +} + // baselineApplies reports whether a lock entry is a baseline for the local // side. A managed file and a source projection have fingerprints of // different schemas, so a lock entry without a projection schema says @@ -578,9 +604,9 @@ func planSyncedAttachment( func missingAttachmentError(id ResourceID, linked bool) string { missing := missingAttachment(id) if linked { - // A descriptor has no upsert, so the recovery is to create the - // resource in LaunchDarkly. - missing = fmt.Sprintf("%s %q does not exist in LaunchDarkly. Create it in LaunchDarkly and run sync again.", + // A descriptor has no upsert, so a link with --upsert creates the + // resource, or the user creates it in LaunchDarkly. + missing = fmt.Sprintf("%s %q does not exist in LaunchDarkly. Run link again with --upsert to create it, or create it in LaunchDarkly and run sync again.", id.Kind, id.LookupKey) } return fmt.Sprintf("%s To stop syncing it, run ldcli sync %ss detach --project %s --key %s.", diff --git a/internal/sync/prompt/runner.go b/internal/sync/prompt/runner.go index a6739a01b..c3b99904e 100644 --- a/internal/sync/prompt/runner.go +++ b/internal/sync/prompt/runner.go @@ -107,7 +107,17 @@ func (runner Runner) runSourceLink(options Options, workspace syncWorkspace, act if err != nil { return err } - return runner.runWorkspaceSyncWithSource(options, workspace, nil, &prepared) + intent := linkIntent{upsert: action.Upsert, mode: action.Mode} + if action.ModelConfigKey != "" { + // The create uses the latest version of the model config, as an add + // does. The read happens before the plan, so the plan shows it. + modelConfig, err := runner.api(options).ModelConfig(action.ID.ProjectKey, action.ModelConfigKey) + if err != nil { + return err + } + intent.modelConfig = &modelConfig + } + return runner.runWorkspaceSyncWithSource(options, workspace, nil, &prepared, intent) } // nothingToSync tells the user how to start a sync of every kind. diff --git a/internal/sync/prompt/state.go b/internal/sync/prompt/state.go index a6949969d..cba641172 100644 --- a/internal/sync/prompt/state.go +++ b/internal/sync/prompt/state.go @@ -41,6 +41,33 @@ func (workspace syncWorkspace) loadState( type sourceLink struct { id ResourceID proposal *synclocal.SourceDefinition + intent linkIntent +} + +// linkIntent is what a link asks for beyond the descriptor: the creation of +// a resource that LaunchDarkly does not have, with the model config and the +// mode of a new variation. The descriptor records none of it, so the intent +// applies to the compiled resource of the link on every load of a link run. +type linkIntent struct { + upsert bool + modelConfig *syncapi.ModelConfig + mode syncdomain.VariationMode +} + +// apply gives the compiled resource of the link the upsert and the model +// config of the intent. +func (intent linkIntent) apply(id ResourceID, variations []syncdomain.SyncedResource) { + for index := range variations { + if variations[index].ID() != id { + continue + } + variations[index].Upsert = variations[index].Upsert || intent.upsert + if intent.modelConfig != nil { + variations[index].Variation.ModelConfigKey = intent.modelConfig.Key + variations[index].Variation.ModelConfigVersion = intent.modelConfig.Version + variations[index].Variation.Model = intent.modelConfig.VariationModel() + } + } } func (workspace syncWorkspace) loadStateWithSource( @@ -103,6 +130,9 @@ func loadWorkspacePlan( if proposal != nil && proposal.Variation != nil { variations = append(variations, *proposal.Variation) } + if link != nil { + link.intent.apply(link.id, variations) + } // A tool that a variation uses can also have a row of its own. The cache // reads it from LaunchDarkly once. attachments := newAttachmentCache(client) @@ -111,14 +141,14 @@ func loadWorkspacePlan( var localFiles map[ResourceID]syncdomain.SyncedResource if scope.Includes(syncdomain.KindVariation) { var rows Plan - if rows, localFiles, err = planVariationRows(baseline, client, attachments, filter, variations); err != nil { + if rows, localFiles, err = planVariationRows(baseline, client, attachments, filter, variations, link); err != nil { return Plan{}, nil, err } plan.Resources = rows.Resources } if scope.Includes(syncdomain.KindTool) || scope.Includes(syncdomain.KindSkill) { include := func(id ResourceID) bool { return scope.Includes(id.Kind) && filter.Includes(id) } - rows, err := planAttachmentRows(repositoryRoot, baseline.Lock, attachments, include, proposal) + rows, err := planAttachmentRows(repositoryRoot, baseline.Lock, attachments, include, proposal, link) if err != nil { return Plan{}, nil, err } @@ -242,6 +272,11 @@ func applySourceDirections(client syncapi.Client, resources []PlannedResource) e continue } direction, ok := directions[resource.ID] + if !ok && resource.Action == ActionCreateServer && !resource.hasServer() { + // Nothing exists in LaunchDarkly yet for a setting to + // govern, so a create needs none. + continue + } if !ok { suggestion := "Configure the resource sync setting in LaunchDarkly, then run link again." if resource.ID.Kind != syncdomain.KindVariation { @@ -404,6 +439,7 @@ func planVariationRows( attachments *attachmentCache, filter syncdomain.Filter, variations []syncdomain.SyncedResource, + link *sourceLink, ) (Plan, map[ResourceID]syncdomain.SyncedResource, error) { localFiles := slices.DeleteFunc(slices.Clone(variations), func(resource syncdomain.SyncedResource) bool { return !filter.Includes(resource.ID()) @@ -435,6 +471,11 @@ func planVariationRows( if err != nil { return Plan{}, nil, err } + if resource.ConfigMissing { + // A config that a create makes takes the mode that the link + // asks for, else the mode that the local variation implies. + resource.ConfigMode = newConfigMode(id, localFilesByID[id], link) + } server[id] = resource } plan := BuildPlan(tracked, local, server) @@ -449,6 +490,7 @@ func planAttachmentRows( attachments *attachmentCache, include func(ResourceID) bool, proposal *synclocal.SourceDefinition, + link *sourceLink, ) ([]PlannedResource, error) { compiled := include if proposal != nil && proposal.Attachment != nil { @@ -464,6 +506,13 @@ func planAttachmentRows( if proposal != nil && proposal.Attachment != nil && include(proposal.Attachment.ID()) { local = append(local, *proposal.Attachment) } + // A link with --upsert creates the tool or skill that LaunchDarkly does + // not have. The descriptor has no upsert, so the intent applies here. + for index := range local { + if link != nil && link.intent.upsert && local[index].ID() == link.id { + local[index].Attachment.Upsert = true + } + } tracked := syncmanifest.New() for _, resource := range lock.Resources { if resource.ResourceKind != syncdomain.KindVariation && include(resource.ID()) { @@ -560,7 +609,7 @@ func planReferencedAttachments( !slices.ContainsFunc(resources, func(resource PlannedResource) bool { return resource.ID == id }) } - rows, err := planAttachmentRows(repositoryRoot, lock, attachments, referenced, proposal) + rows, err := planAttachmentRows(repositoryRoot, lock, attachments, referenced, proposal, nil) if err != nil { return nil, err } @@ -789,6 +838,20 @@ func references(variation *syncdomain.Variation, id ResourceID) bool { }) } +// newConfigMode returns the mode of a config that a create makes: the mode +// that the link asks for, else the mode of the local variation, which a +// source implies and a managed file states. Without a local variation the +// mode does not matter, because nothing is created. +func newConfigMode(id ResourceID, local syncdomain.SyncedResource, link *sourceLink) syncdomain.VariationMode { + if link != nil && link.id == id && link.intent.mode != "" { + return link.intent.mode + } + if local.Variation.Mode.Valid() { + return local.Variation.Mode + } + return syncdomain.VariationModeAgent +} + // 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) { @@ -797,6 +860,11 @@ func readServerResource(client syncapi.Client, attachments *attachmentCache, id return ServerResource{}, err } state, err := client.ReadVariation(id.ProjectKey, configKey, variationKey) + if syncapi.IsNotFound(err) { + // The config is gone or was never created. The row reports it, so + // that one such variation does not stop the plan of every other. + return ServerResource{ConfigMissing: true}, nil + } if err != nil { return ServerResource{}, err } diff --git a/internal/sync/prompt/sync.go b/internal/sync/prompt/sync.go index 35dba1016..a0eddc1c5 100644 --- a/internal/sync/prompt/sync.go +++ b/internal/sync/prompt/sync.go @@ -24,7 +24,7 @@ import ( // In watch mode, watcher is not nil. A file change during the review returns // errRefreshWatchPlan, so that the watch loop builds a new plan. func (runner Runner) runWorkspaceSync(options Options, workspace syncWorkspace, watcher *sourceWatcher) error { - return runner.runWorkspaceSyncWithSource(options, workspace, watcher, nil) + return runner.runWorkspaceSyncWithSource(options, workspace, watcher, nil, linkIntent{}) } func (runner Runner) runWorkspaceSyncWithSource( @@ -32,6 +32,7 @@ func (runner Runner) runWorkspaceSyncWithSource( workspace syncWorkspace, watcher *sourceWatcher, prepared *synclink.Prepared, + intent linkIntent, ) error { options = options.withDefaults() client := runner.api(options) @@ -48,7 +49,7 @@ func (runner Runner) runWorkspaceSyncWithSource( target := options.syncAction() var link *sourceLink if prepared != nil { - link = &sourceLink{id: prepared.ID, proposal: &prepared.Definition} + link = &sourceLink{id: prepared.ID, proposal: &prepared.Definition, intent: intent} } reviewed, err := workspace.loadStateWithSource(client, options.Scope, target.Filter, link) if err != nil { @@ -99,7 +100,7 @@ func (runner Runner) runWorkspaceSyncWithSource( // Read both sides again, so that no action uses state from before the // review. The descriptor exists now, so the link has no proposal. if link != nil { - link = &sourceLink{id: link.id} + link = &sourceLink{id: link.id, intent: intent} } current, err := workspace.loadStateWithSource(client, options.Scope, target.Filter, link) if err == nil {