browser-flow では、実行の形を決める方法は whenbranchforeach、暗黙の parallel のちょうど4つです。そしてそれらはすべて、コンパイル済みの FlowSpec では、手順ごとの2つのフィールド、つまり when 述語と foreach ソースだけに lowering されます。実行時に制御フローインタープリターは存在しません。コンパイラーがすべての選択を静的に解決するため、Run は DAG の純粋な走査になります。

4つの構文

それぞれの構文には YAML 形式と SDK 形式があり、それぞれがグラフ内の厳密な意味に対応します。
構文YAML意味
condition手順上の when: ${{ … }}述語が truthy の場合にのみ手順を実行する
branchyes: / no: の手順リストを持つ branch: ブロック実行する手順のサブリストを選ぶ
fan-out手順上の foreach: ${{ … }}配列要素ごとに手順を実行し、出力を集約する
parallel(暗黙)間にデータエッジがない手順は並行に実行される
nestinguse: flow:checkout@2別の Flow を1つの手順(composite tool)として呼び出す
このうち whenforeach の2つは FlowSpec に直接保存されます。残りは、それらへ lowering されるオーサリング用の糖衣構文です(Lowering を参照)。

並列性は暗黙です

並列性を宣言することはありません。コンパイラーはデータ参照から依存グラフを導出します。つまり、手順の withwhenforeach 式に含まれるすべての steps.<id> 参照を集め、明示的な needs と合わせて、referenced_id → this_id というエッジを追加します。間にエッジがない手順同士は独立しており、並行実行の対象になります。エッジはデータ参照から生まれるため、グラフは常に正直です。先に実行されたことを保証するエッジを作らずに、ある手順の出力を読むことはできません。FlowSpec §6 を参照してください。

branch の2つの書き方

daily-price-watch Flow は、取得した価格がしきい値を下回った場合にのみ、シークレット webhook に通知します。以下は、その条件付き通知を両方の surface で branch として書いたものです。
import { flow, http, browser, transform } from "@browserflow/sdk";
import { z } from "zod";

export default flow("daily-price-watch")
  .input("query", z.string())
  .input("threshold", z.number())
  .input("webhook", z.string().url(), { secret: true })

  .step("lookup", browser.tool("search-price@1.2.0").with((c) => ({ query: c.input.query })))
  .step("shape", transform((c) => ({ price: c.steps.lookup.output, at: c.now })))

  // Conditional notify. The `yes` arm runs only when the predicate is truthy.
  .branch("dropped?", (c) => c.steps.shape.output.price < c.input.threshold, {
    yes: (f) =>
      f.step("notify", http.post(
        (c) => c.secret.webhook,
        (c) => c.steps.shape.output,
      )),
    no: (f) => f.noop("unchanged"),
  })

  .output((c) => ({ price: c.steps.shape.output.price }));
flow: daily-price-watch

inputs:
  - { name: query,     type: string }
  - { name: threshold, type: number }
  - { name: webhook,   type: string, secret: true }

steps:
  - id: lookup
    use: browser:search-price@1.2.0
    with: { query: ${{ input.query }} }

  - id: shape
    use: transform
    with:
      expr: { price: ${{ steps.lookup.output }}, at: ${{ now }} }

  - id: notify
    use: http.post
    when: ${{ steps.shape.output.price < input.threshold }}
    with:
      url:  ${{ secret.webhook }}
      json: ${{ steps.shape.output }}

output: { price: ${{ steps.shape.output.price }} }
YAML はすでに lowering 後の形を示していることに注目してください。branch: ブロックはなく、when: を持つ notify 手順があるだけです。SDK の .branch() がコンパイルされる先も、まさにこの形です。
この2つの surface は、意図的に相互交換可能になっています。TypeScript コードベースでコンパイル時の型が必要な場合は SDK を使い、TypeScript を書かない人が Flow をレビュー、差分確認、編集する必要がある場合は YAML を使います。browserflow emit で両者をラウンドトリップできます。SDK & YAML を参照してください。

foreach とネスト

foreach は、配列式の各要素へ単一の手順を fan-out し、要素ごとの出力を集約します。branch と異なり、これは .step() の上に重ねられた糖衣構文ではありません。foreach(id, over, def) という独自のビルダーメソッドであり、手順ID、配列式、要素ごとに実行する StepDef を受け取ります。その手順のコンテキスト内では、c.item が現在の要素、c.index がその位置です。
.foreach(
  "notify-each",
  (c) => c.steps.shape.output.alerts,
  http.post((c) => c.secret.webhook, (c) => c.item),
)
- id: notify-each
  use: http.post
  foreach: ${{ steps.shape.output.alerts }}
  with:
    url:  ${{ secret.webhook }}
    json: ${{ item }}
ネストは Flow を合成します。use:flow:checkout@2 である手順は、他の tool と同じようにバージョンで固定され、別の Flow を単一の composite tool として呼び出します。式サブ言語には、設計上ループもユーザー定義関数もありません。反復は手順レベルの foreach であり、式の内部では決して行いません。Expression language を参照してください。

Lowering

branchwhenforeach が唯一の制御構文であり、IR が保存するのはそのうち2つだけです。手順ごとの when 述語と、手順ごとの foreach です。オーサリング用の糖衣構文は機械的に lowering されるため、コンパイル済み FlowSpec には、スケジューラーが解釈すべき特別な制御ノードはありません。FlowSpec §6a を参照してください。
1

branch は手順ごとの when に lowering される

branch(pred, \{ yes, no \}) は、それに含まれる各手順上の when に lowering されます。yes: アーム内のすべての手順には when: pred が付き(既存の when がある場合は AND されます)、no: アーム内のすべての手順には when: !(pred) が付きます。FlowSpec には branch ノードは存在しません。あるのは手順だけであり、それぞれが自分自身の述語でゲートされます。
2

不要な noop は取り除かれる

noop(上の no: noop("unchanged") アームなど)は、lowering 後のグラフでは実在する手順です。効果がなく、かつ downstream から参照されていない場合、canonicalization によって取り除かれます。これは dead-noop elimination であり、そのため digest には決して到達しません。何かが参照している場合は保持されます。lowering 後の daily-price-watchunchanged アームの痕跡がないのはこのためです。
3

結果は通常の gated DAG になる

lowering 後、スケジューラーが見るのは手順、エッジ、when 述語、foreach ソースだけです。解釈すべき prose も特別な制御フローもありません。走査する DAG があるだけです。

skip propagation

手順は、その when が false と評価されると skipped になります。その後、skip はデータエッジに沿って 1つのルールで伝播します。このルールは静的に決まり、競合によって決まることはありません
  • X が skipped だったときに steps.X.output を参照する手順は、それ自体も skipped になります。ただし、そのような参照がすべて fallback、つまり default(steps.X.output, …) または steps.X.output ?? … によって guard されている場合を除きます。すべての参照が guard されている場合、その参照は null を返し、手順は実行されます。
  • Flow の output も同じ方法で評価されます。条件によって skipped になりうる手順を fallback なしで読む projection はコンパイル時に拒否されるため、Flow の出力が暗黙に null になることはありません。
output 内で、条件によって skipped になりうる手順の出力を guard なしで読むと、unguarded_skip コンパイルエラーになります。default(…) または ?? の fallback を追加するか、常に実行される手順だけを projection が読むように構造を変えてください。Diagnostics を参照してください。
各 downstream 手順が何を見るかは、どの並行手順がたまたま先にコミットしたかではなく、コンパイラーによって固定されます。そのため branch は total です。どの入力に対しても、すべての手順は succeededskippedfailed のいずれか1つに正確に到達します。
skipped になった出力へのすべての read が guard されている最初の手順で止まります。その guard された手順は、欠けている値の代わりに null を使い、succeeded まで実行されます(または、それ自身の理由で failed になります)。その 手順より downstream の手順から見ると、実在する完了済み producer があるため、伝播は guard を越えて続きません。
決定論(tenet T3)のためです。downstream 手順の運命が、複数の並行手順のうちどれが先にコミットしたかに依存すると、同じ FlowSpec が実行ごとに異なる結果を生む可能性があります。コンパイル済みグラフから skip propagation を決めることで、結果は FlowSpec digest の性質になり、どこで実行しても同一になります。これこそが、一度コンパイルしてどこでも実行できるようにする目的です。
いいえ。skippedsucceededfailed は3つの異なる終端状態です。skipped の手順は、その when が false だったため(または skip を継承したため)実行されませんでした。failed の手順は実行され、エラーになりました。totality により、すべての手順はこの3つのうち正確に1つに到達することが保証されます。

関連

FlowSpec と IR

lowering、canonicalization、digest が、糖衣構文を安定した artifact に変える方法。

式言語

${{ … }} の内部にある純粋なサブ言語。default(…)?? も含みます。

Diagnostics

unguarded_skip を含む、すべてのコンパイル時エラー。