Skip to content
Merged
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
13 changes: 5 additions & 8 deletions internal/sync/local/attachment.go
Original file line number Diff line number Diff line change
Expand Up @@ -347,17 +347,14 @@ func (store Store) OrphanedAttachments() ([]OrphanedAttachment, error) {
return orphaned, nil
}

// DeleteAttachments deletes tool and skill files in one transaction.
func (store Store) DeleteAttachments(attachments []OrphanedAttachment) ([]string, error) {
paths := make([]string, 0, len(attachments))
for _, attachment := range attachments {
relativePath, err := attachmentPath(attachment.ProjectKey, attachment.Kind, attachment.Key)
// DeleteAttachments deletes the files of tools and skills in one transaction.
func (store Store) DeleteAttachments(ids []syncdomain.ResourceID) ([]string, error) {
paths := make([]string, 0, len(ids))
for _, id := range ids {
relativePath, err := attachmentPath(id.ProjectKey, syncdomain.AttachmentKind(id.Kind), id.LookupKey)
if err != nil {
return nil, err
}
if relativePath != attachment.Path {
return nil, fmt.Errorf("attachment path %q does not match %q", attachment.Path, relativePath)
}
paths = append(paths, relativePath)
}
return store.deleteFiles(paths)
Expand Down
30 changes: 28 additions & 2 deletions internal/sync/local/attachment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ func TestOrphanedAttachmentsRequireEveryLocalReferenceToBeRemoved(t *testing.T)
ProjectKey: "project", Kind: syncdomain.AttachmentTool, Key: "search", Path: "project/tools/search.json",
}}, orphaned)

deleted, err := store.DeleteAttachments(orphaned)
deleted, err := store.DeleteAttachments([]syncdomain.ResourceID{{Kind: syncdomain.KindTool, ProjectKey: "project", LookupKey: "search"}})
require.NoError(t, err)
assert.Equal(t, []string{"project/tools/search.json"}, deleted)
_, statErr := os.Stat(filepath.Join(root, ".launchdarkly", "project", "tools", "search.json"))
Expand All @@ -298,7 +298,7 @@ func TestOrphanedAttachmentsFindsFlatSkillFile(t *testing.T) {
ProjectKey: "project", Kind: syncdomain.AttachmentSkill, Key: "support", Path: "project/skills/support.md",
}}, orphaned)

deleted, err := store.DeleteAttachments(orphaned)
deleted, err := store.DeleteAttachments([]syncdomain.ResourceID{{Kind: syncdomain.KindSkill, ProjectKey: "project", LookupKey: "support"}})
require.NoError(t, err)
assert.Equal(t, []string{"project/skills/support.md"}, deleted)
_, statErr := os.Stat(skillPath)
Expand Down Expand Up @@ -355,6 +355,32 @@ func TestCompileAttachmentsRejectsSymlink(t *testing.T) {
require.ErrorContains(t, err, "symbolic links are not supported")
}

func TestReplaceAttachmentsWritesStandaloneFilesAndKeepsLocalFields(t *testing.T) {
root := t.TempDir()
writeManagedFile(t, root, "project/tools/search.json", `{"formatVersion": 1, "upsert": true, "key": "search", "schema": {}}`)
description := "Search the docs"
search := syncdomain.Attachment{Kind: syncdomain.AttachmentTool, Version: 4, Tool: &syncdomain.Tool{
Key: "search", Description: &description, Schema: map[string]any{"type": "object"},
}}
triage := syncdomain.Attachment{Kind: syncdomain.AttachmentSkill, Skill: &syncdomain.Skill{
Key: "triage", Name: "Triage", Description: "Triage", Markdown: "Pick a queue.\n",
}}

written, err := NewStore(root).ReplaceAttachments([]syncdomain.SyncedAttachment{
{ProjectKey: "project", Attachment: search}, {ProjectKey: "project", Attachment: triage},
})

require.NoError(t, err)
assert.Equal(t, []string{"project/tools/search.json", "project/skills/triage.md"}, written)
attachments, err := CompileAttachments(root, func(syncdomain.ResourceID) bool { return true })
require.NoError(t, err)
require.Len(t, attachments, 2)
assert.Equal(t, "Pick a queue.\n", attachments[0].Attachment.Skill.Markdown)
assert.Empty(t, attachments[0].Attachment.Skill.Name, "the file does not get the LaunchDarkly name")
assert.Equal(t, "Search the docs", *attachments[1].Attachment.Tool.Description)
assert.True(t, attachments[1].Attachment.Upsert, "the file keeps its upsert flag")
}

func writeManagedFile(t *testing.T, root, relativePath, content string) {
t.Helper()
path := filepath.Join(root, syncdomain.RootDir, filepath.FromSlash(relativePath))
Expand Down
72 changes: 55 additions & 17 deletions internal/sync/local/replace.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"os"
"path/filepath"

syncdomain "github.com/launchdarkly/ldcli/internal/sync"
syncreference "github.com/launchdarkly/ldcli/internal/sync/reference"
)

Expand Down Expand Up @@ -37,27 +38,65 @@ func (store Store) ReplaceVariations(replacements []VariationReplacement) ([]str
if err != nil {
return nil, err
}
if err := stageFiles(files); err != nil {
if err := replaceFiles(files); err != nil {
return nil, err
}
defer removeStagedFiles(files)

if err := verifyOriginals(files); err != nil {
var paths []string
for _, file := range files {
if file.isVariationFile {
paths = append(paths, file.displayPath)
}
}
return paths, nil
}

// ReplaceAttachments writes the content of each tool and skill file in one
// transaction, like ReplaceVariations. A missing file is created, and an
// existing file keeps its local fields. It returns the paths that it wrote.
func (store Store) ReplaceAttachments(attachments []syncdomain.SyncedAttachment) ([]string, error) {
rendered := make([]RenderedFile, 0, len(attachments))
for _, attachment := range attachments {
filePath, err := attachmentPath(attachment.ProjectKey, attachment.Attachment.Kind, attachment.Attachment.Key())
if err != nil {
return nil, err
}
content, err := renderAttachment(attachment.Attachment)
if err != nil {
return nil, err
}
rendered = append(rendered, RenderedFile{Path: filePath, Content: content})
}
files, err := store.prepareAttachmentFiles(rendered)
if err != nil {
return nil, err
}
if err := commitFiles(files); err != nil {
if err := replaceFiles(files); err != nil {
return nil, err
}

var paths []string
paths := make([]string, 0, len(files))
for _, file := range files {
if file.isVariationFile {
paths = append(paths, file.displayPath)
}
paths = append(paths, file.displayPath)
}
return paths, nil
}

// replaceFiles writes the staged files in one transaction. It writes every
// file to a temporary path, makes sure that no original changed, and then
// renames each file into place.
func replaceFiles(files []stagedFile) error {
if err := stageFiles(files); err != nil {
return err
}
defer removeStagedFiles(files)

if err := verifyOriginals(files); err != nil {
return err
}
return commitFiles(files)
}

// ReplaceFileAtomically replaces the file at path only if its content is
// still original. displayPath names the file in errors.
func ReplaceFileAtomically(path, displayPath string, original, replacement []byte, mode os.FileMode) error {
Expand Down Expand Up @@ -136,7 +175,11 @@ func (store Store) prepareReplacements(replacements []VariationReplacement) ([]s
})
}

attachments, err := store.prepareAttachmentFiles(attachmentSources)
rendered, err := renderAttachmentFiles(attachmentSources)
if err != nil {
return nil, err
}
attachments, err := store.prepareAttachmentFiles(rendered)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -188,14 +231,9 @@ func (store Store) prepareLinkedFile(ref Reference, replacement VariationReplace
}, nil
}

// prepareAttachmentFiles renders each tool and skill file that changed. A new
// file is created. An existing file keeps its local fields.
func (store Store) prepareAttachmentFiles(files []VariationFile) ([]stagedFile, error) {
rendered, err := renderAttachmentFiles(files)
if err != nil {
return nil, err
}

// prepareAttachmentFiles stages each rendered tool and skill file that
// changed. A new file is created. An existing file keeps its local fields.
func (store Store) prepareAttachmentFiles(rendered []RenderedFile) ([]stagedFile, error) {
var staged []stagedFile
for _, file := range rendered {
path := store.absolute(file.Path)
Expand Down
104 changes: 98 additions & 6 deletions internal/sync/prompt/acceptance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -573,7 +573,14 @@ func TestPromptDeletesUnreferencedLocalToolWithYes(t *testing.T) {
assert.Contains(t, api.tools, "search")
_, err = os.Stat(filepath.Join(root, ".launchdarkly", "production", "tools", "search.json"))
require.ErrorIs(t, err, os.ErrNotExist)
assertManifestFingerprint(t, root, detached)
// A sync keeps the row of a tool that no variation uses. Only a detach
// removes it.
searchFingerprint, err := syncdomain.FingerprintAttachment("production", toolAttachment(tool, 2))
require.NoError(t, err)
assert.Equal(t, []syncmanifest.Resource{
{ResourceKind: syncdomain.KindTool, ProjectKey: "production", LookupKey: "search", Fingerprint: searchFingerprint},
{ResourceKind: syncdomain.KindVariation, ProjectKey: "production", LookupKey: "support/default", Fingerprint: fingerprint(t, detached)},
}, readLock(t, root).Resources)
}

func TestPromptUpdateResolvesOmittedModelConfigVersionToLatest(t *testing.T) {
Expand Down Expand Up @@ -1180,7 +1187,7 @@ func TestPromptPlansToolAndSkillRowsByScope(t *testing.T) {
t.Run(string(scope), func(t *testing.T) {
api.requests = nil

stdout, err := runSyncAction(t, root, api, syncprompt.SyncAction{DryRun: true, Scope: scope})
stdout, err := runSyncAction(t, root, api, syncprompt.SyncAction{DryRun: true, Scope: scope}, false)

require.NoError(t, err)
assert.Equal(t, want, planRows(t, stdout))
Expand All @@ -1197,7 +1204,7 @@ func TestPromptFiltersToolRowsBeforeAnyRead(t *testing.T) {
root, api := writeStandaloneAttachmentWorkspace(t)
filter := syncdomain.Filter{Project: "production", Keys: []string{"lookup"}}

stdout, err := runSyncAction(t, root, api, syncprompt.SyncAction{DryRun: true, Scope: syncdomain.ScopeTools, Filter: filter})
stdout, err := runSyncAction(t, root, api, syncprompt.SyncAction{DryRun: true, Scope: syncdomain.ScopeTools, Filter: filter}, false)

require.NoError(t, err)
assert.Equal(t, []string{"tool lookup update_manifest"}, planRows(t, stdout))
Expand All @@ -1221,12 +1228,95 @@ func TestPromptFiltersToolRowsBeforeAnyRead(t *testing.T) {
},
} {
api.requests = nil
_, err := runSyncAction(t, root, api, syncprompt.SyncAction{DryRun: true, Scope: syncdomain.ScopeTools, Filter: test.filter})
_, err := runSyncAction(t, root, api, syncprompt.SyncAction{DryRun: true, Scope: syncdomain.ScopeTools, Filter: test.filter}, false)
require.EqualError(t, err, test.want)
assert.Empty(t, api.requests)
}
}

func TestPromptSyncsStandaloneToolsAndSkills(t *testing.T) {
root, api := writeStandaloneAttachmentWorkspace(t)
writeLocalFile(t, root, "tools/search.json", `{"formatVersion": 1, "key": "search", "description": "Search", "schema": {"type": "object"}}`)
all := syncprompt.SyncAction{Scope: syncdomain.ScopeAll}

// One plan pushes the tool once, creates the new tool, records the rest,
// and then pins the new tool version in the variation.
stdout, err := runSyncAction(t, root, api, all, true)
require.NoError(t, err)
assert.Contains(t, stdout, `"lookupKey": "search",
"action": "update_server",
"status": "succeeded",
"version": 2`)
assert.Equal(t, 1, countRequests(api.requests, "PATCH", "/ai-tools/search"))
assert.Equal(t, 1, countRequests(api.requests, "POST", "/ai-tools"))
assert.Equal(t, 1, countRequests(api.requests, "PATCH", "/configs/sync/manifests"))
assert.Less(t, slices.Index(api.requests, "PATCH https://example.test/api/v2/projects/production/ai-tools/search"),
slices.Index(api.requests, "PATCH https://example.test/api/v2/projects/production/ai-configs/support/variations/default"))
assert.Equal(t, 1, api.tools["refund"].Version)
assert.Equal(t, []syncdomain.AttachmentRef{{Key: "search", Version: 2}}, api.variation.Tools)
assert.Len(t, readLock(t, root).Resources, 6, "the lock keeps every tool and skill, also the ones that no variation uses")

api.requests = nil
stdout, err = runSyncAction(t, root, api, syncprompt.SyncAction{DryRun: true, Scope: syncdomain.ScopeAll}, false)
require.NoError(t, err)
for _, row := range planRows(t, stdout) {
assert.True(t, strings.HasSuffix(row, " in_sync"), row)
}

// A change in LaunchDarkly pulls into the standalone file.
lookup := api.tools["lookup"]
lookup.Description = pointer("Look up an order")
lookup.Version++
api.tools["lookup"] = lookup
_, err = runSyncAction(t, root, api, syncprompt.SyncAction{Scope: syncdomain.ScopeTools}, true)
require.NoError(t, err)
content, err := os.ReadFile(filepath.Join(root, syncdomain.RootDir, "production", "tools", "lookup.json"))
require.NoError(t, err)
assert.Contains(t, string(content), "Look up an order")

// A hand-written skill file with upsert creates the skill.
writeLocalFile(t, root, "skills/handoff.md", "---\nkey: handoff\ndescription: Hand off\nname: Human handoff\nupsert: true\n---\n\n# Handoff\n")
_, err = runSyncAction(t, root, api, syncprompt.SyncAction{Scope: syncdomain.ScopeSkills}, true)
require.NoError(t, err)
assert.Equal(t, "Human handoff", api.skills["handoff"].Name)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude is calling out that we don't have a test to ensure we maintain the functionality of Skills also retaining LD as source of truth. It recommended adding this test coverage - like we have for tools. I agree.

Suggested change
// LaunchDarkly owns the skill name after the create. A rename there is not
// a change, and a push of a local edit does not send the local name.
handoff := api.skills["handoff"]
handoff.Name = "Renamed in LaunchDarkly"
api.skills["handoff"] = handoff
writeLocalFile(t, root, "skills/handoff.md",
"---\nkey: handoff\ndescription: Hand off to a person\nname: Human handoff\nupsert: true\n---\n\n# Handoff\n")
api.requests = nil
_, err = runSyncAction(t, root, api, syncprompt.SyncAction{Scope: syncdomain.ScopeSkills}, true)
require.NoError(t, err)
assert.Equal(t, 1, countRequests(api.requests, "PATCH", "/ai-configs/skills/handoff"))
assert.Equal(t, "Hand off to a person", api.skills["handoff"].Description)
assert.Equal(t, "Renamed in LaunchDarkly", api.skills["handoff"].Name, "the push must not send the local name")
content, err = os.ReadFile(filepath.Join(root, syncdomain.RootDir, "production", "skills", "handoff.md"))
require.NoError(t, err)
assert.Contains(t, string(content), "name: Human handoff\n", "the file keeps its local name")
assert.Contains(t, string(content), "upsert: true\n")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 926508a, with one extra dry-run check that the rename alone plans skill handoff in_sync. The new name assertion fails if the skill PATCH sends a name.

// LaunchDarkly owns the skill name after the create. A rename there is not
// a change, and a push of a local edit does not send the local name.
handoff := api.skills["handoff"]
handoff.Name = "Renamed in LaunchDarkly"
api.skills["handoff"] = handoff
stdout, err = runSyncAction(t, root, api, syncprompt.SyncAction{DryRun: true, Scope: syncdomain.ScopeSkills}, false)
require.NoError(t, err)
assert.Contains(t, planRows(t, stdout), "skill handoff in_sync", "a rename in LaunchDarkly is not a change")
writeLocalFile(t, root, "skills/handoff.md",
"---\nkey: handoff\ndescription: Hand off to a person\nname: Human handoff\nupsert: true\n---\n\n# Handoff\n")
api.requests = nil
_, err = runSyncAction(t, root, api, syncprompt.SyncAction{Scope: syncdomain.ScopeSkills}, true)
require.NoError(t, err)
assert.Equal(t, 1, countRequests(api.requests, "PATCH", "/ai-configs/skills/handoff"))
assert.Equal(t, "Hand off to a person", api.skills["handoff"].Description)
assert.Equal(t, "Renamed in LaunchDarkly", api.skills["handoff"].Name, "the push must not send the local name")
content, err = os.ReadFile(filepath.Join(root, syncdomain.RootDir, "production", "skills", "handoff.md"))
require.NoError(t, err)
assert.Contains(t, string(content), "name: Human handoff\n", "the file keeps its local name")
assert.Contains(t, string(content), "upsert: true\n")

// A deletion in LaunchDarkly deletes the standalone file and its baseline.
delete(api.tools, "lookup")
_, err = runSyncAction(t, root, api, syncprompt.SyncAction{Scope: syncdomain.ScopeTools}, true)
require.NoError(t, err)
_, err = os.Stat(filepath.Join(root, syncdomain.RootDir, "production", "tools", "lookup.json"))
assert.ErrorIs(t, err, os.ErrNotExist)
assert.NotContains(t, string(must(os.ReadFile(filepath.Join(root, syncdomain.RootDir, "sync.lock")))), "key: lookup")
}

func must[T any](value T, err error) T {
if err != nil {
panic(err)
}
return value
}

// writeStandaloneAttachmentWorkspace syncs a variation that uses the tool
// "search" and the skill "summarize". It also writes three tool and skill
// files that no variation uses: "lookup" and "triage", which LaunchDarkly
Expand Down Expand Up @@ -1266,13 +1356,15 @@ func writeStandaloneAttachmentWorkspace(t *testing.T) (string, *directAPI) {
}
}

func runSyncAction(t *testing.T, root string, api *directAPI, action syncprompt.SyncAction) (string, error) {
// runSyncAction runs one sync through Runner.Run, because no command selects
// a scope yet. It returns the JSON output.
func runSyncAction(t *testing.T, root string, api *directAPI, action syncprompt.SyncAction, yes bool) (string, error) {
t.Helper()
api.manifest = manifestsByRoot[root]
var output bytes.Buffer
err := syncprompt.NewRunner(api).Run(syncprompt.Options{
WorkingDirectory: root, AccessToken: "token", BaseURI: "https://example.test", OutputKind: "json",
Action: action, Input: strings.NewReader(""), Output: &output, ErrorOutput: io.Discard,
Action: action, Yes: yes, Input: strings.NewReader(""), Output: &output, ErrorOutput: io.Discard,
})
return output.String(), err
}
Expand Down
Loading
Loading