workspace
Function, in @jr2/orchestrator/src/workspace.ts. It is an export of @jr2/orchestrator, on the Authoring surface page, in Workspaces and Repos.
Overload 1
function workspace<
TSchema extends z.ZodObject,
TBody extends AnyStateMachine,
TSlots extends string = never,
>(
body: TBody & BodyAcceptsDoor<TBody, z.infer<TSchema>, TSlots>,
options: WorkspaceOptions<TSchema, TSlots> | NoSlotWorkspaceOptions<TSchema>,
): WorkspaceMachine<WithoutEmptyIndex<z.infer<TSchema>>, OutputFrom<TBody>, TBody, TSlots>Run a body Machine in a Sandbox: a pod of its own, with a Worktree of each Repo Slot on one branch. The result is a Machine. Register it as a Workflow, or invoke it from another Machine as an actor slot.
When a run enters it, the Workspace makes the Sandbox and waits for room on a node, with no deadline. It clones each Repo Slot and makes its Worktree on the branch that spec gives. Then it runs the body with the run input plus workspace, the handles (see Workspaced). The Agents with Working tools and the command Effects in the body run in that Sandbox. An Agent with workspace: "none" runs on the Instance Harness. When the body reaches a final state, the Workspace deletes the Sandbox, and its output is the body's output. Push the branch before then: jr2 deletes the Sandbox's commits with it.
Leave out repos for a Workspace with no Repo Slot: its Agents and command Effects share /work, as the steps of one job share a folder, and spec is optional and gives only workGroup. The body's input is then Workspaced<RunInput, never>. An http Effect sends a file of /work with file(path). A push, and a command with an Effect secret in its env, need a Repo Slot, so jr2 refuses them there. jr2 deletes /work with the Sandbox: send what must stay before the body ends.
Give each run a different branch, so that two runs do not push to one branch. Make it from a field of the run input, or from instanceId when the run input has no field that is different for each run: spec: ({ input }) => ({ branch: `fix/${input.instanceId}` }). The input of spec holds instanceId with no cast, also for the door z.object({}). Only a root workspace() gets instanceId from jr2. A workspace() that a different Machine invokes gets it only when its parent gives it.
The Sandbox is Lost when its pod is evicted or its node goes. Then the body gets the event workspace.lost, and the body decides what to do. jr2 does not make the Lost Sandbox again.
When the body fails (it invokes a fail() slot), the Workspace deletes the Sandbox, and then the same Failure goes up to the parent. With no parent to catch it, the run ends failed. When the body has a defect (an error that is not a Failure), the run faults: it ends error, and the Sandbox stays for you to inspect, until jr2's operator removes it after an idle timeout.
Give the run input to input, not to the body: a body with an input schema of its own is refused. The types refuse a body whose input does not accept the run input plus the handles, with the message the body's declared input must accept the door plus the injected handles.
It refuses, when you call it:
- a body with a state that is not final, when that state and each state above it have no
workspace.lostroute: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 body with its own
input: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). - an empty
image:workspace(): `image` must be a non-empty string (got <value>) — either a `file:` URL to a docker context this module ships (`import.meta.resolve("./image")`) or a registry ref (ADR-0037). - an empty
repos:workspace(): `repos: {}` names no Repo Slot — leave `repos` out for a Workspace with no Repo Slot, or name a slot: `repos: { app: "https://…" }` (ADR-0051). - a
reposthat is not a map oropen:workspace(): `repos` must be a map of Repo Slots — `repos: { app: "https://…" }` — or `open` whole, for a map the consumer names; leave it out for a Workspace with no Repo Slot (ADR-0051). - a bad Repo Slot name or value (see
RepoSlot) - a
specthat is not a function, or nospecon a Workspace with Repo Slots:workspace(): `spec` must be a function from the run input to the spec — spec: ({ input }) => ({ branch }) — and only a Workspace with no Repo Slot may leave it out (ADR-0051). - a bad
secretslist:workspace(): `secrets` must be a list of distinct names from jr2.config.ts's `secrets` list (got <value>) — secrets: ["JIRA_TOKEN"] (ADR-0059). - a bad
effector:workspace(): `effector` is { image } — a `file:` URL to a docker context or a registry ref (got <value>) — effector: { image: import.meta.resolve("./effector") } (ADR-0068). - a
userthat is not an image or{ image, resources? }:workspace(): `user` must be a non-empty string (an image), or { image, resources? } (got <value>) — either a `file:` URL to a docker context this module ships (`import.meta.resolve("./image")`) or a registry ref (ADR-0037, ADR-0060)., orworkspace(): user.<key> is not accepted — the User Container is { image, resources? } (ADR-0005, ADR-0060)(seeUserContainer) - a
resourceskey other thanlimitsorrequests:workspace(): resources.<key> is not accepted — a Size is { limits: { memory?, cpu? } } and nothing else — Agent Substrate's template size (ADR-0060), or the same text forresources.limits.<key>. The other texts are inSize.
And when a run enters it, a spec without a branch (or with branch: "default"), or with a bad reviewSha or workGroup: workspace spec invalid: <fields> — the spec derives from run input; does `jr2 run --input` carry every field this workflow's workspace() mapping reads?. With no Repo Slot, a spec with a branch or a reviewSha: workspace spec invalid: <field> (the Workspace has no Repo Slot, so it has no branch and no review Worktree; its spec gives only workGroup) …
Parameters
body: The Machine to run in the Sandbox. Its input isWorkspaced<RunInput, Slots>.options(WorkspaceOptions|NoSlotWorkspaceOptions): What the Sandbox is made of (image,user,resources,effector), the Repo Slots it clones (repos), the Held secrets it can use (secrets), the run input (input), and the function from the run input to the branch (spec).
Returns: A Machine whose input is the run input and whose output is the body's output.
Example
import { z } from "zod";
import {
command,
fail,
jr2Setup,
workspace,
type CommandOutput,
type Workspaced,
} from "@jr2/orchestrator";
const runInput = z.object({ ticket: z.string(), repo: z.string() });
type BodyInput = Workspaced<z.infer<typeof runInput>, "app" | "docs">;
const passed = (output: CommandOutput): boolean =>
output.outcome === "exited" && output.code === 0;
const body = jr2Setup({
types: {} as { input: BodyInput },
actors: {
build: command(["npm", "run", "build"], { slot: "app" }),
fail: fail(),
},
}).createMachine({
id: "build",
on: { "workspace.lost": ".lost" },
initial: "building",
states: {
building: {
invoke: {
src: "build",
onDone: [
{ guard: ({ event }) => passed(event.output), target: "built" },
{ target: "broken" },
],
},
},
built: { type: "final" },
broken: { invoke: { src: "fail", input: { reason: "npm run build did not pass" } } },
lost: { invoke: { src: "fail", input: { reason: "the Sandbox is gone" } } },
},
});
export const machine = workspace(body, {
input: runInput,
image: "docker.io/library/node:24",
resources: { limits: { memory: "4Gi", cpu: "2" } },
repos: {
app: ({ input }) => input.repo,
docs: { url: "https://github.com/you/docs.git", ref: "main" },
},
spec: ({ input }) => ({ branch: `fix/${input.ticket}` }),
});Example
import { z } from "zod";
import {
command,
fail,
jr2Setup,
workspace,
type CommandOutput,
type Workspaced,
} from "@jr2/orchestrator";
const passed = (output: CommandOutput): boolean =>
output.outcome === "exited" && output.code === 0;
// The run input has no field that is different for each run, so the branch comes from
// `instanceId`.
const runInput = z.object({});
const body = jr2Setup({
types: {} as { input: Workspaced<z.infer<typeof runInput>, "app"> },
actors: { test: command(["npm", "test"], { slot: "app" }), fail: fail() },
}).createMachine({
id: "nightly",
on: { "workspace.lost": ".lost" },
initial: "testing",
states: {
testing: {
invoke: {
src: "test",
onDone: [
{ guard: ({ event }) => passed(event.output), target: "tested" },
{ target: "red" },
],
},
},
tested: { type: "final" },
red: { invoke: { src: "fail", input: { reason: "npm test did not pass" } } },
lost: { invoke: { src: "fail", input: { reason: "the Sandbox is gone" } } },
},
});
export const machine = workspace(body, {
input: runInput,
image: "docker.io/library/node:24",
repos: { app: "https://github.com/you/app.git" },
spec: ({ input }) => ({ branch: `fix/${input.instanceId}` }),
});See also
- Workspaces
- Write a Workflow
- Size a Workspace
- Add tools to the Sandbox
- ADR-0012:
workspace()wraps a body Machine and owns only Sandbox lifecycle - ADR-0051: A Repo is a slot on the
workspace()and a cache on the node
Overload 2
function workspace<
TBody extends AnyStateMachine,
TSlots extends string = never,
TInput = unknown,
>(
body: TBody & BodyAcceptsSlots<TBody, TSlots>,
options: PermissiveWorkspaceOptions<TSlots, TInput> | PermissiveNoSlotWorkspaceOptions<TInput>,
): WorkspaceMachine<TInput, OutputFrom<TBody>, TBody, TSlots>Run a body Machine in a Sandbox, with no input schema: the Workspace starts with any input. Use it for a Workspace that a Pool or another Machine starts. workspace tells the rest, and what it refuses.
Parameters
body: The Machine to run in the Sandbox. Its input is the input of the Workspace plusworkspace, the handles.options(PermissiveWorkspaceOptions|PermissiveNoSlotWorkspaceOptions): The options of the Sandbox, andspec. There is noinput.
Returns: A Machine whose input is the run input and whose output is the body's output.
See also