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
2 changes: 1 addition & 1 deletion .agent/docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Architecture overview (the concept → type → package table and the execution
- **`meta`** — `Labels` (string k/v) and `Scalars` (string→float64). `Keys()`/`Values()` return sorted slices for determinism. `Merge(other)` is the one non-mutating method on both types. `Every(pred)` on empty = `true` (vacuous truth). `ForEach` returns `error`. Constructors: `NewLabels()`, `NewScalars()`.
- **`port`** — `Flush()` fans out then clears source. `PipeTo` is output→input only. Both return `error`. `PipeTo` validates direction at call time.
- **`component`** — `State` is `map[string]any`, persistent across cycles and across `Run`s (see [runtime.md](runtime.md)). Constructors use functional options: `component.New(name, opts...) (*Component, error)`. Ports come in two creation styles: name-based (`WithInputs`/`AddInputs`, `WithIndexedInputs("i", 1, 3)` → `i1..i3`) and attach-based (`AttachInputPorts` for pre-built `port.NewInput` ports with options). `LoopbackPipe(out, in)` wires a component to itself (such a mesh never stops naturally). `ErrWaitingForInputs`/`ErrWaitingForInputsKeep` are scheduler control-flow sentinels, not failures.
- **`hook`** — generic `hook.Group[T]`, ordered, fail-fast `Trigger`. Three hook levels (mesh/component/port); see [hooks.md](hooks.md).
- **`hook`** — lives at `internal/hook` (not public API). Generic `hook.Group[T]`, ordered, fail-fast `Trigger`. Three hook levels (mesh/component/port); see [hooks.md](hooks.md).
- **`cycle`** — has its own `Any`/`Every`/`Count` on its collection type, independent of `signal.Group`.

## Metadata tiers on groups/collections
Expand Down
5 changes: 3 additions & 2 deletions .agent/docs/hooks.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
# Hooks & plugins — extension points

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

## The hook primitive

`hook.Group[T]` (package `hook`) — the **one approved generic type** in the codebase. An ordered
`hook.Group[T]` (package `internal/hook` — not part of the public API) — the **one approved
generic type** in the codebase. 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
4 changes: 3 additions & 1 deletion .agent/docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
- Table-driven by default; `t.Run` subtests for grouped inline assertions
- `require` for preconditions and error checks (stops on failure); `assert` for value checks (continues)
- No assertion helpers — use plain `assert`/`require` directly
- Only allowed helper: `mustXxx()` panic-on-error for fixture setup, never for assertions
- Only allowed helper: `mustXxx()` panic-on-error for fixture setup, never for assertions.
Shared implementations live in `internal/testutil` (usable from `integration_tests/` and other
external test packages); in-package tests of `fmesh` and `port` keep local copies (import cycle)
- Use `assert.InDelta` for float64 comparisons (tolerance `1e-9` for exact values, larger for computed averages)

## What to cover
Expand Down
2 changes: 1 addition & 1 deletion component/activation.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import (
"fmt"
"runtime/debug"

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

// WithActivationFunc is a component option that sets the activation function.
Expand Down
2 changes: 1 addition & 1 deletion component/component.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ type Component struct {
state State
parentMesh ParentMesh
hooks *Hooks
plugins Plugins
plugins plugins
}

// New creates a new component with the given name and options.
Expand Down
8 changes: 8 additions & 0 deletions component/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Package component provides [Component], the building block of a mesh.
//
// A component owns named input and output ports and an activation function
// that runs once per cycle when inputs are ready. Components are built with
// [New] and functional options (WithInputs, WithActivationFunc, ...); after a
// run, each cycle exposes an [ActivationResult] per component describing
// whether and how it activated. Component types mutate in place.
package component
2 changes: 1 addition & 1 deletion component/hooks.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package component

import (
"github.com/hovsep/fmesh/hook"
"github.com/hovsep/fmesh/internal/hook"
)

// ActivationContext provides context for activation hooks.
Expand Down
10 changes: 5 additions & 5 deletions component/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ type Plugin interface {
Init(*Component) error
}

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

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

// WithPlugins is a component constructor option that adds plugins.
Expand Down
7 changes: 7 additions & 0 deletions cycle/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Package cycle provides [Cycle], one synchronized execution tick of a mesh,
// and [Group], the bounded history of cycles kept during a run.
//
// A cycle collects the activation results of every component that was
// considered in that tick; inspect them after a run through the mesh's
// runtime info. Cycle types mutate in place.
package cycle
10 changes: 10 additions & 0 deletions doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Package fmesh orchestrates a mesh of components exchanging signals through
// ports, following the Flow-Based Programming model.
//
// Build a mesh with [New], add components, and start it with [FMesh.Run].
// Execution proceeds in discrete synchronized cycles: every cycle all ready
// components activate concurrently, then outputs are flushed through pipes to
// downstream inputs. The mesh stops naturally when no component activates in a
// cycle, or on the cycle limit, time limit, or error-handling strategy
// configured via [Config] and the With* options.
package fmesh
2 changes: 1 addition & 1 deletion docs/wiki/999.-Internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ Pre-run checks (return `error` on failure):

## hook.Group

The `hook` package provides `hook.Group[T]` — a generic ordered hook collection used by `fmesh.Hooks`, `component.Hooks`, and `port.Hooks`:
The `internal/hook` package provides `hook.Group[T]` — a generic ordered hook collection used by `fmesh.Hooks`, `component.Hooks`, and `port.Hooks`. It is internal machinery, not importable by user code — hooks are registered through the closure-based API shown in [Hooks](https://github.com/hovsep/fmesh/wiki/501.-Hooks):

```go
g := hook.NewGroup[*MyContext]()
Expand Down
56 changes: 56 additions & 0 deletions example_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package fmesh_test

import (
"fmt"
"strings"

"github.com/hovsep/fmesh"
"github.com/hovsep/fmesh/component"
"github.com/hovsep/fmesh/signal"
)

// Example mirrors the README quick-start: two components connected by a pipe,
// run to completion in discrete cycles.
func Example() {
must := func(err error) {
if err != nil {
panic(err)
}
}

concat, err := component.New("concat",
component.WithInputs("i1", "i2"),
component.WithOutputs("res"),
component.WithActivationFunc(func(this *component.Component) error {
word1 := this.InputByName("i1").Signals().FirstPayloadOrDefault("").(string)
word2 := this.InputByName("i2").Signals().FirstPayloadOrDefault("").(string)
return this.OutputByName("res").PutSignals(signal.New(word1 + word2))
}))
must(err)

uppercase, err := component.New("uppercase",
component.WithInputs("i1"),
component.WithOutputs("res"),
component.WithActivationFunc(func(this *component.Component) error {
str := this.InputByName("i1").Signals().FirstPayloadOrDefault("").(string)
return this.OutputByName("res").PutSignals(signal.New(strings.ToUpper(str)))
}))
must(err)

fm, err := fmesh.New("hello world")
must(err)
must(fm.AddComponents(concat, uppercase))

must(concat.OutputByName("res").PipeTo(uppercase.InputByName("i1")))

must(concat.InputByName("i1").PutSignals(signal.New("hello ")))
must(concat.InputByName("i2").PutSignals(signal.New("world!")))

_, err = fm.Run()
must(err)

result, err := uppercase.OutputByName("res").Signals().FirstPayload()
must(err)
fmt.Printf("Result: %v\n", result)
// Output: Result: HELLO WORLD!
}
4 changes: 2 additions & 2 deletions fmesh.go
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,7 @@ func (fm *FMesh) cleanUpPreviousRun() error {

// Init runtime info
fm.runtimeInfo = newRuntimeInfo(fm.config.CyclesHistoryLimit)
fm.runtimeInfo.MarkStarted()
fm.runtimeInfo.markStarted()
return nil
}

Expand All @@ -335,7 +335,7 @@ func (fm *FMesh) Run() (ri *RuntimeInfo, runErr error) {
ri = fm.runtimeInfo

defer func() {
fm.runtimeInfo.MarkStopped()
fm.runtimeInfo.markStopped()
if err := fm.hooks.afterRun.Trigger(fm); err != nil {
if runErr == nil {
runErr = fmt.Errorf("afterRun hook failed: %w", err)
Expand Down
18 changes: 3 additions & 15 deletions fmesh_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import (

var noOpActivationFunc = func(this *component.Component) error { return nil }

// Local helpers: this in-package test cannot import internal/testutil
// (testutil imports fmesh).
func mustNewFMesh(name string, opts ...Option) *FMesh {
fm, err := New(name, opts...)
if err != nil {
Expand All @@ -34,20 +36,6 @@ func mustNewComponent(name string, opts ...component.Option) *component.Componen
return c
}

func mustAddInputs(c *component.Component, names ...string) *component.Component {
if err := c.AddInputs(names...); err != nil {
panic(err)
}
return c
}

func mustAddOutputs(c *component.Component, names ...string) *component.Component {
if err := c.AddOutputs(names...); err != nil {
panic(err)
}
return c
}

func mustPipeTo(src *port.Port, dsts ...*port.Port) {
if err := src.PipeTo(dsts...); err != nil {
panic(err)
Expand Down Expand Up @@ -952,7 +940,7 @@ func TestFMesh_runCycle(t *testing.T) {
if tt.initFM != nil {
tt.initFM(fm)
}
fm.runtimeInfo.MarkStarted()
fm.runtimeInfo.markStarted()
cycleErr := fm.runCycle()
gotCycleResult := fm.runtimeInfo.Cycles.Last()
if tt.wantError {
Expand Down
2 changes: 1 addition & 1 deletion hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import (

"github.com/hovsep/fmesh/component"
"github.com/hovsep/fmesh/cycle"
"github.com/hovsep/fmesh/hook"
"github.com/hovsep/fmesh/internal/hook"
"github.com/hovsep/fmesh/port"
)

Expand Down
35 changes: 11 additions & 24 deletions integration_tests/chainability/chainability_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,33 +3,20 @@ package chainability
import (
"testing"

"github.com/hovsep/fmesh/internal/testutil"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/hovsep/fmesh/component"
"github.com/hovsep/fmesh/port"
"github.com/hovsep/fmesh/signal"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func mustComponent(name string, opts ...component.Option) *component.Component {
c, err := component.New(name, opts...)
if err != nil {
panic(err)
}
return c
}

func mustInputPort(name string, opts ...port.Option) *port.Port {
p, err := port.NewInput(name, opts...)
if err != nil {
panic(err)
}
return p
}

// TestChainability_CrossPackage verifies realistic cross-package chaining scenarios.
func TestChainability_CrossPackage(t *testing.T) {
t.Run("full component setup", func(t *testing.T) {
c := mustComponent("processor",
c := testutil.MustComponent("processor",
component.WithInputs("in1", "in2"),
component.WithOutputs("out1", "out2"),
component.WithDescription("main processor"),
Expand All @@ -50,7 +37,7 @@ func TestChainability_CrossPackage(t *testing.T) {
})

t.Run("port with signals and labels", func(t *testing.T) {
p := mustInputPort("data", port.WithDescription("data input")).
p := testutil.MustInputPort("data", port.WithDescription("data input")).
AddLabel("type", "data")
require.NoError(t, p.PutSignals(signal.New(1), signal.New(2)))
p.AddLabel("count", "2")
Expand All @@ -75,7 +62,7 @@ func TestChainability_CrossPackage(t *testing.T) {

t.Run("component with label cleanup", func(t *testing.T) {
// Simulate component lifecycle: setup with debug labels, then clean them up
c := mustComponent("worker",
c := testutil.MustComponent("worker",
component.WithInputs("tasks"),
component.WithOutputs("results"),
component.WithDescription("background worker"),
Expand All @@ -97,7 +84,7 @@ func TestChainability_CrossPackage(t *testing.T) {

t.Run("port with label reset workflow", func(t *testing.T) {
// Port initially configured with temporary setup labels, then cleared for production
p := mustInputPort("input").
p := testutil.MustInputPort("input").
AddLabels(map[string]string{
"setup": "true",
"test": "mode",
Expand Down Expand Up @@ -144,7 +131,7 @@ func TestChainability_CrossPackage(t *testing.T) {

t.Run("complex label lifecycle", func(t *testing.T) {
// Realistic scenario: component setup -> debug -> cleanup -> finalize
c := mustComponent("api-handler",
c := testutil.MustComponent("api-handler",
component.WithInputs("request"),
component.WithOutputs("response", "errors"),
component.WithDescription("HTTP API handler"),
Expand Down Expand Up @@ -183,7 +170,7 @@ func TestChainability_CrossPackage(t *testing.T) {
WithNoLabels().
WithLabels(map[string]string{"priority": "high", "source": "validated"})

p := mustInputPort("validated-input").
p := testutil.MustInputPort("validated-input").
AddLabel("type", "input")
require.NoError(t, p.PutSignals(s1, s2))
p.AddLabel("count", "2").
Expand Down
Loading