Skip to content
26 changes: 20 additions & 6 deletions docs/arch/14-plugins-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ different component subsets.
│ per-(scope,name,projectRoot) pluginLock │
└───────────────┬─────────────────────────────────────────────┘
│ MaterializationAdapter interface
│ (Materialize / Dematerialize / SupportedComponents /
│ ScopeSupport)
│ (Materialize / Dematerialize / EnsureRegistered /
Health / SupportedComponents / ScopeSupport)
┌────────┴────────┐
▼ ▼
┌─────────────┐ ┌──────────────┐
Expand Down Expand Up @@ -114,13 +114,27 @@ After resolving the artifact, `installWithExtraction` resolves target clients,
acquires the per-plugin lock, calls each client's `MaterializationAdapter`,
builds the `InstalledPlugin` record, persists it (Create/Update with
upgrade-digest/same-digest-new-clients branches), and registers the plugin in
its group. Failure rolls back already-materialized clients.
its group. When a `ClientManager` is configured, target trees are snapshotted
(files plus registration state via `Health`) before materialization; any later
failure — extraction, DB persist, group registration, or lock write — restores
the snapshot exactly (files, and `EnsureRegistered` only when the tree was
registered before) and joins every compensation error with the trigger.
Without a `ClientManager` (embedded/test services), compensation degrades to
dematerializing what this call wrote.

### 4. Uninstallation

`pluginsvc.Uninstall` calls `Dematerialize` per client (best-effort,
`errors.Join`), deletes the store record, and removes the plugin from all
groups. Idempotent.
`pluginsvc.Uninstall` is scope-dependent:

- **Unmanaged / user scope**: `Dematerialize` per client (best-effort,
`errors.Join`), remove group memberships, then delete the store record.
Group removal snapshots memberships first and restores them if an update
midway or the DB delete fails, keeping the operation retryable. Idempotent.
- **Lock-managed project scope**: fails closed. Every stored client must have
a materializer, the lock entry is removed after snapshotting client trees,
and a later dematerialize/group/DB failure restores the pin, the trees, and
adapter registration so the plugin is never left half-removed or
installed-but-untracked.

### 5. Info

Expand Down
50 changes: 24 additions & 26 deletions pkg/groups/plugins.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,49 +11,47 @@ import (

// AddPluginToGroup adds pluginName to the Plugins slice of the named group.
// Groups that do not exist return an error. Duplicate plugin names are skipped.
// Empty groupName is a no-op.
func AddPluginToGroup(ctx context.Context, mgr Manager, groupName string, pluginName string) error {
// Empty groupName is a no-op. The bool reports whether this call inserted the
// name (false when it was already a member or groupName is empty), so a later
// rollback can remove it only when this operation added it.
func AddPluginToGroup(ctx context.Context, mgr Manager, groupName string, pluginName string) (added bool, err error) {
if groupName == "" {
return nil
return false, nil
}
group, err := mgr.Get(ctx, groupName)
if err != nil {
return fmt.Errorf("getting group %q: %w", groupName, err)
return false, fmt.Errorf("getting group %q: %w", groupName, err)
}

if slices.Contains(group.Plugins, pluginName) {
return nil
return false, nil
}

group.Plugins = append(group.Plugins, pluginName)
if err := mgr.Update(ctx, group); err != nil {
return fmt.Errorf("updating group %q: %w", groupName, err)
return false, fmt.Errorf("updating group %q: %w", groupName, err)
}
return nil
return true, nil
}

// RemovePluginFromAllGroups removes pluginName from every group that references it.
// It is a no-op when the plugin is not found in any group.
func RemovePluginFromAllGroups(ctx context.Context, mgr Manager, pluginName string) error {
allGroups, err := mgr.List(ctx)
// RemovePluginFromGroup removes pluginName from the named group's Plugins
// slice. Missing membership is a no-op. Empty groupName is a no-op.
func RemovePluginFromGroup(ctx context.Context, mgr Manager, groupName string, pluginName string) error {
Comment thread
samuv marked this conversation as resolved.
if groupName == "" {
return nil
}
group, err := mgr.Get(ctx, groupName)
if err != nil {
return fmt.Errorf("listing groups: %w", err)
return fmt.Errorf("getting group %q: %w", groupName, err)
}

for _, group := range allGroups {
modified := false
for i, p := range group.Plugins {
if p == pluginName {
group.Plugins = append(group.Plugins[:i], group.Plugins[i+1:]...)
modified = true
break
}
}
if modified {
if err := mgr.Update(ctx, group); err != nil {
return fmt.Errorf("updating group %q: %w", group.Name, err)
}
}
idx := slices.Index(group.Plugins, pluginName)
if idx < 0 {
return nil
}
group.Plugins = slices.Delete(group.Plugins, idx, idx+1)
if err := mgr.Update(ctx, group); err != nil {
return fmt.Errorf("updating group %q: %w", groupName, err)
}
return nil
}
70 changes: 25 additions & 45 deletions pkg/groups/plugins_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,14 @@ func TestAddPluginToGroups(t *testing.T) {
groupName string
pluginName string
setupMock func(*groupmocks.MockManager)
wantAdded bool
wantErr string
}{
{
name: "adds plugin to one group",
groupName: "mygroup",
pluginName: "my-plugin",
wantAdded: true,
setupMock: func(m *groupmocks.MockManager) {
m.EXPECT().Get(gomock.Any(), "mygroup").
Return(&Group{Name: "mygroup", Plugins: []string{}}, nil)
Expand Down Expand Up @@ -85,87 +87,65 @@ func TestAddPluginToGroups(t *testing.T) {
mgr := groupmocks.NewMockManager(ctrl)
tt.setupMock(mgr)

err := AddPluginToGroup(context.Background(), mgr, tt.groupName, tt.pluginName)
added, err := AddPluginToGroup(context.Background(), mgr, tt.groupName, tt.pluginName)

if tt.wantErr != "" {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
assert.False(t, added)
} else {
require.NoError(t, err)
assert.Equal(t, tt.wantAdded, added)
}
})
}
}

func TestRemovePluginFromAllGroups(t *testing.T) {
func TestRemovePluginFromGroup(t *testing.T) {
t.Parallel()

tests := []struct {
name string
groupName string
pluginName string
setupMock func(*groupmocks.MockManager)
wantErr string
}{
{
name: "removes plugin from matching group",
name: "removes plugin from group",
groupName: "mygroup",
pluginName: "my-plugin",
setupMock: func(m *groupmocks.MockManager) {
m.EXPECT().List(gomock.Any()).Return([]*Group{
{Name: "mygroup", Plugins: []string{"my-plugin", "other"}},
}, nil)
m.EXPECT().Get(gomock.Any(), "mygroup").
Return(&Group{Name: "mygroup", Plugins: []string{"my-plugin", "other"}}, nil)
m.EXPECT().Update(gomock.Any(), &Group{Name: "mygroup", Plugins: []string{"other"}}).
Return(nil)
},
},
{
name: "no-op when plugin is not in any group",
pluginName: "absent-plugin",
setupMock: func(m *groupmocks.MockManager) {
m.EXPECT().List(gomock.Any()).Return([]*Group{
{Name: "mygroup", Plugins: []string{"some-other-plugin"}},
}, nil)
// No Update call expected.
},
},
{
name: "no-op when no groups exist",
pluginName: "my-plugin",
setupMock: func(m *groupmocks.MockManager) {
m.EXPECT().List(gomock.Any()).Return([]*Group{}, nil)
},
},
{
name: "removes plugin from multiple groups",
pluginName: "shared",
name: "no-op when plugin is not a member",
groupName: "mygroup",
pluginName: "absent",
setupMock: func(m *groupmocks.MockManager) {
m.EXPECT().List(gomock.Any()).Return([]*Group{
{Name: "group-a", Plugins: []string{"shared"}},
{Name: "group-b", Plugins: []string{"shared", "other"}},
}, nil)
m.EXPECT().Update(gomock.Any(), &Group{Name: "group-a", Plugins: []string{}}).
Return(nil)
m.EXPECT().Update(gomock.Any(), &Group{Name: "group-b", Plugins: []string{"other"}}).
Return(nil)
m.EXPECT().Get(gomock.Any(), "mygroup").
Return(&Group{Name: "mygroup", Plugins: []string{"other"}}, nil)
},
},
{
name: "returns error when List fails",
name: "no-op when group name is empty",
groupName: "",
pluginName: "my-plugin",
setupMock: func(m *groupmocks.MockManager) {
m.EXPECT().List(gomock.Any()).Return(nil, errors.New("store error"))
},
wantErr: "listing groups",
setupMock: func(_ *groupmocks.MockManager) {},
},
{
name: "returns error when Update fails",
name: "returns error when group not found",
groupName: "nonexistent",
pluginName: "my-plugin",
setupMock: func(m *groupmocks.MockManager) {
m.EXPECT().List(gomock.Any()).Return([]*Group{
{Name: "mygroup", Plugins: []string{"my-plugin"}},
}, nil)
m.EXPECT().Update(gomock.Any(), gomock.Any()).Return(errors.New("write error"))
m.EXPECT().Get(gomock.Any(), "nonexistent").
Return(nil, errors.New("group not found"))
},
wantErr: "updating group",
wantErr: "getting group",
},
}

Expand All @@ -177,7 +157,7 @@ func TestRemovePluginFromAllGroups(t *testing.T) {
mgr := groupmocks.NewMockManager(ctrl)
tt.setupMock(mgr)

err := RemovePluginFromAllGroups(context.Background(), mgr, tt.pluginName)
err := RemovePluginFromGroup(context.Background(), mgr, tt.groupName, tt.pluginName)

if tt.wantErr != "" {
require.Error(t, err)
Expand Down
11 changes: 11 additions & 0 deletions pkg/plugins/adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,17 @@ type MaterializationAdapter interface {
// config mutations the adapter itself made. Must be idempotent: a missing
// install is not an error.
Dematerialize(ctx context.Context, req DematerializeRequest) error
// EnsureRegistered restores this plugin's marketplace/settings entries
// without re-extracting files. Used after restoring a plugin-tree snapshot
// so a failed upgrade does not leave the client unable to discover the
// plugin. Must be idempotent.
EnsureRegistered(ctx context.Context, req DematerializeRequest) error
// Health reports whether this plugin is fully materialized for the client:
// the plugin tree exists and any required marketplace or settings
// registration is present. A missing directory or registration is an
// error. Health must not hash shared files into a content digest — it is
// a presence check, not a pin.
Health(ctx context.Context, req DematerializeRequest) error
Comment thread
samuv marked this conversation as resolved.
// SupportedComponents returns the component types this adapter loads.
SupportedComponents() []ComponentType
// ScopeSupport reports whether a project-scoped install degrades for this
Expand Down
Loading
Loading