Skip to content
Draft
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
2 changes: 1 addition & 1 deletion cmd/sync/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ func NewPromptCmd(client resources.Client) *cobra.Command {
cmd := &cobra.Command{
Use: "prompt",
Short: "Synchronize local prompt variations with LaunchDarkly",
Long: "Bootstrap local prompt variations from LaunchDarkly, add more variations, or synchronize changes using the committed local manifest. " +
Long: "Bootstrap local prompt variations from LaunchDarkly, add more variations, or synchronize changes using the LaunchDarkly manifest. " +
"Sync rechecks state before every write, rerun sync after a change.",
Args: func(cmd *cobra.Command, args []string) error {
if err := cobra.NoArgs(cmd, args); err != nil {
Expand Down
130 changes: 130 additions & 0 deletions internal/sync/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,41 @@ type VariationState struct {
ConfigMode syncdomain.VariationMode
}

// SyncManifest is the complete synchronization baseline for one project and source.
type SyncManifest struct {
Source string `json:"source"`
Items []SyncManifestResource `json:"items"`
}

// SyncManifestResource is one versioned baseline entry returned by LaunchDarkly.
type SyncManifestResource struct {
ResourceKind syncdomain.Kind `json:"resourceKind"`
ResourceLookupKey string `json:"resourceLookupKey"`
Fingerprint string `json:"fingerprint"`
Version int `json:"version"`
}

// SyncManifestUpsert creates or updates one baseline entry.
type SyncManifestUpsert struct {
ResourceKind syncdomain.Kind `json:"resourceKind"`
ResourceLookupKey string `json:"resourceLookupKey"`
Fingerprint string `json:"fingerprint"`
Version int `json:"version"`
}

// SyncManifestDeletion removes one baseline entry at its expected version.
type SyncManifestDeletion struct {
ResourceKind syncdomain.Kind `json:"resourceKind"`
ResourceLookupKey string `json:"resourceLookupKey"`
Version int `json:"version"`
}

type patchSyncManifestRequest struct {
Source string `json:"source"`
Upserts []SyncManifestUpsert `json:"upserts"`
Deletions []SyncManifestDeletion `json:"deletions"`
}

type createVariationRequest struct {
Key string `json:"key"`
Name string `json:"name"`
Expand Down Expand Up @@ -81,6 +116,82 @@ func NewClient(transport resources.Client, accessToken, baseURI string) Client {
}
}

// GetSyncManifest returns the synchronization baseline for one project and source.
func (client Client) GetSyncManifest(projectKey, source string) (SyncManifest, error) {
endpoint, err := client.syncManifestEndpoint(projectKey)
if err != nil {
return SyncManifest{}, err
}

response, err := client.transport.MakeRequest(
client.accessToken,
http.MethodGet,
endpoint,
"",
url.Values{"source": []string{source}},
nil,
false,
)
if err != nil {
return SyncManifest{}, fmt.Errorf("get sync manifest for project %q: %w", projectKey, err)
}

var manifest SyncManifest
if err := json.Unmarshal(response, &manifest); err != nil {
return SyncManifest{}, fmt.Errorf("decode sync manifest for project %q: %w", projectKey, err)
}
return manifest, nil
}

// PatchSyncManifest applies versioned baseline changes and returns the refreshed manifest.
func (client Client) PatchSyncManifest(
projectKey string,
source string,
upserts []SyncManifestUpsert,
deletions []SyncManifestDeletion,
) (SyncManifest, error) {
endpoint, err := client.syncManifestEndpoint(projectKey)
if err != nil {
return SyncManifest{}, err
}
if upserts == nil {
upserts = []SyncManifestUpsert{}
}
if deletions == nil {
deletions = []SyncManifestDeletion{}
}
body, err := json.Marshal(patchSyncManifestRequest{
Source: source, Upserts: upserts, Deletions: deletions,
})
if err != nil {
return SyncManifest{}, fmt.Errorf("encode sync manifest changes for project %q: %w", projectKey, err)
}

response, err := client.transport.MakeRequest(
client.accessToken,
http.MethodPatch,
endpoint,
"application/json",
nil,
body,
false,
)
if err != nil {
return SyncManifest{}, newResourceMutationError("update", "sync manifest for project", projectKey, err)
}

var manifest SyncManifest
if err := json.Unmarshal(response, &manifest); err != nil {
return SyncManifest{}, newResourceMutationError(
"decode updated",
"sync manifest for project",
projectKey,
err,
)
}
return manifest, nil
}

// ModelConfig returns the latest version of one model config.
func (client Client) ModelConfig(projectKey, modelConfigKey string) (ModelConfig, error) {
endpoint, err := url.JoinPath(client.baseURI, "api/v2/projects", projectKey, "ai-configs/model-configs", modelConfigKey)
Expand Down Expand Up @@ -274,6 +385,19 @@ func (client Client) variationEndpoint(projectKey, configKey string, path ...str
return endpoint, nil
}

func (client Client) syncManifestEndpoint(projectKey string) (string, error) {
endpoint, err := url.JoinPath(
client.baseURI,
"api/v2/projects",
projectKey,
"configs/sync/manifests",
)
if err != nil {
return "", fmt.Errorf("build sync manifest endpoint: %w", err)
}
return endpoint, nil
}

// newMutationError records whether a failed request received a definitive API
// response. Errors without a status code may represent a committed write whose
// response was lost, so the reconciliation layer verifies those with a read.
Expand Down Expand Up @@ -302,3 +426,9 @@ func responseStatusCode(err error) (int, bool) {
}
return 0, false
}

// IsConflict reports whether a wrapped LaunchDarkly API error has a 409 status.
func IsConflict(err error) bool {
status, ok := responseStatusCode(err)
return ok && status == http.StatusConflict
}
64 changes: 64 additions & 0 deletions internal/sync/api/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package api

import (
"errors"
"net/http"
"net/url"
"testing"

Expand Down Expand Up @@ -498,6 +499,69 @@ func TestClientArchiveVariationReturnsTransportError(t *testing.T) {
require.ErrorContains(t, err, `archive config variation "helpful": in use`)
}

func TestClientGetsSyncManifest(t *testing.T) {
transport := &recordingClient{Responses: [][]byte{[]byte(`{
"source": "git:example.com/acme/repo",
"items": [{
"resourceKind": "variation",
"resourceLookupKey": "support/helpful",
"fingerprint": "sha256:abc",
"version": 3
}]
}`)}}
client := NewClient(transport, "token", "https://example.com")

manifest, err := client.GetSyncManifest("project", "git:example.com/acme/repo")

require.NoError(t, err)
require.Len(t, manifest.Items, 1)
assert.Equal(t, 3, manifest.Items[0].Version)
require.Len(t, transport.Requests, 1)
request := transport.Requests[0]
assert.Equal(t, http.MethodGet, request.Method)
assert.Equal(t, "https://example.com/api/v2/projects/project/configs/sync/manifests", request.Path)
assert.Equal(t, "git:example.com/acme/repo", request.Query.Get("source"))
}

func TestClientPatchesSyncManifest(t *testing.T) {
transport := &recordingClient{Responses: [][]byte{[]byte(`{
"source": "git:example.com/acme/repo",
"items": []
}`)}}
client := NewClient(transport, "token", "https://example.com")

_, err := client.PatchSyncManifest(
"project",
"git:example.com/acme/repo",
[]SyncManifestUpsert{{
ResourceKind: syncdomain.KindVariation, ResourceLookupKey: "support/helpful",
Fingerprint: "sha256:abc", Version: 2,
}},
nil,
)

require.NoError(t, err)
require.Len(t, transport.Requests, 1)
request := transport.Requests[0]
assert.Equal(t, http.MethodPatch, request.Method)
assert.Equal(t, "application/json", request.ContentType)
assert.JSONEq(t, `{
"source": "git:example.com/acme/repo",
"upserts": [{
"resourceKind": "variation",
"resourceLookupKey": "support/helpful",
"fingerprint": "sha256:abc",
"version": 2
}],
"deletions": []
}`, string(request.Body))
}

func TestIsConflictRecognizesAPIStatus(t *testing.T) {
assert.True(t, IsConflict(errors.New(`{"code":"conflict","statusCode":409}`)))
assert.False(t, IsConflict(errors.New(`{"code":"invalid_request","statusCode":400}`)))
}

func testVariation(mode syncdomain.VariationMode) syncdomain.Variation {
return syncdomain.Variation{
Mode: mode,
Expand Down
18 changes: 12 additions & 6 deletions internal/sync/bootstrap/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ type AttachmentReader interface {

// ManifestStore persists the synchronization baseline after local files are written.
type ManifestStore interface {
Load() (syncmanifest.Manifest, bool, error)
Write(syncmanifest.Manifest) error
Load(projectKeys []string) (syncmanifest.Manifest, error)
Update(previous, next syncmanifest.Manifest) (syncmanifest.Manifest, error)
}

// Options contains the dependencies and streams for one bootstrap flow.
Expand Down Expand Up @@ -236,20 +236,26 @@ func finishSelection(options Options, files []synclocal.VariationFile) error {
return nil
}

manifest, _, err := options.Manifest.Load()
projectKeys := make([]string, 0, len(files))
for _, file := range files {
projectKeys = append(projectKeys, file.ProjectKey)
}
manifest, err := options.Manifest.Load(projectKeys)
if err != nil {
return err
}
updatedManifest := manifest
updatedManifest.Resources = append([]syncmanifest.Resource(nil), manifest.Resources...)
for _, file := range files {
lookupKey := file.ConfigKey + "/" + file.Variation.Key
fingerprint, err := syncdomain.FingerprintVariation(file.ProjectKey, lookupKey, file.Variation)
if err != nil {
return err
}
manifest.SetFingerprint(syncdomain.ResourceID{
updatedManifest.SetFingerprint(syncdomain.ResourceID{
Kind: syncdomain.KindVariation, ProjectKey: file.ProjectKey, LookupKey: lookupKey,
}, fingerprint)
if err := manifest.SetAttachmentsIfMissing(file.ProjectKey, file.Variation.Attachments); err != nil {
if err := updatedManifest.SetAttachmentsIfMissing(file.ProjectKey, file.Variation.Attachments); err != nil {
return err
}
}
Expand All @@ -266,7 +272,7 @@ func finishSelection(options Options, files []synclocal.VariationFile) error {
// The manifest is written last so it never claims a wrapper exists before
// that wrapper reaches disk. Roll back every file this operation created if
// persistence fails, including shared dependencies that did not exist before.
if err := options.Manifest.Write(manifest); err != nil {
if _, err := options.Manifest.Update(manifest, updatedManifest); err != nil {
return errors.Join(err, options.Store.RollbackCreation(creation))
}

Expand Down
36 changes: 26 additions & 10 deletions internal/sync/bootstrap/bootstrap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ Be helpful.
func TestFinishSelectionWritesInitialManifest(t *testing.T) {
root := t.TempDir()
var output bytes.Buffer
manifestStore := &memoryManifestStore{manifest: syncmanifest.New()}
variation := syncdomain.Variation{
Mode: syncdomain.VariationModeAgent, Key: "variation", Name: "Variation", Instructions: "Be helpful.",
Tools: []syncdomain.AttachmentRef{{Key: "search"}},
Expand All @@ -149,7 +150,7 @@ func TestFinishSelectionWritesInitialManifest(t *testing.T) {

err := finishSelection(Options{
Store: synclocal.NewStore(root),
Manifest: syncmanifest.NewStore(root),
Manifest: manifestStore,
Output: &output,
Initial: true,
}, []synclocal.VariationFile{
Expand All @@ -158,9 +159,8 @@ func TestFinishSelectionWritesInitialManifest(t *testing.T) {
})

require.NoError(t, err)
manifest, exists, err := syncmanifest.NewStore(root).Load()
manifest, err := manifestStore.Load([]string{"project"})
require.NoError(t, err)
require.True(t, exists)
require.Len(t, manifest.Resources, 3)
attachmentFingerprint, err := syncdomain.FingerprintAttachment("project", variation.Attachments[0])
require.NoError(t, err)
Expand All @@ -178,7 +178,7 @@ func TestFinishSelectionWritesInitialManifest(t *testing.T) {
func TestFinishSelectionAddsMultipleVersionedVariationsToExistingManifest(t *testing.T) {
root := t.TempDir()
store := synclocal.NewStore(root)
manifestStore := syncmanifest.NewStore(root)
manifestStore := &memoryManifestStore{manifest: syncmanifest.New()}
first := syncdomain.Variation{
Mode: syncdomain.VariationModeAgent, Key: "first", Name: "First", Instructions: "First.",
}
Expand All @@ -203,9 +203,8 @@ func TestFinishSelectionAddsMultipleVersionedVariationsToExistingManifest(t *tes
{ProjectKey: "project", ConfigKey: "config", Variation: third},
}))

manifest, exists, err := manifestStore.Load()
manifest, err := manifestStore.Load([]string{"project"})
require.NoError(t, err)
require.True(t, exists)
require.Len(t, manifest.Resources, 3)
assert.Equal(t, "config/first", manifest.Resources[0].LookupKey)
assert.Equal(t, "config/second", manifest.Resources[1].LookupKey)
Expand Down Expand Up @@ -330,10 +329,27 @@ func (*fakeCatalog) SearchConfigs(string, string, []syncdomain.VariationMode, in

type failingManifestStore struct{}

func (failingManifestStore) Load() (syncmanifest.Manifest, bool, error) {
return syncmanifest.New(), false, nil
func (failingManifestStore) Load([]string) (syncmanifest.Manifest, error) {
return syncmanifest.New(), nil
}

func (failingManifestStore) Write(syncmanifest.Manifest) error {
return errors.New("write manifest")
func (failingManifestStore) Update(syncmanifest.Manifest, syncmanifest.Manifest) (syncmanifest.Manifest, error) {
return syncmanifest.Manifest{}, errors.New("write manifest")
}

type memoryManifestStore struct {
manifest syncmanifest.Manifest
}

func (store *memoryManifestStore) Load([]string) (syncmanifest.Manifest, error) {
return store.manifest, nil
}

func (store *memoryManifestStore) Update(
_ syncmanifest.Manifest,
next syncmanifest.Manifest,
) (syncmanifest.Manifest, error) {
next.Sort()
store.manifest = next
return next, nil
}
Loading
Loading