Documentation

The full Packtrail reference.

Everything needed to embed the engine, declare flows, plug in your transport, and operate a deployment. Packtrail is a durable, NATS-only workflow engine: every state transition is a compare-and-swap write to JetStream KV, so a crashed instance loses no progress.

Getting started

Installation

Packtrail is a Go module. Add it to your project and import the root packtrail package — the single public entry point. It requires Go 1.26+ and a reachable NATS Server 2.12+ with JetStream enabled — packtrail relies on the JetStream Message Scheduler (2.12) for every timer.

shell
$ go get github.com/henomis/packtrail

# run a NATS server with JetStream for local development
$ nats-server -js

The Server never owns the *nats.Conn you give it — your code connects and closes the connection.


Getting started

Quick start

Build a Server against a NATS connection, register a flow and an Invoker, start an execution, then call Run to drive the engine. A single binary can host the engine and its workers.

main.go
nc, _ := nats.Connect(nats.DefaultURL)
defer nc.Close()

srv, err := packtrail.New(nc,
    packtrail.WithFlowsDir("flows"),         // load *.yaml flows from disk
    packtrail.WithInvoker("agent", myInvoker), // your transport
)
if err != nil { log.Fatal(err) }

// Kick off an execution with an initial JSON payload.
id, _ := srv.Start(ctx, "research-pipeline",
    []byte(`{"topic":"nats"}`))
log.Printf("started %s", id)

// Run blocks: it drives the work consumer + visibility indexer
// until ctx is cancelled. Registered workers drain on return.
if err := srv.Run(ctx); err != nil {
    log.Fatal(err)
}
No processing happens until Run is called. 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 (JetStream disabled, missing permissions) to fail fast instead of surfacing on first use.

Getting started

Core concepts

Four invariants explain everything else in this reference.

State on disk, never only in memory

Every execution's control state — current node, status, attempt, branches, which outputs exist — lives as one document in the packtrail-executions KV bucket; each transition is a compare-and-swap write conditioned on the current KV revision. Every payload (the start input, each node's output, each signal) is its own entry in a separate packtrail-payloads bucket, written before the transition that references it commits. Each transition also commits its follow-on work (the next work item, a retry timer, a join re-evaluation) in the same CAS write — a transactional outbox — so state and the work that drives it can never disagree. Crash an engine mid-flight and another instance resumes from the durable state.

One backend: NATS

JetStream KV holds execution state, streams provide the durable work queue and event log, and the Message Scheduler runs every timer (retry backoff, signal timeouts, cron starts). There is no second datastore to run, scale or back up.

Ownership leases, not locks — invocation is at-least-once

A durable pull consumer distributes work across all engine instances; a per-execution lease (TTL, see WithLeaseTTL) is heartbeat-renewed while an instance processes a work item. When an instance crashes, its heartbeats stop and another instance takes the lease over on the next redelivery — no coordinator, no lock to leak. Takeover is clock-skew-immune: a contender must observe the lease unchanged for a full TTL on its own monotonic clock before treating it as stale, so a skewed wall clock can never seize a lease that is still being heartbeat-renewed. The lease is not a hard mutual-exclusion lock: the execution-doc CAS fences state, but it cannot un-fire an external side effect, so node invocation is at-least-once. For non-idempotent targets, enable WithResultCache or make the target idempotent.

The Invoker is the only seam

The engine never speaks a wire protocol itself. Every task node and fan-out branch runs through an Invoker. Plug in any transport — an agent caller, an HTTP client, the built-in NATS worker — and inherit retries, fan-in policies, choice routing, signals and timers for free.


Flow definitions

Anatomy of a flow

A flow describes a graph of nodes connected by edges. It can be provided as a YAML document or constructed programmatically as a FlowDef Go struct — both are parsed, validated, and treated identically at runtime. Top-level fields:

FieldDescription
versionSchema version string, e.g. "1.0".
nameUnique flow name; used by Start and the flow registry. Required.
nodesList of nodes. Each has an id and a type (task, fanout, fanin, choice or signal). Required, non-empty.
edgesList of {from, to} static transitions. A node may have at most one outgoing edge.

Flows are loaded with WithFlowsDir (every *.yaml / *.yml in a directory), WithFlow (one inline YAML document, repeatable), or WithFlowDef (one FlowDef Go struct, repeatable). All three can be combined freely. Duplicate flow names across any source are rejected at startup.

Validation is strict, so mistakes fail at load time rather than mid-execution:

  • YAML is strict. An unknown field (a typo like retires:) is a parse error, and a file may hold exactly one flow document — extra --- documents are rejected, so none is silently ignored.
  • 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}.
  • Every invoker: kind must be registered. A flow naming a kind that is neither the built-in nats-task nor registered via WithInvoker/WithAsyncInvoker is rejected by New.
  • Every node must be reachable from the start node via an edge, choice rule, fanout branch or on_timeout route — dead graph configuration is almost always a typo'd target.
flows/research-pipeline.yaml
version: "1.0"
name: research-pipeline
nodes:
  - {id: triage, type: task, invoker: agent, target: triage-agent,
     timeout: 2m, retry: {max_attempts: 3, backoff: exponential}}
  - {id: fan,  type: fanout, branches: [tech, market]}
  - {id: tech,   type: task, invoker: agent, target: tech-agent}
  - {id: market, type: task, invoker: agent, target: market-agent}
  - {id: join, type: fanin, wait_for: [tech, market], join_policy: all}
edges:
  - {from: triage, to: fan}
  - {from: fan,    to: join}

Flow definitions · node types

task

A task node dispatches a single invocation through the configured Invoker — with the assembled context {"input": …, "results": {…}, "signals": {…}} as its payload — and stores whatever it returns as this node's output, visible downstream as results.<node>. It is the only node type that calls out to your code.

FieldDescription
invokerInvoker kind to dispatch through. Defaults to nats-task when omitted.
targetInvoker-specific target — an agent name, a URL, a subject. Takes precedence over subject.
subjectAlias for target, kept for nats-task flows. One of target / subject is required.
timeoutPer-attempt deadline, e.g. 2m, 30s. Falls back to WithDefaultTimeout (30s).
retryOptional {max_attempts, backoff} policy — see below.

Retry policy

retry.max_attempts must be >= 0. retry.backoff is one of exponential, linear, or fixed (the default when omitted). Backoff delays are scheduled durably through the NATS Message Scheduler, so a waiting retry holds no engine resources and survives restarts. A node is retried when its Invoker returns StatusRetry or a non-nil error; StatusError fails it permanently.

Placeholders

The token {execution_id} in a target / subject is replaced with the concrete execution id before invocation — e.g. subject: "tasks.notify.{execution_id}".


Flow definitions · node types

fanout

A fanout node starts several branches in parallel. Each branch id must reference another node in the flow; those branch nodes run concurrently, each through the Invoker, and their results are persisted in KV so they are never recomputed on engine takeover.

FieldDescription
branchesList of node ids to launch in parallel. Required, non-empty.
Branch nodes are reached only via the fan-out, so they do not count as start nodes and do not need an inbound edge. A branch may itself return StatusPending to run asynchronously.

The fan graph is validated at load: every branch must be a task node, a node may be a branch of at most one fanout, a fanout's single outgoing edge must lead to a fanin (that is where the execution parks and the join is evaluated), and fanout/fanin nodes must not lie on a cycle. Inside a fan, choice rules can also read branches — the current fan's outputs.


Flow definitions · node types

fanin

A fanin node joins parallel branches and proceeds according to its join policy.

FieldDescription
wait_forList of branch node ids to join on — branches of this fanin's own fanout; a subset is fine (join on the critical branches, let the rest settle in the background). Required, non-empty.
join_policyHow many branches must finish — see below. Defaults to all.

Join policies

  • all — wait for every branch in wait_for (the default).
  • any — proceed as soon as one branch completes.
  • quorum:N — proceed once N branches complete. Validation requires 0 < N <= len(wait_for).

The join is re-evaluated whenever a branch settles, including async branches.


Flow definitions · node types

choice

A choice node routes to the first matching rule. Rules are expr-lang expressions evaluated against the assembled context, compiled at startup (not per execution).

FieldDescription
rulesOrdered list of {when, to} rules plus exactly one {default: true, to}. At least one rule and a default are required.
on_errorWhat a rule expression that errors at evaluation time does: omit (the default) to treat it as no match, or fail to fail the execution — see below.

Each rule's to must reference a known node. A non-default rule needs a when expression; the first rule whose when is true wins, and the default rule is the fallback when none match. The expression environment exposes 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) and expr-lang's comparison, boolean and membership operators (==, &&, ||, in, …). Rules are bounded routing predicates, not general programs: range expressions, iteration constructs (filter, map, …) and function calls other than len() are rejected at load time, and evaluation runs under a fixed memory budget — a rule can never loop or blow up over an attacker-sized payload.

A when that errors at evaluation time — most commonly because it references a field the payload doesn't have — is treated as no match (logged at warn level), and evaluation continues to the next rule, ultimately reaching default. An expression that returns a non-boolean is likewise an error and counts as no match. Order rules from most to least specific, since the first match wins. For safety-relevant routing where silently falling through to default would be wrong, set on_error: fail on the node — an erroring rule then fails the execution instead.

choice node
- id: route
  type: choice
  rules:
    - {when: 'results.triage.risk_score > 80',  to: escalation}
    - {when: 'input.region in ["EU","UK"]',      to: gdpr_review}
    - {default: true,                           to: synthesis}

Flow definitions · node types

signal

A signal node pauses the execution until a named external signal arrives. It is the primitive for human approvals and waiting on external events.

FieldDescription
signal_nameThe name the external caller delivers via Server.Signal. Required.
timeoutHow long to wait before taking the timeout route, e.g. 24h. Evaluated by the Message Scheduler at roughly one-second granularity.
on_timeoutNode id to route to when the deadline passes. Must reference a known node, and requires a positive timeout — a route that could never fire is rejected at load.

Signal consumption is durable and idempotent — signals dedupe by JetStream sequence, so redeliveries are safe; a publisher that retries the publish itself should use SignalWithID (below) so retries dedupe too. The timeout is scheduled through the Message Scheduler and survives restarts. Ordering is forgiving: 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. The received payload is visible to downstream nodes and choice rules as signals.<name>. Deliver a signal with:

delivering a signal
srv.Signal(ctx, execID, "approval", []byte(`{"by":"alice"}`))

// retry-safe publish: reusing the idempotency key collapses
// ambiguous publish retries into a single stream entry.
srv.SignalWithID(ctx, execID, "approval", "req-42", []byte(`{"by":"alice"}`))

If your publisher retries on ambiguous errors (a timeout where the publish may or may not have landed), use SignalWithID with a caller-supplied idempotency key — retries within the stream's duplicate window collapse into one entry instead of creating duplicate signals.


Flow definitions

Edges & the start node

Edges define static transitions between nodes as {from, to} pairs. Validation enforces a few rules that make the graph unambiguous:

  • Both endpoints of an edge must reference existing nodes.
  • A node may have at most one outgoing edge — branching is expressed by choice / fanout, not by multiple edges.
  • A node with no outgoing edge is terminal; reaching it completes that path.
  • Branch targets (fan-out branches, choice to, signal on_timeout, fan-in wait_for) count as having an inbound transition.
  • There must be exactly one start node — the single node with no inbound transition. Zero or multiple start nodes is a validation error.

Execution

The Invoker

An Invoker executes a single node invocation. Implement one method and your services inherit all of packtrail's durability machinery. The contract is re-exported from the root packtrail package so embedding apps depend only on it.

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

// InvokerFunc adapts a plain function to the Invoker interface.
agent := packtrail.InvokerFunc(func(ctx context.Context, req packtrail.Request) (packtrail.Result, error) {
    out, err := callAgent(ctx, req.Target, req.Payload)
    if err != nil {
        return packtrail.Result{}, err // transient → retried per node policy
    }
    return packtrail.Result{Status: packtrail.StatusOK, Payload: out}, nil
})

packtrail.WithInvoker("agent", agent) // register under a kind

Request

The engine hands the Invoker everything it needs, transport-agnostic:

FieldDescription
InvokerThe invoker kind selected for this node.
TargetInvoker-specific target, with {execution_id} already resolved.
ExecutionIDThe owning execution id.
NodeIDThe node being executed.
PayloadThe assembled execution context as raw JSON: {"input": …, "results": {…}, "signals": {…}}.
Attempt0-based attempt counter for retries.
GenerationExecution-scoped visit generation for this node — distinguishes re-visits from legal cycles or Resume from retries of the same visit.
DeadlineHard deadline for this attempt.

Result & statuses

A Result carries a Status, an optional Payload (this node's output) and an optional Error string. A non-nil error from Invoke is treated as a transient transport failure (equivalent to StatusRetry) and is never cached. A StatusOK payload is stored as its own data-plane entry and is visible to every downstream node as results.<node> — outputs never merge into a shared document, so any JSON shape is legal (only the start input must be an object).

StatusMeaning
StatusOKNode succeeded; Payload is stored as the node's output and the flow advances.
StatusErrorNode failed permanently; the engine does not retry.
StatusRetryRetry per the node's retry policy.
StatusPendingDispatched asynchronously; the engine parks the execution and frees its slot. See async activities.

Idempotent invocations

Enable WithResultCache() to memoise results by (execution, node, visit generation, attempt) in a KV bucket. A work item redelivered after a crash returns the cached result instead of re-invoking; a genuine retry (a new attempt) still calls the Invoker, and a legal cycle or Resume that revisits the node (a new generation) never replays a stale cached result. Concurrent redeliveries of the same attempt are collapsed too: the first caller claims the invocation and the others wait for its result. Turn it on whenever an invocation has side effects that must not run twice. The cache covers both invocation paths — the engine-side dispatch and the async worker's execution of your Invoker — and entries expire after a TTL (default 24h, tunable via WithResultCacheTTL).


Execution

Built-in nats-task

The nats-task invoker is always registered — a task node that sets a subject (and no invoker) uses it. It does a NATS request/reply on that subject using the pkg/protocol request/reply wire format. You can host a worker in the same process with Server.Handle:

in-process nats-task worker
// subject may contain wildcards; workers join queue group "packtrail-workers".
srv.Handle(ctx, "tasks.notify.*", func(ctx context.Context, req packtrail.TaskRequest) (packtrail.TaskResponse, error) {
    notify(req.Payload)
    return packtrail.TaskResponse{Status: packtrail.TaskOK}, nil
})

A TaskRequest carries ExecutionID, NodeID, Payload, Attempt and Deadline. The handler returns a TaskResponse with a status of TaskOK, TaskError or TaskRetry; returning a non-nil error is reported to the engine as a retry.


Execution

Async activities

Long-running work (an agent call, a human task, an external job) should not hold a work slot. An Invoker that returns StatusPending parks the execution in the waiting state and frees the slot immediately — a single instance can have thousands of inflight async activities. The activity is settled later with CompleteActivity.

You rarely wire that by hand. The built-in invoker/asyncqueue package turns any ordinary synchronous Invoker into a durable async one: register it with WithAsyncInvoker and packtrail dispatches matching nodes to a JetStream work-queue, runs your Invoker on a hosted worker pool, and settles the activity for you — with at-least-once delivery, dispatch dedup, generation-aware completion and ack-extending heartbeats handled automatically. The work-queue itself is bounded (job-count and byte limits, tunable with asyncqueue.WithMaxQueuedJobs / WithMaxQueuedBytes): when workers fall behind past the limit, new dispatches are shed with an error and retried per the node's policy instead of growing the stream until the cluster hurts.

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

srv, _ := packtrail.New(nc,
    packtrail.WithAsyncInvoker("agent", exec, asyncqueue.WithConcurrency(64)),
)

The mechanism the package is built on — implement it yourself only for a bespoke transport:

by hand: dispatch then settle
// 1) dispatch: enqueue durable work, park the execution.
dispatch := packtrail.InvokerFunc(func(ctx context.Context, req packtrail.Request) (packtrail.Result, error) {
    enqueueJob(req.ExecutionID, req.NodeID, req.Attempt, req.Payload)
    return packtrail.Result{Status: packtrail.StatusPending}, nil
})

// 2) later, from the worker that finished the job:
srv.CompleteActivity(ctx, execID, nodeID, attempt,
    packtrail.Result{Status: packtrail.StatusOK, Payload: out})
Idempotent and stale-safe. CompleteActivity is keyed by (execution, node, attempt). A duplicate completion, or one for the wrong attempt, is a silent no-op — so an at-least-once worker can call it freely. Use Request.NodeID and Request.Attempt to identify the dispatched work. This works for plain task nodes and fan-out branches alike. When the flow can revisit the node — a legal cycle, or Resume after a failure — prefer CompleteActivityWithGeneration with Request.Generation from the original dispatch, so a stale completion from an earlier visit can never settle a later one that reuses the same node and attempt.

Execution

Signals & resuming

A signal node waits for an external event. Deliver one with Server.Signal(ctx, execID, name, payload); consumption is idempotent, and SignalWithID makes the publish itself retry-safe via an idempotency key. Received signal payloads are visible on the execution snapshot under Signals, and the name a node is currently waiting on appears as WaitSignal.

Resuming a failed execution

Server.Resume(ctx, execID) revives a failed execution, re-running the node it failed on with a fresh retry budget. The durable state and every stored output are preserved, and only failed executions can be resumed. It is durable — any running engine in the namespace picks up the resumed work.

Cancelling an execution

Server.Cancel(ctx, execID, reason) transitions a running or waiting execution to the distinct terminal cancelled status — separate from failed, so it is observably operator-driven and Resume (failed-only) won't revive it. Cancellation is abandon, not interrupt: in-flight work is not stopped, but pending work items, join evaluations, signal timeouts and a late CompleteActivity all no-op once the execution is non-active. It is idempotent — a no-op on already-terminal or missing executions.


Execution

Scheduling & cron

Recurring flow starts, retry backoff and signal timeouts all run through the JetStream Message Scheduler — the engine never keeps an in-process timer, so every schedule survives restarts. Install a recurring start with a 6-field cron expression (sec min hour dom mon dow):

schedule a recurring flow
// run "research-pipeline" every day at 02:00; reusing the name replaces it.
srv.ScheduleFlow(ctx, "nightly", "research-pipeline", "0 0 2 * * *", nil)

Periodic reconciliation of the visibility indexes uses the same cron format via WithReconcileActive (in-flight executions) and WithReconcileFull (full scan; also runs the archive sweep).


Execution

Statuses & lifecycle

An execution moves between five durable statuses:

StatusMeaning
runningThe engine is actively progressing the execution (ExecRunning).
waitingParked on a signal or an async activity; no work slot held (ExecWaiting).
completedA terminal node was reached successfully (ExecCompleted).
failedA node failed permanently; can be revived with Resume (ExecFailed).
cancelledTerminated by an operator via Cancel; terminal and not resumable (ExecCancelled).

A read-only Execution snapshot (from Get) exposes the control state: ID, Flow, Status, CurrentNode, Attempt, Outputs (which node outputs exist), per-branch state (Branches), received Signals, the active WaitSignal, any Error and UpdatedAt. Payloads live in the data plane and are not carried on the snapshot — read the assembled {input, results, signals} view with Server.Results(ctx, id). The execution KV is the source of truth — read it (not the indexes) for correctness decisions.


Reference

Server API

The methods on *packtrail.Server:

MethodDescription
New(nc, …opts)Build a server: parse and validate flows, register invokers. No NATS I/O — resources are provisioned lazily on first use.
Init(ctx)Provision every bucket and stream eagerly, so setup errors fail fast instead of surfacing on first use. Optional.
Run(ctx)Start the engine, indexer and (if configured) the reconcile and archival schedules; blocks until ctx is cancelled. Drains in-flight work on return (see WithDrainTimeout).
Start(ctx, flow, payload)Create a new execution with a random id and return it.
StartWithID(ctx, execID, flow, payload)Idempotent start with a caller-supplied id (idempotency key, [A-Za-z0-9_-]{1,128}); a retry returns the existing id.
ScheduleFlow(ctx, name, flow, cron, payload)Install/replace a recurring schedule that starts a flow.
Signal(ctx, execID, name, payload)Deliver an external signal to an execution.
SignalWithID(ctx, execID, name, key, payload)Deliver a signal with a caller-supplied idempotency key — ambiguous publish retries collapse into one stream entry.
CompleteActivity(ctx, execID, node, attempt, res)Settle an async activity previously reported as StatusPending. Idempotent.
CompleteActivityWithGeneration(ctx, execID, node, gen, attempt, res)Generation-fenced settlement using Request.Generation — preferred when flows have legal cycles or use Resume.
Resume(ctx, execID)Revive a failed execution with a fresh retry budget.
Cancel(ctx, execID, reason)Transition a running/waiting execution to the terminal cancelled status. Abandon, not interrupt; idempotent.
Get(ctx, execID)Return an Execution control-state snapshot, or ErrNotFound.
Results(ctx, execID)The assembled {input, results, signals} data-plane view — what invokers and choice rules see.
History(ctx, execID, limit)Ordered per-execution transition trace (requires WithHistory).
ByStatus(ctx, status)Execution ids indexed under a status (eventually consistent).
ByFlow(ctx, flow)Execution ids belonging to a flow.
ByStatusEventsLimit(ctx, status, n)Up to n summary events under a status (guardrail cap; ByFlowEventsLimit for flows).
List(ctx) / ListFunc(ctx, fn)Hot-bucket execution ids (active + recently-terminal when archival is on; every execution otherwise). ListFunc streams them.
ListFlows(ctx) / FlowGraph(ctx, name)Registered flow names; a flow's full graph (nodes + edges) from the flow registry.
WatchEvents(ctx)Live channel of execution transitions published after the call.
DeadLetterCount(ctx) / RecentDeadLetters(ctx, n)Durable dead-letter stream depth; the most recent dead-letter records.
Reconcile(ctx) / ReconcileActive(ctx)Rebuild the visibility indexes from the source of truth — full scan, or just in-flight executions.
RedriveStalled(ctx)Run the stall watchdog once: re-drive active executions quiet past the threshold (see WithStallRedrive).
ArchiveTerminal(ctx) / GCIndex(ctx)Sweep terminal non-resumable executions into the cold archive; prune index entries orphaned by archive expiry. No-ops unless archival is enabled.
Flows()Names of the flows this server knows.
Handle(ctx, subject, h)Register an in-process built-in nats-task worker.
Close()Drain registered workers. Does not close the NATS connection.

Reference

Configuration options

Options passed to packtrail.New:

WithNamespace(prefix)Prefix every bucket, stream, subject and durable (default "packtrail") — isolate deployments on a shared cluster.
WithFlowsDir(dir)Load every *.yaml / *.yml flow definition in a directory at startup.
WithFlow(yamlDoc)Register a single flow from an inline YAML document; repeatable.
WithFlowDef(f)Register a single flow from a FlowDef Go struct; repeatable, combinable with the options above.
WithInvoker(kind, inv)Register a custom Invoker under a kind; the built-in nats-task is always present and may be overridden.
WithAsyncInvoker(kind, exec, opts…)Register an async Invoker: nodes of this kind dispatch to a durable work-queue and exec runs on a hosted worker pool (see async activities).
WithResultCache()Cache invocation results by (execution, node, visit generation, attempt) for idempotent redeliveries — engine dispatch and async worker execution alike.
WithResultCacheTTL(d)Result-cache entry TTL (default 24h; implies WithResultCache); a negative value disables expiry.
WithHistory(retention)Durable per-execution transition trace in a <ns>-history stream, queryable via Server.History for retention.
WithReconcileActive(cron)Schedule the cheap active-set reconcile over in-flight executions (6-field cron); each pass also runs the stall watchdog.
WithStallRedrive(d)Stall-watchdog quiet-time threshold (default 5× ack wait): an active execution quiet past d — outside any retry backoff and not lease-held — gets its work re-driven. Negative disables.
WithReconcileFull(cron)Schedule the authoritative full reconcile; also runs the archive sweep, index GC and low-frequency maintenance such as reclaiming consumed scheduler firings. Keep it well below the active cadence.
WithArchive(retention)Sweep terminal non-resumable executions into a cold archive bucket retained for retention, bounding the hot store. Failed executions stay hot so they remain resumable. Runs on the full-reconcile schedule.
WithMaxConcurrency(n)Cap concurrent work items per instance (default 64).
WithDefaultTimeout(d)Invocation timeout for nodes that omit one (default 30s).
WithMaxDeliver(n)Deliveries of a work item, fired schedule or signal before it is dead-lettered instead of retried forever (default 10; the cap cannot be disabled).
WithDrainTimeout(d)Graceful-shutdown window for in-flight work to settle before stragglers are abandoned to redelivery (default 30s).
WithMaxPayloadBytes(n)Cap on a single data-plane entry — the start input, one node's output, one signal payload (default 512 KiB; negative disables). An over-limit output fails its node with a clear reason.
WithMaxDocumentBytes(n)Cap on an execution's serialized control document (default 768 KiB) — guards very wide fanouts against NATS's 1 MiB ceiling with a typed error instead of an opaque publish failure. Negative disables.
WithSignalRetention(d)How long the signals stream retains messages (default 7 days) — the window an undelivered signal survives an engine outage. Negative disables the age limit.
WithLeaseTTL(d)Per-execution ownership lease TTL (default 30s); a crashed instance's work frees after roughly this.
WithOwnerID(id)Stable ownership-lease owner id; defaults to a random id per instance.

Reference

NATS resources

Every resource is prefixed with the namespace (default packtrail), so independent deployments can share a cluster. The buckets and streams a deployment creates:

packtrail-executionsKV · control plane, source of truth
packtrail-payloadsKV · data plane: inputs, outputs, signals
packtrail-leasesKV · ownership (TTL)
packtrail-workstream · work queue
packtrail-eventsstream · domain events
packtrail-signalsstream · external signals
packtrail-schedulescheduler · timers & cron
packtrail-flowsKV · flow registry
packtrail-result-cacheKV · idempotency (opt-in)
packtrail-executions-archiveKV · cold archive (opt-in)
packtrail-deadletterstream · dead letters (~30d)
packtrail-historystream · per-exec trace (opt-in)
packtrail-idx-statusKV · projection
packtrail-idx-flowKV · projection

Each flow's graph is published to packtrail-flows at startup, so observability tools can render flows without access to the source files or the engine process.


Reference

Observability · packtrail-ui

packtrail-ui is a read-only dashboard that connects to the same NATS cluster, reads execution state and the flow registry, and tails the live event stream — no engine process or source files required. It reads NATS_URL (default nats://localhost:4222).

start the dashboard
$ go run ./cmd/packtrail-ui --namespace packtrail --addr :8088
EndpointReturns
GET /api/flowsNames of all registered flows.
GET /api/flows/{name}Full FlowGraph (nodes + edges).
GET /api/executionsExecution summaries; filter with ?status= or ?flow=.
GET /api/executions/{id}Execution control-state snapshot.
GET /api/executions/{id}/resultsAssembled {input, results, signals} context (data plane).
GET /api/executions/{id}/historyOrdered transition trace (?limit=; empty unless WithHistory).
GET /api/deadlettersDead-letter count and recent dead-letter records.
GET /api/eventsLive Server-Sent Events stream of every transition.

The same data is available programmatically through the Server API (ListFlows, FlowGraph, Get, ByStatus, ByFlow, DeadLetterCount, RecentDeadLetters) and, for events, the WatchEvents live channel. With WithHistory enabled, Server.History returns an execution's ordered step-by-step trace.

Dead letters

No message can loop forever: work items, fired schedules, signals and async jobs are all capped by a delivery limit (WithMaxDeliver), and a message that can never succeed — an unknown flow, a removed node — is dead-lettered immediately. Every dead letter is durable and observable: each consumer emits a record (kind, key, reason, deliveries) to the packtrail-deadletter stream (~30-day retention), surfaced by Server.DeadLetterCount, Server.RecentDeadLetters and the dashboard's dead-letter tile.


Reference

Testing

Packtrail's own test suite runs against a real embedded nats-server — there are no mocks. When testing flows that embed packtrail, the same approach works well: start an embedded server, register an InvokerFunc stub for each kind, and drive executions.

go test
$ go build ./...
$ go test -race ./...   # real embedded nats-server, no external NATS needed
$ go vet ./...
Use WithFlow to register flows inline from a YAML literal, or WithFlowDef to build them as Go structs — both keep tests self-contained with no files on disk. Give each test a distinct WithNamespace so they never collide on a shared server.