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>
106 lines
2.9 KiB
Go
106 lines
2.9 KiB
Go
package plugins
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/kandev/kandev/internal/common/logger"
|
|
)
|
|
|
|
// AutoUpdateInterval is the cadence at which the auto-update poller re-checks
|
|
// the marketplace for newer versions of opted-in plugins. Plugin releases are
|
|
// infrequent, so this sits well above the integration health cadence (90s);
|
|
// 90 minutes is responsive enough to pick up a release within a couple of
|
|
// hours without hammering the marketplace sources.
|
|
const AutoUpdateInterval = 90 * time.Minute
|
|
|
|
// AutoUpdatePoller drives Service.RunAutoUpdatePass on a fixed cadence,
|
|
// following the goroutine-ownership shape the repo standardizes on
|
|
// (WaitGroup + context cancel + idempotent Start/Stop, mirroring
|
|
// internal/integrations/healthpoll). Unlike the per-workspace integration
|
|
// pollers, plugin installs are instance-global, so this poller has no
|
|
// per-workspace fan-out — it runs a single sweep per tick.
|
|
type AutoUpdatePoller struct {
|
|
svc *Service
|
|
log *logger.Logger
|
|
interval time.Duration
|
|
|
|
// mu guards started/cancel/wg against concurrent Start/Stop calls.
|
|
mu sync.Mutex
|
|
cancel context.CancelFunc
|
|
wg sync.WaitGroup
|
|
started bool
|
|
}
|
|
|
|
// NewAutoUpdatePoller returns a poller using the default 90-minute cadence.
|
|
func NewAutoUpdatePoller(svc *Service, log *logger.Logger) *AutoUpdatePoller {
|
|
return &AutoUpdatePoller{svc: svc, log: log, interval: AutoUpdateInterval}
|
|
}
|
|
|
|
// Start launches the background loop. Calling Start more than once without an
|
|
// intervening Stop is a no-op.
|
|
func (p *AutoUpdatePoller) Start(ctx context.Context) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
if p.started || p.svc == nil {
|
|
return
|
|
}
|
|
p.started = true
|
|
ctx, p.cancel = context.WithCancel(ctx)
|
|
p.wg.Add(1)
|
|
go p.loop(ctx)
|
|
p.log.Info("plugin auto-update poller started")
|
|
}
|
|
|
|
// Stop cancels the loop and waits for it to drain. Idempotent.
|
|
func (p *AutoUpdatePoller) Stop() {
|
|
p.mu.Lock()
|
|
if !p.started {
|
|
p.mu.Unlock()
|
|
return
|
|
}
|
|
cancel := p.cancel
|
|
p.mu.Unlock()
|
|
if cancel != nil {
|
|
cancel()
|
|
}
|
|
p.wg.Wait()
|
|
p.mu.Lock()
|
|
p.started = false
|
|
p.mu.Unlock()
|
|
p.log.Info("plugin auto-update poller stopped")
|
|
}
|
|
|
|
func (p *AutoUpdatePoller) loop(ctx context.Context) {
|
|
defer p.wg.Done()
|
|
// Run one sweep shortly after start so updates that landed while the
|
|
// backend was down are applied without waiting a full interval.
|
|
p.runOnce(ctx)
|
|
ticker := time.NewTicker(p.interval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
p.runOnce(ctx)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (p *AutoUpdatePoller) runOnce(ctx context.Context) {
|
|
outcome, err := p.svc.RunAutoUpdatePass(ctx)
|
|
if err != nil {
|
|
p.log.Warn("plugin auto-update pass failed", zap.Error(err))
|
|
return
|
|
}
|
|
if len(outcome.Updated) > 0 || len(outcome.Failed) > 0 {
|
|
p.log.Info("plugin auto-update pass complete",
|
|
zap.Int("updated", len(outcome.Updated)),
|
|
zap.Int("failed", len(outcome.Failed)))
|
|
}
|
|
}
|