From 44c39e46632047889ed262283dfb90aaed47fbba Mon Sep 17 00:00:00 2001 From: Clifford Tawiah Date: Mon, 9 Nov 2026 16:03:21 -0500 Subject: [PATCH] feat(sync): prepare repository source links Add the link package. Prepare validates the target, the source path, the format, the mapping, and the compiled projection without a write. Write publishes the descriptor after the review. Co-Authored-By: Claude Fable 5.1 --- internal/sync/link/link.go | 115 ++++++++++++++++++ internal/sync/link/link_test.go | 199 ++++++++++++++++++++++++++++++++ 2 files changed, 314 insertions(+) create mode 100644 internal/sync/link/link.go create mode 100644 internal/sync/link/link_test.go diff --git a/internal/sync/link/link.go b/internal/sync/link/link.go new file mode 100644 index 000000000..02f56b2f3 --- /dev/null +++ b/internal/sync/link/link.go @@ -0,0 +1,115 @@ +// Package link validates and writes repository source descriptors. +package link + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + syncadapter "github.com/launchdarkly/ldcli/internal/sync/adapter" + synclocal "github.com/launchdarkly/ldcli/internal/sync/local" +) + +// Options are the explicit inputs for one repository source link. +type Options struct { + Store synclocal.Store + RepositoryRoot string + WorkingDirectory string + ID syncdomain.ResourceID + File string + Format string + Select string + Map map[string]string +} + +// Prepared is one validated descriptor and its compiled local resource. +type Prepared struct { + ID syncdomain.ResourceID + Reference syncdomain.Reference + Definition synclocal.SourceDefinition + Path string + Content []byte + // Previous is the descriptor that Prepare read, or nil when none existed. + Previous []byte + // written reports that Write published Content, so that Rollback has a + // write of this process to undo. + written bool +} + +// Prepare validates a repository source without writing its descriptor. +func Prepare(options Options) (Prepared, error) { + if err := options.Store.ValidateDescriptorTarget(options.ID); err != nil { + return Prepared{}, err + } + reference, err := synclocal.NewSourceReference( + options.RepositoryRoot, + options.WorkingDirectory, + options.File, + options.Format, + options.Select, + options.Map, + ) + if err != nil { + return Prepared{}, err + } + path, err := synclocal.ReferencePath(options.ID) + if err != nil { + return Prepared{}, err + } + previous, readErr := os.ReadFile(filepath.Join(options.RepositoryRoot, filepath.FromSlash(path))) + if readErr != nil && !errors.Is(readErr, os.ErrNotExist) { + return Prepared{}, readErr + } + if previous != nil { + // The link options set the file, the format, the selection, and the + // map. A relink keeps the tools and skills of the current descriptor, + // so that it does not read as a local removal of them. The one read + // serves the snapshot and the lists, so Write checks the bytes that + // the lists came from. + current, err := synclocal.ParseDescriptor(previous, options.ID) + if err != nil { + return Prepared{}, fmt.Errorf("read the current descriptor %s: %w", path, err) + } + reference.Tools, reference.Skills = current.Tools, current.Skills + } + reference, err = syncadapter.NormalizeReference(options.ID, reference) + if err != nil { + return Prepared{}, err + } + definition, err := synclocal.CompileSource(options.RepositoryRoot, options.ID, reference) + if err != nil { + return Prepared{}, err + } + content, err := synclocal.RenderDescriptor(options.ID, reference) + if err != nil { + return Prepared{}, err + } + return Prepared{ + ID: options.ID, Reference: reference, Definition: definition, Path: path, Content: content, + Previous: previous, + }, nil +} + +// Write atomically publishes the prepared descriptor. The descriptor must +// still be as Prepare read it, present or absent, so that a write of another +// process in the review window is not lost. +func (prepared *Prepared) Write(store synclocal.Store) (string, error) { + path, err := store.WriteDescriptorFrom(prepared.ID, prepared.Reference, prepared.Previous) + if err == nil { + prepared.written = true + } + return path, err +} + +// Rollback undoes a Write that succeeded: it restores the previous +// descriptor, or removes the new one. A descriptor that another process +// wrote stays, also when it has the same content, because a Write that was +// refused or did not run published nothing. +func (prepared *Prepared) Rollback(store synclocal.Store) error { + if !prepared.written { + return nil + } + return store.RestoreDescriptor(prepared.ID, prepared.Previous, prepared.Content) +} diff --git a/internal/sync/link/link_test.go b/internal/sync/link/link_test.go new file mode 100644 index 000000000..43c9aa74d --- /dev/null +++ b/internal/sync/link/link_test.go @@ -0,0 +1,199 @@ +package link + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + syncdomain "github.com/launchdarkly/ldcli/internal/sync" + synclocal "github.com/launchdarkly/ldcli/internal/sync/local" +) + +func TestPrepareDoesNotWriteUntilExplicitWrite(t *testing.T) { + root := t.TempDir() + source := []byte(`from agents import Agent +support = Agent(instructions="Help with orders.") +`) + require.NoError(t, os.WriteFile(filepath.Join(root, "agents.py"), source, 0o644)) + id := syncdomain.VariationID("production", "support", "default") + + prepared, err := Prepare(Options{ + Store: synclocal.NewStore(root), + RepositoryRoot: root, + WorkingDirectory: root, + ID: id, + File: "agents.py", + Format: "python", + Select: `agents[id="support"]`, + Map: map[string]string{"prompt": "instructions"}, + }) + + require.NoError(t, err) + require.Equal(t, ".launchdarkly/production/configs/support/default.ref.yaml", prepared.Path) + require.NoFileExists(t, filepath.Join(root, filepath.FromSlash(prepared.Path))) + path, err := prepared.Write(synclocal.NewStore(root)) + require.NoError(t, err) + descriptor, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(path))) + require.NoError(t, err) + require.Contains(t, string(descriptor), "formatVersion: 1") + require.Contains(t, string(descriptor), `select: agents[id="support"]`) + actual, err := os.ReadFile(filepath.Join(root, "agents.py")) + require.NoError(t, err) + require.Equal(t, source, actual) +} + +func TestWriteStopsWhenTheDescriptorChangedAfterPrepare(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "agents.py"), []byte(`from agents import Agent +support = Agent(instructions="Help with orders.") +`), 0o644)) + id := syncdomain.VariationID("production", "support", "default") + store := synclocal.NewStore(root) + options := Options{ + Store: store, RepositoryRoot: root, WorkingDirectory: root, ID: id, + File: "agents.py", Format: "python", Select: `agents[id="support"]`, + Map: map[string]string{"prompt": "instructions"}, + } + prepared, err := Prepare(options) + require.NoError(t, err) + // Another process writes the descriptor in the review window. + tools := []syncdomain.AttachmentRef{{Key: "search"}} + _, err = store.WriteDescriptor(id, syncdomain.Reference{ + File: "agents.py", Format: "python", Select: "agents[0]", + Map: map[string]string{"prompt": "instructions"}, Tools: &tools, + }) + require.NoError(t, err) + other, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(prepared.Path))) + require.NoError(t, err) + + _, err = prepared.Write(store) + + require.Error(t, err) + problem, ok := syncdomain.ProblemFrom(err) + require.True(t, ok) + require.Equal(t, syncdomain.ErrorStateChanged, problem.Code) + current, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(prepared.Path))) + require.NoError(t, err) + require.Equal(t, other, current, "the other write stays") + + // A new Prepare reads the current descriptor, so its Write proceeds. + prepared, err = Prepare(options) + require.NoError(t, err) + _, err = prepared.Write(store) + require.NoError(t, err) +} + +func TestRollbackLeavesADescriptorThatAnotherProcessWrote(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "agents.py"), []byte(`from agents import Agent +support = Agent(instructions="Help with orders.") +`), 0o644)) + id := syncdomain.VariationID("production", "support", "default") + store := synclocal.NewStore(root) + prepared, err := Prepare(Options{ + Store: store, RepositoryRoot: root, WorkingDirectory: root, ID: id, + File: "agents.py", Format: "python", Select: `agents[id="support"]`, + Map: map[string]string{"prompt": "instructions"}, + }) + require.NoError(t, err) + // Another process creates the descriptor in the review window. + _, err = store.WriteDescriptor(id, syncdomain.Reference{ + File: "agents.py", Format: "python", Select: "agents[0]", Map: map[string]string{"prompt": "instructions"}, + }) + require.NoError(t, err) + other, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(prepared.Path))) + require.NoError(t, err) + _, err = prepared.Write(store) + require.Error(t, err, "the write refuses the descriptor of the other process") + + require.NoError(t, prepared.Rollback(store)) + + current, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(prepared.Path))) + require.NoError(t, err) + require.Equal(t, other, current, "the rollback leaves the descriptor of the other process") +} + +func TestPrepareKeepsTheToolsAndSkillsOfTheCurrentDescriptor(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "agents.py"), []byte(`from agents import Agent +support = Agent(instructions="Help with orders.") +`), 0o644)) + id := syncdomain.VariationID("production", "support", "default") + store := synclocal.NewStore(root) + tools := []syncdomain.AttachmentRef{{Key: "search"}} + skills := []syncdomain.AttachmentRef{{Key: "handoff"}} + _, err := store.WriteDescriptor(id, syncdomain.Reference{ + File: "agents.py", Format: "python", Select: "agents[0]", + Map: map[string]string{"prompt": "instructions"}, Tools: &tools, Skills: &skills, + }) + require.NoError(t, err) + + prepared, err := Prepare(Options{ + Store: store, RepositoryRoot: root, WorkingDirectory: root, ID: id, + File: "agents.py", Format: "python", Select: `agents[id="support"]`, + Map: map[string]string{"prompt": "instructions"}, + }) + + require.NoError(t, err) + require.Equal(t, `agents[id="support"]`, prepared.Reference.Select, "the relink changes the selection") + require.Equal(t, &tools, prepared.Reference.Tools, "the relink keeps the tools") + require.Equal(t, &skills, prepared.Reference.Skills, "the relink keeps the skills") + require.Contains(t, string(prepared.Content), "key: search") + require.Contains(t, string(prepared.Content), "key: handoff") +} + +func TestRollbackAfterARefusedWriteLeavesAnIdenticalDescriptor(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "agents.py"), []byte(`from agents import Agent +support = Agent(instructions="Help with orders.") +`), 0o644)) + id := syncdomain.VariationID("production", "support", "default") + store := synclocal.NewStore(root) + options := Options{ + Store: store, RepositoryRoot: root, WorkingDirectory: root, ID: id, + File: "agents.py", Format: "python", Select: `agents[id="support"]`, + Map: map[string]string{"prompt": "instructions"}, + } + prepared, err := Prepare(options) + require.NoError(t, err) + // Another process links the same resource with the same options in the + // review window, so its descriptor has the same content. + other, err := Prepare(options) + require.NoError(t, err) + _, err = other.Write(store) + require.NoError(t, err) + _, err = prepared.Write(store) + require.Error(t, err, "the write refuses the descriptor of the other process") + + require.NoError(t, prepared.Rollback(store)) + + current, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(prepared.Path))) + require.NoError(t, err) + require.Equal(t, other.Content, current, "a rollback without a write of its own removes nothing") +} + +func TestRollbackWithoutAWriteLeavesTheDescriptor(t *testing.T) { + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "agents.py"), []byte(`from agents import Agent +support = Agent(instructions="Help with orders.") +`), 0o644)) + id := syncdomain.VariationID("production", "support", "default") + store := synclocal.NewStore(root) + options := Options{ + Store: store, RepositoryRoot: root, WorkingDirectory: root, ID: id, + File: "agents.py", Format: "python", Select: `agents[id="support"]`, + Map: map[string]string{"prompt": "instructions"}, + } + prepared, err := Prepare(options) + require.NoError(t, err) + other, err := Prepare(options) + require.NoError(t, err) + _, err = other.Write(store) + require.NoError(t, err) + + require.NoError(t, prepared.Rollback(store)) + + require.FileExists(t, filepath.Join(root, filepath.FromSlash(prepared.Path))) +}