footprintjs - v11.2.0
    Preparing search index...

    Class FlowChartBuilder<TOut, TScope>

    Type Parameters

    • TOut = any
    • TScope = any
    Index
    _subflowDefs: Map<string, { root: StageNode<TOut, TScope> }> = ...
    • THE mount of a subflow chart under id (F10) — every eager mount site (addSubFlowChart, addSubFlowChartNext, a decider/selector addSubFlowChartBranch) goes through it, and it is the one caller of the prefixer. In order: claim id (_claimFreshId — refused when another stage, functionless stage, claimed id or generated fork node holds it; a prior subflow mount under the same id is the one exception, it already holds the claim — see _registerSubflowDef); register the chart's prefixed root under id; place the mount's own node + spec and fire its structure events; merge the chart's stage map under id; register its nested subflows under id/<key> (a key already registered is kept).

      Parameters

      • id: string
      • door: string
      • subflow: FlowChart<any, any>
      • place: () => void

      Returns void

    • Add a stage that fires a child flowchart on the given driver and DISCARDS the handle. Pure fire-and-forget — useful for telemetry exports, audit log shipping, cache warm-up.

      Parameters

      • id: string

        Stable id for this stage (also the stageMap key).

      • child: FlowChart<any, any>

        The child flowchart to detach.

      • options: {
            description?: string;
            driver: DetachDriver;
            inputMapper?: (scope: TScope) => unknown;
            mountName?: string;
        }
        • Optionaldescription?: string

          Stage description for narrative + tools.

        • driver: DetachDriver

          The driver to schedule on (e.g. microtaskBatchDriver).

        • OptionalinputMapper?: (scope: TScope) => unknown

          Maps the parent's scope to the child's input. Defaults to passing undefined.

        • OptionalmountName?: string

          Display name; defaults to id.

      Returns this

      import { microtaskBatchDriver } from 'footprintjs/detach';

      flowChart('process', processFn, 'process')
      .addDetachAndForget('telemetry', telemetryChart, {
      driver: microtaskBatchDriver,
      inputMapper: (scope) => ({ event: 'processed', orderId: scope.orderId }),
      })
      .addFunction('next', nextFn, 'next')
      .build();
    • Add a stage that fires a child flowchart on the given driver and delivers the resulting DetachHandle to a consumer-supplied onHandle callback. The handle CANNOT be stored in shared state — StageContext.setValue calls structuredClone which drops class prototypes (and therefore the handle's .wait() method).

      The callback pattern is the explicit alternative: keep handles in a closure-local array (or whatever shape suits) and have a downstream stage await Promise.all(...) over them.

      Parameters

      • id: string
      • child: FlowChart<any, any>
      • options: {
            description?: string;
            driver: DetachDriver;
            inputMapper?: (scope: TScope) => unknown;
            mountName?: string;
            onHandle: (handle: DetachHandle) => void;
        }

      Returns this

      import { microtaskBatchDriver } from 'footprintjs/detach';
      import type { DetachHandle } from 'footprintjs/detach';

      const handles: DetachHandle[] = [];

      const chart = flowChart('seed', seedFn, 'seed')
      .addDetachAndJoinLater('eval-a', evalChart, {
      driver: microtaskBatchDriver,
      inputMapper: (scope) => scope.configA,
      onHandle: (h) => handles.push(h),
      })
      .addDetachAndJoinLater('eval-b', evalChart, {
      driver: microtaskBatchDriver,
      inputMapper: (scope) => scope.configB,
      onHandle: (h) => handles.push(h),
      })
      .addFunction('join', async (scope) => {
      const settled = await Promise.all(handles.map((h) => h.wait()));
      scope.results = settled;
      }, 'join')
      .build();

      Note: putting handles in a module-level closure is fine for single-run scripts. For server code that runs the same chart concurrently across requests, allocate a new closure per run (e.g., wrap chart construction in a factory function) so handles from different runs don't bleed into each other.

    • Run ONE BRANCH PER ITEM, in parallel — with the number of branches decided at RUN time from the payload rather than at build time.

      addListOfFunction fans out a fixed set of branches you name in the chart. This fans out over data the chart has not seen yet: three chunks make three branches, ten make ten. Each branch runs as its own isolated subflow — its own memory, its own commit log, addressable in every trace query at <stageId>~<index> — so branches cannot corrupt each other's in-flight state, and a backward slice through a branch result works with no special handling. Design: docs/design/execution-control.md.

      Type Parameters

      • TItem = any

      Parameters

      • name: string

        Display label for the fan-out stage.

      • id: string

        Stable stage id. May not contain ~ (reserved — the branch segments are built from this id).

      • config: ParallelForEachConfig<TItem, TScope>

        { items, branch, maxBranches, into, failFast? }:

        • items(scope) picks what to fan out over (reads are tracked);
        • branch(item, index) builds the chart for ONE item (each branch's scope is also seeded with item and index, so the input is in the trace rather than hidden in a closure);
        • maxBranches is REQUIRED — an unbounded fan-out driven by model output is a resource attack, so there is no default to forget. Extra items do not run and the truncation is recorded;
        • into is REQUIRED — the state key the ordered results array is written to. into[i] is branch i's result, in ITEMS order, whatever order the branches finished in;
        • failFast is the same policy the rest of the library's parallelism uses: true rejects on the first failing branch, omitted runs them all best-effort (a failed branch's slot is undefined).
      • Optionaldescription: string

      Returns this

      flowChart<State>('Split', splitFn, 'split')
      .addParallelForEach('Review each chunk', 'review-chunks', {
      items: (scope) => scope.chunks,
      branch: (chunk, i) => buildReviewChart(chunk, i),
      maxBranches: 8,
      into: 'reviews',
      })
      .addFunction('Merge', mergeFn, 'merge') // reads scope.reviews
      .build();
    • Add a pausable stage — can pause execution and resume later with input.

      The handler has two phases:

      • execute: runs first time. Return any non-void value to pause (it becomes the checkpoint's pauseData); return void/undefined to continue normally.
      • resume: runs when the flowchart is resumed with input.

      Parameters

      Returns this

      .addPausableFunction('ApproveOrder', {
      execute: async (scope) => {
      scope.orderId = '123';
      return { question: 'Approve?' };
      },
      resume: async (scope, input) => {
      scope.approved = input.approved;
      },
      }, 'approve-order', 'Manager approval gate')
    • Parameters

      • name: string
      • fn: StageHandler<any, TScope>
      • id: string
      • Optionaldescription: string
      • Optionaloptions: {
            failFast?: boolean;
            retry?: RetryPolicy;
            tags?: readonly string[];
            touches?: StageTouches;
        }

        { retry } gives the SELECTOR STAGE a retry policy — same reasoning as addDeciderFunction: this method returns a sub-builder, so a chained .retry() would be ambiguous; { tags } likewise.

      Returns SelectorFnList<TOut, TScope>

    • Attach a StructureRecorder for build-phase observation. Multiple recorders coexist (same id allowed; iteration order = attach order). Throws if called after .build() — the chart is sealed at that point and any recorder attached late would miss every event fired during construction.

      Seed replay: when this is called AFTER start() has already fired (i.e., after the flowChart() factory returns), the just-attached recorder receives a one-time onStageAdded for the root stage so it observes the seed. Only the new recorder sees the replay; already-attached recorders are not re-fired.

      Mid-chain attach caveat: a recorder attached AFTER one or more addX() calls receives the seed replay but MISSES every intermediate event. Attach BEFORE the first addX() for complete capture.

      Public for now to enable direct attach in tests + early consumers. L7.4 will wire flowChart(..., { structureRecorders: [...] }) as an additional registration site; this method will remain.

      Parameters

      Returns this

    • Declare the chart's time budget: the most milliseconds a caller gives one run (design §3.2).

      WHY: the incident behind it — a stage retried an SDK call four times, 40 s each, past a caller's 120 s, and the record said "one stage, one error". Both numbers were declared: the policy on the stage, the budget of the caller. So build() checks one against the other: it REFUSES the chart when its declared policies already allow longer (attempts × timeoutMs plus the waits, summed along the slowest path — see timeBudget), and in dev mode names every stage it cannot bound (no timeoutMs, a function backoff with no maxBackoffMs, a loop, a lazy subflow, a parallelForEach). The spec root carries it (budgetMs); at run start, a deadline shorter than it is a dev-mode warning.

      Chart-level, so it may be declared anywhere before build(), once.

      Parameters

      • ms: number

      Returns this

      flowChart<State>('Read', readFn, 'read', { retry: { attempts: 1, timeoutMs: 5_000 } })
      .addFunction('Charge', chargeFn, 'charge')
      .retry({ attempts: 4, timeoutMs: 40_000, backoffMs: 1_000 })
      .budget(120_000)
      .build(); // throws: its declared policies allow 168000 ms, over its budget by 48000 ms
    • Declare the API contract — input validation, output shape, and output mapper, in one call.

      If a contract with input schema is declared, chart.run() validates input automatically. Contract data is used by chart.toOpenAPI() and chart.toMCPTool().

      Parameters

      • opts: {
            input?: unknown;
            mapper?: (finalScope: Record<string, unknown>) => unknown;
            output?: unknown;
        }

      Returns this

    • Inspect accumulated StructureBuildErrors. Returns empty array when no recorders attached OR no errors occurred. Returns a defensive copy — caller mutations do not affect subsequent calls.

      Call on the BUILDER, not the chart returned by .build(). Capture the builder reference before .build() if you need post-build access:

      const builder = flowChart(...).attachStructureRecorder(rec);
      const chart = builder.build();
      const errors = builder.getStructureBuildErrors();

      Returns StructureBuildError[]

    • Make a loop back to stageId the current stage's continuation. The loop ends this path: a door that would attach a stage after it is refused (_needContinuationCursor), so add every stage before loopTo().

      Parameters

      • stageId: string

      Returns this

    • Give the stage you JUST added a declarative retry policy.

      A retry hand-rolled inside a stage function is invisible to the trace. The narrative shows one stage, the commit log shows one entry, and the two failed calls that came before the successful one left no mark anywhere — an unexplained behaviour in a library whose whole point is that nothing invisible happens. Declared here, every attempt is part of the record: each attempt fires FlowRecorder.onAttempt with its AttemptRecord (outcome, duration, failure category, the retry-or-stop decision), which is what feeds the narrative — one line per attempt, in order, between the attempts' own reads and writes. The stage's execution-tree node keeps the records (attempts, attemptSummary). A failed attempt that is retried also fires FlowRecorder.onStageRetry, which the narrative does not tell twice.

      A failed attempt's staged writes are DISCARDED — the next attempt starts from committed state, never from the wreckage of the last try. The final attempt behaves exactly as a stage with no policy does: on success it commits; on failure it commits what it wrote and rethrows (footprint has never had rollback, and retry does not introduce one).

      A pause is not a failure: neither addPausableFunction's pause nor interrupt() is ever retried. Nor is a cancelled run.

      Parameters

      Returns this

      flowChart<State>('Fetch user', fetchFn, 'fetch-user')
      .addFunction('Charge card', chargeFn, 'charge')
      .retry({ attempts: 3, backoffMs: (n) => 100 * 2 ** (n - 1) })
      .addFunction('Confirm', confirmFn, 'confirm')

      Applies to the CURRENT cursor — the stage added by the immediately preceding start() / addFunction() / addStreamingFunction() / addPausableFunction(). Where the cursor would be ambiguous (a decider, selector, branch, or fork child), declare the policy in that method's own retry option instead.

    • Start a chart whose ROOT stage IS a selector — it runs first (reading args, seeding state, returning the chosen branch ids via select()), and its branches attach directly to the root. Mirrors start() for the root-node setup, then returns a SelectorFnList bound to the root so .addFunctionBranch() / .addSubFlowChartBranch() / .end() work exactly as they do after addSelectorFunction().

      Use when the first thing a chart does is choose among branches — e.g. a Context selector that inits + picks which context slots to engineer, with no separate seed stage before it.

      Parameters

      • name: string
      • fn: StageHandler<any, TScope>
      • id: string
      • Optionaldescription: string
      • Optionaloptions: {
            failFast?: boolean;
            retry?: RetryPolicy;
            tags?: readonly string[];
            touches?: StageTouches;
        }

      Returns SelectorFnList<TOut, TScope>

    • Put NAMES on the stage you JUST added. The first commit bundle of each execution of that stage carries them (CommitBundle.tags — the bundle commitStops keys on), and the built spec advertises them (SerializedPipelineStructure.tags).

      A reader that wants "the LLM turns" of a stored recording had to classify stages from their ids — a switch over runtimeStageId that parses # and /, lives in the consumer, and silently goes stale when a stage is renamed. Declared here, the name travels WITH the commit: a recording from any chart carries its own milestones, and tagStops (foottrace) scrubs them with no id conventions at all.

      A tag is a NAME declared at build time — never a value. There is no run-time $tag(): a runtime string could carry data ('user:' + email) past every redaction point. Data-dependent marks are a keep rule over the fold at read time, or telemetry via $emit. Free strings: footprintjs owns no vocabulary; a consumer declares its own ('milestone:<kind>').

      Stamped ONCE per execution of the stage: retry attempts share one stamp, a failed stage keeps its tag (the error path commits before it rethrows), a stage that interrupt()s and is resumed is two tagged stops on a chain because it ran twice, and an EMPTY commit is a tagged stop too. A subflow mount or a parallel-for-each stage can be tagged; the tag lands on the bundle that records its result — for a mount, its FIRST bundle in the parent log (the exit bundle carries none), while the subflow's own log is untouched: its inner stages declare their own.

      Parameters

      • ...names: readonly string[]

      Returns this

      flowChart<State>('Seed', seedFn, 'seed')
      .addFunction('Call model', callFn, 'call-llm')
      .tag('milestone:llm-turn')
      .addFunction('Route', routeFn, 'route')
      .tag('milestone:decision', 'audit')

      Applies to the CURRENT cursor — the stage added by the immediately preceding start() / addFunction() / addStreamingFunction() / addPausableFunction() / addSubFlowChartNext() / addParallelForEach(). Where the cursor would be ambiguous (a decider, selector, branch, or fork child), declare the tags in that method's own tags option instead. A subflow MOUNT has that option too (SubflowMountOptions.tags, 9.21.1) — and for a fork-child mount (addSubFlowChart) or a branch mount (addSubFlowChartBranch) it is the ONLY site: those leave the cursor on the parent, so .tag() after them refuses rather than mis-attribute.

      Refused at build time: an empty name, a non-string, the reserved branch-segment marker ~ inside a name, a name declared twice, and a second declaration on the same stage.

    • The graph so far as Mermaid text — the same walker as the built chart's toMermaid() (chartToMermaid).

      Returns string

    • Declare that the stage you JUST added changes an outside system — a charge, an email, a write to another service (10.1, design §3.3) — and give the rule for its idempotency key.

      A retry is safe for a read. For a charge it is safe only when the outside system can tell a retry from a new request — by an idempotency key. So the builder refuses a touching stage that may retry (.retry({ attempts > 1 }), in either order) without a key rule: a double charge must be impossible to write by accident. A default key made by the library was rejected — the run id changes across a resume and a caller's whole-run retry, the two places a double charge actually happens. The key must name the OPERATION: derive it from the input the rule is handed, never from model output (({ getState }) => … reads it from state). A keyless touching stage may not interrupt(): a resume re-runs it from its top. In a chart that pauses, derive keys from state (({ getState }) => …): the run's input does not cross a resume.

      The stage always runs on the attempt runner. The key rule runs ONCE per execution, before attempt 1, and every attempt gets the key as scope.$attempt().idempotencyKey — pass it to the system. Every attempt record says what it did there (effect: 'applied', 'none' when the error itself declares effect: 'none', else 'unknown'). When ANY attempt left the effect unknown — it timed out, was cut, or failed without the adapter saying nothing was sent — the stage fails with UnknownEffectError: reconcile with the system by the key, do not retry blindly. The spec carries the system's name (touches).

      Parameters

      Returns this

      flowChart<Order>('Load order', loadFn, 'load')
      .addFunction('Charge card', chargeFn, 'charge')
      .touches('stripe', { idempotencyKey: ({ args }) => `order-${(args as { orderId: string }).orderId}` })
      .retry({ attempts: 3, timeoutMs: 20_000 })

      Applies to the CURRENT cursor, like .retry(); where the cursor would be ambiguous (a decider, selector, branch, or fork child), declare it in that method's own touches option. Refused: on a subflow mount, a parallel-for-each stage or a loop reference (no function of their own), after a mount or a fork (the cursor did not move), twice, with a system that is not a name, and with a key that is not a function.