Files
kandev/apps/backend/internal/plugins/registry.go
T
João Salavisa 5db3e48262 feat: add opt-in plugin auto-updater (#1903)
* 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>
2026-07-24 13:04:54 +01:00

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
}