Custom script engine¶
A Script Task carries a script body and a scriptFormat MIME hint; the engine
routes the body to a script engine that interprets that format. gobpm ships
one — the pure-Go Lua interpreter in adapters/lua — but the seam is a plain
interface (script.Engine), so you can register a Starlark sibling, a custom
DSL, or a hosted-language runtime alongside it. Each engine owns the formats it
claims; the engine routes by the task's scriptFormat. This page is the
extension reference — the seam interface, the registration call, a minimal real
implementation, and how the engine uses it.
The seam interface¶
An engine interprets one or more script formats and names its kind in the
"##"-convention:
type Engine interface {
// Type names the engine kind ("##Lua", ...) for the startup config
// and the Script facts.
Type() string
// Formats returns the MIME hints this engine interprets — an
// enumerable claim (never empty for a real engine), so the Registry
// can detect conflicts and print the routing table.
Formats() []string
// Execute runs the script body against the read-only process-data
// surface and returns the script's named outputs (nil or empty when
// the script produces none). format is the task's scriptFormat — an
// engine may interpret several dialects by it.
Execute(
ctx context.Context,
format, script string,
r service.DataReader,
) (Outputs, error)
}
| Member | You implement it to… |
|---|---|
Type() |
name the engine kind for startup config and Script facts — the "##"-hint ("##Lua"). |
Formats() |
enumerate the scriptFormat MIME hints you interpret — never empty, so the registry can build its routing table and reject conflicts. |
Execute(ctx, format, script, r) |
interpret script (routed by format) against the read-only data surface r, and return named Outputs. |
Outputs is a map[string]data.Value — each entry commits as its own Ready
datum (a script "sets variables"). The data surface r is service.DataReader
— the same narrow read-only interface a Service Task operation receives:
DataReader method |
Reads |
|---|---|
GetData(name) |
a datum by name (plain name = default scope; "SOURCE/addr" = a named source). |
GetDataByID(id) |
a datum by its ItemDefinition id. |
GetSources() |
the named data sources reachable through the reader. |
List(path) |
variable names at the default scope or a named source. |
Your
Executegets a read-only surface — a script reads process data and returns outputs; it never mutates the data plane directly. The returnedOutputsmap is the only write channel, and the engine commits it.
Registration¶
Register an engine with the repeatable thresher.WithScriptEngine option:
func WithScriptEngine(e script.Engine) thresher.Option
The option is repeatable — each call registers another engine. At
thresher.New, the engines' format claims fold into a routing Registry; a
duplicate claim (two engines answering the same format) fails construction
loudly, so routing stays deterministic and operator-visible.
engine, err := thresher.New("orders",
thresher.WithScriptEngine(lua.New()),
// thresher.WithScriptEngine(starlark.New()), // repeatable — coexist
)
The default is no engines — the empty "##None" registry, whose execution
fails with a wire-an-adapter error. A process with a Script Task must register
at least one engine that claims the task's scriptFormat.
The built-in reference: adapters/lua¶
The batteries engine is adapters/lua — Lua via the pure-Go gopher-lua VM
(no cgo, static builds intact). It is the reference implementation; read it
before writing your own.
package lua
func New() *Engine // stateless — one Engine serves concurrent tracks
func (e *Engine) Type() string // "##Lua"
func (e *Engine) Formats() []string // the claimed scriptFormat MIME hints
func (e *Engine) Execute(ctx, format, body, r) (script.Outputs, error)
Every Execute builds its own sandboxed LState (base/table/string/math only;
io/os never loaded; the load family removed), context-bound so a hung
script aborts on cancellation. Scripts read process data lazily through a
read-only data global — an absent datum raises naming it (fail-loud, not
Lua's nil idiom; probe optional data with has(name)) — and produce outputs by
returning a table of named values (Lua numbers land as float64).
A minimal implementation¶
The whole contract in one type — an engine that echoes its data into an output
(illustrative; a real engine parses and runs script):
type EchoEngine struct{}
func (EchoEngine) Type() string { return "##Echo" }
func (EchoEngine) Formats() []string { return []string{"text/x-echo"} }
func (EchoEngine) Execute(
ctx context.Context, format, script string, r service.DataReader,
) (script.Outputs, error) {
d, err := r.GetData("total") // read process data through the surface
if err != nil {
return nil, err
}
return script.Outputs{ // named outputs commit as Ready data
"echoed": d.Value(),
}, nil
}
Register it exactly like the built-in: thresher.WithScriptEngine(EchoEngine{}).
Because the option is repeatable and each engine owns disjoint formats, your
"##Echo" and the built-in "##Lua" can serve the same process — the task's
scriptFormat picks the interpreter.
How the engine uses it¶
The Script Task carries (scriptFormat, body) from its constructor:
classify, _ := activities.NewScriptTask("classify", "text/x-lua", orderLua)
At construction, thresher.New folds every registered engine into an immutable
script.Registry — itself an Engine, so the task always talks to one
interface no matter how many interpreters are wired. When the task executes, the
registry looks up the engine claiming the task's scriptFormat and calls its
Execute; the returned Outputs commit as named process data the downstream
steps read.
examples/script-task/ wires the Lua engine (thresher.WithScriptEngine(lua.New()))
and runs an embedded order.lua that classifies orders by discount tier.
Running it:
order: tier="vip" total=500
[script] tier=vip total=500 -> 25%
[report] lane=wholesale discount=25%
order: tier="retail" total=150
[script] tier=retail total=150 -> 15%
[report] lane=wholesale discount=15%
order: tier="" total=40
[script] tier=retail total=40 -> 5%
[report] lane=retail discount=5%
✓ script-task completed: three orders classified by the sandboxed Lua script (25/15/5%)
The [script] lines come from Lua's print; the [report] lines are a
downstream Service Task reading the discount_pct and lane outputs the script
committed.
When to reach for it¶
Register a custom script engine when your process authors want to express step
logic as data — an embedded, edit-without-recompile script in a language
they already know — rather than as compiled Go. Reach for the built-in
adapters/lua first; write your own script.Engine only when you need a
different language or a differently-sandboxed runtime. For step logic that stays
in Go, a Service Task operation is the direct path.
See also¶
- Example:
examples/script-task/ - Related guides: Script Task · Custom Operation · Custom rule engine · Custom expression engine
- Design: ADR-031 — Script Task and Script Engine seam
- Full API:
go doc github.com/dr-dobermann/gobpm/pkg/script