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
11 changes: 11 additions & 0 deletions internal/sync/attachment.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,17 @@ func (attachment Attachment) ID(projectKey string) ResourceID {
return ResourceID{Kind: Kind(attachment.Kind), ProjectKey: projectKey, LookupKey: attachment.Key()}
}

// SyncedAttachment is one tool or skill file of the local workspace.
type SyncedAttachment struct {
ProjectKey string
Attachment Attachment
}

// ID returns the identity of the tool or the skill.
func (attachment SyncedAttachment) ID() ResourceID {
return attachment.Attachment.ID(attachment.ProjectKey)
}

// CanonicalAttachment removes the runtime version, local-only flags, and
// server-owned metadata. Two attachments with the same canonical form have
// the same content.
Expand Down
30 changes: 30 additions & 0 deletions internal/sync/local/attachment.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,36 @@ type OrphanedAttachment struct {
Path string
}

// CompileAttachments reads each tool and skill file that include accepts. It
// does not read the other files, so an invalid file outside include does not
// stop the read. Like CompileWorkspace, it rejects a symbolic link in a
// managed path.
func CompileAttachments(repositoryRoot string, include func(syncdomain.ResourceID) bool) ([]syncdomain.SyncedAttachment, error) {
root, err := filepath.EvalSymlinks(repositoryRoot)
if err != nil {
return nil, fmt.Errorf("resolve repository root: %w", err)
}
files, err := SourceFiles(root)
if err != nil {
return nil, err
}

fsys := workspaceFS{root: root}
var attachments []syncdomain.SyncedAttachment
for _, file := range files {
id, ok := ParseManagedPath(file)
if !ok || id.Kind == syncdomain.KindVariation || !include(id) {
continue
}
attachment, err := readAttachment(fsys, id.ProjectKey, syncdomain.AttachmentKind(id.Kind), id.LookupKey)
if err != nil {
return nil, ParseError{Path: file, Err: err}
}
attachments = append(attachments, syncdomain.SyncedAttachment{ProjectKey: id.ProjectKey, Attachment: attachment})
}
return attachments, nil
}

// readAttachment reads the file of one tool or skill.
func readAttachment(fsys fs.FS, projectKey string, kind syncdomain.AttachmentKind, key string) (syncdomain.Attachment, error) {
switch kind {
Expand Down
57 changes: 57 additions & 0 deletions internal/sync/local/attachment_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,63 @@ func TestOrphanedAttachmentsFindsFlatSkillFile(t *testing.T) {
require.ErrorIs(t, statErr, os.ErrNotExist)
}

func TestCompileAttachmentsReadsStandaloneAndReferencedFiles(t *testing.T) {
root := t.TempDir()
search := syncdomain.Tool{Key: "search", Schema: map[string]any{"type": "object"}}
variation := syncdomain.Variation{
Mode: syncdomain.VariationModeAgent, Key: "default", Name: "Default", Instructions: "Help.",
Tools: []syncdomain.AttachmentRef{{Key: "search"}},
Attachments: []syncdomain.Attachment{{Kind: syncdomain.AttachmentTool, Tool: &search}},
}
_, err := NewStore(root).Add([]VariationFile{{ProjectKey: "project", ConfigKey: "config", Variation: variation}})
require.NoError(t, err)
writeManagedFile(t, root, "project/tools/refund.json", `{"formatVersion": 1, "upsert": true, "key": "refund", "schema": {}}`)
writeManagedFile(t, root, "project/skills/triage.md", "---\nkey: triage\ndescription: Triage\n---\n\nPick a queue.\n")
writeManagedFile(t, root, "other/tools/broken.json", "not json")

attachments, err := CompileAttachments(root, func(id syncdomain.ResourceID) bool { return id.ProjectKey == "project" })

require.NoError(t, err)
var ids []string
for _, attachment := range attachments {
ids = append(ids, attachment.ID().Label())
}
assert.Equal(t, []string{"skill project/triage", "tool project/refund", "tool project/search"}, ids)
assert.True(t, attachments[1].Attachment.Upsert)
}

func TestCompileAttachmentsReportsTheInvalidFile(t *testing.T) {
root := t.TempDir()
writeManagedFile(t, root, "project/tools/broken.json", "not json")

_, err := CompileAttachments(root, func(syncdomain.ResourceID) bool { return true })

var parseErr ParseError
require.ErrorAs(t, err, &parseErr)
assert.Equal(t, ".launchdarkly/project/tools/broken.json", parseErr.Path)
assert.ErrorContains(t, err, `parse tool "broken"`)
}

func TestCompileAttachmentsRejectsSymlink(t *testing.T) {
root := t.TempDir()
external := filepath.Join(root, "external.json")
require.NoError(t, os.WriteFile(external, []byte(`{"formatVersion":1,"key":"search","schema":{}}`), 0o644))
toolPath := filepath.Join(root, ".launchdarkly", "project", "tools", "search.json")
require.NoError(t, os.MkdirAll(filepath.Dir(toolPath), 0o755))
require.NoError(t, os.Symlink(external, toolPath))

_, err := CompileAttachments(root, func(syncdomain.ResourceID) bool { return true })

require.ErrorContains(t, err, "symbolic links are not supported")
}

func writeManagedFile(t *testing.T, root, relativePath, content string) {
t.Helper()
path := filepath.Join(root, syncdomain.RootDir, filepath.FromSlash(relativePath))
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
}

func TestCompileWorkspaceRejectsAttachmentSymlink(t *testing.T) {
root := t.TempDir()
wrapperPath := filepath.Join(root, ".launchdarkly", "project", "configs", "config", "default.prompt.md")
Expand Down
152 changes: 151 additions & 1 deletion internal/sync/prompt/acceptance_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package prompt_test

import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
Expand All @@ -24,6 +26,7 @@ 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"
syncprompt "github.com/launchdarkly/ldcli/internal/sync/prompt"
syncreference "github.com/launchdarkly/ldcli/internal/sync/reference"
)

Expand Down Expand Up @@ -112,7 +115,7 @@ func (api *directAPI) MakeRequest(
key := path[strings.LastIndex(path, "/")+1:]
skill, ok := api.skills[key]
if !ok {
return nil, fmt.Errorf("skill not found")
return nil, fmt.Errorf(`{"code":"not_found","message":"skill not found","statusCode":404}`)
}
if method == "PATCH" {
if err := json.Unmarshal(body, &skill.Skill); err != nil {
Expand Down Expand Up @@ -1123,6 +1126,153 @@ func readLock(t *testing.T, root string) syncmanifest.Manifest {
return lock
}

func TestPromptPlansToolAndSkillRowsByScope(t *testing.T) {
root, api := writeStandaloneAttachmentWorkspace(t)
tools := []string{"tool lookup update_manifest", "tool refund create_server", "tool search in_sync"}
skills := []string{"skill summarize in_sync", "skill triage update_manifest"}
variations := []string{"variation support/default in_sync"}

for scope, want := range map[syncdomain.Scope][]string{
syncdomain.ScopePrompts: variations,
syncdomain.ScopeTools: tools,
syncdomain.ScopeSkills: skills,
syncdomain.ScopeAll: slices.Concat(skills, tools, variations),
} {
t.Run(string(scope), func(t *testing.T) {
api.requests = nil

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

require.NoError(t, err)
assert.Equal(t, want, planRows(t, stdout))
requireOnlyReads(t, api.requests)
if scope == syncdomain.ScopeAll {
assert.Equal(t, 1, countRequests(api.requests, "GET", "/ai-tools/search"),
"the tool row and the variation share one read")
}
})
}
}

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})

require.NoError(t, err)
assert.Equal(t, []string{"tool lookup update_manifest"}, planRows(t, stdout))
assert.Equal(t, []string{
"GET https://example.test/api/v2/projects/production/configs/sync/manifests",
"GET https://example.test/api/v2/projects/production/ai-tools/lookup",
}, api.requests)

for _, test := range []struct {
filter syncdomain.Filter
want string
}{
{
syncdomain.Filter{Project: "production", Keys: []string{"serch"}},
"tool production/serch is not synced in this repository. " +
"To start syncing it, run ldcli sync tools add --project production --key serch.",
},
{
syncdomain.Filter{Project: "demo"},
`project "demo" has no synced resources in this repository. To start, run ldcli sync tools add --project demo.`,
},
Comment thread
ctawiah marked this conversation as resolved.
} {
api.requests = nil
_, err := runSyncAction(t, root, api, syncprompt.SyncAction{DryRun: true, Scope: syncdomain.ScopeTools, Filter: test.filter})
require.EqualError(t, err, test.want)
assert.Empty(t, api.requests)
}
}

// 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
// has, and "refund", which LaunchDarkly does not have and which has upsert.
func writeStandaloneAttachmentWorkspace(t *testing.T) (string, *directAPI) {
t.Helper()
root := initRepository(t)
search := syncdomain.Tool{Key: "search", Schema: map[string]any{"type": "object"}}
summarize := syncdomain.Skill{Key: "summarize", Description: "Summarize a ticket", Markdown: "Write three bullet points.\n"}
used := variation("Baseline")
used.Tools = []syncdomain.AttachmentRef{{Key: "search"}}
used.Skills = []syncdomain.AttachmentRef{{Key: "summarize"}}
used.Attachments = []syncdomain.Attachment{
toolAttachment(search, 0), {Kind: syncdomain.AttachmentSkill, Skill: &summarize},
}
writeVariation(t, root, used, true)
writeManifest(t, root, used)

lookup := syncdomain.Tool{Key: "lookup", Schema: map[string]any{"type": "object"}}
triage := syncdomain.Skill{Key: "triage", Name: "Triage", Description: "Triage a ticket", Markdown: "Pick a queue.\n"}
writeLocalFile(t, root, "tools/lookup.json", `{"formatVersion": 1, "key": "lookup", "schema": {"type": "object"}}`)
writeLocalFile(t, root, "tools/refund.json", `{"formatVersion": 1, "upsert": true, "key": "refund", "schema": {"type": "object"}}`)
writeLocalFile(t, root, "skills/triage.md", "---\nkey: triage\ndescription: Triage a ticket\n---\n\nPick a queue.\n")

server := used
server.Tools = []syncdomain.AttachmentRef{{Key: "search", Version: 1}}
server.Skills = []syncdomain.AttachmentRef{{Key: "summarize", Version: 1}}
server.Attachments = nil
summarizeInLaunchDarkly := summarize
summarizeInLaunchDarkly.Name = "Summarize"
return root, &directAPI{
variation: &server,
tools: map[string]versionedTool{"search": {Tool: search, Version: 1}, "lookup": {Tool: lookup, Version: 2}},
skills: map[string]versionedSkill{
"summarize": {Skill: summarizeInLaunchDarkly, Version: 1}, "triage": {Skill: triage, Version: 3},
},
}
}

func runSyncAction(t *testing.T, root string, api *directAPI, action syncprompt.SyncAction) (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,
})
return output.String(), err
}

// planRows returns "kind lookup-key action" for each row of a JSON plan.
func planRows(t *testing.T, plan string) []string {
t.Helper()
var output struct {
Resources []struct {
ResourceKind string `json:"resourceKind"`
LookupKey string `json:"lookupKey"`
Action string `json:"action"`
} `json:"resources"`
}
require.NoError(t, json.Unmarshal([]byte(plan), &output))
rows := make([]string, 0, len(output.Resources))
for _, resource := range output.Resources {
rows = append(rows, resource.ResourceKind+" "+resource.LookupKey+" "+resource.Action)
}
return rows
}

func countRequests(requests []string, method, pathSuffix string) int {
count := 0
for _, request := range requests {
if strings.HasPrefix(request, method+" ") && strings.HasSuffix(request, pathSuffix) {
count++
}
}
return count
}

func writeLocalFile(t *testing.T, root, projectPath, content string) {
t.Helper()
path := filepath.Join(root, syncdomain.RootDir, "production", filepath.FromSlash(projectPath))
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
}

func TestPromptRestoresTrackedMissingFileInsteadOfArchiving(t *testing.T) {
root := initRepository(t)
baseline := variation("Baseline")
Expand Down
6 changes: 4 additions & 2 deletions internal/sync/prompt/action.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@ type CommandAction interface {
}

// SyncAction reconciles the workspace once, or each time a watched file
// changes. Filter limits the sync to the resources that it names.
// changes. Scope is the resource kinds that the sync plans, and Filter limits
// the sync to the resources that it names.
type SyncAction struct {
Watch bool
DryRun bool
Scope syncdomain.Scope
Filter syncdomain.Filter
}

Expand Down Expand Up @@ -64,7 +66,7 @@ func (action SyncAction) validate() error {
if action.Watch && action.DryRun {
return errors.New("watch does not support --dry-run")
}
return action.Filter.Validate(syncdomain.KindVariation)
return action.Filter.Validate(action.Scope.KeyKind())
}

func (action AddAction) validate() error {
Expand Down
17 changes: 12 additions & 5 deletions internal/sync/prompt/conflict.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"slices"
"strings"

syncdomain "github.com/launchdarkly/ldcli/internal/sync"
syncconsole "github.com/launchdarkly/ldcli/internal/sync/console"
syncinteractive "github.com/launchdarkly/ldcli/internal/sync/interactive"
)
Expand Down Expand Up @@ -183,7 +184,10 @@ func writeConflict(output io.Writer, group conflictGroup) error {

// groupConflicts returns one group for each conflict. A group also has every
// other variation that shares a changed attachment with it, directly or
// through another variation in the group.
// through another variation in the group. An error row does not join a group.
// A tool or skill that LaunchDarkly deleted does not join either, because a
// local choice would create it again. checkResolvedAttachmentDeletes decides
// that row after the choice.
func groupConflicts(plan Plan) []conflictGroup {
var groups []conflictGroup
visited := make([]bool, len(plan.Resources))
Expand All @@ -195,9 +199,11 @@ func groupConflicts(plan Plan) []conflictGroup {
members := []int{start}
for next := 0; next < len(members); next++ {
for candidate := range plan.Resources {
row := plan.Resources[candidate]
if visited[candidate] ||
plan.Resources[candidate].Action == ActionError ||
!sharesChangedAttachment(plan.Resources[members[next]], plan.Resources[candidate]) {
row.Action == ActionError ||
(row.ID.Kind != syncdomain.KindVariation && row.Action == ActionDeleteLocal) ||
!sharesChangedAttachment(plan.Resources[members[next]], row) {
continue
}
visited[candidate] = true
Expand Down Expand Up @@ -306,20 +312,21 @@ func applyConflictResolutions(plan Plan, resolutions map[ResourceID]ConflictReso
resource.Action = resolvedConflictAction(*resource, resolution)
}
}
checkResolvedAttachmentDeletes(resolved.Resources)
return resolved
}

// resolvedConflictAction returns the action that makes the chosen side win.
func resolvedConflictAction(resource PlannedResource, resolution ConflictResolution) Action {
switch resolution {
case ConflictUseLaunchDarkly:
if resource.Server == nil {
if !resource.hasServer() {
return ActionDeleteLocal
}
return ActionUpdateLocal
case ConflictUseLocal:
// A conflict always has a local file, because a missing file is restored.
if resource.Server == nil {
if !resource.hasServer() {
return ActionCreateServer
}
return ActionUpdateServer
Expand Down
Loading
Loading