fix: resolve plugin lifecycle dependencies deterministically

This commit is contained in:
2026-07-10 04:03:08 +08:00
parent 0f293640f9
commit a323710a6f
7 changed files with 247 additions and 92 deletions
+75
View File
@@ -0,0 +1,75 @@
package plugin
import (
"fmt"
"strings"
"github.com/verstak/verstak-desktop/internal/core/capability"
)
// ResolveLifecycle registers only viable plugin capabilities and assigns final
// lifecycle statuses after all required dependencies have had a chance to load.
func ResolveLifecycle(plugins []Plugin, registry *capability.Registry, isDisabled func(string) bool) {
if registry == nil {
for i := range plugins {
plugins[i].Status = StatusFailed
plugins[i].Error = "capability registry is unavailable"
}
return
}
pending := make(map[int]struct{}, len(plugins))
for i := range plugins {
p := &plugins[i]
p.Error = ""
if !p.Enabled || (isDisabled != nil && isDisabled(p.Manifest.ID)) {
p.Enabled = false
p.Status = StatusDisabled
continue
}
pending[i] = struct{}{}
}
for len(pending) > 0 {
progressed := false
for i := range plugins {
if _, ok := pending[i]; !ok {
continue
}
p := &plugins[i]
if len(registry.CheckRequired(p.Manifest.Requires)) > 0 {
continue
}
if err := registry.Register(p.Manifest.ID, p.Manifest.Provides); err != nil {
p.Status = StatusFailed
p.Error = err.Error()
} else {
p.Status = StatusLoaded
}
delete(pending, i)
progressed = true
}
if progressed {
continue
}
for i := range pending {
p := &plugins[i]
missing := registry.CheckRequired(p.Manifest.Requires)
p.Status = StatusMissingRequiredCapability
p.Error = fmt.Sprintf("missing required: %s", strings.Join(missing, ", "))
}
break
}
for i := range plugins {
p := &plugins[i]
if p.Status != StatusLoaded {
continue
}
if missing := registry.CheckRequired(p.Manifest.OptionalRequires); len(missing) > 0 {
p.Status = StatusDegraded
p.Error = fmt.Sprintf("missing optional: %s", strings.Join(missing, ", "))
}
}
}
+99
View File
@@ -336,3 +336,102 @@ func TestLifecycle_DisabledPlugin(t *testing.T) {
t.Errorf("expected plugin ID 'test.lifecycle.disabled', got %q", plugins[0].Manifest.ID)
}
}
func TestResolveLifecycleResolvesRequiredCapabilitiesRegardlessOfDiscoveryOrder(t *testing.T) {
reg := capability.NewRegistry()
plugins := []Plugin{
{
Manifest: Manifest{
ID: "consumer.plugin",
Provides: []string{"consumer.capability"},
Requires: []string{"provider.capability"},
Permissions: []string{"vault.read"},
},
Enabled: true,
},
{
Manifest: Manifest{
ID: "provider.plugin",
Provides: []string{"provider.capability"},
Permissions: []string{"vault.read"},
},
Enabled: true,
},
}
ResolveLifecycle(plugins, reg, nil)
if plugins[0].Status != StatusLoaded {
t.Fatalf("consumer status = %q, want %q; error=%q", plugins[0].Status, StatusLoaded, plugins[0].Error)
}
if plugins[1].Status != StatusLoaded {
t.Fatalf("provider status = %q, want %q; error=%q", plugins[1].Status, StatusLoaded, plugins[1].Error)
}
if !reg.Has("consumer.capability") || !reg.Has("provider.capability") {
t.Fatalf("resolved capabilities = %#v, want provider and consumer", reg.Available())
}
}
func TestResolveLifecycleDoesNotExposeCapabilitiesFromUnresolvedPlugin(t *testing.T) {
reg := capability.NewRegistry()
plugins := []Plugin{
{
Manifest: Manifest{
ID: "dependent.plugin",
Provides: []string{"dependent.capability"},
Requires: []string{"unresolved.capability"},
Permissions: []string{"vault.read"},
},
Enabled: true,
},
{
Manifest: Manifest{
ID: "unresolved.plugin",
Provides: []string{"unresolved.capability"},
Requires: []string{"missing.capability"},
Permissions: []string{"vault.read"},
},
Enabled: true,
},
}
ResolveLifecycle(plugins, reg, nil)
for _, p := range plugins {
if p.Status != StatusMissingRequiredCapability {
t.Fatalf("%s status = %q, want %q; error=%q", p.Manifest.ID, p.Status, StatusMissingRequiredCapability, p.Error)
}
}
if reg.Has("unresolved.capability") || reg.Has("dependent.capability") {
t.Fatalf("unresolved capabilities leaked into registry: %#v", reg.Available())
}
}
func TestResolveLifecycleEvaluatesOptionalCapabilitiesAfterProvidersLoad(t *testing.T) {
reg := capability.NewRegistry()
plugins := []Plugin{
{
Manifest: Manifest{
ID: "optional.consumer",
Provides: []string{"consumer.capability"},
OptionalRequires: []string{"optional.capability"},
Permissions: []string{"vault.read"},
},
Enabled: true,
},
{
Manifest: Manifest{
ID: "optional.provider",
Provides: []string{"optional.capability"},
Permissions: []string{"vault.read"},
},
Enabled: true,
},
}
ResolveLifecycle(plugins, reg, nil)
if plugins[0].Status != StatusLoaded {
t.Fatalf("optional consumer status = %q, want %q; error=%q", plugins[0].Status, StatusLoaded, plugins[0].Error)
}
}