Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 50 additions & 8 deletions .agent/docs/hooks.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
# Hooks & plugins — extension points

Source: `hooks.go`, `component/hooks.go`, `port/hooks.go`, `internal/hook/hook_group.go`, `component/plugin.go`.
Source: `hooks.go`, `component/hooks.go`, `port/hooks.go`, `internal/hook/hook_group.go`,
`plugin.go`, `component/plugin.go`, `internal/plugin/registry.go`, `plugin/`.

## The hook primitive

`hook.Group[T]` (package `internal/hook` — not part of the public API) — the **one approved
generic type** in the codebase. An ordered
`hook.Group[T]` (package `internal/hook` — not part of the public API). Generics are confined to
`internal/` containers — `hook.Group[T]` and `plugin.Registry[T]` are **the only two approved
generic types** in the codebase; nothing public is generic. An ordered
slice of `func(T) error`; `Trigger(arg)` runs all in insertion order, **fail-fast** on first error.
Registration is chainable and happens through `SetupHooks(func(*Hooks))` closures (or the
`WithHooks` constructor option on components) — the `Hooks` structs' fields are unexported, so
Expand Down Expand Up @@ -43,8 +45,48 @@ closures are the only registration path.

## Plugins

`component.Plugin` interface: `GetName() string` + `Init(*Component) error`. Registered via the
`component.WithPlugins(...)` constructor option; `Init` runs during `component.New` (after all
other options), duplicate names are a construction error. Query with `c.PluginRegistered(name)`.
A plugin is just an initialization bundle — typically registers hooks/ports on the component.
Order inside `New`: options → plugin `Init`s → `OnCreation` hooks.
Two levels, same shape: `GetName() string` + `Init(T) error`, registered via a `WithPlugins(...)`
constructor option, duplicate names are a construction error, queried with `PluginRegistered(name)`.
A plugin is just an initialization bundle — typically registers hooks.

| Level | Interface | Registration | `Init` receives | Query |
|---|---|---|---|---|
| Component | `component.Plugin` | `component.WithPlugins(...)` | `*Component` | `c.PluginRegistered(name)` |
| Mesh | `fmesh.Plugin` | `fmesh.WithPlugins(...)` | `*FMesh` | `fm.PluginRegistered(name)` |

Storage, the duplicate-name check and the init order are shared: both levels hold an
`internal/plugin.Registry[T]` and call its `InitAll`. Only the public interfaces and the
`WithPlugins` options (which must be typed as each level's `Option`) live at the level itself — so
the two cannot drift, and a third level would not be a third copy.

- **Both levels `Init` in sorted name order**, not map order — plugins register hooks, hooks fire in
registration order, so init order is observable and must not vary between runs of the same binary.
- **Component**: `Init` runs during `component.New` after all other options. It may modify the
component's ports and metadata as well as its hooks.
Order inside `New`: options → plugin `Init`s → `OnCreation` hooks.
- **Mesh**: `Init` runs during `fmesh.New` after the options *and* after `runtimeInfo` is built, so
a plugin sees a fully configured mesh.

**A mesh plugin cannot walk components in `Init`** — a mesh is constructed empty and filled by
`AddComponents` afterwards, so there is nothing to walk yet. To instrument components, register an
`OnComponentAdded` hook and attach component hooks to each one as it arrives. That is how a mesh
plugin observes every activation without any component knowing it exists, and it is the intended
pattern for profiling, tracing, and assertion plugins.

Two consequences of the arrival-hook pattern, both learned the hard way in `plugin/`:

- A component is only ever looked at **on arrival**, so ports added afterwards
(`AddInputs`/`AddOutputs`) are invisible to a plugin that wires or inspects ports.
- Instrumentation hooks fire from the **concurrent activation goroutines**. Guard shared state, and
take clock readings *outside* the plugin's own lock — timing from inside the critical section
charges each component for the contention caused by all the others.

## Bundled plugins (`plugin/`)

Package `plugin` ships mesh-level plugins built on exactly the pattern above. It imports `fmesh`, so
`fmesh` can never import it.

| Plugin | What it does |
|---|---|
| `plugin.NewProfiler()` | Times whole runs, single cycles, and each component's activations. `Runs()`, `Cycles()`, `Components()`, `TopN(n)`, `Report()`, `Reset()`. One instance belongs to one mesh. |
| `plugin.AutowirePrefixed(prefix)` / `AutowireBroadcast(name)` / `AutowireBroadcastAs(out, in)` / `&Autowire{Name: ...}` | Pipes ports by naming convention, in both directions on every arrival, so `AddComponents` order does not matter. Each convention is a separate plugin instance with its own `PluginName`. |
9 changes: 4 additions & 5 deletions component/component.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"log"

"github.com/hovsep/fmesh/internal/plugin"
"github.com/hovsep/fmesh/meta"
"github.com/hovsep/fmesh/port"
)
Expand All @@ -23,7 +24,7 @@ type Component struct {
state State
parentMesh ParentMesh
hooks *Hooks
plugins plugins
plugins *plugin.Registry[*Component]
}

// New creates a new component with the given name and options.
Expand All @@ -47,10 +48,8 @@ func New(name string, opts ...Option) (*Component, error) {
}
}

for pluginName, plugin := range c.plugins {
if err := plugin.Init(c); err != nil {
return nil, fmt.Errorf("component %q plugin %s initialization failed: %w", name, pluginName, err)
}
if err := c.initPlugins(); err != nil {
return nil, err
}

if err := c.hooks.onCreation.Trigger(c); err != nil {
Expand Down
36 changes: 22 additions & 14 deletions component/plugin.go
Original file line number Diff line number Diff line change
@@ -1,35 +1,43 @@
package component

import "fmt"
import (
"fmt"

"github.com/hovsep/fmesh/internal/plugin"
)

// Plugin defines the component plugin interface.
type Plugin interface {
GetName() string
Init(*Component) error
}

// plugins defines a container of component plugins.
type plugins map[string]Plugin

// newPlugins is a constructor for plugins.
func newPlugins() plugins {
return make(plugins)
}

// WithPlugins is a component constructor option that adds plugins.
func WithPlugins(plugins ...Plugin) Option {
return func(c *Component) error {
for _, plugin := range plugins {
if _, exists := c.plugins[plugin.GetName()]; exists {
return fmt.Errorf("plugin %s already registered", plugin.GetName())
for _, p := range plugins {
if err := c.plugins.Add(p); err != nil {
return err
}
c.plugins[plugin.GetName()] = plugin
}
return nil
}
}

// PluginRegistered returns true if the plugin is registered.
func (c *Component) PluginRegistered(name string) bool {
return c.plugins[name] != nil
return c.plugins.Has(name)
}

// initPlugins runs every registered plugin's Init, in name order.
func (c *Component) initPlugins() error {
if err := c.plugins.InitAll(c); err != nil {
return fmt.Errorf("component %q %w", c.name, err)
}
return nil
}

// newPlugins is a constructor for the component plugin registry.
func newPlugins() *plugin.Registry[*Component] {
return plugin.NewRegistry[*Component]()
}
43 changes: 43 additions & 0 deletions component/plugin_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package component

import (
"errors"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -46,6 +47,8 @@ func TestComponent_Plugin(t *testing.T) {
}), 0.0001)
assert.True(t, c.Labels().ValueIs("plugin/price/version", "v1.2.4"))
assert.True(t, c.Scalars().ValueIs("plugin/price/threshold", 105.54))
assert.True(t, c.PluginRegistered("PricePlugin"))
assert.False(t, c.PluginRegistered("nope"))
})

t.Run("plugins can be registered only once", func(t *testing.T) {
Expand All @@ -66,8 +69,48 @@ func TestComponent_Plugin(t *testing.T) {
require.ErrorContains(t, err, "plugin PricePlugin already registered")
assert.Nil(t, c)
})

t.Run("plugins initialize in name order", func(t *testing.T) {
// Same reasoning as at mesh level: plugins register hooks, hooks fire in
// registration order, so init order is observable behavior.
var order []string
record := func(name string) recordingPlugin {
return recordingPlugin{name: name, seen: &order}
}

c, err := New("dummy", WithPlugins(record("zulu"), record("alpha"), record("mike")))

require.NoError(t, err)
require.NotNil(t, c)
assert.Equal(t, []string{"alpha", "mike", "zulu"}, order)
})

t.Run("a failing plugin fails construction", func(t *testing.T) {
c, err := New("dummy", WithPlugins(brokenPlugin{}))

require.Error(t, err)
require.ErrorContains(t, err, `component "dummy" plugin brokenPlugin initialization failed`)
assert.Nil(t, c)
})
}

type recordingPlugin struct {
name string
seen *[]string
}

func (p recordingPlugin) GetName() string { return p.name }

func (p recordingPlugin) Init(*Component) error {
*p.seen = append(*p.seen, p.name)
return nil
}

type brokenPlugin struct{}

func (brokenPlugin) GetName() string { return "brokenPlugin" }
func (brokenPlugin) Init(*Component) error { return errors.New("no") }

type PricePlugin struct {
}

Expand Down
2 changes: 2 additions & 0 deletions docs/wiki/501.-Hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -425,3 +425,5 @@ Hooks make F-Mesh more extensible and observable without compromising the simpli

For reading `ActivationResult`s after a run, see [Inspecting a run](https://github.com/hovsep/fmesh/wiki/402.-Inspecting-a-run).

To package a set of hooks so it can be attached to any component or mesh — including the `OnComponentAdded` trick that lets one object instrument every component in a mesh — see [502. Plugins](https://github.com/hovsep/fmesh/wiki/502.-Plugins).

Loading