@jr2/orchestrator 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/agents-block.md +2 -2
- package/docs/concepts/agents-and-turns.md +56 -20
- package/docs/concepts/architecture.md +30 -17
- package/docs/concepts/charts.md +6 -3
- package/docs/concepts/converge.md +3 -0
- package/docs/concepts/effects.md +86 -31
- package/docs/concepts/machines-and-workflows.md +9 -0
- package/docs/concepts/pools-and-sources.md +6 -6
- package/docs/concepts/secrets-and-trust.md +126 -42
- package/docs/concepts/the-menu.md +3 -0
- package/docs/concepts/workspaces.md +129 -51
- package/docs/get-started/an-agent-in-a-sandbox.md +21 -19
- package/docs/get-started/have-your-assistant-write-a-machine.md +111 -40
- package/docs/get-started/install.md +4 -0
- package/docs/guides/add-tools-to-the-sandbox.md +17 -4
- package/docs/guides/bind-open-parts.md +5 -4
- package/docs/guides/cut-a-worktree.md +560 -0
- package/docs/guides/fix-a-failed-converge.md +1 -1
- package/docs/guides/let-an-agent-write-a-chart.md +5 -1
- package/docs/guides/look-inside-a-sandbox.md +18 -15
- package/docs/guides/move-from-kind-to-a-shared-cluster.md +16 -0
- package/docs/guides/push-and-sign-a-branch.md +67 -21
- package/docs/guides/run-a-command-or-call-a-url.md +84 -41
- package/docs/guides/run-one-worker-per-item.md +19 -18
- package/docs/guides/set-an-agents-access-and-dials.md +67 -22
- package/docs/guides/size-a-workspace.md +19 -6
- package/docs/guides/use-a-hosted-provider.md +6 -0
- package/docs/guides/watch-a-run.md +47 -24
- package/docs/guides/write-a-workflow.md +1 -1
- package/docs/llms.txt +33 -12
- package/docs/reference/authoring-surface/agent.md +34 -11
- package/docs/reference/authoring-surface/answer.md +19 -3
- package/docs/reference/authoring-surface/arg.md +27 -5
- package/docs/reference/authoring-surface/branchname.md +15 -0
- package/docs/reference/authoring-surface/command.md +36 -21
- package/docs/reference/authoring-surface/fail.md +17 -4
- package/docs/reference/authoring-surface/file.md +36 -16
- package/docs/reference/authoring-surface/http.md +108 -8
- package/docs/reference/authoring-surface/isready.md +14 -0
- package/docs/reference/authoring-surface/jr2setup.md +1 -1
- package/docs/reference/authoring-surface/open.md +36 -5
- package/docs/reference/authoring-surface/paramsof.md +22 -4
- package/docs/reference/authoring-surface/pool.md +4 -4
- package/docs/reference/authoring-surface/push.md +30 -12
- package/docs/reference/authoring-surface/readyof.md +21 -0
- package/docs/reference/authoring-surface/secret.md +25 -4
- package/docs/reference/authoring-surface/source.md +3 -3
- package/docs/reference/authoring-surface/type-agentdefinition.md +2 -2
- package/docs/reference/authoring-surface/type-agentslots.md +4 -1
- package/docs/reference/authoring-surface/type-anyframepath.md +14 -0
- package/docs/reference/authoring-surface/type-anyworktreepath.md +15 -0
- package/docs/reference/authoring-surface/type-bindactors.md +14 -0
- package/docs/reference/authoring-surface/type-binding.md +5 -6
- package/docs/reference/authoring-surface/type-commandinput.md +4 -3
- package/docs/reference/authoring-surface/type-commandoptions.md +0 -22
- package/docs/reference/authoring-surface/type-commandoutput.md +22 -4
- package/docs/reference/authoring-surface/type-customize.md +4 -2
- package/docs/reference/authoring-surface/type-dispatchoutput.md +8 -1
- package/docs/reference/authoring-surface/type-doorlessinput.md +19 -0
- package/docs/reference/authoring-surface/type-filebody.md +4 -8
- package/docs/reference/authoring-surface/type-framepath.md +14 -0
- package/docs/reference/authoring-surface/type-hostinjectedinput.md +2 -4
- package/docs/reference/authoring-surface/type-httpdefinition.md +6 -3
- package/docs/reference/authoring-surface/type-httpfileinput.md +20 -0
- package/docs/reference/authoring-surface/type-httpoutput.md +7 -1
- package/docs/reference/authoring-surface/type-lateroutcome.md +21 -0
- package/docs/reference/authoring-surface/type-namestart.md +77 -0
- package/docs/reference/authoring-surface/type-noslotworkspaceoptions.md +1 -6
- package/docs/reference/authoring-surface/type-notworktreekeys.md +12 -0
- package/docs/reference/authoring-surface/type-opencommandinput.md +7 -2
- package/docs/reference/authoring-surface/type-permissivenoslotworkspaceoptions.md +1 -6
- package/docs/reference/authoring-surface/type-permissiveworkspaceoptions.md +5 -10
- package/docs/reference/authoring-surface/type-poolnextinput.md +22 -0
- package/docs/reference/authoring-surface/type-poolspec.md +2 -2
- package/docs/reference/authoring-surface/type-pushinput.md +22 -3
- package/docs/reference/authoring-surface/type-pushoutput.md +32 -7
- package/docs/reference/authoring-surface/type-reposlot.md +5 -5
- package/docs/reference/authoring-surface/type-sandboxoptions.md +11 -4
- package/docs/reference/authoring-surface/type-slothandles.md +5 -13
- package/docs/reference/authoring-surface/type-sourcespec.md +10 -10
- package/docs/reference/authoring-surface/type-workspaced.md +29 -8
- package/docs/reference/authoring-surface/type-workspacehandles.md +5 -6
- package/docs/reference/authoring-surface/type-workspaceoptions.md +1 -13
- package/docs/reference/authoring-surface/type-worktreefound.md +16 -0
- package/docs/reference/authoring-surface/type-worktreeinput.md +56 -0
- package/docs/reference/authoring-surface/type-worktreekey.md +14 -0
- package/docs/reference/authoring-surface/type-worktreeoptions.md +29 -0
- package/docs/reference/authoring-surface/type-worktreeoutput.md +45 -0
- package/docs/reference/authoring-surface/type-worktreepath.md +16 -0
- package/docs/reference/authoring-surface/type-worktreeready.md +37 -0
- package/docs/reference/authoring-surface/type-worktreerefused.md +21 -0
- package/docs/reference/authoring-surface/workspace.md +65 -37
- package/docs/reference/authoring-surface/worktree.md +71 -0
- package/docs/reference/authoring-surface.md +30 -10
- package/docs/reference/cli.md +1 -1
- package/docs/reference/config.md +13 -4
- package/docs/reference/decisions/0001-operator-owned-generic-sandbox.md +10 -9
- package/docs/reference/decisions/0002-duplex-streaming-actor.md +17 -10
- package/docs/reference/decisions/0004-worktree-provisioning-model.md +27 -21
- package/docs/reference/decisions/0005-sandbox-pod-composition.md +29 -19
- package/docs/reference/decisions/0006-agent-control-surface.md +6 -3
- package/docs/reference/decisions/0012-workspace-wrapper-machine.md +30 -30
- package/docs/reference/decisions/0013-the-agent-reaches-its-machine-through-a-container-it-cannot-read.md +35 -17
- package/docs/reference/decisions/0015-authoring-surface-absorbs-the-mechanism.md +8 -2
- package/docs/reference/decisions/0016-agent-turn-mechanics-are-internal.md +9 -7
- package/docs/reference/decisions/0017-pool-over-a-source-port.md +6 -4
- package/docs/reference/decisions/0019-one-converging-command-against-the-current-context.md +21 -9
- package/docs/reference/decisions/0021-workspace-continuity-is-a-lease-that-answers-back.md +2 -1
- package/docs/reference/decisions/0023-the-harness-prints-the-conversation.md +8 -9
- package/docs/reference/decisions/0024-an-agents-turn-ends-with-the-state-that-asked-for-it.md +62 -65
- package/docs/reference/decisions/0027-the-harness-is-jr2s-own-server-flue-retires-the-wire-stays.md +9 -1
- package/docs/reference/decisions/0028-what-an-agent-may-do-to-the-workspace-is-part-of-its-definition.md +13 -12
- package/docs/reference/decisions/0031-menu-only-agents-run-on-the-instance-harness.md +4 -1
- package/docs/reference/decisions/0033-a-machine-declares-the-input-that-starts-it.md +18 -19
- package/docs/reference/decisions/0037-an-instance-builds-its-sandbox-images-jr2-injects-the-harness.md +8 -8
- package/docs/reference/decisions/0049-a-machine-carries-its-parts-and-composes-by-invoke.md +4 -4
- package/docs/reference/decisions/0051-a-repo-is-a-slot-on-the-workspace-and-a-cache-on-the-node.md +57 -46
- package/docs/reference/decisions/0052-a-sandbox-lands-where-an-ordinary-pod-lands-and-the-cache-follows-it.md +5 -0
- package/docs/reference/decisions/0053-a-fetch-inside-the-pod-asks-the-node-cache-and-the-cache-asks-the-remote.md +20 -8
- package/docs/reference/decisions/0057-a-turn-is-its-frame-its-dials-and-whether-it-continues.md +24 -24
- package/docs/reference/decisions/0058-a-harness-answers-the-orchestrator-alone.md +5 -3
- package/docs/reference/decisions/0059-a-harness-holds-stand-ins-and-the-custodian-holds-the-keys.md +110 -67
- package/docs/reference/decisions/0062-jr2-briefs-the-agent-on-its-seat-never-on-its-task.md +101 -74
- package/docs/reference/decisions/0063-the-orchestrator-watches-the-cluster.md +5 -6
- package/docs/reference/decisions/0066-a-model-tier-runs-a-real-model-and-a-change-to-what-it-reads-runs-the-tier.md +2 -0
- package/docs/reference/decisions/0067-an-effect-is-an-actor-slot-and-runs-at-most-once-unless-marked-idempotent.md +181 -153
- package/docs/reference/decisions/0068-an-effector-holds-an-effects-secrets-and-signs-its-wire.md +74 -63
- package/docs/reference/decisions/0070-a-machine-lends-its-parts-to-a-chart.md +31 -20
- package/docs/reference/decisions/0073-a-machine-fails-a-run-with-fail-and-an-uncaught-failure-ends-it-failed.md +1 -1
- package/docs/reference/decisions/0075-a-machine-cuts-a-worktree-and-the-workspace-only-clones.md +295 -0
- package/docs/reference/decisions/0076-an-agents-control-credential-lives-only-where-no-workspace-code-runs.md +320 -0
- package/docs/reference/decisions/0077-jr2-requires-landlock-on-every-sandbox-node.md +79 -0
- package/docs/reference/decisions.md +3 -0
- package/docs/reference/glossary.md +51 -12
- package/docs/reference/http-api.md +45 -7
- package/docs/reference/machines.md +0 -1
- package/docs/why.md +5 -4
- package/package.json +3 -3
- package/src/actor.ts +161 -35
- package/src/agent.ts +6 -6
- package/src/ambient.ts +3 -5
- package/src/chart.ts +186 -19
- package/src/charts.ts +9 -2
- package/src/config.ts +16 -6
- package/src/custodian.lua +17 -5
- package/src/custodian.ts +251 -77
- package/src/customize.ts +11 -3
- package/src/effect-checks.ts +50 -3
- package/src/effect.ts +343 -127
- package/src/effector-wire.ts +6 -3
- package/src/fail.ts +17 -4
- package/src/harness-client.ts +74 -31
- package/src/harness-env.ts +33 -0
- package/src/held-secrets.ts +125 -16
- package/src/holds.ts +3 -0
- package/src/http-effect.ts +88 -32
- package/src/http.ts +56 -17
- package/src/index.ts +15 -1
- package/src/internal.ts +1 -0
- package/src/kube-client.ts +6 -3
- package/src/machine-doc.ts +3 -4
- package/src/model-keys.ts +24 -0
- package/src/names.ts +19 -6
- package/src/notices.ts +443 -54
- package/src/open-body.ts +85 -19
- package/src/open.ts +36 -5
- package/src/parts.ts +40 -28
- package/src/party.ts +33 -0
- package/src/pick.ts +41 -7
- package/src/pool.ts +44 -23
- package/src/registration.ts +53 -11
- package/src/repo-identity.ts +7 -3
- package/src/repos.ts +20 -2
- package/src/run-host.ts +106 -20
- package/src/sandbox-effect.ts +217 -66
- package/src/sandbox-kube.ts +63 -16
- package/src/secret-placement.ts +9 -2
- package/src/setup.ts +86 -6
- package/src/tokens.ts +107 -32
- package/src/vocabulary.ts +23 -4
- package/src/wire.ts +10 -10
- package/src/workspace.ts +264 -253
- package/src/worktree.ts +249 -0
- package/docs/reference/authoring-surface/type-noslotspec.md +0 -26
- package/docs/reference/authoring-surface/type-workspacespec.md +0 -37
package/src/workspace.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
// `workspace(body, { input, image, user, repos, resources,
|
|
2
|
-
// ADR-0060): the jr2-owned wrapper Machine that owns ONLY Sandbox lifecycle — provision the Sandbox
|
|
3
|
-
// (out of the STATIC `image`/`user`/`repos`/`resources` options it carries) +
|
|
4
|
-
//
|
|
5
|
-
// `{ workspace: { repos
|
|
1
|
+
// `workspace(body, { input, image, user, repos, resources, workGroup })` (ADR-0012, ADR-0049, ADR-0051,
|
|
2
|
+
// ADR-0060, ADR-0075): the jr2-owned wrapper Machine that owns ONLY Sandbox lifecycle — provision the Sandbox
|
|
3
|
+
// (out of the STATIC `image`/`user`/`repos`/`resources` options it carries) + clone each Repo
|
|
4
|
+
// Slot, run the author's body Machine inside it as the named slot `body`, with
|
|
5
|
+
// `{ workspace: { repos } }` appended to its input (the mechanism-facing endpoint/sandbox
|
|
6
6
|
// are published ambiently — ADR-0016, ambient.ts), and destroy the Sandbox when the body reaches a
|
|
7
7
|
// final state or fails (ADR-0073). Teardown lives INSIDE the
|
|
8
8
|
// wrapper's own states because an xstate stop is synchronous — multi-step async cleanup must be
|
|
@@ -44,6 +44,8 @@ import {
|
|
|
44
44
|
} from "xstate";
|
|
45
45
|
import type { z } from "zod";
|
|
46
46
|
import { registerAmbientHandles, type AmbientHandles } from "./ambient.ts";
|
|
47
|
+
import { isAgent } from "./agent.ts";
|
|
48
|
+
import { isEffect } from "./effect.ts";
|
|
47
49
|
import { failLogic, failurePayload, isFailure, type Failure, type FailureInput } from "./fail.ts";
|
|
48
50
|
import { refuseUnroutedLost } from "./mechanism-routes.ts";
|
|
49
51
|
import {
|
|
@@ -68,7 +70,14 @@ import {
|
|
|
68
70
|
} from "./parts.ts";
|
|
69
71
|
import { actorPath, runBindingOf, type AnyActorSystem } from "./registration.ts";
|
|
70
72
|
import { assertSize, assertUserSeat, userSeatOf, type Size, type UserContainer } from "./size.ts";
|
|
71
|
-
import {
|
|
73
|
+
import {
|
|
74
|
+
attachInputSchema,
|
|
75
|
+
declaresRunInput,
|
|
76
|
+
inputSchemaOf,
|
|
77
|
+
parsedNestedDoor,
|
|
78
|
+
invokingMachine,
|
|
79
|
+
type HostInjectedInput,
|
|
80
|
+
} from "./vocabulary.ts";
|
|
72
81
|
|
|
73
82
|
/** Lease cadence when the backend names none. Well inside the 30m default idle timeout, so a
|
|
74
83
|
* few missed renewals in a row are survivable. It bounds only how long an orphan waits to be
|
|
@@ -90,47 +99,6 @@ export function leaseDelay(interval: number, which: "first" | "next", random: nu
|
|
|
90
99
|
return interval * (1 - LEASE_JITTER + 2 * LEASE_JITTER * random);
|
|
91
100
|
}
|
|
92
101
|
|
|
93
|
-
// What to attach, in workspace vocabulary only (ADR-0012 boundary): the one branch the body works
|
|
94
|
-
// on, the pod's work group, and the review sha. Derived PER RUN from the wrapper's input, which is
|
|
95
|
-
// what keeps it out here rather than in the options — and which is exactly why the two IMAGES and
|
|
96
|
-
// the REPOS are NOT here (ADR-0049, ADR-0051): `jr2 up` must find them by walking the Machine, and
|
|
97
|
-
// no walk can evaluate a function of run input. They are static `workspace()` options instead; a
|
|
98
|
-
// Repo that IS a function of run input is a per-run slot, a mapper the walk can see the shape of
|
|
99
|
-
// even though it cannot see the url.
|
|
100
|
-
/** What the `spec` of a `workspace()` returns: the branch that the Workspace works on, and two
|
|
101
|
-
* optional fields. jr2 calls `spec` with the run input when the run enters the Workspace. */
|
|
102
|
-
export type WorkspaceSpec = {
|
|
103
|
-
/** The branch that the Workspace works on: each Repo Slot gets a Worktree for it, at
|
|
104
|
-
* `/work/<slot>/<branch>`, with each `/` in the branch written as `-`. Required, and not
|
|
105
|
-
* `default`, which is the directory of the clone. Your Machine decides its name. */
|
|
106
|
-
branch: string;
|
|
107
|
-
// The pod's work group (ADR-0005): `fsGroup`. The two writing seats may run different uids —
|
|
108
|
-
// each image's own `USER` decides — and POSIX would then make the other seat's files read-only;
|
|
109
|
-
// fsGroup (group ownership) plus the default ACL the attach stamps on each repo root (group
|
|
110
|
-
// writability, umask-proof) closes that, both inert when the uids already match. The override
|
|
111
|
-
// exists for the image whose sessions already hold a gid of their own — a root sshd's logins
|
|
112
|
-
// rebuild groups from `/etc/group`, so pointing the work group at one they have costs no
|
|
113
|
-
// rebuild. Never a config key — pod composition is the spec's business.
|
|
114
|
-
/** The group id that owns the files in `/work`. With it, the Sandbox Image and the User
|
|
115
|
-
* Container can both write those files when they run as different users. Default: 2000. Set it
|
|
116
|
-
* to a group id that the User Container's sessions already have, for example when a root sshd
|
|
117
|
-
* builds their groups from `/etc/group`. A non-negative integer. */
|
|
118
|
-
workGroup?: number;
|
|
119
|
-
// `<branchDir>-review`, a sibling of the branch worktree, forced to exactly this sha on every
|
|
120
|
-
// attach (ADR-0028). Creation-time seat only; the per-round refresh verb (the sha moves between
|
|
121
|
-
// review rounds) is a later, workflow-driven change.
|
|
122
|
-
/** A commit to check out in a second, detached Worktree beside each branch Worktree, for a
|
|
123
|
-
* reviewer: `/work/<slot>/<branch>-review`. The body gets its paths as `workspace.review`. */
|
|
124
|
-
reviewSha?: string;
|
|
125
|
-
};
|
|
126
|
-
|
|
127
|
-
// A Workspace with no Repo Slot (ADR-0051) attaches nothing, so a branch and a review sha name
|
|
128
|
-
// nothing: `never` refuses them where the spec is written, and `assertSpec` refuses them at run
|
|
129
|
-
// time for the caller the types do not see.
|
|
130
|
-
/** What the `spec` of a `workspace()` with no Repo Slot returns: only `workGroup`. There is no
|
|
131
|
-
* branch and no review Worktree, because the Workspace has no Repo Slot. */
|
|
132
|
-
export type NoSlotSpec = Pick<WorkspaceSpec, "workGroup"> & { branch?: never; reviewSha?: never };
|
|
133
|
-
|
|
134
102
|
// What the workspace hands the BODY (ADR-0012, ADR-0016): worktree geography only, keyed by Repo
|
|
135
103
|
// Slot (ADR-0051) — so a path a prompt names is right in every Instance that consumes the Machine.
|
|
136
104
|
// `endpoint` and `sandbox` are mechanism-internal — the Agent actor resolves them ambiently from
|
|
@@ -145,14 +113,13 @@ export type NoSlotSpec = Pick<WorkspaceSpec, "workGroup"> & { branch?: never; re
|
|
|
145
113
|
// body under a wrapper that declares its map Open (`repos: open`, ADR-0051), where the slots are
|
|
146
114
|
// the composer's words and no body could name them.
|
|
147
115
|
/**
|
|
148
|
-
* What the body of a `workspace()` gets as `input.workspace`: the
|
|
149
|
-
*
|
|
116
|
+
* What the body of a `workspace()` gets as `input.workspace`: the folder of each Repo Slot. A path
|
|
117
|
+
* is correct in each Instance that registers the Machine.
|
|
150
118
|
*
|
|
151
119
|
* `TSlots` is the names of the Repo Slots that the body reads. `workspace()` refuses a body that
|
|
152
120
|
* names a slot that the Workspace does not have. Write `string` for a body that names no slot and
|
|
153
|
-
* reads the
|
|
154
|
-
* Workspace with no Repo Slot: its handles are `{ repos: {} }`,
|
|
155
|
-
* in `/work`.
|
|
121
|
+
* reads the folders in the order of `repos`, as under `repos: open`. Write `never` for a
|
|
122
|
+
* Workspace with no Repo Slot: its handles are `{ repos: {} }`, and its Agents work in `/work`.
|
|
156
123
|
*/
|
|
157
124
|
// `[TSlots] extends [never]`, not `TSlots extends never`: a bare `never` would distribute to `never`.
|
|
158
125
|
export type WorkspaceHandles<TSlots extends string> = [TSlots] extends [never]
|
|
@@ -161,20 +128,10 @@ export type WorkspaceHandles<TSlots extends string> = [TSlots] extends [never]
|
|
|
161
128
|
|
|
162
129
|
/** The handles of a Workspace that declares Repo Slots. @internal */
|
|
163
130
|
type SlotHandles<TSlots extends string> = {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
// promise; the meaning is the Machine's (ADR-0051).
|
|
169
|
-
/** The absolute path of each Repo Slot's Worktree, `/work/<slot>/<branch>`, in the order that
|
|
170
|
-
* `repos` declares the slots. Give one to an Agent's Turn as its `cwd`. */
|
|
171
|
-
repos: Record<TSlots, string>;
|
|
172
|
-
/** The branch that the Workspace works on: the `branch` of the spec. */
|
|
173
|
-
branch: string;
|
|
174
|
-
// The review seat (ADR-0028).
|
|
175
|
-
/** The path of each Repo Slot's review Worktree. Present only when the spec has `reviewSha`.
|
|
176
|
-
* Give one to a reviewer's Turn as its `cwd`. */
|
|
177
|
-
review?: Partial<Record<TSlots, string>>;
|
|
131
|
+
/** The folder of each Repo Slot, `/work/<slot>`, in the order that `repos` declares the slots.
|
|
132
|
+
* `${handle}/default` is the clone, on the Binding's base. Any other Worktree is one the Machine
|
|
133
|
+
* cut with `worktree()`. */
|
|
134
|
+
repos: { [K in TSlots]: `/work/${K}` };
|
|
178
135
|
};
|
|
179
136
|
|
|
180
137
|
// The composition is the whole reason the door is declared on the wrapper and not on the body
|
|
@@ -190,7 +147,7 @@ type SlotHandles<TSlots extends string> = {
|
|
|
190
147
|
// the composition the WRAPPER makes.
|
|
191
148
|
/**
|
|
192
149
|
* The input of a `workspace()` body: the run input, plus `workspace`, the handles that the
|
|
193
|
-
* Workspace adds (`workspace.repos`,
|
|
150
|
+
* Workspace adds (`workspace.repos`, the folder of each Repo Slot). Use it as the type of
|
|
194
151
|
* the body's input.
|
|
195
152
|
*
|
|
196
153
|
* The first argument is the run input: the `input` schema of the `workspace()`. The second is the
|
|
@@ -207,16 +164,29 @@ type SlotHandles<TSlots extends string> = {
|
|
|
207
164
|
* @example
|
|
208
165
|
* ```ts
|
|
209
166
|
* import { z } from "zod";
|
|
210
|
-
* import {
|
|
167
|
+
* import {
|
|
168
|
+
* agent,
|
|
169
|
+
* defineEvent,
|
|
170
|
+
* fail,
|
|
171
|
+
* isReady,
|
|
172
|
+
* jr2Setup,
|
|
173
|
+
* workspace,
|
|
174
|
+
* worktree,
|
|
175
|
+
* type Workspaced,
|
|
176
|
+
* } from "@jr2/orchestrator";
|
|
211
177
|
*
|
|
212
178
|
* const done = defineEvent({
|
|
213
179
|
* name: "done",
|
|
214
180
|
* description: "Call this when REVIEW.md is written.",
|
|
215
181
|
* input: z.object({}),
|
|
216
182
|
* });
|
|
217
|
-
* const runInput = z.object({
|
|
183
|
+
* const runInput = z.object({ sha: z.string() });
|
|
218
184
|
* type BodyInput = Workspaced<z.infer<typeof runInput>, "app">;
|
|
219
185
|
*
|
|
186
|
+
* // A handle is the slot's folder: `/work/app`. A helper returns `as const`, so that the compiler
|
|
187
|
+
* // sees the slot.
|
|
188
|
+
* const reviewTree = (context: BodyInput) => `${context.workspace.repos.app}/review` as const;
|
|
189
|
+
*
|
|
220
190
|
* const body = jr2Setup({
|
|
221
191
|
* types: {} as { input: BodyInput; context: BodyInput },
|
|
222
192
|
* events: [done],
|
|
@@ -226,25 +196,34 @@ type SlotHandles<TSlots extends string> = {
|
|
|
226
196
|
* instructions: "You review one commit and write REVIEW.md.",
|
|
227
197
|
* workspace: "read",
|
|
228
198
|
* }),
|
|
199
|
+
* cut: worktree(),
|
|
229
200
|
* fail: fail(),
|
|
230
201
|
* },
|
|
231
202
|
* }).createMachine({
|
|
232
203
|
* id: "review",
|
|
233
204
|
* context: ({ input }) => input,
|
|
234
205
|
* on: { "workspace.lost": ".lost", "agent.fault": ".failed" },
|
|
235
|
-
* initial: "
|
|
206
|
+
* initial: "cutting",
|
|
236
207
|
* states: {
|
|
208
|
+
* cutting: {
|
|
209
|
+
* invoke: {
|
|
210
|
+
* src: "cut",
|
|
211
|
+
* input: ({ context }) => ({ path: reviewTree(context), detach: context.sha }),
|
|
212
|
+
* onDone: [{ guard: ({ event }) => isReady(event.output), target: "reviewing" }, { target: "notCut" }],
|
|
213
|
+
* },
|
|
214
|
+
* },
|
|
237
215
|
* reviewing: {
|
|
238
216
|
* invoke: {
|
|
239
217
|
* src: "reviewer",
|
|
240
218
|
* input: ({ context }) => ({
|
|
241
|
-
* prompt: `Review commit ${context.sha}
|
|
242
|
-
* cwd: context
|
|
219
|
+
* prompt: `Review commit ${context.sha}.`,
|
|
220
|
+
* cwd: reviewTree(context),
|
|
243
221
|
* }),
|
|
244
222
|
* },
|
|
245
223
|
* on: { done: "finished" },
|
|
246
224
|
* },
|
|
247
225
|
* finished: { type: "final" },
|
|
226
|
+
* notCut: { invoke: { src: "fail", input: { reason: "the Worktree was not cut" } } },
|
|
248
227
|
* failed: { invoke: { src: "fail", input: { reason: "the Agent's Turn could not go on" } } },
|
|
249
228
|
* lost: { invoke: { src: "fail", input: { reason: "the Sandbox is gone" } } },
|
|
250
229
|
* },
|
|
@@ -253,7 +232,6 @@ type SlotHandles<TSlots extends string> = {
|
|
|
253
232
|
* export const machine = workspace(body, {
|
|
254
233
|
* input: runInput,
|
|
255
234
|
* repos: { app: "https://github.com/you/app.git" },
|
|
256
|
-
* spec: ({ input }) => ({ branch: input.branch, reviewSha: input.sha }),
|
|
257
235
|
* });
|
|
258
236
|
* ```
|
|
259
237
|
*
|
|
@@ -323,6 +301,9 @@ export type PlaceRequest = {
|
|
|
323
301
|
image?: string;
|
|
324
302
|
user?: string;
|
|
325
303
|
workGroup?: number;
|
|
304
|
+
/** The Held secrets the `workspace()`'s `secrets` list names (ADR-0059): the ones the Harness may
|
|
305
|
+
* put in a confined child's env, by Stand-in (ADR-0076). Names, never a value. Absent → none. */
|
|
306
|
+
secrets?: string[];
|
|
326
307
|
repos: ProvisionedRepo[];
|
|
327
308
|
resources?: Size;
|
|
328
309
|
userResources?: Size;
|
|
@@ -370,17 +351,12 @@ export interface SandboxPort {
|
|
|
370
351
|
*/
|
|
371
352
|
provision(name: string): Promise<{ endpoint: string }>;
|
|
372
353
|
/** Post-Ready attach (ADR-0004): per slot, `git clone --shared` off the node's read-only
|
|
373
|
-
* cache (the `default/` checkout)
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
|
|
377
|
-
attach(req: {
|
|
378
|
-
name: string;
|
|
379
|
-
spec: WorkspaceSpec;
|
|
380
|
-
repos: Array<{ slot: string; url: string; ref?: string }>;
|
|
381
|
-
}): Promise<{
|
|
354
|
+
* cache (the `default/` checkout). A Machine cuts each Worktree (ADR-0075). Resolves with the
|
|
355
|
+
* folder of each slot; `stale` names the slots whose cache could not be fetched before this
|
|
356
|
+
* attach, with git's own error (ADR-0051: freshness degrades, absence does not). */
|
|
357
|
+
attach(req: { name: string; repos: Array<{ slot: string; url: string; ref?: string }> }): Promise<{
|
|
382
358
|
repos: Record<string, string>;
|
|
383
|
-
|
|
359
|
+
bases?: Record<string, string>;
|
|
384
360
|
stale?: Record<string, string>;
|
|
385
361
|
}>;
|
|
386
362
|
/**
|
|
@@ -483,12 +459,11 @@ export type WorkspaceWait = PlacingWait & { since: string };
|
|
|
483
459
|
export const WORKSPACE_WAIT_KEY = "wait";
|
|
484
460
|
|
|
485
461
|
type WsContext = {
|
|
486
|
-
/** The wrapper's
|
|
462
|
+
/** The wrapper's input, which the body gets (plus `workspace`). Under a parent, `place` replaces
|
|
463
|
+
* it with the door's parsed value. */
|
|
487
464
|
runInput: Record<string, unknown>;
|
|
488
465
|
/** This invocation's stable identity (the actor id the parent invoked/spawned us under). */
|
|
489
466
|
wsId: string;
|
|
490
|
-
/** The resolved spec — computed once from input and persisted, like any other context data. */
|
|
491
|
-
spec: AnySpec;
|
|
492
467
|
/** Every slot's resolved Binding, by slot, in declaration order — assigned at provision. */
|
|
493
468
|
bindings?: Record<string, ResolvedBinding>;
|
|
494
469
|
endpoint?: string;
|
|
@@ -581,6 +556,20 @@ export type WorkspaceMachine<TInput, TOutput, TBody extends AnyStateMachine, TSl
|
|
|
581
556
|
// satisfies a body that writes that type itself (`z.infer<typeof door>`, alone or with the
|
|
582
557
|
// handles). The raw door keeps those bodies legal, and the stripped form keeps a body that
|
|
583
558
|
// declares `Workspaced<…>` legal. A key that the door does not carry is still refused.
|
|
559
|
+
/** The input of a door-less Workspace: the body's, without the handles. `unknown` when the body
|
|
560
|
+
* asks for nothing else, and with `undefined` added when each field it asks for is optional. In
|
|
561
|
+
* both cases a parent has nothing that it must give, and xstate makes an invoke's `input` optional
|
|
562
|
+
* only for a type that holds `undefined`.
|
|
563
|
+
* @internal */
|
|
564
|
+
type DoorlessInput<TBody extends AnyStateMachine> =
|
|
565
|
+
unknown extends InputFrom<TBody>
|
|
566
|
+
? unknown
|
|
567
|
+
: [keyof Omit<InputFrom<TBody>, "workspace">] extends [never]
|
|
568
|
+
? unknown
|
|
569
|
+
: {} extends Omit<InputFrom<TBody>, "workspace">
|
|
570
|
+
? Omit<InputFrom<TBody>, "workspace"> | undefined
|
|
571
|
+
: Omit<InputFrom<TBody>, "workspace">;
|
|
572
|
+
|
|
584
573
|
/** @internal */
|
|
585
574
|
type BodyAcceptsDoor<TBody extends AnyStateMachine, TDoor, TSlots extends string> =
|
|
586
575
|
TDoor & Workspaced<TDoor, TSlots> & HostInjectedInput extends InputFrom<TBody>
|
|
@@ -625,10 +614,10 @@ type BodyAcceptsSlots<TBody extends AnyStateMachine, TSlots extends string> =
|
|
|
625
614
|
: unknown;
|
|
626
615
|
|
|
627
616
|
// What the Sandbox is MADE OF (ADR-0037, ADR-0005) and which Repos it attaches (ADR-0051), as
|
|
628
|
-
// STATIC options on the wrapper rather than
|
|
617
|
+
// STATIC options on the wrapper rather than a function of the run input (ADR-0049). Static is the
|
|
629
618
|
// whole point: `jr2 up` walks the registered Machines to find every `file:` context and build it,
|
|
630
|
-
// every bound Repo and warm it, every open slot and refuse it (parts.ts) — and
|
|
631
|
-
// of run input
|
|
619
|
+
// every bound Repo and warm it, every open slot and refuse it (parts.ts) — and no walk can evaluate a
|
|
620
|
+
// function of run input. They are also never persisted — the provisioning state
|
|
632
621
|
// re-reads them off the Machine it was invoked as, so a restore, a `provide()` and a `customize()`
|
|
633
622
|
// all get the image and the slots the Machine carries NOW.
|
|
634
623
|
//
|
|
@@ -649,6 +638,9 @@ export type SandboxOptions<TSlots extends string, TInput = unknown> = {
|
|
|
649
638
|
* glibc and git; jr2 adds its own runtime at `/opt/jr2`. Default: the Instance's
|
|
650
639
|
* `images/default`, then jr2's Harness image. */
|
|
651
640
|
image?: string;
|
|
641
|
+
// A gid is a fact of the images, so it is static.
|
|
642
|
+
/** The group id that owns the files in `/work`. Default: 2000. A non-negative integer. */
|
|
643
|
+
workGroup?: number;
|
|
652
644
|
// The widening ADR-0005 foresaw: `{ image, resources }` states the seat's split of the Size
|
|
653
645
|
// (ADR-0060). There is no default, because the seat's whole identity is "what jr2 does not own"
|
|
654
646
|
// and jr2 has nothing to put there. Env and ports are deliberately not forwarded.
|
|
@@ -680,8 +672,8 @@ export type SandboxOptions<TSlots extends string, TInput = unknown> = {
|
|
|
680
672
|
* choice. The name is the key of `workspace.repos` in the body and the directory under `/work`.
|
|
681
673
|
* At least one slot. Leave `repos` out for a Workspace with no Repo Slot (see
|
|
682
674
|
* {@link NoSlotWorkspaceOptions}). Each value is one of:
|
|
683
|
-
* - a url, or `{ url, ref }`, where `ref` is the branch or commit that the
|
|
684
|
-
* (default: the Repo's default branch);
|
|
675
|
+
* - a url, or `{ url, ref }`, where `ref` is the branch, the tag or the commit that the clone
|
|
676
|
+
* stays on, and that a new branch starts from (default: the Repo's default branch);
|
|
685
677
|
* - `open`, for the Instance that registers the Machine to bind with `customize()`;
|
|
686
678
|
* - a function of the run input, `({ input }) => url` or `{ url, ref }`, when each run chooses.
|
|
687
679
|
*
|
|
@@ -695,8 +687,11 @@ export type SandboxOptions<TSlots extends string, TInput = unknown> = {
|
|
|
695
687
|
/**
|
|
696
688
|
* The Held secrets that this Sandbox can use: names from the `secrets` list in `jr2.config.ts`.
|
|
697
689
|
* The Sandbox's Custodian holds each value and puts it into requests to the hosts of that Held
|
|
698
|
-
* secret.
|
|
699
|
-
*
|
|
690
|
+
* secret. Listing a name also gives its Stand-in to the Agent's programs — each `bash` call, test,
|
|
691
|
+
* build and `command` — in their env under that name, so code in `/work` can spend it (ADR-0076).
|
|
692
|
+
* You need not list the model key of an Agent that runs here: its provider claims it, and the
|
|
693
|
+
* Harness gives it to the model calls alone. List the model key only when code in `/work` must
|
|
694
|
+
* call the model itself: such a call is outside the Turn's usage record. Each name once.
|
|
700
695
|
*/
|
|
701
696
|
secrets?: readonly string[];
|
|
702
697
|
// The container exists only when an Effect below this `workspace()` is placed there (ADR-0068,
|
|
@@ -725,47 +720,27 @@ export type WorkspaceOptions<TSchema extends z.ZodObject, TSlots extends string>
|
|
|
725
720
|
// `workspace` handles ({@link Workspaced}), which no caller can send, so the body's contract is
|
|
726
721
|
// the door plus something that does not exist yet. Symmetric with `PoolSpec.input`.
|
|
727
722
|
/** The input that starts a run, as a `z.object`. jr2 checks each start against it. It also types
|
|
728
|
-
* the `input` of
|
|
723
|
+
* the `input` of a per-run Repo Slot, and the body must accept it plus the
|
|
729
724
|
* `workspace` handles. Write it here, not on the body. */
|
|
730
725
|
input: TSchema;
|
|
731
|
-
// ADR-0012's boundary: workflow configuration never enters the spec.
|
|
732
|
-
/**
|
|
733
|
-
* A function from the run input to the Workspace's {@link WorkspaceSpec}: the `branch` it works
|
|
734
|
-
* on, and optionally `reviewSha` and `workGroup`. jr2 calls it when the run enters the
|
|
735
|
-
* Workspace.
|
|
736
|
-
*
|
|
737
|
-
* The input of `spec` also holds `instanceId`, the seed Instance ID of the run (see
|
|
738
|
-
* {@link HostInjectedInput}). Use it to give each run a branch of its own:
|
|
739
|
-
* ``spec: ({ input }) => ({ branch: `fix/${input.instanceId}` })``. Only a root `workspace()` gets
|
|
740
|
-
* it from jr2. A `workspace()` that a different Machine invokes gets only the input that its
|
|
741
|
-
* parent gives it, so that parent must give `instanceId` when `spec` reads it.
|
|
742
|
-
*/
|
|
743
|
-
// The type says `instanceId` is present also below the root, where only the parent can supply it:
|
|
744
|
-
// the same permissive answer the body guard gives (BodyAcceptsDoor), because no type can see where
|
|
745
|
-
// the wrapper sits. The alternative, `Partial<HostInjectedInput>`, makes each root `spec` that reads
|
|
746
|
-
// it write a fallback for a value that is always there.
|
|
747
|
-
spec: (args: { input: WithoutEmptyIndex<z.infer<TSchema>> & HostInjectedInput }) => WorkspaceSpec;
|
|
748
726
|
};
|
|
749
727
|
|
|
750
728
|
// How a Workspace with NO declared door is configured: absence is permissive (ADR-0033), so jr2
|
|
751
729
|
// has nothing to infer from and says `unknown` rather than `any` — an honest "jr2 does not know",
|
|
752
730
|
// which the mapper must narrow before it reads a field.
|
|
753
731
|
/**
|
|
754
|
-
* The options of a `workspace()` with no `input`: it starts with any input. The `input` of
|
|
755
|
-
* is then `unknown`, and
|
|
732
|
+
* The options of a `workspace()` with no `input`: it starts with any input. The `input` of a
|
|
733
|
+
* per-run Repo Slot is then `unknown`, and the mapper must check it before it reads a field.
|
|
756
734
|
*
|
|
757
|
-
* Use it for a Workspace that a Pool or another Machine starts.
|
|
758
|
-
*
|
|
759
|
-
*
|
|
735
|
+
* Use it for a Workspace that a Pool or another Machine starts. The body's `input` type says what
|
|
736
|
+
* that input is, and that also types the input of the Workspace. For a Workspace that a caller
|
|
737
|
+
* starts, declare `input`.
|
|
760
738
|
*/
|
|
761
739
|
export type PermissiveWorkspaceOptions<TSlots extends string, TInput = unknown> = SandboxOptions<TSlots, TInput> & {
|
|
762
740
|
// Spelled out so a declared schema can never fall through to the permissive overload, where the
|
|
763
741
|
// body's door would go unchecked.
|
|
764
742
|
/** Not present: a `workspace()` without `input` starts with any input. */
|
|
765
743
|
input?: never;
|
|
766
|
-
/** A function from the input to the Workspace's {@link WorkspaceSpec}. Its `input` is
|
|
767
|
-
* `unknown` unless you give the parameter a type: `spec: ({ input }: { input: Item }) => …`. */
|
|
768
|
-
spec: (args: { input: TInput }) => WorkspaceSpec;
|
|
769
744
|
};
|
|
770
745
|
|
|
771
746
|
// A Workspace with no Repo Slot (ADR-0051): a Sandbox for one unit of work whose Agents and
|
|
@@ -773,53 +748,50 @@ export type PermissiveWorkspaceOptions<TSlots extends string, TInput = unknown>
|
|
|
773
748
|
// conditional on `TSlots`, because the slot overloads infer `TSlots` from `repos`, and no
|
|
774
749
|
// inference reaches through a conditional type.
|
|
775
750
|
/** The options of a `workspace()` with no Repo Slot that declares the input that starts a run.
|
|
776
|
-
* `repos` is left out
|
|
751
|
+
* `repos` is left out. */
|
|
777
752
|
export type NoSlotWorkspaceOptions<TSchema extends z.ZodObject> = Omit<SandboxOptions<never>, "repos"> & {
|
|
778
753
|
/** Not present: the Workspace has no Repo Slot. Its Agents work in `/work`. */
|
|
779
754
|
repos?: never;
|
|
780
755
|
/** The input that starts a run, as a `z.object`, as in {@link WorkspaceOptions}. */
|
|
781
756
|
input: TSchema;
|
|
782
|
-
/** A function from the run input to the Workspace's {@link NoSlotSpec}: only `workGroup`.
|
|
783
|
-
* Default: no `workGroup`. */
|
|
784
|
-
spec?: (args: { input: WithoutEmptyIndex<z.infer<TSchema>> & HostInjectedInput }) => NoSlotSpec;
|
|
785
757
|
};
|
|
786
758
|
|
|
787
759
|
/** The options of a `workspace()` with no Repo Slot and no `input`: it starts with any input. */
|
|
788
|
-
export type PermissiveNoSlotWorkspaceOptions
|
|
760
|
+
export type PermissiveNoSlotWorkspaceOptions = Omit<SandboxOptions<never>, "repos"> & {
|
|
789
761
|
/** Not present: the Workspace has no Repo Slot. Its Agents work in `/work`. */
|
|
790
762
|
repos?: never;
|
|
791
763
|
/** Not present: a `workspace()` without `input` starts with any input. */
|
|
792
764
|
input?: never;
|
|
793
|
-
/** A function from the input to the Workspace's {@link NoSlotSpec}: only `workGroup`. Default:
|
|
794
|
-
* no `workGroup`. */
|
|
795
|
-
spec?: (args: { input: TInput }) => NoSlotSpec;
|
|
796
765
|
};
|
|
797
766
|
|
|
798
767
|
/**
|
|
799
|
-
* Run a body Machine in a Sandbox: a pod of its own, with a
|
|
800
|
-
*
|
|
768
|
+
* Run a body Machine in a Sandbox: a pod of its own, with a clone of each Repo Slot. The result is
|
|
769
|
+
* a Machine. Register it as a Workflow, or invoke it from another Machine as
|
|
801
770
|
* an actor slot.
|
|
802
771
|
*
|
|
803
772
|
* When a run enters it, the Workspace makes the Sandbox and waits for room on a node, with no
|
|
804
|
-
* deadline. It clones each Repo Slot
|
|
805
|
-
*
|
|
806
|
-
*
|
|
773
|
+
* deadline. It clones each Repo Slot to `/work/<slot>/default`, on the Binding's base, and makes no
|
|
774
|
+
* Worktree: the body cuts each Worktree that it needs with `worktree()`. A slot that is only read
|
|
775
|
+
* needs no cut. No work happens in `default/`. Then it runs the body with the run input plus
|
|
776
|
+
* `workspace`, the handles (see {@link Workspaced}). The Agents with Working tools and the `command` Effects in the body run in that Sandbox. An Agent
|
|
807
777
|
* with `workspace: "none"` runs on the Instance Harness. When the body reaches a final state, the
|
|
808
|
-
* Workspace deletes the Sandbox, and its output is the body's output. Push
|
|
778
|
+
* Workspace deletes the Sandbox, and its output is the body's output. Push each branch before
|
|
809
779
|
* then: jr2 deletes the Sandbox's commits with it.
|
|
810
780
|
*
|
|
781
|
+
* The author chooses how many Agents work in a Workspace. Agents in one Worktree share git, as
|
|
782
|
+
* people in one checkout do. Each coder that needs its own branch gets its own Worktree of the one
|
|
783
|
+
* clone.
|
|
784
|
+
*
|
|
811
785
|
* Leave out `repos` for a Workspace with no Repo Slot: its Agents and `command` Effects share
|
|
812
|
-
* `/work`, as the steps of one job share a folder,
|
|
813
|
-
* `
|
|
814
|
-
*
|
|
815
|
-
* need a Repo Slot, so jr2 refuses them there. jr2 deletes `/work` with the Sandbox: send what
|
|
786
|
+
* `/work`, as the steps of one job share a folder. The body's input is then `Workspaced<RunInput, never>`. An `http` Effect sends a
|
|
787
|
+
* file of `/work` with `file(path)`. A `worktree()`, a `push`, and a `command` with an Effect secret in its
|
|
788
|
+
* `env`, need a Repo Slot, so jr2 refuses them there. jr2 deletes `/work` with the Sandbox: send what
|
|
816
789
|
* must stay before the body ends.
|
|
817
790
|
*
|
|
818
791
|
* Give each run a different branch, so that two runs do not push to one branch. Make it from a
|
|
819
792
|
* field of the run input, or from `instanceId` when the run input has no field that is different
|
|
820
|
-
* for each run: ``
|
|
821
|
-
* `
|
|
822
|
-
* `workspace()` gets `instanceId` from jr2. A `workspace()` that a different Machine invokes gets
|
|
793
|
+
* for each run: ``branch: `fix/${context.instanceId}` `` in the input of the cut. Add
|
|
794
|
+
* `HostInjectedInput` to the body's input type to read it. Only a root `workspace()` gets `instanceId` from jr2. A `workspace()` that a different Machine invokes gets
|
|
823
795
|
* it only when its parent gives it.
|
|
824
796
|
*
|
|
825
797
|
* The Sandbox is Lost when its pod is evicted or its node goes. Then the body gets the event
|
|
@@ -843,7 +815,7 @@ export type PermissiveNoSlotWorkspaceOptions<TInput = unknown> = Omit<SandboxOpt
|
|
|
843
815
|
* - a body with its own `input`: ``workspace(): the body "<id>" declares its own run input, which
|
|
844
816
|
* nothing will ever serve or enforce — the wrapper feeds the body the run input PLUS the injected
|
|
845
817
|
* `workspace` handles, so the body's schema is not the door. Move it to the wrapper:
|
|
846
|
-
* workspace(body, { input
|
|
818
|
+
* workspace(body, { input }) (ADR-0033).``
|
|
847
819
|
* - an empty `image`: ``workspace(): `image` must be a non-empty string (got <value>) — either a
|
|
848
820
|
* `file:` URL to a docker context this module ships (`import.meta.resolve("./image")`) or a
|
|
849
821
|
* registry ref (ADR-0037).``
|
|
@@ -853,9 +825,7 @@ export type PermissiveNoSlotWorkspaceOptions<TInput = unknown> = Omit<SandboxOpt
|
|
|
853
825
|
* `repos: { app: "https://…" }` — or `open` whole, for a map the consumer names; leave it out
|
|
854
826
|
* for a Workspace with no Repo Slot (ADR-0051).``
|
|
855
827
|
* - a bad Repo Slot name or value (see {@link RepoSlot})
|
|
856
|
-
* - a `
|
|
857
|
-
* `spec` must be a function from the run input to the spec — spec: ({ input }) => ({ branch }) —
|
|
858
|
-
* and only a Workspace with no Repo Slot may leave it out (ADR-0051).``
|
|
828
|
+
* - a `workGroup` that is not a gid: ``workspace(): `workGroup` must be a gid (got <value>)``
|
|
859
829
|
* - a bad `secrets` list: ``workspace(): `secrets` must be a list of distinct names from
|
|
860
830
|
* jr2.config.ts's `secrets` list (got <value>) — secrets: ["JIRA_TOKEN"] (ADR-0059).``
|
|
861
831
|
* - a bad `effector`: ``workspace(): `effector` is { image } — a `file:` URL to a docker context
|
|
@@ -871,17 +841,14 @@ export type PermissiveNoSlotWorkspaceOptions<TInput = unknown> = Omit<SandboxOpt
|
|
|
871
841
|
* (ADR-0060)``, or the same text for `resources.limits.<key>`. The other texts are in
|
|
872
842
|
* {@link Size}.
|
|
873
843
|
*
|
|
874
|
-
* And when a run enters it, a
|
|
875
|
-
*
|
|
876
|
-
* does `jr2 run --input` carry every field this workflow's workspace()
|
|
877
|
-
* Repo Slot, a spec with a `branch` or a `reviewSha`: ``workspace spec invalid: <field> (the
|
|
878
|
-
* Workspace has no Repo Slot, so it has no branch and no review Worktree; its spec gives only
|
|
879
|
-
* workGroup) …``
|
|
844
|
+
* And when a run enters it, a per-run Repo Slot's mapper that gives no `url`, or a bad `ref`:
|
|
845
|
+
* ``workspace invalid: repos.<slot> mapper returned <fields> — the mapper derives from run
|
|
846
|
+
* input; does `jr2 run --input` carry every field this workflow's workspace() slot reads?``
|
|
880
847
|
*
|
|
881
848
|
* @param body - The Machine to run in the Sandbox. Its input is `Workspaced<RunInput, Slots>`.
|
|
882
849
|
* @param options - What the Sandbox is made of (`image`, `user`, `resources`, `effector`), the
|
|
883
|
-
* Repo Slots it clones (`repos`), the Held secrets it can use (`secrets`), the
|
|
884
|
-
* (`
|
|
850
|
+
* Repo Slots it clones (`repos`), the Held secrets it can use (`secrets`), the pod's `fsGroup`
|
|
851
|
+
* (`workGroup`), and the run input (`input`).
|
|
885
852
|
* @returns A Machine whose input is the run input and whose output is the body's output.
|
|
886
853
|
*
|
|
887
854
|
* @example
|
|
@@ -890,8 +857,10 @@ export type PermissiveNoSlotWorkspaceOptions<TInput = unknown> = Omit<SandboxOpt
|
|
|
890
857
|
* import {
|
|
891
858
|
* command,
|
|
892
859
|
* fail,
|
|
860
|
+
* isReady,
|
|
893
861
|
* jr2Setup,
|
|
894
862
|
* workspace,
|
|
863
|
+
* worktree,
|
|
895
864
|
* type CommandOutput,
|
|
896
865
|
* type Workspaced,
|
|
897
866
|
* } from "@jr2/orchestrator";
|
|
@@ -899,23 +868,36 @@ export type PermissiveNoSlotWorkspaceOptions<TInput = unknown> = Omit<SandboxOpt
|
|
|
899
868
|
* const runInput = z.object({ ticket: z.string(), repo: z.string() });
|
|
900
869
|
* type BodyInput = Workspaced<z.infer<typeof runInput>, "app" | "docs">;
|
|
901
870
|
*
|
|
871
|
+
* // The Worktree of this run. An Agent or a command that only reads `docs` uses `/work/docs/default`.
|
|
872
|
+
* const buildTree = (context: BodyInput) => `${context.workspace.repos.app}/build` as const;
|
|
873
|
+
*
|
|
902
874
|
* const passed = (output: CommandOutput): boolean =>
|
|
903
875
|
* output.outcome === "exited" && output.code === 0;
|
|
904
876
|
*
|
|
905
877
|
* const body = jr2Setup({
|
|
906
|
-
* types: {} as { input: BodyInput },
|
|
878
|
+
* types: {} as { input: BodyInput; context: BodyInput },
|
|
907
879
|
* actors: {
|
|
908
|
-
*
|
|
880
|
+
* cut: worktree(),
|
|
881
|
+
* build: command(["npm", "run", "build"]),
|
|
909
882
|
* fail: fail(),
|
|
910
883
|
* },
|
|
911
884
|
* }).createMachine({
|
|
912
885
|
* id: "build",
|
|
886
|
+
* context: ({ input }) => input,
|
|
913
887
|
* on: { "workspace.lost": ".lost" },
|
|
914
|
-
* initial: "
|
|
888
|
+
* initial: "cutting",
|
|
915
889
|
* states: {
|
|
890
|
+
* cutting: {
|
|
891
|
+
* invoke: {
|
|
892
|
+
* src: "cut",
|
|
893
|
+
* input: ({ context }) => ({ path: buildTree(context), branch: `fix/${context.ticket}` }),
|
|
894
|
+
* onDone: [{ guard: ({ event }) => isReady(event.output), target: "building" }, { target: "notCut" }],
|
|
895
|
+
* },
|
|
896
|
+
* },
|
|
916
897
|
* building: {
|
|
917
898
|
* invoke: {
|
|
918
899
|
* src: "build",
|
|
900
|
+
* input: ({ context }) => ({ worktree: buildTree(context) }),
|
|
919
901
|
* onDone: [
|
|
920
902
|
* { guard: ({ event }) => passed(event.output), target: "built" },
|
|
921
903
|
* { target: "broken" },
|
|
@@ -924,6 +906,7 @@ export type PermissiveNoSlotWorkspaceOptions<TInput = unknown> = Omit<SandboxOpt
|
|
|
924
906
|
* },
|
|
925
907
|
* built: { type: "final" },
|
|
926
908
|
* broken: { invoke: { src: "fail", input: { reason: "npm run build did not pass" } } },
|
|
909
|
+
* notCut: { invoke: { src: "fail", input: { reason: "the Worktree was not cut" } } },
|
|
927
910
|
* lost: { invoke: { src: "fail", input: { reason: "the Sandbox is gone" } } },
|
|
928
911
|
* },
|
|
929
912
|
* });
|
|
@@ -936,7 +919,6 @@ export type PermissiveNoSlotWorkspaceOptions<TInput = unknown> = Omit<SandboxOpt
|
|
|
936
919
|
* app: ({ input }) => input.repo,
|
|
937
920
|
* docs: { url: "https://github.com/you/docs.git", ref: "main" },
|
|
938
921
|
* },
|
|
939
|
-
* spec: ({ input }) => ({ branch: `fix/${input.ticket}` }),
|
|
940
922
|
* });
|
|
941
923
|
* ```
|
|
942
924
|
*
|
|
@@ -946,9 +928,12 @@ export type PermissiveNoSlotWorkspaceOptions<TInput = unknown> = Omit<SandboxOpt
|
|
|
946
928
|
* import {
|
|
947
929
|
* command,
|
|
948
930
|
* fail,
|
|
931
|
+
* isReady,
|
|
949
932
|
* jr2Setup,
|
|
950
933
|
* workspace,
|
|
934
|
+
* worktree,
|
|
951
935
|
* type CommandOutput,
|
|
936
|
+
* type HostInjectedInput,
|
|
952
937
|
* type Workspaced,
|
|
953
938
|
* } from "@jr2/orchestrator";
|
|
954
939
|
*
|
|
@@ -958,18 +943,30 @@ export type PermissiveNoSlotWorkspaceOptions<TInput = unknown> = Omit<SandboxOpt
|
|
|
958
943
|
* // The run input has no field that is different for each run, so the branch comes from
|
|
959
944
|
* // `instanceId`.
|
|
960
945
|
* const runInput = z.object({});
|
|
946
|
+
* type BodyInput = Workspaced<z.infer<typeof runInput> & HostInjectedInput, "app">;
|
|
947
|
+
*
|
|
948
|
+
* const nightlyTree = (context: BodyInput) => `${context.workspace.repos.app}/nightly` as const;
|
|
961
949
|
*
|
|
962
950
|
* const body = jr2Setup({
|
|
963
|
-
* types: {} as { input:
|
|
964
|
-
* actors: { test: command(["npm", "test"]
|
|
951
|
+
* types: {} as { input: BodyInput; context: BodyInput },
|
|
952
|
+
* actors: { cut: worktree(), test: command(["npm", "test"]), fail: fail() },
|
|
965
953
|
* }).createMachine({
|
|
966
954
|
* id: "nightly",
|
|
955
|
+
* context: ({ input }) => input,
|
|
967
956
|
* on: { "workspace.lost": ".lost" },
|
|
968
|
-
* initial: "
|
|
957
|
+
* initial: "cutting",
|
|
969
958
|
* states: {
|
|
959
|
+
* cutting: {
|
|
960
|
+
* invoke: {
|
|
961
|
+
* src: "cut",
|
|
962
|
+
* input: ({ context }) => ({ path: nightlyTree(context), branch: `nightly/${context.instanceId}` }),
|
|
963
|
+
* onDone: [{ guard: ({ event }) => isReady(event.output), target: "testing" }, { target: "notCut" }],
|
|
964
|
+
* },
|
|
965
|
+
* },
|
|
970
966
|
* testing: {
|
|
971
967
|
* invoke: {
|
|
972
968
|
* src: "test",
|
|
969
|
+
* input: ({ context }) => ({ worktree: nightlyTree(context) }),
|
|
973
970
|
* onDone: [
|
|
974
971
|
* { guard: ({ event }) => passed(event.output), target: "tested" },
|
|
975
972
|
* { target: "red" },
|
|
@@ -978,6 +975,7 @@ export type PermissiveNoSlotWorkspaceOptions<TInput = unknown> = Omit<SandboxOpt
|
|
|
978
975
|
* },
|
|
979
976
|
* tested: { type: "final" },
|
|
980
977
|
* red: { invoke: { src: "fail", input: { reason: "npm test did not pass" } } },
|
|
978
|
+
* notCut: { invoke: { src: "fail", input: { reason: "the Worktree was not cut" } } },
|
|
981
979
|
* lost: { invoke: { src: "fail", input: { reason: "the Sandbox is gone" } } },
|
|
982
980
|
* },
|
|
983
981
|
* });
|
|
@@ -986,7 +984,6 @@ export type PermissiveNoSlotWorkspaceOptions<TInput = unknown> = Omit<SandboxOpt
|
|
|
986
984
|
* input: runInput,
|
|
987
985
|
* image: "docker.io/library/node:24",
|
|
988
986
|
* repos: { app: "https://github.com/you/app.git" },
|
|
989
|
-
* spec: ({ input }) => ({ branch: `fix/${input.instanceId}` }),
|
|
990
987
|
* });
|
|
991
988
|
* ```
|
|
992
989
|
*
|
|
@@ -1004,7 +1001,7 @@ export function workspace<TSchema extends z.ZodObject, TBody extends AnyStateMac
|
|
|
1004
1001
|
body: TBody & BodyAcceptsDoor<TBody, z.infer<TSchema>, TSlots>,
|
|
1005
1002
|
options: WorkspaceOptions<TSchema, TSlots> | NoSlotWorkspaceOptions<TSchema>,
|
|
1006
1003
|
// The input is the door without the empty door's `Record<string, never>` (`{}` for `z.object({})`), so
|
|
1007
|
-
// a parent can give `instanceId` to a nested
|
|
1004
|
+
// a parent can give `instanceId` to a nested body that reads it (ADR-0033).
|
|
1008
1005
|
): WorkspaceMachine<WithoutEmptyIndex<z.infer<TSchema>>, OutputFrom<TBody>, TBody, TSlots>;
|
|
1009
1006
|
/**
|
|
1010
1007
|
* Run a body Machine in a Sandbox, with no `input` schema: the Workspace starts with any input. Use
|
|
@@ -1013,26 +1010,30 @@ export function workspace<TSchema extends z.ZodObject, TBody extends AnyStateMac
|
|
|
1013
1010
|
*
|
|
1014
1011
|
* @param body - The Machine to run in the Sandbox. Its input is the input of the Workspace plus
|
|
1015
1012
|
* `workspace`, the handles.
|
|
1016
|
-
* @param options - The options of the Sandbox
|
|
1013
|
+
* @param options - The options of the Sandbox. There is no `input`.
|
|
1017
1014
|
* @returns A Machine whose input is the run input and whose output is the body's output.
|
|
1018
1015
|
*
|
|
1019
1016
|
* @see docs/concepts/workspaces.md
|
|
1020
1017
|
* @see docs/reference/decisions/0033-a-machine-declares-the-input-that-starts-it.md
|
|
1021
1018
|
*/
|
|
1022
|
-
export function workspace<
|
|
1019
|
+
export function workspace<
|
|
1020
|
+
TBody extends AnyStateMachine,
|
|
1021
|
+
TSlots extends string = never,
|
|
1022
|
+
// Nothing else annotates the input of a door-less Workspace, so the body says it.
|
|
1023
|
+
TInput = DoorlessInput<TBody>,
|
|
1024
|
+
>(
|
|
1023
1025
|
body: TBody & BodyAcceptsSlots<TBody, TSlots>,
|
|
1024
|
-
|
|
1026
|
+
// `undefined` is for the invoke alone: a run with no input gives a mapper `{}`.
|
|
1027
|
+
options: PermissiveWorkspaceOptions<TSlots, Exclude<TInput, undefined>> | PermissiveNoSlotWorkspaceOptions,
|
|
1025
1028
|
): WorkspaceMachine<TInput, OutputFrom<TBody>, TBody, TSlots>;
|
|
1026
1029
|
export function workspace(
|
|
1027
1030
|
body: AnyStateMachine,
|
|
1028
1031
|
options: Omit<SandboxOptions<string, any>, "repos"> & {
|
|
1029
1032
|
repos?: SandboxOptions<string, any>["repos"];
|
|
1030
1033
|
input?: z.ZodObject;
|
|
1031
|
-
spec?: (args: { input: any }) => WorkspaceSpec | NoSlotSpec;
|
|
1032
1034
|
},
|
|
1033
1035
|
): AnyStateMachine {
|
|
1034
|
-
//
|
|
1035
|
-
// the wrapper's input plus the handles — `Workspaced<TInput, TSlots>`, which is also what the
|
|
1036
|
+
// The body receives the wrapper's input plus the handles — `Workspaced<TInput, TSlots>`, which is also what the
|
|
1036
1037
|
// declared door checks the body against. A Failure of the body is caught: the wrapper destroys
|
|
1037
1038
|
// the Sandbox and fails again with the same payload (ADR-0073). Every other body error (a defect)
|
|
1038
1039
|
// is not caught: it faults the run (RunStatus.fault) and leaves the Sandbox to the operator's
|
|
@@ -1048,7 +1049,7 @@ export function workspace(
|
|
|
1048
1049
|
`workspace(): the body "${body.id}" declares its own run input, which nothing will ever ` +
|
|
1049
1050
|
"serve or enforce — the wrapper feeds the body the run input PLUS the injected `workspace` " +
|
|
1050
1051
|
"handles, so the body's schema is not the door. Move it to the wrapper: " +
|
|
1051
|
-
"workspace(body, { input
|
|
1052
|
+
"workspace(body, { input }) (ADR-0033).",
|
|
1052
1053
|
);
|
|
1053
1054
|
}
|
|
1054
1055
|
// A body state that cannot route `workspace.lost` (ADR-0074): with no route, the body would show
|
|
@@ -1057,7 +1058,7 @@ export function workspace(
|
|
|
1057
1058
|
// not judged: its states are jr2's, and the walk refuses a Workspace below a Workspace (ADR-0059).
|
|
1058
1059
|
if (!composesSandbox(body) && wrapperBodyOf(body) === undefined) refuseUnroutedLost(body);
|
|
1059
1060
|
// Static, so checkable NOW rather than at the first provision — an empty or non-string image is
|
|
1060
|
-
//
|
|
1061
|
+
// a derives-from-a-typo bug, and this catches it one build earlier.
|
|
1061
1062
|
const image = options.image;
|
|
1062
1063
|
if (image !== undefined && (typeof image !== "string" || !image)) {
|
|
1063
1064
|
throw new Error(
|
|
@@ -1118,15 +1119,10 @@ export function workspace(
|
|
|
1118
1119
|
`${JSON.stringify(effector)}) — effector: { image: import.meta.resolve("./effector") } (ADR-0068).`,
|
|
1119
1120
|
);
|
|
1120
1121
|
}
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
if (
|
|
1124
|
-
|
|
1125
|
-
"workspace(): `spec` must be a function from the run input to the spec — spec: ({ input }) => ({ branch }) — " +
|
|
1126
|
-
"and only a Workspace with no Repo Slot may leave it out (ADR-0051).",
|
|
1127
|
-
);
|
|
1128
|
-
}
|
|
1129
|
-
const wrapper = buildWorkspaceMachine(body, options.spec ?? (() => ({})));
|
|
1122
|
+
const workGroup = options.workGroup;
|
|
1123
|
+
assertWorkGroup(workGroup);
|
|
1124
|
+
if (repos === open || Object.keys(repos).length > 0) refuseUnnamedWorktrees(body);
|
|
1125
|
+
const wrapper = buildWorkspaceMachine(body);
|
|
1130
1126
|
// The wrapper is TRANSPARENT to its body (ADR-0049): `customize(machine, { agents })` on a
|
|
1131
1127
|
// Workspace means the Machine inside, so the composer never spells `body` and never has to know
|
|
1132
1128
|
// that jr2 wrapped anything.
|
|
@@ -1141,6 +1137,7 @@ export function workspace(
|
|
|
1141
1137
|
...(options.user !== undefined ? { user: options.user } : {}),
|
|
1142
1138
|
repos: repos === open ? open : { ...repos },
|
|
1143
1139
|
...(options.resources !== undefined ? { resources: options.resources } : {}),
|
|
1140
|
+
...(workGroup !== undefined ? { workGroup } : {}),
|
|
1144
1141
|
...(secrets !== undefined ? { secrets: [...secrets] } : {}),
|
|
1145
1142
|
...(effector !== undefined ? { effector: { image: effector.image } } : {}),
|
|
1146
1143
|
});
|
|
@@ -1158,54 +1155,75 @@ export function workspace(
|
|
|
1158
1155
|
}
|
|
1159
1156
|
|
|
1160
1157
|
/**
|
|
1161
|
-
*
|
|
1162
|
-
*
|
|
1163
|
-
*
|
|
1164
|
-
*
|
|
1158
|
+
* Each Turn and each Effect that works in a Worktree says which, in a Workspace with Repo Slots: the
|
|
1159
|
+
* walk's half of what `jr2Setup` refuses by type, for a plain `setup()`, for a nested key, and for a
|
|
1160
|
+
* body with no typed handles. It sees an invoke with no input and a literal input. A mapper's answer
|
|
1161
|
+
* is its start's to refuse. A Machine below the body is walked too: a child, and a Pool's worker,
|
|
1162
|
+
* which is no invoke, so each Machine in `actors` is walked. A nested Workspace is not: its own
|
|
1163
|
+
* `workspace()` call walks it, by its own Repo Slots.
|
|
1165
1164
|
*/
|
|
1166
|
-
function
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
);
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
)
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1165
|
+
function refuseUnnamedWorktrees(body: AnyStateMachine): void {
|
|
1166
|
+
const faults: string[] = [];
|
|
1167
|
+
const seen = new Set<AnyStateMachine>();
|
|
1168
|
+
const walk = (machine: AnyStateMachine): void => {
|
|
1169
|
+
if (seen.has(machine) || (machine !== body && composesSandbox(machine))) return;
|
|
1170
|
+
seen.add(machine);
|
|
1171
|
+
const actors = (machine.implementations.actors ?? {}) as Record<string, unknown>;
|
|
1172
|
+
for (const logic of Object.values(actors)) if (isMachineLogic(logic)) walk(logic);
|
|
1173
|
+
const nodes = [machine.root as unknown as InvokeNode];
|
|
1174
|
+
for (const node of nodes) {
|
|
1175
|
+
nodes.push(...Object.values(node.states));
|
|
1176
|
+
const configs = [node.config.invoke ?? []].flat() as Array<{ input?: unknown } | undefined>;
|
|
1177
|
+
node.invoke.forEach((inv, i) => {
|
|
1178
|
+
const logic = typeof inv.src === "string" ? actors[inv.src] : inv.src;
|
|
1179
|
+
if (isMachineLogic(logic)) walk(logic);
|
|
1180
|
+
const key = keyOf(logic);
|
|
1181
|
+
if (key === undefined) return;
|
|
1182
|
+
const given = configs[i]?.input;
|
|
1183
|
+
const written = (given as { consumerInput?: unknown } | undefined)?.consumerInput ?? given;
|
|
1184
|
+
if (typeof written === "function") return;
|
|
1185
|
+
if (typeof written === "object" && written !== null && key in written) return;
|
|
1186
|
+
faults.push(
|
|
1187
|
+
`state "${node.id}": "${typeof inv.src === "string" ? inv.src : inv.id}" names no \`${key}\`, and a Workspace ` +
|
|
1188
|
+
'with Repo Slots says where each Turn and Effect works: a Worktree, or `cwd: "/work"` for a Turn',
|
|
1189
|
+
);
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
1192
|
+
};
|
|
1193
|
+
walk(body);
|
|
1194
|
+
if (faults.length) throw new Error(`workspace(): ${faults.join("; ")} (ADR-0057)`);
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
const isMachineLogic = (logic: unknown): logic is AnyStateMachine =>
|
|
1198
|
+
typeof logic === "object" && logic !== null && "implementations" in logic;
|
|
1199
|
+
|
|
1200
|
+
type InvokeNode = {
|
|
1201
|
+
id: string;
|
|
1202
|
+
states: Record<string, InvokeNode>;
|
|
1203
|
+
config: { invoke?: unknown };
|
|
1204
|
+
invoke: Array<{ src: unknown; id: string }>;
|
|
1205
|
+
};
|
|
1206
|
+
|
|
1207
|
+
/** The key that says where this logic works, or undefined when it works nowhere in `/work`. */
|
|
1208
|
+
function keyOf(logic: unknown): "cwd" | "worktree" | undefined {
|
|
1209
|
+
if (isAgent(logic)) return (logic.definition as { workspace?: unknown }).workspace === "none" ? undefined : "cwd";
|
|
1210
|
+
if (!isEffect(logic)) return undefined;
|
|
1211
|
+
const parts = logic.effect;
|
|
1212
|
+
if (parts.kind === "command") return parts.cut ? undefined : "worktree";
|
|
1213
|
+
return parts.kind === "http" && parts.file ? "worktree" : undefined;
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
/** A gid, so an integer: a float or a negative is a pod the API server rejects at admission. */
|
|
1217
|
+
function assertWorkGroup(workGroup: unknown): void {
|
|
1218
|
+
if (workGroup !== undefined && (typeof workGroup !== "number" || !Number.isInteger(workGroup) || workGroup < 0)) {
|
|
1219
|
+
throw new Error(`workspace(): \`workGroup\` must be a gid (got ${JSON.stringify(workGroup)})`);
|
|
1202
1220
|
}
|
|
1203
1221
|
}
|
|
1204
1222
|
|
|
1205
1223
|
/**
|
|
1206
1224
|
* Resolve every Repo Slot to a Binding, in declaration order (ADR-0051). A bound slot is its
|
|
1207
1225
|
* Binding; a per-run slot is its mapper called over the run input, validated like the static forms
|
|
1208
|
-
* because it derives from `jr2 run --input`
|
|
1226
|
+
* because it derives from `jr2 run --input` ; an open slot nobody bound is
|
|
1209
1227
|
* a fault BEFORE the port, naming the `customize` line that fixes it — the run-time twin of the
|
|
1210
1228
|
* refusal `jr2 up`'s walk makes for a registered Machine, reached here only by a Machine that was
|
|
1211
1229
|
* never registered as itself (a test seam, a nested invoke of an unbound import). The Machine is
|
|
@@ -1245,7 +1263,7 @@ function resolveBindings(
|
|
|
1245
1263
|
bad.push(`ref (got ${JSON.stringify(binding.ref)})`);
|
|
1246
1264
|
if (bad.length) {
|
|
1247
1265
|
throw new Error(
|
|
1248
|
-
`workspace
|
|
1266
|
+
`workspace invalid: repos.${slot} mapper returned ${bad.join("; ")} — the mapper derives from run ` +
|
|
1249
1267
|
"input; does `jr2 run --input` carry every field this workflow's workspace() slot reads?",
|
|
1250
1268
|
);
|
|
1251
1269
|
}
|
|
@@ -1254,15 +1272,6 @@ function resolveBindings(
|
|
|
1254
1272
|
return bindings;
|
|
1255
1273
|
}
|
|
1256
1274
|
|
|
1257
|
-
/** A spec as the wrapper holds it: a Workspace's with Repo Slots, or one with none (ADR-0051). */
|
|
1258
|
-
type AnySpec = WorkspaceSpec | NoSlotSpec;
|
|
1259
|
-
|
|
1260
|
-
/** Does this Workspace declare no Repo Slot (ADR-0051)? An Open map is not "none": its slots are
|
|
1261
|
-
* the composer's, and `customize()` names at least one. */
|
|
1262
|
-
function noSlot(slots: SandboxParts["repos"]): boolean {
|
|
1263
|
-
return slots !== open && Object.keys(slots).length === 0;
|
|
1264
|
-
}
|
|
1265
|
-
|
|
1266
1275
|
/** The port-facing view of the persisted bindings, in declaration order. */
|
|
1267
1276
|
function attachedRepos(bindings: Record<string, ResolvedBinding>): Array<{ slot: string; url: string; ref?: string }> {
|
|
1268
1277
|
return Object.entries(bindings).map(([slot, b]) => ({
|
|
@@ -1272,7 +1281,7 @@ function attachedRepos(bindings: Record<string, ResolvedBinding>): Array<{ slot:
|
|
|
1272
1281
|
}));
|
|
1273
1282
|
}
|
|
1274
1283
|
|
|
1275
|
-
function buildWorkspaceMachine(body: AnyStateMachine
|
|
1284
|
+
function buildWorkspaceMachine(body: AnyStateMachine): AnyStateMachine {
|
|
1276
1285
|
/**
|
|
1277
1286
|
* `placing` (ADR-0064): write the Sandbox and wait, with no deadline, for its pod to be
|
|
1278
1287
|
* scheduled. jr2 cannot tell "full now" from "never fits", so a bound is the Machine's own
|
|
@@ -1297,8 +1306,8 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
|
|
|
1297
1306
|
* and so the same name, which that Workspace has placed again by then (ADR-0073).
|
|
1298
1307
|
*/
|
|
1299
1308
|
const place = fromPromise<
|
|
1300
|
-
{ bindings: Record<string, ResolvedBinding> },
|
|
1301
|
-
{ wsId: string;
|
|
1309
|
+
{ bindings: Record<string, ResolvedBinding>; runInput: Record<string, unknown> },
|
|
1310
|
+
{ wsId: string; runInput: Record<string, unknown> }
|
|
1302
1311
|
>(async ({ input, self, system, signal }) => {
|
|
1303
1312
|
const binding = runBindingOf(system);
|
|
1304
1313
|
// The images and the slots come off the WRAPPER, at invoke time, not out of context and not
|
|
@@ -1308,15 +1317,21 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
|
|
|
1308
1317
|
// content-addressed tag, which would outlive the image it names. The RESOLVED bindings are
|
|
1309
1318
|
// persisted, because a per-run mapper's answer is this run's fact.
|
|
1310
1319
|
const parts = sandboxPartsOf(invokingMachine(self));
|
|
1311
|
-
|
|
1320
|
+
const wrapper = self._parent;
|
|
1321
|
+
// RunHost parsed a root run's door and added `instanceId`, so that value is final: a second
|
|
1322
|
+
// parse would refuse the added key under a strict door and transform a value twice. A parent's
|
|
1323
|
+
// input is parsed here, and a door value that is wrong costs no pod (ADR-0033).
|
|
1324
|
+
const door = wrapper?._parent ? inputSchemaOf(invokingMachine(self)!) : undefined;
|
|
1325
|
+
const runInput = door
|
|
1326
|
+
? parsedNestedDoor(door, input.runInput, ["instanceId"], "workspace input invalid, before any pod")
|
|
1327
|
+
: input.runInput;
|
|
1312
1328
|
const user = userSeatOf(parts.user);
|
|
1313
1329
|
const path = actorSlotPath(self._parent);
|
|
1314
1330
|
// Read before the port: a Workspace no walk can see placed nothing, and is refused with the slot form.
|
|
1315
1331
|
const route = actorRoute(self._parent);
|
|
1316
|
-
const bindings = resolveBindings({ workflow: binding.workflow, path }, parts.repos,
|
|
1332
|
+
const bindings = resolveBindings({ workflow: binding.workflow, path }, parts.repos, runInput);
|
|
1317
1333
|
const port = sandboxOf(system);
|
|
1318
1334
|
const name = workspaceName(binding.runId, input.wsId);
|
|
1319
|
-
const wrapper = self._parent;
|
|
1320
1335
|
const child = wrapper ? actorPath(wrapper).join("/") : "";
|
|
1321
1336
|
// A wait opened before a restart is the same wait: its start is in the wrapper's persisted
|
|
1322
1337
|
// context, never in this actor's input, which a restore replays as it was first invoked.
|
|
@@ -1349,7 +1364,9 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
|
|
|
1349
1364
|
// work group's default (ADR-0005 puts it in pod composition, where the pod is built).
|
|
1350
1365
|
...(parts.image !== undefined ? { image: parts.image } : {}),
|
|
1351
1366
|
...(user.image !== undefined ? { user: user.image } : {}),
|
|
1352
|
-
...(
|
|
1367
|
+
...(parts.workGroup !== undefined ? { workGroup: parts.workGroup } : {}),
|
|
1368
|
+
// The Held secrets the `workspace()` grants its confined children (ADR-0076): names only.
|
|
1369
|
+
...(parts.secrets?.length ? { secrets: [...parts.secrets] } : {}),
|
|
1353
1370
|
repos: Object.entries(bindings).map(([slot, b]) => ({ slot, ...b })),
|
|
1354
1371
|
// The Size as the Machine states it, re-read here like the images (ADR-0060): a redeploy
|
|
1355
1372
|
// that retuned it reaches the next provision, and no snapshot holds it.
|
|
@@ -1387,7 +1404,7 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
|
|
|
1387
1404
|
openedAt !== undefined ? Math.round(performance.now() - openedAt) : Math.max(0, Date.now() - Date.parse(since));
|
|
1388
1405
|
binding.marker?.({ kind: "placed", child, since, after });
|
|
1389
1406
|
}
|
|
1390
|
-
return { bindings };
|
|
1407
|
+
return { bindings, runInput };
|
|
1391
1408
|
});
|
|
1392
1409
|
|
|
1393
1410
|
/** `provisioning`: the placed pod's Ready, under the port's budget, which starts here (ADR-0064). */
|
|
@@ -1396,8 +1413,8 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
|
|
|
1396
1413
|
);
|
|
1397
1414
|
|
|
1398
1415
|
const attach = fromPromise<
|
|
1399
|
-
{ repos: Record<string, string>;
|
|
1400
|
-
{ wsId: string;
|
|
1416
|
+
{ repos: Record<string, string>; bases?: Record<string, string>; stale?: Record<string, string> },
|
|
1417
|
+
{ wsId: string; bindings: Record<string, ResolvedBinding> }
|
|
1401
1418
|
>(async ({ input, system, signal }) => {
|
|
1402
1419
|
// No Repo Slot, nothing to clone (ADR-0051): the Harness's attach would refuse an empty list,
|
|
1403
1420
|
// and `/work` needs nothing from it — the pod's `fsGroup` already makes it the work group's.
|
|
@@ -1420,9 +1437,7 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
|
|
|
1420
1437
|
signal.addEventListener("abort", () => unsubscribe(), { once: true });
|
|
1421
1438
|
let out: Awaited<ReturnType<typeof port.attach>>;
|
|
1422
1439
|
try {
|
|
1423
|
-
|
|
1424
|
-
const spec = input.spec as WorkspaceSpec;
|
|
1425
|
-
out = await Promise.race([port.attach({ name, spec, repos: attachedRepos(input.bindings) }), loss]);
|
|
1440
|
+
out = await Promise.race([port.attach({ name, repos: attachedRepos(input.bindings) }), loss]);
|
|
1426
1441
|
} finally {
|
|
1427
1442
|
unsubscribe();
|
|
1428
1443
|
}
|
|
@@ -1552,7 +1567,6 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
|
|
|
1552
1567
|
context: ({ input, self }: { input: unknown; self: { id: string } }): WsContext => ({
|
|
1553
1568
|
runInput: (input ?? {}) as Record<string, unknown>,
|
|
1554
1569
|
wsId: self.id,
|
|
1555
|
-
spec: spec({ input }),
|
|
1556
1570
|
}),
|
|
1557
1571
|
initial: "placing",
|
|
1558
1572
|
states: {
|
|
@@ -1563,7 +1577,6 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
|
|
|
1563
1577
|
src: "place",
|
|
1564
1578
|
input: ({ context }) => ({
|
|
1565
1579
|
wsId: (context as unknown as WsContext).wsId,
|
|
1566
|
-
spec: (context as unknown as WsContext).spec,
|
|
1567
1580
|
runInput: (context as unknown as WsContext).runInput,
|
|
1568
1581
|
}),
|
|
1569
1582
|
onDone: {
|
|
@@ -1571,6 +1584,8 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
|
|
|
1571
1584
|
actions: assign({
|
|
1572
1585
|
bindings: ({ event }) =>
|
|
1573
1586
|
(event as unknown as { output: { bindings: Record<string, ResolvedBinding> } }).output.bindings,
|
|
1587
|
+
runInput: ({ event }) =>
|
|
1588
|
+
(event as unknown as { output: { runInput: Record<string, unknown> } }).output.runInput,
|
|
1574
1589
|
wait: undefined,
|
|
1575
1590
|
}),
|
|
1576
1591
|
},
|
|
@@ -1608,7 +1623,6 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
|
|
|
1608
1623
|
src: "attach",
|
|
1609
1624
|
input: ({ context }) => ({
|
|
1610
1625
|
wsId: (context as unknown as WsContext).wsId,
|
|
1611
|
-
spec: (context as unknown as WsContext).spec,
|
|
1612
1626
|
bindings: (context as unknown as WsContext).bindings!,
|
|
1613
1627
|
}),
|
|
1614
1628
|
onDone: {
|
|
@@ -1618,7 +1632,7 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
|
|
|
1618
1632
|
const ctx = context as WsContext;
|
|
1619
1633
|
const out = (
|
|
1620
1634
|
event as unknown as {
|
|
1621
|
-
output: { repos: Record<string, string>;
|
|
1635
|
+
output: { repos: Record<string, string>; bases?: Record<string, string> };
|
|
1622
1636
|
}
|
|
1623
1637
|
).output;
|
|
1624
1638
|
return {
|
|
@@ -1634,9 +1648,8 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
|
|
|
1634
1648
|
...(ctx.bindings
|
|
1635
1649
|
? { bindings: Object.fromEntries(Object.entries(ctx.bindings).map(([s, b]) => [s, b.url])) }
|
|
1636
1650
|
: {}),
|
|
1637
|
-
//
|
|
1638
|
-
...(
|
|
1639
|
-
...(out.review ? { review: out.review } : {}),
|
|
1651
|
+
// Each slot's base: what the attach put `default/` on, sent with every cut.
|
|
1652
|
+
...(out.bases ? { bases: out.bases } : {}),
|
|
1640
1653
|
};
|
|
1641
1654
|
},
|
|
1642
1655
|
}),
|
|
@@ -1660,12 +1673,10 @@ function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any
|
|
|
1660
1673
|
src: "body",
|
|
1661
1674
|
input: ({ context }: { context: WsContext }) => {
|
|
1662
1675
|
const ctx = context;
|
|
1663
|
-
const { repos
|
|
1676
|
+
const { repos } = ctx.handles!;
|
|
1664
1677
|
// Body-facing subset only (ADR-0016): endpoint/sandbox are mechanism-internal. With
|
|
1665
1678
|
// no Repo Slot the handles are `{ repos: {} }` (WorkspaceHandles<never>).
|
|
1666
|
-
|
|
1667
|
-
branch === undefined ? { repos: {} } : { repos, branch, ...(review ? { review } : {}) };
|
|
1668
|
-
return { ...ctx.runInput, workspace: handles };
|
|
1679
|
+
return { ...ctx.runInput, workspace: { repos } };
|
|
1669
1680
|
},
|
|
1670
1681
|
onDone: {
|
|
1671
1682
|
target: "teardown",
|