Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions internal/sync/api/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ type Config struct {
Name string `json:"name"`
Mode syncdomain.VariationMode `json:"mode"`
Variations []syncdomain.Variation `json:"variations"`
// ArchivedVariationKeys are the keys of the archived variations. A new
// variation cannot use one of them.
ArchivedVariationKeys []string `json:"-"`
}

// ModelConfig is a versioned set of model settings that a variation can use.
Expand Down Expand Up @@ -136,8 +139,9 @@ func (client Client) ModelConfigs(projectKey string) ([]ModelConfig, error) {
return decodeJSON[[]ModelConfig](response, "model configs response")
}

// UnmarshalJSON drops archived variations. LaunchDarkly returns them in a
// config, but sync treats an archived variation as absent.
// UnmarshalJSON drops archived variations and keeps only their keys.
// LaunchDarkly returns them in a config, but sync treats an archived
// variation as absent.
func (config *Config) UnmarshalJSON(data []byte) error {
var response struct {
Key string `json:"key"`
Expand All @@ -155,7 +159,9 @@ func (config *Config) UnmarshalJSON(data []byte) error {
*config = Config{Key: response.Key, Name: response.Name, Mode: response.Mode}
config.Variations = make([]syncdomain.Variation, 0, len(response.Variations))
for _, variation := range response.Variations {
if variation.State != "archived" {
if variation.State == "archived" {
config.ArchivedVariationKeys = append(config.ArchivedVariationKeys, variation.Key)
} else {
canonical := variation.Variation
canonical.Model = syncdomain.CanonicalModel(canonical.Model, canonical.Tools)
config.Variations = append(config.Variations, canonical)
Expand Down
14 changes: 14 additions & 0 deletions internal/sync/api/catalog_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,20 @@ func TestCatalogClientSearchProjectsFiltersAndReturnsOnePage(t *testing.T) {
assert.Equal(t, "20", request.Query.Get("offset"))
}

func TestCatalogClientConfigKeepsTheKeysOfArchivedVariations(t *testing.T) {
transport := &recordingClient{Responses: [][]byte{[]byte(`{"key": "support", "mode": "agent", "variations": [
{"key": "helpful", "name": "Helpful", "instructions": "Help the user."},
{"key": "escalation", "name": "Escalation", "state": "archived", "instructions": "Escalate."}
]}`)}}

config, err := NewClient(transport, "token", "https://example.com").Config("project", "support")

require.NoError(t, err)
require.Len(t, config.Variations, 1)
assert.Equal(t, "helpful", config.Variations[0].Key)
assert.Equal(t, []string{"escalation"}, config.ArchivedVariationKeys)
}

func TestCatalogClientSearchConfigsFiltersAndAppliesMode(t *testing.T) {
transport := &recordingClient{Responses: [][]byte{
mustCatalogJSON(t, Page[Config]{
Expand Down
27 changes: 18 additions & 9 deletions internal/sync/bootstrap/attachments.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,35 +100,44 @@ var createFlags = map[syncdomain.Kind]string{
syncdomain.KindSkill: "--content, and optionally --name and --description",
}

// SyncCommand is the command that syncs the resources of kind, for example
// "ldcli sync tools".
func SyncCommand(kind syncdomain.Kind) string {
if kind == syncdomain.KindVariation {
return "ldcli sync prompts"
}
return fmt.Sprintf("ldcli sync %ss", kind)
}

// AddCommand is the command that starts syncing one resource.
func AddCommand(id syncdomain.ResourceID) string {
if configKey, variationKey, err := id.VariationKeys(); err == nil {
return fmt.Sprintf("ldcli sync prompts add --project %s --config-key %s --key %s", id.ProjectKey, configKey, variationKey)
return fmt.Sprintf("%s add --project %s --config-key %s --key %s", SyncCommand(id.Kind), id.ProjectKey, configKey, variationKey)
}
return fmt.Sprintf("ldcli sync %ss add --project %s --key %s", id.Kind, id.ProjectKey, id.LookupKey)
return fmt.Sprintf("%s add --project %s --key %s", SyncCommand(id.Kind), id.ProjectKey, id.LookupKey)
}

// CheckNotSynced returns an error for a tool or skill that this repository
// already syncs, because it has a local file or a sync.lock entry. It reads
// only local files.
// CheckNotSynced returns an error for a resource that this repository already
// syncs, because it has a local file or a sync.lock entry. It reads only local
// files.
func CheckNotSynced(store synclocal.Store, ids []syncdomain.ResourceID) error {
lock, err := syncmanifest.ReadLock(store)
if err != nil {
return err
}
for _, id := range ids {
exists, err := store.AttachmentExists(id)
exists, err := store.ResourceExists(id)
switch {
case err != nil:
return err
case exists:
path, err := synclocal.AttachmentPath(id)
path, err := synclocal.ResourcePath(id)
if err != nil {
return err
}
return fmt.Errorf("%s is already synced. Edit %s and run ldcli sync %ss or ldcli sync all.", id.Label(), path, id.Kind)
return fmt.Errorf("%s is already synced. Edit %s and run %s or ldcli sync all.", id.Label(), path, SyncCommand(id.Kind))
case slices.ContainsFunc(lock.Resources, func(resource syncmanifest.Resource) bool { return resource.ID() == id }):
return fmt.Errorf("%s has a sync.lock entry but no local file. Run ldcli sync %ss or ldcli sync all to restore it.", id.Label(), id.Kind)
return fmt.Errorf("%s has a sync.lock entry but no local file. Run %s or ldcli sync all to restore it.", id.Label(), SyncCommand(id.Kind))
}
}
return nil
Expand Down
21 changes: 21 additions & 0 deletions internal/sync/bootstrap/attachments_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,27 @@ func TestRunRefusesAToolBeforeAnyWrite(t *testing.T) {
}
}

func TestCheckNotSyncedRefusesAVariationWithAFileOrALockEntry(t *testing.T) {
root := t.TempDir()
store := synclocal.NewStore(root)
id := syncdomain.VariationID("project", "support", "default")
variation := syncdomain.Variation{Mode: syncdomain.VariationModeAgent, Key: "default", Name: "Default", Instructions: "Be helpful."}
_, err := store.Add([]synclocal.VariationFile{{ProjectKey: "project", ConfigKey: "support", Variation: variation}})
require.NoError(t, err)

require.EqualError(t, CheckNotSynced(store, []syncdomain.ResourceID{id}), "variation project/support/default is already synced. "+
"Edit .launchdarkly/project/configs/support/default.prompt.md and run ldcli sync prompts or ldcli sync all.")

require.NoError(t, os.Remove(filepath.Join(root, syncdomain.RootDir, "project", "configs", "support", "default.prompt.md")))
fingerprint, err := syncdomain.FingerprintVariation(id.ProjectKey, id.LookupKey, variation)
require.NoError(t, err)
lock := syncmanifest.New()
lock.SetFingerprint(id, fingerprint)
require.NoError(t, syncmanifest.WriteLock(store, lock))
require.EqualError(t, CheckNotSynced(store, []syncdomain.ResourceID{id}),
"variation project/support/default has a sync.lock entry but no local file. Run ldcli sync prompts or ldcli sync all to restore it.")
}

func TestRunNamesTheCreateFlagsOfAMissingSkill(t *testing.T) {
id := syncdomain.ResourceID{Kind: syncdomain.KindSkill, ProjectKey: "project", LookupKey: "handoff"}

Expand Down
82 changes: 82 additions & 0 deletions internal/sync/create/prompt.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package create

import (
"fmt"
"slices"

syncdomain "github.com/launchdarkly/ldcli/internal/sync"
syncapi "github.com/launchdarkly/ldcli/internal/sync/api"
syncconsole "github.com/launchdarkly/ldcli/internal/sync/console"
syncinteractive "github.com/launchdarkly/ldcli/internal/sync/interactive"
)

// Catalog finds the project, the config, and the model config of a new
// variation.
type Catalog interface {
syncinteractive.ProjectSearcher
syncinteractive.ConfigSearcher
Config(projectKey, configKey string) (syncapi.Config, error)
ModelConfigs(projectKey string) ([]syncapi.ModelConfig, error)
}

// SelectConfig asks for the project of a new variation, unless projectKey is
// set, and for its config. modes keeps only the configs of those modes. The
// bool result is true when the user cancels.
func SelectConfig(
questions Questions,
catalog Catalog,
projectKey string,
modes []syncdomain.VariationMode,
) (syncapi.Project, syncapi.Config, bool, error) {
project := syncapi.Project{Key: projectKey}
if project.Key == "" {
var canceled bool
var err error
if project, canceled, err = syncinteractive.SelectProject(questions.Input, questions.Output, catalog); err != nil || canceled {
return syncapi.Project{}, syncapi.Config{}, canceled, err
}
}
config, canceled, err := syncinteractive.SelectConfig(questions.Input, questions.Output, catalog, project.Key, modes)
if syncapi.IsNotFound(err) && projectKey != "" {
// The config search is the first read in the project, so a 404 means
// that the project is missing.
return syncapi.Project{}, syncapi.Config{}, false, fmt.Errorf(
"project %q was not found in LaunchDarkly, or the access token cannot read it", projectKey,
)
}
if err != nil || canceled {
return syncapi.Project{}, syncapi.Config{}, canceled, err
}
return project, config, false, nil
}

// SelectModelConfig asks for the model config of a new variation. The bool
// result is true when the user cancels.
func SelectModelConfig(questions Questions, catalog Catalog, projectKey string) (syncapi.ModelConfig, bool, error) {
_ = syncconsole.New(questions.Output).Line("Loading model configs...")
modelConfigs, err := catalog.ModelConfigs(projectKey)
if err != nil {
return syncapi.ModelConfig{}, false, err
}
choices := make([]syncinteractive.Choice[syncapi.ModelConfig], 0, len(modelConfigs))
for _, modelConfig := range modelConfigs {
choices = append(choices, syncinteractive.Choice[syncapi.ModelConfig]{
Title: modelConfig.Name, Description: "Key: " + modelConfig.Key, Value: modelConfig,
})
}
return syncinteractive.Select(questions.Input, questions.Output, "Choose a model config", choices)
}

// FindModelConfig returns the model config with key in a project. The list
// has the latest version of each model config.
func FindModelConfig(catalog Catalog, projectKey, key string) (syncapi.ModelConfig, error) {
modelConfigs, err := catalog.ModelConfigs(projectKey)
if err != nil {
return syncapi.ModelConfig{}, err
}
index := slices.IndexFunc(modelConfigs, func(candidate syncapi.ModelConfig) bool { return candidate.Key == key })
if index < 0 {
return syncapi.ModelConfig{}, fmt.Errorf("model config %q was not found", key)
}
return modelConfigs[index], nil
}
60 changes: 60 additions & 0 deletions internal/sync/create/prompt_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package create

import (
"bytes"
"errors"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

syncdomain "github.com/launchdarkly/ldcli/internal/sync"
syncapi "github.com/launchdarkly/ldcli/internal/sync/api"
)

func TestFindModelConfigReturnsTheModelConfigWithTheKey(t *testing.T) {
catalog := &fakeCatalog{modelConfigs: []syncapi.ModelConfig{{Key: "gpt", Version: 3}, {Key: "claude", Version: 2}}}

modelConfig, err := FindModelConfig(catalog, "demo", "claude")
require.NoError(t, err)
assert.Equal(t, syncapi.ModelConfig{Key: "claude", Version: 2}, modelConfig)

_, err = FindModelConfig(catalog, "demo", "llama")
require.EqualError(t, err, `model config "llama" was not found`)
}

func TestSelectConfigSkipsTheProjectQuestionAndNamesAMissingProject(t *testing.T) {
catalog := &fakeCatalog{}
questions := Questions{Allowed: true, Input: &bytes.Buffer{}, Output: &bytes.Buffer{}}

_, _, _, err := SelectConfig(questions, catalog, "demo", nil)

require.EqualError(t, err, `project "demo" was not found in LaunchDarkly, or the access token cannot read it`)
assert.Equal(t, []string{"demo"}, catalog.configSearches)
}

// fakeCatalog has model configs. It fails a project search, and answers a
// config search with a 404, as LaunchDarkly does for a missing project.
type fakeCatalog struct {
modelConfigs []syncapi.ModelConfig
configSearches []string
}

func (*fakeCatalog) SearchProjects(string, int, int) (syncapi.Page[syncapi.Project], error) {
return syncapi.Page[syncapi.Project]{}, errors.New("asked for the project")
}

func (catalog *fakeCatalog) 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}`)
}

func (*fakeCatalog) Config(string, string) (syncapi.Config, error) {
return syncapi.Config{}, errors.New("read the config")
}

func (catalog *fakeCatalog) ModelConfigs(string) ([]syncapi.ModelConfig, error) {
return catalog.modelConfigs, nil
}
53 changes: 6 additions & 47 deletions internal/sync/link/link.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,25 +17,16 @@ import (

syncdomain "github.com/launchdarkly/ldcli/internal/sync"
syncapi "github.com/launchdarkly/ldcli/internal/sync/api"
syncconsole "github.com/launchdarkly/ldcli/internal/sync/console"
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"
)

// Catalog finds the project, the config, and the model config of a new variation.
type Catalog interface {
syncinteractive.ProjectSearcher
syncinteractive.ConfigSearcher
Config(projectKey, configKey string) (syncapi.Config, error)
ModelConfigs(projectKey string) ([]syncapi.ModelConfig, error)
}

// Options are the dependencies and the input of one link.
type Options struct {
Catalog Catalog
Catalog synccreate.Catalog
Store synclocal.Store
RepositoryRoot string
WorkingDirectory string
Expand Down Expand Up @@ -140,22 +131,16 @@ func targetSelection(options Options, prompt linkedPrompt, target Target) (Selec
if err != nil {
return Selection{}, "", err
}
modelConfigs, err := options.Catalog.ModelConfigs(projectKey)
modelConfig, err := synccreate.FindModelConfig(options.Catalog, projectKey, target.ModelConfigKey)
if err != nil {
return Selection{}, "", err
}
index := slices.IndexFunc(modelConfigs, func(candidate syncapi.ModelConfig) bool {
return candidate.Key == target.ModelConfigKey
})
if index < 0 {
return Selection{}, "", fmt.Errorf("model config %q was not found", target.ModelConfigKey)
}

name := cmp.Or(prompt.parsed.Name, target.Name, syncdomain.DisplayName(variationKey))
return Selection{
Project: syncapi.Project{Key: projectKey},
Config: config,
ModelConfig: modelConfigs[index],
ModelConfig: modelConfig,
Key: variationKey,
Name: name,
}, target.Content, nil
Expand All @@ -165,42 +150,16 @@ func targetSelection(options Options, prompt linkedPrompt, target Target) (Selec
// 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) {
project := syncapi.Project{Key: options.ProjectKey}
if project.Key == "" {
var canceled bool
var err error
if project, canceled, err = syncinteractive.SelectProject(options.Input, options.Output, options.Catalog); err != nil || canceled {
return Selection{}, "", canceled, err
}
}
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}
}
config, canceled, err := syncinteractive.SelectConfig(options.Input, options.Output, options.Catalog, project.Key, modes)
if syncapi.IsNotFound(err) && options.ProjectKey != "" {
// The config search is the first read in the project, so a 404 means
// that the project is missing.
return Selection{}, "", false, fmt.Errorf(
"project %q was not found in LaunchDarkly, or the access token cannot read it", options.ProjectKey,
)
}
project, config, canceled, err := synccreate.SelectConfig(questions, options.Catalog, options.ProjectKey, modes)
if err != nil || canceled {
return Selection{}, "", canceled, err
}

_ = syncconsole.New(options.Output).Line("Loading model configs...")
modelConfigs, err := options.Catalog.ModelConfigs(project.Key)
if err != nil {
return Selection{}, "", false, err
}
choices := make([]syncinteractive.Choice[syncapi.ModelConfig], 0, len(modelConfigs))
for _, modelConfig := range modelConfigs {
choices = append(choices, syncinteractive.Choice[syncapi.ModelConfig]{
Title: modelConfig.Name, Description: "Key: " + modelConfig.Key, Value: modelConfig,
})
}
modelConfig, canceled, err := syncinteractive.Select(options.Input, options.Output, "Choose a model config", choices)
modelConfig, canceled, err := synccreate.SelectModelConfig(questions, options.Catalog, project.Key)
if err != nil || canceled {
return Selection{}, "", canceled, err
}
Expand Down
9 changes: 5 additions & 4 deletions internal/sync/local/layout.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,11 @@ func ParseManagedPath(file string) (syncdomain.ResourceID, bool) {
return id, true
}

// AttachmentPath returns the path of a tool or skill file relative to the
// repository root, in the form that ParseManagedPath reads.
func AttachmentPath(id syncdomain.ResourceID) (string, error) {
relativePath, err := attachmentPath(id.ProjectKey, syncdomain.AttachmentKind(id.Kind), id.LookupKey)
// ResourcePath returns the path of the file of a variation, a tool, or a
// skill relative to the repository root, in the form that ParseManagedPath
// reads.
func ResourcePath(id syncdomain.ResourceID) (string, error) {
relativePath, err := resourcePath(id)
if err != nil {
return "", err
}
Expand Down
Loading
Loading