v2 (CLI-aligned) YAML format for Lightning workflows.
See test/fixtures/portability/v2/canonical_workflow.yaml for the
spec-by-example. New contributors should read that file before this module.
Spec source (pinned)
This module implements the OpenFn Portability Spec as defined at:
- Project / Workflow / Step / Trigger / Job types: https://github.com/OpenFn/kit/blob/42d6b380242050c3248e2714e1e590a18bef8dbd/packages/lexicon/portability.d.ts
conditionunion (literals + JS body): https://github.com/OpenFn/kit/blob/2d87928c74d74fb11c1f34d4277ccbba364578d7/packages/lexicon/lightning.d.ts#L102- Kitchen-sink example (a project YAML exercising the spec): https://github.com/OpenFn/docs/blob/fb7fa4ae8b629e6bc4382412662bf6e376b87087/docs/deploy/portability.md#L87-L153
The links above pin specific commits so future readers can audit
which version of the spec this code targets. The spec carries explicit
// TODO markers (next-as-string deprecation, step.id required,
condition.label vs name, credential as id-string) — those are pending
upstream. Where Lightning's behavior diverges from the in-flight spec,
see "Subject to upstream finalization" below.
Subject to upstream finalization
cron_cursor— Lightning emitscron_cursor: <step-id>flat at the trigger root (stateless, by-name). The kitchen-sink example usescron_cursor_job_id: <uuid>flat at the trigger root, which contradicts the spec's statelessness principle and isn't defined inportability.d.tsat all; we use the step-id form pending upstream resolution.next:form —portability.d.ts:60carries a// TODO remove next: string (next should always be an object)note. Lightning emits verbose-only to align with the spec direction and to avoid the bare-string parsing bug in@openfn/project@0.15wherenext: <step-id>gets misread as iterating over the target id's characters. Bare-stringnext:is no longer emitted.namevslabelonConditionalStepEdge—portability.d.tshas// TODO this is probably the name. Lightning emitslabel:today; swap when upstream lands.
Shape
Workflow portability shape:
id: <string>
name: <string>
start: <step-id> # entry trigger's step-id (spec: WorkflowSpec.start)
steps: [<step>, ...] # one array — both jobs and triggersBefore emission, the canonical map splits the single steps: array into
two sibling keys — :triggers and :steps — so the emitter can iterate
triggers and jobs separately. Both keys are always present (empty list
when there are none).
A trigger step has a type discriminator (webhook / cron).
All Lightning extensions land flat at the trigger root — there is no
openfn: wrapper:
- id: <string>
name: <string>
enabled: true | false
type: webhook | cron
cron_expression: "0 0 * * *" # cron only (spec: flat field)
cron_cursor: <step-id> # cron only (Lightning ext, flat)
webhook_reply: <string> # webhook only (spec: flat field)
webhook_response_config: # webhook only (Lightning ext)
success_code: <int> # omitted entirely when unset
error_code: <int>
next:
<step-id>:
condition: always | on_job_success | on_job_failure | <js body>A job step has no type field:
- id: <string>
name: <string>
adaptor: <string>
expression: |
fn(state => state)
configuration: <credential-key> # optional
next:
<step-id>:
condition: always | on_job_success | on_job_failure | <js body>
expression: | # only emitted alongside js body
<js body>
label: <string> # optional
disabled: true # optional, defaults to falseCondition discrimination
Per lightning.d.ts:102, condition is the union
'always' | 'on_job_success' | 'on_job_failure' | string. Lightning emits
the named literals (always, on_job_success, on_job_failure) verbatim
for those condition_type values; for :js_expression the field carries
the user-supplied JS body string. The literal is always present (matches
the kitchen-sink example), so parsers don't need to assume a default for
a missing condition: field.
Field-name table
| concept | v2 field name |
|---|---|
| workflow steps array (YAML) | steps: (jobs + triggers) |
| workflow start step | start: (workflow head) |
| trigger discriminator | type: |
| trigger enabled | enabled: |
| step expression / body | expression: |
| step adaptor | adaptor: |
| step credential | configuration: |
| cron expression (flat on trig) | cron_expression: |
| cron cursor (flat on trig, ext) | cron_cursor: |
| webhook reply (flat on trig) | webhook_reply: |
| webhook response codes (trig) | webhook_response_config: |
| project channels (Lightning ext) | channels: (array) |
| channel destination credential | destination_credential: |
| outgoing edges from a node | next: (string or object) |
| edge condition | condition: |
| edge js body | expression: (with JS cond) |
| edge label | label: |
| edge disabled (inverted) | disabled: |
Summary
Functions
@spec serialize_project(Lightning.Projects.Project.t(), [any()] | nil) :: {:ok, binary()} | {:error, term()}
Serialize a project to v2 YAML.
Produces a stateless project document — no UUIDs in the body. Stable hyphenated names are the join keys.
The snapshots argument is accepted for façade-compatibility with the v1
serializer but is not used for v2 (v2 always emits the project's current
workflow set).
Expects the project to have its associations preloaded; if not, this function preloads them itself.
@spec serialize_workflow(Lightning.Workflows.Workflow.t()) :: {:ok, binary()} | {:error, term()}
Serialize a workflow struct to v2 YAML.
Expects workflow.jobs, workflow.triggers, workflow.edges to be loaded.