feat(01-01): boot hello app and dispatch greeter:hello

- Add party plugin registry with Register-before-Boot activation
- Add backpack, compass, bonfire, and pact capability interfaces
- Add examples/hello with two compiled plugin modules
This commit is contained in:
Jakub Zych
2026-09-16 12:57:18 +02:00
parent 552cadbdf9
commit 96a8d38be2
26 changed files with 982 additions and 0 deletions

195
party/registry.go Normal file
View File

@@ -0,0 +1,195 @@
package party
import (
"fmt"
"strings"
"sync"
"git.golem15.com/golem15/summercms/backpack"
)
// Plugin is the required descriptor every compiled plugin implements.
type Plugin interface {
ID() string
Requires() []string
Register(app *backpack.App) error
Boot(app *backpack.App) error
}
var (
mu sync.Mutex
plugins []Plugin
)
// Register appends a plugin to the process-wide registry. Plugins call this
// from init() after a blank import; Activate selects and orders them.
func Register(p Plugin) {
if p == nil {
return
}
mu.Lock()
defer mu.Unlock()
plugins = append(plugins, p)
}
func snapshot() []Plugin {
mu.Lock()
defer mu.Unlock()
out := make([]Plugin, len(plugins))
copy(out, plugins)
return out
}
// Activate resolves ids in manifest order, topologically sorts Requires(),
// then runs every Register before any Boot.
func Activate(app *backpack.App, ids []string) ([]Plugin, error) {
if app == nil {
return nil, fmt.Errorf("party: app is nil")
}
return activate(snapshot(), app, ids)
}
func activate(registered []Plugin, app *backpack.App, ids []string) ([]Plugin, error) {
byID := make(map[string]Plugin, len(registered))
for _, p := range registered {
if p == nil {
continue
}
id := p.ID()
if id == "" {
return nil, fmt.Errorf("party: registered plugin has empty id")
}
if _, exists := byID[id]; exists {
return nil, fmt.Errorf("party: duplicate plugin id %q", id)
}
byID[id] = p
}
seen := make(map[string]struct{}, len(ids))
selected := make([]Plugin, 0, len(ids))
index := make(map[string]int, len(ids))
for _, id := range ids {
if id == "" {
return nil, fmt.Errorf("party: empty plugin id in manifest")
}
if _, dup := seen[id]; dup {
return nil, fmt.Errorf("party: duplicate plugin id %q", id)
}
seen[id] = struct{}{}
p, ok := byID[id]
if !ok {
return nil, fmt.Errorf("party: plugin %q is not registered", id)
}
index[id] = len(selected)
selected = append(selected, p)
}
for _, p := range selected {
for _, req := range p.Requires() {
if _, ok := seen[req]; !ok {
return nil, fmt.Errorf("party: plugin %q requires %q, which is not registered", p.ID(), req)
}
}
}
ordered, err := topoSort(selected, index)
if err != nil {
return nil, err
}
for _, p := range ordered {
if err := p.Register(app); err != nil {
return nil, fmt.Errorf("party: register %s: %w", p.ID(), err)
}
}
for _, p := range ordered {
if err := p.Boot(app); err != nil {
return nil, fmt.Errorf("party: boot %s: %w", p.ID(), err)
}
}
return ordered, nil
}
func topoSort(plugins []Plugin, index map[string]int) ([]Plugin, error) {
byID := make(map[string]Plugin, len(plugins))
indeg := make(map[string]int, len(plugins))
dependents := make(map[string][]string, len(plugins))
for _, p := range plugins {
byID[p.ID()] = p
indeg[p.ID()] = 0
}
for _, p := range plugins {
seenReq := make(map[string]struct{})
for _, req := range p.Requires() {
if _, ok := seenReq[req]; ok {
continue
}
seenReq[req] = struct{}{}
if _, ok := byID[req]; !ok {
continue
}
indeg[p.ID()]++
dependents[req] = append(dependents[req], p.ID())
}
}
ready := make([]string, 0, len(plugins))
for _, p := range plugins {
if indeg[p.ID()] == 0 {
ready = append(ready, p.ID())
}
}
out := make([]Plugin, 0, len(plugins))
for len(ready) > 0 {
id := ready[0]
ready = ready[1:]
out = append(out, byID[id])
next := append([]string(nil), dependents[id]...)
sortByIndex(next, index)
for _, dep := range next {
indeg[dep]--
if indeg[dep] == 0 {
ready = insertByIndex(ready, dep, index)
}
}
}
if len(out) != len(plugins) {
leftover := make([]string, 0)
done := make(map[string]struct{}, len(out))
for _, p := range out {
done[p.ID()] = struct{}{}
}
for _, p := range plugins {
if _, ok := done[p.ID()]; !ok {
leftover = append(leftover, p.ID())
}
}
return nil, fmt.Errorf("party: dependency cycle involving %s", strings.Join(leftover, ", "))
}
return out, nil
}
func sortByIndex(ids []string, index map[string]int) {
for i := 1; i < len(ids); i++ {
j := i
for j > 0 && index[ids[j]] < index[ids[j-1]] {
ids[j], ids[j-1] = ids[j-1], ids[j]
j--
}
}
}
func insertByIndex(ready []string, id string, index map[string]int) []string {
at := len(ready)
for i, existing := range ready {
if index[id] < index[existing] {
at = i
break
}
}
ready = append(ready, "")
copy(ready[at+1:], ready[at:])
ready[at] = id
return ready
}

56
party/registry_test.go Normal file
View File

@@ -0,0 +1,56 @@
package party
import (
"strings"
"testing"
"git.golem15.com/golem15/summercms/backpack"
"git.golem15.com/golem15/summercms/compass"
)
type tracePlugin struct {
id string
req []string
log *[]string
}
func (p *tracePlugin) ID() string { return p.id }
func (p *tracePlugin) Requires() []string { return p.req }
func (p *tracePlugin) Register(*backpack.App) error {
*p.log = append(*p.log, p.id+":register")
return nil
}
func (p *tracePlugin) Boot(*backpack.App) error {
*p.log = append(*p.log, p.id+":boot")
return nil
}
func TestActivateRunsAllRegisterBeforeAnyBoot(t *testing.T) {
var log []string
hello := &tracePlugin{id: "golem15.hello", log: &log}
greeter := &tracePlugin{id: "golem15.greeter", req: []string{"golem15.hello"}, log: &log}
app := backpack.New(&compass.Config{})
got, err := activate([]Plugin{greeter, hello}, app, []string{"golem15.greeter", "golem15.hello"})
if err != nil {
t.Fatalf("activate: %v", err)
}
if len(got) != 2 {
t.Fatalf("got %d plugins, want 2", len(got))
}
if got[0].ID() != "golem15.hello" || got[1].ID() != "golem15.greeter" {
t.Fatalf("order = %s, %s", got[0].ID(), got[1].ID())
}
want := []string{
"golem15.hello:register",
"golem15.greeter:register",
"golem15.hello:boot",
"golem15.greeter:boot",
}
if strings.Join(log, ",") != strings.Join(want, ",") {
t.Fatalf("lifecycle = %v, want %v", log, want)
}
}