Skip to content

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:

ts
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.object removes 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 invoked fail, and no state caught the Failure. The output of the run is the { reason, detail } of the Failure, not the root output.
  • 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:

ts
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 reject at a Gate, goes to a state that invokes fail with the reason of the person, as the rejected state of refund does. 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 an onError with the guard isFailure: onError: [{ guard: ({ event }) => isFailure(event.error), target: "replan" }, { target: "broken" }]. A broad onError with 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, and jr2 logs -f, write each Emit on stderr while they are attached to the run. The line holds emit, then the Emit as JSON. jr2 logs without -f writes 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 .ts file in workflows/, 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.ts file. Put a Machine that only other Machines import in such a file. A file in workflows/ 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 on key of a jr2Setup Machine is an event of its Vocabulary, a jr2 or xstate event, which has a dot, or the wildcard *. createMachine refuses an on key that no event declares.
  • Each event in the Vocabulary is an on key of a state. createMachine refuses an event that no state takes.
  • A payload schema is a flat z.object, and it has no sender field. jr2 writes sender on 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 output of its Machine. jr2 refuses an output on a final state directly below the root when the root has no output.
  • An Emit never moves a Machine.
  • The input of fail has a reason that is not empty. Else the run ends error: 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.

The docs of Kit version 0.5.1. Each page is also at its .md URL.