Process runner

The screen a run is driven from: rendering the current step, collecting input, and completing it.

Route: /process/[processId]


What it does

Renders the current step of a running process, collects input, and completes the step. For input steps that means a form; for every other type it shows progress while the worker does the work.

It also hosts everything wrapped around a run: the step rail, past-step browsing, sharing, export, audit, and the completion view.


Files

ComponentRole
page.tsxOrchestration, polling, form state, completion.
ProcessSharingPanel.tsxLink visibility and per-user assignments.
StepInputControl.tsxRenders one field — dispatches on the 11 field types.
ItemListEditor.tsxRepeating rows for item_list, including nesting.
ProcessFileField.tsxUpload and attachment display.
StepInputsForm.tsxAssembles fields into the step form.
PastStepReadonlyView.tsxRead-only view of a completed step.
ProcessStepRail.tsxStep list, grouped by phase.
ProcessCompletedView.tsxTerminal screen, renders resultViewControls.
StepRequiredPermissions.tsxShows which permissions the current step needs.
ProcessAuditStateButton.tsx, ProcessExportButton.tsxEntry points to audit and export.

Siblings: /processes and the dashboard, and /start/[templateKey] (collects runInputs, evaluates startExpression).


Concepts it introduces

Viewed step vs current step

viewedStepIndex lets you open any step while the run sits somewhere else. What you get depends on whether that step ran:

StepView
CompletedPastStepReadonlyView — the values it captured, read-only
Skipped by enabledExpressionUpcomingStepPreview — what it would have asked
Still aheadUpcomingStepPreview — what it will ask, no values

Selecting the live step clears the selection and returns to it, and the selection resets once the run reaches the step being viewed.

The live step is always steps[steps.length - 1]. Everything before it is history.

Sections

Section headers live in the same inputs[] array as fields, so a long step reads as one flat list. groupFieldsBySection (platform/forms/src/step-field-groups.ts) turns that array into the structure the headers imply — a section header opens a card, a subsection opens a block inside it, and fields before any header render bare, so a step without headers looks exactly as it always did.

A section header carrying no fields of its own is read as a title for the sections below it rather than an empty card. ib-payouts is authored this way: "Ethereum Mainnet" over "Past week", "Transfers" over the import sections. sectionNav turns the same structure into the rail's table of contents, so the nav nests instead of listing peers.

The runner form, the editor preview and the editor canvas all consume these helpers, so the three cannot drift.

Automatic steps

An automatic step advances in about a second, so its live view is rarely seen — the place people meet it is the past-step view. Both render AutomaticStepDetails (ui/generic/src/components/AutomaticStepDetails.tsx), which shows what the step is configured to do from its own settings: the Slack channel and message, a Decision's expression and branches, a Wait's schedule. The past view adds the outcome read from { ok, at, error }, with the raw context behind a Raw step output toggle.

The editor preview renders the same component, so the preview stays a mirror. The one difference it cannot close: the runner resolves expressions against real context, the editor cannot, so the editor labels them as written.

Field dispatch

StepInputControl is the single switch over all 11 field types. A new field type is added here and in the editor's config panel — nowhere else in the runner.

Read-only fields as view controls

A field with readOnly: true renders resolved defaultValue text rather than an input. This is how templates display computed values, generated payloads, and instructions inline — there is no separate "display" field type. downloadFilename turns one into a download button.


Where it meets the spine

The runner drives two stages directly:

PUT  /api/process/{id}/steps/{stepId}           → updateStep
POST /api/process/{id}/steps/{stepId}/complete  → completeStep

Writes address the step instance id, not the step key — a looping run visits the same stepKey repeatedly and each visit is a separate instance.

Everything else the runner shows — a slack_notify step in flight, a wait step parked — is the worker's executeStep happening elsewhere. The runner polls and re-renders.


Two gates the UI reflects

step.permissions[] decides who may act. StepRequiredPermissions surfaces this so a blocked user sees why rather than a dead form.

completeExpression decides whether Continue/Finish appears at all. This is independent of permission — a user may be allowed to edit a step and still be unable to finish it because the data is incomplete.

Both are enforced server-side. The API returns a single 403 for either case with a deliberately merged message, so the UI cannot distinguish "not allowed" from "not complete."

Per-field permissions mean the form can be partly editable: fields the user lacks permission for render read-only, and writes are filtered to permitted keys before the merge — a rejected field write is dropped silently rather than erroring.


Sharing

ProcessSharingPanel introduces vocabulary used nowhere else. See sharing.


Known gaps

A failed run looks completed

failProcess() sets status = "completed" and populates error. The runner must read error separately to know a run died. Anything filtering on status alone shows failures as successes. See known issues.

(Notification delivery is no longer one of these. A notify step that failed to send is marked in the rail, shows Not sent with the reason when opened, and is listed on the completion screen. The run still advances, which is the soft-failure contract working as intended — see step types.)