Write a Workflow
Write a module in workflows/ that exports a Machine, so that the Instance registers it as a Workflow.
The module contract has three parts:
- The file is
workflows/<name>.ts.<name>is the name of the Workflow.jr2 run <name>starts it. - The file exports its Machine as
machine. jr2 reads no other export. - The Machine holds all that it needs: its Vocabulary, its Agents, its Effects and its Sandbox Image. The Workflow is only a name for it.
The example
This module is the Workflow rollout. A run waits at a Gate until a person sends promote or roll_back. It has no Agent, so you can try it with no model. Save it as workflows/rollout.ts:
import { assign, emit } from "xstate";
import { z } from "zod";
import { defineEvent, fail, jr2Setup, type HostInjectedInput } from "@jr2/orchestrator";
// The door: the input that starts a run. jr2 checks each start against this schema.
const runInput = z.object({
service: z.string().min(1).describe("The service to roll out."),
version: z.string().min(1).describe("The version to roll out."),
// A field with a default is optional for the caller. The run starts with the default.
canaryPercent: z.number().int().min(1).max(50).default(10).describe("The percent of hosts in the canary."),
});
type RunInput = z.infer<typeof runInput>;
// jr2 adds instanceId to the input of the root Machine of a run. No caller sends it.
type Input = RunInput & HostInjectedInput;
type Context = Input & { reason?: string };
// The Vocabulary: the events that this Machine takes. Here, a person sends them to the Gate.
const promote = defineEvent({
name: "promote",
description: "The canary is healthy. Roll out the version to all hosts.",
input: z.object({}),
});
const rollBack = defineEvent({
name: "roll_back",
description: "Stop the rollout. Say why.",
input: z.object({ reason: z.string().min(1) }),
});
// jr2 reads only this export. The file name, rollout.ts, gives the Workflow its name: rollout.
export const machine = jr2Setup({
types: {} as {
input: Input;
context: Context;
emitted: { type: "decided"; promoted: boolean };
},
events: [promote, rollBack],
actors: { fail: fail() },
}).createMachine({
id: "rollout",
input: runInput,
context: ({ input }) => ({ ...input }),
initial: "canary",
states: {
// A Gate. The run waits here until a person sends promote or roll_back.
canary: {
invoke: {
src: "gate",
input: ({ context }) => ({
meta: {
service: context.service,
version: context.version,
canaryPercent: context.canaryPercent,
instanceId: context.instanceId,
},
}),
},
on: {
promote: { target: "promoted", actions: emit({ type: "decided", promoted: true }) },
roll_back: {
target: "rolledBack",
actions: [assign({ reason: ({ event }) => event.reason }), emit({ type: "decided", promoted: false })],
},
},
},
promoted: { type: "final" },
// A person stopped the rollout, and the run ends here. Not a final state: the run ends `failed`,
// with the person's reason, and `jr2 run` exits 1.
rolledBack: { invoke: { src: "fail", input: ({ context }) => ({ reason: context.reason! }) } },
},
});The parts of the module:
- The door. The
inputofcreateMachineis a zod object. It is the input that starts a run. jr2 examines each start against it, and it starts the run with the input that zod parsed. ThuscanaryPercentgets its default when the caller does not send it. Give the same type totypes.input, withz.infer, so that TypeScript and jr2 agree. instanceId. jr2 addsinstanceIdto the input of the root Machine of a run. AddHostInjectedInputto the input type when the Machine reads it. Do not put it in the door. No caller sends it.- The Vocabulary. Make each event with
defineEvent, and list it in theeventsofjr2Setup. Write its name as anonkey on each state that takes it. - The Gate. The state
canaryinvokesgate. The Gate takes the events of theonkeys of that state. Itsmetais the data thatjr2 statusand the Console show to the person who answers. - The Emit.
emit()tells the people who watch the run what happened. It does not move the Machine. Declare its type intypes.emitted.
A Machine with a door uses jr2Setup, also when it takes no workflow event. Then leave out events, as in jr2Setup(). The setup() of xstate does not read the input of createMachine. TypeScript accepts a zod input there, but no door reads it, so jr2 up refuses that Machine. Without the refusal, a run would start with any input, and the Console start form would be only a field for JSON. Only a Machine with no door and no workflow event can use setup(), as the ping Workflow that jr2 init writes does.
Give the run an output
The output of a run is the output at the root of createMachine, beside id and states. The status of the run shows it as output. When a different Machine invokes this Machine, the onDone of that invoke gets it as event.output. The rollout example has no root output. Thus a run of it that ends done has no output, and its result is only in its context.
xstate does not use the output of a final state as the output of the Machine. It gives that output to the root output as event.output. Thus a Machine that has an output only on its final states has no output. TypeScript does not find this error, but createMachine refuses it when the module loads (see the refusal). Write the root output in one of these two forms:
- From the context:
output: ({ context }) => ({ reason: context.reason }). - From the final state that ended the run:
output: ({ event }) => event.output as Output, with anoutputon each final state. The example of Gates does this.
Give the same type to types.output.
A final state inside a compound state can have an output without a root output. The onDone of that compound state gets it as event.output. A Machine with no output, such as rollout, is also correct.
Fail the run
A final state means that the work succeeded: the run ends done. When the Machine could not do its work, invoke fail from a state. Put it under actors as fail: fail(), and give it a reason:
failed: { invoke: { src: "fail", input: { reason: "the tests did not pass", detail: { code: 1 } } } },The run ends failed, and its output is the { reason, detail } of the Failure. jr2 run exits with 1. Do not throw from a promise to fail. A throw is a defect: the run ends error, with no output, and nobody can tell it from a bug in the kit. A Failure deletes the Sandbox of each workspace() that it goes through. To keep the work for a person to read, wait at a Gate before the state that fails.
The same rule applies to the decision of a person. A decision that sends the work back, such as a reject that goes back to the coder, is a usual route. A decision that ends the run, such as roll_back in rollout or a reject at a Gate, goes to a state that invokes fail with the reason of the person, as rolledBack does. Do not put a { outcome: "rejected" } in the output type for it. When the job of the Machine is the decision itself, such as a triage where "no" is a correct answer, the decision is a final state, and the Machine puts the decision in its output.
A Failure goes up through each parent Machine. To catch it, guard an onError with isFailure: onError: [{ guard: ({ event }) => isFailure(event.error), target: "replan" }, { target: "broken" }]. How a run ends tells more.
Register it and run it
Run jr2 up in the Instance folder. jr2 up typechecks the Instance, then loads each module in workflows/ before it changes the cluster. An error in a module stops it. The Orchestrator runs only the Machines that the last jr2 up gave it. Iterate on a Machine on kind gives the loop.
jr2 upStart a run in one terminal. The run waits at the Gate, so jr2 run stays attached. The output uses the kind cluster jr2 and the Instance my-instance from Get started:
jr2 run rollout --input '{"service":"billing","version":"1.4.0"}'→ context kind-jr2 / namespace my-instance
run b7c3d871-e3ae-46af-8bb3-774913bcd6cc (rollout) — attached
→ active "canary"Your run id is different. When your Instance folder has a .env file, the output starts with one more line, → .env:, with the keys that the CLI took from it. In a second terminal, answer the Gate. Write the run id in place of <run id>, or its first 4 characters or more:
jr2 send <run id> --gate canary --event roll_back --input '{"reason":"The error rate went up."}'→ context kind-jr2 / namespace my-instance
delivered roll_back to gate "canary" on b7c3d871-e3ae-46af-8bb3-774913bcd6ccIn the first terminal, the run ends. A roll_back ends the rollout, so the run ends failed, with the reason of the person as its output, and jr2 run exits with 1. Its context holds the default of canaryPercent, the instanceId that jr2 added, and the reason:
emit {"type":"decided","promoted":false}
→ active "rolledBack"
→ failed "rolledBack"
failed: The error rate went up.
{"runId":"b7c3d871-e3ae-46af-8bb3-774913bcd6cc","workflow":"rollout","instanceId":"b79e7e8b-6289-4dbc-a9a3-be945f8b1173","status":"failed","value":"rolledBack","context":{"service":"billing","version":"1.4.0","canaryPercent":10,"instanceId":"b79e7e8b-6289-4dbc-a9a3-be945f8b1173","reason":"The error rate went up."},"children":[],"waiting":[],"held":[],"assembly":"83858e28b054","output":{"reason":"The error rate went up."}}Your instance id and your assembly hash are different.
Start a run gives the other ways to start a run. Answer a Gate gives the other ways to answer one.
Put shared code in a file that jr2 does not register
jr2 registers each .ts file in workflows/, also a file that a different Workflow imports. It does not register a file whose name starts with _, or a .d.ts file. Thus put the code that two Workflows share in a file such as workflows/_events.ts, or outside workflows/. Import it with its .ts extension: import { promote } from "./_events.ts";.
Rules
jr2 upchanges nothing when the Instance does not typecheck. It printsrefusing: this instance does not typecheck — nothing was built and nothing was applied, then each line of the output of the compiler.npm run typecheckdoes not find a refusal ofdefineEvent,createMachineorworkspace(). These refusals occur when a module loads.jr2 uploads each module after the typecheck and before it connects to the cluster, so a refusal stops it before it changes the cluster.- Run
jr2 check(npm run checkin the scaffold) to find these errors without a cluster. It runs the typecheck and the load of each module thatjr2 upruns first, with the same code, and it gives the same refusals. It exits with1when it refuses. Run it before you say that a change is done. Do not import a workflow module withnode: in an installed Instance,nodecannot load the Kit, because the Kit is TypeScript undernode_modules. Thejr2binary can. - The name of a Workflow file has lower-case letters, digits and
-, such asrelease-notes.ts.jr2 uprefuses a different name, and gives the name to use:error: <folder>/workflows/<file>.ts: a Workflow is named by its filename stem, in lowercase letters, digits and "-", so that `jr2 run` and a `dispatch` slot name it the same way — rename it to <name>.ts (ADR-0071) jr2 uprefuses a module with nomachineexport. It prints the error and exits with1:error: workflow "<name>" (<file>) has no `machine` named export (module contract, ADR-0011/0015: `export const machine`)defineEventrefuses a name with a character other than a letter, a digit,_or-:defineEvent: invalid name "<name>" — must match /^[a-zA-Z0-9_-]+$/ (it becomes an MCP tool name and an xstate event type)defineEventrefuses an input with asenderfield:defineEvent: "<name>" declares `sender` in its input — the Orchestrator writes that field on every delivered event (who sent it); name the payload field something elsecreateMachinerefuses anonkey that no listed event has. The compiler does not always find this error.jr2 checkandjr2 upfind it when they load the module:machine "<id>" handles event "<key>", which no def in jr2Setup({ events }) declares (declared: <names>) — a typo, or a missing defineEvent. Anonkey with a.is a jr2 or xstate event, such asagent.fault, and you do not list it.createMachinerefuses a listed event that no state takes:machine "<id>" declares "<name>", which no state handles — a pick no state takes is on no Menu and no Gate: handle it, or drop it from jr2Setup({ events })createMachinerefuses an entry ofeventsthatdefineEventdid not make, and a name that you list two times:machine "<id>": `events` entry is not a defineEvent() def: <entry>andmachine "<id>": duplicate event "<name>" in `events`createMachinerefuses anagent(), an Effect or aworkspace()that a state writes in itsinvoke.src. Put it underactors, and invoke it by its key:machine "<id>": state "<state>" invokes <part> inline — a Part is a key of actors, so it has a name that the ledger, the report and customize() share: actors: { <name>: <factory>(...) }, then invoke: { src: "<name>" }jr2 uprefuses a registered Machine that has a zodinputifsetup()orcreateMachine()of xstate made the Machine in place ofjr2Setup. The compiler does not find this error:refusing: workflow "<workflow>": machine "<id>" has a zod `input` on createMachine, but jr2Setup did not make this Machine, so no door reads that input: a run starts with any input, and the Console start form is only a field for JSON — make the Machine with jr2Setup in place of setup() or createMachine() (leave out `events` if it takes no workflow event) (ADR-0033). The Orchestrator refuses a publish of that Machine with the same line.createMachinerefuses anoutputon a final state directly below the root when the root has nooutput, because the run then has no output (see Give the run an output). Move theoutputto the root, or pass it on from the root:machine "<id>": final state "<state>" declares an output, and the root has no output — xstate v5 gives a run only its root output, so this output reaches nothing and the run ends with none: write it at the root, as output: ({ context }) => ..., or keep it on the final state and pass it on with output: ({ event }) => event.output at the root. With more than one such final state, the refusal names each one and puts the words in the plural:final states "<state>", "<state>" declare an output,these outputs reach nothing,write one at the rootandkeep them on the final states and pass them on.jr2 checkandjr2 upalso refuse this in a Machine that you make with thesetup()of xstate, and in a child Machine.createMachinerefuses a state that invokes an Agent when noagent.faultroute is on that state or on a state above it. jr2 sendsagent.faultwhen the Turn of the Agent cannot go on. Route it on the state, or one time on the root:machine "<id>": state "<state>" invokes an Agent, and no route for "agent.fault" is on it or on a state above — jr2 sends agent.fault when the Agent's Turn cannot go on, and with no route the run faults: route it on that state or on a state above it, as on: { "agent.fault": "<the state that handles it>" } (ADR-0074). With more than one such state, the refusal names each one:states "<state>", "<state>" invoke an Agentandis on them. A state that invokes an Open body that lends an Agent or allows a model gets the same refusal, withinvokes an Open body that can run an Agent: the Open body sends theagent.faultof a Chart's Agent that the Chart does not route to the Machine.jr2 checkandjr2 upalso refuse this in a Machine that you make with thesetup()of xstate, and in a child Machine.workspace()refuses a body with a state that is not final when noworkspace.lostroute is on that state or on a state above it.workspace()sendsworkspace.lostto the body when its Sandbox is gone. Route it on the root of the body:workspace(): the body "<id>" has a state "<state>" that is not final, and no route for "workspace.lost" is on it or on a state above — jr2 sends workspace.lost to the body when its Sandbox is gone, and with no route the run faults: route it on the body's root, as on: { "workspace.lost": "<the state that handles it>" } (ADR-0074). A compound state whose final child has noonDoneis a state that the body stays in, so it needs the route too.- When a run gets
agent.faultorworkspace.lostand no transition takes it, for example because the guard of its route is false, the run endserror. Its fault names the event, the state and the reason (ADR-0074). failrefuses an input that has noreason, or areasonthat is empty. The compiler finds a missingreason, but not an empty one. The run endserror, and its fault is:fail: the input must be { reason: string; detail?: unknown } with a reason that is not empty (got <value>) — invoke: { src: "fail", input: { reason: "the tests failed" } } (ADR-0073).workspace()refuses a body that declares a door. Declare the door with theinputoption ofworkspace():workspace(): the body "<id>" declares its own run input, which nothing will ever serve or enforce — the wrapper feeds the body the run input PLUS the injected `workspace` handles, so the body's schema is not the door. Move it to the wrapper: workspace(body, { input, spec }) (ADR-0033).jr2 upprints each refusal ofdefineEvent,createMachineandworkspace()aserror: <refusal>, and exits with1. It changes nothing in the cluster.- The Orchestrator refuses a start whose input does not agree with the door, with the status code
400:invalid input for workflow "<workflow>": <issues>.<issues>is the list of the problems as JSON.jr2 runprintserror: invalid input for workflow "<workflow>": <issues>and exits with1. No run starts. - The Orchestrator refuses a name that no file in
workflows/registers, with the status code404:no workflow registered as "<workflow>".
Read next
- Machines and Workflows
- ADR-0011: the events are the Workflow's, and jr2 delivers them.
- ADR-0015:
jr2Setup, the Vocabulary and the module contract. - ADR-0033: the door.
- ADR-0050: jr2 finds a Workflow by its file name.
- Gates and The Menu
- Set an Agent's access and Dials, to add an Agent to the Machine.
- Bind a shipped Machine's Open parts, to register a Machine from a package.
- Reference:
jr2Setup,defineEvent,HostInjectedInput,GET /workflows/:name