control

package
v0.0.51 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 27, 2026 License: MIT Imports: 13 Imported by: 0

README

sup/control

Go Reference Test License

sup/control provides a lightweight HTTP control surface for sup applications. It exposes selected actors and signals through a registry so external tools, dashboards, CLIs, or automation can inspect state, subscribe to updates, and invoke explicitly registered control actions.

The package is intentionally opt-in: actors are only controllable when you register typed Cast or Call actions for them, and signals are exposed as read-only values with live update events.

Features

  • HTTP Registry: Serve a JSON index of exposed actors, signals, and their control metadata.
  • Typed Control Actions: Register fire-and-forget Cast actions and request/reply Call actions with typed Go handlers.
  • Automatic JSON Schemas: Input and output schemas are inferred from Go types and json struct tags.
  • Signal Exposure: Expose sup.ReadableSignal values with inferred UI-friendly value types.
  • Live Updates: Signal changes are streamed to clients with Server-Sent Events (SSE).
  • Actor-Model Friendly: Control handlers can call an actor's public API while the actor keeps ownership of its internal state.
  • Supervisable: Registry implements sup.Actor, so it can be managed by the same supervisor tree as the rest of the system.

Installation

go get github.com/webermarci/sup/control

Quick start

This example exposes a counter actor through HTTP. The control registry provides one cast action (increment), one call action (get), and a readable status signal.

package main

import (
	"context"
	"fmt"
	"net/http"
	"time"

	"github.com/webermarci/sup"
	"github.com/webermarci/sup/control"
)

type incrementMsg struct{ Amount int }
type getCountMsg struct{}

type Counter struct {
	*sup.BaseActor
	casts *sup.CastInbox[incrementMsg]
	calls *sup.CallInbox[getCountMsg, int]
	count int
}

func NewCounter(id string) *Counter {
	return &Counter{
		BaseActor: sup.NewBaseActor(id),
		casts:     sup.NewCastInbox[incrementMsg](16),
		calls:     sup.NewCallInbox[getCountMsg, int](8),
	}
}

func (c *Counter) Increment(ctx context.Context, amount int) error {
	return c.casts.Cast(ctx, incrementMsg{Amount: amount})
}

func (c *Counter) Get(ctx context.Context) (int, error) {
	return c.calls.Call(ctx, getCountMsg{})
}

func (c *Counter) Run(ctx context.Context) error {
	for {
		select {
		case <-ctx.Done():
			return nil

		case msg, ok := <-c.casts.Receive():
			if !ok {
				return nil
			}
			c.count += msg.Amount

		case req, ok := <-c.calls.Receive():
			if !ok {
				return nil
			}
			req.Reply(c.count, nil)
		}
	}
}

type incrementInput struct {
	Amount int `json:"amount"`
}

type countOutput struct {
	Count int `json:"count"`
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	counter := NewCounter("counter")
	status := sup.NewPushedSignal("status", func(ctx context.Context, value string) error {
		return nil
	})

	registry := control.NewRegistry("control",
		control.WithActor(counter,
			control.Cast("increment", func(ctx context.Context, in incrementInput) error {
				return counter.Increment(ctx, in.Amount)
			}),
			control.Call("get", func(ctx context.Context, _ struct{}) (countOutput, error) {
				count, err := counter.Get(ctx)
				return countOutput{Count: count}, err
			}),
		),
		control.WithSignal(status),
	)

	supervisor := sup.NewSupervisor("root",
		sup.WithActors(counter, status, registry),
		sup.WithPolicy(sup.Permanent),
		sup.WithRestartDelay(time.Second),
	)

	go func() {
		if err := http.ListenAndServe(":8080", registry.Handler()); err != nil {
			fmt.Println("control server stopped:", err)
		}
	}()

	go supervisor.Run(ctx)

	_ = status.Write(ctx, "online")

	<-ctx.Done()
	supervisor.Wait()
}

Concepts

Registry

Registry is the package's main actor. It stores the exposed actor and signal metadata, runs signal streams, and serves the HTTP API through Handler().

registry := control.NewRegistry("control",
	control.WithActor(actor),
	control.WithSignal(signal),
)

http.ListenAndServe(":8080", registry.Handler())

Because Registry implements sup.Actor, it should usually be supervised:

supervisor := sup.NewSupervisor("root",
	sup.WithActors(appActor, registry),
	sup.WithPolicy(sup.Permanent),
)

supervisor.Run(ctx)
Exposed actors

Use WithActor to include an actor in the registry index. By default this only exposes the actor's ID and Inspect() spec.

Add control actions with Cast and Call:

registry := control.NewRegistry("control",
	control.WithActor(worker,
		control.Cast("reset", func(ctx context.Context, _ struct{}) error {
			return worker.Reset(ctx)
		}),
		control.Call("stats", func(ctx context.Context, _ struct{}) (Stats, error) {
			return worker.Stats(ctx)
		}),
	),
)
  • Cast[T] registers a named command that accepts JSON input of type T and returns only success or error.
  • Call[T, R] registers a named request that accepts JSON input of type T and returns a JSON response of type R.
  • Empty HTTP request bodies are treated as {}, which is useful for struct{} inputs.
  • Duplicate actor IDs, duplicate action names, or cast/call name collisions panic during registry construction.
Exposed signals

Use WithSignal to expose any sup.ReadableSignal[V]:

status := sup.NewPushedSignal("status", func(ctx context.Context, value string) error {
	return nil
})

registry := control.NewRegistry("control",
	control.WithSignal(status),
)

The registry index includes each signal's current value, Inspect() spec, and an inferred display type:

Go type Exposed type
bool boolean
integer and float types number
string string
other values json

When a signal publishes a new value, the registry updates its snapshot and broadcasts a signal:update SSE event.

HTTP API

Method Path Description
GET / Returns all exposed actors and signals as JSON.
GET /events Opens an SSE stream for live registry events.
POST /actors/{actorID}/casts/{castName} Invokes a registered cast action.
POST /actors/{actorID}/calls/{callName} Invokes a registered call action and returns its JSON response.
Registry index

GET / returns a JSON object with actors and signals arrays:

{
  "actors": [
    {
      "id": "counter",
      "spec": { "kind": "counter" },
      "control": {
        "casts": [
          {
            "name": "increment",
            "input_schema": {
              "type": "object",
              "properties": {
                "amount": { "type": "integer" }
              },
              "required": ["amount"]
            }
          }
        ],
        "calls": [
          {
            "name": "get",
            "input_schema": { "type": "object", "properties": {} },
            "output_schema": {
              "type": "object",
              "properties": {
                "count": { "type": "integer" }
              },
              "required": ["count"]
            }
          }
        ]
      }
    }
  ],
  "signals": [
    {
      "id": "status",
      "spec": { "kind": "pushed_signal" },
      "type": "string",
      "value": "online"
    }
  ]
}
Cast actions

POST /actors/{actorID}/casts/{castName} decodes the request body into the cast input type and runs the registered handler.

Successful response:

{ "ok": true }
Call actions

POST /actors/{actorID}/calls/{callName} decodes the request body into the call input type and returns the handler's output as JSON.

Example response:

{ "count": 5 }
SSE events

GET /events returns an SSE stream. Signal updates are sent as signal:update events:

event: signal:update
data: {"timestamp":1760000000000,"id":"status","value":"online"}

The stream also sends periodic : ping comments to keep long-lived connections open.

JSON schema inference

sup/control derives lightweight JSON schemas from Go types:

  • Primitive Go types map to JSON boolean, string, integer, or number.
  • Slices and arrays become JSON arrays with an items schema.
  • map[string]T becomes a JSON object with additionalProperties.
  • Structs become JSON objects using exported fields and json tags.
  • Fields tagged with json:"-" and unexported fields are ignored.
  • Fields tagged with omitempty are omitted from the generated required list.

These schemas are intended for control clients and dashboards; validation is still performed by normal Go JSON decoding in the registered handler.

Documentation

Index

Constants

This section is empty.

Variables

View Source
var (
	ErrCastNotFound = errors.New("sup: cast not found")
	ErrCallNotFound = errors.New("sup: call not found")
)

Functions

This section is empty.

Types

type ActorOption

type ActorOption func(*ExposedActor)

ActorOption defines a function type for configuring the ExposedActor.

func Call

func Call[T any, R any](name string, fn func(context.Context, T) (R, error)) ActorOption

Call defines a call action that can be performed on an ExposedActor.

func Cast

func Cast[T any](name string, fn func(context.Context, T) error) ActorOption

Cast defines a cast action that can be performed on an ExposedActor.

type CallAction

type CallAction struct {
	Name         string         `json:"name"`
	InputSchema  map[string]any `json:"input_schema"`
	OutputSchema map[string]any `json:"output_schema"`
	// contains filtered or unexported fields
}

CallAction defines a call action that can be performed on a control actor.

type CastAction

type CastAction struct {
	Name        string         `json:"name"`
	InputSchema map[string]any `json:"input_schema"`
	// contains filtered or unexported fields
}

CastAction defines a cast action that can be performed on a control actor.

type Control

type Control struct {
	Casts []*CastAction `json:"casts"`
	Calls []*CallAction `json:"calls"`
	// contains filtered or unexported fields
}

Control is an actor that handles control commands for a system.

func NewControl

func NewControl() *Control

NewControl creates a new Control actor.

func (*Control) Call

func (c *Control) Call(ctx context.Context, name string, raw json.RawMessage) (any, error)

Call performs a call action on the Control actor.

func (*Control) Cast

func (c *Control) Cast(ctx context.Context, name string, raw json.RawMessage) error

Cast performs a cast action on the Control actor.

type ExposedActor

type ExposedActor struct {
	ID      string   `json:"id"`
	Spec    sup.Spec `json:"spec"`
	Control *Control `json:"control,omitempty"`
}

ExposedActor is an actor that exposes control actions to the system.

type ExposedSignal

type ExposedSignal struct {
	ID    string   `json:"id"`
	Spec  sup.Spec `json:"spec"`
	Type  string   `json:"type"`
	Value any      `json:"value"`
}

type Registry

type Registry struct {
	*sup.BaseActor
	// contains filtered or unexported fields
}

Registry is the main control registry for the system.

func NewRegistry

func NewRegistry(id string, opts ...RegistryOption) *Registry

NewRegistry creates a new Registry with the given ID and options.

func (*Registry) ExposedActors added in v0.0.50

func (r *Registry) ExposedActors() []ExposedActor

ExposedActors returns a copy of the exposed actors.

func (*Registry) ExposedSignals added in v0.0.50

func (r *Registry) ExposedSignals() []ExposedSignal

ExposedSignals returns a copy of the exposed signals.

func (*Registry) Handler

func (r *Registry) Handler() http.Handler

Handler returns the HTTP handler for the Registry.

func (*Registry) Inspect

func (r *Registry) Inspect() sup.Spec

Inspect returns the specification of the Registry.

func (*Registry) Run

func (r *Registry) Run(ctx context.Context) error

Run runs the Registry, starting all streams and handling errors.

type RegistryOption

type RegistryOption func(*Registry)

RegistryOption defines a function type for configuring the Registry.

func WithActor

func WithActor(actor sup.Actor, opts ...ActorOption) RegistryOption

WithActor adds an actor to the Registry with the given options.

func WithSignal

func WithSignal[V any](signal sup.ReadableSignal[V]) RegistryOption

WithSignal adds a signal to the Registry.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL