@jr2/orchestrator 0.1.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/LICENSE +21 -0
- package/README.md +23 -0
- package/bin/server.ts +23 -0
- package/console/canvas.ts +843 -0
- package/console/components/app.ts +79 -0
- package/console/components/drawer.ts +131 -0
- package/console/components/fleet.ts +117 -0
- package/console/components/machine-pane.ts +85 -0
- package/console/components/nav.ts +81 -0
- package/console/components/schema-form.ts +137 -0
- package/console/main.ts +383 -0
- package/console/page.html +28 -0
- package/console/store.ts +336 -0
- package/console/style.css +700 -0
- package/console/tsconfig.json +18 -0
- package/package.json +61 -0
- package/src/actor.ts +562 -0
- package/src/agent.ts +124 -0
- package/src/ambient.ts +50 -0
- package/src/config.ts +297 -0
- package/src/customize.ts +348 -0
- package/src/durability.ts +135 -0
- package/src/fingerprint.ts +92 -0
- package/src/gate.ts +76 -0
- package/src/harness-client.ts +503 -0
- package/src/http.ts +753 -0
- package/src/images.ts +303 -0
- package/src/index.ts +40 -0
- package/src/instance.ts +294 -0
- package/src/machine-doc.ts +334 -0
- package/src/names.ts +78 -0
- package/src/open.ts +17 -0
- package/src/parts.ts +500 -0
- package/src/pool.ts +284 -0
- package/src/registration.ts +340 -0
- package/src/repo-fetch.ts +259 -0
- package/src/repo-identity.ts +145 -0
- package/src/repos.ts +330 -0
- package/src/run-host.ts +1095 -0
- package/src/sandbox-kubectl.ts +1136 -0
- package/src/server.ts +220 -0
- package/src/setup.ts +360 -0
- package/src/snapshot-store.ts +150 -0
- package/src/stub-harness.ts +217 -0
- package/src/tokens.ts +126 -0
- package/src/vocabulary.ts +99 -0
- package/src/wire.ts +103 -0
- package/src/workspace.ts +874 -0
- package/tsconfig.instance.json +26 -0
package/src/workspace.ts
ADDED
|
@@ -0,0 +1,874 @@
|
|
|
1
|
+
// `workspace(body, { input, image, user, repos, spec })` (ADR-0012, ADR-0049, ADR-0051): the
|
|
2
|
+
// jr2-owned wrapper Machine that owns ONLY Sandbox lifecycle — provision the Sandbox (out of the
|
|
3
|
+
// STATIC `image`/`user`/`repos` options it carries) + attach one worktree per Repo Slot, run the
|
|
4
|
+
// author's body Machine inside it as the named slot `body`, with `{ workspace: { repos, branch } }`
|
|
5
|
+
// appended to its input (the mechanism-facing endpoint/sandbox are published ambiently
|
|
6
|
+
// — ADR-0016, ambient.ts), and destroy the Sandbox when the body reaches a final state. Teardown lives INSIDE the
|
|
7
|
+
// wrapper's own states because an xstate stop is synchronous — multi-step async cleanup must be
|
|
8
|
+
// states the machine transitions through itself, which forces the thing that provisions to also
|
|
9
|
+
// observe the body's completion (the ADR's load-bearing argument). There is no retain policy: a
|
|
10
|
+
// body that parks in a non-final state keeps its Sandbox alive by construction; the operator's
|
|
11
|
+
// idle-timeout GC is the backstop for the paths no machine can cover (kill -9, body error).
|
|
12
|
+
//
|
|
13
|
+
// The SandboxPort is HOST infrastructure reached via the run binding (like the registration
|
|
14
|
+
// table): one cluster per orchestrator instance, so the port rides `RunHostOptions.sandbox`,
|
|
15
|
+
// never workflow code — `workspace` stays a plain static import (ADR-0011 doctrine) and unit
|
|
16
|
+
// tests bind a fake port. Every port operation is invoked from a state that RE-RUNS on restore
|
|
17
|
+
// (invoked actors re-execute from persisted input), so all four operations must be idempotent.
|
|
18
|
+
//
|
|
19
|
+
// Restore-reconcile (ADR-0012): the `running` state co-invokes a reconcile probe beside the
|
|
20
|
+
// body. Invoked callback actors restart on every (re)entry — including snapshot restore — so
|
|
21
|
+
// after an orchestrator restart the probe re-checks the Sandbox CR mechanically: present →
|
|
22
|
+
// nothing (agent admissions re-attach — ADR-0016); absent → the pod-local clone and any unpushed
|
|
23
|
+
// commits are gone, so it delivers `workspace.lost` INTO the restored body (same channel as
|
|
24
|
+
// `agent.fault`) and the body's policy decides. Never silently re-provision.
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
assign,
|
|
28
|
+
fromCallback,
|
|
29
|
+
fromPromise,
|
|
30
|
+
sendTo,
|
|
31
|
+
setup,
|
|
32
|
+
type AnyStateMachine,
|
|
33
|
+
type InputFrom,
|
|
34
|
+
type OutputFrom,
|
|
35
|
+
type StateMachine,
|
|
36
|
+
} from "xstate";
|
|
37
|
+
import type { z } from "zod";
|
|
38
|
+
import { registerAmbientHandles, type AmbientHandles } from "./ambient.ts";
|
|
39
|
+
import {
|
|
40
|
+
actorSlotPath,
|
|
41
|
+
assertRepoSlot,
|
|
42
|
+
attachSandboxParts,
|
|
43
|
+
attachWrapperBody,
|
|
44
|
+
customizeLine,
|
|
45
|
+
open,
|
|
46
|
+
repoSlotState,
|
|
47
|
+
sandboxPartsOf,
|
|
48
|
+
type Binding,
|
|
49
|
+
type JR2Repos,
|
|
50
|
+
type JR2Wrapper,
|
|
51
|
+
type RepoSlot,
|
|
52
|
+
type SandboxParts,
|
|
53
|
+
type WrapperActors,
|
|
54
|
+
} from "./parts.ts";
|
|
55
|
+
import { runBindingOf, type AnyActorSystem } from "./registration.ts";
|
|
56
|
+
import { attachInputSchema, inputSchemaOf, invokingMachine, type HostInjectedInput } from "./vocabulary.ts";
|
|
57
|
+
|
|
58
|
+
/** Lease cadence when the backend names none. Well inside the 30m default idle timeout, so a
|
|
59
|
+
* few missed renewals in a row are survivable; also the worst-case detection latency for a
|
|
60
|
+
* workspace that went away (ADR-0021). */
|
|
61
|
+
const DEFAULT_LEASE_INTERVAL_MS = 5 * 60_000;
|
|
62
|
+
|
|
63
|
+
/** What to attach, in workspace vocabulary only (ADR-0012 boundary): the one branch the body
|
|
64
|
+
* works on, the pod's work group, and the review sha. Derived PER RUN from the wrapper's input,
|
|
65
|
+
* which is what keeps it out here rather than in the options — and which is exactly why the two
|
|
66
|
+
* IMAGES and the REPOS are NOT here (ADR-0049, ADR-0051): `jr2 up` must find them by walking the
|
|
67
|
+
* Machine, and no walk can evaluate a function of run input. They are static `workspace()`
|
|
68
|
+
* options instead; a Repo that IS a function of run input is a per-run slot, a mapper the walk
|
|
69
|
+
* can see the shape of even though it cannot see the url. */
|
|
70
|
+
export type WorkspaceSpec = {
|
|
71
|
+
branch: string;
|
|
72
|
+
/** The pod's work group (ADR-0005): `fsGroup`, default 2000. The two writing seats may run
|
|
73
|
+
* different uids — each image's own `USER` decides — and POSIX would then make the other seat's
|
|
74
|
+
* files read-only; fsGroup (group ownership) plus the default ACL the attach stamps on each
|
|
75
|
+
* repo root (group writability, umask-proof) closes that, both inert when the uids already
|
|
76
|
+
* match. The override exists for the image whose sessions already hold a gid of their own —
|
|
77
|
+
* a root sshd's logins rebuild groups from `/etc/group`, so pointing the work group at one
|
|
78
|
+
* they have costs no rebuild. Never a config key — pod composition is the spec's business. */
|
|
79
|
+
workGroup?: number;
|
|
80
|
+
/** Attach the detached review worktree at this sha (ADR-0028): `<branchDir>-review`, a sibling
|
|
81
|
+
* of the branch worktree, forced to exactly this sha on every attach. Creation-time seat only;
|
|
82
|
+
* the per-round refresh verb (the sha moves between review rounds) is a later, workflow-driven
|
|
83
|
+
* change. */
|
|
84
|
+
reviewSha?: string;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* What the workspace hands the BODY (ADR-0012, ADR-0016): worktree geography only, keyed by Repo
|
|
89
|
+
* Slot (ADR-0051) — so a path a prompt names is right in every Instance that consumes the Machine.
|
|
90
|
+
* `endpoint` and `sandbox` are mechanism-internal — the Agent actor resolves them ambiently from
|
|
91
|
+
* the enclosing wrapper (ambient.ts), so a workflow can no longer forget to thread them (the
|
|
92
|
+
* baba71f incident: `sandbox` omitted, every tool call 403'd, fail-closed but silent).
|
|
93
|
+
*
|
|
94
|
+
* `TSlots` has NO default on purpose (ADR-0050): a body names the slots it reads, and
|
|
95
|
+
* `workspace()` holds those names to the ones the wrapper declares. A default of `string` would
|
|
96
|
+
* type `repos` as `Record<string, string>`, under which a misspelled slot reads as a path — the
|
|
97
|
+
* silent widening the slot key exists to refuse. `string` WRITTEN is a different statement: the
|
|
98
|
+
* body names no slot at all — it enumerates them, in the order the composer wrote — which is the body
|
|
99
|
+
* under a wrapper that declares its map Open (`repos: open`, ADR-0051), where the slots are the
|
|
100
|
+
* composer's words and no body could name them.
|
|
101
|
+
*/
|
|
102
|
+
export type WorkspaceHandles<TSlots extends string> = {
|
|
103
|
+
/** Every slot's branch-worktree path: `/work/<slot>/<branch>`, keyed in DECLARATION ORDER. The
|
|
104
|
+
* kit gives no slot a privileged meaning — there is no `workdir` — because which tree an Agent
|
|
105
|
+
* works in is a fact about that Agent's Turn, not about the Workspace: a body that names its
|
|
106
|
+
* slots frames each Agent with its own, and a body that names none may give the order a
|
|
107
|
+
* meaning of its own (`task`: the first is the one the coder edits). The order is the kit's
|
|
108
|
+
* promise; the meaning is the Machine's (ADR-0051). */
|
|
109
|
+
repos: Record<TSlots, string>;
|
|
110
|
+
branch: string;
|
|
111
|
+
/** Detached review-worktree paths by slot (ADR-0028) — present only when the spec carried
|
|
112
|
+
* `reviewSha`. The reviewer's seat: hand one of these as its cwd/prompt frame. */
|
|
113
|
+
review?: Partial<Record<TSlots, string>>;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A body's input under a Workspace: the run input the wrapper passes through, PLUS the handles it
|
|
118
|
+
* injects. The composition is the whole reason the door is declared on the wrapper and not on the
|
|
119
|
+
* body (ADR-0033) — `Workspaced<RunInput, Slot>` is what the body receives, `RunInput` is what a caller
|
|
120
|
+
* may send, and no caller can send `workspace` (the handles do not exist until a Sandbox is
|
|
121
|
+
* provisioned and attached). Naming it here keeps the body from hand-copying
|
|
122
|
+
* {@link WorkspaceHandles}, which drifts. The second argument is the body's word for each Repo
|
|
123
|
+
* Slot it reads (`Workspaced<RunInput, "target">`) — required, never defaulted, so
|
|
124
|
+
* `workspace.repos.<slot>` is typed by the same keys the wrapper declares (ADR-0050, ADR-0051).
|
|
125
|
+
*
|
|
126
|
+
* The wrapper passes its input through UNTOUCHED, so a ROOT-placed wrapper's body also receives
|
|
127
|
+
* what the host injected beside the door — `HostInjectedInput` today (the run's `instanceId`).
|
|
128
|
+
* That is outside this type on purpose: it depends on where the wrapper sits, and `Workspaced` is
|
|
129
|
+
* the composition the WRAPPER makes.
|
|
130
|
+
*/
|
|
131
|
+
export type Workspaced<TInput, TSlots extends string> = TInput & { workspace: WorkspaceHandles<TSlots> };
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* What a renewal learned about the workspace it just stamped (ADR-0021).
|
|
135
|
+
*
|
|
136
|
+
* `identity` is the backing pod's identity, NOT its address. Addresses are deterministic — the
|
|
137
|
+
* CR name, the Service DNS, the worktree paths are all derived from the run — so every name a
|
|
138
|
+
* live run holds still resolves after an eviction or a node loss, while the pod behind them is
|
|
139
|
+
* a replacement with an empty `work` volume: clones, worktrees, and unpushed commits gone.
|
|
140
|
+
* Presence cannot see that; identity can. A backend with no such notion may omit it, and its
|
|
141
|
+
* workspaces are then reconciled on presence alone.
|
|
142
|
+
*/
|
|
143
|
+
export type Continuity = { present: false } | { present: true; identity?: string };
|
|
144
|
+
|
|
145
|
+
/** One Repo Slot as the port receives it at provision (ADR-0051): resolved to a Binding, and
|
|
146
|
+
* flagged when the run — not the Machine — chose the url, because that is what the credentials
|
|
147
|
+
* fence keys on. */
|
|
148
|
+
export type ProvisionedRepo = { slot: string; url: string; ref?: string; perRun: boolean };
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* The Sandbox backend a host supplies (`RunHostOptions.sandbox`) — the seam between the
|
|
152
|
+
* workspace Machine and the cluster. All four operations MUST be idempotent: the invoking
|
|
153
|
+
* states re-run on snapshot restore (create-if-absent, attach-if-absent, delete-if-present).
|
|
154
|
+
*/
|
|
155
|
+
export interface SandboxPort {
|
|
156
|
+
/** Ensure the Sandbox CR exists (labeled with its run for `jr2 ls`) and await `phase: Ready`;
|
|
157
|
+
* resolve with the Harness endpoint the orchestrator can reach, and the identity the lease
|
|
158
|
+
* will hold this workspace to. `image`/`user` are the wrapper's static image options
|
|
159
|
+
* (ADR-0037/0005/0049) — a `file:` context or a registry ref, the port resolves both, and a
|
|
160
|
+
* context the last converge did not build fails here rather than converge-time. `repos` are the
|
|
161
|
+
* wrapper's slots, resolved, in declaration order (ADR-0051): the port names each Repo on the
|
|
162
|
+
* CR so the cluster mounts its cache, and refuses a per-run url no `git.credentials` entry
|
|
163
|
+
* admits. `workGroup` is the pod's `fsGroup`; the port owns the default. */
|
|
164
|
+
provision(req: {
|
|
165
|
+
name: string;
|
|
166
|
+
runId: string;
|
|
167
|
+
workflow: string;
|
|
168
|
+
image?: string;
|
|
169
|
+
user?: string;
|
|
170
|
+
workGroup?: number;
|
|
171
|
+
repos: ProvisionedRepo[];
|
|
172
|
+
}): Promise<{ endpoint: string; identity?: string }>;
|
|
173
|
+
/** Post-Ready attach (ADR-0004): per slot, `git clone --shared` off the node's read-only
|
|
174
|
+
* cache (the `default/` checkout), then a branch worktree sibling — and, with `spec.reviewSha`, the detached
|
|
175
|
+
* review worktree (ADR-0028). Resolves with the worktree paths by slot; `stale` names the slots
|
|
176
|
+
* whose cache could not be fetched before this attach, with git's own error (ADR-0051: freshness
|
|
177
|
+
* degrades, absence does not). */
|
|
178
|
+
attach(req: {
|
|
179
|
+
name: string;
|
|
180
|
+
spec: WorkspaceSpec;
|
|
181
|
+
repos: Array<{ slot: string; url: string; ref?: string }>;
|
|
182
|
+
}): Promise<{
|
|
183
|
+
repos: Record<string, string>;
|
|
184
|
+
review?: Record<string, string>;
|
|
185
|
+
stale?: Record<string, string>;
|
|
186
|
+
}>;
|
|
187
|
+
/**
|
|
188
|
+
* Renew this workspace's keepalive lease AND report what the renewal found — one exchange,
|
|
189
|
+
* because it is one question: is the thing I am keeping alive still the thing I attached to?
|
|
190
|
+
* Nothing in the cluster represents a run (ADR-0001), so the lease is how the Orchestrator
|
|
191
|
+
* asserts liveness; the answer is how it learns the truth. Idempotent, called on a timer.
|
|
192
|
+
*
|
|
193
|
+
* A renewal that FAILS must reject, not resolve `{present: false}` — an unreachable API server
|
|
194
|
+
* is "unknown", and fabricating loss would settle a live run holding real work.
|
|
195
|
+
*/
|
|
196
|
+
renew(name: string): Promise<Continuity>;
|
|
197
|
+
/** Delete the Sandbox CR. Absent is success. */
|
|
198
|
+
destroy(name: string): Promise<void>;
|
|
199
|
+
/** How often to renew. Must be well inside the backend's idle-timeout, since a lapsed lease is
|
|
200
|
+
* what lets the operator reap. Also the detection latency for a lost workspace. */
|
|
201
|
+
readonly leaseIntervalMs?: number;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Resolve the host's Sandbox backend, failing with a pointed message on a host without one. */
|
|
205
|
+
export function sandboxOf(system: AnyActorSystem): SandboxPort {
|
|
206
|
+
const port = runBindingOf(system).sandbox;
|
|
207
|
+
if (!port) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
"this orchestrator has no Sandbox backend — a Workspace is always a real Sandbox (ADR-0012); " +
|
|
210
|
+
"this process is not deployed in a cluster (JR2_NAMESPACE unset). `jr2 up` the instance and run there.",
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
return port;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The Sandbox CR name for one workspace invocation: DNS-1123, deterministic from the run and
|
|
218
|
+
* the wrapper's actor id (both stable across restore — that is what lets the reconcile probe
|
|
219
|
+
* and a re-run provision find the SAME CR), collision-proofed by a content suffix.
|
|
220
|
+
*/
|
|
221
|
+
export function workspaceName(runId: string, wsId: string): string {
|
|
222
|
+
const slug = (s: string) =>
|
|
223
|
+
s
|
|
224
|
+
.toLowerCase()
|
|
225
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
226
|
+
.replace(/^-+|-+$/g, "");
|
|
227
|
+
let h = 0;
|
|
228
|
+
for (const ch of `${runId}/${wsId}`) h = (h * 31 + ch.charCodeAt(0)) | 0;
|
|
229
|
+
const base = `ws-${slug(runId).slice(0, 8)}-${slug(wsId)}`.replace(/-+/g, "-").replace(/-+$/, "").slice(0, 55);
|
|
230
|
+
return `${base}-${(h >>> 0).toString(36)}`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** The full mechanism-facing handles: what the registrar publishes for ambient resolution
|
|
234
|
+
* (ambient.ts). The body sees only the {@link WorkspaceHandles} subset. */
|
|
235
|
+
type MechanismHandles = AmbientHandles;
|
|
236
|
+
|
|
237
|
+
/** One slot as the run resolved it (ADR-0051): the Binding, and whether the run's input chose
|
|
238
|
+
* it. Persisted, so the attach after a restore reuses exactly what was provisioned — a per-run
|
|
239
|
+
* mapper is not re-evaluated against an input that may since have been re-parsed. */
|
|
240
|
+
export type ResolvedBinding = { url: string; ref?: string; perRun: boolean };
|
|
241
|
+
|
|
242
|
+
type WsContext = {
|
|
243
|
+
/** The wrapper's own input, passed through to the body untouched (plus `workspace`). */
|
|
244
|
+
runInput: Record<string, unknown>;
|
|
245
|
+
/** This invocation's stable identity (the actor id the parent invoked/spawned us under). */
|
|
246
|
+
wsId: string;
|
|
247
|
+
/** The resolved spec — computed once from input and persisted, like any other context data. */
|
|
248
|
+
spec: WorkspaceSpec;
|
|
249
|
+
/** Every slot's resolved Binding, by slot, in declaration order — assigned at provision. */
|
|
250
|
+
bindings?: Record<string, ResolvedBinding>;
|
|
251
|
+
endpoint?: string;
|
|
252
|
+
/** The pod identity this workspace attached to, captured at provision and persisted so the
|
|
253
|
+
* lease can hold the workspace to it across a restart (ADR-0021). Plain serializable data,
|
|
254
|
+
* exactly like `endpoint` — ADR-0007's rule about what may ride context. */
|
|
255
|
+
identity?: string;
|
|
256
|
+
/** Persisted in context so the registrar can re-publish them on restore. */
|
|
257
|
+
handles?: MechanismHandles;
|
|
258
|
+
output?: unknown;
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* What `workspace()` returns: a Machine erased to the parameters that carry meaning across the
|
|
263
|
+
* seam — the door a run of it starts with, the body's output, which the wrapper forwards verbatim
|
|
264
|
+
* (ADR-0012), and the wrapper's own actor slots. Everything else is the wrapper's own business,
|
|
265
|
+
* so it stays `any`: a generic `setup()` over the body does not infer (report-xstate.md §3),
|
|
266
|
+
* which is why the implementation is loosely typed and only the public signature is precise.
|
|
267
|
+
*
|
|
268
|
+
* The slots are stated because the wrapper is TRANSPARENT to its body (ADR-0049): `body` holding
|
|
269
|
+
* the body's own type is what lets `customize(machine, { agents })` offer the BODY's Agents
|
|
270
|
+
* through the wrapper, without the composer ever spelling `body`. {@link JR2Wrapper} is what SAYS
|
|
271
|
+
* it is a wrapper — the type twin of the `attachWrapperBody` stamp `workspace()` writes below — so
|
|
272
|
+
* `customize()` reaches the body because this Machine IS one, never because a slot is spelled
|
|
273
|
+
* `body`: that name is an author's to choose too (parts.ts).
|
|
274
|
+
*
|
|
275
|
+
* No parameter defaults (ADR-0050, as {@link Workspaced}): an annotation names the body and the
|
|
276
|
+
* Repo Slots it carries, because a default would widen exactly where the phantom exists to refuse
|
|
277
|
+
* — `JR2Repos<string>` offers `customize()` every key, and `AnyStateMachine` as the body offers no
|
|
278
|
+
* Agent at all. `PoolMachine` names its worker the same way.
|
|
279
|
+
*/
|
|
280
|
+
export type WorkspaceMachine<TInput, TOutput, TBody extends AnyStateMachine, TSlots extends string> = StateMachine<
|
|
281
|
+
any,
|
|
282
|
+
any,
|
|
283
|
+
any,
|
|
284
|
+
WrapperActors<"body", TBody, "provision" | "attach" | "registrar" | "lease" | "destroy">,
|
|
285
|
+
any,
|
|
286
|
+
any,
|
|
287
|
+
any,
|
|
288
|
+
any,
|
|
289
|
+
any,
|
|
290
|
+
TInput,
|
|
291
|
+
TOutput,
|
|
292
|
+
any,
|
|
293
|
+
any,
|
|
294
|
+
any
|
|
295
|
+
> &
|
|
296
|
+
JR2Wrapper<TBody> &
|
|
297
|
+
JR2Repos<TSlots>;
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* The door CONSTRAINS the body (ADR-0033), in one direction only: the body may not demand more
|
|
301
|
+
* than the wrapper will hand it, which is the door plus the injected handles ({@link Workspaced}).
|
|
302
|
+
* A body that demands LESS is safe — it is fed a superset — so this is an assignability test, not
|
|
303
|
+
* an equality one.
|
|
304
|
+
*
|
|
305
|
+
* {@link HostInjectedInput} is added to the PROVIDED side, not subtracted from the demanded one.
|
|
306
|
+
* `RunHost.start` hands the ROOT machine `{ ...runInput, instanceId }` and the wrapper passes its
|
|
307
|
+
* input through untouched, so a root-placed wrapper feeds its body that field too — while a nested
|
|
308
|
+
* one does not, and no type can see which. Of the two answers a type can give, this takes the
|
|
309
|
+
* permissive one: holding the body to the door alone rejects one that declares the field honestly
|
|
310
|
+
* (`features/kind-instance/workflows/*.ts` do) with a diagnostic telling the author to widen the
|
|
311
|
+
* door — the wrong fix, since the field is host-supplied, never sent, and never served as JSON
|
|
312
|
+
* Schema (ADR-0033).
|
|
313
|
+
*
|
|
314
|
+
* Widening the provided side is what keeps the carve-out from becoming a hole. SUBTRACTING the
|
|
315
|
+
* keys instead (`Omit<InputFrom<TBody>, keyof HostInjectedInput>`) drops them from the comparison
|
|
316
|
+
* entirely, which loses two cases claim 1 owns: a body declaring `instanceId: number` passes,
|
|
317
|
+
* because the key it got wrong is the key that was removed; and a body whose input is a UNION is
|
|
318
|
+
* checked against the union's SHARED keys only, so every member could demand a field the door
|
|
319
|
+
* never carries and still compile. Stated on the provided side, both are rejected, and the admit
|
|
320
|
+
* set is otherwise identical.
|
|
321
|
+
*
|
|
322
|
+
* The failure is spelled as an object type whose single key is the sentence to read: TypeScript
|
|
323
|
+
* prints the key of the property it could not satisfy, so the diagnostic on a rejected body names
|
|
324
|
+
* the fix instead of a structural diff. Pinning the body's TInput slot instead would NOT work —
|
|
325
|
+
* `StateMachine`'s members include methods, and method parameters are bivariant, so a body
|
|
326
|
+
* demanding MORE than the door provides compiles. Both directions are pinned by
|
|
327
|
+
* `test/door-types.test.ts`, which the typecheck gate runs.
|
|
328
|
+
*/
|
|
329
|
+
// The handles are keyed by the wrapper's DECLARED slots (ADR-0051), so a body demanding a slot the
|
|
330
|
+
// wrapper never declared is refused here too; demanding fewer is fine, as with any other field.
|
|
331
|
+
type BodyAcceptsDoor<TBody extends AnyStateMachine, TDoor, TSlots extends string> =
|
|
332
|
+
Workspaced<TDoor, TSlots> & HostInjectedInput extends InputFrom<TBody>
|
|
333
|
+
? BodyNamesNoSlotUnderOpenMap<TBody, TSlots>
|
|
334
|
+
: { "the body's declared input must accept the door plus the injected handles": Workspaced<TDoor, TSlots> };
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* The one case assignability cannot see (ADR-0051): a wrapper that declares its map Open
|
|
338
|
+
* (`repos: open`) hands the body `Record<string, string>`, and TypeScript relates that to a body's
|
|
339
|
+
* `Record<"target", string>` — an index signature satisfies a mapped type's named keys — so the
|
|
340
|
+
* check above would pass a body that names a slot the composer may never write. Under an Open
|
|
341
|
+
* map the body must name NO slot: its handles' keys are `string`, which is how a body says "I
|
|
342
|
+
* enumerate whatever is attached". A body that names one is refused here,
|
|
343
|
+
* where the wrapper is written.
|
|
344
|
+
*/
|
|
345
|
+
type BodyNamesNoSlotUnderOpenMap<TBody extends AnyStateMachine, TSlots extends string> = string extends TSlots
|
|
346
|
+
? InputFrom<TBody> extends { workspace: { repos: infer THandles } }
|
|
347
|
+
? string extends keyof THandles
|
|
348
|
+
? unknown
|
|
349
|
+
: {
|
|
350
|
+
"a body under an Open slot map (repos: open) names no slot — its handles are Workspaced<…, string>": keyof THandles;
|
|
351
|
+
}
|
|
352
|
+
: unknown
|
|
353
|
+
: unknown;
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* The handles half of {@link BodyAcceptsDoor} alone, for the wrapper with NO declared door. The
|
|
357
|
+
* door is unchecked there because it is unknown (ADR-0033) — but the Repo Slots are declared on
|
|
358
|
+
* this path exactly as on the other, so a body naming a slot the wrapper never declared is refused
|
|
359
|
+
* here too (ADR-0051): it would read `workspace.repos.<slot>`, a path that never exists. The test
|
|
360
|
+
* is the same one-direction assignability, on the `workspace` field alone: the handles the wrapper
|
|
361
|
+
* will inject must satisfy what the body declares for them, so a body that names fewer slots, or
|
|
362
|
+
* no handles at all, is fed a superset and passes, as ever.
|
|
363
|
+
*/
|
|
364
|
+
type BodyAcceptsSlots<TBody extends AnyStateMachine, TSlots extends string> =
|
|
365
|
+
InputFrom<TBody> extends { workspace: infer THandles }
|
|
366
|
+
? WorkspaceHandles<TSlots> extends THandles
|
|
367
|
+
? BodyNamesNoSlotUnderOpenMap<TBody, TSlots>
|
|
368
|
+
: { "the body's declared handles must accept the Repo Slots the wrapper declares": WorkspaceHandles<TSlots> }
|
|
369
|
+
: unknown;
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* What the Sandbox is MADE OF (ADR-0037, ADR-0005) and which Repos it attaches (ADR-0051), as
|
|
373
|
+
* STATIC options on the wrapper rather than fields of the per-run spec (ADR-0049). Static is the
|
|
374
|
+
* whole point: `jr2 up` walks the registered Machines to find every `file:` context and build it,
|
|
375
|
+
* every bound Repo and warm it, every open slot and refuse it (parts.ts) — and a spec is a function
|
|
376
|
+
* of run input that no walk can evaluate. They are also never persisted — the provisioning state
|
|
377
|
+
* re-reads them off the Machine it was invoked as, so a restore, a `provide()` and a `customize()`
|
|
378
|
+
* all get the image and the slots the Machine carries NOW.
|
|
379
|
+
*
|
|
380
|
+
* Each image is one string in ADR-0037's two shapes: a `file:` URL to a docker context the
|
|
381
|
+
* Machine's module ships (`import.meta.resolve("./image")`), or a registry ref its owner baked and
|
|
382
|
+
* hosts.
|
|
383
|
+
*
|
|
384
|
+
* `TSlots` has no default, here and on the two option types below (ADR-0050): a value annotated
|
|
385
|
+
* with a `string` slot set types `repos` as `Record<string, RepoSlot>`, under which a body naming
|
|
386
|
+
* any slot passes the wrapper's check — the widening {@link WorkspaceHandles} refuses for the
|
|
387
|
+
* same reason. The `workspace()` overloads infer it; an annotation names it.
|
|
388
|
+
*/
|
|
389
|
+
export type SandboxOptions<TSlots extends string, TInput = unknown> = {
|
|
390
|
+
/** The Sandbox Image. Absent → the Instance's `images/default`, then the stock Harness. */
|
|
391
|
+
image?: string;
|
|
392
|
+
/** The User Container's image (ADR-0005). Absent → the pod has no third container: there is no
|
|
393
|
+
* default, because the seat's whole identity is "what jr2 does not own" and jr2 has nothing to put
|
|
394
|
+
* there. One string is the entire authoring surface — env, ports, and resources are deliberately
|
|
395
|
+
* not forwarded. */
|
|
396
|
+
user?: string;
|
|
397
|
+
/**
|
|
398
|
+
* The Repo Slots (ADR-0051), keyed by the Machine's own word for each — the key of the body's
|
|
399
|
+
* `workspace.repos` handles and the directory under `/work`. The handles keep this map's order,
|
|
400
|
+
* and the kit reads nothing into it. Required, at least one: a Workspace exists to work on a repository. Each slot is bound (a url,
|
|
401
|
+
* or `{ url, ref? }` — the package's own), open (`open` — the consumer binds it with
|
|
402
|
+
* `customize`), or per-run (a mapper over the door: `({ input }) => input.repo`).
|
|
403
|
+
*
|
|
404
|
+
* Or the whole map Open (`repos: open`): the Machine names no slot, the composer names every
|
|
405
|
+
* one with `customize`, in an order the Machine may give a meaning to. The shape a packaged
|
|
406
|
+
* Machine takes when its body enumerates its checkouts rather than naming them (`@jr2/machines`'s
|
|
407
|
+
* `task`, whose first slot is the one the coder edits). Under it `TSlots` is `string`, and the body's handles must say so.
|
|
408
|
+
*/
|
|
409
|
+
repos: Record<TSlots, RepoSlot<TInput>> | typeof open;
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* How a Workspace with a declared door is configured (ADR-0012, ADR-0033) — the wrapper's own
|
|
414
|
+
* run-input schema, what the pod is made of, and the mapping from what comes through the door to
|
|
415
|
+
* workspace vocabulary.
|
|
416
|
+
*/
|
|
417
|
+
export type WorkspaceOptions<TSchema extends z.ZodObject, TSlots extends string> = SandboxOptions<
|
|
418
|
+
TSlots,
|
|
419
|
+
z.infer<TSchema>
|
|
420
|
+
> & {
|
|
421
|
+
/** The wrapper's OWN declared run input (ADR-0033) — what a caller sends to start a run of it,
|
|
422
|
+
* what types `spec`'s `input`, and what the body is checked against. Deliberately NOT the body's
|
|
423
|
+
* schema: the body is fed the run input PLUS the injected `workspace` handles
|
|
424
|
+
* ({@link Workspaced}), which no caller can send, so the body's contract is the door plus
|
|
425
|
+
* something that does not exist yet. Symmetric with `PoolSpec.input`. */
|
|
426
|
+
input: TSchema;
|
|
427
|
+
/** Map what came through the door to the workspace-domain spec: what to attach, on what ref,
|
|
428
|
+
* on which branch (ADR-0012's boundary — workflow configuration never enters it). */
|
|
429
|
+
spec: (args: { input: z.infer<TSchema> }) => WorkspaceSpec;
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* How a Workspace with NO declared door is configured: absence is permissive (ADR-0033), so jr2
|
|
434
|
+
* has nothing to infer from and says `unknown` rather than `any` — an honest "jr2 does not know",
|
|
435
|
+
* which the mapper must narrow before it reads a field. A wrapper that is fed by something other
|
|
436
|
+
* than a caller — a pool worker, a nested invoke — may state what it is fed by annotating the
|
|
437
|
+
* parameter (`spec: ({ input }: { input: Item }) => …`), which types the wrapper's input too. For
|
|
438
|
+
* anything a caller starts, the honest fix is to declare `input`.
|
|
439
|
+
*/
|
|
440
|
+
export type PermissiveWorkspaceOptions<TSlots extends string, TInput = unknown> = SandboxOptions<TSlots, TInput> & {
|
|
441
|
+
/** Never present on this path. Spelled out so a declared schema can never fall through to the
|
|
442
|
+
* permissive overload, where the body's door would go unchecked. */
|
|
443
|
+
input?: never;
|
|
444
|
+
spec: (args: { input: TInput }) => WorkspaceSpec;
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Wrap a body Machine in Sandbox lifecycle (ADR-0012). `spec` maps the wrapper's input to the
|
|
449
|
+
* workspace-domain spec; the body receives the wrapper's input plus `workspace` (the handles) —
|
|
450
|
+
* `Workspaced<TInput, TSlots>`, which is also what the declared door checks the body against. The
|
|
451
|
+
* wrapper's output is the body's output. A body ERROR is deliberately unhandled: it faults the run
|
|
452
|
+
* loudly (RunStatus.fault) and leaves the Sandbox to the operator's idle-timeout GC — the trail
|
|
453
|
+
* stays inspectable, and silent cleanup would destroy the evidence.
|
|
454
|
+
*/
|
|
455
|
+
export function workspace<TSchema extends z.ZodObject, TBody extends AnyStateMachine, TSlots extends string>(
|
|
456
|
+
body: TBody & BodyAcceptsDoor<TBody, z.infer<TSchema>, TSlots>,
|
|
457
|
+
options: WorkspaceOptions<TSchema, TSlots>,
|
|
458
|
+
): WorkspaceMachine<z.infer<TSchema>, OutputFrom<TBody>, TBody, TSlots>;
|
|
459
|
+
export function workspace<TBody extends AnyStateMachine, TSlots extends string, TInput = unknown>(
|
|
460
|
+
body: TBody & BodyAcceptsSlots<TBody, TSlots>,
|
|
461
|
+
options: PermissiveWorkspaceOptions<TSlots, TInput>,
|
|
462
|
+
): WorkspaceMachine<TInput, OutputFrom<TBody>, TBody, TSlots>;
|
|
463
|
+
export function workspace(
|
|
464
|
+
body: AnyStateMachine,
|
|
465
|
+
options: SandboxOptions<string, any> & { input?: z.ZodObject; spec: (args: { input: any }) => WorkspaceSpec },
|
|
466
|
+
): AnyStateMachine {
|
|
467
|
+
// A body that still declares its own run input is a dead declaration under the door design: the
|
|
468
|
+
// wrapper never serves it, never validates against it, and feeds the body something it does not
|
|
469
|
+
// describe. Silence would leave an author believing a contract that nothing enforces (ADR-0033).
|
|
470
|
+
if (inputSchemaOf(body)) {
|
|
471
|
+
throw new Error(
|
|
472
|
+
`workspace(): the body "${body.id}" declares its own run input, which nothing will ever ` +
|
|
473
|
+
"serve or enforce — the wrapper feeds the body the run input PLUS the injected `workspace` " +
|
|
474
|
+
"handles, so the body's schema is not the door. Move it to the wrapper: " +
|
|
475
|
+
"workspace(body, { input, spec }) (ADR-0033).",
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
// Static, so checkable NOW rather than at the first provision — an empty or non-string image is
|
|
479
|
+
// the same derives-from-a-typo bug `assertSpec` catches for the spec, one build earlier.
|
|
480
|
+
for (const seat of ["image", "user"] as const) {
|
|
481
|
+
const value = options[seat];
|
|
482
|
+
if (value !== undefined && (typeof value !== "string" || !value)) {
|
|
483
|
+
throw new Error(
|
|
484
|
+
`workspace(): \`${seat}\` must be a non-empty string (got ${JSON.stringify(value)}) — either a \`file:\` ` +
|
|
485
|
+
'URL to a docker context this module ships (`import.meta.resolve("./image")`) or a registry ref ' +
|
|
486
|
+
"(ADR-0037).",
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
// The slots, checked NOW for the same reason (ADR-0051): every value is one of the three forms,
|
|
491
|
+
// every key is a directory name, and there is at least one — a Workspace exists to work on a
|
|
492
|
+
// repository, and a wrapper with no slot would attach nothing and hand the body no checkout.
|
|
493
|
+
// An Open map defers all of that to the `customize` that fills it, which runs the same checks.
|
|
494
|
+
const repos = options.repos;
|
|
495
|
+
if (repos !== open) {
|
|
496
|
+
if (typeof repos !== "object" || repos === null || Array.isArray(repos) || Object.keys(repos).length === 0) {
|
|
497
|
+
throw new Error(
|
|
498
|
+
'workspace(): `repos` must name at least one Repo Slot — `repos: { app: "https://…" }`, or ' +
|
|
499
|
+
"`open` for a slot the consumer binds, or a mapper over the door for one the run chooses — or be " +
|
|
500
|
+
"`open` whole, for a map the consumer names (ADR-0051).",
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
for (const [slot, value] of Object.entries(repos)) assertRepoSlot("workspace()", slot, value);
|
|
504
|
+
}
|
|
505
|
+
const wrapper = buildWorkspaceMachine(body, options.spec);
|
|
506
|
+
// The wrapper is TRANSPARENT to its body (ADR-0049): `customize(machine, { agents })` on a
|
|
507
|
+
// Workspace means the Machine inside, so the composer never spells `body` and never has to know
|
|
508
|
+
// that jr2 wrapped anything.
|
|
509
|
+
attachWrapperBody(wrapper, "body");
|
|
510
|
+
// What the pod is MADE of and which Repos it attaches ride the Machine (ADR-0049, ADR-0051),
|
|
511
|
+
// keyed on `machine.config` like the vocabulary — so a `provide()` clone keeps them, and the
|
|
512
|
+
// provisioning state reads them back off the Machine it was invoked as instead of closing over
|
|
513
|
+
// these values. That is also what lets `jr2 up` find every `file:` context, every bound Repo and
|
|
514
|
+
// every open slot by walking the registered Machines (parts.ts).
|
|
515
|
+
attachSandboxParts(wrapper, {
|
|
516
|
+
...(options.image !== undefined ? { image: options.image } : {}),
|
|
517
|
+
...(options.user !== undefined ? { user: options.user } : {}),
|
|
518
|
+
repos: repos === open ? open : { ...repos },
|
|
519
|
+
});
|
|
520
|
+
// The body's vocabulary stays the BODY's (ADR-0011, ADR-0049): the wrapper declares no events
|
|
521
|
+
// of its own and merges none, because the actors that use the body's names resolve against the
|
|
522
|
+
// Machine that invoked them — the body — at any nesting depth. Propagating them up was what
|
|
523
|
+
// made a nested Machine's events its parent's problem to re-declare.
|
|
524
|
+
//
|
|
525
|
+
// The door does NOT propagate from the body either (ADR-0033): the wrapper hands the body the run
|
|
526
|
+
// input plus the injected `workspace` field, so the body's declared input would be the door
|
|
527
|
+
// plus a field no caller can send — declaring it there 400s every valid start. The wrapper
|
|
528
|
+
// declares its own, exactly as a pool does.
|
|
529
|
+
if (options.input) attachInputSchema(wrapper, options.input);
|
|
530
|
+
return wrapper;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Fail a malformed spec BEFORE any pod exists. The spec derives from run input via the workflow's
|
|
535
|
+
* mapping fn, so a `jr2 run --input` missing a field the mapping reads arrives here as `undefined` —
|
|
536
|
+
* unchecked, it survives until the attach script's string ops and dies as "Cannot read properties
|
|
537
|
+
* of undefined", with a Sandbox already provisioned and nothing pointing back at the input.
|
|
538
|
+
*/
|
|
539
|
+
function assertSpec(spec: WorkspaceSpec): void {
|
|
540
|
+
const bad: string[] = [];
|
|
541
|
+
if (typeof spec?.branch !== "string" || !spec.branch) bad.push(`branch (got ${JSON.stringify(spec?.branch)})`);
|
|
542
|
+
// The branch Worktree is a SIBLING of the pod-local clone at `<slot>/default/` (ADR-0004), so
|
|
543
|
+
// the one branch name that is not a worktree directory is `default`.
|
|
544
|
+
else if (spec.branch === "default")
|
|
545
|
+
bad.push(
|
|
546
|
+
'branch "default" (the pod-local clone\'s own directory — a branch Worktree sits beside `default/`, ADR-0004)',
|
|
547
|
+
);
|
|
548
|
+
if (spec?.reviewSha !== undefined && (typeof spec.reviewSha !== "string" || !spec.reviewSha))
|
|
549
|
+
bad.push(`reviewSha (got ${JSON.stringify(spec?.reviewSha)})`);
|
|
550
|
+
// A gid, so an integer — a float or a negative becomes a pod the API server rejects at
|
|
551
|
+
// admission, which surfaces as "never reached Ready" with nothing pointing back at the spec.
|
|
552
|
+
if (
|
|
553
|
+
spec?.workGroup !== undefined &&
|
|
554
|
+
(typeof spec.workGroup !== "number" || !Number.isInteger(spec.workGroup) || spec.workGroup < 0)
|
|
555
|
+
)
|
|
556
|
+
bad.push(`workGroup (got ${JSON.stringify(spec?.workGroup)}; want a gid)`);
|
|
557
|
+
if (bad.length) {
|
|
558
|
+
throw new Error(
|
|
559
|
+
`workspace spec invalid: ${bad.join("; ")} — the spec derives from run input; does ` +
|
|
560
|
+
"`jr2 run --input` carry every field this workflow's workspace() mapping reads?",
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Resolve every Repo Slot to a Binding, in declaration order (ADR-0051). A bound slot is its
|
|
567
|
+
* Binding; a per-run slot is its mapper called over the run input, validated like the static forms
|
|
568
|
+
* because it derives from `jr2 run --input` exactly as the spec does; an open slot nobody bound is
|
|
569
|
+
* a fault BEFORE the port, naming the `customize` line that fixes it — the run-time twin of the
|
|
570
|
+
* refusal `jr2 up`'s walk makes for a registered Machine, reached here only by a Machine that was
|
|
571
|
+
* never registered as itself (a test seam, a nested invoke of an unbound import). The Machine is
|
|
572
|
+
* named as the walk names it: the Workflow it runs under, and the slot chain (`path`) from that
|
|
573
|
+
* root to this wrapper — which is the `actors` nesting of the line, so it pastes.
|
|
574
|
+
*/
|
|
575
|
+
function resolveBindings(
|
|
576
|
+
where: { workflow: string; path: string[] | undefined },
|
|
577
|
+
slots: SandboxParts["repos"],
|
|
578
|
+
runInput: unknown,
|
|
579
|
+
): Record<string, ResolvedBinding> {
|
|
580
|
+
const unbound = (slot: string | undefined): Error => {
|
|
581
|
+
const fix =
|
|
582
|
+
where.path === undefined
|
|
583
|
+
? "no customize() reaches a Machine invoked inline; declare it under setup({ actors }) and bind the slots there"
|
|
584
|
+
: `bind them where the Machine is registered: export const machine = ${customizeLine("<import>", where.path, slot)}`;
|
|
585
|
+
const what =
|
|
586
|
+
slot === undefined ? "Repo Slots are open — nobody named any" : `Repo Slot "${slot}" is open — nobody bound it`;
|
|
587
|
+
return new Error(`workflow "${where.workflow}": ${what}; ${fix} (ADR-0051)`);
|
|
588
|
+
};
|
|
589
|
+
if (slots === open) throw unbound(undefined);
|
|
590
|
+
const bindings: Record<string, ResolvedBinding> = {};
|
|
591
|
+
for (const [slot, value] of Object.entries(slots)) {
|
|
592
|
+
const state = repoSlotState(value);
|
|
593
|
+
if (state.kind === "open") throw unbound(slot);
|
|
594
|
+
if (state.kind === "bound") {
|
|
595
|
+
bindings[slot] = { ...state.binding, perRun: false };
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
const mapped = state.mapper({ input: runInput });
|
|
599
|
+
const binding: Binding | undefined =
|
|
600
|
+
typeof mapped === "string" ? { url: mapped } : typeof mapped === "object" && mapped !== null ? mapped : undefined;
|
|
601
|
+
const bad: string[] = [];
|
|
602
|
+
if (typeof binding?.url !== "string" || !binding.url)
|
|
603
|
+
bad.push(`url (got ${JSON.stringify(binding === undefined ? mapped : binding.url)})`);
|
|
604
|
+
if (binding?.ref !== undefined && (typeof binding.ref !== "string" || !binding.ref))
|
|
605
|
+
bad.push(`ref (got ${JSON.stringify(binding.ref)})`);
|
|
606
|
+
if (bad.length) {
|
|
607
|
+
throw new Error(
|
|
608
|
+
`workspace spec invalid: repos.${slot} mapper returned ${bad.join("; ")} — the mapper derives from run ` +
|
|
609
|
+
"input; does `jr2 run --input` carry every field this workflow's workspace() slot reads?",
|
|
610
|
+
);
|
|
611
|
+
}
|
|
612
|
+
bindings[slot] = { ...binding!, perRun: true };
|
|
613
|
+
}
|
|
614
|
+
return bindings;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** The port-facing view of the persisted bindings, in declaration order. */
|
|
618
|
+
function attachedRepos(bindings: Record<string, ResolvedBinding>): Array<{ slot: string; url: string; ref?: string }> {
|
|
619
|
+
return Object.entries(bindings).map(([slot, b]) => ({
|
|
620
|
+
slot,
|
|
621
|
+
url: b.url,
|
|
622
|
+
...(b.ref !== undefined ? { ref: b.ref } : {}),
|
|
623
|
+
}));
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function buildWorkspaceMachine(body: AnyStateMachine, spec: (args: { input: any }) => WorkspaceSpec): AnyStateMachine {
|
|
627
|
+
const provision = fromPromise<
|
|
628
|
+
{ endpoint: string; identity?: string; bindings: Record<string, ResolvedBinding> },
|
|
629
|
+
{ wsId: string; spec: WorkspaceSpec; runInput: unknown }
|
|
630
|
+
>(async ({ input, self, system }) => {
|
|
631
|
+
assertSpec(input.spec); // before the port: a bad spec must never cost a pod
|
|
632
|
+
const binding = runBindingOf(system);
|
|
633
|
+
// The images and the slots come off the WRAPPER, at invoke time, not out of context and not
|
|
634
|
+
// out of a build-time closure (ADR-0049, ADR-0051). This state re-runs on every restore, so
|
|
635
|
+
// the re-read is the whole mechanism: a redeployed instance provisions what the Machine
|
|
636
|
+
// carries NOW, and no snapshot ever holds an image name — let alone a resolved
|
|
637
|
+
// content-addressed tag, which would outlive the image it names. The RESOLVED bindings are
|
|
638
|
+
// persisted, because a per-run mapper's answer is this run's fact.
|
|
639
|
+
const parts = sandboxPartsOf(invokingMachine(self));
|
|
640
|
+
const bindings = resolveBindings(
|
|
641
|
+
{ workflow: binding.workflow, path: actorSlotPath(self._parent) },
|
|
642
|
+
parts.repos,
|
|
643
|
+
input.runInput,
|
|
644
|
+
);
|
|
645
|
+
const provisioned = await sandboxOf(system).provision({
|
|
646
|
+
name: workspaceName(binding.runId, input.wsId),
|
|
647
|
+
runId: binding.runId,
|
|
648
|
+
workflow: binding.workflow,
|
|
649
|
+
// The image strings straight through (ADR-0037/0005) — the port owns resolution, and the
|
|
650
|
+
// work group's default (ADR-0005 puts it in pod composition, where the pod is built).
|
|
651
|
+
...(parts.image !== undefined ? { image: parts.image } : {}),
|
|
652
|
+
...(parts.user !== undefined ? { user: parts.user } : {}),
|
|
653
|
+
...(input.spec.workGroup !== undefined ? { workGroup: input.spec.workGroup } : {}),
|
|
654
|
+
repos: Object.entries(bindings).map(([slot, b]) => ({ slot, ...b })),
|
|
655
|
+
});
|
|
656
|
+
return { ...provisioned, bindings };
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
const attach = fromPromise<
|
|
660
|
+
{ repos: Record<string, string>; review?: Record<string, string>; stale?: Record<string, string> },
|
|
661
|
+
{ wsId: string; spec: WorkspaceSpec; bindings: Record<string, ResolvedBinding> }
|
|
662
|
+
>(async ({ input, system }) => {
|
|
663
|
+
const name = workspaceName(runBindingOf(system).runId, input.wsId);
|
|
664
|
+
const out = await sandboxOf(system).attach({ name, spec: input.spec, repos: attachedRepos(input.bindings) });
|
|
665
|
+
// Announced, never persisted (ADR-0051): a stale cache is a degraded attach the run proceeds
|
|
666
|
+
// through on the objects the node holds. The pod cannot heal it — the worktree's `origin`
|
|
667
|
+
// fetches from the cache, not the remote (ADR-0005: the pod holds no credential) — so stale
|
|
668
|
+
// lasts until the cache agent's next successful fetch lands in place, which the Agent's own
|
|
669
|
+
// `git fetch` then picks up. It is a notice, not a fact of the run.
|
|
670
|
+
for (const [slot, error] of Object.entries(out.stale ?? {})) {
|
|
671
|
+
console.error(`workspace ${name}: Repo Slot "${slot}" attached from a stale cache — ${error}`);
|
|
672
|
+
}
|
|
673
|
+
return out;
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
// The ambient registrar (ADR-0016): publishes this wrapper's handles for the parent-chain
|
|
677
|
+
// walk the Agent actor does. An INVOKED actor, co-invoked in `running` beside the body — invoked
|
|
678
|
+
// actors restart on snapshot restore (entry actions do not), so the publication is
|
|
679
|
+
// restore-safe by construction; and it is listed FIRST, so the handles are readable before
|
|
680
|
+
// the body's first Agent turn starts.
|
|
681
|
+
//
|
|
682
|
+
// The run-narrative echo (ADR-0023) rides the same seat: attaching here IS "at workspace
|
|
683
|
+
// attach" — the host replays the run's feed-so-far to this Workspace's Harness (the log opens
|
|
684
|
+
// with its preamble) and tees live thereafter — and the invoked-actor lifetime makes the tee
|
|
685
|
+
// restore-safe and self-detaching for free. Per-run binding, so the tee carries the OWNING
|
|
686
|
+
// run's lineage only, never a sibling run's.
|
|
687
|
+
const registrar = fromCallback<{ type: string }, { handles: MechanismHandles }>(({ input, self, system }) => {
|
|
688
|
+
const wrapperRef = self._parent;
|
|
689
|
+
if (!wrapperRef) return;
|
|
690
|
+
const disposeHandles = registerAmbientHandles(wrapperRef, input.handles);
|
|
691
|
+
const detachEcho = runBindingOf(system as AnyActorSystem).echo?.(input.handles.endpoint);
|
|
692
|
+
return () => {
|
|
693
|
+
detachEcho?.();
|
|
694
|
+
disposeHandles();
|
|
695
|
+
};
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* The lease (ADR-0021). One actor owns the whole exchange with the cluster for one workspace:
|
|
700
|
+
* it asserts liveness (nothing in the cluster represents a run, so the Orchestrator must keep
|
|
701
|
+
* saying "still mine" or the operator's idle GC reaps — ADR-0001) and, in the same call, reads
|
|
702
|
+
* back whether what it just stamped is still what the body attached to.
|
|
703
|
+
*
|
|
704
|
+
* Being an INVOKED actor is the whole design. Its lifetime IS `running`'s lifetime, which
|
|
705
|
+
* xstate already manages: it re-invokes on snapshot restore (so a restart reconciles for free,
|
|
706
|
+
* with no restore-specific code path), and it stops on every exit — body final, run stopped,
|
|
707
|
+
* run faulted. That last one is why there is no `release()`: a faulted run stops its actors,
|
|
708
|
+
* the lease stops with them, and the abandoned pod ages out of the idle timeout on its own.
|
|
709
|
+
*
|
|
710
|
+
* Level-triggered on purpose. The one-shot probe this replaces could only fire on entry, so a
|
|
711
|
+
* run parked on a gate for hours — the state most likely to outlive its Sandbox — never
|
|
712
|
+
* rechecked anything until the next restart.
|
|
713
|
+
*/
|
|
714
|
+
const lease = fromCallback<{ type: string }, { wsId: string; identity?: string }>(({ input, system, sendBack }) => {
|
|
715
|
+
const port = sandboxOf(system);
|
|
716
|
+
const name = workspaceName(runBindingOf(system).runId, input.wsId);
|
|
717
|
+
let stopped = false;
|
|
718
|
+
|
|
719
|
+
const renew = async (): Promise<void> => {
|
|
720
|
+
let seen: Continuity;
|
|
721
|
+
try {
|
|
722
|
+
seen = await port.renew(name);
|
|
723
|
+
} catch {
|
|
724
|
+
return; // unknown, never lost: an API blip must not settle a run holding real work
|
|
725
|
+
}
|
|
726
|
+
if (stopped) return;
|
|
727
|
+
// Two ways to lose a workspace, one event. Gone: reaped, deleted, namespace cleared.
|
|
728
|
+
// Replaced: the CR survived an eviction or node loss but the pod behind it did not, so
|
|
729
|
+
// every name still resolves over an empty `work` volume. Re-provisioning either silently
|
|
730
|
+
// would resume into an inconsistent world — the body decides (ADR-0012).
|
|
731
|
+
const replaced =
|
|
732
|
+
seen.present && input.identity !== undefined && seen.identity !== undefined
|
|
733
|
+
? seen.identity !== input.identity
|
|
734
|
+
: false;
|
|
735
|
+
if (!seen.present || replaced) sendBack({ type: "workspace.lost" });
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
void renew(); // immediately on entry: this is the restore-reconcile, no longer a special case
|
|
739
|
+
const timer = setInterval(() => void renew(), port.leaseIntervalMs ?? DEFAULT_LEASE_INTERVAL_MS);
|
|
740
|
+
timer.unref?.(); // a lease never holds the process open; it matters only while the run runs
|
|
741
|
+
return () => {
|
|
742
|
+
stopped = true;
|
|
743
|
+
clearInterval(timer);
|
|
744
|
+
};
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
const destroy = fromPromise<void, { wsId: string }>(async ({ input, system }) =>
|
|
748
|
+
sandboxOf(system).destroy(workspaceName(runBindingOf(system).runId, input.wsId)),
|
|
749
|
+
);
|
|
750
|
+
|
|
751
|
+
// Every actor this wrapper runs is a NAMED SLOT (ADR-0049), the body first among them: a Machine
|
|
752
|
+
// composes by invoking a declared `src`, and `body` is what `provide()`, `customize()`, Stately,
|
|
753
|
+
// the Console's join key and the `jr2 up` parts walk all reach it by. The mechanism's own four —
|
|
754
|
+
// provision, attach, registrar, lease, destroy — are named for the same price, and the Console
|
|
755
|
+
// now shows what each state is doing instead of "inline".
|
|
756
|
+
return setup({
|
|
757
|
+
actors: { body, provision, attach, registrar, lease, destroy },
|
|
758
|
+
}).createMachine({
|
|
759
|
+
id: "workspace",
|
|
760
|
+
context: ({ input, self }: { input: unknown; self: { id: string } }): WsContext => ({
|
|
761
|
+
runInput: (input ?? {}) as Record<string, unknown>,
|
|
762
|
+
wsId: self.id,
|
|
763
|
+
spec: spec({ input }),
|
|
764
|
+
}),
|
|
765
|
+
initial: "provisioning",
|
|
766
|
+
states: {
|
|
767
|
+
provisioning: {
|
|
768
|
+
invoke: {
|
|
769
|
+
src: "provision",
|
|
770
|
+
input: ({ context }) => ({
|
|
771
|
+
wsId: (context as unknown as WsContext).wsId,
|
|
772
|
+
spec: (context as unknown as WsContext).spec,
|
|
773
|
+
runInput: (context as unknown as WsContext).runInput,
|
|
774
|
+
}),
|
|
775
|
+
onDone: {
|
|
776
|
+
target: "attaching",
|
|
777
|
+
actions: assign({
|
|
778
|
+
endpoint: ({ event }) => (event as unknown as { output: { endpoint: string } }).output.endpoint,
|
|
779
|
+
identity: ({ event }) => (event as unknown as { output: { identity?: string } }).output.identity,
|
|
780
|
+
bindings: ({ event }) =>
|
|
781
|
+
(event as unknown as { output: { bindings: Record<string, ResolvedBinding> } }).output.bindings,
|
|
782
|
+
}),
|
|
783
|
+
},
|
|
784
|
+
},
|
|
785
|
+
},
|
|
786
|
+
attaching: {
|
|
787
|
+
invoke: {
|
|
788
|
+
src: "attach",
|
|
789
|
+
input: ({ context }) => ({
|
|
790
|
+
wsId: (context as unknown as WsContext).wsId,
|
|
791
|
+
spec: (context as unknown as WsContext).spec,
|
|
792
|
+
bindings: (context as unknown as WsContext).bindings!,
|
|
793
|
+
}),
|
|
794
|
+
onDone: {
|
|
795
|
+
target: "running",
|
|
796
|
+
actions: assign({
|
|
797
|
+
handles: ({ context, event, system }): MechanismHandles => {
|
|
798
|
+
const ctx = context as WsContext;
|
|
799
|
+
const out = (
|
|
800
|
+
event as unknown as {
|
|
801
|
+
output: { repos: Record<string, string>; review?: Record<string, string> };
|
|
802
|
+
}
|
|
803
|
+
).output;
|
|
804
|
+
return {
|
|
805
|
+
endpoint: ctx.endpoint!,
|
|
806
|
+
// Derived, not remembered: the same function every port operation names the CR
|
|
807
|
+
// with, so the Sandbox the registrar publishes — and the Agent actor records on its
|
|
808
|
+
// registration — is the one the Adapter's token is scoped to, by construction
|
|
809
|
+
// (ADR-0013).
|
|
810
|
+
sandbox: workspaceName(runBindingOf(system as AnyActorSystem).runId, ctx.wsId),
|
|
811
|
+
repos: out.repos,
|
|
812
|
+
branch: ctx.spec.branch,
|
|
813
|
+
...(out.review ? { review: out.review } : {}),
|
|
814
|
+
};
|
|
815
|
+
},
|
|
816
|
+
}),
|
|
817
|
+
},
|
|
818
|
+
},
|
|
819
|
+
},
|
|
820
|
+
running: {
|
|
821
|
+
invoke: [
|
|
822
|
+
// Registrar FIRST: the ambient handles must be readable before the body starts.
|
|
823
|
+
{
|
|
824
|
+
id: "registrar",
|
|
825
|
+
src: "registrar",
|
|
826
|
+
input: ({ context }) => ({ handles: (context as unknown as WsContext).handles! }),
|
|
827
|
+
},
|
|
828
|
+
{
|
|
829
|
+
id: "body",
|
|
830
|
+
// Annotated because the body is `AnyStateMachine`: its declared input type is opaque,
|
|
831
|
+
// so `setup()` has nothing to contextually type this callback's parameter from.
|
|
832
|
+
src: "body",
|
|
833
|
+
input: ({ context }: { context: WsContext }) => {
|
|
834
|
+
const ctx = context;
|
|
835
|
+
const { repos, branch, review } = ctx.handles!;
|
|
836
|
+
// Body-facing subset only (ADR-0016): endpoint/sandbox are mechanism-internal.
|
|
837
|
+
return {
|
|
838
|
+
...ctx.runInput,
|
|
839
|
+
workspace: { repos, branch, ...(review ? { review } : {}) } satisfies WorkspaceHandles<string>,
|
|
840
|
+
};
|
|
841
|
+
},
|
|
842
|
+
onDone: {
|
|
843
|
+
target: "teardown",
|
|
844
|
+
actions: assign({ output: ({ event }) => (event as unknown as { output: unknown }).output }),
|
|
845
|
+
},
|
|
846
|
+
},
|
|
847
|
+
{
|
|
848
|
+
id: "lease",
|
|
849
|
+
src: "lease",
|
|
850
|
+
input: ({ context }) => ({
|
|
851
|
+
wsId: (context as unknown as WsContext).wsId,
|
|
852
|
+
identity: (context as unknown as WsContext).identity,
|
|
853
|
+
}),
|
|
854
|
+
},
|
|
855
|
+
],
|
|
856
|
+
// The wrapper emits, the body decides (ADR-0012): forward loss into the body's policy.
|
|
857
|
+
on: { "workspace.lost": { actions: sendTo("body", { type: "workspace.lost" }) } },
|
|
858
|
+
},
|
|
859
|
+
teardown: {
|
|
860
|
+
invoke: {
|
|
861
|
+
src: "destroy",
|
|
862
|
+
input: ({ context }) => ({ wsId: (context as unknown as WsContext).wsId }),
|
|
863
|
+
onDone: "done",
|
|
864
|
+
// A failed delete is the operator GC's problem (ADR-0012 backstop), not the run's.
|
|
865
|
+
onError: "done",
|
|
866
|
+
},
|
|
867
|
+
},
|
|
868
|
+
done: { type: "final" },
|
|
869
|
+
},
|
|
870
|
+
// Machine output must be declared at the ROOT in xstate v5 (a final state's own `output`
|
|
871
|
+
// only rides the done event); the workspace's output is the body's, verbatim (ADR-0012).
|
|
872
|
+
output: ({ context }) => (context as unknown as WsContext).output,
|
|
873
|
+
});
|
|
874
|
+
}
|