5db3e48262
* feat: add opt-in plugin auto-updater Automatically update installed plugins in the background, off by default and opt-in. Builds on the existing verified install pipeline — an auto-update is just an automatic reinstall of a newer catalog package_url. Two-tier toggle on Settings > Plugins: an instance-wide default (SQLite plugin_settings) plus a per-plugin override (tri-state auto_update on the plugin record, carried forward on upgrade). The AutoUpdatePoller (~90-min cadence + a boot sweep) upgrades only plugins that are currently active, opted in, and report update_available; disabled/errored plugins stay on their installed version. New endpoints: GET/PUT /api/plugins/settings and PUT /api/plugins/:id/auto-update. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(plugins): recheck auto-update eligibility at install and fix test mock Address PR #1903 review feedback: - Re-check each plugin's current status + effective opt-in immediately before InstallFromURL (eligibleForAutoUpdate), closing the TOCTOU window between the candidate snapshot / catalog fetch and the install so a plugin disabled or opted out mid-sweep is never reinstalled and reactivated. - Correct the marketplace spec to describe the real failure behavior (shared with manual updates) instead of promising a version rollback the install pipeline does not perform. - Fix the frontend CI failure: the plugins page test mocked the whole plugins-api module without the new settings/auto-update functions, so useAutoUpdateSettings threw at mount. - Add an aria-label to the per-plugin auto-update Switch and Reset control. - Add a use-auto-update-settings hook test (load, failure, optimistic setDefault, rollback) and an eligibleForAutoUpdate unit test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
137 lines
3.7 KiB
Go
137 lines
3.7 KiB
Go
package plugins
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"sync"
|
|
|
|
"github.com/kandev/kandev/internal/plugins/store"
|
|
)
|
|
|
|
// Registry is an in-memory, mutex-guarded index of installed plugins, keyed
|
|
// by plugin id. It is loaded from the filesystem store at startup (Load)
|
|
// and kept in sync as Service mutates installations, so read paths (List,
|
|
// Get) never hit disk.
|
|
//
|
|
// Get and List return copies of the stored *store.Record so callers cannot
|
|
// mutate registry state by holding onto a returned pointer; all writes go
|
|
// through Add / Remove / SetStatus / SetRestartCount.
|
|
type Registry struct {
|
|
mu sync.RWMutex
|
|
byID map[string]*store.Record
|
|
}
|
|
|
|
// NewRegistry returns an empty Registry. Call Load to populate it from a
|
|
// store.Store at startup.
|
|
func NewRegistry() *Registry {
|
|
return &Registry{byID: make(map[string]*store.Record)}
|
|
}
|
|
|
|
// Load replaces the registry's contents with every record currently
|
|
// persisted in s. Intended to be called once at startup (see Provide).
|
|
func (r *Registry) Load(s store.Store) error {
|
|
records, err := s.List()
|
|
if err != nil {
|
|
return fmt.Errorf("load plugin registry: %w", err)
|
|
}
|
|
|
|
byID := make(map[string]*store.Record, len(records))
|
|
for _, rec := range records {
|
|
byID[rec.ID] = rec
|
|
}
|
|
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.byID = byID
|
|
return nil
|
|
}
|
|
|
|
// Get returns a copy of the record for id, and whether it was found.
|
|
func (r *Registry) Get(id string) (*store.Record, bool) {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
rec, ok := r.byID[id]
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
clone := *rec
|
|
return &clone, true
|
|
}
|
|
|
|
// List returns a copy of every registered record, sorted by id for
|
|
// deterministic output.
|
|
func (r *Registry) List() []*store.Record {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
out := make([]*store.Record, 0, len(r.byID))
|
|
for _, rec := range r.byID {
|
|
clone := *rec
|
|
out = append(out, &clone)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
|
return out
|
|
}
|
|
|
|
// Add inserts or replaces the record for record.ID.
|
|
func (r *Registry) Add(record *store.Record) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
clone := *record
|
|
r.byID[record.ID] = &clone
|
|
}
|
|
|
|
// Remove deletes the record for id, if present. A no-op if id is unknown.
|
|
func (r *Registry) Remove(id string) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
delete(r.byID, id)
|
|
}
|
|
|
|
// SetStatus updates the in-memory status for id and returns a copy of the
|
|
// updated record. ok is false if id is not registered. Callers wanting FSM
|
|
// validation (Service.SetStatus) must check that before calling this — this
|
|
// method performs the raw mutation only.
|
|
func (r *Registry) SetStatus(id string, status Status) (*store.Record, bool) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
rec, ok := r.byID[id]
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
rec.Status = status
|
|
clone := *rec
|
|
return &clone, true
|
|
}
|
|
|
|
// SetAutoUpdate updates the in-memory per-plugin auto-update override for id
|
|
// and returns a copy of the updated record. ok is false if id is not
|
|
// registered. Called by Service.SetPluginAutoUpdate, which persists the copy
|
|
// to the store; v is nil to clear the override (inherit the global default).
|
|
func (r *Registry) SetAutoUpdate(id string, v *bool) (*store.Record, bool) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
rec, ok := r.byID[id]
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
rec.AutoUpdate = v
|
|
clone := *rec
|
|
return &clone, true
|
|
}
|
|
|
|
// SetRestartCount updates the in-memory restart count for id and returns a
|
|
// copy of the updated record. ok is false if id is not registered. Called
|
|
// by Service after a runtime.Manager-driven restart, to persist
|
|
// store.Record.RestartCount best-effort.
|
|
func (r *Registry) SetRestartCount(id string, count int) (*store.Record, bool) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
rec, ok := r.byID[id]
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
rec.RestartCount = count
|
|
clone := *rec
|
|
return &clone, true
|
|
}
|