Inbox & Dispatch

Overview

Pipelines often split work across workflows that hand off a payload: one stage produces output, a later stage reacts to it. A generic way to do that without a separate broker is an in-module channel: a named queue the runtime can drain after a caller finishes its steps, driving receiver workflows in order.

Jaiph’s model is a small orchestration feature on top of that idea: a channel is declared with optional -> routes to workflow targets; a send uses <- to enqueue a string payload. NodeWorkflowRuntime keeps the queue and route map in memory, writes a matching file under the run for audit, and dispatches targets when the entry workflow’s step list completes (plus any implicit run async join) — not when a separate -> “fires”; the -> in source code is static routing on the channel line, not a runtime operator.

NodeWorkflowRuntime attaches an in-memory queue and route map to each WorkflowContext (one per run/inbox nesting level; channel-level -> rows populate the map only on the entry context — see Who registers routes and who drains). Each send also writes a durable copy to inbox/NNN-<channel>.txt under the run directory for audit and reporting — channel transport is queue-based, not filesystem-driven. There are no directory watchers, no polling loops, and no third-party brokers.

At a glance

channel findings -> analyst

workflow researcher() {
  findings <- "## analysis results"
}

workflow analyst(message, chan, sender) {
  log "Received: ${message}"
}

workflow default() {
  run researcher()
}

researcher sends data to the findings channel. The channel findings -> analyst declaration routes findings messages to analyst, which receives the message, channel name, and sender bound to its declared parameters message, chan, and sender (see Trigger contract).

Design principles

Syntax

Channel declarations: channel <name> [-> <workflow>, ...]

Declare channels at top level, one per line. Optionally declare inline routes with ->:

channel findings -> analyst
channel report
channel events -> handler_a, handler_b

workflow default() { ... }

Every channel used by send (<-) must be defined in the current module or imported from another module (e.g. shared.findings). Undefined channels fail validation with:

Send operator: <channel_ref> <- <rhs>

The channel reference is always on the left side of the <- operator. Valid channel forms:

The send step resolves the message from the RHS, writes the payload to the next inbox file on disk, and appends to the in-memory queue of the workflow context selected by the routing rule (innermost matching route on the stack, or the sender’s own context if none match — see Runtime dispatch).

Valid RHS forms:

RHS form Example Behavior
Double-quoted literal findings <- "## results" Interpolated string
Triple-quoted block findings <- """line1\n ${x}""" Multiline string; margin rules match other """ steps (see Grammar)
Variable expansion findings <- ${var} or $name Value of the variable
run capture findings <- run build_msg() Return value or trimmed stdout of the workflow/script

The RHS does not accept raw shell commands or bare workflow/rule/script names (use a string, $ / ${…}, or run ref(…) — see Grammar — send and Grammar — channel routing).

channel findings

workflow researcher() {
  findings <- "## findings"
}

An explicit RHS is always required — bare channel <- (without a value) is invalid.

The <- operator is only recognized when it appears outside of quoted strings on the surrounding line so channel names and literals are not misread as send syntax.

Send and route syntax, plus compile-time checks, are summarized under Grammar — send and Grammar — channel routing; the EBNF and validation list live at the end of Grammar.

Route declaration: channel <name> -> <workflow>

Routes are declared inline on channel declarations at the top level, not inside workflow bodies. When a message arrives on that channel, the runtime calls each listed workflow that must declare exactly 3 parameters. The runtime binds the dispatch values (message, channel, sender) to whatever names the target declares.

Targets must be workflows (local or imported as alias.name). Rules and scripts are not valid route targets — the compiler uses workflow-only reference checks, so a bad target is E_VALIDATE with messages such as unknown local workflow reference "…", imported workflow "…" does not exist, rule "…" must be called with ensure, or script "…" cannot be called with run (see Grammar — channel routing for a short version of the same rules). A name that is not a valid alias.name / name pattern fails at parse time as E_PARSE invalid workflow reference in channel route: "…". The wrong parameter count on a resolved workflow is E_VALIDATE: inbox route target "…" must declare exactly 3 parameters (message, channel, sender), but declares N.

channel findings -> analyst
channel summary -> reviewer

workflow default() {
  run researcher()
}

Multiple targets on one declaration are comma-separated — they share one route and dispatch in declaration order, sequentially:

channel findings -> analyst, reviewer

Route declarations are static routing rules stored on ChannelDef, not on workflow definitions or steps. The compiler validates that all target workflow references exist and declare exactly 3 parameters.

A -> route inside a workflow body is a parse error with guidance: route declarations belong at the top level: channel <name> -> <targets>.

Capture + send is a parse error

# E_PARSE: capture and send cannot be combined; use separate steps
name = channel <- cmd

Use two steps instead:

const payload = run build_message()
channel <- "${payload}"

Inbox layout

Under the run directory (see Architecture — Durable artifact layout):

.jaiph/runs/<YYYY-MM-DD>/<HH-MM-SS>-<source-basename>/inbox/
  001-findings.txt
  002-summary.txt
  003-findings.txt
  ...

Each message is a file named NNN-<channel>.txt where NNN is a zero-padded sequence for that run (monotonic on the runtime instance via inboxSeq). The orchestration queue itself is in memory; these files are the durable copy of the payload.

Runtime dispatch

Who registers routes and who drains

Every entered workflow gets a WorkflowContext: a route map and a message queue. Channel-level route declarations are registered on the entry workflow (the outermost workflow invoked by jaiph run). After the workflow’s steps finish (including the implicit join for run async branches), the runtime runs drainWorkflowQueue for that context.

Nested workflows invoked with run share a workflow context stack. On send, the runtime looks for a route for that channel starting at the sending workflow (innermost on the stack) and moving outward toward the entry workflow. Since channel-level routes are registered only on the entry workflow, sends from nested workflows bubble up to the orchestrator for dispatch, preserving the expected progress tree nesting. If no workflow on the stack declares a route, the message is queued on the sender’s context; when that context is drained, there are no targets and the message is skipped (see Error semantics).

Dispatch loop

Implementation: src/runtime/kernel/node-workflow-runtime.tssend step handling and drainWorkflowQueue.

  1. On workflow entry, push a WorkflowContext (route map, empty queue).
  2. For the entry workflow, channel-level route declarations populate the context’s route map.
  3. Execute workflow steps top to bottom.
  4. On <-: resolve payload, allocate the next sequence id from inboxSeq, append InboxMsg to the selected context’s queue, write inbox/NNN-<channel>.txt, and append INBOX_ENQUEUE to run_summary.jsonl.
  5. After all steps (and implicit run async joins) complete, drainWorkflowQueue:
    • while (cursor < queue.length) — new sends during dispatch append to the same queue and are processed in subsequent iterations.
    • For each message, look up targets for channel on that workflow’s context. If there is no route, skip (silent drop).
    • If there are targets, invoke each target sequentially in target-list order, binding message, channel, and sender to the target’s 3 declared parameters (see Ordering and sequence ids).
  6. Pop the workflow context and return.

There is no E_DISPATCH_DEPTH / JAIPH_INBOX_MAX_DISPATCH_DEPTH check in NodeWorkflowRuntime’s drain loop. Avoid unbounded circular sends in orchestration.

Implementation notes

Ordering and sequence ids

Messages are handled one at a time in queue order (FIFO). For each message, targets run strictly in list order on the channel line; the next message is not processed until all targets for the current message have finished (success, or fail-fast on the first non-zero exit).

Error semantics

Trigger contract

Routed receivers get three dispatch values bound to their declared parameters:

Param position Dispatch value
1st declared parameter Message payload (content sent to the channel)
2nd declared parameter Channel name (e.g. findings)
3rd declared parameter Sender name (the workflow name that performed the send)

Receivers get channel and sender via their declared parameter names — no environment-variable plumbing.

Progress tree integration

Example output

Illustrative progress tree for a pipeline where researcher sends on findings, analyst sends on report, and default routes both channels:

workflow default
  ▸ workflow researcher
  ✓ workflow researcher (0s)
  ▸ workflow analyst (message="Found 3 issues in auth module", chan="findings", sender="researcher")
  ✓ workflow analyst (0s)
  ▸ workflow reviewer (message="Summary: Found 3 issues in auth ...", chan="report", sender="analyst")
  ✓ workflow reviewer (0s)
✓ PASS workflow default (0.1s)