Machines and Workflows
A Machine is an xstate state machine that you write. It holds the control flow of your work. The control flow is the states, the events that move a run between them, and what each state does. A Workflow is a Machine that your Instance registers under a name. You start a run of a Workflow with jr2 run <name>. This page tells how a Machine gets the input of a run, which events it takes, and how it tells you what it does.
Why the control flow is a Machine
A model does not always do the same thing for the same prompt. If a model decides the order of the work, you cannot know before a run what the run will do. In jr2, the Machine decides the order. An Agent does the work of one state, then picks an event from its Menu. The Machine decides what that event does.
A Machine is code that you can read before a run starts. The Console draws its states, and it shows the states of each run while the run continues. Thus you review how the work happens, and not only the code that comes out of it. The Orchestrator keeps the state of each run, so a run continues after a restart. Durable runs tells how.
A Workflow is a file
Each .ts file in the workflows/ folder of the Instance is a Workflow. The name of the file, without .ts, is the name of the Workflow. Thus workflows/refund.ts is the Workflow refund, and you start it with jr2 run refund. The name has only lower-case letters, digits and -, so jr2 run and a dispatch Effect write it the same way. The file exports its Machine as machine. jr2 reads no other export.
A Workflow is only a name. The Machine holds all that it needs: its events, its Agents, its Effects and its Sandbox Image. Thus one Machine can be a Workflow of your Instance, a child of a different Machine, or a part of an npm package. Composition tells how one Machine invokes a different Machine.
A Workflow can also be a named Chart: a Machine written as data, which runs under a Machine of the Instance. Its name is <wrapper>/<name>, such as notes/summary. It comes from a Chart file, workflows/<wrapper>/<name>.chart.json, or from a runtime Chart that the Orchestrator keeps. Charts tells how.
jr2 up loads each Workflow before it changes the cluster. It runs the typecheck of the Instance, then it loads each Machine and examines its parts. An error that the typecheck or the load finds stops jr2 up, so a run never starts with that error. The Orchestrator runs the Machines that the last jr2 up gave it. After you change a Machine, run jr2 up again.
An example
This Workflow takes a refund request, and it waits for a person to approve or reject it. It has no Agent:
import { z } from "zod";
import { assign, emit } from "xstate";
import { defineEvent, fail, jr2Setup } from "@jr2/orchestrator";
// The Vocabulary: the events that this Machine takes.
const approve = defineEvent({
name: "approve",
description: "Pay the refund.",
input: z.object({}),
});
const reject = defineEvent({
name: "reject",
description: "Do not pay the refund, and say why.",
input: z.object({ reason: z.string().min(1) }),
});
// The door: the input that starts a run. jr2 refuses a start that does not match it.
const runInput = z.object({
order: z.string(),
amount: z.number().positive(),
});
type RunInput = z.infer<typeof runInput>;
export const machine = jr2Setup({
types: {} as {
input: RunInput;
context: RunInput & { reason?: string };
emitted: { type: "decided"; approved: boolean };
},
events: [approve, reject],
actors: { fail: fail() },
}).createMachine({
id: "refund",
input: runInput,
context: ({ input }) => ({ order: input.order, amount: input.amount }),
initial: "review",
states: {
// A Gate: the run waits here until jr2 send, the Console or a webhook sends approve or reject.
review: {
invoke: {
src: "gate",
input: ({ context }) => ({ meta: { order: context.order, amount: context.amount } }),
},
on: {
// An Emit tells the people who watch the run what happened. Nothing in the Machine reads it.
approve: { target: "approved", actions: emit({ type: "decided", approved: true }) },
reject: {
target: "rejected",
actions: [assign({ reason: ({ event }) => event.reason }), emit({ type: "decided", approved: false })],
},
},
},
approved: { type: "final" },
// A person stopped the refund, and the run ends here. Not a final state: the run ends `failed`,
// with the person's reason.
rejected: { invoke: { src: "fail", input: ({ context }) => ({ reason: context.reason! }) } },
},
});The parts of this file are the subjects of the sections below: the door (runInput), the Vocabulary (approve and reject), and the Emit (decided). The review state invokes a Gate, which waits for an event from outside the run. Gates tells how.
The door
The door of a Machine is the input that starts a run. A Machine declares its door as a zod object in the input of createMachine. jr2Setup adds this option to createMachine. The same schema, through z.infer, gives types.input, so TypeScript and jr2 agree about one shape.
With a door, jr2 does these things:
- It examines each start against the schema. When the input does not agree with the schema, the Orchestrator refuses the start, and no run starts. Thus a wrong input stops at the start, and not in the middle of a run.
- It starts the run with the input that zod parsed. Thus the defaults of the schema apply, and a plain
z.objectremoves the keys that it does not declare. - It serves the schema as JSON Schema, at
GET /workflows/:name. This route needs no token. The Console makes its start form from the schema.
Without a door, a run starts with any input, and the start form of the Console is a field for JSON.
The root Machine of a run also gets instanceId, an id that jr2 makes for the run. It is not the run id. It is not a part of the door, because no caller sends it. HostInjectedInput is its type.
A workspace() or a pool() has a door of its own. It does not use the door of the Machine inside it, because that Machine gets other input. A workspace() body gets the run input and the Workspace handles. A Pool worker gets its item. Thus the wrapper declares the door, with its input option. workspace() refuses a body that has a zod input, also a body that setup() made.
The Vocabulary
The Vocabulary of a Machine is the list of the workflow events that it takes. Each event comes from defineEvent: a name, a zod schema for its payload and, if you want, a description. jr2Setup lists the events. Each name is an on key of the states that take it.
jr2 makes three things from the Vocabulary:
- The Menu of each Agent. Each event that an Agent can send is a tool for its model. The description of the event is the description of the tool. The Menu tells how.
- The events that a Gate accepts.
jr2 send, the Console and a webhook can send only those events. - The check of each delivery. jr2 parses the payload of each event with its schema before the Machine gets the event.
The position of an on key tells which party can send the event. The key belongs to the party that its state invokes, or to the party that the nearest state above it invokes. A party is an Agent or a Gate. The audience of an event can change this. The Menu gives the rule. Each event that a party sends carries its Sender.
A Vocabulary belongs to one Machine. A child Machine keeps its own Vocabulary. The Machine that invokes it does not see the events of the child, and does not declare them again. Thus two Machines in one run can each have an approve event, with different payloads.
jr2 also sends its own events into each Machine that jr2Setup makes: agent.fault and workspace.lost. They are not in the Vocabulary. Their names have a dot, and the name of a workflow event cannot have a dot. Each one must have a route: jr2 check refuses a state that invokes an Agent, or an Open body that can run one, with no agent.fault route on it or on a state above it, and a workspace() body with no workspace.lost route. Mechanism events gives them.
The output of a run
The output of a run is the root output of its Machine: the output beside id and states in createMachine. The status of the run shows it. A Machine that invokes this Machine gets it as event.output in onDone. xstate reads the output of a final state only as the event.output of the root output. Thus a Machine with an output only on its final states has no output, and jr2 refuses it when the module loads. Write a Workflow gives the two forms of a root output.
How a run ends
A run that ended has one Run status. These are the statuses that a Machine causes:
done: the Machine got to a final state. A final state means that the work succeeded.failed: a state invokedfail, and no state caught the Failure. The output of the run is the{ reason, detail }of the Failure, not the rootoutput.error: the run faulted. A throw from a promise, or a mechanism event that no transition took, is a fault. The fault of the run gives the error.
To fail a run, put fail: fail() under actors, and invoke it from a state. Its input is { reason, detail? }. reason is text for a person, and detail is optional JSON for a script:
failed: { invoke: { src: "fail", input: { reason: "the tests did not pass", detail: { code: 1 } } } },- Do not use a final state for work that did not succeed. A final state means
done. - Do not throw 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 decision of a person that ends the run, such as a
rejectat a Gate, goes to a state that invokesfailwith the reason of the person, as therejectedstate ofrefunddoes. A decision that sends the work back is a usual route. 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 it in its output. - A Failure goes up from the state that invokes
fail, through each parent Machine. To catch it, write anonErrorwith the guardisFailure:onError: [{ guard: ({ event }) => isFailure(event.error), target: "replan" }, { target: "broken" }]. A broadonErrorwith no guard catches a Failure too. - A Failure that goes through a
workspace()deletes its Sandbox. To keep the work for a person to read, wait at a Gate before the state that fails.
jr2 run exits 0 only for done. It exits 1 for failed, error and each other end. jr2 up and jr2 check list each state that invokes fail, under failures.
Emits
An Emit is a message that a Machine sends to the people and the tools that watch a run, such as "the review is done". A Machine sends it with xstate's emit() action. jr2 has no function of its own for it. jr2 gives the Emits of each actor in the run to the feeds, also those of each child Machine.
You see an Emit in these places:
jr2 run, andjr2 logs -f, write each Emit on stderr while they are attached to the run. The line holdsemit, then the Emit as JSON.jr2 logswithout-fwrites no Emit.- The Activity tab of the Console shows the type of each Emit that comes while the page is open.
- The Harness of each Workspace writes the Emits of its run in its log, beside the conversation of its Agents.
- The feed of a run,
GET /runs/:runId/events, carries each Emit with its payload. This feed needs the Instance token.
The feeds that need no token carry the type of an Emit, but not its payload. The payload is your data, as the context of a run is.
An Emit does not control the run. No part of the run acts on an Emit, and an Emit does not move a Machine. Only an event moves a Machine.
Rules
- A Workflow is a
.tsfile inworkflows/, and its name is the name of the file without.ts. The name has only lower-case letters, digits and-. A named Chart is a Workflow too, with the name<wrapper>/<name>. - jr2 does not register a file whose name starts with
_, or a.d.tsfile. Put a Machine that only other Machines import in such a file. A file inworkflows/that a different Workflow imports is also a Workflow of its own. - A Workflow file must export
machine. jr2 refuses a file without it. - The name of an event has only letters, digits,
_and-. - Each
onkey of ajr2SetupMachine is an event of its Vocabulary, a jr2 or xstate event, which has a dot, or the wildcard*.createMachinerefuses anonkey that no event declares. - Each event in the Vocabulary is an
onkey of a state.createMachinerefuses an event that no state takes. - A payload schema is a flat
z.object, and it has nosenderfield. jr2 writessenderon each event that a party sends. - When a Machine declares a door, the Orchestrator refuses a start whose input does not agree with it.
- The body of a
workspace()declares no door.workspace()refuses a body that declares one. - The output of a run is the root
outputof its Machine. jr2 refuses anoutputon a final state directly below the root when the root has nooutput. - An Emit never moves a Machine.
- The input of
failhas areasonthat is not empty. Else the run endserror: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).
Write a Workflow gives the exact text of each refusal.
Read next
- Write a Workflow
- Start a run
- Agents and Turns, The Menu, Gates and Charts
- Reference:
jr2Setup,defineEvent,fail,isFailure,HostInjectedInput,jr2 run,POST /workflows/:name/runsand Mechanism events - ADR-0011: the events are the Workflow's, and jr2 delivers them
- ADR-0015: the Vocabulary and the Menus come from the Machine
- ADR-0033: a Machine declares the input that starts it
- ADR-0049: a Machine carries its parts
- ADR-0050: what only the CLI names, jr2 finds by its file name
- ADR-0014: observation is open, and the state of a run is not
- ADR-0073: a Machine fails a run with
fail(), and an uncaught Failure ends itfailed