Embedding

Run the Argyll engine inside a Go application, with its own storage, Step handlers, and Flows.

The github.com/kode4food/argyll/engine/pkg/argyll package runs the full engine in your process. The application owns storage, decides which Step types exist, registers Steps, and starts Flows through ordinary Go calls. Everything the standalone server does over HTTP is available as a method on the embedded Engine.

Create and Start

argyll.New builds an engine over a timebox backend. Nothing runs until Start, which begins scheduling Work and recovers any Flows that were interrupted. Stop shuts the engine down and closes the stores it opened:

import (
    "github.com/kode4food/timebox"
    "github.com/kode4food/timebox/memory"

    "github.com/kode4food/argyll/engine/pkg/argyll"
)

func start() (argyll.Engine, error) {
    eng, err := argyll.New(argyll.Options{
        Backend: func(pub timebox.Publisher) (timebox.Backend, error) {
            return memory.Open(memory.Config{Publisher: pub}), nil
        },
    })
    if err != nil {
        return nil, err
    }
    return eng, eng.Start()
}

Backend opens the store the engine runs on, wired to the publisher its committed events reach. The memory backend suits tests and single-process tools; timebox also provides postgres, redis, and raft backends for durable and clustered deployments.

OptionEffect
BackendOpens the timebox backend the engine runs on
ConfigEngine configuration, defaulting to config.NewDefaultConfig()
HandlersThe Step types the engine runs, defaulting to the built-in Script, Service, and Flow handlers

Register Steps and Run a Flow

Steps, Spaces, and Flows use the same types as the HTTP API, from github.com/kode4food/argyll/engine/pkg/api:

err := eng.RegisterStep(&api.Step{
    ID:   "greet",
    Name: "Greet",
    Type: api.StepTypeScript,
    Script: &api.ScriptConfig{
        Language: api.ScriptLangLua,
        Script: `
            return { greeting = "hello " .. name }
        `,
    },
    Attributes: api.AttributeSpecs{
        "name":     {Role: api.RoleRequired, Type: api.TypeString},
        "greeting": {Role: api.RoleOutput, Type: api.TypeString},
    },
})

err = eng.StartFlow(api.CreateFlowRequest{
    ID:    "greet-ada",
    Goals: []api.StepID{"greet"},
    Init:  api.InitArgs{"name": {"ada"}},
})

fl, err := eng.GetFlowState("greet-ada")

RegisterSteps registers several Steps in one transaction, so a conflict among them leaves the catalog as it was. QueryFlows, ListFlows, and GetFlowStatus report on Flows, and the Space methods scope planning exactly as the Spaces API does.

CreatePlan builds the plan a set of Goals resolves to, taking the same api.ExecutionPlanRequest that CreateFlowRequest embeds. StartPlan runs that plan under a Flow ID you choose, with Metadata, Tags, or a parent:

pl, err := eng.CreatePlan(api.ExecutionPlanRequest{
    Goals: []api.StepID{"greet"},
    Init:  api.InitArgs{"name": {"ada"}},
})

err = eng.StartPlan("greet-ada", pl, flow.WithTags(api.Tags{"demo"}))

Step Handlers

A Step’s type selects the handler that runs it. Handlers maps each Step type to a step.Handler, and the map you pass is the exact set the engine runs. builtins.All returns the built-in handlers, and With returns a copy extended by your own:

import (
    "time"

    "github.com/kode4food/argyll/engine/pkg/api"
    "github.com/kode4food/argyll/engine/pkg/step"
    "github.com/kode4food/argyll/engine/pkg/step/builtins"
)

greet := &step.Handler{
    Invoke: func(
        rt step.Runtime, st *api.Step, args api.Args, tkn api.Token,
    ) error {
        name, _ := args["name"].(string)
        return rt.CompleteWork(tkn, api.Args{"greeting": "hello " + name})
    },
}

client := builtins.NewHTTPClient(30 * time.Second)
handlers := builtins.All(client, nil)

eng, err := argyll.New(argyll.Options{
    Backend:  open,
    Handlers: handlers.With(step.Handlers{"greet": greet}),
})

With accepts any number of handler sets, and a later set replaces a Step type an earlier one registers. It leaves the handlers it extends unchanged.

A Step whose type is greet now runs in process, with no configuration beyond its Attributes:

err = eng.RegisterStep(&api.Step{
    ID:   "greet",
    Name: "Greet",
    Type: "greet",
    Attributes: api.AttributeSpecs{
        "name":     {Role: api.RoleRequired, Type: api.TypeString},
        "greeting": {Role: api.RoleOutput, Type: api.TypeString},
    },
})

A handler supplies whichever capabilities its Step type needs:

FieldCapability
InvokeRuns one Work Item. Call rt.CompleteWork with the outputs, or return an error to fail the Work Item
CompensateReverses a completed Work Item for Steps with compensated handling
ValidateChecks Step configuration when the Step is registered
HealthReports whether the Step can run, recorded when it is registered
ChildrenNames the Steps a Step expands into, as Flow Steps do

Invoke receives the Work Item’s inputs by their invocation names, and step.Runtime provides the Flow ID, Step ID, and Flow metadata. An error that wraps api.ErrWorkNotCompleted leaves the Work Item for retry; any other error fails it.

One handler serves every Step of its type. Each Step keeps its own ID and Attributes, so several Steps can share the greet handler while mapping their own Attribute names onto name and greeting, or supplying their own defaults and constants.

A handler can also finish Work later. Return from Invoke without completing, keep the Flow ID, Step ID, and token, and report the outcome through the engine’s CompleteWork or FailWork when the Work is done.

The Go Step Generator writes these handlers for you from ordinary Go functions, one Step type per function.

Async Service Steps

An async Service Step reports its outcome to a callback URL. Pass Handlers with a callback builder aimed at an address your application serves, and route that address to builtins.Callback:

eng, err := argyll.New(argyll.Options{
    Backend: open,
    Handlers: builtins.All(
        builtins.NewHTTPClient(30*time.Second),
        builtins.BaseCallbackURL("https://orders.example.com"),
    ),
})

mux := http.NewServeMux()
mux.HandleFunc("POST /callbacks/{flow_id}/{step_id}/{token}/{action}",
    func(w http.ResponseWriter, r *http.Request) {
        status, err := builtins.Callback(eng, &builtins.CallbackRequest{
            FlowStep: api.FlowStep{
                FlowID: api.FlowID(r.PathValue(api.ParamFlowID)),
                StepID: api.StepID(r.PathValue(api.ParamStepID)),
            },
            Token:   api.Token(r.PathValue(api.ParamToken)),
            Action:  api.CallbackAction(r.PathValue(api.ParamAction)),
            Request: r,
        })
        if err != nil {
            http.Error(w, err.Error(), status)
            return
        }
        w.WriteHeader(status)
    },
)

builtins.Callback records the outcome and returns the status to answer with, accepting duplicate callbacks. builtins.All takes the callback builder, so a custom handler set passes it the same way.