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
38 changes: 25 additions & 13 deletions internal/sync/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,22 +27,26 @@ type VariationState struct {
}

type createVariationRequest struct {
Key string `json:"key"`
Name string `json:"name"`
Instructions string `json:"instructions,omitempty"`
ModelConfigKey string `json:"modelConfigKey,omitempty"`
ModelConfigVersion int `json:"modelConfigVersion,omitempty"`
Model map[string]any `json:"model,omitempty"`
Messages []syncdomain.Message `json:"messages,omitempty"`
Key string `json:"key"`
Name string `json:"name"`
Instructions string `json:"instructions,omitempty"`
ModelConfigKey string `json:"modelConfigKey,omitempty"`
ModelConfigVersion int `json:"modelConfigVersion,omitempty"`
Model map[string]any `json:"model,omitempty"`
Messages []syncdomain.Message `json:"messages,omitempty"`
Tools []syncdomain.AttachmentRef `json:"tools,omitempty"`
Skills []syncdomain.AttachmentRef `json:"skills,omitempty"`
}

type updateVariationRequest struct {
Name string `json:"name"`
Instructions *string `json:"instructions,omitempty"`
ModelConfigKey string `json:"modelConfigKey"`
ModelConfigVersion int `json:"modelConfigVersion,omitempty"`
Model map[string]any `json:"model"`
Messages *[]syncdomain.Message `json:"messages,omitempty"`
Name string `json:"name"`
Instructions *string `json:"instructions,omitempty"`
ModelConfigKey string `json:"modelConfigKey"`
ModelConfigVersion int `json:"modelConfigVersion,omitempty"`
Model map[string]any `json:"model"`
Messages *[]syncdomain.Message `json:"messages,omitempty"`
Tools *[]syncdomain.AttachmentRef `json:"tools,omitempty"`
Skills *[]syncdomain.AttachmentRef `json:"skills,omitempty"`
}

type mutationError struct {
Expand Down Expand Up @@ -133,6 +137,8 @@ func (client Client) CreateVariation(projectKey, configKey string, variation syn
ModelConfigKey: variation.ModelConfigKey,
ModelConfigVersion: variation.ModelConfigVersion,
Model: variation.Model,
Tools: variation.Tools,
Skills: variation.Skills,
}
if variation.Mode == syncdomain.VariationModeAgent {
request.Instructions = variation.Instructions
Expand Down Expand Up @@ -182,6 +188,12 @@ func (client Client) UpdateVariation(projectKey, configKey string, variation syn
ModelConfigVersion: variation.ModelConfigVersion,
Model: model,
}
if variation.Tools != nil {
request.Tools = &variation.Tools
}
if variation.Skills != nil {
request.Skills = &variation.Skills
}

// Agent and completion configs reject fields owned by the other mode, even
// when those fields are empty. Include only the prompt field for this mode.
Expand Down
135 changes: 96 additions & 39 deletions internal/sync/bootstrap/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package bootstrap

import (
"cmp"
"encoding/json"
"errors"
"fmt"
"io"
Expand All @@ -23,6 +24,10 @@ type Catalog interface {
SearchConfigs(projectKey, query string, modes []syncdomain.VariationMode, limit, offset int) (syncapi.Page[syncapi.Config], error)
}

type AttachmentReader interface {
ReadAttachment(projectKey string, kind syncdomain.AttachmentKind, key string) (syncdomain.Attachment, error)
}

// ManifestStore persists the synchronization baseline after local files are written.
type ManifestStore interface {
Load() (syncmanifest.Manifest, bool, error)
Expand All @@ -31,13 +36,14 @@ type ManifestStore interface {

// Options contains the dependencies and streams for one bootstrap flow.
type Options struct {
Catalog Catalog
Store synclocal.Store
Manifest ManifestStore
Input io.Reader
Output io.Writer
Initial bool
DryRun bool
Catalog Catalog
Attachments AttachmentReader
Store synclocal.Store
Manifest ManifestStore
Input io.Reader
Output io.Writer
Initial bool
DryRun bool
}

// Run interactively selects prompt variations and writes their local wrappers.
Expand Down Expand Up @@ -131,6 +137,9 @@ func selectVariationFiles(options Options) ([]synclocal.VariationFile, bool, err

files := make([]synclocal.VariationFile, 0, len(variations))
for _, variation := range variations {
if err := hydrateAttachments(options.Attachments, project.Key, &variation); err != nil {
return nil, false, err
}
files = append(files, synclocal.VariationFile{
ProjectKey: project.Key,
ConfigKey: config.Key,
Expand All @@ -141,6 +150,26 @@ func selectVariationFiles(options Options) ([]synclocal.VariationFile, bool, err
return files, false, nil
}

func hydrateAttachments(reader AttachmentReader, projectKey string, variation *syncdomain.Variation) error {
for index := range variation.Tools {
attachment, err := reader.ReadAttachment(projectKey, syncdomain.AttachmentTool, variation.Tools[index].Key)
if err != nil {
return err
}
variation.Tools[index].Version = attachment.Version
variation.SetAttachment(attachment)
}
for index := range variation.Skills {
attachment, err := reader.ReadAttachment(projectKey, syncdomain.AttachmentSkill, variation.Skills[index].Key)
if err != nil {
return err
}
variation.Skills[index].Version = attachment.Version
variation.SetAttachment(attachment)
}
return variation.NormalizeAttachments()
}

// variationChoices returns unsynchronized variations in stable display order
// and separately counts variations that already have local wrappers.
func variationChoices(
Expand Down Expand Up @@ -194,13 +223,13 @@ func finishSelection(options Options, files []synclocal.VariationFile) error {
writeNoChangeSummary(options.Output, options.DryRun)
return nil
}
for _, file := range files {
if err := syncdomain.ValidateDirectAPIVariation(file.Variation); err != nil {
return err
}
}
if options.DryRun {
previews, err := options.Store.RenderVariations(files)
for _, file := range files {
if err := syncdomain.ValidateDirectAPIVariation(file.Variation); err != nil {
return err
}
}
previews, err := options.Store.RenderResources(files)
if err != nil {
return err
}
Expand All @@ -212,49 +241,77 @@ func finishSelection(options Options, files []synclocal.VariationFile) error {
if err != nil {
return err
}
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{
Kind: syncdomain.KindVariation, ProjectKey: file.ProjectKey, LookupKey: lookupKey,
}, fingerprint)
}

var paths []string
var creation synclocal.Creation
if options.Initial {
paths, err = options.Store.Bootstrap(files)
creation, err = options.Store.BootstrapResources(files)
} else {
paths, err = options.Store.Add(files)
creation, err = options.Store.AddResources(files)
Comment thread
cursor[bot] marked this conversation as resolved.
}
if err != nil {
return err
}
if err := setManifestFromLocalResources(&manifest, options.Store, files); err != nil {
return errors.Join(err, options.Store.RollbackCreation(creation))
}
// The manifest is written last so it never claims a wrapper exists before
// that wrapper reaches disk. Roll back the wrappers if persistence fails.
// 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 {
return errors.Join(err, rollbackVariationFiles(options.Store, files))
return errors.Join(err, options.Store.RollbackCreation(creation))
}

writeSummary(options.Output, options.Initial, len(paths))
writeSummary(options.Output, options.Initial, len(creation.VariationPaths))

return nil
}

// rollbackVariationFiles removes wrappers created by a failed bootstrap or add.
func rollbackVariationFiles(store synclocal.Store, files []synclocal.VariationFile) error {
deletions := make([]synclocal.VariationDeletion, 0, len(files))
// setManifestFromLocalResources records the files that reached disk, including
// existing attachment files that creation preserved.
func setManifestFromLocalResources(
manifest *syncmanifest.Manifest,
store synclocal.Store,
files []synclocal.VariationFile,
) error {
selected := make(map[syncdomain.ResourceID]struct{}, len(files))
for _, file := range files {
deletions = append(deletions, synclocal.VariationDeletion{
ProjectKey: file.ProjectKey,
ConfigKey: file.ConfigKey,
VariationKey: file.Variation.Key,
})
selected[syncdomain.ResourceID{
Kind: syncdomain.KindVariation,
ProjectKey: file.ProjectKey,
LookupKey: file.ConfigKey + "/" + file.Variation.Key,
}] = struct{}{}
}
_, err := store.DeleteVariations(deletions)
return err

resources, err := store.Compile()
if err != nil {
return err
}
for _, resource := range resources {
id := syncdomain.ResourceID{
Kind: resource.Kind, ProjectKey: resource.ProjectKey, LookupKey: resource.LookupKey,
}
if _, ok := selected[id]; !ok {
continue
}
var variation syncdomain.Variation
if err := json.Unmarshal(resource.Payload, &variation); err != nil {
return fmt.Errorf("decode local variation %q: %w", resource.LookupKey, err)
}
variation.Attachments = resource.Attachments
fingerprint, err := syncdomain.FingerprintVariation(resource.ProjectKey, resource.LookupKey, variation)
if err != nil {
return err
}
manifest.SetFingerprint(id, fingerprint)
if err := manifest.SetAttachmentsIfMissing(resource.ProjectKey, resource.Attachments); err != nil {
return err
}
delete(selected, id)
}
for id := range selected {
return fmt.Errorf("created variation %s/%s was not found", id.ProjectKey, id.LookupKey)
}
return nil
}

// writeNoChangeSummary explains that every available variation is already local.
Expand Down
Loading
Loading