packtrail

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Jul 15, 2026 License: Apache-2.0 Imports: 26 Imported by: 0

README

Packtrail

Build Status GoDoc Go Report Card GitHub release

A durable, ecosystem-agnostic workflow engine in Go, backed only by NATS (Core + JetStream + KV + Message Scheduler). Packtrail orchestrates declarative flow graphs — task, fanout, fanin, choice and signal nodes — defined either in YAML or directly as Go structs, with crash-durable state, retries, conditional routing, external signals and timers/cron.

Packtrail's defining feature is that node execution is pluggable. The engine never speaks a wire protocol directly: every task/branch node runs through an Invoker. A project plugs in its own transport — an agent caller, an HTTP client, a NATS request/reply worker — and inherits all of packtrail's durability machinery for free.

flowchart LR
    FLOWS["YAML flows\nor Go structs"] --> Engine["Engine\n(runtime)"]
    Engine <-->|"invoker.Invoke\n(pluggable seam)"| Invokers["Invoker(s)\nagent / http\nnats-task"]
    Invokers --> Services["your services\n(agents, APIs…)"]
    Engine -->|"CAS state / work / timers"| NATS["NATS JetStream + KV\n(the only backend)"]

Installation

go get github.com/henomis/packtrail

Requires Go 1.26+ and a running NATS Server 2.12+ with JetStream enabled (nats-server -js) — packtrail relies on the JetStream Message Scheduler (2.12) for every timer (retry backoff, signal timeouts, cron). Tests embed a real NATS server — no external server needed to run them.

Upgrading across v0.0.x releases: the on-NATS layout (bucket and stream shapes) may change between pre-1.0 versions with no migration tooling. Drain in-flight executions before upgrading, or start the new version under a fresh namespace.

Quick start

nc, _ := nats.Connect(nats.DefaultURL)

srv, _ := packtrail.New(nc,
    packtrail.WithFlowsDir("flows"),           // directory of *.yaml flow files
    packtrail.WithNamespace("acme"),           // isolate from other deployments
    packtrail.WithInvoker("agent", myInvoker), // your transport
    packtrail.WithResultCache(),               // idempotent retries
)

// Register an in-process nats-task worker (optional)
srv.Handle(ctx, "tasks.notify.*", notifyHandler)

id, _ := srv.Start(ctx, "agent-pipeline", payload)
srv.Signal(ctx, id, "approval", data)
ex, _ := srv.Get(ctx, id)

srv.Run(ctx) // blocks: engine + indexer + reconcile + archival

New performs no NATS I/O: it parses and validates the flows, and every bucket and stream is provisioned lazily by the first call that needs NATS (Start, Run, Get, …). Call srv.Init(ctx) explicitly at startup if you want provisioning errors (e.g. JetStream disabled, missing permissions) to fail fast instead of surfacing on first use.

Built-in transport

Packtrail ships the built-in nats-task invoker — a pkg/protocol request/reply on tasks.<x>.* — as the default transport. So:

  • Any task worker that serves the protocol (protocol.Serve on tasks.*) works unchanged — just use the default subject: on a node.
  • New flows can select any registered invoker per node via invoker: + target:.
  • The core has no dependency on any agent framework (enforced by internal/acceptance), so it stays reusable by any project.

For slow nodes there is also the built-in invoker/asyncqueue package, which makes any ordinary Invoker durable and asynchronous — see Async activities.

Flow definition

Flows can be defined in YAML or as Go structs — both paths run through the same validation and produce identical runtime behaviour.

YAML
version: "1.0"
name: agent-pipeline
nodes:
  - {id: triage, type: task, invoker: agent, target: triage-agent,
     timeout: 2m, retry: {max_attempts: 3, backoff: exponential}}
  - id: route
    type: choice
    rules:
      - {when: 'results.triage.category == "billing"', to: billing-agent}
      - {default: true, to: general-agent}
  - {id: billing-agent, type: task, invoker: agent, target: billing-agent}
  - {id: general-agent, type: task, invoker: agent, target: general-agent}
  - {id: notify, type: task, subject: "tasks.notify.{execution_id}"}  # built-in nats-task
edges:
  - {from: triage, to: route}
  - {from: billing-agent, to: notify}
  - {from: general-agent, to: notify}
Go structs

The same flow as a FlowDef, useful when flows are constructed programmatically:

packtrail.WithFlowDef(packtrail.FlowDef{
    Version: "1.0",
    Name: "agent-pipeline",
    Nodes: []packtrail.NodeDef{
        {ID: "triage", Type: "task", Invoker: "agent", Target: "triage-agent",
         Timeout: 2 * time.Minute, Retry: &packtrail.RetryPolicy{MaxAttempts: 3, Backoff: "exponential"}},
        {ID: "route", Type: "choice", Rules: []packtrail.RuleDef{
            {When: `results.triage.category == "billing"`, To: "billing-agent"},
            {Default: true, To: "general-agent"},
        }},
        {ID: "billing-agent", Type: "task", Invoker: "agent", Target: "billing-agent"},
        {ID: "general-agent", Type: "task", Invoker: "agent", Target: "general-agent"},
        {ID: "notify", Type: "task", Subject: "tasks.notify.{execution_id}"},
    },
    Edges: []packtrail.EdgeDef{
        {From: "triage", To: "route"},
        {From: "billing-agent", To: "notify"},
        {From: "general-agent", To: "notify"},
    },
})

WithFlowDef may be combined freely with WithFlow and WithFlowsDir; duplicate flow names across any source are rejected at startup.

Use keyed composite literals for FlowDef and NodeDef. These structs mirror the YAML schema and may grow as the schema gains fields (for example Version and choice-node OnError).

  • invoker: / Invoker selects a registered Invoker kind (default nats-task).
  • target: / Target is interpreted by that Invoker (an agent name, a URL, …); subject: / Subject is the nats-task alias. {execution_id} is substituted at dispatch.
  • retry.backoff / Retry.Backoff accepts exponential, linear, or fixed (default).
  • Flow names, node ids and signal names become NATS subject tokens and KV key segments, so they must match [A-Za-z0-9_-]{1,128} (the namespace prefix: [A-Za-z0-9_-]{1,64}); anything else is rejected at load time.
  • YAML is strict. An unknown field (a typo like retires:) is a parse error, not a silently dropped setting, and a file may hold exactly one flow document (extra --- documents are rejected, so none is silently ignored).
  • Every invoker: kind must be registered. New rejects a flow whose task node names a kind that is neither the built-in nats-task nor registered via WithInvoker/WithAsyncInvoker — a typo'd kind fails at construction, not on the first execution to reach that node. Kind registrations must also be unambiguous: the same custom kind registered twice, both sync and async, or an async kind shadowing nats-task, is a construction error. A sync WithInvoker("nats-task", ...) intentionally replaces the built-in transport.
  • Every node must be reachable. A node not connected to the start node by any edge, choice rule, fanout branch or on_timeout route is rejected — dead graph configuration is almost always a typo'd target.
  • A nats-task subject must be publishable: whitespace or wildcard characters (*, >) are rejected at load; the {execution_id} placeholder is legal.

Node types

task

Invokes an Invoker with the assembled context — {"input": <start payload>, "results": {<node>: <output>, …}, "signals": {<name>: <payload>, …}} — and stores whatever it returns as this node's output. The most common node type.

- id: step
  type: task
  invoker: agent          # registered invoker kind (default: nats-task)
  target: my-agent        # interpreted by the invoker
  timeout: 2m
  retry:
    max_attempts: 3
    backoff: exponential
choice

Routes the execution to one of several branches based on boolean expressions evaluated against the assembled context:

- id: route
  type: choice
  rules:
    - {when: 'results.triage.risk_score > 80', to: manual-review}
    - {when: 'input.category == "billing" && results.triage.amount > 1000', to: billing-agent}
    - {default: true, to: general-agent}
  • Expression language. when uses expr-lang: comparisons (==, !=, <, >), boolean logic (&&, ||, !), membership (in), string and arithmetic operators. Compiled once on load — a syntax error is a validation error, not a runtime surprise.
  • Bounded evaluation. Choice rules run as straight-line predicates with an explicit VM memory budget. To keep evaluation bounded, validation rejects ranges, iteration helpers (map, filter, all, any, sortBy, …), and function calls other than len(...).
  • Variables in scope. input (the start payload), results (each visited node's output, keyed by node id), signals (received signal payloads, keyed by signal name), branches (the current fan's outputs) and last_node (the id of the most recently settled output — "the previous step's result" is results[last_node]). Reach into them with dotted paths: results.triage.risk_score, input.user.tier, signals.approval.granted.
  • First match wins. Rules are evaluated top to bottom. Order from most to least specific.
  • default is required. Validation rejects a choice node without a {default: true, to: …} branch, so a choice can never dead-end.
  • Missing fields fall through. If a when expression errors (e.g. missing field), that rule counts as no match and evaluation continues to the next rule. Add on_error: fail (or NodeDef.OnError: "fail") to fail the execution on an evaluation error instead.
fanout / fanin

Dispatch multiple branches in parallel and join them back:

- id: fan
  type: fanout
  branches: [worker-a, worker-b, worker-c]

- id: join
  type: fanin
  wait_for: [worker-a, worker-b, worker-c]
  join_policy: all          # all | any | quorum:N
  • fanout launches every branch listed in branches as a parallel sub-execution.
  • fanin waits for the branches listed in wait_for according to join_policy:
    • all (default) — advance when every branch completes.
    • any — advance when the first branch completes.
    • quorum:N — advance when at least N branches complete.
  • The fan graph is validated at load: a node may be a branch of at most one fanout, every wait_for node must be some fanout's branch, and fanout/fanin nodes must not lie on a cycle (branch state is per-execution, not per-visit, so a revisit would reuse it). Adjacency is checked too: every branch must be a task node (any other type would never settle), a fanout's single outgoing edge must lead to a fanin (that is where the execution parks and the join is evaluated), and that fanin may only wait for branches of its own fanout — waiting on a subset is fine (join on the critical branches, let the rest settle in the background).
signal

Parks the execution until an external signal arrives (or the timeout fires):

- id: wait-approval
  type: signal
  signal_name: approval
  timeout: 24h
  on_timeout: escalation    # node to jump to on timeout

Send the signal from your application:

srv.Signal(ctx, execID, "approval", json.RawMessage(`{"approved": true}`))
// If the caller may retry after an ambiguous publish result:
srv.SignalWithID(ctx, execID, "approval", "request-123", json.RawMessage(`{"approved": true}`))

The signal payload is stored in the data plane — downstream nodes and choice rules see it as signals.approval — and execution resumes at the next node. If timeout elapses first, the execution advances to on_timeout instead. An on_timeout without a positive timeout is rejected at load — the route could never fire.

Signals are durable and forgiving about ordering: a signal sent before the execution reaches its signal node is stored and consumed on arrival, and one sent just before the execution is created is redelivered until the execution exists. A genuinely orphaned signal (e.g. a typo'd execution id) is dead-lettered after the delivery cap instead of vanishing silently. Timeouts are evaluated by the NATS Message Scheduler at roughly one-second granularity, so sub-second timeout values fire at the next tick. Use SignalWithID when a caller needs an idempotency key for ambiguous publish retries; duplicate publishes with the same key collapse within the signal stream's dedupe window.

Async activities (long-running work)

An Invoker normally returns a terminal status (StatusOK/Error/Retry) and the engine settles the node synchronously. For long-running work (an agent call, a remote job) an Invoker can instead return StatusPending: the engine parks the execution as waiting and frees its work slot immediately, without blocking. The activity is settled later via Server.CompleteActivityWithGeneration(ctx, execID, node, generation, attempt, result) — OK to advance, Error to fail, Retry to re-dispatch per the node policy. Use the Generation from the original Request; it fences stale completions from an earlier legal cycle or Resume visit of the same node/attempt. The legacy CompleteActivity(ctx, execID, node, attempt, result) remains available when no generation is available. Completion is idempotent and robust to a completion that arrives before the task has finished parking, so an at-least-once worker can call it freely. This works for plain task nodes and fan-out branches alike.

You rarely need to wire that plumbing by hand. The invoker/asyncqueue package turns any ordinary synchronous Invoker into a durable asynchronous one: register it with WithAsyncInvoker and packtrail dispatches matching nodes to a JetStream work-queue (returning StatusPending for you), runs your Invoker on an in-process worker pool off the engine's critical path, and settles the result via CompleteActivityWithGeneration — with bounded queues, at-least-once delivery, generation-aware dispatch dedup, ack-extending heartbeats and crash redelivery all handled for you.

// Your slow work is just a normal Invoker — no queue/ack/heartbeat code.
exec := packtrail.InvokerFunc(func(ctx context.Context, req packtrail.Request) (packtrail.Result, error) {
    out, err := callSlowService(ctx, req.Target, req.Payload) // an agent, an API, …
    if err != nil {
        return packtrail.Result{}, err // transient → retried per the node policy
    }
    return packtrail.Result{Status: packtrail.StatusOK, Payload: out}, nil
})

srv, _ := packtrail.New(nc,
    packtrail.WithAsyncInvoker("agent", exec,
        asyncqueue.WithConcurrency(64)), // tune the worker (optional)
    // … plus flows whose nodes select `invoker: agent`
)

Each kind gets its own work-queue stream, so many workers — in or out of process — can share it to scale horizontally; the low-level asyncqueue.Dispatcher and asyncqueue.Worker are exported for out-of-process workers.

Doing it by hand

For a bespoke transport you can implement the two halves yourself: return StatusPending from your Invoker after enqueuing a durable job, and call CompleteActivityWithGeneration from the worker that runs it.

// dispatch (non-blocking): enqueue a durable job, return pending
func (d *dispatcher) Invoke(ctx context.Context, req packtrail.Request) (packtrail.Result, error) {
    enqueueJob(req.ExecutionID, req.NodeID, req.Generation, req.Attempt, req.Payload) // your durable queue
    return packtrail.Result{Status: packtrail.StatusPending}, nil
}

// later, from the worker that ran the job:
srv.CompleteActivityWithGeneration(ctx, execID, node, generation, attempt,
    packtrail.Result{Status: packtrail.StatusOK, Payload: out})

Resuming failed executions

A failed execution can be revived with Resume. It re-runs the node it failed on with a fresh retry budget, preserving the durable state and every stored output. Any running engine for the namespace picks up the resumed work.

err := srv.Resume(ctx, execID)

Cron scheduling

Start a flow on a recurring schedule with ScheduleFlow. The cron expression is 6-field (sec min hour dom mon dow):

// trigger "daily-report" at 08:00 every day
srv.ScheduleFlow(ctx, "daily-report-schedule", "daily-report", "0 0 8 * * *", nil)

Calling ScheduleFlow again with the same name replaces the existing schedule.

To also run periodic visibility reconciliation, configure it at startup. There are two independent, durable schedules: a cheap active-set pass over in-flight executions and an authoritative full scan as the deep backstop:

packtrail.WithReconcileActive("0 */5 * * * *") // in-flight execs, every 5 minutes
packtrail.WithReconcileFull("0 0 * * * *")     // full scan, hourly

To keep the executions bucket (and the scans over it) bounded, enable archival. Completed executions are swept into a cold archive bucket on the full-reconcile schedule and kept for the retention window; failed executions stay hot so they remain resumable:

packtrail.WithArchive(30 * 24 * time.Hour) // keep completed execs queryable for 30 days

Durability model

Two design rules make crashes boring:

  • Control plane vs data plane. The execution document (one KV entry, CAS-guarded) holds only control state: current node, node-visit generation, attempt, branches, which outputs exist. Every payload — the start input, each node's output, each signal — is its own entry in a separate payloads bucket, written before the transition that references it commits. The document never grows with payload bytes, and a flow's size is bounded per-output (see WithMaxPayloadBytes), not per-flow. Read the assembled view with Server.Results(ctx, id) — the same {input, results, signals, branches, last_node} document invokers and choice rules see.
  • Transactional outbox. Every state transition commits its follow-on work (the next work item, a retry timer, a join re-evaluation) in the same CAS write, then a flush publishes it (deduplicated by msg-id). A crash between commit and publish leaves the message durably on the document, where the next delivery, completion, or the stall watchdog (run by the reconcile-active schedule — see WithStallRedrive) re-flushes it. State and the work that drives it can never disagree.

With WithHistory(retention), every transition is also appended to a durable per-execution trace, queryable via Server.History(ctx, id, limit) — the step-by-step story of a run, kept for the configured retention.

Writing an Invoker

An Invoker is the bridge between packtrail and your ecosystem:

type Invoker interface {
    Invoke(ctx context.Context, req Request) (Result, error)
}

Request carries the resolved Target, the shared Payload (opaque JSON), the node-visit Generation, the Attempt number and a Deadline. Return Result{Status: StatusOK, Payload: out} to advance with a new node output, StatusError to fail the node, or StatusRetry (or a non-nil error) to retry per the node's policy.

A StatusOK payload becomes this node's output, stored as its own data-plane entry and visible to every downstream node as results.<node>. Outputs never merge into a shared document, so any JSON shape is legal (the start input alone must be an object, for expression ergonomics). Return an empty payload to record no output for the node.

Idempotency under at-least-once delivery

Packtrail is durable because it may redeliver: if an engine crashes after invoking a node but before persisting the advance, the work item is redelivered. Wrap invocations in the result cache (WithResultCache()) so a redelivery of the same (execution, node, generation, attempt) returns the stored result instead of re-running the side effect, while a genuine retry (a new attempt), a Resume, or a legal cycle revisit still re-invokes. Enable it whenever invocations have side effects that must not run twice (LLM calls, writes, e-mails). See invoker/cache.go.

The cache covers both invocation paths: the engine-side dispatch (including an async node's StatusPending, so a redelivered work item re-parks instead of dispatching a second job) and the async worker's execution of your Invoker (under a separate keyspace in the same bucket, so a job redelivered after a worker crash serves the completed result instead of re-firing the side effect).

Server options

Option Default Description
WithNamespace(prefix) "packtrail" Prefix for every NATS resource; isolates deployments on a shared cluster
WithFlowsDir(dir) Load all *.yaml/*.yml files in dir
WithFlow(yamlDoc) Register a single flow from an inline YAML document; may be called multiple times
WithFlowDef(f) Register a single flow from a FlowDef Go struct; may be combined with WithFlow/WithFlowsDir
WithInvoker(kind, inv) Register an Invoker under kind; overrides the built-in "nats-task" if reused
WithAsyncInvoker(kind, exec, opts…) Register an async Invoker under kind: nodes dispatch to a bounded durable work-queue and exec runs on a hosted worker pool (see invoker/asyncqueue)
WithResultCache() disabled Cache invocation results by (execution, node, generation, attempt) for idempotent retries — engine dispatch and async worker execution alike; entries expire after the cache TTL
WithResultCacheTTL(d) 24h Result-cache entry TTL (implies WithResultCache); a negative value disables expiry
WithReconcileActive(cronExpr) Schedule the cheap active-set reconcile over in-flight executions (6-field cron); each pass also runs the stall watchdog
WithStallRedrive(d) 5× ack wait Stall watchdog threshold: an active execution quiet past d — outside any retry backoff and not lease-held — gets its work item re-driven (heals lost work after a crash); negative disables
WithReconcileFull(cronExpr) Schedule the authoritative full reconcile; also runs fired-schedule reclaim, archival sweep and index GC. Keep it well below the active cadence
WithArchive(retention) disabled Sweep completed executions into a cold archive bucket retained for retention; bounds the hot bucket while keeping retained archive records queryable/idempotent. Runs on the full-reconcile schedule
WithSignalRetention(d) 7d Signal stream retention and dedupe-window ceiling; raise if executions may wait through a longer outage
WithOwnerID(id) random Stable per-instance lease owner id
WithLeaseTTL(d) 30s Ownership lease TTL; a contender may take over after observing the same foreign lease revision unchanged for roughly this long
WithMaxConcurrency(n) 64 Max work items processed concurrently per instance
WithDefaultTimeout(d) 30s Invocation timeout for nodes that omit one
WithMaxDeliver(n) 10 Deliveries of a work item, fired schedule or signal before it is dead-lettered instead of retried forever; non-positive values are treated as the default (the cap cannot be disabled)
WithDrainTimeout(d) 30s Graceful-shutdown window for in-flight work to settle before stragglers are abandoned to redelivery
WithMaxPayloadBytes(n) 512 KiB Cap on a single payload entry (start input, one node's output, one signal); an over-limit output fails its node with a clear reason (negative disables)
WithMaxDocumentBytes(n) 768 KiB Cap on the execution control document; protects very wide fanouts or large outboxes from opaque NATS size errors (negative disables)
WithHistory(retention) disabled Durable per-execution transition trace in a <ns>-history stream, queryable via Server.History for retention

Observability (packtrail-ui)

cmd/packtrail-ui is a read-only web dashboard for any packtrail deployment. It connects to the same NATS cluster, reads execution state and the flow registry (every flow's graph is published to a KV bucket at startup), and tails the live event stream — so it needs no access to your flow source or engine process.

go run ./cmd/packtrail-ui --namespace packtrail --addr :8088   # NATS_URL honoured

It serves an embedded (no-npm) dashboard: a filterable execution list, a detail view (status, current node, payload, branches, signals, error), and an SVG flow graph with the live execution overlaid, updated in real time over SSE. The backing API is also usable directly:

endpoint returns
GET /api/flows flow names
GET /api/flows/{name} flow graph (FlowGraph)
GET /api/executions[?status=&flow=] execution summaries
GET /api/executions/{id} execution control-state snapshot
GET /api/executions/{id}/results assembled {input, results, signals, branches, last_node} context
GET /api/executions/{id}/history ordered transition trace (?limit=; empty unless WithHistory)
GET /api/deadletters dead-letter count + recent records
GET /api/events live transitions (Server-Sent Events)

The same data is available programmatically via Server:

// flows
names, _ := srv.ListFlows(ctx)
graph, _ := srv.FlowGraph(ctx, "agent-pipeline")

// executions
ids, _ := srv.ByStatus(ctx, packtrail.ExecRunning)
ids, _ := srv.ByFlow(ctx, "agent-pipeline")
ex, _ := srv.Get(ctx, execID)

// live event stream
events, _ := srv.WatchEvents(ctx)
for ev := range events {
    fmt.Println(ev.ExecID, ev.Status, ev.Node)
}

WatchEvents delivers events published after the call. Load current state via Get/ByStatus first, then apply events live to avoid races.

Development

go build ./...
go test -race ./...   # all packages run against a real embedded nats-server
go vet ./...
gofmt -l .

make build-ui         # self-contained packtrail-ui binary in bin/ (assets embedded)

License

Apache 2.0 — see LICENSE.

Documentation

Overview

Package packtrail is the public, embeddable entry point to the packtrail durable workflow engine. packtrail orchestrates declarative YAML flow graphs — task, fanout, fanin, choice and signal nodes — with crash-durable state backed only by NATS (Core + JetStream + KV + Message Scheduler).

packtrail is ecosystem-agnostic: nodes are executed through a pluggable Invoker, so any project can drive its own services (an agent caller, an HTTP client, a NATS request/reply worker) while inheriting durability, retries, fan-in policies, conditional routing, signals and timers. A built-in "nats-task" Invoker (pkg/protocol request/reply) is always registered.

nc, _ := nats.Connect(nats.DefaultURL)
srv, _ := packtrail.New(nc,
    packtrail.WithFlowsDir("flows"),
    packtrail.WithInvoker("agent", myInvoker),  // your ecosystem's transport
    packtrail.WithResultCache(),                // idempotent retries
)
id, _ := srv.Start(ctx, "research-pipeline", nil)
srv.Run(ctx) // blocks: engine + indexer

The Server does not own the *nats.Conn it is given; the caller connects and closes it.

Index

Constants

View Source
const (
	StatusOK      = invoker.StatusOK
	StatusError   = invoker.StatusError
	StatusRetry   = invoker.StatusRetry
	StatusPending = invoker.StatusPending
)

Invocation outcome statuses.

View Source
const (
	TaskOK    = protocol.StatusOK
	TaskError = protocol.StatusError
	TaskRetry = protocol.StatusRetry
)

Built-in nats-task worker response statuses (the string values a Handler sets on a TaskResponse). For Invoker implementations, use the Status* constants.

View Source
const (
	ExecRunning   = store.StatusRunning
	ExecWaiting   = store.StatusWaiting
	ExecCompleted = store.StatusCompleted
	ExecFailed    = store.StatusFailed
	ExecCancelled = store.StatusCancelled
)

Execution statuses.

View Source
const NATSTaskKind = natstask.Kind

NATSTaskKind is the invoker kind of the always-registered built-in transport.

Variables

View Source
var ErrNotFound = store.ErrNotFound

ErrNotFound is returned by Get when an execution does not exist.

Functions

func ValidateFlowDef added in v0.1.0

func ValidateFlowDef(defs ...FlowDef) error

ValidateFlowDef validates one or more FlowDefs against the full flow-graph rules — node/edge structure, a unique start node, choice defaults, fan-in join policy, retry bounds, etc. — without a NATS connection, so a builder can verify programmatic flows offline (e.g. in a `validate` command) and catch the same errors New would raise at startup. It returns the first validation error (which already names the offending flow).

Types

type Branch

type Branch struct {
	Node       string `json:"node"`
	Status     string `json:"status"`
	Generation uint64 `json:"generation,omitempty"`
	Attempt    int    `json:"attempt"`
	Error      string `json:"error,omitempty"`
}

Branch is the control state of a single fan-out branch; a completed branch's result appears under results.<branch> in Server.Results.

type DeadLetter added in v0.1.0

type DeadLetter = store.DeadLetter

DeadLetter is a durable record of a work item a durable consumer gave up on (Term'd) — a terminal error or an exhausted delivery cap. Kind is the source consumer ("work", "schedule", "signal" or "async").

type EdgeDef added in v0.0.2

type EdgeDef struct {
	From string
	To   string
}

EdgeDef connects two nodes in a FlowDef.

type Event

type Event struct {
	ExecID   string    `json:"exec_id"`
	Flow     string    `json:"flow"`
	Status   string    `json:"status"`
	Node     string    `json:"node"`
	Error    string    `json:"error,omitempty"`
	Revision uint64    `json:"revision"`
	Time     time.Time `json:"time"`
}

Event is a flow execution transition, suitable for a live activity feed.

type Execution

type Execution struct {
	ID             string            `json:"id"`
	Flow           string            `json:"flow"`
	Status         string            `json:"status"`
	CurrentNode    string            `json:"current_node"`
	NodeGeneration uint64            `json:"node_generation,omitempty"`
	Attempt        int               `json:"attempt"`
	Outputs        []string          `json:"outputs,omitempty"`         // node ids with a stored output, in settle order
	OutputVersions map[string]string `json:"output_versions,omitempty"` // committed versioned output keys, per node
	Branches       map[string]Branch `json:"branches,omitempty"`
	Signals        []string          `json:"signals,omitempty"` // received-but-unconsumed signal names
	WaitSignal     string            `json:"wait_signal,omitempty"`
	Error          string            `json:"error,omitempty"`
	UpdatedAt      time.Time         `json:"updated_at"`
}

Execution is a read-only snapshot of a flow instance's control state. Payloads live in the data plane and are not carried on the snapshot — read them with Server.Results, which assembles {input, results, signals, branches, last_node}.

type FlowDef added in v0.0.2

type FlowDef struct {
	Version string // "1.0"; empty is accepted for legacy parity with YAML
	Name    string
	Nodes   []NodeDef
	Edges   []EdgeDef
}

FlowDef is a programmatic flow definition. It mirrors the YAML schema and can be passed to WithFlowDef instead of writing YAML.

type FlowGraph

type FlowGraph struct {
	Version string      `json:"version,omitempty"`
	Name    string      `json:"name"`
	Nodes   []GraphNode `json:"nodes"`
	Edges   []GraphEdge `json:"edges"`
}

FlowGraph is the static structure of a flow, for visualisation. It is published to a KV registry at startup so observability tools can render a flow without its source YAML.

type GraphEdge

type GraphEdge struct {
	From string `json:"from"`
	To   string `json:"to"`
}

GraphEdge is a static edge between two nodes.

type GraphNode

type GraphNode struct {
	ID         string      `json:"id"`
	Type       string      `json:"type"` // task | fanout | fanin | choice | signal
	Invoker    string      `json:"invoker,omitempty"`
	Target     string      `json:"target,omitempty"`
	Branches   []string    `json:"branches,omitempty"`
	WaitFor    []string    `json:"wait_for,omitempty"`
	JoinPolicy string      `json:"join_policy,omitempty"`
	Rules      []GraphRule `json:"rules,omitempty"`
	OnError    string      `json:"on_error,omitempty"`
	SignalName string      `json:"signal_name,omitempty"`
	OnTimeout  string      `json:"on_timeout,omitempty"`
}

GraphNode is one node of a FlowGraph. Fields are type-specific; empty ones are omitted.

type GraphRule

type GraphRule struct {
	When    string `json:"when,omitempty"`
	Default bool   `json:"default,omitempty"`
	To      string `json:"to"`
}

GraphRule is one routing rule of a choice node.

type Handler

type Handler = protocol.Handler

Handler implements a nats-task worker's business logic.

type Invoker

type Invoker = invoker.Invoker

Invoker executes a single node invocation. Implement it to plug in a transport for your ecosystem.

type InvokerFunc

type InvokerFunc = invoker.Func

InvokerFunc adapts a plain function to Invoker.

type NodeDef added in v0.0.2

type NodeDef struct {
	ID   string
	Type string // "task" | "fanout" | "fanin" | "choice" | "signal"

	// task
	Invoker string
	Target  string
	Subject string
	Timeout time.Duration
	Retry   *RetryPolicy

	// fanout
	Branches []string

	// fanin
	WaitFor    []string
	JoinPolicy string // "all" | "any" | "quorum:N"

	// choice
	Rules   []RuleDef
	OnError string // "" routes eval errors to the default rule; "fail" fails the execution

	// signal
	SignalName string
	OnTimeout  string
}

NodeDef is a single node in a FlowDef.

type Option

type Option func(*config)

Option configures a Server. Pass options to New.

func WithArchive added in v0.1.0

func WithArchive(retention time.Duration) Option

WithArchive enables execution archival: completed executions are swept out of the hot executions bucket into a cold archive bucket that retains them for roughly retention before they expire. This bounds the hot bucket — and the List/Keys and full-reconcile scans over it — by in-flight volume rather than all-time volume. The sweep runs on the full-reconcile schedule (see WithReconcileFull), so pair the two. Failed executions are left hot so they remain resumable. Without this option the hot bucket retains every execution.

func WithAsyncInvoker added in v0.1.0

func WithAsyncInvoker(kind string, exec invoker.Invoker, opts ...asyncqueue.Option) Option

WithAsyncInvoker registers an asynchronous Invoker under kind. Unlike WithInvoker, exec does not run on the engine's critical path: a flow node selecting this kind is dispatched to a durable JetStream work-queue (the engine parks the execution) and exec is run later by an in-process worker, with at-least-once delivery. Use it for slow nodes — an agent call, an HTTP request — so they never hold an engine slot. exec is an ordinary synchronous Invoker returning StatusOK/StatusError/StatusRetry; the durability, retries and completion are handled for you. opts tune the worker and its stream (see the asyncqueue package). It may be passed multiple times for distinct kinds.

Delivery to exec is at-least-once: a job redelivered after a worker crash (invoked, but not yet settled and acked) runs exec again. For a target whose side effects must not fire twice, enable WithResultCache — it dedups the worker's execution as well — or make the target idempotent.

func WithDefaultTimeout

func WithDefaultTimeout(d time.Duration) Option

WithDefaultTimeout sets the invocation timeout used when a node omits one (default 30s).

func WithDrainTimeout added in v0.1.0

func WithDrainTimeout(d time.Duration) Option

WithDrainTimeout sets how long a graceful shutdown waits for in-flight work items to settle and ack before aborting the stragglers (default 30s). When Run returns because its context was cancelled, the engine stops accepting new work and drains what is already running within this window — so a clean restart does not abandon in-flight invocations to redelivery (which would double-fire naturally non-idempotent targets). Stragglers exceeding the window are cancelled and their work redelivers. A hard crash is unaffected (it always relies on redelivery).

func WithFlow

func WithFlow(yamlDoc []byte) Option

WithFlow registers a single flow from its YAML document. It may be passed multiple times.

func WithFlowDef added in v0.0.2

func WithFlowDef(f FlowDef) Option

WithFlowDef registers a single flow from a Go struct. It may be passed multiple times and combined with WithFlow / WithFlowsDir.

func WithFlowsDir

func WithFlowsDir(dir string) Option

WithFlowsDir loads every *.yaml / *.yml flow definition in dir.

func WithHistory added in v0.1.0

func WithHistory(retention time.Duration) Option

WithHistory enables the durable per-execution history: every state transition is also appended, best-effort, to a `<namespace>-history` stream retained for the given duration, and Server.History returns an execution's ordered step-by-step trace. Without it, transition events live only in the short-retention events stream that feeds the visibility indexes. The trace is observability, not operational truth.

func WithInvoker

func WithInvoker(kind string, inv invoker.Invoker) Option

WithInvoker registers an Invoker under kind, the value a flow node selects via its `invoker:` field. The built-in "nats-task" kind is always registered and may be overridden by passing WithInvoker("nats-task", ...). It may be passed multiple times for distinct kinds; duplicate kinds are rejected by New.

func WithLeaseTTL

func WithLeaseTTL(d time.Duration) Option

WithLeaseTTL sets the per-execution ownership lease TTL (default 30s). A crashed instance's executions become available to others after roughly this.

func WithMaxConcurrency

func WithMaxConcurrency(n int) Option

WithMaxConcurrency caps how many work items this instance processes at once (default 64).

func WithMaxDeliver added in v0.1.0

func WithMaxDeliver(n int) Option

WithMaxDeliver caps how many times a single message is delivered before the engine dead-letters it instead of redelivering forever (default 10). It bounds the blast radius of a message that can never succeed (e.g. its flow or node was removed) or one that keeps hitting a transient fault. The cap applies to all three of the engine's durable consumers: the work stream (failing the execution with a descriptive reason), the fired-schedule stream (a removed-flow cron tick or persistently-failing reconcile), and the signal stream. A terminal error on any of them is dead-lettered immediately, regardless of this cap. A non-positive value is treated as the default — the cap cannot be disabled, or a poisoned message could redeliver forever. (The async invoker worker has its own asyncqueue.WithMaxDeliver, which does allow an explicit opt-out.)

func WithMaxDocumentBytes added in v0.1.0

func WithMaxDocumentBytes(n int) Option

WithMaxDocumentBytes caps the serialized size of an execution's control document (default 768 KiB, store.DefaultMaxDocumentBytes). The document is small control metadata, but a very wide fanout (one BranchState per branch) or a large transient outbox can grow it toward NATS's 1 MiB ceiling; a write that would exceed the limit is rejected with a typed error (and, on the fanout path, fails the node with a clear reason) instead of an opaque NATS publish error. Pass a negative value to disable the guard; zero keeps the default.

func WithMaxPayloadBytes added in v0.1.0

func WithMaxPayloadBytes(n int) Option

WithMaxPayloadBytes caps the size of an execution's payload (default 512 KiB, store.DefaultMaxPayloadBytes). The payload grows as task results, branch results and signal payloads are merged in; a transition that would exceed the limit fails the execution with a clear reason instead of producing an opaque KV write error. The default leaves headroom below NATS's 1 MiB max message size for the rest of the execution document. Pass a negative value to disable the guard; zero keeps the default.

func WithNamespace

func WithNamespace(prefix string) Option

WithNamespace sets the resource prefix for every NATS bucket, stream, subject and durable consumer (default "packtrail"). Give each independent deployment a distinct namespace to let them share a NATS cluster without colliding.

func WithOwnerID

func WithOwnerID(id string) Option

WithOwnerID sets this instance's ownership-lease owner id. Defaults to a random id; only set it if you need a stable, distinct id per instance.

func WithReconcileActive added in v0.1.0

func WithReconcileActive(cronExpr string) Option

WithReconcileActive installs the recurring active-set reconcile: a cheap pass over only the in-flight (running/waiting) executions, on the given 6-field cron expression ("sec min hour dom mon dow"), e.g. "0 */5 * * * *". Its cost is independent of accumulated terminal executions, so it is safe to run often. It fixes the common drift where a finished execution is still indexed as active, but cannot recover an execution missing from the index entirely.

func WithReconcileFull added in v0.1.0

func WithReconcileFull(cronExpr string) Option

WithReconcileFull installs the recurring full reconcile: an authoritative scan of every execution on the given 6-field cron expression, e.g. "0 0 * * * *" for hourly. It is the deep backstop that recovers index entries the active pass cannot see, and it also runs low-frequency maintenance such as reclaiming consumed scheduler firings; its cost grows with total execution volume, so schedule it well below the active cadence. Without either option the indexer still runs but no periodic reconcile is scheduled.

func WithResultCache

func WithResultCache() Option

WithResultCache enables idempotent invocation: every node result is cached by (execution, node, attempt) in a KV bucket, so a re-invocation returns the cached result instead of running the node again. Node invocation is otherwise at-least-once — a work item redelivered after a crash, or a lease taken over while an instance is paused, can run the same node twice (the execution-doc CAS fences state, not external side-effects). Enable this (or make targets idempotent) whenever an invocation has a side effect that must not run twice. The cache covers both invocation paths: the engine-side dispatch (including an async node's StatusPending, so a redelivered work item re-parks instead of dispatching a second job) and the async worker's execution of the target (under a separate keyspace in the same bucket, so a redelivered job serves the completed result instead of re-firing the side effect). Cache entries expire after a TTL (default 24h — see WithResultCacheTTL): an entry is only ever consulted during the redelivery window of its own attempt, so retaining it longer would just grow the bucket without bound.

func WithResultCacheTTL added in v0.1.0

func WithResultCacheTTL(d time.Duration) Option

WithResultCacheTTL enables the result cache with a custom entry TTL. Entries need only outlive the redelivery window of a single attempt (ack wait × delivery cap), so the 24h default is already generous; raise it if your redelivery horizon is unusually long. A negative TTL disables expiry (the bucket then grows without bound — the pre-TTL behaviour). Implies WithResultCache.

func WithSignalRetention added in v0.1.0

func WithSignalRetention(d time.Duration) Option

WithSignalRetention sets how long the signals stream retains messages (its MaxAge) — the window during which an undelivered signal survives an engine outage before it is dropped. A positive duration sets that window; a negative value disables the age limit (retain until the stream's other limits); zero keeps the default (7 days). Raise it if executions may wait for a signal through a longer outage than a week.

func WithStallRedrive added in v0.1.0

func WithStallRedrive(d time.Duration) Option

WithStallRedrive sets the quiet-time threshold after which the stall watchdog re-drives an active execution (default 5× the engine ack wait; a negative value disables the watchdog). The watchdog runs after each reconcile-active pass (see WithReconcileActive — without that schedule it only runs via Server.RedriveStalled), and it heals executions whose driving work item was lost in a crash window: still active, quiet past the threshold, not inside a scheduled retry backoff, and not lease-held by a live instance. Set the threshold above your longest legitimate quiet period; a false positive is state-safe (guarded transitions) but duplicates an invocation within the at-least-once contract.

type Request

type Request = invoker.Request

Request is the invocation passed to an Invoker.

type Result

type Result = invoker.Result

Result is what an Invoker returns.

type RetryPolicy added in v0.0.2

type RetryPolicy struct {
	MaxAttempts int
	Backoff     string // "exponential" | "linear" | "fixed"
}

RetryPolicy controls task retries for a NodeDef.

type RuleDef added in v0.0.2

type RuleDef struct {
	When    string
	Default bool
	To      string
}

RuleDef is one branch of a choice node.

type Server

type Server struct {
	// contains filtered or unexported fields
}

Server is an embeddable packtrail engine instance: it runs the work consumer, visibility indexer and (optionally) reconciliation, and can host built-in nats-task workers in the same process.

Construction is pure (see New); every NATS resource is provisioned by Init — explicitly for fail-fast startups, or implicitly by the first operation that needs it.

func New

func New(nc *nats.Conn, opts ...Option) (*Server, error)

New builds a Server against an existing NATS connection. It parses and validates the configured flows and options but performs no NATS I/O: every bucket and stream is provisioned by Init, which the first operation that needs NATS (Start, Run, Get, …) calls implicitly with its own context. Call Init yourself at startup to surface provisioning errors fail-fast.

func (*Server) ArchiveTerminal added in v0.1.0

func (s *Server) ArchiveTerminal(ctx context.Context) (int, error)

ArchiveTerminal sweeps terminal, non-resumable executions (completed or cancelled) out of the hot bucket into the cold archive; failed executions stay hot so they remain resumable. It is a no-op unless archival is enabled (see WithArchive). The full-reconcile schedule runs it automatically; it is exported for manual or test-driven sweeps.

func (*Server) ByFlow

func (s *Server) ByFlow(ctx context.Context, flow string) ([]string, error)

ByFlow returns the ids of executions belonging to flow.

func (*Server) ByFlowEvents added in v0.0.2

func (s *Server) ByFlowEvents(ctx context.Context, flow string) ([]Event, error)

ByFlowEvents returns a summary event for every execution belonging to flow, read directly from the visibility index without a per-execution round-trip.

func (*Server) ByFlowEventsLimit added in v0.1.0

func (s *Server) ByFlowEventsLimit(ctx context.Context, flow string, limit int) ([]Event, error)

ByFlowEventsLimit is ByFlowEvents capped at limit entries (0 = no cap). The same arbitrary-subset caveat as ByStatusEventsLimit applies.

func (*Server) ByStatus

func (s *Server) ByStatus(ctx context.Context, status string) ([]string, error)

ByStatus returns the ids of executions currently indexed under status. The index is eventually consistent (best-effort visibility).

func (*Server) ByStatusEvents added in v0.0.2

func (s *Server) ByStatusEvents(ctx context.Context, status string) ([]Event, error)

ByStatusEvents returns a summary event for every execution currently indexed under status, read directly from the visibility index without a per-execution round-trip. The index is eventually consistent; use Get for authoritative state.

func (*Server) ByStatusEventsLimit added in v0.1.0

func (s *Server) ByStatusEventsLimit(ctx context.Context, status string, limit int) ([]Event, error)

ByStatusEventsLimit is ByStatusEvents capped at limit entries (0 = no cap). Since KV keys have no inherent order the cap yields an arbitrary subset, not an ordered page; it is a guardrail against an unbounded transfer.

func (*Server) Cancel added in v0.1.0

func (s *Server) Cancel(ctx context.Context, execID, reason string) error

Cancel transitions a running or waiting execution to the terminal cancelled state with an optional reason (stored on the execution's error field). It is idempotent and stale-safe: cancelling an already-terminal execution is a no-op, and any in-flight work — pending retries, fanin joins, signal waits, or an async activity later settled via CompleteActivity — no-ops once the execution is cancelled. A cancelled execution is terminal and, unlike a failed one, cannot be resumed.

func (*Server) Close

func (s *Server) Close()

Close drains any registered task workers. It does not close the NATS connection, which the caller owns.

func (*Server) CompleteActivity

func (s *Server) CompleteActivity(ctx context.Context, execID, node string, attempt int, res Result) error

CompleteActivity settles an asynchronous activity using the legacy attempt-only identity. Prefer CompleteActivityWithGeneration when Request.Generation is available so stale completions from earlier node visits cannot settle a later legal cycle or resume.

func (*Server) CompleteActivityWithGeneration added in v0.1.0

func (s *Server) CompleteActivityWithGeneration(
	ctx context.Context, execID, node string, generation uint64, attempt int, res Result,
) error

CompleteActivityWithGeneration settles an asynchronous activity for a specific node visit generation. Use Request.Generation from the original asynchronous dispatch to prevent stale completions from earlier legal cycles or resumes settling a later visit with the same node/attempt.

func (*Server) DeadLetterCount added in v0.1.0

func (s *Server) DeadLetterCount(ctx context.Context) (uint64, error)

DeadLetterCount returns the durable number of dead-letter records retained in the dead-letter stream (bounded by its ~30-day retention). It is the operational signal that poisoned work is being dropped; a non-zero, growing count warrants investigation.

func (*Server) FlowGraph

func (s *Server) FlowGraph(ctx context.Context, name string) (*FlowGraph, error)

FlowGraph returns a flow's graph from the registry, or ErrNotFound.

func (*Server) Flows

func (s *Server) Flows() []string

Flows returns the names of the flows this server knows.

func (*Server) GCIndex added in v0.1.0

func (s *Server) GCIndex(ctx context.Context) (int, error)

GCIndex prunes index entries whose execution has expired out of the archive. It is a no-op unless archival is enabled. The full-reconcile schedule runs it automatically; it is exported for manual or test-driven runs.

func (*Server) Get

func (s *Server) Get(ctx context.Context, execID string) (*Execution, error)

Get returns a snapshot of an execution, or ErrNotFound. The execution KV is the source of truth; read it (not the indexes) for correctness decisions.

func (*Server) Handle

func (s *Server) Handle(ctx context.Context, subject string, h Handler) error

Handle registers a built-in nats-task worker for subject (NATS wildcards allowed, e.g. "tasks.triage.*") in this process. The namespace prefix is prepended automatically, so the worker subscribes to "<namespace>.tasks.triage.*". ctx is the worker's lifetime: every handler invocation derives its context from it, so cancelling ctx cancels in-flight handlers. The subscription itself is drained when Run returns or Close is called.

func (*Server) History added in v0.1.0

func (s *Server) History(ctx context.Context, execID string, limit int) ([]Event, error)

History returns an execution's ordered transition trace (oldest first, up to limit; non-positive = a generous default). It requires WithHistory — otherwise it returns nothing — and records expire after the configured retention.

func (*Server) Init added in v0.1.0

func (s *Server) Init(ctx context.Context) error

Init provisions every NATS resource the Server needs — KV buckets, streams, the flow-graph registry — and builds the engine on top of them. It is idempotent and safe for concurrent use, and a failed attempt leaves the Server unprovisioned so a later call retries. Calling it explicitly at startup gives fail-fast provisioning errors; otherwise the first operation that needs NATS calls it implicitly, bounded by that operation's ctx.

func (*Server) List

func (s *Server) List(ctx context.Context) ([]string, error)

List returns the execution ids in the hot bucket. With archival enabled (see WithArchive) this is the active set plus recently-completed executions, not every execution ever — archived executions are still readable via Get but are not listed here. Without archival it is every execution.

func (*Server) ListFlows

func (s *Server) ListFlows(ctx context.Context) ([]string, error)

ListFlows returns the names of every flow in the registry. Unlike Flows() (the flows this Server instance loaded), this reads the shared KV registry, so an observer process that loaded no flows still sees them.

func (*Server) ListFunc added in v0.1.0

func (s *Server) ListFunc(ctx context.Context, fn func(id string) error) error

ListFunc streams the hot-bucket execution ids to fn instead of materialising them into a slice, so a large active set can be rendered incrementally and a caller can stop early by returning a sentinel error (returned as-is). It covers the same set as List.

func (*Server) RecentDeadLetters added in v0.1.0

func (s *Server) RecentDeadLetters(ctx context.Context, limit int) ([]DeadLetter, error)

RecentDeadLetters returns up to limit of the most recent dead-letter records, oldest-first (limit <= 0 uses a sane default). Use it to inspect what poisoned work was dropped and why, without scanning logs.

func (*Server) Reconcile

func (s *Server) Reconcile(ctx context.Context) error

Reconcile rebuilds the visibility indexes from the source of truth with a full scan of every execution. It is the authoritative deep backstop; its cost grows with total execution volume, so schedule it sparingly (the scheduled reconcile already runs it periodically — see Run).

func (*Server) ReconcileActive added in v0.1.0

func (s *Server) ReconcileActive(ctx context.Context) error

ReconcileActive re-asserts the indexes for only the in-flight (running or waiting) executions. It is cheap enough to run frequently and fixes the common drift where a finished execution is still indexed as active; it does not catch active executions missing from the index entirely, for which Reconcile is the backstop.

func (*Server) RedriveStalled added in v0.1.0

func (s *Server) RedriveStalled(ctx context.Context) (int, error)

RedriveStalled scans the in-flight executions the visibility index knows of and re-drives any that look stranded — active, quiet past the stall threshold (WithStallRedrive; default 5× the engine ack wait), outside any scheduled retry backoff, and with no live ownership lease. It heals executions whose committed follow-on messages were never flushed (a crash between the outbox commit and its publish) without a manual Resume. The reconcile-active schedule runs it automatically after each index pass; it is exported for manual or test-driven runs. It returns how many executions were re-driven.

func (*Server) Results added in v0.1.0

func (s *Server) Results(ctx context.Context, execID string) (json.RawMessage, error)

Results assembles an execution's data-plane view — {"input": <start payload>, "results": {<node>: <output>, …}, "signals": {<name>: <payload>, …}} — the same context document invokers and choice rules see. Entries of an archived execution are dropped by the archive sweep, so Results of an archived id returns only what remains.

func (*Server) Resume

func (s *Server) Resume(ctx context.Context, execID string) error

Resume revives a failed execution, re-running the node it failed on with a fresh retry budget (the durable payload is preserved). Only failed executions can be resumed. It is durable: any running engine for the namespace picks up the resumed work.

func (*Server) Run

func (s *Server) Run(ctx context.Context) error

Run starts the engine, the visibility indexer and (if configured) the reconciliation schedule, and blocks until ctx is cancelled. Registered task workers are drained on return.

func (*Server) ScheduleFlow

func (s *Server) ScheduleFlow(ctx context.Context, name, flow, cronExpr string, payload json.RawMessage) error

ScheduleFlow installs a recurring schedule named name that starts flow on the given 6-field cron expression ("sec min hour dom mon dow"). Reusing name replaces the schedule.

Timing semantics (the JetStream Message Scheduler's, NATS 2.12+): the cron expression is evaluated in UTC, not the server's or the caller's local time zone. Ticks that would have fired while the NATS server was down are skipped, not replayed — after recovery the schedule resumes at its next occurrence, so downtime spanning N ticks starts zero executions for them, never N.

func (*Server) Signal

func (s *Server) Signal(ctx context.Context, execID, name string, payload json.RawMessage) error

Signal sends an external signal to an execution.

func (*Server) SignalWithID added in v0.1.0

func (s *Server) SignalWithID(
	ctx context.Context, execID, name, idempotencyKey string, payload json.RawMessage,
) error

SignalWithID sends an external signal with a caller-supplied idempotency key. Reusing the same key for the same execution/signal within the signal stream's duplicate window collapses ambiguous publish retries into one stream entry.

func (*Server) Start

func (s *Server) Start(ctx context.Context, flow string, payload json.RawMessage) (string, error)

Start creates a new execution of flow with the given initial payload and returns its (freshly minted) id. The payload must be a JSON object (the keyed context that task/branch/signal results merge into); nil or empty defaults to {}, and a non-object is rejected with an error.

func (*Server) StartWithID added in v0.1.0

func (s *Server) StartWithID(ctx context.Context, execID, flow string, payload json.RawMessage) (string, error)

StartWithID is an idempotent Start keyed by a caller-supplied execution id (an idempotency key): the first call creates the execution, and any retry with the same id and the same arguments returns that id without creating a duplicate. Use it to make Start safe to retry — e.g. key it on a domain id so a timed-out Start does not spawn a second execution. First-write wins, and reuse is checked: a call whose flow or payload differs from what the id is already bound to returns an error instead of silently reporting the existing execution as its own — retries must replay byte-identical arguments. The id must match [A-Za-z0-9_-]{1,128}. Like Start, the payload must be a JSON object (or empty).

func (*Server) WatchEvents

func (s *Server) WatchEvents(ctx context.Context) (<-chan Event, error)

WatchEvents streams execution transitions as they happen. It delivers events published after the call (an ephemeral consumer with DeliverNew); load current state via Get/ByStatus first, then apply events live. The channel is closed when ctx is cancelled.

type Status

type Status = invoker.Status

Status is an invocation outcome.

type TaskRequest

type TaskRequest = protocol.TaskRequest

TaskRequest is the envelope delivered to a nats-task handler.

type TaskResponse

type TaskResponse = protocol.TaskResponse

TaskResponse is the envelope a nats-task handler returns.

Directories

Path Synopsis
cmd
packtrail-ui command
Command packtrail-ui is a read-only observability dashboard for a packtrail deployment.
Command packtrail-ui is a read-only observability dashboard for a packtrail deployment.
examples
approval command
Command approval demonstrates human-in-the-loop control: a signal node parks the execution until an external approval arrives (with a timeout fallback), Cancel abandons an execution an operator no longer wants, and WithHistory records a durable step-by-step trace queryable with Server.History.
Command approval demonstrates human-in-the-loop control: a signal node parks the execution until an external approval arrives (with a timeout fallback), Cancel abandons an execution an operator no longer wants, and WithHistory records a durable step-by-step trace queryable with Server.History.
async command
Command async demonstrates long-running work off the engine's critical path.
Command async demonstrates long-running work off the engine's critical path.
custom-invoker command
Command custom-invoker demonstrates packtrail's defining feature: the pluggable Invoker seam.
Command custom-invoker demonstrates packtrail's defining feature: the pluggable Invoker seam.
embedded command
Command embedded shows packtrail running as a single microservice: the engine and the task workers live in one process, importing only the public github.com/henomis/packtrail package.
Command embedded shows packtrail running as a single microservice: the engine and the task workers live in one process, importing only the public github.com/henomis/packtrail package.
worker command
Command worker is a tiny external task service for the research-pipeline flow, demonstrating the pkg/protocol request/reply contract without importing the packtrail engine at all.
Command worker is a tiny external task service for the research-pipeline flow, demonstrating the pkg/protocol request/reply contract without importing the packtrail engine at all.
internal
dsl
Package dsl parses and validates Packtrail Flow Definitions (YAML) and exposes graph-walk helpers used by the runtime.
Package dsl parses and validates Packtrail Flow Definitions (YAML) and exposes graph-walk helpers used by the runtime.
names
Package names centralises every NATS resource name Packtrail uses — KV buckets, streams, subject prefixes and durable consumer names — derived from a single namespace prefix.
Package names centralises every NATS resource name Packtrail uses — KV buckets, streams, subject prefixes and durable consumer names — derived from a single namespace prefix.
natstest
Package natstest starts an embedded, in-process nats-server with JetStream enabled for use in tests.
Package natstest starts an embedded, in-process nats-server with JetStream enabled for use in tests.
rules
Package rules compiles and evaluates the boolean expressions used by choice nodes.
Package rules compiles and evaluates the boolean expressions used by choice nodes.
runtime
Package runtime is the packtrail execution engine: it walks flow graphs, invokes nodes through a pluggable Invoker, and drives fanout/fanin, choice and signal nodes.
Package runtime is the packtrail execution engine: it walks flow graphs, invokes nodes through a pluggable Invoker, and drives fanout/fanin, choice and signal nodes.
scheduler
Package scheduler wraps the NATS JetStream Message Scheduler.
Package scheduler wraps the NATS JetStream Message Scheduler.
signal
Package signal carries external signals to waiting executions.
Package signal carries external signals to waiting executions.
store
Package store wraps the NATS JetStream KV buckets and streams that hold all Packtrail state: executions, ownership leases, visibility indexes and the domain event stream.
Package store wraps the NATS JetStream KV buckets and streams that hold all Packtrail state: executions, ownership leases, visibility indexes and the domain event stream.
visibility
Package visibility maintains the eventually-consistent status/flow indexes (spec §9).
Package visibility maintains the eventually-consistent status/flow indexes (spec §9).
Package invoker defines packtrail's agnostic node-invocation contract.
Package invoker defines packtrail's agnostic node-invocation contract.
asyncqueue
Package asyncqueue is packtrail's durable asynchronous Invoker: it makes any ordinary synchronous Invoker run off the engine's critical path with at-least-once delivery.
Package asyncqueue is packtrail's durable asynchronous Invoker: it makes any ordinary synchronous Invoker run off the engine's critical path with at-least-once delivery.
natstask
Package natstask is packtrail's built-in Invoker: a NATS request/reply caller that speaks the pkg/protocol envelope.
Package natstask is packtrail's built-in Invoker: a NATS request/reply caller that speaks the pkg/protocol envelope.
pkg
protocol
Package protocol defines the public NATS request/reply contract between the Packtrail engine and the remote services that implement workflow tasks.
Package protocol defines the public NATS request/reply contract between the Packtrail engine and the remote services that implement workflow tasks.

Jump to

Keyboard shortcuts

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